diff --git a/.editorconfig b/.editorconfig index c3bba3c4..32b2a7c6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,7 +27,7 @@ indent_size = 2 indent_style = space indent_size = 4 -# Go 由 gofmt 统一格式化:缩进恒用 tab(非空格),此处仅约定 tab 的显示宽度为 4。 +# Go is formatted uniformly by gofmt: indentation is always tab (not spaces); this only sets the tab display width to 4. [*.go] indent_style = tab tab_width = 4 diff --git a/.gitattributes b/.gitattributes index 72d047a3..7c8ce408 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ -# Git LFS —— 二进制资源(图片/图标)走 LFS,避免仓库膨胀。 -# SVG 是文本(XML),保持普通 diff,不走 LFS。 +# Git LFS — binary assets (images/icons) go through LFS to avoid repo bloat. +# SVG is text (XML), so keep normal diff and do not use LFS. *.png filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2c538502..00510a69 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ -name: 缺陷报告(Bug report) -description: 报告一个问题或异常行为 +name: Bug report +description: Report a problem or unexpected behavior labels: [bug] assignees: - huhamhire @@ -7,22 +7,29 @@ body: - type: textarea id: description attributes: - label: 问题描述(What happened) - description: 发生了什么、预期是什么,以及复现步骤。 - placeholder: 简要描述问题与复现步骤。 + label: What happened + description: What happened, what you expected, and steps to reproduce. + placeholder: Briefly describe the problem and how to reproduce it. validations: required: true - - type: input + - type: textarea id: version attributes: - label: 版本(Version) - placeholder: 如 0.6.0-alpha.1 + label: Version / environment + description: Open **Settings → About**, click the copy button, and paste the runtime info here (app version, PR-Agent, OS, etc.). + placeholder: | + App version: 0.10.0 + Electron: 33.0.0 + Node: 22.0.0 + Operating system: darwin 15.5 + Architecture: arm64 + PR-Agent: 0.36.0 validations: required: false - type: dropdown id: os attributes: - label: 操作系统(OS) + label: OS options: - Windows - macOS @@ -32,7 +39,7 @@ body: - type: textarea id: logs attributes: - label: 日志 / 截图(Logs / screenshots) - description: 相关日志(`~/.code-meeseeks/`)或截图,便于定位。 + label: Logs / screenshots + description: Relevant logs (`~/.code-meeseeks/`) or screenshots to help diagnose. validations: required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2fa53d56..a573796a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,6 @@ -# 关掉空白 issue,强制走下方模板——模板带 assignees,确保新 issue 默认有处理人。 +# Disable blank issues so every new issue uses a template — templates carry assignees, so each issue has an owner by default. blank_issues_enabled: false contact_links: - - name: 使用文档(Documentation) + - name: Documentation url: https://github.com/huhamhire/code-meeseeks/tree/master/docs/guide - about: 安装、代码平台 / LLM / 代理配置与使用说明,先看这里。 + about: Installation, code-platform / LLM / proxy setup and usage — check here first. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index c891dc1e..62532b56 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ -name: 功能建议(Feature request) -description: 提出新功能或改进建议 +name: Feature request +description: Suggest a new feature or improvement labels: [enhancement] assignees: - huhamhire @@ -7,14 +7,14 @@ body: - type: textarea id: problem attributes: - label: 需求 / 痛点(Problem) - description: 你希望解决什么问题、当前有何不便。 + label: Problem / pain point + description: What problem do you want to solve, and what is inconvenient today. validations: required: true - type: textarea id: proposal attributes: - label: 期望方案(Proposed solution) - description: 期望的功能形态或交互;有备选方案也可一并说明。 + label: Proposed solution + description: The desired feature or interaction; alternatives are welcome too. validations: required: false diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index e6160228..7e9e2d13 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -1,24 +1,24 @@ -> ⚠️ **早期预览版(0.x)**:功能、配置与数据格式可能发生不兼容变更,稳定性未经充分验证。 -> 请勿用于关键生产评审流程,使用前自行评估风险并备份数据。 +> ⚠️ **Early preview (0.x)**: features, configuration, and data formats may change incompatibly, and stability is not fully verified. +> Do not use it for critical production review workflows — assess the risk and back up your data first. -## 版本变更 +## What's changed %%CHANGELOG_SECTION%% -> 完整历史见 [CHANGELOG](https://github.com/huhamhire/code-meeseeks/blob/master/CHANGELOG.md)。 +> 📖 This version on the website changelog (English · 简体中文): %%CHANGELOG_URL%% -## 安装说明 +## Installation -安装包开箱即用,已内置评审所需运行环境,**安装后即可使用,无需额外配置**。 +The installers are ready to use — the review runtime is bundled in, so **no extra setup is needed after installing**. -**首次打开**:本版**未做代码签名公证**(开源免费路线),系统会拦截未知开发者的应用: +**First launch**: this build is **not code-signed / notarized** (a free, open-source path), so the OS blocks apps from unidentified developers: -- **macOS**:右键点 App 选「打开 → 仍要打开」;或「系统设置 → 隐私与安全性 → 仍要打开」。 - 也可终端执行 `xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app"`。 -- **Windows**:SmartScreen 弹窗点「更多信息 → 仍要运行」。 +- **macOS**: right-click the app → "Open" → "Open"; or System Settings → Privacy & Security → "Open Anyway". + You can also run `xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app"` in a terminal. +- **Windows**: on the SmartScreen prompt, click "More info" → "Run anyway". -## 许可 +## License -本项目 [Apache-2.0](https://github.com/huhamhire/code-meeseeks/blob/master/LICENSE)。安装包内含的 -第三方组件许可归集为 **`THIRD-PARTY-NOTICES.md`**,已随安装包内置(位于 App 资源目录,macOS 为 -`Code Meeseeks.app/Contents/Resources/`,Windows 为安装目录 `resources/`)。 +This project is licensed under [Apache-2.0](https://github.com/huhamhire/code-meeseeks/blob/master/LICENSE). The third-party +component licenses bundled in the installer are collected into **`THIRD-PARTY-NOTICES.md`**, shipped inside the package (in the +app resources directory — `Code Meeseeks.app/Contents/Resources/` on macOS, the install dir's `resources/` on Windows). diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml index 2e03888d..869ff69d 100644 --- a/.github/workflows/ci-cli.yml +++ b/.github/workflows/ci-cli.yml @@ -1,8 +1,8 @@ name: CLI -# meebox CLI(cli/,独立 Go module)的门禁,与 Node/Nx 的 CI 分开: -# 路径过滤只能加在 workflow 的 on 层(不能按 job 过滤),故独立成一条流水线——仅当 cli/ 变更时才跑, -# 既隔离 Go 工具链、又省 CI 分钟。发布期的交叉编译 / 出包见 release.yml 的 cli job。 +# Gate for the meebox CLI (cli/, standalone Go module), separate from the Node/Nx CI: +# path filters can only live at the workflow `on` level (not per-job), so this is its own pipeline — runs only when cli/ changes, +# both isolating the Go toolchain and saving CI minutes. For release-time cross-compile / packaging see the cli job in release.yml. on: push: branches: [master] diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..2fc91f60 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,75 @@ +name: Deploy Website (GitHub Pages) + +# Brand website (website/, VitePress). Decoupled from the v* release pipeline: +# build on every PR touching the site/docs (as a check), deploy on push to master. +on: + push: + branches: [master] + paths: + - 'website/**' + - 'docs/**' + - '.github/workflows/pages.yml' + pull_request: + paths: + - 'website/**' + - 'docs/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; do not cancel an in-progress production deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + with: + lfs: true # brand icon/favicon are Git LFS assets — fetch real bytes, not pointers + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install + run: npm install + + # CI-only prep stage: snapshot the latest release with the authenticated + # Actions token (1000 req/h) so the deployed download page has a static + # fallback when a visitor's live API call is rate-limited. Kept out of + # `npm run build` so local / PR builds stay network-free. + - name: Snapshot latest release + run: npm run fetch-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + run: npm run build + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/master' + uses: actions/upload-pages-artifact@v3 + with: + path: website/.vitepress/dist + + deploy: + if: github.ref == 'refs/heads/master' + needs: build + runs-on: ubuntu-latest + 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/.github/workflows/release.yml b/.github/workflows/release.yml index 56142338..b81d76b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,19 @@ -# 发布 workflow —— tag 触发,自动出 Windows + macOS(arm64) 安装包并挂到 GitHub Release。 +# Release workflow — tag-triggered, automatically builds Windows + macOS(arm64) installers and attaches them to a GitHub Release. # -# 免费路线(当前):仓库无 Apple 签名 secrets → mac 包由 afterPack 做 ad-hoc 签名 -# (arm64 能跑,含嵌入式 python),但不公证,用户首次需"仍要打开"。见 docs/mac-build.md。 +# Free route (current): repo has no Apple signing secrets → mac package is ad-hoc signed by afterPack +# (runs on arm64, includes embedded python), but not notarized, so users need "Open anyway" on first launch. See docs/mac-build.md. # -# 升级到公证(将来有 Apple Developer ID):把下列 secrets 配进仓库即可,workflow 不用改—— -# afterPack 检测到凭据会自动让位给 electron-builder 的正式签名 + 公证: -# MAC_CSC_LINK Developer ID 证书 .p12 的 base64 -# MAC_CSC_KEY_PASSWORD .p12 密码 -# APPLE_API_KEY App Store Connect API key .p8 的 base64(或路径) +# Upgrading to notarization (once an Apple Developer ID is available): just configure the following secrets in the repo, no workflow change needed — +# on detecting credentials afterPack yields to electron-builder's proper signing + notarization: +# MAC_CSC_LINK base64 of the Developer ID certificate .p12 +# MAC_CSC_KEY_PASSWORD .p12 password +# APPLE_API_KEY base64 (or path) of the App Store Connect API key .p8 # APPLE_API_KEY_ID / APPLE_API_ISSUER -# 并在 electron-builder.yml mac 段加 hardenedRuntime / entitlements / notarize。 +# and add hardenedRuntime / entitlements / notarize to the mac section of electron-builder.yml. # -# 发布采用**两阶段**:gui / cli 各 matrix job 只构建并把产物上传为 workflow artifact;末置单个 -# release job 汇总下载 + 组装正文 + 一次性上传到 Release。此前各 job 各自调 softprops 直传,多个 job -# 并发对同一 tag 创建 / finalize Release 会撞 `already_exists` 竞态——单点发布根除之。 +# Release is **two-stage**: the gui / cli matrix jobs only build and upload their artifacts as workflow artifacts; a final single +# release job aggregates the downloads + assembles the body + uploads to the Release in one shot. Previously each job called softprops directly, and multiple jobs +# concurrently creating / finalizing the Release for the same tag hit an `already_exists` race — a single publish point eliminates it. name: Release @@ -24,7 +24,7 @@ on: workflow_dispatch: {} permissions: - contents: write # 创建 Release + 上传产物 + contents: write # create Release + upload artifacts concurrency: group: release-${{ github.ref }} @@ -38,27 +38,27 @@ jobs: include: - os: windows-latest artifacts: apps/desktop/release/*.exe - - os: macos-14 # arm64 runner,原生构建 mac arm64 + - os: macos-14 # arm64 runner, natively builds mac arm64 artifacts: apps/desktop/release/*.dmg runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: - lfs: true # 拉 LFS 图标(assets/icons/icon.ico 等),否则只是指针 → 构建失败 + lfs: true # pull LFS icons (assets/icons/icon.ico etc.), otherwise they are just pointers → build failure - uses: actions/setup-node@v4 with: node-version: '22' cache: npm - - name: 安装依赖 + - name: Install dependencies run: npm ci - # 有 Apple 凭据时才提升为签名 env(走正式签名 + 公证);没有则一个都不设。 - # 关键:不能把缺失的 secret 直接挂到 env —— 那样 CSC_LINK 会变成空串 "", - # electron-builder 会把它当证书路径解析成 projectDir(apps/desktop) → "not a file" 报错。 - # 无凭据时交给 afterPack 做 ad-hoc 签名(见文件头注释 + after-pack.cjs)。 - - name: 准备 mac 签名凭据 + # Only promote to signing env when Apple credentials are present (proper signing + notarization); otherwise set none. + # Key point: do not attach a missing secret straight to env — that turns CSC_LINK into an empty string "", + # and electron-builder resolves it as a certificate path relative to projectDir(apps/desktop) → "not a file" error. + # Without credentials, hand off to afterPack for ad-hoc signing (see file-header comment + after-pack.cjs). + - name: Prepare mac signing credentials if: runner.os == 'macOS' shell: bash env: @@ -76,28 +76,28 @@ jobs: echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" } >> "$GITHUB_ENV" - echo "检测到 Apple 凭据 → 正式签名 + 公证" + echo "Apple credentials detected → proper signing + notarization" else - echo "无 Apple 凭据 → afterPack ad-hoc 签名(免费路线)" + echo "No Apple credentials → afterPack ad-hoc signing (free route)" fi - - name: 构建 + 出包 - shell: bash # win/mac 统一用 bash,保证多行命令 fail-fast + - name: Build + package + shell: bash # use bash on both win/mac to guarantee fail-fast on multi-line commands working-directory: apps/desktop run: | - npm run prepare:pragent # 组装当前平台的嵌入式 pr-agent 运行时 + npm run prepare:pragent # assemble the embedded pr-agent runtime for the current platform npm run build # electron-vite build - npm run notices # 生成第三方声明,供 electron-builder 打入包 + npm run notices # generate third-party notices for electron-builder to bundle npx electron-builder --publish never env: - # 无 CSC_LINK 时禁止 electron-builder 去钥匙串自动找签名身份(afterPack 已 ad-hoc); - # 有真证书时走 CSC_LINK 导入签名,不受此项影响。 + # Without CSC_LINK, forbid electron-builder from auto-finding a signing identity in the keychain (afterPack already did ad-hoc); + # with a real certificate, CSC_LINK imports the signature and this setting has no effect. CSC_IDENTITY_AUTO_DISCOVERY: false - # prepare:pragent 调 GitHub API 列 python-build-standalone release 资产;匿名 60 次/h/IP - # 易被限流(HTTP 403)。带上 Actions 自带 token 提到认证额度(脚本已支持 GITHUB_TOKEN)。 + # prepare:pragent calls the GitHub API to list python-build-standalone release assets; anonymous 60/h/IP + # is easily rate-limited (HTTP 403). Pass the Actions-provided token to raise it to the authenticated quota (the script supports GITHUB_TOKEN). GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 生成 SHA256 校验和 + - name: Generate SHA256 checksums if: startsWith(github.ref, 'refs/tags/') shell: bash working-directory: apps/desktop/release @@ -111,8 +111,8 @@ jobs: fi done - # 安装包 + 校验和上传为 workflow artifact,交末置 release job 单点发布(不在此直传 Release)。 - - name: 上传构建产物 + # Upload installers + checksums as workflow artifacts, leaving the final release job as the single publish point (no direct Release upload here). + - name: Upload build artifacts if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: @@ -123,9 +123,9 @@ jobs: if-no-files-found: error retention-days: 1 - # meebox CLI(cli/,独立 Go module):纯 Go、无 CGO → 单 runner 交叉编译全平台。出四平台压缩包 + - # 校验和,随桌面安装包挂到同一个 GitHub Release(不打进安装包,是独立可分发物)。同样只上传为 artifact, - # 由末置 release job 单点发布;workflow_dispatch 仍构建做编译冒烟、不上传(无 tag)。 + # meebox CLI (cli/, standalone Go module): pure Go, no CGO → cross-compile all platforms on a single runner. Produces four-platform archives + + # checksums, attached alongside the desktop installers to the same GitHub Release (not bundled into the installer, a separate distributable). Likewise only uploaded as an artifact, + # published by the final release job as the single point; workflow_dispatch still builds as a compile smoke test but does not upload (no tag). cli: name: CLI (${{ matrix.goos }}/${{ matrix.goarch }}) runs-on: ubuntu-latest @@ -145,12 +145,12 @@ jobs: go-version-file: cli/go.mod cache-dependency-path: cli/go.sum - # 显式声明 node:版本号取自 apps/desktop/package.json(`node -p` 读取),不依赖 runner 隐式自带 node。 + # Declare node explicitly: the version is read from apps/desktop/package.json (via `node -p`), not relying on the runner's implicit node. - uses: actions/setup-node@v4 with: node-version: '22' - - name: 交叉编译 meebox CLI + - name: Cross-compile meebox CLI shell: bash working-directory: cli env: @@ -158,20 +158,20 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: '0' run: | - # 版本与 app 同源:取自 apps/desktop/package.json(app 运行期版本的唯一真相源), - # 而非独立依赖 git tag——发布前置已校验 tag == 该版本,故二者一致但只有一个来源。 + # Version shares the app's source: taken from apps/desktop/package.json (the single source of truth for the app's runtime version), + # rather than independently relying on the git tag — release prechecks already verified tag == that version, so they agree but there is only one source. VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" mkdir -p dist go build -trimpath \ -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ -o "dist/meebox${{ matrix.ext }}" . - - name: 打包压缩包 + 校验和 + - name: Package archive + checksum if: startsWith(github.ref, 'refs/tags/') shell: bash working-directory: cli/dist run: | - VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # 与 app 同源 + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # shares the app's source BIN="meebox${{ matrix.ext }}" ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}.${{ matrix.archive }}" # Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill @@ -183,13 +183,13 @@ jobs: else tar -czf "${ARCHIVE}" "${FILES[@]}" fi - # 只对本 matrix 实际产出的那个压缩包做校验和。此前遍历 .zip/.tar.gz 两种扩展名:zip 变体 - # (windows/mac)最后一轮 `[ -e X.tar.gz ]` 为假、返回 1,恰是步骤末命令 → 整步以 1 退出而挂; - # linux(.tar.gz 末轮命中)才幸免。直接对已知文件名求 sha256,去掉这个脆弱循环。 + # Checksum only the one archive this matrix actually produced. Previously it iterated both .zip/.tar.gz extensions: for the zip variants + # (windows/mac) the last round `[ -e X.tar.gz ]` was false and returned 1, and being the step's final command → the whole step exited 1 and failed; + # only linux (.tar.gz matched on the last round) escaped. Compute sha256 on the known filename directly, dropping this fragile loop. sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" - # 压缩包 + 校验和上传为 workflow artifact(glob 仅取 meebox-cli-*,排除 dist 里的裸二进制与随包 LICENSE 等)。 - - name: 上传构建产物 + # Upload archive + checksum as workflow artifacts (glob takes only meebox-cli-*, excluding the bare binary and bundled LICENSE etc. in dist). + - name: Upload build artifacts if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: @@ -198,44 +198,51 @@ jobs: if-no-files-found: error retention-days: 1 - # 单点发布:等所有构建产物就绪后,一个 job 汇总下载 + 组装正文 + 一次性上传到 Release。避免多个 - # matrix job 并发对同一 tag 创建 / finalize Release 触发 `already_exists` 竞态。仅 tag 触发。 + # Single publish point: once all build artifacts are ready, one job aggregates downloads + assembles the body + uploads to the Release in one shot. Avoids multiple + # matrix jobs concurrently creating / finalizing the Release for the same tag triggering an `already_exists` race. Tag-triggered only. release: needs: [gui, cli] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 # 取 CHANGELOG / RELEASE_NOTES(正文组装用);无需 LFS + - uses: actions/checkout@v4 # fetch CHANGELOG / RELEASE_NOTES (for body assembly); no LFS needed - - name: 下载全部构建产物 + - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: dist - merge-multiple: true # 各 job 的产物合并平铺到 dist/(文件名互不相同、无冲突) + merge-multiple: true # merge each job's artifacts flat into dist/ (filenames are all distinct, no conflicts) - # 组装 Release 正文:把 CHANGELOG 里对应版本的段落注入 RELEASE_NOTES 的占位, - # 让 Release 页直接看到本版变更,而不是只给一个链接。找不到对应段落则回退用原说明。 - - name: 组装 Release 说明 + # Assemble the Release body: inject the CHANGELOG section for the matching version into the placeholder in RELEASE_NOTES, + # so the Release page shows this version's changes directly instead of just a link. If no matching section is found, fall back to the original notes. + # Source is the English canonical CHANGELOG.md (RELEASE_NOTES.md is English too, so the Release body is single-language English); + # the bilingual venue is the website changelog (https://huhamhire.github.io/code-meeseeks/changelog), linked from RELEASE_NOTES.md. + - name: Assemble Release notes shell: bash run: | VERSION="${GITHUB_REF_NAME#v}" - # 抽取 `## [VERSION] ...` 到下一个 `## [` 之间的正文(index==1 做字面前缀匹配,避开正则元字符) + # Deep-link to this version on the website changelog. The anchor is stable and date-independent: + # sync-docs.mjs injects `{#v}`; build the same slug here from the tag. + CHANGELOG_URL="https://huhamhire.github.io/code-meeseeks/changelog#v$(echo "$VERSION" | tr '.' '-')" + # Extract the body between `## [VERSION] ...` and the next `## [` (index==1 does a literal prefix match, avoiding regex metacharacters) awk -v ver="## [$VERSION]" 'index($0,ver)==1{f=1;next} f&&/^## \[/{exit} f{print}' CHANGELOG.md > changelog-section.md if [ -s changelog-section.md ]; then awk 'FNR==NR{a[++n]=$0;next} /%%CHANGELOG_SECTION%%/{for(i=1;i<=n;i++)print a[i];next} {print}' \ - changelog-section.md .github/RELEASE_NOTES.md > RELEASE_BODY.md + changelog-section.md .github/RELEASE_NOTES.md > RELEASE_BODY.tmp else - echo "::warning::CHANGELOG 未找到 [$VERSION] 段,Release 正文回退用 RELEASE_NOTES.md 原文" - sed 's/%%CHANGELOG_SECTION%%//' .github/RELEASE_NOTES.md > RELEASE_BODY.md + echo "::warning::CHANGELOG.md has no [$VERSION] section, Release body falls back to the original RELEASE_NOTES.md" + sed 's/%%CHANGELOG_SECTION%%//' .github/RELEASE_NOTES.md > RELEASE_BODY.tmp fi + # Fill the version deep-link (use | as the sed delimiter — the URL contains slashes) + sed "s|%%CHANGELOG_URL%%|${CHANGELOG_URL}|" RELEASE_BODY.tmp > RELEASE_BODY.md - - name: 上传到 Release(单点) + - name: Upload to Release (single point) uses: softprops/action-gh-release@v2 with: files: dist/* fail_on_unmatched_files: true - # alpha / 任何带 - 的预发布 tag(如 v0.1.0-alpha.1)→ 标为 prerelease,且不抢占 Latest + # alpha / any prerelease tag with a - (e.g. v0.1.0-alpha.1) → marked prerelease, and does not claim Latest prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ !contains(github.ref_name, '-') }} - # 正文 = RELEASE_NOTES(安装 / 首次打开 / 校验和)+ 注入的本版 CHANGELOG 段 + # body = RELEASE_NOTES (install / first open / checksums) + the injected CHANGELOG section for this version body_path: RELEASE_BODY.md diff --git a/.gitignore b/.gitignore index 08bbdd94..a5641a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,15 +11,19 @@ out-tsc/ *.tsbuildinfo .cache/ +# Python bytecode (tools/ scripts, etc.) +__pycache__/ +*.pyc + # Electron / packaged binaries release/ -# 嵌入式 pr-agent 运行时(scripts/assemble-pragent-runtime.mjs 生成;几百 MB、 -# 平台特定的下载+pip 产物,不入库;见 docs/modules/04-pragent-runtime.md) +# Embedded pr-agent runtime (generated by scripts/assemble-pragent-runtime.mjs; hundreds of MB, +# platform-specific download + pip output, not committed; see docs/arch/02-agent/05-pragent-runtime.md) apps/desktop/vendor/ -# 第三方声明全文(tools/gen-third-party-notices.mjs 生成;近万行许可证全文,不入库—— -# 出包前生成,由 electron-builder 打入安装包 /THIRD-PARTY-NOTICES.md) +# Full third-party notices (generated by tools/gen-third-party-notices.mjs; ~10k lines of full license +# text, not committed — generated before packaging, bundled by electron-builder into the installer at /THIRD-PARTY-NOTICES.md) THIRD-PARTY-NOTICES.md *.exe *.dmg @@ -60,7 +64,7 @@ logs/ coverage/ .nyc_output/ -# Claude Code 本地状态 +# Claude Code local state .claude/ .nx/polygraph diff --git a/AGENTS.md b/AGENTS.md index 12f6e5d4..a09a92f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,128 +1,132 @@ # AGENTS.md -面向自动化编码 agent 的工程维护速览。背景/业务见 [docs/ROADMAP.md](docs/ROADMAP.md) 与 [docs/arch/](docs/arch/README.md)。 +Engineering-maintenance overview for automated coding agents. For background / product context see [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/arch/](docs/arch/README.md). -## 仓库结构 +## Repository structure -Electron 桌面应用(npm workspaces + Nx 单仓多包)外加一个独立 Go CLI 子工程。关键路径(`apps/desktop` 是主战场): +An Electron desktop app (npm workspaces + Nx monorepo) plus a standalone Go CLI sub-project. Key paths (`apps/desktop` is the main working area): ``` -apps/desktop/ # Electron 应用(唯一有 build/dist 的项目) +apps/desktop/ # Electron app (the only project with build/dist) ├── src/ -│ ├── main/ # 主进程:业务与 IO 唯一所在(启动/单例锁 · IPC handlers · 平台适配 · 服务层:pr · agent 编排 · 本地 API) -│ ├── preload/ # contextBridge 暴露泛型 invoke() -│ └── renderer/src/ # React 渲染层(UI / 交互) -├── scripts/ # 嵌入式 pr-agent 运行时组装 + monkeypatch shim -├── build-resources/ # electron-builder 打包资源(签名钩子 · entitlements) -└── vendor/pragent/ # 嵌入式运行时(gitignored,prepare:pragent 生成) - -packages// # 内部库 @meebox/*(各 src/index.ts 为入口);shared 含共享类型 + IPC 契约 -cli/ # 独立 Go module:跨平台 CLI meebox(命令树 + HTTP client;经本地 API 集成,不入 npm/Nx) +│ ├── main/ # main process: the sole home of business logic & IO (startup/single-instance lock · IPC handlers · platform adapters · service layer: pr · agent orchestration · local API) +│ ├── preload/ # contextBridge exposing a generic invoke() +│ └── renderer/src/ # React render layer (UI / interaction) +├── scripts/ # embedded pr-agent runtime assembly + monkeypatch shim +├── build-resources/ # electron-builder packaging resources (signing hooks · entitlements) +└── vendor/pragent/ # embedded runtime (gitignored, generated by prepare:pragent) + +packages// # internal libraries @meebox/* (each src/index.ts is the entry); shared holds shared types + the IPC contract +cli/ # standalone Go module: the cross-platform CLI meebox (command tree + HTTP client; integrates via the local API, not part of npm/Nx) ``` -- `apps/desktop` —— Electron 应用(main + preload + renderer/React)。唯一有 `build`/`dist` 的项目。 -- `packages/*` —— 内部库(`@meebox/*`),按职责拆分;其中 `shared` 含共享类型与 IPC 契约。 -- `cli/` —— 独立分发的 Go 命令行工具 `meebox`(外部集成用,不属 npm/Nx;详见下「CLI 工程(cli/)」段)。 -- `docs/arch/` 各模块设计文档(首选入口);`docs/guide/` 使用说明;`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 +- `apps/desktop` — the Electron app (main + preload + renderer/React). The only project with `build`/`dist`. +- `packages/*` — internal libraries (`@meebox/*`), split by responsibility; `shared` holds shared types and the IPC contract. +- `cli/` — the independently distributed Go command-line tool `meebox` (for external integration, not part of npm/Nx; see the "CLI sub-project (cli/)" section below). +- `docs/arch/` per-module design docs (the preferred entry point); `docs/guide/` user guide; `docs/ROADMAP.md` roadmap; `tools/` misc scripts. -**命名约定**:代码内部统一用中性代号 `meebox`(npm 作用域 `@meebox/*`);对外品牌名 `Code Meeseeks`;用户数据目录 `~/.code-meeseeks/`。`pr-agent` 为第三方依赖,不在重命名范围内。 +**Naming convention**: the codebase uses the neutral codename `meebox` throughout (npm scope `@meebox/*`); the public brand name is `Code Meeseeks`; the user data directory is `~/.code-meeseeks/`. `pr-agent` is a third-party dependency and is out of the renaming scope. -## 常用命令 +## Common commands -根目录脚本(底层都是 `nx run-many`): +Root scripts (all `nx run-many` under the hood): ```bash -npm run lint # eslint,--max-warnings=0(warning 也算失败) +npm run lint # eslint, --max-warnings=0 (a warning counts as failure too) npm run typecheck # tsc --noEmit -npm run test # vitest(仅 packages 里带 test target 的,desktop/shared 无测试) -npm run build # 构建(实际只 @meebox/desktop 有 build) -npm run format # prettier 写入 +npm run test # vitest (only packages with a test target; desktop/shared have no tests) +npm run build # build (in practice only @meebox/desktop has build) +npm run format # prettier write ``` -单项目:`npx nx `,如 `npx nx test poller`、`npx nx typecheck desktop`。 +Single project: `npx nx `, e.g. `npx nx test poller`, `npx nx typecheck desktop`. -桌面应用(在 `apps/desktop`): +Desktop app (from `apps/desktop`): ```bash -npm --prefix apps/desktop run prepare:pragent # 组装嵌入式 Python + pr-agent 运行时(首次必跑) +npm --prefix apps/desktop run prepare:pragent # assemble the embedded Python + pr-agent runtime (required on first run) npm --prefix apps/desktop run dev # electron-vite dev -npm --prefix apps/desktop run dist # 出安装包(见 docs/development/mac-build.md) +npm --prefix apps/desktop run dist # produce the installer (see docs/development/mac-build.md) ``` -环境:Node ≥ 20(实测 22)、npm ≥ 10。**包管理器统一用 npm**(workspaces,lockfile `package-lock.json`)——勿用 yarn / pnpm。 +Environment: Node ≥ 20 (22 in practice), npm ≥ 10. **Use npm as the sole package manager** (workspaces, lockfile `package-lock.json`) — do not use yarn / pnpm. -## 依赖同步 +## Dependency sync -**每次拉取代码后、以及发布前**,在仓库根目录执行一次完整安装并对齐运行时: +**After every pull, and before every release**, run a full install and align the runtime from the repo root: ```bash -npm install # 完整安装依赖、对齐 package-lock.json(workspaces 软链) -npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运行时与 shim(见 docs/arch/02-agent/03-pragent-runtime) +npm install # full dependency install, aligns package-lock.json (workspace symlinks) +npm --prefix apps/desktop run prepare:pragent # align the embedded pr-agent runtime and shim (see docs/arch/02-agent/05-pragent-runtime) ``` -`npm install`(非 `npm ci`)会按 `package.json` 解析并写回 lockfile,确保本地与远端 `package-lock.json` 一致;`prepare:pragent` 幂等,按 `pragent-runtime.json` 对齐本地 pr-agent 运行时(版本不变则跳过、仅同步 shim)。lockfile 若被改动,按本次改动归属一并提交。 +`npm install` (not `npm ci`) resolves against `package.json` and writes the lockfile back, keeping local and remote `package-lock.json` consistent; `prepare:pragent` is idempotent and aligns the local pr-agent runtime per `pragent-runtime.json` (skips if the version is unchanged, only syncing the shim). If the lockfile changes, commit it together with the change it belongs to. -## 提交前必做 +## Required before committing -改完代码务必本地跑通这四步再收尾(CI 就是这套):`lint` → `typecheck` → `test` → `build`。lint 零容忍(`--max-warnings=0`),warning 也会让 CI 红。 +After changing code, run these four steps locally before wrapping up (this is exactly what CI does): `lint` → `typecheck` → `test` → `build`. lint is zero-tolerance (`--max-warnings=0`), and a warning will turn CI red. -## 发布流程 +## Release flow -发版:`dev` 汇入 `master` → 在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + CLI 二进制 + GitHub Release)。 +Release: merge `dev` into `master` → tag `v*` on `master`, which triggers [release.yml](.github/workflows/release.yml) (produces Windows / macOS installers + CLI binaries + a GitHub Release). -⚠️ **打 tag 前必须在同一批改动里完成三步前置(版本号 / CHANGELOG / 校对),随发版一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release。**完整前置清单、`-dev` 版本号规则、CHANGELOG 撰写风格见 [打包与发布](docs/development/packaging-release.md)**。tag 名须等于 package.json 版本(`v<版本>`);名含 `-` 的预发布 tag 自动标 prerelease、不抢占 Latest。 +⚠️ **Before tagging, complete the three prerequisites (version / CHANGELOG / proofread) in the same batch of changes, flowing through `dev` → `master` with the release** — miss any step and CI won't error (only `::warning::`) but will produce a wrong Release. **The full prerequisite checklist, the `-dev` version-number rule, and CHANGELOG writing style are in [Packaging & release](docs/development/packaging-release.md)**. The tag name must equal the package.json version (`v`); a prerelease tag with a `-` in the name is automatically marked prerelease and does not claim Latest. -## CLI 工程(cli/) +## CLI sub-project (cli/) -`cli/` 是独立分发的跨平台命令行客户端 `meebox`(供外部 agent / 脚本经[本地 API 服务](docs/arch/04-integration/01-service-api.md)集成)。设计见 [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md),用法见 [docs/guide/06-cli.md](docs/guide/06-cli.md)。 +`cli/` is the independently distributed cross-platform command-line client `meebox` (for external agents / scripts to integrate via the [local API service](docs/arch/04-integration/01-service-api.md)). Design in [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md), usage in [docs/guide/06-cli.md](docs/guide/06-cli.md). -- **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 -- **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 -- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本**取自 `apps/desktop/package.json`(与 app 同源,即 app 运行期版本的唯一真相源)**经 `-ldflags -X …/cmd.version` 注入——不独立依赖 git tag(发布前置已校验 tag == 该版本)。 -- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。 -- **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。 -- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 -- **领域归类**:CLI / API / GUI 都是同一 service 层之上的薄前端,命令树与端点应**镜像业务领域**。新增 CLI 命令 / API 端点按语义归位——PR 相关入 `pr`、评审 Agent 入 `agent`;与具体 PR / Agent 无关的**系统性 / 会话级**操作(whoami / version 等)置于**根层级**,不套领域组。归属看**语义而非是否 PR 维度**(如 `categories` / `refresh` 无 `--pr` 仍属 `pr`——它们服务于 PR 列表)。完整理由见 [CLI 设计](docs/arch/04-integration/02-cli.md)。 -- **文档对齐**:CLI 改动(命令树 / 写边界 / 输出契约 / 连接配置)须同步三类文档,缺一即漂移——① **arch 设计**:[docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md)(命令树与边界),涉及 API 端点再改 [01-service-api.md](docs/arch/04-integration/01-service-api.md)(端点表);② **guide 用法**:[docs/guide/06-cli.md](docs/guide/06-cli.md);③ **skill 交付**:`cli/SKILL.md` + `cli/README.md`(随压缩包投放为 agent skill 的主形态)。新增 / 改命令务必三类齐更。 +- **Standalone Go module, not part of npm/Nx**: has its own `cli/go.mod` (pure Go, no CGO), is not a workspace member, and is not in Nx — the root `lint/typecheck/test/build` don't cover it; the CLI is its own thing. +- **Local commands** (from `cli/`): `go vet ./...` → `go test ./...` → `go build ./...`; get these three passing before wrapping up a CLI change. `go.sum` is committed (locks checksums); build artifacts (`bin/` / `meebox` etc.) are gitignored (see `cli/.gitignore`). +- **Two CI lanes**: the PR gate [ci-cli.yml](.github/workflows/ci-cli.yml) (path filter `cli/**`, runs vet/test/build, separate from the Node ci.yml); the release output is the `cli` job in [release.yml](.github/workflows/release.yml) (`v*` tag triggered, cross-compiles Windows / macOS / Linux×2, produces archives attached to the same Release; Windows / macOS use `.zip`, Linux uses `.tar.gz`). The version is **taken from `apps/desktop/package.json` (same source as the app — the single source of truth for the app's runtime version)**, injected via `-ldflags -X …/cmd.version` — it does not independently depend on the git tag (the release prerequisites already verified tag == that version). +- **The archive is itself a skill directory**: besides the binary, the CLI archive also packages `LICENSE` + `cli/README.md` + `cli/SKILL.md` (frontmatter `name: meebox`) — extract it into an agent's skills directory and you have a usable skill (the primary delivery form for agents). +- **Write boundary**: the CLI does browsing + **review write actions** — approve / needswork (real remote review decisions) and comment (posting a comment), via dedicated server endpoints (reusing the GUI's same-source controller). Still **not exposed**: merge and pr-agent change-type tools (publish etc.; `instruct`'s read-only whitelist describe/review/ask/improve is gated in both the CLI and the server). For a new command, first confirm the corresponding API endpoint exists; opening a new write endpoint requires assessing remote side effects. The CLI must not bypass the API to reach app internals directly. +- **Contract sync**: the only coupling between the CLI and the server is the HTTP/JSON wire protocol. Currently hand-written Go structs align with the contract; switch to OpenAPI / schema code generation once the contract grows. Output defaults to YAML (human-facing, order-preserving), with `--output json` for machines (also order-preserving); the PR list returns a compact projection, the PR identifier is exposed as `id`, and PR-scoped commands use `--pr `. Connection config comes from flags / environment variables (`MEEBOX_API_URL` / `MEEBOX_TOKEN`) / `~/.code-meeseeks/cli.yaml`, and **does not read the GUI's `config.yaml`** (to avoid over-reaching into connection-layer secrets); proxying honors the standard `HTTP(S)_PROXY` / `NO_PROXY`. +- **Domain grouping**: CLI / API / GUI are all thin front-ends over the same service layer, so the command tree and endpoints should **mirror the business domains**. Place a new CLI command / API endpoint by semantics — PR-related goes in `pr`, the review Agent goes in `agent`; **systemic / session-level** operations unrelated to a specific PR / Agent (whoami / version etc.) go at the **root level**, not under a domain group. Grouping follows **semantics, not whether it's PR-scoped** (e.g. `categories` / `refresh` have no `--pr` yet still belong to `pr` — they serve the PR list). Full rationale in [CLI design](docs/arch/04-integration/02-cli.md). +- **Doc alignment**: a CLI change (command tree / write boundary / output contract / connection config) must sync three kinds of docs, or they drift — ① **arch design**: [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md) (command tree and boundaries), and if API endpoints are involved also [01-service-api.md](docs/arch/04-integration/01-service-api.md) (the endpoint table); ② **guide usage**: [docs/guide/06-cli.md](docs/guide/06-cli.md) (English canonical + Chinese [zh-CN/06-cli.md](docs/guide/zh-CN/06-cli.md), kept in sync); ③ **skill delivery**: `cli/SKILL.md` + `cli/README.md` (the primary form dropped in as an agent skill with the archive). Always update all three when adding / changing a command. -## 约定 +## Conventions -- **TypeScript strict**;React 19 + electron-vite + Monaco。优先复用现有工具/类型,匹配周边代码风格与注释密度。 -- **包内异常用英语**:`packages/*`(内部库)里 `throw` 的错误信息一律用**英语**、**不做 i18n**(英语为默认/兜底语言,面向开发者排障)。**面向用户展示**的状态文案(如 Agent 的 terminationReason)才走 i18n 资源——二者区分清楚,别把用户文案塞进异常、也别给技术异常做翻译。 -- **后台日志用英语**:`logger.*` / `console.*` 的日志信息一律用**英语**(开发者排障向、不面向用户、不做 i18n)。结构化字段值(路径 / id 等)原样;仅信息文本用英语。 -- **面向用户的错误走错误码**:会跨 IPC 展示给用户的后端错误,统一封装 `AppError`(`code` + 可序列化 `meta`)、以错误码(`E`+两字母领域+四位数字,如 `EAG0001`)承载,本地化由**前端**按码做(i18n `errors.`);后端不拼面向用户的本地化字符串。技术异常 / 日志仍英语(与上两条不冲突——边界是「是否跨 IPC 展示给用户」)。规范见 [docs/arch/99-core/04-error-codes.md](docs/arch/99-core/04-error-codes.md)。 -- **IPC**:main 用 `ipcMain.handle(channel, ...)`,renderer/preload 用泛型 `invoke(channel, req)`,全部由 `packages/shared/src/ipc.ts` 的 `IpcChannels` 类型映射约束。新增通道先在那里加类型。 -- **分支策略**:`master` 为发布分支,**禁止直接提交/修改**;所有特性与修复从 `dev` 拉分支开发,汇入 `dev` 验证后再合并到 `master`,发版在 `master` 打 `v*` tag 触发 release。 -- **提交信息**:约定式提交、**中文**,带 scope,例:`feat(desktop): …` / `fix(review): …` / `docs(readme): …` / `build(mac): …`。结尾带 `Co-Authored-By` trailer。 -- **提交 / 推送须经许可**:**未获用户明确许可,禁止 `git commit` 与 `git push`**——改完只把文件落盘,等用户明确指示再提交、再推送。此约束**不分改动类型、不分目标分支**(纯文档、`dev` 分支同样适用);许可一次只对应一次操作,不默认延续到后续改动。 -- **不提交无关改动**:工作区可能混有他人未提交编辑,按文件归属拆成内聚 commit,别混进同一条。 -- **按文件显式暂存**:只 `git add` 自己本次改动的具体文件路径,**禁止 `git add -A` / `git add .` / `git add :/`** 整目录暂存。多个 agent 任务可能并行编辑同一工作区,全量暂存会把他人未完成的改动一并卷入。暂存后 `git status` 复核暂存区,确认只含本任务文件再提交。 -- **PR 打标签**:开 / 更新 PR 后**习惯性打标签**——从仓库既有标签集(`gh label list`:enhancement / documentation / bug / …)选最贴切的一或多个贴上(`gh pr edit --add-label`)。没有合适的现成标签时按需新建或留空并说明,不强凑。 -- **平台展示顺序统一**:代码平台(GitHub / Bitbucket / GitLab …)在各处的展示顺序统一为 **GitHub → Bitbucket → GitLab**,**新增平台一律追加在末尾**。准绳是 [PlatformIcon.tsx](apps/desktop/src/renderer/src/components/PlatformIcon.tsx) 的 `PLATFORM_META` 数组;设置页平台下拉、首启向导网格、使用文档 [docs/guide/01-code-platform.md](docs/guide/01-code-platform.md)、`PlatformKind` 类型等各处均以此为序,改动平台清单时同步对齐,避免各处错位。 +- **TypeScript strict**; React 19 + electron-vite + Monaco. Prefer reusing existing utilities/types, and match the surrounding code style and comment density. +- **In-package exceptions in English**: error messages `throw`n in `packages/*` (internal libraries) are always in **English**, **no i18n** (English is the default/fallback language, for developer troubleshooting). Only **user-facing** status text (e.g. an Agent's terminationReason) goes through i18n resources — keep the two distinct: don't stuff user text into exceptions, and don't translate technical exceptions. +- **Backend logs in English**: log messages in `logger.*` / `console.*` are always in **English** (developer-troubleshooting-facing, not user-facing, no i18n). Structured field values (paths / ids etc.) stay as-is; only the message text is English. +- **Code comments in English**: source comments (`//`, `/* */`, JSDoc) are written in **English**, completing the "developer-facing code-surface artifacts are English" principle (alongside exceptions and logs; the Go CLI is already all-English). New or edited comments must be English; keep the density conventional (don't inflate). The existing Chinese comments are being backfilled **in batches by package / domain** — translate a file's comments opportunistically when you touch it if its area isn't converted yet. This is comment text only: never change code, identifiers, string literals, or formatting. +- **User-facing errors use error codes**: backend errors that cross IPC to be shown to the user are wrapped uniformly in `AppError` (`code` + serializable `meta`), carried by an error code (`E` + two-letter domain + four digits, e.g. `EAG0001`), with localization done by the **frontend** by code (i18n `errors.`); the backend does not assemble user-facing localized strings. Technical exceptions / logs stay English (no conflict with the two rules above — the boundary is "does it cross IPC to the user"). Spec in [docs/arch/99-core/04-error-codes.md](docs/arch/99-core/04-error-codes.md). +- **IPC**: main uses `ipcMain.handle(channel, ...)`, renderer/preload use the generic `invoke(channel, req)`, all constrained by the `IpcChannels` type map in `packages/shared/src/ipc.ts`. Add the type there first when adding a channel. +- **Branch strategy**: `master` is the release branch — **direct commits/edits forbidden**; branch all features and fixes off `dev`, merge into `dev` to validate, then merge to `master`; releases tag `v*` on `master` to trigger the release. +- **Commit messages**: Conventional Commits, in **English**, with a scope, e.g. `feat(desktop): …` / `fix(review): …` / `docs(readme): …` / `build(mac): …`. End with a `Co-Authored-By` trailer. (Historical commits used Chinese; from this convention onward all English, without rewriting history.) +- **Commit / push require permission**: **without the user's explicit permission, `git commit` and `git push` are forbidden** — just write files to disk and wait for the user's explicit instruction to commit and push. This applies **regardless of change type or target branch** (pure docs, the `dev` branch — same rule); one grant corresponds to one operation and does not carry over to later changes. +- **Don't commit unrelated changes**: the working tree may contain others' uncommitted edits, so split into cohesive commits by file ownership — don't mix them into one. +- **Stage files explicitly**: only `git add` the specific file paths of your own current change; **`git add -A` / `git add .` / `git add :/` (whole-directory staging) is forbidden**. Multiple agent tasks may edit the same working tree in parallel, and a blanket stage would sweep in others' unfinished changes. After staging, review the index with `git status` and confirm it contains only this task's files before committing. +- **PR descriptions in English**: the PR title and body are always in **English** (an external collaboration surface, consistent with English commits); the base branch points at `dev` per the branch strategy. +- **Label PRs**: after opening / updating a PR, **habitually label it** — pick the most fitting one or more from the repo's existing label set (`gh label list`: enhancement / documentation / bug / …) and apply them (`gh pr edit --add-label`). If no existing label fits, create one as needed or leave it empty with a note — don't force a poor fit. +- **Uniform platform display order**: code platforms (GitHub / Bitbucket / GitLab …) are displayed everywhere in the uniform order **GitHub → Bitbucket → GitLab**, and **a new platform is always appended at the end**. The reference is the `PLATFORM_META` array in [PlatformIcon.tsx](apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx); the settings-page platform dropdown, the first-launch wizard grid, the user doc [docs/guide/01-code-platform.md](docs/guide/01-code-platform.md), the `PlatformKind` type, etc. all follow this order — align them together when changing the platform list to avoid mismatches. -## 国际化 (i18n) +## Internationalization (i18n) -GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**;**默认 / 兜底 `en-US`**,缺 key 回退英文)。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩(详见该篇): +GUI text goes through **react-i18next** (keys are neutral identifiers; `zh-CN` / `en-US` / `ja-JP` / `de-DE` are **equivalent translation sets**; **default / fallback `en-US`**, missing keys fall back to English). Design, key naming, and translation conventions in [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md). Three easy traps (details in that doc): -1. 新增文本各语言 locale 都加且保持**递归字典序**; -2. 复数只认 `count`(普通计数插值换别名); -3. 勿开 `nonExplicitSupportedLngs`(按基码 `zh` 错位 → 整页裸 key)。 +1. Add new text to every locale and keep **recursive dictionary order**; +2. Plurals only recognize `count` (rename a plain counting interpolation to something else); +3. Don't enable `nonExplicitSupportedLngs` (it mismatches on the base code `zh` → a whole page of bare keys). -## 文档约定 +## Documentation conventions -- **两类文档分目录**:面向使用者的「使用说明」放 [docs/guide/](docs/guide/README.md)(**序号命名**,如 `01-code-platform.md`,README.md 作索引);面向开发/agent 的模块设计文档放 [docs/arch/](docs/arch/README.md)。同一主题用户向与设计向各写一份,互相链接、不混写。 -- **书面化表达**:正式、简洁的书面语,避免口语化措辞(如「怎么配」→「配置方法」、「搞定」→「完成」)。 -- **不用总结性套话**:避免「一句话」「总之」「简言之」「综上」这类收尾/概括套话,直接陈述结论。 -- **示例力求通用**:能力性描述用通用表述 + 多个示例(如「本机 agentic CLI(claude / codex)」),不绑定单一品牌,便于扩展。 +- **Doc language follows the reader** (two categories): **user-facing surfaces** — the guide [docs/guide/](docs/guide/README.md), the root [README.md](README.md), and the [CHANGELOG.md](CHANGELOG.md) — are **bilingual** (English canonical + Chinese mirror, see below); **everything developer/design-facing** — [docs/development/](docs/development/README.md), the root [CONTRIBUTING.md](CONTRIBUTING.md), the [ROADMAP](docs/ROADMAP.md), the module design docs [docs/arch/](docs/arch/README.md) (with a terminology [glossary](docs/arch/glossary.md) that locks canonical renderings), and this [AGENTS.md](AGENTS.md) — is **English single-language** (consistent with English commits/PRs and the now-English codebase). The CHANGELOG mirror is `CHANGELOG.zh-CN.md`; release.yml injects its Chinese section into the GitHub Release body (see [Packaging & release](docs/development/packaging-release.md)). +- **Two doc kinds, separate directories**: the user-facing "guide" goes in [docs/guide/](docs/guide/README.md) (**numbered names**, e.g. `01-code-platform.md`, with README.md as the index); developer/agent-facing module design docs go in [docs/arch/](docs/arch/README.md). For the same topic, write one user-facing and one design-facing doc, cross-linked, not intermixed. +- **Guide is bilingual, English canonical**: `docs/guide/` user docs are EN + ZH — **English at the root** (`docs/guide/*.md`, the canonical/fallback), with the Chinese mirror at `docs/guide/zh-CN/*.md` (same names, same structure). Each page carries a language-switch line under the H1 (English `**English** · [简体中文](zh-CN/x.md)`, Chinese `[English](../x.md) · **简体中文**`). **Content changes must sync both locales**, or they drift. The website [website/](website/README.md) builds from both via `scripts/sync-docs.mjs` (EN → `/guide/`, ZH → `/zh/guide/`, the switch line stripped at build time) and is the **sole render source** — don't keep a second copy. Chinese docs (Chinese README / arch etc.) link the guide at `zh-CN/`; English docs (English README / `cli/` etc.) link at the root. +- **Written register**: formal, concise prose; avoid colloquialisms. +- **No summarizing filler**: avoid wrap-up/summary clichés like "in a word", "in short", "to sum up" — state the conclusion directly. +- **Keep examples generic**: describe capabilities in general terms + multiple examples (e.g. "a local agentic CLI (claude / codex)"), not bound to a single brand, so they extend easily. -## 工程维护坑 +## Engineering maintenance pitfalls -- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外**必须**: - 1. 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`; - 2. 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。 +- **A new internal `@meebox/*` package requires two registration steps** (miss them and you get `Cannot find module …/src/.js`): internal package sources are `.ts` with relative imports carrying a `.js` extension (the NodeNext convention), which Node can't read directly at runtime. After creating an internal package referenced by desktop main/preload, besides `npm install` (which creates the workspace symlink) you **must**: + 1. Add `"@meebox/": "*"` to the `apps/desktop/package.json` dependencies; + 2. Add the name to the `internalPackages` array in [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) — so electron-vite **bundles** it (transpiling TS, resolving `.js`→`.ts`) rather than externalizing it. - 漏第 2 步时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 -- **pr-agent 运行时 / shim**:嵌入式 CPython + pinned pr-agent 由 `assemble-pragent-runtime.mjs` 装到 `vendor/pragent`(gitignored);对 pr-agent 的无侵入补丁在 `scripts/pragent-shim/`。机制与铁律(惰性 import 拆分 · 版本守卫 · 改后跑 `prepare:pragent` 同步 · 调试 `MEEBOX_SHIM_DEBUG=1`)见 [02-agent/05-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)。弱网 pip 超时加 `PIP_DEFAULT_TIMEOUT=120`。 -- **二进制资源走 Git LFS**(`*.png/.ico/.icns` 等):本地没装 git-lfs 时拿到的是指针文件,electron-builder 转图标会崩 → `brew install git-lfs && git lfs pull`。 -- **dev 起不来**:若 `npm run dev` 报 `electron does not provide an export named …`,是环境里有 `ELECTRON_RUN_AS_NODE=1`(VSCode 扩展宿主会注入)→ `unset ELECTRON_RUN_AS_NODE` 再跑。 -- **grep 个别文件无输出**:如 `repo-mirror-manager.ts` 被 `file` 判为 `data`(含非 UTF-8 字节),普通 grep 静默 → 用 `grep -a`。 + Missing step 2, Node treats it as an external package loaded per `main: src/index.ts`, hits `export … from './x.js'` while the file is `.ts` → crashes at runtime. +- **pr-agent runtime / shim**: the embedded CPython + pinned pr-agent is installed to `vendor/pragent` (gitignored) by `assemble-pragent-runtime.mjs`; the non-invasive patches to pr-agent are in `scripts/pragent-shim/`. The mechanism and hard rules (lazy-import split · version guard · run `prepare:pragent` to sync after changes · debug with `MEEBOX_SHIM_DEBUG=1`) are in [02-agent/05-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md). On a weak network add `PIP_DEFAULT_TIMEOUT=120` for pip timeouts. +- **Binary assets go through Git LFS** (`*.png/.ico/.icns` etc.): without git-lfs installed locally you get pointer files, and electron-builder's icon conversion crashes → `brew install git-lfs && git lfs pull`. +- **dev won't start**: if `npm run dev` reports `electron does not provide an export named …`, the environment has `ELECTRON_RUN_AS_NODE=1` (injected by the VSCode extension host) → `unset ELECTRON_RUN_AS_NODE` and rerun. +- **grep silent on certain files**: e.g. `repo-mirror-manager.ts` is judged `data` by `file` (contains non-UTF-8 bytes), so plain grep stays silent → use `grep -a`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1914e354..8103eef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,409 +1,441 @@ -# 更新日志(Changelog) +# Changelog -本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), -版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +**English** · [简体中文](CHANGELOG.zh-CN.md) + +All notable changes to this project are recorded here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the versioning follows [Semantic Versioning](https://semver.org/). + +## [0.10.0] - 2026-07-05 + +> Highlights of this release: +> +> - **Brand website launched**: a VitePress-based project website with a landing page / download page / FAQ and hosted bilingual user docs, auto-deployed via GitHub Pages. +> - **Project internationalization**: the README and user docs are now fully bilingual (English default + Chinese). +> - **Customizable code-suggestion output**: steer how AI suggestions are written and lay out the review-draft comment they produce (Settings → Agent → Strategy). +> +> Beyond the external presentation, the desktop app gains customizable code-suggestion output; the rest of the desktop app and CLI behavior is largely unchanged from 0.9.0, aside from a minor startup fix. + +### ✨ Added + +- **Brand website**: the user-facing project website is now live (). + - Landing page: one-line positioning + light/dark-adaptive product screenshots + core features + a model-ecosystem showcase + a download entry linking to Releases. + - Download page: automatically recommends the matching installer for the visitor's OS, with GUI and CLI in separate columns; the CLI offers one-line install scripts for macOS / Linux; download info is fetched from the latest Release at runtime, with a build-time static fallback to withstand API rate-limiting. + - FAQ on its own page. + - Hosted user docs: the website builds and renders directly from the repo's user docs, bilingual and kept in sync with them, and site search supports a Chinese interface. + - Deploy decoupled from releases: the website deploys to GitHub Pages via an independent pipeline, without occupying the app / CLI release flow. +- **Project documentation internationalization**: external docs are now fully bilingual (English as default / fallback, Chinese as the mirror). + - The README is split into English (default) and Chinese, switchable from the top of each. + - User docs are bilingual — English is canonical, Chinese is the mirror, and the website renders each by language. +- **Customizable code-suggestion output** (Settings → Agent → Strategy, both edited in an inline modal): + - _Suggestion spec_ (`code_suggestion_spec`): free-text guidance injected into `/improve`, `/review`, `/ask` to shape each suggestion's structure (e.g. Problem / Analysis / Suggestion) — a soft constraint the model generally follows. + - _Suggestion layout_ (`code_suggestion_layout`): a deterministic markdown template for the review-draft comment, with `` / `<SUGGESTIONS>` / `<HOME>` / `<PR>` / `<MODEL>` placeholders. Empty uses a default layout — a linked "AI suggestion" label + model name above the suggestion body (the default draft-comment prefix thus changes from the previous plain `[AI suggestion]`). + +### 🔧 Fixed + +- Startup now pings only the active connection instead of every configured one: a non-active connection's identity has no UI consumer, so pinging it only added startup network requests and, for an unreachable connection, a recurring warning on each launch. Switching the active connection still refreshes its identity on demand. ## [0.9.0] - 2026-07-02 -> 本版重点: +> Highlights of this release: > -> - **外部集成与 CLI**:开启本机 API 把 PR 浏览与评审 Agent 操作开放给外部集成,并提供跨平台命令行工具 `meebox` +> - **External integration and CLI**: open a local API to expose PR browsing and review-Agent operations to external integrations, plus a cross-platform command-line tool `meebox` -### ✨ 新增 +### ✨ Added -- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 - - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 - - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 - - 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)。 -- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr <id>`;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 -- **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 -- **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 -- **命令回显气泡**:在评审 Agent 面板直接发起 `/review`、`/describe`、`/improve`、`/ask` 等命令时,命令即时以用户气泡回显在其结果卡片之上,贴合对话习惯;编排 / AutoPilot 派发的子任务不回显,避免与编排会话的用户消息重复。 -- **按提交发起评审 Agent**:在 Diff 视图将变更范围切到某个提交后,评审 Agent 面板直接键入的 `/describe`、`/review`、`/improve`、`/ask`(含自然语言提问)会自动限定在该提交自身改动(`parent..sha`)而非 PR 全量;输入栏以范围 chip 标示当前提交,chip 选中态源自视图选择,点击可临时禁用(不删除、切到别的提交自动复位),与 Diff 选区 chip 交互一致。同一时刻仅一个 scope 生效:选中 Diff 行时以选区为准、暂挂提交范围,取消选区后自动还原。运行中与完成的评审结果卡均展示所限定提交的范围徽标。一键自动评审仍作用于 PR 全量。 +- **External integration · local API service**: Settings gains an "Integration" section where you can start a local API service that exposes PR browsing and review-Agent operations as endpoints for external agents / tools / scripts. + - Off by default; enabling it enforces access-token authentication, with one-click token generate / show / copy / regenerate. + - Custom listen address: reachable only from the local machine by default, optionally opened to the LAN (with a security warning when opened). + - Exposes browsing (current identity / PR list / detail / diff / activity / commits / reviewers), the review Agent (status / history / auto-review / instructions / conversation / interrupt), and review write actions (approve / needs-work / comment); does not expose merge or change-type Agent tools (publish, etc.). +- **External integration · command-line tool `meebox`**: a cross-platform Windows / macOS / Linux command-line client shipped with the release that browses PRs, operates the review Agent, and performs review write actions (approve / needswork / comment) through the local API service — convenient for scripts and external agents. The PR list is compact and paginated; PR-scoped commands use `--pr <id>`; connection info must be provided explicitly (flag / environment variable / cli.yaml) and does not read the GUI's main config. +- **Unread dots on PR-list discovery categories**: when a discovery category (To review / Created by me, etc.) has new pending PRs, an unread dot is added after that category's label so you can see at a glance which category has new activity; the dot is always based on active PRs, correctly reflecting active-category unreads even while in the "Closed" view. +- **Notifications for PRs I created**: three new system notifications for your own PRs — a new comment from someone else, a reviewer marking "needs work", and a merge conflict; the Notifications section provides independent toggles, on by default. +- **Command echo bubbles**: when you issue `/review`, `/describe`, `/improve`, `/ask`, etc. directly in the review-Agent panel, the command is instantly echoed as a user bubble above its result card, matching conversational habit; subtasks dispatched by orchestration / AutoPilot are not echoed, avoiding duplication with the orchestration conversation's user messages. +- **Launch the review Agent per commit**: after switching the change scope to a specific commit in the Diff view, `/describe`, `/review`, `/improve`, `/ask` (including natural-language questions) typed directly in the review-Agent panel are automatically scoped to that commit's own changes (`parent..sha`) rather than the whole PR; the input bar marks the current commit with a scope chip, whose selected state derives from the view selection and can be temporarily disabled by clicking (not removed; switching to another commit auto-resets), consistent with the Diff-selection chip interaction. Only one scope is in effect at a time: when Diff lines are selected the selection takes precedence and the commit scope is suspended, restored automatically once the selection is cleared. Both running and completed review-result cards show the scoped commit's range badge. One-click auto-review still applies to the whole PR. -### ♻️ 变更 +### ♻️ Changed -- 评审 Agent 的 `/ask`(本机 agentic CLI 提供方)注入代码检索指引:引导以只读搜索定向查符号 / 只读所需行段替代整文件通读与全仓扫描,在保留读真实文件深度的同时,降低探索轮次与 token 消耗。 -- 「追问数量」上限现同时约束自由对话 Agent(会话即委派)的自动 `/ask`:此前该上限仅作用于评审微流程的条件追问,自由规划循环仅受「Agent 最大步数」约束、可连续多次 `/ask`(每次均为一次成本较高的 agentic 探索);现自由对话中的 `/ask` 亦按「追问数量」封顶(始终生效,与「自动追问」开关无关),避免探索成本失控。 -- 状态栏不再常态显示 pr-agent 版本号(减少常态噪声),仅在其不可用时保留告警提示;版本号改在设置「关于」页的运行环境信息中展示。 -- 「我创建的」分类下的「待处理」筛选并入存在合并冲突的 PR:作者视角下有冲突的 PR 需其跟进解决(即便评审已通过),故一并计入待处理。 -- PR 列表的状态二级筛选改为宽度自适应胶囊:一行容得下即不换行,容纳不下时换行并按每行项数均分撑满整行,消除换行后右侧留白参差。 -- 评审 Agent 派生的临时工作树内部分支不再使用固定的品牌化名称,改为与 PR 关联的 `pr-<localId>/head`、`pr-<localId>/base`(分层命名贴合平台 PR 引用惯例):消除产出中可辨识的固定行为特征,同时保证该内部分支名不会漏入对外发布的评审内容。 +- The review Agent's `/ask` (local agentic-CLI provider) injects code-retrieval guidance: it steers toward read-only searches to locate symbols / read only the needed line ranges instead of reading whole files and scanning the whole repo, lowering exploration rounds and token consumption while preserving the depth of reading real files. +- The "follow-up count" cap now also constrains the free-conversation Agent's (conversation-as-delegation) automatic `/ask`: previously this cap applied only to the review micro-flow's conditional follow-ups, while the free planning loop was bounded only by "Agent max steps" and could `/ask` many times in a row (each a costly agentic exploration); now `/ask` in free conversation is likewise capped by "follow-up count" (always in effect, independent of the "auto follow-up" toggle), preventing runaway exploration cost. +- The status bar no longer routinely shows the pr-agent version (reducing steady-state noise), keeping only the warning when it is unavailable; the version now appears in the runtime-environment info on the Settings "About" page. +- The "pending" filter under the "Created by me" category now includes PRs with merge conflicts: from the author's perspective a conflicted PR needs their follow-up to resolve (even if the review has passed), so it counts as pending. +- The PR-list status sub-filter becomes width-adaptive pills: no wrap when a row fits, wrapping otherwise and distributing items evenly to fill each row, eliminating the ragged right-side gap after wrapping. +- Internal branches in the review Agent's derived temporary worktree no longer use a fixed branded name, switching to PR-associated `pr-<localId>/head`, `pr-<localId>/base` (layered naming matching platform PR-reference conventions): removes an identifiable fixed behavioral signature from the output while ensuring the internal branch name never leaks into externally published review content. -### 🔧 修复 +### 🔧 Fixed -- 修复 PR 列表分组标题背景色、Windows 窗口右上角控制按钮此前不随主题(编辑器配色主题)变化的问题;现二者均跟随当前主题派生配色,深浅 / 主题切换实时生效。 -- 修复点开带「@我 / 回复我」未读计数的 PR 后,标题处的未读计数 chip 不立即消除、需等下一轮轮询才清零的问题:标为已读时同步乐观清零该计数(此前仅清未读圆点,遗漏了计数 chip)。 -- 修复德语等界面下设置页左侧导航的长标签(如「通知」)溢出被裁切的问题;现长标签自动换行完整显示。 +- Fixed PR-list group-header backgrounds and the Windows window's top-right control buttons not following the theme (editor color theme); both now derive their colors from the current theme, with light/dark and theme switches taking effect in real time. +- Fixed the unread-count chip at the title not clearing immediately after opening a PR with an "@me / replied to me" unread count and requiring the next poll to reset: marking as read now optimistically zeroes that count in sync (previously it only cleared the unread dot, missing the count chip). +- Fixed long labels in the Settings left nav (e.g. "Notifications") overflowing and being clipped in German and other locales; long labels now wrap and display in full. ## [0.8.0] - 2026-06-30 -> 本版重点: +> Highlights of this release: > -> - **消息通知**:系统通知(新 PR / 评论回复 / 被 @)+ macOS dock 角标 + 授权引导 -> - **评论体验优化**:emoji 反应、@ 提及补全、图片附件、表情代码渲染——评论 / 回复 / 行内 / 草稿一致 -> - **命令面板**:`Ctrl/Cmd+Shift+P` 归口常用操作 -> - **已关闭 PR 浏览与按 URL 打开**:查看历史 PR、补充评论 / 补跑评审 -> - **评审规则增强**:规则目录递归、多规则按 `Ruleset` 分段注入 -> - **2026 主题配色** 与 **PR 列表点名计数** - -### ✨ 新增 - -- **评论体验优化**:评论列表、回复、行内(代码行)评论与草稿编辑保持一致的撰写与互动体验。 - - **emoji 反应**:评论(含行内评论)下方展示已有 emoji 反应(含计数与本人标记),可点选添加 / 取消;GitLab / Bitbucket 支持搜索挑选更多表情,GitHub 为其固定表情集——随平台能力提供,不支持的平台自动隐藏。 - - **@提及自动补全**:撰写评论 / 回复时输入 `@` 可从本 PR 参与者(评论与提交作者)中选择补全;仍可自由手打任意用户名。 - - **图片附件**:撰写评论 / 回复 / 行内评论 / 草稿时可粘贴图片或点输入框右上角的图片按钮选取上传,自动插入正文(随平台能力提供,不支持的平台隐藏入口)。 - - **表情代码渲染**:评论正文里的 `:tada:` 等表情代码按内置表情集渲染为对应 emoji(代码块与未知代码原样保留)。 -- **消息通知**:新增「通知」设置分区,可开启系统通知并按事件类型分别控制——收到新的 PR、收到评论回复、评论中被 @。 - - Windows / macOS 弹原生系统通知;是否显示遵循操作系统的通知设置,关闭后自动静默。 - - Windows 通知带发起人头像、按 PR / 评论 / 回复区分的类型标记,并显示所在仓库。 - - 仅对「待处理」的 PR 弹通知——已批准 / 已标记需修改的 PR 不再打扰。 - - 点击通知直接定位:新 PR 打开该 PR;内联评论跳到 Diff 对应代码行,顶层评论打开该 PR 的对话标签。 - - 批量动态时最多单独弹 5 条,更多折叠为一条「查看更多最新动态」提示,点击打开主界面查看,避免通知刷屏。 - - macOS dock 图标显示待你回应的评论计数角标。 - - macOS 通知设置提供「打开系统通知设置」按钮,便于在系统层授予通知权限(macOS 不允许应用代为开启)。 -- **PR 列表点名计数**:当未读评论中有 @ 你 / 回复你时,列表项标题前的未读圆点升级为显示条数的计数标记(封顶「10+」),一眼看出有多少条待你回应的评论;仅新分配 / 新提交导致的未读仍显示圆点。 -- **关于页系统信息**:设置「关于」页新增操作系统(平台 + 版本)与 CPU 架构信息,并提供「复制信息」按钮一键复制全部运行环境信息,便于反馈问题时附带环境。 -- **已关闭 PR 浏览与补充评审**:侧栏新增「已关闭」范围切换,查看历史退场(已合并 / 关闭 / 不再需你评审)的 PR;进入时按需加载。 - - 已合并 / 仍开放的 PR 可补充评论、补跑 AI 评审(源分支已删的合并 PR 按提交定位 diff);仅不再提供合并 / 审批等改动操作。 - - 已关闭(declined)的 PR 仅供浏览。 -- **按 URL 打开 PR**:命令面板新增「打开 URL」(快捷键 mac `⌘⇧U` / 其余 `Ctrl+Shift+U`),粘贴 / 输入当前平台的 PR 链接即可打开(自动忽略尾缀),用于查看未正式被请求参与评审的他人 PR,含已合并 / 已关闭的 PR。 - - 已在列表或历史中的 PR 直接定位;新链接会鉴权拉取后存入历史(随相同生命周期到期清理),无权限 / 链接无效即提示。 -- **命令面板**:标题栏新增命令输入框(`Ctrl/Cmd+Shift+P` 唤起),快速执行常用操作并归口分散功能。 - - PR 类命令:查看各发现分类(待我评审 / 我创建 等,随平台能力)、查看已关闭、按 URL 打开、分类筛选(待处理 / 全部 / 冲突 / 可合并 等)、切换 PR 列表。 - - 评审类命令:运行自动评审、切换 AutoPilot、切换对话面板。 - - 设置类命令:切换显示语言 / 主题 / 模型、切换代理、打开设置 / 关于 / DevTools。 - - 命令按领域前缀分类(PR / 评审 / 设置)、两级选择(如「切换主题」展开主题列表);非英语界面附英文名且始终支持中英文检索,匹配处高亮。 - - 打开时默认选中上次用过的命令,回车即重复。 - - 常用操作支持快捷键(开合 PR 列表 / 对话面板、运行自动评审、DevTools),命令面板内显示对应键位。 -- Agent 上下文目录新增 README 说明文件:介绍各文件用途并指向项目主页,便于用第三方编辑器了解与维护该目录。 - -### ♻️ 变更 - -- 默认主题(跟随系统)改用 2026 系列配色:深色 / 浅色分别采用 Dark 2026 / Light 2026,启动闪屏同步对齐;已自定义主题的设置不受影响。 -- 退场(已合并 / 关闭 / 不再需你评审)的 PR 数据迁出活跃目录、独立归档存放,归档保留与到期自动清理策略保持不变。 -- Agent 的 SOUL(人格定义)文件改由应用统一管理:每次加载自动对齐到内置模版、本地改动不再保留,以便随版本统一更新 Agent 行为基线(AGENTS / MEMORY / USER 与规则仍归你所有、可自由改写)。 -- 评审规则增强:规则目录支持子目录递归组织、自动加载全部规则文件;同一 PR 命中的多条规则不再只取首条,而是按 `Ruleset` 分段一并注入评审(按 priority 排序、单次封顶 20 条),命中提示改为显示条数、可预览全部命中规则。 -- PR 列表的状态二级筛选随发现分类细化:「待处理」反映「本人尚未评审」,仅在「待我评审」分类、以及支持「需修改」评审态的平台(GitHub / Bitbucket)的「我创建的」等分类下保留;GitLab(二元审批、无「需修改」)的非「待我评审」分类不再展示无意义的「待处理」。 -- Agent 目录的示例规则改为仅在首次初始化时生成;删除后不再被重新创建,便于清理不需要的示例。 - -### 🔧 修复 - -- 修复将 Agent 上下文目录指向自定义或新位置后该目录未被初始化、加载为空的问题;现在无论经设置或直接修改配置变更,目录在使用前都会自动补齐 SOUL / AGENTS 等上下文模版。 -- 修复运行中的 AI 任务、其 PR 被自动移入「已关闭」后,点击状态栏的执行指示无法定位并打开该 PR 会话的问题;现可正确跳转到「已关闭」分类、选中该 PR 并打开会话。 -- 修复状态栏「待审 PR」计数把「我创建的」等并非待我评审的 PR 也计入的问题;现仅统计需我评审且本人尚未处理的 PR。 -- 修复部分平台(尤以 Bitbucket)上他人回复评论 / 在评论中 @你 时常常不弹通知的问题;现对待处理(待我评审 / 我创建)的 PR 可靠跟踪评论变化,回复与提及均会及时提醒。 -- 修复点击系统通知无法定位到已归档 PR(如运行中任务的 PR 刚被移入「已关闭」)的问题;现可正确跳转到「已关闭」分类、选中该 PR 并按类型打开对应位置。 -- 修复正在查看某 PR 的变更(Diff)时,该 PR 收到新的评论回复,内嵌评论却需切换到其它 PR 再切回才刷新的问题;现在轮询发现回复 / 提及后会即时刷新当前打开的评论。 +> - **Notifications**: system notifications (new PR / comment reply / @mention) + macOS dock badge + permission guidance +> - **Comment experience improvements**: emoji reactions, @-mention completion, image attachments, emoji-code rendering — consistent across comments / replies / inline / drafts +> - **Command palette**: `Ctrl/Cmd+Shift+P` centralizes common actions +> - **Closed-PR browsing and open-by-URL**: view historical PRs, add comments / re-run reviews +> - **Review-rule enhancements**: recursive rule directory, multiple rules injected by `Ruleset` section +> - **2026 theme colors** and **PR-list mention counts** + +### ✨ Added + +- **Comment experience improvements**: the comment list, replies, inline (code-line) comments, and draft editing keep a consistent authoring and interaction experience. + - **Emoji reactions**: existing emoji reactions (with counts and your own marks) are shown beneath comments (including inline comments), clickable to add / remove; GitLab / Bitbucket support searching for more emoji, GitHub offers its fixed set — provided per platform capability, hidden automatically where unsupported. + - **@-mention autocomplete**: typing `@` while writing a comment / reply lets you complete from this PR's participants (comment and commit authors); you can still freely type any username. + - **Image attachments**: while writing a comment / reply / inline comment / draft you can paste an image or click the image button at the top-right of the input to upload, auto-inserting it into the body (provided per platform capability, entry hidden where unsupported). + - **Emoji-code rendering**: emoji codes like `:tada:` in comment bodies render to the corresponding emoji per the built-in set (code blocks and unknown codes are preserved as-is). +- **Notifications**: a new "Notifications" settings section lets you enable system notifications and control them by event type — a new PR received, a comment reply received, being @-mentioned in a comment. + - Windows / macOS pop native system notifications; whether they show respects the OS notification settings, silenced automatically when off. + - Windows notifications carry the initiator's avatar, a type marker distinguishing PR / comment / reply, and the repository shown. + - Notifications pop only for "pending" PRs — approved / needs-work PRs no longer disturb. + - Click a notification to jump straight there: a new PR opens that PR; an inline comment jumps to the corresponding code line in the Diff, a top-level comment opens that PR's conversation tab. + - For batch activity, at most 5 pop individually, more collapse into one "See more recent activity" prompt that opens the main UI on click, avoiding notification floods. + - The macOS dock icon shows a badge with the count of comments awaiting your response. + - macOS notification settings provide an "Open system notification settings" button to grant notification permission at the OS level (macOS does not allow an app to enable it on your behalf). +- **PR-list mention counts**: when unread comments include @-you / replies-to-you, the unread dot before the list-item title upgrades to a count marker showing the number (capped at "10+"), so you can see at a glance how many comments await your response; unread from new assignment / new commits alone still shows a dot. +- **About-page system info**: the Settings "About" page adds the operating system (platform + version) and CPU architecture, plus a "Copy info" button to copy all runtime-environment info at once, handy for attaching your environment when reporting issues. +- **Closed-PR browsing and supplementary review**: a new "Closed" scope switch in the sidebar to view PRs that have exited (merged / closed / no longer need your review); loaded on demand on entry. + - Merged / still-open PRs can take additional comments and re-run AI review (a merged PR whose source branch was deleted locates diffs by commit); only merge / approval and other change actions are withheld. + - Closed (declined) PRs are browse-only. +- **Open PR by URL**: the command palette adds "Open URL" (shortcut mac `⌘⇧U` / otherwise `Ctrl+Shift+U`) — paste / type a PR link for the current platform to open it (trailing suffixes ignored), for viewing others' PRs you were not formally requested to review, including merged / closed PRs. + - A PR already in the list or history is located directly; a new link is authenticated, fetched, and stored in history (cleared on the same lifecycle expiry), prompting on no permission / invalid link. +- **Command palette**: a command input in the title bar (`Ctrl/Cmd+Shift+P`) to quickly run common actions and centralize scattered features. + - PR commands: view each discovery category (To review / Created by me, etc., per platform capability), view Closed, Open by URL, category filters (pending / all / conflict / mergeable, etc.), toggle the PR list. + - Review commands: run auto-review, toggle AutoPilot, toggle the conversation panel. + - Settings commands: switch display language / theme / model, toggle proxy, open Settings / About / DevTools. + - Commands are categorized by domain prefix (PR / Review / Settings), with two-level selection (e.g. "Switch theme" expands the theme list); non-English UIs append the English name and always support Chinese-and-English search, with matches highlighted. + - Opens with the last-used command selected by default; press Enter to repeat. + - Common actions support shortcuts (toggle PR list / conversation panel, run auto-review, DevTools), with the corresponding keys shown in the palette. +- A README file is added to the Agent context directory: it introduces each file's purpose and links to the project home, making the directory easier to understand and maintain in a third-party editor. + +### ♻️ Changed + +- The default theme (follow system) switches to the 2026 series colors: dark / light use Dark 2026 / Light 2026 respectively, with the launch splash aligned; settings with a custom theme are unaffected. +- Exited (merged / closed / no-longer-need-your-review) PR data moves out of the active directory into separate archive storage, with the archive retention and expiry auto-cleanup policy unchanged. +- The Agent's SOUL (persona definition) file is now managed uniformly by the app: each load auto-aligns to the built-in template and local edits are no longer kept, so the Agent's behavior baseline updates uniformly with each version (AGENTS / MEMORY / USER and rules remain yours, freely editable). +- Review-rule enhancements: the rule directory supports recursive sub-directory organization, auto-loading all rule files; multiple rules matched by the same PR are no longer limited to the first — they are injected into the review by `Ruleset` section (sorted by priority, capped at 20 per run), and the match hint now shows a count with all matched rules previewable. +- The PR-list status sub-filter refines per discovery category: "pending" reflects "not yet reviewed by me", kept only in the "To review" category and, on platforms supporting a "needs work" review state (GitHub / Bitbucket), in categories like "Created by me"; GitLab (binary approval, no "needs work") no longer shows the meaningless "pending" in non-"To review" categories. +- The Agent directory's example rules are now generated only on first initialization; once deleted they are not recreated, making it easy to clear unwanted examples. + +### 🔧 Fixed + +- Fixed the Agent context directory not being initialized and loading empty after pointing it to a custom or new location; now, whether changed via settings or by directly editing the config, the directory auto-fills the SOUL / AGENTS and other context templates before use. +- Fixed being unable to locate and open a running AI task's PR conversation from the status-bar execution indicator after its PR was auto-moved to "Closed"; it now correctly jumps to the "Closed" category, selects that PR, and opens the conversation. +- Fixed the status-bar "PRs to review" count including PRs like "Created by me" that are not for me to review; it now counts only PRs that need my review and are not yet handled by me. +- Fixed comment replies / @-mentions from others on some platforms (especially Bitbucket) often not popping a notification; comment changes are now reliably tracked for pending (To review / Created by me) PRs, with replies and mentions promptly alerted. +- Fixed clicking a system notification being unable to locate an archived PR (e.g. a running task's PR just moved to "Closed"); it now correctly jumps to the "Closed" category, selects that PR, and opens the corresponding position by type. +- Fixed inline comments needing a switch to another PR and back to refresh when the PR you are viewing (Diff) receives a new comment reply; the currently open comments now refresh immediately once polling detects a reply / mention. ## [0.7.0] - 2026-06-27 -> 本版重点: +> Highlights of this release: > -> - **深色 / 浅色主题与外观体系**:主题切换、编辑器配色主题、自定义等宽字体与字号 -> - **AI 评审运行参数**:评审任务并发、上下文长度、Agent 策略(自动追问 / 代码建议数量) -> - **PR 列表未读标记** -> - **Agent 对话增强**:选区引用附带显示、思考过程 markdown 排版、`/merge` 直接合并 -> - **本机 agentic CLI 编排提速** -> - **代码平台接入层领域化重构**(行为不变) - -### ✨ 新增 - -- **主题与外观** - - 深色 / 浅色主题切换:可选浅色、深色或跟随系统,界面即时切换、重启保留。 - - 编辑器配色主题:内置多款可选(VS Code 2026 / Modern、高对比,以及 GitHub、Monokai、Dracula、Nord、Solarized 等),可跟随应用主题或单独指定。 - - 编辑器字体与字号:自定义等宽字体(可多个候选)与字号,编辑器及全应用等宽文本(diff / 评论 / 代码块)一并生效。 -- **AI 评审运行参数** - - 评审任务并发:在设置页「AI」分区调整同时执行的评审任务数(1~8),即时生效、无需重启。 - - 上下文长度:在设置页「AI」分区设置裁剪输入内容的上下文长度上限(32k~1M 的习惯档位),让长 PR 完整入 prompt;对本地 CLI 模式不生效。 - - Agent 策略:在设置页「智能体」分区新增策略组——「自动追问」(关闭后评审直接总结、不再条件追问,省 token)与「代码建议数量」(统一约束 /review·/improve·/ask 单次生成的代码建议数量,2~8)。 -- **PR 列表** - - 未读标记:PR 新进入待审列表(新分配 / 请求你评审),或自上次查看后有新 commit 推送、有人 @ 你 / 回复你,列表项会标一个未读圆点;打开 PR 即清除。 -- **Agent 对话** - - 提问引用附带显示:带 Diff 选区代码提问时,引用的代码在消息气泡下方折叠展示(评论建议引用沿用复评卡片上的定位徽标)。 - - 思考过程支持预格式化排版:思考 / 判读内容按 markdown 渲染(代码块 / 列表 / 换行)。 - - 新增 `/merge` 指令:满足合并条件的 PR 可在对话中直接合并,触发前弹二次确认。 - -### ♻️ 变更 - -- 本机 agentic CLI 模式下 Agent 编排的自有步骤(路由 / 追问判读 / 评审总结)响应更快:每步不再加载用不到的 API 调用栈,降低每次响应的固定启动延迟,对话与自动评审整体更跟手。 -- 配置面板改为左右分区布局:左侧分区导航(常规 / 连接 / AI / 关于)、右侧按分区归类展示配置项,替代此前单列平铺;分区结构为后续扩展(主题、编辑器风格、上下文窗口等)预留。 -- **代码平台接入层基于领域设计重构**(行为不变):为后续平台接入与维护打基础。 - - 按连接、PR 操作、评论、用户与媒体四个领域拆分为独立服务,职责更清晰、便于按领域独立维护与测试。 - - 各平台的连接与代理配置统一收口到连接层,新增代码平台可按领域分步接入。 - - 平台连接相关的状态提示(如版本不支持原因)改为按界面语言本地化。 - -### 🔧 修复 - -- 并排 diff 在窗口较窄自动降为统一布局时,滚动条总览标尺的删除标记丢失、只剩新增的绿色;现按实际布局正确区分红/绿。 -- 窗口尺寸与最大化状态现可跨重启记住(此前调整尺寸或最大化后关窗常丢失)。 -- 高分屏开启缩放时,默认窗口尺寸可能超出屏幕范围;现按当前显示器可用区域自适应并居中显示。 -- 本地状态目录下偶发残留的临时文件会随每次运行持续累积;现于启动时自动清理。 +> - **Dark / light themes and appearance system**: theme switching, editor color themes, custom monospace font and size +> - **AI review runtime parameters**: review-task concurrency, context length, Agent strategy (auto follow-up / code-suggestion count) +> - **PR-list unread markers** +> - **Agent conversation enhancements**: attached display of selection references, markdown-formatted thinking, `/merge` for direct merge +> - **Faster local agentic-CLI orchestration** +> - **Domain-oriented refactor of the code-platform integration layer** (behavior unchanged) + +### ✨ Added + +- **Themes and appearance** + - Dark / light theme switching: choose light, dark, or follow system, with the UI switching instantly and persisting across restarts. + - Editor color themes: several built-in options (VS Code 2026 / Modern, high-contrast, plus GitHub, Monokai, Dracula, Nord, Solarized, etc.), following the app theme or set separately. + - Editor font and size: custom monospace font (multiple candidates) and size, applied to the editor and all app monospace text (diff / comments / code blocks) alike. +- **AI review runtime parameters** + - Review-task concurrency: adjust the number of simultaneous review tasks (1–8) in the Settings "AI" section, effective immediately, no restart. + - Context length: set the context-length cap for trimming input content (habitual tiers from 32k–1M) in the Settings "AI" section, so long PRs fit fully into the prompt; not in effect for local CLI mode. + - Agent strategy: a new strategy group in the Settings "Agent" section — "auto follow-up" (when off, the review summarizes directly with no conditional follow-up, saving tokens) and "code-suggestion count" (uniformly constrains the number of code suggestions generated per `/review`·`/improve`·`/ask`, 2–8). +- **PR list** + - Unread markers: a PR newly entering the review list (newly assigned / your review requested), or with new commits pushed since you last viewed it, or someone @-ing you / replying to you, gets an unread dot on the list item; opening the PR clears it. +- **Agent conversation** + - Attached display of question references: when asking about code with a Diff selection, the referenced code is shown collapsed beneath the message bubble (comment-suggestion references reuse the locating badge on the re-review card). + - Preformatted thinking: thinking / judgment content renders as markdown (code blocks / lists / line breaks). + - New `/merge` command: a PR meeting the merge conditions can be merged directly in the conversation, with a confirmation prompt before triggering. + +### ♻️ Changed + +- The Agent's own orchestration steps (routing / follow-up judgment / review summary) respond faster in local agentic-CLI mode: each step no longer loads the unused API call stack, lowering the fixed startup latency per response, making conversation and auto-review more responsive overall. +- The settings panel switches to a left-right sectioned layout: section nav on the left (General / Connection / AI / About), config items grouped by section on the right, replacing the previous single-column layout; the section structure reserves room for later expansion (theme, editor style, context window, etc.). +- **Domain-driven refactor of the code-platform integration layer** (behavior unchanged): laying the groundwork for later platform integration and maintenance. + - Split into independent services by the four domains of connection, PR operations, comments, and users & media, with clearer responsibilities, easier to maintain and test per domain. + - Each platform's connection and proxy config is unified into the connection layer, and a new code platform can be integrated step by step per domain. + - Platform-connection status hints (e.g. version-unsupported reasons) are now localized to the UI language. + +### 🔧 Fixed + +- When a side-by-side diff auto-degrades to a unified layout in a narrow window, the scrollbar-overview ruler's deletion marks were lost, leaving only additions in green; it now correctly distinguishes red/green per the actual layout. +- Window size and maximized state are now remembered across restarts (previously often lost after resizing or maximizing and closing). +- With scaling enabled on a high-DPI display, the default window size could exceed the screen; it now adapts to the current display's available area and centers. +- Temporary files occasionally left in the local state directory would keep accumulating each run; they are now cleaned up automatically at startup. ## [0.6.0] - 2026-06-23 -> 本版重点: +> Highlights of this release: > -> - **`/ask` 复评闭环**:对评审建议发起复评、自动取代 / 关闭原评论 -> - **`/ask` 结构化分段输出**与**完整文件上下文** -> - **Agent 会话中途输入与「计划」面板** -> - **Diff 体验增强**:选区引用提问、按变更范围 / 单 commit 查看、冲突文件标注、删除行评论、滚动条总览标尺 -> - **PR「活动」时间线** -> - 前后端基于领域设计的重大重构(行为不变)与 Agent 编排提速 - -### ✨ 新增 - -- **Agent 评审与对话** - - `/ask` 复评闭环:对 `/review`、`/improve` 的代码评论建议(finding)发起「复评」,按裁决(取代 / 保留 / 撤销)自动取代或关闭原评论;自动评审微流程亦可由 judge 触发复评。 - - `/ask` 结构化分段输出:自由问答按「结论 / 分析解读 / 建议」三段着色呈现,针对代码的建议可定位行号、采纳为行内评论。 - - CLI 模式 `/ask` 取完整文件上下文:本机 CLI 接管时可读取仓库完整文件作答,读取前清空仓库自带 agent 指令文件以防注入污染。 - - 会话「中途输入」与「计划」面板:运行期间再输入消息即时入队并重排后续行动;规划 Agent 维护可视的 todo 计划,随会话持久化、切 PR / 重启自动恢复。 - - run 卡片展示「模型实际交互规模」:呈现提示缓存命中量与模型交互轮次,避免本机 CLI 多轮累加的 token 用量被误读为超限。 -- **Diff 阅览** - - 选中代码引用进提问:选中若干行后作为隐式上下文随提问注入模型、可一键忽略,删除行与未改动行同样可引用。 - - 按「变更范围」查看:可切换查看全部变更或某个 commit 的变更,点击 commit 本地渲染只读 diff、不再跳浏览器。 - - 文件树标注合并冲突文件:有冲突的 PR 对会冲突的文件标琥珀色三角警示图标,无需逐文件试合并即可定位。 - - 给「删除行」新增行内评论 / 草稿:并排视图下 base 侧(删除 / 上下文行)也可 hover「+」创建。 - - 滚动条总览标尺:把增 / 改 / 删与「有评论的行」投影到滚动条旁,拖动即可快速定位。 -- **PR 详情与协作** - - 「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):评论、提交更新、评审决断归并为一条时间线,并可直接发不锚定文件的 summary 评论;GitLab 保持纯评论视图。 - - PR 头部展示 reviewer 头像栈:按评审状态排序展示评审者头像、带决断角标,超出折叠为「+n」下拉。 - - 详情标签页国际化与左右布局:整面板按界面语言出文案,改为左描述 / 右时间线 + 评审者列表、窄宽响应式堆叠。 -- 连接 / LLM 配置模态退出拦截:有未提交改动时关闭弹确认框,避免误丢未保存内容。 - -### ♻️ 变更 - -- **前后端基于领域设计的重大重构**(可维护性,行为不变):按领域边界重组前后端代码,划清模块职责与依赖方向。 - - 前端:组件按 `common`(基础 UI)/ `layout`(应用骨架)/ `features`(业务领域)分层,业务逻辑下沉所属领域,超大组件(ChatPane / SettingsModal / DiffView 等)拆为「容器 + 领域组件 + hooks」。 - - 后端:抽出 IPC 服务层、按领域分组 Agent 服务、解耦运行队列;Agent 引擎抽出可插拔「步骤」抽象统一记步与用量累计,编排提示词外置为资源文件。 - - 对外接口、界面与交互行为均不变。 -- **Agent 编排响应提速**(对用户行为不变):条件追问并行派发、追问判读瘦身为轻量路由、编排链路统一低推理 + 判读输出封顶,并把全局稳定系统前缀接入 Anthropic 1h 提示缓存,整体延迟与成本下降。 -- 复评 `/ask` 取代 / 撤销改为静默自动关闭原 finding,「取代」裁决把建议提升为可采纳的代码反馈卡,前端仅只读展示关闭态与「查看复评」导航。 -- agent「评审总结」聚焦 PR 整体结论:只吃每条追问的结论而非完整答案明细,输出 PR 级整体结论、不复制明细。 -- PR 提交列表 / 活动时间线按 first-parent 过滤合入的他人提交,只保留本 PR 自产提交;镜像未就位时回退不丢信息。 -- 评审 / Diff 界面交互打磨(一批小优化):评审总结卡与 finding 卡统一样式行距、可折叠卡整行标题即展开并带过渡动画、点击复评引用徽标定位并高亮原卡、危险按钮统一为高饱和红、设置模态复用首启向导左右布局,及移除「已达并发上限」横幅、隐藏 `/review`「评估工作量」段等。 - -### 🔧 修复 - -- 源分支 merge 目标分支后,变更页 diff 混入目标分支的已有改动。(#107,感谢 @csj2000) -- `/ask` 的结构化分段 / 引用上下文 / 复评裁决指令此前对模型不生效。 -- 复评「取代」裁决的改进建议改为可直接发布的替代评论,不再是关于评论的元讨论。 -- 失败 / 取消的任务不再产出无意义的 finding 卡。 -- CLI 模式 `/ask` 在仓库自带 agent 指令文件被版本管理时整体失败。 -- 本地镜像缺 PR head sha(源分支被删 / 强推)导致 diff / 评审失败且不自愈。 -- 消除 PR 切换 / 刷新 / 标签页切换时的多处渲染抖动与闪烁。 -- 消除 Monaco 控制台 `Missing requestHandler` 噪音报错。 -- 评审总结偶发被截断 / 回落「无法解析建议」。 -- 拉取变更文件列表偶发失败(`ENOENT … diff-base.json`)。 -- 合并已合并 / 已关闭的 PR 时报错不友好。 -- 补齐 PR 评审状态 chip、Agent 步骤行等写死文案的国际化。 -- 设置页手动「检查更新」结果即时同步到状态栏。 -- PR 详情 / 评论页正文限宽居中、reviewers 列表排序稳定。 +> - **`/ask` re-review loop**: launch a re-review of review suggestions, auto-superseding / closing the original comment +> - **`/ask` structured sectioned output** and **full-file context** +> - **Mid-run Agent input and the "Plan" panel** +> - **Diff experience enhancements**: selection-reference questions, view by change scope / single commit, conflict-file annotation, deleted-line comments, scrollbar-overview ruler +> - **PR "Activity" timeline** +> - Major domain-driven front/back-end refactor (behavior unchanged) and faster Agent orchestration + +### ✨ Added + +- **Agent review and conversation** + - `/ask` re-review loop: launch a "re-review" of `/review`, `/improve` code-comment suggestions (findings), automatically superseding or closing the original comment per the verdict (supersede / keep / withdraw); the auto-review micro-flow can also trigger a re-review from the judge. + - `/ask` structured sectioned output: free Q&A is presented color-coded in three sections — conclusion / analysis / suggestions, with code-targeted suggestions locatable by line number and adoptable as inline comments. + - CLI-mode `/ask` takes full-file context: when the local CLI takes over it can read complete repo files to answer, clearing the repo's own agent-instruction files before reading to prevent injection contamination. + - "Mid-run input" and the "Plan" panel: typing a message during a run queues it instantly and re-orders subsequent actions; the planning Agent maintains a visible todo plan, persisted with the conversation and auto-restored on PR switch / restart. + - Run cards show the "actual model interaction scale": presenting prompt-cache hits and model interaction rounds, so multi-round token usage accumulated by the local CLI is not misread as over-limit. +- **Diff reading** + - Reference selected code in a question: after selecting some lines, they are injected into the model as implicit context with the question and can be ignored with one click; deleted lines and unchanged lines can be referenced too. + - View by "change scope": switch between viewing all changes or a specific commit's changes, clicking a commit renders a read-only diff locally instead of jumping to the browser. + - File tree annotates conflicting files: a conflicted PR marks the conflicting files with an amber triangle warning icon, locating them without trying to merge file by file. + - New inline comments / drafts on "deleted lines": in the side-by-side view the base side (deleted / context lines) can also hover "+" to create. + - Scrollbar-overview ruler: projects add / change / delete and "lines with comments" beside the scrollbar, drag to locate quickly. +- **PR detail and collaboration** + - The "Comments" tab evolves into an "Activity" timeline (GitHub / Bitbucket): comments, commit updates, and review decisions merge into one timeline, and you can post a summary comment not anchored to a file directly; GitLab keeps a pure comment view. + - The PR header shows a reviewer avatar stack: reviewer avatars sorted by review status, with a decision corner badge, overflow collapsed into a "+n" dropdown. + - Detail-tab internationalization and left-right layout: the whole panel renders text per UI language, changed to description on the left / timeline + reviewer list on the right, responsively stacked when narrow. +- Connection / LLM config modal exit interception: closing with uncommitted changes pops a confirmation, avoiding accidental loss of unsaved content. + +### ♻️ Changed + +- **Major domain-driven front/back-end refactor** (maintainability, behavior unchanged): reorganizes front/back-end code by domain boundaries, clarifying module responsibilities and dependency direction. + - Front end: components layered by `common` (base UI) / `layout` (app skeleton) / `features` (business domains), with business logic pushed down to its domain and oversized components (ChatPane / SettingsModal / DiffView, etc.) split into "container + domain components + hooks". + - Back end: extracts an IPC service layer, groups Agent services by domain, decouples the run queue; the Agent engine extracts a pluggable "step" abstraction to unify step recording and usage accumulation, with orchestration prompts externalized to resource files. + - External interfaces, UI, and interaction behavior are all unchanged. +- **Faster Agent orchestration response** (behavior unchanged to users): conditional follow-ups are dispatched in parallel, follow-up judgment is slimmed to a lightweight route, the orchestration chain uniformly uses low reasoning + capped judgment output, and the global stable system prefix is wired into the Anthropic 1h prompt cache, lowering overall latency and cost. +- Re-review `/ask` supersede / withdraw now silently auto-closes the original finding, the "supersede" verdict promotes the suggestion into an adoptable code-feedback card, and the front end only shows the closed state read-only with a "view re-review" navigation. +- The Agent's "review summary" focuses on the PR's overall conclusion: it consumes only each follow-up's conclusion rather than the full answer detail, outputting a PR-level overall conclusion without duplicating detail. +- The PR commit list / activity timeline filters merged-in others' commits by first-parent, keeping only this PR's own commits; falls back without losing info when the mirror is not ready. +- Review / Diff UI interaction polish (a batch of small improvements): the review-summary and finding cards share styling and line spacing, collapsible cards expand from the whole title row with a transition animation, clicking a re-review reference badge locates and highlights the original card, danger buttons are unified to a saturated red, the settings modal reuses the first-launch wizard's left-right layout, and the "concurrency limit reached" banner is removed, the `/review` "estimate effort" section is hidden, etc. + +### 🔧 Fixed + +- After the source branch merges the target branch, the changes-page diff mixed in the target branch's existing changes. (#107, thanks @csj2000) +- `/ask`'s structured sectioning / reference context / re-review verdict instructions previously had no effect on the model. +- The re-review "supersede" verdict's improvement suggestion becomes a directly publishable alternative comment, no longer a meta-discussion about the comment. +- Failed / canceled tasks no longer produce meaningless finding cards. +- CLI-mode `/ask` failed entirely when the repo's own agent-instruction files were under version control. +- A local mirror missing the PR head sha (source branch deleted / force-pushed) caused diff / review failure without self-healing. +- Eliminated multiple render jitters and flickers on PR switch / refresh / tab switch. +- Eliminated the Monaco console `Missing requestHandler` noise error. +- The review summary was occasionally truncated / fell back to "unable to parse suggestions". +- Fetching the changed-file list occasionally failed (`ENOENT … diff-base.json`). +- Merging an already-merged / closed PR gave an unfriendly error. +- Filled in the internationalization of hardcoded text like the PR review-status chip and Agent step rows. +- The Settings-page manual "Check for updates" result now syncs to the status bar immediately. +- The PR detail / comments-page body is width-constrained and centered, and the reviewers list sorts stably. ## [0.5.0] - 2026-06-17 -> 本版重点: +> Highlights of this release: > -> - 可委派的**高阶 Agent**(会话 Agent 化 + AutoPilot 后台预评审) -> - **无边框窗口 + 自绘标题栏** -> - 重型组件加载抖动、评论嵌套展示等体验打磨 - -### ✨ 新增 - -- **高阶 Agent(会话 Agent 化 + AutoPilot 预评审)**:在 PR 评审中引入可委派的智能体,随 LLM 配置自动可用、无需单独启用开关。 - - 一键自动评审:对当前 PR 跑「描述 → 评审 →(仅严重问题)追问 → 总结」微流程,给出非约束性建议(建议通过 / 修改 / 人工复核)并汇总为「评审总结」卡片。 - - 对话即委派:聊天框输入自然语言,规划 Agent 按需调用只读工具完成请求,与 PR 无关的请求礼貌拒绝、运行中可随时停止。 - - AutoPilot 后台预评审:对「待我评审」且「待处理」的新 PR 自动预评审,建议落入列表徽标、总结落入会话;写操作经逐项授权 + 红线校验把关(默认仅开放只读工具)。 - - 评审状态可视化:PR 列表项展示蓝色「执行中」旋转指示或评审建议 ★(覆盖纯思考阶段),AutoPilot 触发的评审打机器人标记。 - - 并行多问:规划 Agent 可在一轮内并行派发多个 `/ask`。 - - 评审步骤 token 用量可见:每个推理步右侧分步展示本步 token 用量(不累计)。 - - Agent 上下文目录:以 SOUL / AGENTS / MEMORY / USER 与 rules/ 构成 Agent 的人格与知识来源,默认落 `~/.code-meeseeks/agent`、首启幂等补齐模版。 -- **无边框窗口 + 自绘标题栏**(VS Code 风):去掉系统原生标题栏、渲染层自绘 36px 标题栏、深色主题贯通到底,窗控按钮交由系统绘制保留原生行为,标题栏展示品牌名与当前 PR 标题。 -- 设置页新增「关于 & 反馈」入口:GitHub 仓库 / 提交 Issue / Releases 三个外链。 - -### ♻️ 变更 - -- **重型组件加载抖动收敛**:切换 PR / 文件时 diff(Monaco)、会话内容等重型区域统一盖延迟 loading、就绪后一次性 reveal,缓存命中的快切换零闪。 -- 移除独立 `ollama` provider,统一经 `openai-compatible` 接入本地 Ollama(自带兼容端点、更标准),旧配置自动迁移;`openai-compatible` 标记为已验证。 -- 评论嵌套展示统一(评论 tab + 行内):回复满 5 层拉平为同层级、嵌套改「左竖线缩进」扁平样式。 -- describe「文件变更」分类默认折叠,避免输出过长。 -- 评审总结不再硬截断:`summary_max_chars` 仅作软约束,已生成内容完整保留。 -- 一批 UI 细节:评审建议星标改为四角 sparkle ✦、统一 PR 列表状态 chip 带高消除行高漂移、`/ask` 问题末尾追加语言要求改善按界面语言作答。 - -### 🔧 修复 - -- 修复 PR diff 基准随目标分支漂移导致的「修改被撤回」误判。 -- 修复 Windows 控制台中文日志乱码。 -- 修复 finding 锚点解析在文件路径含方括号(如 `a/[m-123]/x.ts`)时出错。 -- 修复 Anthropic provider 的自建 / 中转 base_url 此前不生效。(#65,感谢 @dnvyrn) -- 本地镜像 clone/fetch 被打断留下的残缺镜像现可自动重建自愈。 -- 清空 PR 执行历史时一并清掉列表评审建议 ★,自动评审完成后 ★ 即时更新。 -- PR「提交」数角标排除源分支合入目标分支带来的提交与 merge 提交。 -- 补 walkthrough 文件分类标题(Miscellaneous / Formatting / Dependencies)的中 / 日 / 德译文。 -- 消除评论页 poll / 刷新触发的渲染抖动。 +> - Delegable **high-level Agent** (conversation-as-Agent + AutoPilot background pre-review) +> - **Frameless window + custom title bar** +> - Polish of heavy-component load jitter, nested comment display, etc. + +### ✨ Added + +- **High-level Agent (conversation-as-Agent + AutoPilot pre-review)**: a delegable agent introduced into PR review, automatically available with the LLM config, no separate enable toggle. + - One-click auto-review: runs a "describe → review → (serious issues only) follow-up → summarize" micro-flow for the current PR, giving non-binding advice (suggest approve / request changes / manual review) summarized into a "review summary" card. + - Conversation-as-delegation: type natural language in the chat box, and the planning Agent calls read-only tools as needed to fulfill the request, politely declining requests unrelated to the PR and stoppable anytime while running. + - AutoPilot background pre-review: automatically pre-reviews new PRs that are "to review" and "pending", with advice landing in the list badge and the summary landing in the conversation; write operations are gated by per-item authorization + red-line checks (only read-only tools open by default). + - Review-status visualization: PR-list items show a blue "running" spinner or a review-advice ★ (covering the pure-thinking phase), and AutoPilot-triggered reviews are marked with a robot icon. + - Parallel multi-question: the planning Agent can dispatch multiple `/ask` in parallel within one round. + - Review-step token usage visible: each reasoning step shows that step's token usage on the right (not cumulative). + - Agent context directory: SOUL / AGENTS / MEMORY / USER and rules/ constitute the Agent's persona and knowledge source, landing by default in `~/.code-meeseeks/agent`, idempotently filled with templates on first launch. +- **Frameless window + custom title bar** (VS Code style): removes the native system title bar, renders a 36px title bar in the render layer, carries the dark theme all the way through, hands window-control buttons to the system to keep native behavior, and shows the brand name and current PR title in the title bar. +- The Settings page adds an "About & feedback" entry: three external links — GitHub repo / submit Issue / Releases. + +### ♻️ Changed + +- **Heavy-component load-jitter convergence**: when switching PRs / files, heavy areas like the diff (Monaco) and conversation content uniformly cover with a delayed loading state and reveal all at once when ready, with zero flicker on cache-hit fast switches. +- Removed the standalone `ollama` provider, unifying local Ollama via `openai-compatible` (with its own compatible endpoint, more standard); old config migrates automatically; `openai-compatible` is marked verified. +- Unified nested comment display (comment tab + inline): replies flatten to the same level at 5 deep, with nesting changed to a flat "left vertical line indent" style. +- describe's "file changes" category is collapsed by default, avoiding overly long output. +- The review summary no longer hard-truncates: `summary_max_chars` is only a soft constraint, generated content is preserved in full. +- A batch of UI details: the review-advice star changes to a four-point sparkle ✦, the unified PR-list status chip has high line height to eliminate drift, and appending a language requirement to the end of the `/ask` question improves answering in the UI language. + +### 🔧 Fixed + +- Fixed the "changes reverted" misjudgment caused by the PR diff base drifting with the target branch. +- Fixed garbled Chinese logs in the Windows console. +- Fixed a finding-anchor parse error when the file path contains square brackets (e.g. `a/[m-123]/x.ts`). +- Fixed the Anthropic provider's self-built / relay base_url previously having no effect. (#65, thanks @dnvyrn) +- A broken mirror left by an interrupted local-mirror clone/fetch now self-heals by auto-rebuilding. +- Clearing a PR's execution history now also clears the list review-advice ★, and the ★ updates immediately after auto-review completes. +- The PR "commits" count badge excludes commits brought in by merging the target branch into the source branch and merge commits. +- Added the Chinese / Japanese / German translations for the walkthrough file-category headings (Miscellaneous / Formatting / Dependencies). +- Eliminated render jitter triggered by comment-page poll / refresh. ## [0.4.0] - 2026-06-14 -> 本版重点: +> Highlights of this release: > -> - **接入 GitLab**(gitlab.com + Self-Managed,CE / EE) -> - 评审交互与渲染打磨(拒绝折叠、草稿锚点对齐、评论内嵌附件图片、GitHub / GitLab 评论编辑删除) -> - **连接 Base URL 放宽** -> - **Windows 升级安装健壮性**(per-machine 提权 + 绕过旧卸载器) +> - **GitLab integration** (gitlab.com + Self-Managed, CE / EE) +> - Review-interaction and rendering polish (decline collapse, draft-anchor alignment, in-comment attachment images, GitHub / GitLab comment edit & delete) +> - **Relaxed connection Base URL** +> - **Windows upgrade-install robustness** (per-machine elevation + bypassing the old uninstaller) > -> ⚠️ **Windows 安装说明**:本版为 **per-machine 安装**(所有用户 / Program Files),安装器双击即弹 UAC 提权运行;安装后的应用以普通权限启动。从旧版升级会自动清理旧安装,无需手动卸载。 +> ⚠️ **Windows install note**: this release is a **per-machine install** (all users / Program Files); the installer pops UAC elevation on double-click, and the installed app launches with normal privileges. Upgrading from an old version auto-cleans the old install, no manual uninstall needed. -### ✨ 新增 +### ✨ Added -- **GitLab 接入**(gitlab.com + Self-Managed,CE / EE,REST API v4):MR 发现、diff 评论读 / 发 / 改 / 删 / 回复、合并、clone(PAT / SSH)、头像 / 附件代理;设置页与首启向导可新增 GitLab 连接(Base URL 可留空默认 gitlab.com)。 - - CE / EE 审批降级:经 `/metadata` 探测 edition——EE 支持通过 / 撤销,CE 无 API 审批、UI 灰显(GitLab 审批二元、无「需修改」)。 +- **GitLab integration** (gitlab.com + Self-Managed, CE / EE, REST API v4): MR discovery, diff-comment read / post / edit / delete / reply, merge, clone (PAT / SSH), avatar / attachment proxy; the Settings page and first-launch wizard can add a GitLab connection (Base URL can be left empty to default to gitlab.com). + - CE / EE approval degradation: detects the edition via `/metadata` — EE supports approve / revoke, CE has no API approval and grays it out in the UI (GitLab approval is binary, no "needs work"). -### ♻️ 变更 +### ♻️ Changed -- **连接 Base URL 放宽**:GitHub Enterprise / GitLab Self-Managed 可直接填实例地址(如 `https://ghe.example.com`),`/api/v3`、`/api/v4` 自动补全;github.com / gitlab.com 留空即用默认。 -- 拒绝代码反馈 / 改进建议后卡片自动折叠置灰、仅保留头部与锚点行(含撤销入口),降低已决断项视觉占用。 -- 本地 CLI 类 LLM provider 标注「实验性」:提示其依赖上游 CLI(claude / codex 等)、稳定性不作保证。 -- 设置页连接 / LLM 预设卡片显示对应品牌类型图标避免误配;危险按钮实底改为饱和红提高警示力;Windows 安装页不再展开空白的文件日志列表、仅留进度条。 +- **Relaxed connection Base URL**: GitHub Enterprise / GitLab Self-Managed can fill the instance address directly (e.g. `https://ghe.example.com`), with `/api/v3`, `/api/v4` auto-completed; github.com / gitlab.com use the default when left empty. +- After declining a code feedback / improvement suggestion, the card auto-collapses to gray, keeping only the header and anchor row (with an undo entry), reducing the visual footprint of decided items. +- Local CLI-type LLM providers are marked "experimental": noting their dependence on an upstream CLI (claude / codex, etc.) with no stability guarantee. +- The Settings-page connection / LLM preset cards show the corresponding brand-type icon to avoid misconfiguration; danger buttons change to solid saturated red for a stronger warning; the Windows install page no longer expands the blank file-log list, leaving only the progress bar. -### 🔧 修复 +### 🔧 Fixed -- 修复 GitHub / GitLab 无法编辑 / 删除自己的评论。 -- 修复 Bitbucket 评论内嵌附件图片不渲染。 -- 修复代码建议草稿区的锚定行与最终发布落点不一致。 -- 评论内嵌图片代理失败时降级为「浏览器打开」链接,不再显示破图标。 -- 修复 Windows 升级安装卡死 /「无法关闭」。 +- Fixed being unable to edit / delete your own comments on GitHub / GitLab. +- Fixed Bitbucket in-comment attachment images not rendering. +- Fixed the anchored line in the code-suggestion draft area not matching the final published location. +- In-comment image proxy failure now degrades to an "open in browser" link instead of showing a broken icon. +- Fixed Windows upgrade-install hanging / "cannot close". ## [0.3.1] - 2026-06-11 -### 🔧 修复 +### 🔧 Fixed -- 修复 macOS 分发版「本地 CLI」provider(claude / codex)经 Finder / Dock 启动时因 PATH 不全而失效。(#21) +- Fixed the macOS distribution's "local CLI" providers (claude / codex) failing due to an incomplete PATH when launched via Finder / Dock. (#21) ## [0.3.0] - 2026-06-11 -> 本版重点: +> Highlights of this release: > -> - **界面国际化**(四语 + 即时切换) -> - **Mermaid 架构图渲染** -> - **版本更新检测** -> - `/improve` 与 `/describe` 思路建议段等 pr-agent 能力扩展 -> - 修复首启同步、子进程树清理与安装 / 升级健壮性 +> - **UI internationalization** (four languages + instant switching) +> - **Mermaid architecture-diagram rendering** +> - **Version-update detection** +> - pr-agent capability extensions such as `/improve` and the `/describe` approach-suggestion section +> - Fixes for first-launch sync, child-process-tree cleanup, and install / upgrade robustness > -> ⚠️ **Windows 用户升级注意**:若已安装**早期版本**(含 `0.3.0-alpha.1` 及更早),升级到本版前请**先手动卸载旧版**(设置 → 应用 → Code Meeseeks → 卸载,或安装目录下的 `Uninstall Code Meeseeks.exe`),完成后再运行新安装器;否则覆盖安装可能长时间卡住或弹出「Code Meeseeks 无法关闭」。原因:早期版本运行时会在安装目录写入上万个 Python 字节码(`.pyc`)缓存文件,使覆盖升级时「卸载旧版」一步需逐个删除海量小文件、极慢甚至卡死。本版起运行时不再写入这些缓存,**之后的升级可正常覆盖、无需手动卸载**。 +> ⚠️ **Windows upgrade note**: if an **earlier version** is installed (including `0.3.0-alpha.1` and earlier), **manually uninstall the old version first** before upgrading to this one (Settings → Apps → Code Meeseeks → Uninstall, or `Uninstall Code Meeseeks.exe` in the install directory), then run the new installer; otherwise the overwrite install may hang for a long time or pop "Code Meeseeks cannot be closed". Reason: at runtime, earlier versions wrote tens of thousands of Python bytecode (`.pyc`) cache files into the install directory, making the "uninstall old version" step of an overwrite upgrade delete a huge number of small files one by one — extremely slow, even hanging. From this release runtime no longer writes these caches, so **subsequent upgrades overwrite normally with no manual uninstall needed**. -### ✨ 新增 +### ✨ Added -- **多语言界面(i18n)**:接入 react-i18next,全部 GUI 文本与主进程面向用户文案覆盖**简体中文 / English / 日本語 / Deutsch** 四语;pr-agent 输出模板渲染期翻译同步语言感知。 - - 语言选择:设置页与首启向导下拉选择、即时生效,AI 回复语言随之(下次运行起)。 - - 语言解析:`config.language` 为空时按操作系统偏好语言匹配,默认 / 兜底为 en-US。 - - 按需懒加载:默认语言静态进入口,其余语言切换时才拉取(`ja-JP` / `de-DE` 为机器初稿)。 -- **Mermaid 架构图渲染**:markdown 中的 `mermaid` 代码块渲染为图形,覆盖 PR 描述 / 评论 / chat 评审输出,点击进入模态预览(缩放 / 平移 / 适应窗口),渲染失败回退原始代码块。 -- **版本更新检测**:启动时及设置页查 GitHub Releases 最新稳定版比对,有新版在状态栏提示并可点击前往下载(仅检测、不自动安装),走配置的出站代理、可关闭。 -- **启用 `/improve` 指令**:逐行代码改进建议(带 1-10 重要度评分),输出落独立 `improve.md` 与 `/review` 分流。 -- **/describe 架构图与思路建议段**:统一启用 GFM 使社区版 `/describe` 选择性输出 mermaid 架构图;并注入「思路建议」段——2-4 个替代实现方案(各自折叠)+ 倾向性推荐。 -- describe 排版优化:架构图、文件变更各自独立成段配色块标题,文件变更按分类折叠。 -- **清空执行历史**:chat 面板标题栏新增垃圾桶按钮,清空当前 PR 的执行历史。 +- **Multilingual UI (i18n)**: integrates react-i18next, covering all GUI text and main-process user-facing text in **Simplified Chinese / English / 日本語 / Deutsch**; pr-agent output-template rendering is language-aware at render time. + - Language selection: dropdown selection in the Settings page and first-launch wizard, effective immediately, with the AI reply language following (from the next run). + - Language resolution: when `config.language` is empty, matches the OS preferred language, defaulting / falling back to en-US. + - On-demand lazy loading: the default language enters statically, others are fetched only on switch (`ja-JP` / `de-DE` are machine first drafts). +- **Mermaid architecture-diagram rendering**: `mermaid` code blocks in markdown render to diagrams, covering PR descriptions / comments / chat review output, clickable into a modal preview (zoom / pan / fit to window), falling back to the raw code block on render failure. +- **Version-update detection**: at startup and on the Settings page, queries the latest stable release on GitHub Releases for comparison, prompting in the status bar with a click to go download when there's a new version (detection only, no auto-install), going through the configured outbound proxy, and can be turned off. +- **Enable the `/improve` command**: line-by-line code-improvement suggestions (with a 1–10 importance score), output landing in a separate `improve.md` split from `/review`. +- **/describe architecture diagram and approach-suggestion section**: uniformly enables GFM so the community-edition `/describe` selectively outputs a mermaid architecture diagram; and injects an "approach suggestions" section — 2–4 alternative implementation approaches (each collapsed) + a leaning recommendation. +- describe layout optimization: the architecture diagram and file changes each become a separate section with a colored-block heading, and file changes collapse by category. +- **Clear execution history**: a trash button added to the chat-panel title bar clears the current PR's execution history. -### 🔧 修复 +### 🔧 Fixed -- **安装 / 升级健壮性**:减少安装目录小文件数,缓解升级卸载缓慢 / 卡死(已装早期版本仍需先手动卸载)。 -- 修复取消 / 超时 / 退出时 litellm 等孙进程变孤儿。 -- 修复活动连接无缓存身份时首启「看似未触发远端同步」。 +- **Install / upgrade robustness**: reduces the number of small files in the install directory, alleviating slow / hanging uninstall on upgrade (an installed earlier version still needs a manual uninstall first). +- Fixed litellm and other grandchild processes being orphaned on cancel / timeout / exit. +- Fixed first launch "appearing not to trigger a remote sync" when the active connection has no cached identity. ## [0.2.0] - 2026-06-09 -> 本版重点: +> Highlights of this release: > -> - **接入 GitHub**(github.com + GitHub Enterprise Server)与多平台适配抽象 -> - **评审任务并发执行** -> - **启动显著提速** -> - **移除 Docker 运行策略**,收敛到内嵌运行时 +> - **GitHub integration** (github.com + GitHub Enterprise Server) and a multi-platform adaptation abstraction +> - **Concurrent review-task execution** +> - **Significantly faster startup** +> - **Removed the Docker run strategy**, converging to the embedded runtime -### ✨ 新增 +### ✨ Added -- **GitHub 适配**(github.com + GitHub Enterprise Server,REST API v3):PR 发现、diff 评论读写、行内评论、审批(通过 / 需修改 / 撤销)、合并;审批按平台能力降级,自己作者的 PR 审批按钮灰显。 -- **多平台适配抽象基线**:`PlatformAdapter` 能力描述符 + 评论线程字段,UI 据能力位显 / 隐 / 灰,不在调用处写平台判断。 -- **PR 发现分类**:GitHub 对齐仪表盘四类(待我评审 / 我创建 / 指派我 / 提及我),Bitbucket 增两类;结果本地缓存、按标签本地过滤。 -- **评审任务并发执行**:队列改为可配置并发(每个 run 独立 worktree + 子进程),多个 PR 评审可并行,并发数由 `pr_agent.max_concurrency` 控制(1~8,默认 2)。 -- **本地 CLI 模型 provider**(`cli`):把评审请求转交本机已安装并授权的命令行工具(Claude Code / Codex CLI),凭据与计费由该 CLI 自理。 -- **单活动连接模型**:PR 列表与状态栏只反映当前活动连接,切换后归档旧连接的 PR。 -- 新增面向用户的**使用说明**文档(`docs/guide/`):安装与首次使用、平台 / LLM / 代理配置、配置文件参考、自定义评审规则。 -- 合并按钮等待态,防止重复点击。 +- **GitHub adaptation** (github.com + GitHub Enterprise Server, REST API v3): PR discovery, diff-comment read/write, inline comments, approval (approve / needs-work / revoke), merge; approval degrades per platform capability, and the approve button is grayed out for your own authored PRs. +- **Multi-platform adaptation baseline**: `PlatformAdapter` capability descriptors + comment-thread fields, with the UI showing / hiding / graying per capability bits, no platform checks written at call sites. +- **PR discovery categories**: GitHub aligns with the dashboard's four categories (To review / Created by me / Assigned to me / Mentioning me), Bitbucket adds two; results are cached locally and filtered locally by label. +- **Concurrent review-task execution**: the queue becomes configurable-concurrency (each run an independent worktree + child process), so multiple PR reviews run in parallel, with concurrency controlled by `pr_agent.max_concurrency` (1–8, default 2). +- **Local CLI model provider** (`cli`): hands review requests to a locally installed and authorized command-line tool (Claude Code / Codex CLI), with credentials and billing handled by that CLI. +- **Single-active-connection model**: the PR list and status bar reflect only the current active connection, archiving the old connection's PRs on switch. +- Added user-facing **user-guide** docs (`docs/guide/`): install and first use, platform / LLM / proxy config, config-file reference, custom review rules. +- Merge-button waiting state, preventing repeat clicks. -### ♻️ 变更 +### ♻️ Changed -- **启动提速**:新增启动闪屏即时呈现 logo + spinner;Monaco 改懒加载,渲染入口包 ~10MB → ~2.6MB;pr-agent 探测移出建窗关键路径。 -- 全仓内部命名统一为 **Bitbucket**,去除 `BBS` / `BB` 等歧义缩写(纯改名)。 -- 架构设计文档目录 `docs/modules/` → `docs/arch/`。 -- 日志增强:dev 控制台改 logfmt 单行(按级别上色,文件仍 JSON);渲染层未捕获错误经 IPC 回传 main 一并落日志。 +- **Faster startup**: adds a launch splash presenting the logo + spinner instantly; Monaco changes to lazy loading, shrinking the render entry bundle ~10MB → ~2.6MB; pr-agent detection moved off the window-creation critical path. +- Unified internal naming to **Bitbucket** repo-wide, removing ambiguous abbreviations like `BBS` / `BB` (pure rename). +- The architecture-design docs directory `docs/modules/` → `docs/arch/`. +- Logging enhancements: the dev console changes to single-line logfmt (colored by level, files still JSON); uncaught render-layer errors are relayed to main via IPC and logged together. -### 🗑️ 移除 +### 🗑️ Removed -- **移除 Docker 运行策略**:嵌入式运行时 + 系统 local-cli 已覆盖全部场景,`pr_agent.strategy` 不再接受 `docker`。 +- **Removed the Docker run strategy**: the embedded runtime + system local-cli already cover all scenarios, `pr_agent.strategy` no longer accepts `docker`. -### 🔧 修复 +### 🔧 Fixed -- 修复模型返回多行自由文本值导致 pr-agent YAML 解析失败、`/review` 崩溃。 -- 修复删除文件的行号片段渲染崩溃。 -- 修复首启向导平台卡视觉错位。 +- Fixed multi-line free-text values returned by the model breaking pr-agent YAML parsing and crashing `/review`. +- Fixed a render crash on deleted-file line-number fragments. +- Fixed the first-launch wizard platform-card visual misalignment. -### 🔒 安全 +### 🔒 Security -- GitHub 图片代理仅对可信的 GitHub / GHE 资产域附带 PAT,避免凭据被带往第三方域。 -- 升级 `nx` 至 22.7.5 并修复 `minimatch` ReDoS(high)依赖告警。 +- The GitHub image proxy attaches the PAT only for trusted GitHub / GHE asset domains, avoiding credentials being carried to third-party domains. +- Upgraded `nx` to 22.7.5 and fixed the `minimatch` ReDoS (high) dependency warning. ## [0.1.0] - 2026-06-08 -> 面向 **Reviewer 个人** 的本地化、半自动 AI 代码评审桌面客户端, -> 基于社区版 [pr-agent](https://docs.pr-agent.ai/) 构建:拉取待评审 PR、本地跑 AI 生成评审意见, -> 逐条确认 / 编辑后再发布到代码平台。**决策权在人、规则在本地、数据在本地。** - -### ✨ 新增 - -- **平台接入与 PR 发现** - - Bitbucket Server / Data Center 接入(REST API v1,>= 7.0)。 - - 轮询自动发现作为 Reviewer 的待评审 Open PR;按仓库分组、状态过滤、搜索。 - - 首启配置向导:引导配置代码平台连接 +(可选)LLM;缺有效连接时下次启动仍回向导。 - - 单例锁:二次启动聚焦已有窗口,不再多开。 -- **本地 Diff 阅读** - - bare 镜像(按需 clone / fetch)+ Monaco 并排 / 内联 diff。 - - 文件树、行内评论、git blame、跨文件代码搜索。 - - GitHub 风格未变更段折叠。 -- **AI 评审(pr-agent)** - - 对话式驱动 `/describe`、`/review`、`/ask`,输出结构化成可操作的 findings。 - - 评审任务队列:串行执行、排队任务在 chat 内可见、随时取消、失败重试。 - - finding 行号锚点可点击跳转到 Diff 对应行。 - - 真实 token 用量采集(输入 / 输出分列)。 - - LLM 未配置时 chat 面板给出明确提示并禁用输入。 -- **评审 → 发布闭环** - - findings → 草稿池 → 行内编辑(Monaco view zone)→ 单条 / 批量发布到远端。 - - 发布后远端评论自动刷新;重复发布幂等(发完即删本地草稿)。 - - 自己作者的远端评论支持回复 / 编辑 / 删除。 - - 远端可合并时一键合并 PR;审批 / 合并远端失败时弹 toast 提示,不再静默。 -- **个性化规则** - - 每位 Reviewer 维护自己的规则目录(markdown + frontmatter),按项目 / 仓库 / 目标分支命中后注入评审。 -- **多 LLM Provider** - - 适配并实测验证:OpenAI、Anthropic、DeepSeek、阿里百炼(通义千问)、火山方舟(豆包)。 - - 厂商原厂模型只填型号名即用(按 provider 自动补 litellm 前缀)。 - - ollama / openai-compatible 理论可行(待验证)。 - - 设置页连接 / LLM / 代理可视化 CRUD(草稿态「写入不启用」,保存或显式启用才应用)。 - - 出站 HTTP 代理:LLM 调用 / 代码平台 / git HTTPS 统一走代理,本地地址自动直连。 -- **运行时与打包** - - 内嵌可重定位 Python + 固定版本 pr-agent,开箱即用,无需自装 Python / Docker(Docker 模式可选)。 - - 桌面安装包:Windows x64(NSIS)、macOS arm64(dmg,ad-hoc 签名、未公证)。 - - 对 pr-agent 的无侵入补丁体系:二进制安全 diff、新模型兼容、YAML 容错、token 用量采集等。 -- **隐私与数据** - - 本地优先:除调用所配置的 LLM API 与代码平台外不向第三方上报数据。 - - 配置 / 状态 / 日志固定在 `~/.code-meeseeks/`;仓库镜像目录可配置。 - -### 🔧 修复 - -- 修复只读安装目录(如 `C:\Program Files`)下 pr-agent 启动告警。 +> A localized, semi-automatic AI code-review desktop client **for individual Reviewers**, +> built on the community edition of [pr-agent](https://docs.pr-agent.ai/): pull PRs awaiting review, run AI locally to generate review opinions, +> confirm / edit each one, then publish to the code platform. **Decisions rest with the human, rules stay local, data stays local.** + +### ✨ Added + +- **Platform integration and PR discovery** + - Bitbucket Server / Data Center integration (REST API v1, >= 7.0). + - Polling auto-discovers Open PRs awaiting review where you are a Reviewer; grouped by repo, status-filtered, searchable. + - First-launch config wizard: guides configuring the code-platform connection + (optionally) the LLM; returns to the wizard on next launch when a valid connection is missing. + - Single-instance lock: a second launch focuses the existing window instead of opening another. +- **Local diff reading** + - Bare mirror (on-demand clone / fetch) + Monaco side-by-side / inline diff. + - File tree, inline comments, git blame, cross-file code search. + - GitHub-style unchanged-section collapsing. +- **AI review (pr-agent)** + - Conversationally drives `/describe`, `/review`, `/ask`, outputting structured, actionable findings. + - Review-task queue: serial execution, queued tasks visible in chat, cancelable anytime, retry on failure. + - Finding line-anchor clicks jump to the corresponding line in the Diff. + - Real token-usage collection (input / output separately). + - When the LLM is unconfigured, the chat panel gives a clear prompt and disables input. +- **Review → publish loop** + - findings → draft pool → inline editing (Monaco view zone) → publish single / batch to remote. + - Remote comments auto-refresh after publish; repeat publish is idempotent (local draft deleted once published). + - Your own remote comments support reply / edit / delete. + - One-click merge when the remote is mergeable; a toast prompts on approval / merge remote failure instead of failing silently. +- **Personalized rules** + - Each Reviewer maintains their own rules directory (markdown + frontmatter), injected into the review after matching by project / repo / target branch. +- **Multiple LLM providers** + - Adapted and tested: OpenAI, Anthropic, DeepSeek, Alibaba Bailian (Tongyi Qianwen), Volcano Ark (Doubao). + - Vendors' first-party models take just the model name (litellm prefix auto-added per provider). + - ollama / openai-compatible are theoretically workable (pending verification). + - The Settings page offers visual CRUD for connection / LLM / proxy (draft state "write without enabling", applied only on save or explicit enable). + - Outbound HTTP proxy: LLM calls / code platform / git HTTPS uniformly go through the proxy, with local addresses auto-direct. +- **Runtime and packaging** + - Embedded relocatable Python + pinned pr-agent, works out of the box, no self-installed Python / Docker needed (Docker mode optional). + - Desktop installers: Windows x64 (NSIS), macOS arm64 (dmg, ad-hoc signed, un-notarized). + - A non-invasive patch system for pr-agent: binary-safe diff, new-model compatibility, YAML fault tolerance, token-usage collection, etc. +- **Privacy and data** + - Local-first: reports no data to third parties beyond calling the configured LLM API and code platform. + - Config / state / logs fixed under `~/.code-meeseeks/`; the repo mirror directory is configurable. + +### 🔧 Fixed + +- Fixed the pr-agent startup warning under a read-only install directory (e.g. `C:\Program Files`). --- -许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +License: [Apache-2.0](LICENSE). The package bundles third-party components (pr-agent, Electron, etc.), each distributed under its own license, see [NOTICE](NOTICE). +[0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0...v0.7.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md new file mode 100644 index 00000000..65a8d36d --- /dev/null +++ b/CHANGELOG.zh-CN.md @@ -0,0 +1,448 @@ +# 更新日志(Changelog) + +[English](CHANGELOG.md) · **简体中文** + +本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 + +## [0.10.0] - 2026-07-05 + +> 本版重点: +> +> - **品牌官网上线**:基于 VitePress 的项目官网,含落地页 / 下载页 / 常见问题与托管的双语使用文档,经 GitHub Pages 自动部署。 +> - **项目国际化**:README 与使用文档全面双语化(英文默认 + 中文)。 +> - **可定制的代码建议输出**:可约束 AI 建议的写法、并自定义其生成的评审草稿评论排版(设置 → Agent → 策略)。 +> +> 除对外呈现外,桌面应用新增了可定制的代码建议输出;其余桌面应用与 CLI 的功能行为较 0.9.0 基本无变化,仅含一处启动期小修复。 + +### ✨ 新增 + +- **品牌官网**:面向用户的项目官网正式上线(<https://huhamhire.github.io/code-meeseeks/>)。 + - 落地页:一句话定位 + 深浅自适应的产品截图 + 核心特性 + 模型生态展示 + 跳转 Releases 的下载入口。 + - 下载页:按访问者操作系统自动推荐对应安装包,GUI 与 CLI 分栏;CLI 提供 macOS / Linux 一键安装脚本;下载信息运行期取最新 Release,并有构建期静态兜底以抵御接口限流。 + - 常见问题(FAQ)独立成页。 + - 托管使用文档:官网直接从仓库使用文档构建渲染,中英文双语、随文档更新同步,且站内搜索支持中文界面。 + - 部署与发版解耦:官网经独立流水线部署到 GitHub Pages,不占用应用 / CLI 的发版流程。 +- **项目文档国际化**:对外文档全面双语化(英文为默认 / 兜底、中文对照)。 + - README 拆分为英文(默认)与中文两份,顶部可互相切换。 + - 使用文档双语化——英文为正本、中文为对照镜像,官网按语言分别渲染。 +- **可定制的代码建议输出**(设置 → Agent → 策略,均在内联弹窗中编辑): + - _建议规范_(`code_suggestion_spec`):注入 `/improve`、`/review`、`/ask` 的自由文本,用于约束每条建议的结构(如 问题 / 分析 / 建议)——软约束,模型通常会遵守。 + - _建议排版_(`code_suggestion_layout`):评审草稿评论的确定性 Markdown 模板,支持 `<TITLE>` / `<SUGGESTIONS>` / `<HOME>` / `<PR>` / `<MODEL>` 占位符。留空则使用默认排版——建议正文上方是一行可点击的「AI 建议」链接 + 模型名(默认草稿评论前缀因此从此前的纯 `[AI 建议]` 变为该形式)。 + +### 🔧 修复 + +- 启动时仅 ping 当前激活的连接,不再遍历所有已配置连接:非激活连接的身份无界面消费方,遍历 ping 只会增加启动期网络请求,并让不可达的连接在每次启动时反复告警。切换激活连接时仍会按需刷新其身份。 + +## [0.9.0] - 2026-07-02 + +> 本版重点: +> +> - **外部集成与 CLI**:开启本机 API 把 PR 浏览与评审 Agent 操作开放给外部集成,并提供跨平台命令行工具 `meebox` + +### ✨ 新增 + +- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 + - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 + - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 + - 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)。 +- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr <id>`;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 +- **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 +- **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 +- **命令回显气泡**:在评审 Agent 面板直接发起 `/review`、`/describe`、`/improve`、`/ask` 等命令时,命令即时以用户气泡回显在其结果卡片之上,贴合对话习惯;编排 / AutoPilot 派发的子任务不回显,避免与编排会话的用户消息重复。 +- **按提交发起评审 Agent**:在 Diff 视图将变更范围切到某个提交后,评审 Agent 面板直接键入的 `/describe`、`/review`、`/improve`、`/ask`(含自然语言提问)会自动限定在该提交自身改动(`parent..sha`)而非 PR 全量;输入栏以范围 chip 标示当前提交,chip 选中态源自视图选择,点击可临时禁用(不删除、切到别的提交自动复位),与 Diff 选区 chip 交互一致。同一时刻仅一个 scope 生效:选中 Diff 行时以选区为准、暂挂提交范围,取消选区后自动还原。运行中与完成的评审结果卡均展示所限定提交的范围徽标。一键自动评审仍作用于 PR 全量。 + +### ♻️ 变更 + +- 评审 Agent 的 `/ask`(本机 agentic CLI 提供方)注入代码检索指引:引导以只读搜索定向查符号 / 只读所需行段替代整文件通读与全仓扫描,在保留读真实文件深度的同时,降低探索轮次与 token 消耗。 +- 「追问数量」上限现同时约束自由对话 Agent(会话即委派)的自动 `/ask`:此前该上限仅作用于评审微流程的条件追问,自由规划循环仅受「Agent 最大步数」约束、可连续多次 `/ask`(每次均为一次成本较高的 agentic 探索);现自由对话中的 `/ask` 亦按「追问数量」封顶(始终生效,与「自动追问」开关无关),避免探索成本失控。 +- 状态栏不再常态显示 pr-agent 版本号(减少常态噪声),仅在其不可用时保留告警提示;版本号改在设置「关于」页的运行环境信息中展示。 +- 「我创建的」分类下的「待处理」筛选并入存在合并冲突的 PR:作者视角下有冲突的 PR 需其跟进解决(即便评审已通过),故一并计入待处理。 +- PR 列表的状态二级筛选改为宽度自适应胶囊:一行容得下即不换行,容纳不下时换行并按每行项数均分撑满整行,消除换行后右侧留白参差。 +- 评审 Agent 派生的临时工作树内部分支不再使用固定的品牌化名称,改为与 PR 关联的 `pr-<localId>/head`、`pr-<localId>/base`(分层命名贴合平台 PR 引用惯例):消除产出中可辨识的固定行为特征,同时保证该内部分支名不会漏入对外发布的评审内容。 + +### 🔧 修复 + +- 修复 PR 列表分组标题背景色、Windows 窗口右上角控制按钮此前不随主题(编辑器配色主题)变化的问题;现二者均跟随当前主题派生配色,深浅 / 主题切换实时生效。 +- 修复点开带「@我 / 回复我」未读计数的 PR 后,标题处的未读计数 chip 不立即消除、需等下一轮轮询才清零的问题:标为已读时同步乐观清零该计数(此前仅清未读圆点,遗漏了计数 chip)。 +- 修复德语等界面下设置页左侧导航的长标签(如「通知」)溢出被裁切的问题;现长标签自动换行完整显示。 + +## [0.8.0] - 2026-06-30 + +> 本版重点: +> +> - **消息通知**:系统通知(新 PR / 评论回复 / 被 @)+ macOS dock 角标 + 授权引导 +> - **评论体验优化**:emoji 反应、@ 提及补全、图片附件、表情代码渲染——评论 / 回复 / 行内 / 草稿一致 +> - **命令面板**:`Ctrl/Cmd+Shift+P` 归口常用操作 +> - **已关闭 PR 浏览与按 URL 打开**:查看历史 PR、补充评论 / 补跑评审 +> - **评审规则增强**:规则目录递归、多规则按 `Ruleset` 分段注入 +> - **2026 主题配色** 与 **PR 列表点名计数** + +### ✨ 新增 + +- **评论体验优化**:评论列表、回复、行内(代码行)评论与草稿编辑保持一致的撰写与互动体验。 + - **emoji 反应**:评论(含行内评论)下方展示已有 emoji 反应(含计数与本人标记),可点选添加 / 取消;GitLab / Bitbucket 支持搜索挑选更多表情,GitHub 为其固定表情集——随平台能力提供,不支持的平台自动隐藏。 + - **@提及自动补全**:撰写评论 / 回复时输入 `@` 可从本 PR 参与者(评论与提交作者)中选择补全;仍可自由手打任意用户名。 + - **图片附件**:撰写评论 / 回复 / 行内评论 / 草稿时可粘贴图片或点输入框右上角的图片按钮选取上传,自动插入正文(随平台能力提供,不支持的平台隐藏入口)。 + - **表情代码渲染**:评论正文里的 `:tada:` 等表情代码按内置表情集渲染为对应 emoji(代码块与未知代码原样保留)。 +- **消息通知**:新增「通知」设置分区,可开启系统通知并按事件类型分别控制——收到新的 PR、收到评论回复、评论中被 @。 + - Windows / macOS 弹原生系统通知;是否显示遵循操作系统的通知设置,关闭后自动静默。 + - Windows 通知带发起人头像、按 PR / 评论 / 回复区分的类型标记,并显示所在仓库。 + - 仅对「待处理」的 PR 弹通知——已批准 / 已标记需修改的 PR 不再打扰。 + - 点击通知直接定位:新 PR 打开该 PR;内联评论跳到 Diff 对应代码行,顶层评论打开该 PR 的对话标签。 + - 批量动态时最多单独弹 5 条,更多折叠为一条「查看更多最新动态」提示,点击打开主界面查看,避免通知刷屏。 + - macOS dock 图标显示待你回应的评论计数角标。 + - macOS 通知设置提供「打开系统通知设置」按钮,便于在系统层授予通知权限(macOS 不允许应用代为开启)。 +- **PR 列表点名计数**:当未读评论中有 @ 你 / 回复你时,列表项标题前的未读圆点升级为显示条数的计数标记(封顶「10+」),一眼看出有多少条待你回应的评论;仅新分配 / 新提交导致的未读仍显示圆点。 +- **关于页系统信息**:设置「关于」页新增操作系统(平台 + 版本)与 CPU 架构信息,并提供「复制信息」按钮一键复制全部运行环境信息,便于反馈问题时附带环境。 +- **已关闭 PR 浏览与补充评审**:侧栏新增「已关闭」范围切换,查看历史退场(已合并 / 关闭 / 不再需你评审)的 PR;进入时按需加载。 + - 已合并 / 仍开放的 PR 可补充评论、补跑 AI 评审(源分支已删的合并 PR 按提交定位 diff);仅不再提供合并 / 审批等改动操作。 + - 已关闭(declined)的 PR 仅供浏览。 +- **按 URL 打开 PR**:命令面板新增「打开 URL」(快捷键 mac `⌘⇧U` / 其余 `Ctrl+Shift+U`),粘贴 / 输入当前平台的 PR 链接即可打开(自动忽略尾缀),用于查看未正式被请求参与评审的他人 PR,含已合并 / 已关闭的 PR。 + - 已在列表或历史中的 PR 直接定位;新链接会鉴权拉取后存入历史(随相同生命周期到期清理),无权限 / 链接无效即提示。 +- **命令面板**:标题栏新增命令输入框(`Ctrl/Cmd+Shift+P` 唤起),快速执行常用操作并归口分散功能。 + - PR 类命令:查看各发现分类(待我评审 / 我创建 等,随平台能力)、查看已关闭、按 URL 打开、分类筛选(待处理 / 全部 / 冲突 / 可合并 等)、切换 PR 列表。 + - 评审类命令:运行自动评审、切换 AutoPilot、切换对话面板。 + - 设置类命令:切换显示语言 / 主题 / 模型、切换代理、打开设置 / 关于 / DevTools。 + - 命令按领域前缀分类(PR / 评审 / 设置)、两级选择(如「切换主题」展开主题列表);非英语界面附英文名且始终支持中英文检索,匹配处高亮。 + - 打开时默认选中上次用过的命令,回车即重复。 + - 常用操作支持快捷键(开合 PR 列表 / 对话面板、运行自动评审、DevTools),命令面板内显示对应键位。 +- Agent 上下文目录新增 README 说明文件:介绍各文件用途并指向项目主页,便于用第三方编辑器了解与维护该目录。 + +### ♻️ 变更 + +- 默认主题(跟随系统)改用 2026 系列配色:深色 / 浅色分别采用 Dark 2026 / Light 2026,启动闪屏同步对齐;已自定义主题的设置不受影响。 +- 退场(已合并 / 关闭 / 不再需你评审)的 PR 数据迁出活跃目录、独立归档存放,归档保留与到期自动清理策略保持不变。 +- Agent 的 SOUL(人格定义)文件改由应用统一管理:每次加载自动对齐到内置模版、本地改动不再保留,以便随版本统一更新 Agent 行为基线(AGENTS / MEMORY / USER 与规则仍归你所有、可自由改写)。 +- 评审规则增强:规则目录支持子目录递归组织、自动加载全部规则文件;同一 PR 命中的多条规则不再只取首条,而是按 `Ruleset` 分段一并注入评审(按 priority 排序、单次封顶 20 条),命中提示改为显示条数、可预览全部命中规则。 +- PR 列表的状态二级筛选随发现分类细化:「待处理」反映「本人尚未评审」,仅在「待我评审」分类、以及支持「需修改」评审态的平台(GitHub / Bitbucket)的「我创建的」等分类下保留;GitLab(二元审批、无「需修改」)的非「待我评审」分类不再展示无意义的「待处理」。 +- Agent 目录的示例规则改为仅在首次初始化时生成;删除后不再被重新创建,便于清理不需要的示例。 + +### 🔧 修复 + +- 修复将 Agent 上下文目录指向自定义或新位置后该目录未被初始化、加载为空的问题;现在无论经设置或直接修改配置变更,目录在使用前都会自动补齐 SOUL / AGENTS 等上下文模版。 +- 修复运行中的 AI 任务、其 PR 被自动移入「已关闭」后,点击状态栏的执行指示无法定位并打开该 PR 会话的问题;现可正确跳转到「已关闭」分类、选中该 PR 并打开会话。 +- 修复状态栏「待审 PR」计数把「我创建的」等并非待我评审的 PR 也计入的问题;现仅统计需我评审且本人尚未处理的 PR。 +- 修复部分平台(尤以 Bitbucket)上他人回复评论 / 在评论中 @你 时常常不弹通知的问题;现对待处理(待我评审 / 我创建)的 PR 可靠跟踪评论变化,回复与提及均会及时提醒。 +- 修复点击系统通知无法定位到已归档 PR(如运行中任务的 PR 刚被移入「已关闭」)的问题;现可正确跳转到「已关闭」分类、选中该 PR 并按类型打开对应位置。 +- 修复正在查看某 PR 的变更(Diff)时,该 PR 收到新的评论回复,内嵌评论却需切换到其它 PR 再切回才刷新的问题;现在轮询发现回复 / 提及后会即时刷新当前打开的评论。 + +## [0.7.0] - 2026-06-27 + +> 本版重点: +> +> - **深色 / 浅色主题与外观体系**:主题切换、编辑器配色主题、自定义等宽字体与字号 +> - **AI 评审运行参数**:评审任务并发、上下文长度、Agent 策略(自动追问 / 代码建议数量) +> - **PR 列表未读标记** +> - **Agent 对话增强**:选区引用附带显示、思考过程 markdown 排版、`/merge` 直接合并 +> - **本机 agentic CLI 编排提速** +> - **代码平台接入层领域化重构**(行为不变) + +### ✨ 新增 + +- **主题与外观** + - 深色 / 浅色主题切换:可选浅色、深色或跟随系统,界面即时切换、重启保留。 + - 编辑器配色主题:内置多款可选(VS Code 2026 / Modern、高对比,以及 GitHub、Monokai、Dracula、Nord、Solarized 等),可跟随应用主题或单独指定。 + - 编辑器字体与字号:自定义等宽字体(可多个候选)与字号,编辑器及全应用等宽文本(diff / 评论 / 代码块)一并生效。 +- **AI 评审运行参数** + - 评审任务并发:在设置页「AI」分区调整同时执行的评审任务数(1~8),即时生效、无需重启。 + - 上下文长度:在设置页「AI」分区设置裁剪输入内容的上下文长度上限(32k~1M 的习惯档位),让长 PR 完整入 prompt;对本地 CLI 模式不生效。 + - Agent 策略:在设置页「智能体」分区新增策略组——「自动追问」(关闭后评审直接总结、不再条件追问,省 token)与「代码建议数量」(统一约束 /review·/improve·/ask 单次生成的代码建议数量,2~8)。 +- **PR 列表** + - 未读标记:PR 新进入待审列表(新分配 / 请求你评审),或自上次查看后有新 commit 推送、有人 @ 你 / 回复你,列表项会标一个未读圆点;打开 PR 即清除。 +- **Agent 对话** + - 提问引用附带显示:带 Diff 选区代码提问时,引用的代码在消息气泡下方折叠展示(评论建议引用沿用复评卡片上的定位徽标)。 + - 思考过程支持预格式化排版:思考 / 判读内容按 markdown 渲染(代码块 / 列表 / 换行)。 + - 新增 `/merge` 指令:满足合并条件的 PR 可在对话中直接合并,触发前弹二次确认。 + +### ♻️ 变更 + +- 本机 agentic CLI 模式下 Agent 编排的自有步骤(路由 / 追问判读 / 评审总结)响应更快:每步不再加载用不到的 API 调用栈,降低每次响应的固定启动延迟,对话与自动评审整体更跟手。 +- 配置面板改为左右分区布局:左侧分区导航(常规 / 连接 / AI / 关于)、右侧按分区归类展示配置项,替代此前单列平铺;分区结构为后续扩展(主题、编辑器风格、上下文窗口等)预留。 +- **代码平台接入层基于领域设计重构**(行为不变):为后续平台接入与维护打基础。 + - 按连接、PR 操作、评论、用户与媒体四个领域拆分为独立服务,职责更清晰、便于按领域独立维护与测试。 + - 各平台的连接与代理配置统一收口到连接层,新增代码平台可按领域分步接入。 + - 平台连接相关的状态提示(如版本不支持原因)改为按界面语言本地化。 + +### 🔧 修复 + +- 并排 diff 在窗口较窄自动降为统一布局时,滚动条总览标尺的删除标记丢失、只剩新增的绿色;现按实际布局正确区分红/绿。 +- 窗口尺寸与最大化状态现可跨重启记住(此前调整尺寸或最大化后关窗常丢失)。 +- 高分屏开启缩放时,默认窗口尺寸可能超出屏幕范围;现按当前显示器可用区域自适应并居中显示。 +- 本地状态目录下偶发残留的临时文件会随每次运行持续累积;现于启动时自动清理。 + +## [0.6.0] - 2026-06-23 + +> 本版重点: +> +> - **`/ask` 复评闭环**:对评审建议发起复评、自动取代 / 关闭原评论 +> - **`/ask` 结构化分段输出**与**完整文件上下文** +> - **Agent 会话中途输入与「计划」面板** +> - **Diff 体验增强**:选区引用提问、按变更范围 / 单 commit 查看、冲突文件标注、删除行评论、滚动条总览标尺 +> - **PR「活动」时间线** +> - 前后端基于领域设计的重大重构(行为不变)与 Agent 编排提速 + +### ✨ 新增 + +- **Agent 评审与对话** + - `/ask` 复评闭环:对 `/review`、`/improve` 的代码评论建议(finding)发起「复评」,按裁决(取代 / 保留 / 撤销)自动取代或关闭原评论;自动评审微流程亦可由 judge 触发复评。 + - `/ask` 结构化分段输出:自由问答按「结论 / 分析解读 / 建议」三段着色呈现,针对代码的建议可定位行号、采纳为行内评论。 + - CLI 模式 `/ask` 取完整文件上下文:本机 CLI 接管时可读取仓库完整文件作答,读取前清空仓库自带 agent 指令文件以防注入污染。 + - 会话「中途输入」与「计划」面板:运行期间再输入消息即时入队并重排后续行动;规划 Agent 维护可视的 todo 计划,随会话持久化、切 PR / 重启自动恢复。 + - run 卡片展示「模型实际交互规模」:呈现提示缓存命中量与模型交互轮次,避免本机 CLI 多轮累加的 token 用量被误读为超限。 +- **Diff 阅览** + - 选中代码引用进提问:选中若干行后作为隐式上下文随提问注入模型、可一键忽略,删除行与未改动行同样可引用。 + - 按「变更范围」查看:可切换查看全部变更或某个 commit 的变更,点击 commit 本地渲染只读 diff、不再跳浏览器。 + - 文件树标注合并冲突文件:有冲突的 PR 对会冲突的文件标琥珀色三角警示图标,无需逐文件试合并即可定位。 + - 给「删除行」新增行内评论 / 草稿:并排视图下 base 侧(删除 / 上下文行)也可 hover「+」创建。 + - 滚动条总览标尺:把增 / 改 / 删与「有评论的行」投影到滚动条旁,拖动即可快速定位。 +- **PR 详情与协作** + - 「评论」标签页演进为「活动」时间线(GitHub / Bitbucket):评论、提交更新、评审决断归并为一条时间线,并可直接发不锚定文件的 summary 评论;GitLab 保持纯评论视图。 + - PR 头部展示 reviewer 头像栈:按评审状态排序展示评审者头像、带决断角标,超出折叠为「+n」下拉。 + - 详情标签页国际化与左右布局:整面板按界面语言出文案,改为左描述 / 右时间线 + 评审者列表、窄宽响应式堆叠。 +- 连接 / LLM 配置模态退出拦截:有未提交改动时关闭弹确认框,避免误丢未保存内容。 + +### ♻️ 变更 + +- **前后端基于领域设计的重大重构**(可维护性,行为不变):按领域边界重组前后端代码,划清模块职责与依赖方向。 + - 前端:组件按 `common`(基础 UI)/ `layout`(应用骨架)/ `features`(业务领域)分层,业务逻辑下沉所属领域,超大组件(ChatPane / SettingsModal / DiffView 等)拆为「容器 + 领域组件 + hooks」。 + - 后端:抽出 IPC 服务层、按领域分组 Agent 服务、解耦运行队列;Agent 引擎抽出可插拔「步骤」抽象统一记步与用量累计,编排提示词外置为资源文件。 + - 对外接口、界面与交互行为均不变。 +- **Agent 编排响应提速**(对用户行为不变):条件追问并行派发、追问判读瘦身为轻量路由、编排链路统一低推理 + 判读输出封顶,并把全局稳定系统前缀接入 Anthropic 1h 提示缓存,整体延迟与成本下降。 +- 复评 `/ask` 取代 / 撤销改为静默自动关闭原 finding,「取代」裁决把建议提升为可采纳的代码反馈卡,前端仅只读展示关闭态与「查看复评」导航。 +- agent「评审总结」聚焦 PR 整体结论:只吃每条追问的结论而非完整答案明细,输出 PR 级整体结论、不复制明细。 +- PR 提交列表 / 活动时间线按 first-parent 过滤合入的他人提交,只保留本 PR 自产提交;镜像未就位时回退不丢信息。 +- 评审 / Diff 界面交互打磨(一批小优化):评审总结卡与 finding 卡统一样式行距、可折叠卡整行标题即展开并带过渡动画、点击复评引用徽标定位并高亮原卡、危险按钮统一为高饱和红、设置模态复用首启向导左右布局,及移除「已达并发上限」横幅、隐藏 `/review`「评估工作量」段等。 + +### 🔧 修复 + +- 源分支 merge 目标分支后,变更页 diff 混入目标分支的已有改动。(#107,感谢 @csj2000) +- `/ask` 的结构化分段 / 引用上下文 / 复评裁决指令此前对模型不生效。 +- 复评「取代」裁决的改进建议改为可直接发布的替代评论,不再是关于评论的元讨论。 +- 失败 / 取消的任务不再产出无意义的 finding 卡。 +- CLI 模式 `/ask` 在仓库自带 agent 指令文件被版本管理时整体失败。 +- 本地镜像缺 PR head sha(源分支被删 / 强推)导致 diff / 评审失败且不自愈。 +- 消除 PR 切换 / 刷新 / 标签页切换时的多处渲染抖动与闪烁。 +- 消除 Monaco 控制台 `Missing requestHandler` 噪音报错。 +- 评审总结偶发被截断 / 回落「无法解析建议」。 +- 拉取变更文件列表偶发失败(`ENOENT … diff-base.json`)。 +- 合并已合并 / 已关闭的 PR 时报错不友好。 +- 补齐 PR 评审状态 chip、Agent 步骤行等写死文案的国际化。 +- 设置页手动「检查更新」结果即时同步到状态栏。 +- PR 详情 / 评论页正文限宽居中、reviewers 列表排序稳定。 + +## [0.5.0] - 2026-06-17 + +> 本版重点: +> +> - 可委派的**高阶 Agent**(会话 Agent 化 + AutoPilot 后台预评审) +> - **无边框窗口 + 自绘标题栏** +> - 重型组件加载抖动、评论嵌套展示等体验打磨 + +### ✨ 新增 + +- **高阶 Agent(会话 Agent 化 + AutoPilot 预评审)**:在 PR 评审中引入可委派的智能体,随 LLM 配置自动可用、无需单独启用开关。 + - 一键自动评审:对当前 PR 跑「描述 → 评审 →(仅严重问题)追问 → 总结」微流程,给出非约束性建议(建议通过 / 修改 / 人工复核)并汇总为「评审总结」卡片。 + - 对话即委派:聊天框输入自然语言,规划 Agent 按需调用只读工具完成请求,与 PR 无关的请求礼貌拒绝、运行中可随时停止。 + - AutoPilot 后台预评审:对「待我评审」且「待处理」的新 PR 自动预评审,建议落入列表徽标、总结落入会话;写操作经逐项授权 + 红线校验把关(默认仅开放只读工具)。 + - 评审状态可视化:PR 列表项展示蓝色「执行中」旋转指示或评审建议 ★(覆盖纯思考阶段),AutoPilot 触发的评审打机器人标记。 + - 并行多问:规划 Agent 可在一轮内并行派发多个 `/ask`。 + - 评审步骤 token 用量可见:每个推理步右侧分步展示本步 token 用量(不累计)。 + - Agent 上下文目录:以 SOUL / AGENTS / MEMORY / USER 与 rules/ 构成 Agent 的人格与知识来源,默认落 `~/.code-meeseeks/agent`、首启幂等补齐模版。 +- **无边框窗口 + 自绘标题栏**(VS Code 风):去掉系统原生标题栏、渲染层自绘 36px 标题栏、深色主题贯通到底,窗控按钮交由系统绘制保留原生行为,标题栏展示品牌名与当前 PR 标题。 +- 设置页新增「关于 & 反馈」入口:GitHub 仓库 / 提交 Issue / Releases 三个外链。 + +### ♻️ 变更 + +- **重型组件加载抖动收敛**:切换 PR / 文件时 diff(Monaco)、会话内容等重型区域统一盖延迟 loading、就绪后一次性 reveal,缓存命中的快切换零闪。 +- 移除独立 `ollama` provider,统一经 `openai-compatible` 接入本地 Ollama(自带兼容端点、更标准),旧配置自动迁移;`openai-compatible` 标记为已验证。 +- 评论嵌套展示统一(评论 tab + 行内):回复满 5 层拉平为同层级、嵌套改「左竖线缩进」扁平样式。 +- describe「文件变更」分类默认折叠,避免输出过长。 +- 评审总结不再硬截断:`summary_max_chars` 仅作软约束,已生成内容完整保留。 +- 一批 UI 细节:评审建议星标改为四角 sparkle ✦、统一 PR 列表状态 chip 带高消除行高漂移、`/ask` 问题末尾追加语言要求改善按界面语言作答。 + +### 🔧 修复 + +- 修复 PR diff 基准随目标分支漂移导致的「修改被撤回」误判。 +- 修复 Windows 控制台中文日志乱码。 +- 修复 finding 锚点解析在文件路径含方括号(如 `a/[m-123]/x.ts`)时出错。 +- 修复 Anthropic provider 的自建 / 中转 base_url 此前不生效。(#65,感谢 @dnvyrn) +- 本地镜像 clone/fetch 被打断留下的残缺镜像现可自动重建自愈。 +- 清空 PR 执行历史时一并清掉列表评审建议 ★,自动评审完成后 ★ 即时更新。 +- PR「提交」数角标排除源分支合入目标分支带来的提交与 merge 提交。 +- 补 walkthrough 文件分类标题(Miscellaneous / Formatting / Dependencies)的中 / 日 / 德译文。 +- 消除评论页 poll / 刷新触发的渲染抖动。 + +## [0.4.0] - 2026-06-14 + +> 本版重点: +> +> - **接入 GitLab**(gitlab.com + Self-Managed,CE / EE) +> - 评审交互与渲染打磨(拒绝折叠、草稿锚点对齐、评论内嵌附件图片、GitHub / GitLab 评论编辑删除) +> - **连接 Base URL 放宽** +> - **Windows 升级安装健壮性**(per-machine 提权 + 绕过旧卸载器) +> +> ⚠️ **Windows 安装说明**:本版为 **per-machine 安装**(所有用户 / Program Files),安装器双击即弹 UAC 提权运行;安装后的应用以普通权限启动。从旧版升级会自动清理旧安装,无需手动卸载。 + +### ✨ 新增 + +- **GitLab 接入**(gitlab.com + Self-Managed,CE / EE,REST API v4):MR 发现、diff 评论读 / 发 / 改 / 删 / 回复、合并、clone(PAT / SSH)、头像 / 附件代理;设置页与首启向导可新增 GitLab 连接(Base URL 可留空默认 gitlab.com)。 + - CE / EE 审批降级:经 `/metadata` 探测 edition——EE 支持通过 / 撤销,CE 无 API 审批、UI 灰显(GitLab 审批二元、无「需修改」)。 + +### ♻️ 变更 + +- **连接 Base URL 放宽**:GitHub Enterprise / GitLab Self-Managed 可直接填实例地址(如 `https://ghe.example.com`),`/api/v3`、`/api/v4` 自动补全;github.com / gitlab.com 留空即用默认。 +- 拒绝代码反馈 / 改进建议后卡片自动折叠置灰、仅保留头部与锚点行(含撤销入口),降低已决断项视觉占用。 +- 本地 CLI 类 LLM provider 标注「实验性」:提示其依赖上游 CLI(claude / codex 等)、稳定性不作保证。 +- 设置页连接 / LLM 预设卡片显示对应品牌类型图标避免误配;危险按钮实底改为饱和红提高警示力;Windows 安装页不再展开空白的文件日志列表、仅留进度条。 + +### 🔧 修复 + +- 修复 GitHub / GitLab 无法编辑 / 删除自己的评论。 +- 修复 Bitbucket 评论内嵌附件图片不渲染。 +- 修复代码建议草稿区的锚定行与最终发布落点不一致。 +- 评论内嵌图片代理失败时降级为「浏览器打开」链接,不再显示破图标。 +- 修复 Windows 升级安装卡死 /「无法关闭」。 + +## [0.3.1] - 2026-06-11 + +### 🔧 修复 + +- 修复 macOS 分发版「本地 CLI」provider(claude / codex)经 Finder / Dock 启动时因 PATH 不全而失效。(#21) + +## [0.3.0] - 2026-06-11 + +> 本版重点: +> +> - **界面国际化**(四语 + 即时切换) +> - **Mermaid 架构图渲染** +> - **版本更新检测** +> - `/improve` 与 `/describe` 思路建议段等 pr-agent 能力扩展 +> - 修复首启同步、子进程树清理与安装 / 升级健壮性 +> +> ⚠️ **Windows 用户升级注意**:若已安装**早期版本**(含 `0.3.0-alpha.1` 及更早),升级到本版前请**先手动卸载旧版**(设置 → 应用 → Code Meeseeks → 卸载,或安装目录下的 `Uninstall Code Meeseeks.exe`),完成后再运行新安装器;否则覆盖安装可能长时间卡住或弹出「Code Meeseeks 无法关闭」。原因:早期版本运行时会在安装目录写入上万个 Python 字节码(`.pyc`)缓存文件,使覆盖升级时「卸载旧版」一步需逐个删除海量小文件、极慢甚至卡死。本版起运行时不再写入这些缓存,**之后的升级可正常覆盖、无需手动卸载**。 + +### ✨ 新增 + +- **多语言界面(i18n)**:接入 react-i18next,全部 GUI 文本与主进程面向用户文案覆盖**简体中文 / English / 日本語 / Deutsch** 四语;pr-agent 输出模板渲染期翻译同步语言感知。 + - 语言选择:设置页与首启向导下拉选择、即时生效,AI 回复语言随之(下次运行起)。 + - 语言解析:`config.language` 为空时按操作系统偏好语言匹配,默认 / 兜底为 en-US。 + - 按需懒加载:默认语言静态进入口,其余语言切换时才拉取(`ja-JP` / `de-DE` 为机器初稿)。 +- **Mermaid 架构图渲染**:markdown 中的 `mermaid` 代码块渲染为图形,覆盖 PR 描述 / 评论 / chat 评审输出,点击进入模态预览(缩放 / 平移 / 适应窗口),渲染失败回退原始代码块。 +- **版本更新检测**:启动时及设置页查 GitHub Releases 最新稳定版比对,有新版在状态栏提示并可点击前往下载(仅检测、不自动安装),走配置的出站代理、可关闭。 +- **启用 `/improve` 指令**:逐行代码改进建议(带 1-10 重要度评分),输出落独立 `improve.md` 与 `/review` 分流。 +- **/describe 架构图与思路建议段**:统一启用 GFM 使社区版 `/describe` 选择性输出 mermaid 架构图;并注入「思路建议」段——2-4 个替代实现方案(各自折叠)+ 倾向性推荐。 +- describe 排版优化:架构图、文件变更各自独立成段配色块标题,文件变更按分类折叠。 +- **清空执行历史**:chat 面板标题栏新增垃圾桶按钮,清空当前 PR 的执行历史。 + +### 🔧 修复 + +- **安装 / 升级健壮性**:减少安装目录小文件数,缓解升级卸载缓慢 / 卡死(已装早期版本仍需先手动卸载)。 +- 修复取消 / 超时 / 退出时 litellm 等孙进程变孤儿。 +- 修复活动连接无缓存身份时首启「看似未触发远端同步」。 + +## [0.2.0] - 2026-06-09 + +> 本版重点: +> +> - **接入 GitHub**(github.com + GitHub Enterprise Server)与多平台适配抽象 +> - **评审任务并发执行** +> - **启动显著提速** +> - **移除 Docker 运行策略**,收敛到内嵌运行时 + +### ✨ 新增 + +- **GitHub 适配**(github.com + GitHub Enterprise Server,REST API v3):PR 发现、diff 评论读写、行内评论、审批(通过 / 需修改 / 撤销)、合并;审批按平台能力降级,自己作者的 PR 审批按钮灰显。 +- **多平台适配抽象基线**:`PlatformAdapter` 能力描述符 + 评论线程字段,UI 据能力位显 / 隐 / 灰,不在调用处写平台判断。 +- **PR 发现分类**:GitHub 对齐仪表盘四类(待我评审 / 我创建 / 指派我 / 提及我),Bitbucket 增两类;结果本地缓存、按标签本地过滤。 +- **评审任务并发执行**:队列改为可配置并发(每个 run 独立 worktree + 子进程),多个 PR 评审可并行,并发数由 `pr_agent.max_concurrency` 控制(1~8,默认 2)。 +- **本地 CLI 模型 provider**(`cli`):把评审请求转交本机已安装并授权的命令行工具(Claude Code / Codex CLI),凭据与计费由该 CLI 自理。 +- **单活动连接模型**:PR 列表与状态栏只反映当前活动连接,切换后归档旧连接的 PR。 +- 新增面向用户的**使用说明**文档(`docs/guide/`):安装与首次使用、平台 / LLM / 代理配置、配置文件参考、自定义评审规则。 +- 合并按钮等待态,防止重复点击。 + +### ♻️ 变更 + +- **启动提速**:新增启动闪屏即时呈现 logo + spinner;Monaco 改懒加载,渲染入口包 ~10MB → ~2.6MB;pr-agent 探测移出建窗关键路径。 +- 全仓内部命名统一为 **Bitbucket**,去除 `BBS` / `BB` 等歧义缩写(纯改名)。 +- 架构设计文档目录 `docs/modules/` → `docs/arch/`。 +- 日志增强:dev 控制台改 logfmt 单行(按级别上色,文件仍 JSON);渲染层未捕获错误经 IPC 回传 main 一并落日志。 + +### 🗑️ 移除 + +- **移除 Docker 运行策略**:嵌入式运行时 + 系统 local-cli 已覆盖全部场景,`pr_agent.strategy` 不再接受 `docker`。 + +### 🔧 修复 + +- 修复模型返回多行自由文本值导致 pr-agent YAML 解析失败、`/review` 崩溃。 +- 修复删除文件的行号片段渲染崩溃。 +- 修复首启向导平台卡视觉错位。 + +### 🔒 安全 + +- GitHub 图片代理仅对可信的 GitHub / GHE 资产域附带 PAT,避免凭据被带往第三方域。 +- 升级 `nx` 至 22.7.5 并修复 `minimatch` ReDoS(high)依赖告警。 + +## [0.1.0] - 2026-06-08 + +> 面向 **Reviewer 个人** 的本地化、半自动 AI 代码评审桌面客户端, +> 基于社区版 [pr-agent](https://docs.pr-agent.ai/) 构建:拉取待评审 PR、本地跑 AI 生成评审意见, +> 逐条确认 / 编辑后再发布到代码平台。**决策权在人、规则在本地、数据在本地。** + +### ✨ 新增 + +- **平台接入与 PR 发现** + - Bitbucket Server / Data Center 接入(REST API v1,>= 7.0)。 + - 轮询自动发现作为 Reviewer 的待评审 Open PR;按仓库分组、状态过滤、搜索。 + - 首启配置向导:引导配置代码平台连接 +(可选)LLM;缺有效连接时下次启动仍回向导。 + - 单例锁:二次启动聚焦已有窗口,不再多开。 +- **本地 Diff 阅读** + - bare 镜像(按需 clone / fetch)+ Monaco 并排 / 内联 diff。 + - 文件树、行内评论、git blame、跨文件代码搜索。 + - GitHub 风格未变更段折叠。 +- **AI 评审(pr-agent)** + - 对话式驱动 `/describe`、`/review`、`/ask`,输出结构化成可操作的 findings。 + - 评审任务队列:串行执行、排队任务在 chat 内可见、随时取消、失败重试。 + - finding 行号锚点可点击跳转到 Diff 对应行。 + - 真实 token 用量采集(输入 / 输出分列)。 + - LLM 未配置时 chat 面板给出明确提示并禁用输入。 +- **评审 → 发布闭环** + - findings → 草稿池 → 行内编辑(Monaco view zone)→ 单条 / 批量发布到远端。 + - 发布后远端评论自动刷新;重复发布幂等(发完即删本地草稿)。 + - 自己作者的远端评论支持回复 / 编辑 / 删除。 + - 远端可合并时一键合并 PR;审批 / 合并远端失败时弹 toast 提示,不再静默。 +- **个性化规则** + - 每位 Reviewer 维护自己的规则目录(markdown + frontmatter),按项目 / 仓库 / 目标分支命中后注入评审。 +- **多 LLM Provider** + - 适配并实测验证:OpenAI、Anthropic、DeepSeek、阿里百炼(通义千问)、火山方舟(豆包)。 + - 厂商原厂模型只填型号名即用(按 provider 自动补 litellm 前缀)。 + - ollama / openai-compatible 理论可行(待验证)。 + - 设置页连接 / LLM / 代理可视化 CRUD(草稿态「写入不启用」,保存或显式启用才应用)。 + - 出站 HTTP 代理:LLM 调用 / 代码平台 / git HTTPS 统一走代理,本地地址自动直连。 +- **运行时与打包** + - 内嵌可重定位 Python + 固定版本 pr-agent,开箱即用,无需自装 Python / Docker(Docker 模式可选)。 + - 桌面安装包:Windows x64(NSIS)、macOS arm64(dmg,ad-hoc 签名、未公证)。 + - 对 pr-agent 的无侵入补丁体系:二进制安全 diff、新模型兼容、YAML 容错、token 用量采集等。 +- **隐私与数据** + - 本地优先:除调用所配置的 LLM API 与代码平台外不向第三方上报数据。 + - 配置 / 状态 / 日志固定在 `~/.code-meeseeks/`;仓库镜像目录可配置。 + +### 🔧 修复 + +- 修复只读安装目录(如 `C:\Program Files`)下 pr-agent 启动告警。 + +--- + +许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 + +[0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 +[0.9.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...v0.9.0 +[0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.3.1...v0.4.0 +[0.3.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.1.0-alpha.1...v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7d1cf09e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to Code Meeseeks + +Thanks for your interest in contributing! This page is the entry point for contributors. For the deeper engineering handbook (repo structure, module boundaries, conventions), see [AGENTS.md](AGENTS.md); for environment setup and build steps, see the [Development guide](docs/development/README.md). + +> **Language**: developer- and design-facing docs and code artifacts are in English — the developer/contributor docs under [`docs/development/`](docs/development/README.md), the module design docs under [`docs/arch/`](docs/arch/README.md) (with a terminology [glossary](docs/arch/glossary.md)), commit messages, and PR descriptions. Only the user-facing surfaces are bilingual: the user guide ([`docs/guide/`](docs/guide/README.md)), the [README](README.md), and the [CHANGELOG](CHANGELOG.md) — English canonical + a Chinese mirror. + +## Getting set up + +See the [Development guide](docs/development/README.md) for prerequisites (Node ≥ 20, npm ≥ 10, Git + Git LFS), installing dependencies, assembling the embedded pr-agent runtime, and running in dev mode. + +The standalone Go CLI under [`cli/`](cli/README.md) is a separate module with its own workflow — see [AGENTS.md · CLI](AGENTS.md). + +## Branch strategy + +- `master` is the release branch — **never commit to it directly**. +- Branch all features and fixes off `dev`, open a PR **targeting `dev`**, and let it merge into `dev` first. +- Releases are cut on `master` by tagging `v*` (see [Packaging & release](docs/development/packaging-release.md)). + +## Commit messages + +- Use **[Conventional Commits](https://www.conventionalcommits.org/)** with a scope, in **English**, e.g. `feat(desktop): …`, `fix(review): …`, `docs(readme): …`, `build(mac): …`. +- Keep each commit cohesive; do not mix unrelated changes. Stage explicit file paths (avoid `git add -A` / `.`), since a shared working tree may hold others' in-progress edits. + +## Pull requests + +- Write the PR title and description in **English**. +- Target `dev` (see branch strategy above). +- Add fitting labels from the existing set (`gh label list`: `enhancement` / `documentation` / `bug` / …). + +## Before you submit + +Run the same checks CI does — all four must pass (lint is zero-tolerance, `--max-warnings=0`): + +```bash +npm run lint +npm run typecheck +npm run test +npm run build +``` + +If you changed the Go CLI under `cli/`, also run (from `cli/`): + +```bash +go vet ./... +go test ./... +go build ./... +``` + +## Documentation conventions + +- **User guide** — [`docs/guide/`](docs/guide/README.md), bilingual (English canonical at the root, Chinese mirror under `zh-CN/`). When you change one locale, update the other. +- **Module design docs** — [`docs/arch/`](docs/arch/README.md), in English (terminology locked by [`glossary.md`](docs/arch/glossary.md)). +- **Developer/contributor docs** — [`docs/development/`](docs/development/README.md), in English. + +See [AGENTS.md](AGENTS.md) for the full set of engineering conventions (IPC, i18n, error codes, platform ordering, and more). diff --git a/README.md b/README.md index d878cffd..00470c05 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ # Code Meeseeks -**PR Agent 的桌面 GUI · 面向 Reviewer 个人的本地化、半自动 AI 代码评审客户端** +**A desktop GUI for PR-Agent · A local, semi-automated AI code-review client for the individual reviewer** -社区版 [PR-Agent](https://docs.pr-agent.ai/) 的图形界面 (GUI) · Electron 桌面应用 · 数据全部留在本地 +Graphical interface (GUI) for the community [PR-Agent](https://docs.pr-agent.ai/) · Electron desktop app · All data stays on your machine [![Electron](https://img.shields.io/badge/Electron-2B2E3A?logo=electron&logoColor=9FEAF9)](https://www.electronjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) @@ -17,205 +17,208 @@ [![Bitbucket](https://img.shields.io/badge/Bitbucket-0052CC?logo=bitbucket&logoColor=white)](https://bitbucket.org/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://gitlab.com/) -<sub>关键词 / Keywords:PR-Agent GUI · pr-agent desktop client · AI 代码评审 / AI code review · Pull Request & Merge Request review · Bitbucket / GitHub reviewer 工具 · 本地化 / 私有部署 / self-hosted</sub> +<sub>Keywords:PR-Agent GUI · pr-agent desktop client · AI code review · Pull Request & Merge Request review · Bitbucket / GitHub reviewer tool · local / self-hosted / private deployment</sub> + +**English** · [简体中文](README.zh-CN.md) · [Website](https://huhamhire.github.io/code-meeseeks/) </div> --- -> ⚠️ **早期预览版(0.x)**:项目仍在快速迭代,功能、配置与数据格式可能发生不兼容变更,稳定性未经充分验证。请勿用于关键的生产评审流程,使用前请自行评估风险并做好数据备份。 - -Code Meeseeks(内部开发代号 `meebox`)是命令行工具 [pr-agent](https://docs.pr-agent.ai/) 的**桌面图形界面 (GUI)**:把 AI 辅助的代码评审装进一个桌面客户端 —— 拉取评审者待评审的 PR(Pull Request / Merge Request),本地跑 pr-agent 生成评审意见,由评审者**逐条确认 / 编辑后**再发布到代码托管平台(GitHub / Bitbucket / GitLab)。 +Code Meeseeks (internal codename `meebox`) is a **desktop graphical interface (GUI)** for the command-line tool [pr-agent](https://docs.pr-agent.ai/): it packages AI-assisted code review into a desktop client — it fetches the PRs (Pull Requests / Merge Requests) awaiting your review, runs pr-agent locally to generate review comments, and lets the reviewer **confirm / edit each one** before publishing it back to the code hosting platform (GitHub / Bitbucket / GitLab). -核心设计立场: +Core design stance: -- **决策权在人** —— 所有评论必须经评审者二次确认 / 编辑才会发到远端,AI 只做草稿。 -- **规则在本地** —— 评审者自行配置检查规则、风格偏好、LLM Provider。 -- **数据在本地** —— 仓库副本、PR 元数据、评论草稿都存在本机工作目录,企业内网友好。 +- **Human decides** — every comment must be confirmed / edited by the reviewer before it reaches the remote; the AI only drafts. +- **Rules stay local** — the reviewer configures their own check rules, style preferences, and LLM provider. +- **Data stays local** — repository mirrors, PR metadata, and comment drafts all live in a local working directory; friendly to corporate intranets. -> 灵感来自 _Rick and Morty_ 里的 Mr. Meeseeks:召之即来、专做一件事、做完即走。 +> Inspired by Mr. Meeseeks from _Rick and Morty_: summoned on demand, does exactly one thing, and vanishes once it's done. -## 适用场景 +## Where it fits -- ✅ 承担 code review 职责的工程师 / Tech Lead -- ✅ 希望借助 AI 提升评审效率,同时保留最终决策权、不将判断完全交由 bot -- ✅ 在企业内网环境中使用自建 Bitbucket / GitLab 的团队 +- ✅ Engineers / Tech Leads who take on code-review duties +- ✅ Those who want AI to speed up review while keeping the final decision, rather than handing judgment entirely to a bot +- ✅ Teams running self-hosted Bitbucket / GitLab inside a corporate intranet -## 不适用场景 +## Where it doesn't -- ❌ 不是在 CI 中自动运行的 review bot(该定位属于 pr-agent 本身) -- ❌ 不是团队协同评审平台(无服务端,不提供多用户同步) -- ❌ 不替代代码托管平台原生的评审界面 +- ❌ Not a review bot that runs automatically in CI (that role belongs to pr-agent itself) +- ❌ Not a collaborative team review platform (no server, no multi-user sync) +- ❌ Not a replacement for the code platform's native review interface --- -## 核心特性 +## Core features -#### 🌍 多平台接入 +#### 🌍 Multi-platform access -- **统一接入 GitHub / Bitbucket / GitLab** —— 自建 GitHub Enterprise / GitLab Self-Managed 直接填实例地址,按平台能力自适应(如 GitLab CE/EE 审批降级)。 -- **本地优先,开箱即用** —— 仓库副本、PR 元数据、评论草稿都存本机工作目录,企业内网友好;安装包内嵌 pr-agent,零外部依赖。 -- **网络代理** —— LLM 调用、代码平台、git 拉取统一经 HTTP 代理,本地地址自动直连。 +- **Unified access to GitHub / Bitbucket / GitLab** — for self-hosted GitHub Enterprise / GitLab Self-Managed, just enter the instance URL; capabilities adapt per platform (e.g. graceful degradation of GitLab CE/EE approvals). +- **Local-first, works out of the box** — repository mirrors, PR metadata, and comment drafts all live in a local working directory, friendly to corporate intranets; pr-agent is embedded in the installer, with zero external dependencies. +- **HTTP proxy** — LLM calls, code platforms, and git fetches all go through an HTTP proxy, with local addresses connecting directly. -#### 📥 PR 发现与浏览 +#### 📥 PR discovery and browsing -- **自动发现** —— 轮询拉取待评审 PR,按「待我评审 / 我创建」等分类与仓库分组,支持状态过滤与搜索。 -- **未读与点名标记** —— 新分配 / 新提交 / 被 @ / 被回复标未读,其中被点名的条数单独计数。 -- **历史与按需打开** —— 浏览已合并 / 已关闭的 PR,或按 URL 直接打开任意 PR(含补充评论、补跑评审)。 +- **Automatic discovery** — polls for PRs awaiting review, categorized ("to review / created by me", etc.) and grouped by repository, with status filtering and search. +- **Unread and mention markers** — new assignments / new commits / @-mentions / replies are marked unread, with mentions counted separately. +- **History and open-on-demand** — browse merged / closed PRs, or open any PR directly by URL (including adding comments and re-running review). -#### 🔍 本地 Diff 阅读 +#### 🔍 Local diff reading -- **并排 / 内联 diff** —— 编辑器级代码阅读体验:文件树(合并冲突标注)、按变更范围 / 单 commit 查看、滚动条总览标尺、blame、跨文件代码搜索。 -- **行内评论** —— 新增行与删除行均可评论;选中代码可作为上下文引用进提问。 +- **Side-by-side / inline diff** — an editor-grade reading experience: file tree (with merge-conflict markers), view by change range / single commit, scrollbar overview ruler, blame, and cross-file code search. +- **Inline comments** — comment on both added and removed lines; selected code can be referenced as context in a question. -#### 🤖 AI / Agentic 评审 +#### 🤖 AI / Agentic review -- **指令驱动 pr-agent** —— `/describe`、`/review`、`/improve`、`/ask` 对话式驱动,结果结构化成可操作的评审发现。 -- **复评闭环** —— 对评审建议发起 `/ask` 复评,按裁决(取代 / 保留 / 撤销)自动取代或关闭原评论。 -- **Agentic 自主编排** —— 自然语言驱动的规划 + 多工具编排 + 长期 Memory,过程可观测(think → tool → think 时间线),可中途追加输入、随时停止,让评审走向可累积上下文的协作。 -- **AutoPilot 预评审** —— 对待我评审·待处理的新 PR 自动预跑评审,进应用即见待确认草稿;写操作经逐项授权 + 红线校验把关(默认仅开放只读工具)。 +- **Command-driven pr-agent** — drive it conversationally with `/describe`, `/review`, `/improve`, `/ask`; results are structured into actionable review findings. +- **Re-review loop** — raise an `/ask` re-review on a review suggestion, and per the verdict (supersede / keep / withdraw) the original comment is automatically superseded or closed. +- **Agentic orchestration** — natural-language-driven planning + multi-tool orchestration + long-term Memory, with an observable process (a think → tool → think timeline); you can add input mid-run and stop at any time, turning review into collaboration with accumulating context. +- **AutoPilot pre-review** — automatically pre-runs review on new PRs pending your review, so drafts are ready for confirmation the moment you open the app; write actions are gated by per-item authorization + red-line checks (read-only tools only by default). -#### ✍️ 评审闭环与协作 +#### ✍️ Review loop and collaboration -- **确认 → 发布** —— 评审发现转草稿、行内编辑、单条 / 批量发布到远端;远端可合并状态可视、满足条件一键合并(亦可对话 `/merge`)。 -- **评论互动** —— 自己的评论可回复 / 编辑 / 删除;支持 emoji 反应、@ 提及补全、图片附件(粘贴 / 选取上传)、`:shortcode:` 表情渲染(随平台能力提供)。 -- **活动时间线** —— 评论 / 提交更新 / 评审决断归并为一条时间线(GitHub / Bitbucket)。 -- **消息通知** —— 新 PR、评论回复、被 @ 分类弹系统通知,仅对待处理 PR 提醒、不打扰已决断项;点击直达对应 PR 或代码行,macOS dock 角标显示待你回应的评论数。 +- **Confirm → publish** — turn findings into drafts, edit inline, and publish to the remote one by one or in bulk; the remote mergeable state is visible, and you can merge with one click when conditions are met (or via `/merge`). +- **Comment interaction** — reply to / edit / delete your own comments; supports emoji reactions, @-mention completion, image attachments (paste / pick to upload), and `:shortcode:` emoji rendering (as each platform allows). +- **Activity timeline** — comments / commit updates / review verdicts are merged into a single timeline (GitHub / Bitbucket). +- **Notifications** — categorized system notifications for new PRs, comment replies, and @-mentions, only for pending PRs and never for already-decided items; click to jump straight to the PR or code line, with a macOS dock badge showing how many comments await your response. -#### ⚙️ 模型与规则 +#### ⚙️ Models and rules -- **多 LLM Provider** —— OpenAI / openai-compatible / DeepSeek / Anthropic / 通义千问 / 火山方舟等(本地 Ollama 经 openai-compatible 的 `/v1` 接入);也可通过本机已授权的本地 CLI 工具(如 claude / codex)调用第三方模型。 -- **个性化规则** —— 每位 Reviewer 维护自己的规则目录(markdown + frontmatter,支持子目录递归组织),按项目 / 仓库 / 目标分支命中;命中的多条规则按 Ruleset 分段一并注入评审,`priority` 控制排序。 -- **运行参数可调** —— 评审任务并发、输入上下文长度、Agent 策略(自动追问开关、追问 / 代码建议数量)均可在设置页调整。 +- **Multiple LLM providers** — OpenAI / openai-compatible / DeepSeek / Anthropic / Tongyi Qianwen / Volcengine Ark, and more (local Ollama connects via the openai-compatible `/v1` endpoint); you can also call third-party models through an authorized local CLI tool (e.g. claude / codex). +- **Personalized rules** — each reviewer maintains their own rules directory (markdown + frontmatter, organized recursively into subdirectories), matched by project / repository / target branch; matched rules are injected into the review in Ruleset sections, ordered by `priority`. +- **Tunable runtime parameters** — review task concurrency, input context length, and agent strategy (auto follow-up toggle, number of follow-ups / code suggestions) are all adjustable on the settings page. -#### 🔌 外部集成与 CLI +#### 🔌 External integration and CLI -- **可供外部 Agent 集成** —— PR 评审能力经本机 HTTP 接口 + 跨平台 CLI 对外开放,让本机 agentic 工具(如 claude / codex)、脚本、CI 把 PR 发现 / 浏览 / 评审 Agent 操作纳入自动化流程。 -- **本地 API 服务** —— 可选开启一个本机 API,将 PR 发现 / 浏览 / diff / 评审 Agent 操作 / 评审写动作以语言无关的 HTTP 契约开放出来;默认仅本机可达、强制访问令牌鉴权,不开放合并与变更类工具。 -- **跨平台命令行工具 `meebox`** —— 随发布提供 Windows / macOS / Linux 客户端,经本地 API 浏览 PR、驱动评审 Agent 并执行评审写动作(approve / needswork / comment);`meebox login` 一次存好凭据即可免传参,**压缩包即 agent skill 目录、可直接投放到 agent 的 skills 目录**。用法见 **[CLI 命令行工具](docs/guide/06-cli.md)**。 +- **Integrable by external agents** — PR review capabilities are exposed via a local HTTP interface + a cross-platform CLI, letting local agentic tools (e.g. claude / codex), scripts, and CI fold PR discovery / browsing / review-agent operations into automated workflows. +- **Local API service** — optionally enable a local API that exposes PR discovery / browsing / diff / review-agent operations / review write actions over a language-agnostic HTTP contract; reachable only from localhost by default, with mandatory access-token auth, and no merge or mutating tools exposed. +- **Cross-platform CLI `meebox`** — Windows / macOS / Linux clients ship with each release to browse PRs, drive the review agent, and perform review write actions (approve / needswork / comment) via the local API; `meebox login` stores credentials once so you never pass them again, and **the archive is itself an agent skill directory that drops straight into an agent's skills folder**. See **[CLI tool](docs/guide/06-cli.md)** for usage. -#### 🎨 界面与体验 +#### 🎨 Interface and experience -- **主题与外观** —— 深色 / 浅色 / 跟随系统,多款编辑器配色主题,自定义等宽字体与字号。 -- **命令面板** —— `Ctrl/Cmd+Shift+P` 唤起,快速执行常用操作并归口分散功能。 -- **多语言界面** —— 简体中文 / English / 日本語 / Deutsch,AI 回复语言随界面语言。 -- **无边框窗口** —— 自绘标题栏(VS Code 风),展示品牌名与当前 PR 标题。 +- **Themes and appearance** — dark / light / follow system, several editor color themes, and a custom monospace font and size. +- **Command palette** — invoke with `Ctrl/Cmd+Shift+P` to quickly run common actions and centralize scattered features. +- **Multilingual UI** — English / 简体中文 / 日本語 / Deutsch, with the AI's reply language following the UI language. +- **Frameless window** — a custom-drawn title bar (VS Code style) showing the brand name and current PR title. <div align="center"> -<img src="assets/images/screenshot.zh-CN.png" alt="Code Meeseeks 界面预览" width="900" /> +<picture> + <source media="(prefers-color-scheme: dark)" srcset="assets/images/screenshot.dark.png" /> + <img src="assets/images/screenshot.light.png" alt="Code Meeseeks UI preview" width="900" /> +</picture> </div> --- -## 安装 +## Installation -到 [Releases](../../releases) 下载对应平台安装包: +Download the installer for your platform from [Releases](../../releases): -| 平台 | 产物 | 状态 | -| ----------- | ---------------------------------------------------- | ------- | -| Windows x64 | `code-meeseeks-<version>-win-x64.exe`(NSIS 安装包) | ✅ 可用 | -| macOS arm64 | `code-meeseeks-<version>-mac-arm64.dmg` | ✅ 可用 | +| Platform | Artifact | Status | +| ----------- | ---------------------------------------------------- | ----------- | +| Windows x64 | `code-meeseeks-<version>-win-x64.exe` (NSIS installer) | ✅ Available | +| macOS arm64 | `code-meeseeks-<version>-mac-arm64.dmg` | ✅ Available | -安装包已内嵌 pr-agent,安装后即可使用,无需额外环境。 +pr-agent is embedded in the installer, so it works right after installation with no extra environment setup. -> **macOS 首次打开**:安装包为 ad-hoc 签名、未做 Apple 公证(notarization),Gatekeeper 会拦下未知开发者的 App。首次使用请右键点击 App 选「打开」,或到「系统设置 → 隐私与安全性」点「仍要打开」,确认一次后即可正常启动。 +> **First launch on macOS**: the installer is ad-hoc signed and not Apple-notarized, so Gatekeeper will block the app from an unknown developer. On first use, right-click the app and choose "Open", or go to "System Settings → Privacy & Security" and click "Open Anyway"; after confirming once it launches normally. > -> 之所以未公证:本项目是**免费开源软件**,未购置 Apple Developer 付费账号(公证依赖该账号)。源码完全公开、可自行审计与构建,ad-hoc 签名不影响功能与安全。 +> Why it isn't notarized: this project is **free and open-source software** without a paid Apple Developer account (which notarization requires). The source is fully public, auditable, and buildable yourself; ad-hoc signing does not affect functionality or security. --- -## 快速上手 +## Quick start -1. **配置连接** —— 设置页填入代码平台地址 + 鉴权信息。 -2. **配置 LLM** —— 选择 Provider,填 API Key / base_url / 模型名。 -3. **发现 PR** —— 应用自动轮询拉取评审者待评审的 PR,左侧列表按仓库分组。 -4. **阅读 + 评审** —— 选中 PR 看 diff,点击自动评审按钮让 AI 生成评审发现;也可在对话框输入固定指令(如 `/describe`)或自然语言请求。 -5. **确认 + 发布** —— 把评审发现转成草稿、编辑措辞,单条或批量发布到远端。 +1. **Configure the connection** — enter the code platform URL + credentials on the settings page. +2. **Configure the LLM** — pick a provider and fill in the API key / base_url / model name. +3. **Discover PRs** — the app automatically polls for PRs awaiting your review, grouped by repository in the left list. +4. **Read + review** — select a PR to view the diff, and click the auto-review button to have the AI generate findings; you can also enter fixed commands (e.g. `/describe`) or a natural-language request in the dialog box. +5. **Confirm + publish** — turn findings into drafts, edit the wording, and publish to the remote one by one or in bulk. -配置存放在 `~/.code-meeseeks/config.yaml`;仓库镜像默认在 `~/.code-meeseeks/repos/`,可在设置页改到其他目录。 +Configuration lives in `~/.code-meeseeks/config.yaml`; repository mirrors default to `~/.code-meeseeks/repos/` and can be moved elsewhere on the settings page. -> **网络代理**(可选,内网用户):设置页「网络代理」填 HTTP 代理地址 / 端口 / Basic Auth,开启后 LLM 调用、代码平台、git 拉取统一经代理,本地地址自动直连(含「测试连通」按钮)。 +> **HTTP proxy** (optional, for intranet users): under "Network proxy" on the settings page, enter the HTTP proxy host / port / Basic Auth; once enabled, LLM calls, code platforms, and git fetches all go through the proxy, with local addresses connecting directly (includes a "Test connection" button). > -> **SSH 方式的 git 拉取**不走此配置,请在 `~/.ssh/config` 为对应 host 自配 `ProxyCommand`。 +> **Git fetches over SSH** do not use this setting; configure `ProxyCommand` for the relevant host in `~/.ssh/config` yourself. -各步的详细说明(安装与首次使用、PAT 权限与 Clone 协议、LLM 模型选择、网络代理)见 **[使用说明](docs/guide/README.md)**。 +For details on each step (installation and first use, PAT permissions and clone protocols, LLM model selection, HTTP proxy) see the **[User guide](docs/guide/README.md)**. --- -## 平台支持 +## Platform support -| 平台 | 状态 | +| Platform | Status | | ------------------------------ | --------------------------------------------------------------- | -| GitHub | ✅ 已验证(github.com + GitHub Enterprise Server,REST API v3) | -| Bitbucket Server / Data Center | ✅ 已支持(REST API v1,>= 7.0) | -| GitLab | ✅ 已支持(gitlab.com + Self-Managed,CE / EE,REST API v4,>= 13.8,推荐 15.6+) | +| GitHub | ✅ Verified (github.com + GitHub Enterprise Server, REST API v3) | +| Bitbucket Server / Data Center | ✅ Supported (REST API v1, >= 7.0) | +| GitLab | ✅ Supported (gitlab.com + Self-Managed, CE / EE, REST API v4, >= 13.8, 15.6+ recommended) | --- -## 模型支持 +## Model support -评审能力经 pr-agent(底层 litellm)接入,**理论上兼容任意 OpenAI 兼容 / litellm 支持的模型供应商**(在设置页选模型供应商,填 API Key、base_url、模型名即可)。下表为设置页内置的厂商选项及实测状态: +Review capabilities connect through pr-agent (backed by litellm), so it is **in principle compatible with any OpenAI-compatible / litellm-supported model provider** (pick the provider on the settings page and fill in the API key, base_url, and model name). The table below lists the built-in provider options on the settings page and their tested status: -| 模型供应商(厂商) | 说明 | 状态 | -| ------------------- | ------------------------------------------- | ------------------- | -| `openai` | OpenAI(GPT 系) | ✅ 已验证 | -| `anthropic` | Anthropic(Claude 系) | ✅ 已验证 | -| `deepseek` | DeepSeek | ✅ 已验证 | -| `dashscope` | 阿里百炼(DashScope,通义千问) | ✅ 已验证 | -| `volcengine-ark` | 火山方舟(Volcengine Ark,豆包) | ✅ 已验证 | -| `openai-compatible` | OpenAI 协议兼容(vLLM / 中转 / 自建 / 本地 Ollama 的 `/v1`) | ✅ 已验证 | -| `cli` | 通过本地 CLI 工具调用第三方模型 | ✅ 已验证 | +| Provider | Notes | Status | +| ------------------- | ------------------------------------------- | ------------ | +| `openai` | OpenAI (GPT family) | ✅ Verified | +| `anthropic` | Anthropic (Claude family) | ✅ Verified | +| `deepseek` | DeepSeek | ✅ Verified | +| `dashscope` | Alibaba Bailian (DashScope, Tongyi Qianwen) | ✅ Verified | +| `volcengine-ark` | Volcengine Ark (Doubao) | ✅ Verified | +| `openai-compatible` | OpenAI-protocol compatible (vLLM / gateway / self-hosted / local Ollama `/v1`) | ✅ Verified | +| `cli` | Call a third-party model via a local CLI tool | ✅ Verified | -> **本地 CLI 模式说明**:该模式不直连模型 API,而是把评审请求转交给使用者**自行安装并授权**的本地命令行工具,由其代为调用背后的第三方模型。需先在本机完成对应 CLI 工具的安装与登录授权,应用本身不负责其凭据管理与计费。 +> **About local CLI mode**: this mode does not call a model API directly; instead it hands the review request to a local command-line tool that you **install and authorize yourself**, which in turn calls the third-party model behind it. You must first install and log in to the corresponding CLI tool on your machine; the app itself does not manage its credentials or billing. -> 💸 **成本提示**:Agentic 评审与 AutoPilot 预评审会对每个 PR 串联多次模型调用(描述、评审、必要时追问、汇总),token 消耗显著高于单次手动评审。无论使用按量计费的 API、还是有额度上限的订阅 / 本地 CLI 账户,都请留意用量节奏,自行评估成本投入;每步的 token 用量已在评审时间线上分步展示,便于观察消耗。 +> 💸 **Cost note**: Agentic review and AutoPilot pre-review chain multiple model calls per PR (describe, review, follow-ups when needed, summarize), so token consumption is noticeably higher than a single manual review. Whether you use a pay-as-you-go API or a subscription / local CLI account with a quota cap, watch your usage pace and assess the cost yourself; per-step token usage is shown step by step on the review timeline for easy monitoring. --- -## 技术栈 +## Tech stack -- **桌面壳**:Electron + Vite(electron-vite) -- **渲染层**:React + TypeScript(strict) -- **编辑器**:Monaco(并排 / 内联 diff) -- **工程**:npm workspaces + Nx 单仓多包 -- **pr-agent 集成**:内嵌 Python 运行时子进程(缺失时回退系统 pr-agent CLI) +- **Desktop shell**: Electron + Vite (electron-vite) +- **Renderer**: React + TypeScript (strict) +- **Editor**: Monaco (side-by-side / inline diff) +- **Engineering**: npm workspaces + Nx monorepo +- **pr-agent integration**: an embedded Python-runtime subprocess (falls back to the system pr-agent CLI when absent) -> 📚 **延伸阅读** +> 📚 **Further reading** > -> - 已交付能力、规划与未决项见 **[Roadmap](docs/ROADMAP.md)** -> - 详细架构与各模块设计见 **[模块文档](docs/arch/README.md)** +> - Delivered capabilities, roadmap, and open items: **[Roadmap](docs/ROADMAP.md)** +> - Detailed architecture and per-module design: **[Module docs](docs/arch/README.md)** --- -## 开发 +## Development -环境准备、启动调试、构建打包步骤见 **[开发指南](docs/development/README.md)**。 +For environment setup, running in dev, and building / packaging, see the **[Development guide](docs/development/README.md)**. --- -## 隐私与数据 +## Privacy and data -- **本地优先**:除调用 LLM API 与访问所配置的 Git 平台外,不向任何第三方上报数据。 -- **工作目录**:应用数据固定在 `~/.code-meeseeks/`,仓库镜像目录可配置。 -- pr-agent 评审时仅把 PR diff + 评审者的规则发给评审者自行配置的 LLM;接入本地模型(如本地 Ollama)即可全程不出本机。 +- **Local-first**: apart from calling the LLM API and accessing the configured Git platform, no data is reported to any third party. +- **Working directory**: app data lives at `~/.code-meeseeks/`, with a configurable repository mirror directory. +- During review, pr-agent sends only the PR diff + the reviewer's rules to the reviewer's own configured LLM; wire up a local model (e.g. local Ollama) and nothing ever leaves your machine. --- -## 致谢 +## Acknowledgements -构建于 [PR-Agent](https://github.com/The-PR-Agent/pr-agent) 之上 —— Qodo 贡献给社区的开源版本(官网 [docs.pr-agent.ai](https://docs.pr-agent.ai/))。作为第三方依赖打包,按其自身许可证分发,**不在本项目重命名 / 改动范围内**。 +Built on top of [PR-Agent](https://github.com/The-PR-Agent/pr-agent) — the open-source version Qodo contributes to the community (site: [docs.pr-agent.ai](https://docs.pr-agent.ai/)). It is bundled as a third-party dependency, distributed under its own license, and **outside the renaming / modification scope of this project**. -## 许可证 +## License -本项目采用 [Apache License 2.0](LICENSE)。 +This project is licensed under the [Apache License 2.0](LICENSE). -打包分发的安装包内含第三方组件(PR-Agent、Electron 等),各按其自身许可证分发,归集见 [NOTICE](NOTICE);完整第三方许可(THIRD-PARTY-NOTICES)出包时自动生成并随安装包内置(位于 App 资源目录)。 +The distributed installers bundle third-party components (PR-Agent, Electron, etc.), each distributed under its own license, aggregated in [NOTICE](NOTICE); the full third-party license set (THIRD-PARTY-NOTICES) is generated automatically at build time and bundled into the installer (in the app's resources directory). -## 商标与免责声明 +## Trademarks and disclaimer -本项目为非官方、独立的开源工具,**与 _Rick and Morty_ 及其权利方无任何关联,亦未获其授权或认可**。「Rick and Morty」「Mr. Meeseeks」等名称、角色及相关元素的版权与商标归其各自权利人所有(Adult Swim / Warner Bros. Discovery 等)。本项目名称与图标仅出于致敬目的进行借用,不主张任何相关权利;如权利方有异议,将配合调整。 +This project is an unofficial, independent open-source tool, **not affiliated with, authorized by, or endorsed by _Rick and Morty_ or its rights holders in any way**. Names, characters, and related elements such as "Rick and Morty" and "Mr. Meeseeks" are the copyrights and trademarks of their respective owners (Adult Swim / Warner Bros. Discovery, etc.). This project's name and icon are borrowed purely as an homage and assert no related rights; should the rights holders object, we will adjust accordingly. --- diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..cd9729d6 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,229 @@ +<div align="center"> + +<img src="assets/icons/icon.png" alt="Code Meeseeks" width="96" /> + +# Code Meeseeks + +**PR Agent 的桌面 GUI · 面向 Reviewer 个人的本地化、半自动 AI 代码评审客户端** + +社区版 [PR-Agent](https://docs.pr-agent.ai/) 的图形界面 (GUI) · Electron 桌面应用 · 数据全部留在本地 + +[![Electron](https://img.shields.io/badge/Electron-2B2E3A?logo=electron&logoColor=9FEAF9)](https://www.electronjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Release](https://img.shields.io/github/v/release/huhamhire/code-meeseeks?include_prereleases&sort=semver&label=release&color=4c9a40)](https://github.com/huhamhire/code-meeseeks/releases) + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/) +[![Bitbucket](https://img.shields.io/badge/Bitbucket-0052CC?logo=bitbucket&logoColor=white)](https://bitbucket.org/) +[![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://gitlab.com/) + +<sub>关键词 / Keywords:PR-Agent GUI · pr-agent desktop client · AI 代码评审 / AI code review · Pull Request & Merge Request review · Bitbucket / GitHub reviewer 工具 · 本地化 / 私有部署 / self-hosted</sub> + +[English](README.md) · **简体中文** · [官网](https://huhamhire.github.io/code-meeseeks/) + +</div> + +--- + +Code Meeseeks(内部开发代号 `meebox`)是命令行工具 [pr-agent](https://docs.pr-agent.ai/) 的**桌面图形界面 (GUI)**:把 AI 辅助的代码评审装进一个桌面客户端 —— 拉取评审者待评审的 PR(Pull Request / Merge Request),本地跑 pr-agent 生成评审意见,由评审者**逐条确认 / 编辑后**再发布到代码托管平台(GitHub / Bitbucket / GitLab)。 + +核心设计立场: + +- **决策权在人** —— 所有评论必须经评审者二次确认 / 编辑才会发到远端,AI 只做草稿。 +- **规则在本地** —— 评审者自行配置检查规则、风格偏好、LLM Provider。 +- **数据在本地** —— 仓库副本、PR 元数据、评论草稿都存在本机工作目录,企业内网友好。 + +> 灵感来自 _Rick and Morty_ 里的 Mr. Meeseeks:召之即来、专做一件事、做完即走。 + +## 适用场景 + +- ✅ 承担 code review 职责的工程师 / Tech Lead +- ✅ 希望借助 AI 提升评审效率,同时保留最终决策权、不将判断完全交由 bot +- ✅ 在企业内网环境中使用自建 Bitbucket / GitLab 的团队 + +## 不适用场景 + +- ❌ 不是在 CI 中自动运行的 review bot(该定位属于 pr-agent 本身) +- ❌ 不是团队协同评审平台(无服务端,不提供多用户同步) +- ❌ 不替代代码托管平台原生的评审界面 + +--- + +## 核心特性 + +#### 🌍 多平台接入 + +- **统一接入 GitHub / Bitbucket / GitLab** —— 自建 GitHub Enterprise / GitLab Self-Managed 直接填实例地址,按平台能力自适应(如 GitLab CE/EE 审批降级)。 +- **本地优先,开箱即用** —— 仓库副本、PR 元数据、评论草稿都存本机工作目录,企业内网友好;安装包内嵌 pr-agent,零外部依赖。 +- **网络代理** —— LLM 调用、代码平台、git 拉取统一经 HTTP 代理,本地地址自动直连。 + +#### 📥 PR 发现与浏览 + +- **自动发现** —— 轮询拉取待评审 PR,按「待我评审 / 我创建」等分类与仓库分组,支持状态过滤与搜索。 +- **未读与点名标记** —— 新分配 / 新提交 / 被 @ / 被回复标未读,其中被点名的条数单独计数。 +- **历史与按需打开** —— 浏览已合并 / 已关闭的 PR,或按 URL 直接打开任意 PR(含补充评论、补跑评审)。 + +#### 🔍 本地 Diff 阅读 + +- **并排 / 内联 diff** —— 编辑器级代码阅读体验:文件树(合并冲突标注)、按变更范围 / 单 commit 查看、滚动条总览标尺、blame、跨文件代码搜索。 +- **行内评论** —— 新增行与删除行均可评论;选中代码可作为上下文引用进提问。 + +#### 🤖 AI / Agentic 评审 + +- **指令驱动 pr-agent** —— `/describe`、`/review`、`/improve`、`/ask` 对话式驱动,结果结构化成可操作的评审发现。 +- **复评闭环** —— 对评审建议发起 `/ask` 复评,按裁决(取代 / 保留 / 撤销)自动取代或关闭原评论。 +- **Agentic 自主编排** —— 自然语言驱动的规划 + 多工具编排 + 长期 Memory,过程可观测(think → tool → think 时间线),可中途追加输入、随时停止,让评审走向可累积上下文的协作。 +- **AutoPilot 预评审** —— 对待我评审·待处理的新 PR 自动预跑评审,进应用即见待确认草稿;写操作经逐项授权 + 红线校验把关(默认仅开放只读工具)。 + +#### ✍️ 评审闭环与协作 + +- **确认 → 发布** —— 评审发现转草稿、行内编辑、单条 / 批量发布到远端;远端可合并状态可视、满足条件一键合并(亦可对话 `/merge`)。 +- **评论互动** —— 自己的评论可回复 / 编辑 / 删除;支持 emoji 反应、@ 提及补全、图片附件(粘贴 / 选取上传)、`:shortcode:` 表情渲染(随平台能力提供)。 +- **活动时间线** —— 评论 / 提交更新 / 评审决断归并为一条时间线(GitHub / Bitbucket)。 +- **消息通知** —— 新 PR、评论回复、被 @ 分类弹系统通知,仅对待处理 PR 提醒、不打扰已决断项;点击直达对应 PR 或代码行,macOS dock 角标显示待你回应的评论数。 + +#### ⚙️ 模型与规则 + +- **多 LLM Provider** —— OpenAI / openai-compatible / DeepSeek / Anthropic / 通义千问 / 火山方舟等(本地 Ollama 经 openai-compatible 的 `/v1` 接入);也可通过本机已授权的本地 CLI 工具(如 claude / codex)调用第三方模型。 +- **个性化规则** —— 每位 Reviewer 维护自己的规则目录(markdown + frontmatter,支持子目录递归组织),按项目 / 仓库 / 目标分支命中;命中的多条规则按 Ruleset 分段一并注入评审,`priority` 控制排序。 +- **运行参数可调** —— 评审任务并发、输入上下文长度、Agent 策略(自动追问开关、追问 / 代码建议数量)均可在设置页调整。 + +#### 🔌 外部集成与 CLI + +- **可供外部 Agent 集成** —— PR 评审能力经本机 HTTP 接口 + 跨平台 CLI 对外开放,让本机 agentic 工具(如 claude / codex)、脚本、CI 把 PR 发现 / 浏览 / 评审 Agent 操作纳入自动化流程。 +- **本地 API 服务** —— 可选开启一个本机 API,将 PR 发现 / 浏览 / diff / 评审 Agent 操作 / 评审写动作以语言无关的 HTTP 契约开放出来;默认仅本机可达、强制访问令牌鉴权,不开放合并与变更类工具。 +- **跨平台命令行工具 `meebox`** —— 随发布提供 Windows / macOS / Linux 客户端,经本地 API 浏览 PR、驱动评审 Agent 并执行评审写动作(approve / needswork / comment);`meebox login` 一次存好凭据即可免传参,**压缩包即 agent skill 目录、可直接投放到 agent 的 skills 目录**。用法见 **[CLI 命令行工具](docs/guide/zh-CN/06-cli.md)**。 + +#### 🎨 界面与体验 + +- **主题与外观** —— 深色 / 浅色 / 跟随系统,多款编辑器配色主题,自定义等宽字体与字号。 +- **命令面板** —— `Ctrl/Cmd+Shift+P` 唤起,快速执行常用操作并归口分散功能。 +- **多语言界面** —— English / 简体中文 / 日本語 / Deutsch,AI 回复语言随界面语言。 +- **无边框窗口** —— 自绘标题栏(VS Code 风),展示品牌名与当前 PR 标题。 + +<div align="center"> + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="assets/images/screenshot.dark.png" /> + <img src="assets/images/screenshot.light.png" alt="Code Meeseeks 界面预览" width="900" /> +</picture> + +</div> + +--- + +## 安装 + +到 [Releases](../../releases) 下载对应平台安装包: + +| 平台 | 产物 | 状态 | +| ----------- | ---------------------------------------------------- | ------- | +| Windows x64 | `code-meeseeks-<version>-win-x64.exe`(NSIS 安装包) | ✅ 可用 | +| macOS arm64 | `code-meeseeks-<version>-mac-arm64.dmg` | ✅ 可用 | + +安装包已内嵌 pr-agent,安装后即可使用,无需额外环境。 + +> **macOS 首次打开**:安装包为 ad-hoc 签名、未做 Apple 公证(notarization),Gatekeeper 会拦下未知开发者的 App。首次使用请右键点击 App 选「打开」,或到「系统设置 → 隐私与安全性」点「仍要打开」,确认一次后即可正常启动。 +> +> 之所以未公证:本项目是**免费开源软件**,未购置 Apple Developer 付费账号(公证依赖该账号)。源码完全公开、可自行审计与构建,ad-hoc 签名不影响功能与安全。 + +--- + +## 快速上手 + +1. **配置连接** —— 设置页填入代码平台地址 + 鉴权信息。 +2. **配置 LLM** —— 选择 Provider,填 API Key / base_url / 模型名。 +3. **发现 PR** —— 应用自动轮询拉取评审者待评审的 PR,左侧列表按仓库分组。 +4. **阅读 + 评审** —— 选中 PR 看 diff,点击自动评审按钮让 AI 生成评审发现;也可在对话框输入固定指令(如 `/describe`)或自然语言请求。 +5. **确认 + 发布** —— 把评审发现转成草稿、编辑措辞,单条或批量发布到远端。 + +配置存放在 `~/.code-meeseeks/config.yaml`;仓库镜像默认在 `~/.code-meeseeks/repos/`,可在设置页改到其他目录。 + +> **网络代理**(可选,内网用户):设置页「网络代理」填 HTTP 代理地址 / 端口 / Basic Auth,开启后 LLM 调用、代码平台、git 拉取统一经代理,本地地址自动直连(含「测试连通」按钮)。 +> +> **SSH 方式的 git 拉取**不走此配置,请在 `~/.ssh/config` 为对应 host 自配 `ProxyCommand`。 + +各步的详细说明(安装与首次使用、PAT 权限与 Clone 协议、LLM 模型选择、网络代理)见 **[使用说明](docs/guide/zh-CN/README.md)**。 + +--- + +## 平台支持 + +| 平台 | 状态 | +| ------------------------------ | --------------------------------------------------------------- | +| GitHub | ✅ 已验证(github.com + GitHub Enterprise Server,REST API v3) | +| Bitbucket Server / Data Center | ✅ 已支持(REST API v1,>= 7.0) | +| GitLab | ✅ 已支持(gitlab.com + Self-Managed,CE / EE,REST API v4,>= 13.8,推荐 15.6+) | + +--- + +## 模型支持 + +评审能力经 pr-agent(底层 litellm)接入,**理论上兼容任意 OpenAI 兼容 / litellm 支持的模型供应商**(在设置页选模型供应商,填 API Key、base_url、模型名即可)。下表为设置页内置的厂商选项及实测状态: + +| 模型供应商(厂商) | 说明 | 状态 | +| ------------------- | ------------------------------------------- | ------------------- | +| `openai` | OpenAI(GPT 系) | ✅ 已验证 | +| `anthropic` | Anthropic(Claude 系) | ✅ 已验证 | +| `deepseek` | DeepSeek | ✅ 已验证 | +| `dashscope` | 阿里百炼(DashScope,通义千问) | ✅ 已验证 | +| `volcengine-ark` | 火山方舟(Volcengine Ark,豆包) | ✅ 已验证 | +| `openai-compatible` | OpenAI 协议兼容(vLLM / 中转 / 自建 / 本地 Ollama 的 `/v1`) | ✅ 已验证 | +| `cli` | 通过本地 CLI 工具调用第三方模型 | ✅ 已验证 | + +> **本地 CLI 模式说明**:该模式不直连模型 API,而是把评审请求转交给使用者**自行安装并授权**的本地命令行工具,由其代为调用背后的第三方模型。需先在本机完成对应 CLI 工具的安装与登录授权,应用本身不负责其凭据管理与计费。 + +> 💸 **成本提示**:Agentic 评审与 AutoPilot 预评审会对每个 PR 串联多次模型调用(描述、评审、必要时追问、汇总),token 消耗显著高于单次手动评审。无论使用按量计费的 API、还是有额度上限的订阅 / 本地 CLI 账户,都请留意用量节奏,自行评估成本投入;每步的 token 用量已在评审时间线上分步展示,便于观察消耗。 + +--- + +## 技术栈 + +- **桌面壳**:Electron + Vite(electron-vite) +- **渲染层**:React + TypeScript(strict) +- **编辑器**:Monaco(并排 / 内联 diff) +- **工程**:npm workspaces + Nx 单仓多包 +- **pr-agent 集成**:内嵌 Python 运行时子进程(缺失时回退系统 pr-agent CLI) + +> 📚 **延伸阅读** +> +> - 已交付能力、规划与未决项见 **[Roadmap](docs/ROADMAP.md)** +> - 详细架构与各模块设计见 **[模块文档](docs/arch/README.md)** + +--- + +## 开发 + +环境准备、启动调试、构建打包步骤见 **[开发指南](docs/development/README.md)**。 + +--- + +## 隐私与数据 + +- **本地优先**:除调用 LLM API 与访问所配置的 Git 平台外,不向任何第三方上报数据。 +- **工作目录**:应用数据固定在 `~/.code-meeseeks/`,仓库镜像目录可配置。 +- pr-agent 评审时仅把 PR diff + 评审者的规则发给评审者自行配置的 LLM;接入本地模型(如本地 Ollama)即可全程不出本机。 + +--- + +## 致谢 + +构建于 [PR-Agent](https://github.com/The-PR-Agent/pr-agent) 之上 —— Qodo 贡献给社区的开源版本(官网 [docs.pr-agent.ai](https://docs.pr-agent.ai/))。作为第三方依赖打包,按其自身许可证分发,**不在本项目重命名 / 改动范围内**。 + +## 许可证 + +本项目采用 [Apache License 2.0](LICENSE)。 + +打包分发的安装包内含第三方组件(PR-Agent、Electron 等),各按其自身许可证分发,归集见 [NOTICE](NOTICE);完整第三方许可(THIRD-PARTY-NOTICES)出包时自动生成并随安装包内置(位于 App 资源目录)。 + +## 商标与免责声明 + +本项目为非官方、独立的开源工具,**与 _Rick and Morty_ 及其权利方无任何关联,亦未获其授权或认可**。「Rick and Morty」「Mr. Meeseeks」等名称、角色及相关元素的版权与商标归其各自权利人所有(Adult Swim / Warner Bros. Discovery 等)。本项目名称与图标仅出于致敬目的进行借用,不主张任何相关权利;如权利方有异议,将配合调整。 + +--- + +<div align="center"> + +Made on 🌏 with ♥️. + +</div> diff --git a/apps/desktop/build-resources/after-pack.cjs b/apps/desktop/build-resources/after-pack.cjs index 00e53fdd..262d0ead 100644 --- a/apps/desktop/build-resources/after-pack.cjs +++ b/apps/desktop/build-resources/after-pack.cjs @@ -1,16 +1,16 @@ -// electron-builder afterPack 钩子 —— macOS 免费发布路线的 ad-hoc 签名。 +// electron-builder afterPack hook — ad-hoc signing for the macOS free release route. // -// 背景:Apple Silicon(arm64) 上任何 Mach-O 必须带有效签名才能执行;未签名的 -// 嵌入式 python 解释器 / .dylib / .so 会在 spawn 时直接崩。没有 Apple Developer ID -// 时无法公证,但可以用 ad-hoc 身份(`codesign -s -`)免费签名让二进制能跑。 +// Background: on Apple Silicon(arm64) any Mach-O must carry a valid signature to execute; unsigned +// embedded python interpreter / .dylib / .so crash directly on spawn. Without an Apple Developer ID +// notarization is impossible, but an ad-hoc identity (`codesign -s -`) can sign for free so the binaries run. // -// 行为: -// - 仅在打 macOS 包时动作;win / linux 直接跳过。 -// - 若检测到真实签名凭据(env),跳过 —— 交回 electron-builder 走正式签名 + 公证。 -// - 否则对整个 .app 递归 ad-hoc 签名(含 Contents/Resources/pragent 下的嵌入式 python)。 +// Behavior: +// - Only acts when packaging macOS; win / linux skip directly. +// - If real signing credentials (env) are detected, skip — handing back to electron-builder for proper signing + notarization. +// - Otherwise recursively ad-hoc sign the whole .app (including the embedded python under Contents/Resources/pragent). // -// 注意:ad-hoc 签名只让二进制能运行,不去除 Gatekeeper 警告(仍需用户首次"仍要打开" -// 或走 Homebrew)。见 docs/mac-build.md。 +// Note: ad-hoc signing only lets the binaries run, it does not remove the Gatekeeper warning (users still need "Open anyway" on first launch +// or go via Homebrew). See docs/mac-build.md. const { execFileSync } = require('node:child_process'); const path = require('node:path'); @@ -19,7 +19,7 @@ const path = require('node:path'); exports.default = async function afterPack(context) { if (context.electronPlatformName !== 'darwin') return; - // 有真证书 / 公证凭据时不做 ad-hoc,让 electron-builder 接管正式签名 + 公证 + // With a real certificate / notarization credentials, skip ad-hoc and let electron-builder take over proper signing + notarization const hasRealIdentity = Boolean( process.env.CSC_LINK || process.env.CSC_NAME || @@ -27,18 +27,18 @@ exports.default = async function afterPack(context) { process.env.APPLE_ID, ); if (hasRealIdentity) { - console.log('[after-pack] 检测到 Apple 签名凭据,跳过 ad-hoc(走正式签名 + 公证)'); + console.log('[after-pack] Apple signing credentials detected, skipping ad-hoc (using proper signing + notarization)'); return; } const appName = `${context.packager.appInfo.productFilename}.app`; const appPath = path.join(context.appOutDir, appName); - console.log(`[after-pack] ad-hoc 递归签名(免费路线,不公证): ${appPath}`); + console.log(`[after-pack] ad-hoc recursive signing (free route, not notarized): ${appPath}`); - // --force 覆盖既有签名;--deep 递归签 bundle 内嵌套代码(含嵌入式 python 的 Mach-O)。 - // ad-hoc 身份为 "-"。若个别 .so 仍报签名无效,见 docs/mac-build.md §嵌入式 python 补签。 + // --force overwrites the existing signature; --deep recursively signs nested code inside the bundle (including the embedded python Mach-O). + // The ad-hoc identity is "-". If an individual .so still reports an invalid signature, see docs/mac-build.md §embedded python re-signing. execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'inherit', }); - console.log('[after-pack] ad-hoc 签名完成'); + console.log('[after-pack] ad-hoc signing done'); }; diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f9855092..8966069d 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -2,17 +2,17 @@ appId: com.huhamhire.code-meeseeks productName: Code Meeseeks copyright: Copyright © 2026 huhamhire -# npm workspaces hoist 下 electron-builder 无法从 apps/desktop/node_modules -# 推算出 electron 实际版本,必须显式声明(保持与 devDependencies 一致)。 +# Under npm workspaces hoisting, electron-builder cannot infer electron's actual version +# from apps/desktop/node_modules, so it must be declared explicitly (keep in sync with devDependencies). electronVersion: 42.3.0 -# macOS 免费路线:对打出的 .app 做 ad-hoc 递归签名(arm64 必需,含嵌入式 python)。 -# 有 Apple 签名凭据 env 时自动跳过,交回正式签名 + 公证。win/linux 无动作。 +# macOS free route: ad-hoc recursive signing of the built .app (required on arm64, includes embedded python). +# Automatically skipped when Apple signing credentials env is present, handing back to proper signing + notarization. No-op on win/linux. afterPack: build-resources/after-pack.cjs -# 不使用 electron 自动更新;声明 GitHub provider 仅为让 electron-builder 能算出更新通道 -# (computeChannelNames),消除 CI 上「无法从 .git/config 探测仓库」导致的 null 崩溃。 -# 实际发布由 workflow 的 softprops 上传,构建步骤用 --publish never 只生成本地元数据、不上传。 +# Not using electron auto-update; the GitHub provider is declared only so electron-builder can compute the update channel +# (computeChannelNames), eliminating the null crash on CI caused by "cannot detect repository from .git/config". +# Actual release is uploaded by the workflow's softprops; the build step uses --publish never to only generate local metadata, no upload. publish: provider: github owner: huhamhire @@ -20,11 +20,11 @@ publish: directories: output: release - # 指向已入库的 build-resources/:其中的 installer.nsh 会被 electron-builder **自动收录** - # (auto-include `${buildResources}/installer.nsh`,路径无歧义)。原默认 build/ 被 gitignore、未用。 + # Point at the checked-in build-resources/: its installer.nsh is **auto-included** by electron-builder + # (auto-include `${buildResources}/installer.nsh`, path is unambiguous). The original default build/ is gitignored and unused. buildResources: build-resources -# 入口 + 所有 bundle 产物。out/* 是 electron-vite build 的输出。 +# Entry point + all bundle artifacts. out/* is the output of electron-vite build. files: - out/**/* - package.json @@ -33,8 +33,8 @@ files: - '!**/*.test.{ts,tsx,js}' - '!**/*.{md,markdown}' -# Workspace 模式下,第三方运行时依赖被 hoist 到根 node_modules。 -# 让 electron-builder 也从那里抽 prod deps(pino / yaml / zod 等)。 +# In workspace mode, third-party runtime dependencies are hoisted to the root node_modules. +# Let electron-builder pull prod deps (pino / yaml / zod etc.) from there too. nodeGypRebuild: false npmRebuild: false @@ -44,28 +44,28 @@ asarUnpack: - '**/node_modules/pino-roll/**' - '**/node_modules/thread-stream/**' -# 嵌入式 pr-agent 运行时(见 ADR-0008):vendor/pragent → <resources>/pragent, -# main 的 resolveEmbeddedPython 打包态走 process.resourcesPath/pragent。extraResources -# 天然落在 asar 外(原生解释器 + .pyd/.dll 必须是真实文件,不能进 asar)。 -# 由构建机宿主平台 prepare:pragent 组装,与所构建的目标平台一致(初版 Windows x64)。 -# __pycache__ 排除以瘦身(首次启动会重新生成 .pyc)。 +# Embedded pr-agent runtime (see ADR-0008): vendor/pragent → <resources>/pragent, +# main's resolveEmbeddedPython uses process.resourcesPath/pragent when packaged. extraResources +# naturally land outside the asar (the native interpreter + .pyd/.dll must be real files, cannot go into the asar). +# Assembled by prepare:pragent on the build machine's host platform, matching the target platform being built (initial version Windows x64). +# __pycache__ is excluded to slim down (first launch regenerates .pyc). extraResources: - from: vendor/pragent to: pragent filter: - '**/*' - '!**/__pycache__/**' - # 第三方声明随包内置(落到 <resources>/THIRD-PARTY-NOTICES.md);由 tools/gen-third-party-notices.mjs - # 在 electron-builder 之前生成(见 dist/pack 脚本与 release workflow)。仓库不入库该文件。 + # Third-party notices bundled with the package (landing at <resources>/THIRD-PARTY-NOTICES.md); generated by tools/gen-third-party-notices.mjs + # before electron-builder (see the dist/pack scripts and release workflow). The repo does not check in this file. - from: ../../THIRD-PARTY-NOTICES.md to: THIRD-PARTY-NOTICES.md - # 启动闪屏 logo:assets 不进 asar / 不随 out 打包,单独 copy 到 <resources>/icon.png, - # 供 main 进程 createSplash 运行时读取并 base64 内联到 splash data URL。 + # Startup splash logo: assets do not go into the asar / are not bundled with out, so copied separately to <resources>/icon.png, + # for the main process createSplash to read at runtime and inline as base64 into the splash data URL. - from: ../../assets/icons/icon.png to: icon.png win: - # 图标源放在资源目录 assets/icons/(build/ 被 gitignore,不用它)。含 16/32/48/256。 + # Icon source lives in the assets/icons/ resource directory (build/ is gitignored, not used). Contains 16/32/48/256. icon: ../../assets/icons/icon.ico target: - target: nsis @@ -74,17 +74,17 @@ win: artifactName: code-meeseeks-${version}-win-${arch}.${ext} mac: - # mac 专用图标:深色圆角底板 + 留边 glyph(透明异形图标在 macOS 会被系统垫白底)。 - # 由 tools/icons/gen-mac-icon.py 从 icon.png 合成;给 ≥512 PNG,electron-builder 自动转 .icns。 + # mac-specific icon: dark rounded backing + padded glyph (transparent non-square icons get a white backing from the system on macOS). + # Composited from icon.png by tools/icons/gen-mac-icon.py; supply a ≥512 PNG and electron-builder auto-converts to .icns. icon: ../../assets/icons/icon-mac.png gatekeeperAssess: false - # 免费路线(无 Apple Developer ID,不公证):ad-hoc 递归签名由 afterPack 完成 - # (arm64 上 Mach-O 必须签名才能跑,含嵌入式 python)。有真证书 env 时 afterPack - # 自动跳过、交回 electron-builder 走正式签名 + 公证(届时再加 hardenedRuntime / - # entitlements / notarize,见 docs/mac-build.md + build-resources/entitlements.mac.plist) + # Free route (no Apple Developer ID, not notarized): ad-hoc recursive signing done by afterPack + # (on arm64 a Mach-O must be signed to run, includes embedded python). With real certificate env, afterPack + # skips automatically, handing back to electron-builder for proper signing + notarization (add hardenedRuntime / + # entitlements / notarize at that point, see docs/mac-build.md + build-resources/entitlements.mac.plist) target: - target: dmg - # 初版仅 arm64(见 ADR-0008);需要 Intel 时再加 x64 + # Initial version arm64 only (see ADR-0008); add x64 when Intel is needed arch: - arm64 category: public.app-category.developer-tools @@ -101,10 +101,10 @@ linux: nsis: oneClick: false - # per-machine 安装(所有用户 / Program Files)。electron-builder 据此定义 INSTALL_MODE_PER_ALL_USERS - # → 安装器清单 RequestExecutionLevel admin(installer.nsi:20-25)→ 双击即弹 UAC、提权运行, - # 避免 perMachine:false(asInvoker) 在已有 per-machine 安装时"按需提权失败→静默退出→打不开"。 - # 升级也变成单一提权实例,customInit 绕过旧卸载器更稳。安装后的应用本体仍 asInvoker、普通启动。 + # per-machine install (all users / Program Files). electron-builder accordingly defines INSTALL_MODE_PER_ALL_USERS + # → installer manifest RequestExecutionLevel admin (installer.nsi:20-25) → double-click prompts UAC and runs elevated, + # avoiding perMachine:false(asInvoker) "on-demand elevation fails → silent exit → won't open" when a per-machine install already exists. + # Upgrades also become a single elevated instance, and customInit bypassing the old uninstaller is more robust. The installed app itself stays asInvoker, normal launch. perMachine: true allowToChangeInstallationDirectory: true - # 自定义注入见 build-resources/installer.nsh —— 由 buildResources auto-include 自动收录,无需显式 include。 + # For custom injection see build-resources/installer.nsh — auto-included via buildResources auto-include, no explicit include needed. diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index b793a4b9..c1c8571f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -2,8 +2,8 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; -// Workspace 内部包源码是 .ts,Node 无法直接 import;让 Vite 把它们 bundle 进主进程/preload, -// 外部第三方依赖(electron / pino / yaml / zod ...)继续 externalize 让 Node 在运行时解析。 +// Workspace internal packages are .ts source that Node cannot import directly; let Vite bundle them into main/preload, +// while external third-party deps (electron / pino / yaml / zod ...) stay externalized for Node to resolve at runtime. const internalPackages = [ '@meebox/shared', '@meebox/ipc', @@ -40,7 +40,7 @@ export default defineConfig({ }, renderer: { root: resolve('src/renderer'), - // 渲染层引用仓库根 assets/(品牌图标等单一来源,避免拷贝重复二进制) + // Renderer references the repo root assets/ (single source for brand icons etc., avoiding duplicate binary copies) resolve: { alias: { '@assets': resolve('../../assets') }, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4747a2b4..fe51ea79 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/apps/desktop/scripts/assemble-pragent-runtime.mjs b/apps/desktop/scripts/assemble-pragent-runtime.mjs index 7705c51f..ade01ef2 100644 --- a/apps/desktop/scripts/assemble-pragent-runtime.mjs +++ b/apps/desktop/scripts/assemble-pragent-runtime.mjs @@ -1,22 +1,22 @@ -// 组装 pr-agent 嵌入式运行时到 apps/desktop/vendor/pragent/。 +// Assemble the pr-agent embedded runtime into apps/desktop/vendor/pragent/. // -// 流程: -// 1. 读 pragent-runtime.json(pin 的 PBS tag + python 主次版本 + pr-agent 版本) -// 2. 按 tag + 主次版本 + 宿主平台三元组,从 GitHub release 解析 install_only 资产 -// 3. 下载 tar.gz + 其 .sha256 sidecar,校验完整性 -// 4. 清空 vendor/pragent → 解压(得到 vendor/pragent/python/...) -// 5. 用嵌入式解释器 pip install pr-agent==<ver>(装进它自己隔离的 site-packages) -// 6. 把 shim(sitecustomize.py 薄加载器 + meebox_pragent_shim 包)拷进 site-packages -// 7. 写 VERSION,做 `import pr_agent` 冒烟 +// Flow: +// 1. Read pragent-runtime.json (pinned PBS tag + python major.minor + pr-agent version) +// 2. Resolve the install_only asset from the GitHub release by tag + major.minor + host platform triple +// 3. Download tar.gz + its .sha256 sidecar, verify integrity +// 4. Clear vendor/pragent → extract (yields vendor/pragent/python/...) +// 5. pip install pr-agent==<ver> with the embedded interpreter (into its own isolated site-packages) +// 6. Copy the shim (sitecustomize.py thin loader + meebox_pragent_shim package) into site-packages +// 7. Write VERSION, run an `import pr_agent` smoke test // -// 设计:零系统二进制依赖(不依赖 curl / 系统 tar)。 -// - 网络走 node fetch;通过 undici ProxyAgent honor HTTP(S)_PROXY env(Node 自带 -// fetch 默认不读代理,内网/代理环境连 GitHub CDN 会超时——这里补上)。 -// - 解压走 node-tar(跨平台,不依赖系统 tar)。 -// 幂等:VERSION 与期望一致则跳过(除非 --force)。需要 Node 22+。 +// Design: zero system binary dependencies (no curl / system tar). +// - Networking via node fetch; honor HTTP(S)_PROXY env through undici ProxyAgent (Node's built-in +// fetch does not read the proxy by default, so intranet/proxy environments time out on the GitHub CDN — patched here). +// - Extraction via node-tar (cross-platform, no system tar). +// Idempotent: skip when VERSION matches the expected one (unless --force). Requires Node 22+. // -// 可选 env:GITHUB_TOKEN / GH_TOKEN(避开 API 限流,CI 推荐);HTTP(S)_PROXY / ALL_PROXY -// (自动用于所有请求);MEEBOX_PRAGENT_FORCE=1 等价 --force。 +// Optional env: GITHUB_TOKEN / GH_TOKEN (avoid API rate limiting, recommended in CI); HTTP(S)_PROXY / ALL_PROXY +// (applied to all requests automatically); MEEBOX_PRAGENT_FORCE=1 is equivalent to --force. import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -34,11 +34,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const APP_DIR = resolve(__dirname, '..'); // apps/desktop const VENDOR_DIR = join(APP_DIR, 'vendor', 'pragent'); const MANIFEST_PATH = join(__dirname, 'pragent-runtime.json'); -// shim 源在 pragent-shim/:薄加载器 sitecustomize.py + 领域拆分包 meebox_pragent_shim/。 +// Shim sources live in pragent-shim/: thin loader sitecustomize.py + domain-split package meebox_pragent_shim/. const SHIM_DIR = join(__dirname, 'pragent-shim'); const SHIM_PKG_NAME = 'meebox_pragent_shim'; const SHIM_LOADER = join(SHIM_DIR, 'sitecustomize.py'); -const SHIM_RUNTIME = join(SHIM_DIR, SHIM_PKG_NAME, 'runtime.py'); // _EXPECTED_PRAGENT_VERSION 所在 +const SHIM_RUNTIME = join(SHIM_DIR, SHIM_PKG_NAME, 'runtime.py'); // where _EXPECTED_PRAGENT_VERSION lives const UA = 'meebox-runtime-assembler'; const FORCE = process.argv.includes('--force') || process.env.MEEBOX_PRAGENT_FORCE === '1'; @@ -52,7 +52,7 @@ function fail(msg) { process.exit(1); } -// 取显式传入的代理:`--proxy <url>` 或 `--proxy=<url>`。优先级高于环境变量。 +// Get an explicitly passed proxy: `--proxy <url>` or `--proxy=<url>`. Takes priority over env vars. function getProxyArg() { const i = process.argv.indexOf('--proxy'); if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; @@ -60,8 +60,8 @@ function getProxyArg() { return eq ? eq.slice('--proxy='.length) : null; } -// 让 fetch 走代理(Node 22 fetch 无内置代理支持,需 undici ProxyAgent 路由)。 -// 来源优先级:--proxy 入参 > HTTPS_PROXY/HTTP_PROXY/ALL_PROXY env。都没有则直连。 +// Route fetch through a proxy (Node 22 fetch has no built-in proxy support, needs undici ProxyAgent routing). +// Source priority: --proxy arg > HTTPS_PROXY/HTTP_PROXY/ALL_PROXY env. Direct connection if none. function configureProxy() { const proxy = getProxyArg() || @@ -73,11 +73,11 @@ function configureProxy() { process.env.all_proxy; if (proxy) { setGlobalDispatcher(new ProxyAgent(proxy)); - log(`经代理 ${proxy}`); + log(`via proxy ${proxy}`); } } -/** 宿主平台 → PBS 三元组 + 解释器在归档内的相对路径段。 */ +/** Host platform → PBS triple + interpreter's relative path segments inside the archive. */ function hostTarget() { const { platform, arch } = process; if (platform === 'win32' && arch === 'x64') @@ -88,7 +88,7 @@ function hostTarget() { return { triple: 'x86_64-apple-darwin', pythonRel: ['python', 'bin', 'python3'] }; if (platform === 'linux' && arch === 'x64') return { triple: 'x86_64-unknown-linux-gnu', pythonRel: ['python', 'bin', 'python3'] }; - fail(`不支持的宿主平台 ${platform}/${arch}(初版仅 win x64;mac arm64 后续)`); + fail(`unsupported host platform ${platform}/${arch} (initial release win x64 only; mac arm64 later)`); } function ghHeaders() { @@ -107,17 +107,17 @@ function parseNextLink(linkHeader) { return null; } -/** 拉某 release 的全部资产(assets_url 分页,跟随 Link: rel=next)。 */ +/** Fetch all assets of a release (paginate assets_url, follow Link: rel=next). */ async function listReleaseAssets(repo, tag) { const relUrl = `https://api.github.com/repos/${repo}/releases/tags/${tag}`; const relRes = await fetch(relUrl, { headers: ghHeaders() }); - if (!relRes.ok) fail(`拉 release 失败 ${relUrl}: HTTP ${relRes.status}`); + if (!relRes.ok) fail(`failed to fetch release ${relUrl}: HTTP ${relRes.status}`); const release = await relRes.json(); const assets = []; let url = `${release.assets_url}?per_page=100`; while (url) { const res = await fetch(url, { headers: ghHeaders() }); - if (!res.ok) fail(`拉 assets 失败 ${url}: HTTP ${res.status}`); + if (!res.ok) fail(`failed to fetch assets ${url}: HTTP ${res.status}`); assets.push(...(await res.json())); url = parseNextLink(res.headers.get('link')); } @@ -126,13 +126,13 @@ async function listReleaseAssets(repo, tag) { async function downloadToFile(url, outPath) { const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok || !res.body) fail(`下载失败 ${url}: HTTP ${res.status}`); + if (!res.ok || !res.body) fail(`download failed ${url}: HTTP ${res.status}`); await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath)); } async function fetchText(url) { const res = await fetch(url, { headers: { 'User-Agent': UA } }); - if (!res.ok) fail(`拉取失败 ${url}: HTTP ${res.status}`); + if (!res.ok) fail(`fetch failed ${url}: HTTP ${res.status}`); return res.text(); } @@ -146,28 +146,28 @@ function sha256File(path) { }); } -/** 在嵌入式解释器里跑一条命令,失败即 fail(allowFail 时仅返回布尔)。 */ +/** Run one command in the embedded interpreter, fail on failure (return boolean only when allowFail). */ function runPython(pythonExe, args, { allowFail = false } = {}) { const r = spawnSync(pythonExe, args, { stdio: 'inherit' }); if (r.error) { if (allowFail) return false; - fail(`执行失败 ${pythonExe} ${args.join(' ')}: ${r.error.message}`); + fail(`execution failed ${pythonExe} ${args.join(' ')}: ${r.error.message}`); } - if (r.status !== 0 && !allowFail) fail(`非零退出 (${r.status}) ${pythonExe} ${args.join(' ')}`); + if (r.status !== 0 && !allowFail) fail(`non-zero exit (${r.status}) ${pythonExe} ${args.join(' ')}`); return r.status === 0; } function pythonStdout(pythonExe, code) { const r = spawnSync(pythonExe, ['-c', code], { encoding: 'utf8' }); - if (r.status !== 0) fail(`python -c 失败: ${r.stderr || r.error?.message || ''}`); + if (r.status !== 0) fail(`python -c failed: ${r.stderr || r.error?.message || ''}`); return r.stdout.trim(); } /** - * 把 shim 拷进嵌入式解释器的 site-packages(CPython 启动经 site 自动 import sitecustomize): - * 薄加载器 sitecustomize.py + 领域拆分包 meebox_pragent_shim/。很轻量,故即便幂等跳过整体 - * 重建也会重跑一次——本地改了 shim 跑一次 prepare:pragent 就生效,无需 --force 全量重建。 - * 先清旧包目录再整体拷,避免改名/删文件后残留陈旧模块。返回 site-packages 路径。 + * Copy the shim into the embedded interpreter's site-packages (CPython auto-imports sitecustomize via site at startup): + * thin loader sitecustomize.py + domain-split package meebox_pragent_shim/. It is very lightweight, so it reruns even when + * the idempotent path skips the whole rebuild — editing the shim locally then running prepare:pragent once takes effect, no --force full rebuild needed. + * Clear the old package directory before copying the whole thing, to avoid stale modules left behind after renames/deletions. Returns the site-packages path. */ async function syncShim(pythonExe) { const sitePackages = pythonStdout( @@ -185,16 +185,16 @@ async function syncShim(pythonExe) { } /** - * 在组装期就把空 `.secrets.toml` 占位写进 pr_agent/settings(_prod)/,烤进 vendor。 - * pr-agent 启动时找不到该文件会每次打两条 WARNING;我们走 env 传密钥不用 secrets.toml。 + * Write an empty `.secrets.toml` placeholder into pr_agent/settings(_prod)/ at assemble time, baked into vendor. + * pr-agent prints two WARNINGs every time it cannot find this file at startup; we pass secrets via env and do not use secrets.toml. * - * 为何不靠执行期补(原 ipc.ts ensureEmbeddedSecrets):装到 `C:\Program Files\…` - * 这类只读目录时,运行期写 site-packages 会因权限失败 → 占位建不出来 → 告警照旧。 - * 组装期写入则随包分发、运行期只读也无所谓。跟 shim 一样在「跳过重建」快路径也补, - * 重跑 prepare:pragent 即可修好旧 vendor,无需 --force 全量重建。 + * Why not patch at runtime (the former ipc.ts ensureEmbeddedSecrets): when installed into a read-only directory like `C:\Program Files\…`, + * writing site-packages at runtime fails on permissions → the placeholder cannot be created → the warnings persist. + * Writing at assemble time ships with the package, so a read-only runtime does not matter. Like the shim, it is also filled on the "skip rebuild" fast path, + * so rerunning prepare:pragent fixes an old vendor without a --force full rebuild. */ async function ensureSecretsPlaceholders(sitePackages) { - const body = '# meebox 占位空文件:抑制 pr-agent 缺失 .secrets.toml 的启动告警\n'; + const body = '# meebox placeholder empty file: suppress the pr-agent startup warning about a missing .secrets.toml\n'; for (const sub of ['settings', 'settings_prod']) { const dir = join(sitePackages, 'pr_agent', sub); await mkdir(dir, { recursive: true }); @@ -202,11 +202,11 @@ async function ensureSecretsPlaceholders(sitePackages) { } } -// ── 运行时瘦身(B):删运行时不需要的目录/文件,减少安装包小文件数(Windows 升级时 -// 删旧+写新海量小文件极慢、被 Defender 逐个扫,会拖到安装器误判「应用无法关闭」)。 -// 取保守通用集:纯粹运行期用不到、删了不影响 pr-agent / shim。准确性由 smokeTest 兜底。 -// 目录名(任意层级整删):stdlib 测试套件 / 字节码缓存 / GUI(tkinter,turtledemo) / -// 交互式(idlelib) / 历史迁移(lib2to3) / 运行期不用的 pip 引导(ensurepip) / 文档数据(pydoc_data)。 +// ── Runtime slimming (B): delete directories/files not needed at runtime, to reduce the installer's small-file count (on Windows upgrades, +// deleting old + writing new masses of small files is extremely slow and scanned one-by-one by Defender, dragging the installer into a false "app cannot be closed"). +// Take a conservative general set: purely unused at runtime, safe to delete without affecting pr-agent / shim. Accuracy is backstopped by smokeTest. +// Directory names (deleted wholesale at any level): stdlib test suites / bytecode caches / GUI(tkinter,turtledemo) / +// interactive(idlelib) / historical migration(lib2to3) / pip bootstrap not used at runtime(ensurepip) / doc data(pydoc_data). const SLIM_DIR_NAMES = new Set([ '__pycache__', 'test', @@ -218,12 +218,12 @@ const SLIM_DIR_NAMES = new Set([ 'ensurepip', 'pydoc_data', ]); -// 文件扩展名:字节码(随源码运行期可再生) + 类型存根(仅类型检查用)。 +// File extensions: bytecode (regenerable at runtime from source) + type stubs (used only for type checking). const SLIM_FILE_EXTS = new Set(['.pyc', '.pyo', '.pyi']); /** - * 递归瘦身 root:整删 SLIM_DIR_NAMES 目录、删 SLIM_FILE_EXTS 文件。幂等(已删则跳过), - * 故全量构建与快路径都可调。返回删除统计。 + * Recursively slim root: delete SLIM_DIR_NAMES directories wholesale, delete SLIM_FILE_EXTS files. Idempotent (skip if already deleted), + * so callable from both full builds and the fast path. Returns deletion stats. */ async function slimRuntime(root) { let dirsRemoved = 0; @@ -254,27 +254,27 @@ async function slimRuntime(root) { } } await walk(root); - log(`运行时瘦身:删除 ${dirsRemoved} 个目录 + ${filesRemoved} 个文件`); + log(`runtime slimming: removed ${dirsRemoved} directories + ${filesRemoved} files`); } -// 注:曾尝试删未用 provider SDK(botocore/azure/grpc…)瘦身,但 smokeTest 证明不可行—— -// pr-agent 的 git_providers/__init__ **启动即 eager 导入全部 provider**(CodeCommit→boto3、 -// AzureDevOps→azure),删了 `import pr_agent` 直接崩。故这些 SDK 必须保留,不做 provider 裁剪。 +// Note: an attempt to slim by deleting unused provider SDKs (botocore/azure/grpc…) proved infeasible per smokeTest — +// pr-agent's git_providers/__init__ **eagerly imports all providers at startup** (CodeCommit→boto3, +// AzureDevOps→azure), so deleting them crashes `import pr_agent` outright. These SDKs must be kept; no provider trimming. /** - * 构建期冒烟(CI 安全网):用嵌入式解释器端到端验证瘦身后运行时仍完好——pr-agent 可导入、 - * shim 补丁链路在位、pr-agent 实际依赖的 stdlib C 扩展/纯 py 模块都在。任一项失败即 fail() - * 让构建红,**过度裁剪在 CI 直接挡下、不会出包**。 + * Build-time smoke test (CI safety net): use the embedded interpreter to verify end-to-end that the slimmed runtime is still intact — pr-agent imports, + * the shim patch chain is in place, and the stdlib C extensions / pure-py modules pr-agent actually depends on are all present. Any failure calls fail() + * to turn the build red, so **over-trimming is blocked directly in CI and never ships**. */ function smokeTest(pythonExe) { - // (0) 拆分铁律:单独 import meebox_pragent_shim 不应把 pr_agent 拉进 sys.modules(顶层禁 eager - // import pr_agent,否则拖慢每次 python 启动)。fresh 解释器里验。 + // (0) Split rule: importing meebox_pragent_shim alone must not pull pr_agent into sys.modules (no eager + // import pr_agent at the top level, else it slows every python startup). Verified in a fresh interpreter. const lazy = pythonStdout( pythonExe, - 'import sys, meebox_pragent_shim; assert "pr_agent" not in sys.modules, "shim 顶层 eager 加载了 pr_agent"; print("LAZY_OK")', + 'import sys, meebox_pragent_shim; assert "pr_agent" not in sys.modules, "shim eager-loaded pr_agent at the top level"; print("LAZY_OK")', ); - if (!lazy.includes('LAZY_OK')) fail(`冒烟未通过(shim 惰性加载,输出:${lazy.slice(0, 200)})`); - // shim 生效校验:get_pr_labels 被 sitecustomize 的补丁打成返回 [](未打补丁会抛 NotImplementedError)。 + if (!lazy.includes('LAZY_OK')) fail(`smoke test failed (shim lazy loading, output: ${lazy.slice(0, 200)})`); + // Shim effectiveness check: get_pr_labels is patched by sitecustomize to return [] (unpatched it throws NotImplementedError). const code = [ 'import os', "os.environ.setdefault('OPENAI_API_KEY', 'sk-smoke-test')", @@ -282,19 +282,19 @@ function smokeTest(pythonExe) { 'from pr_agent.algo.utils import load_yaml', 'import pr_agent.git_providers.local_git_provider as lgp', 'inst = object.__new__(lgp.LocalGitProvider)', - 'assert lgp.LocalGitProvider.get_pr_labels(inst) == [], "shim get_pr_labels 未生效"', - // pr-agent 实际用到的关键 stdlib(含 C 扩展):删 stdlib / 误删依赖在此暴露。 + 'assert lgp.LocalGitProvider.get_pr_labels(inst) == [], "shim get_pr_labels not in effect"', + // Key stdlib pr-agent actually uses (incl. C extensions): deleting stdlib / accidentally deleting deps surfaces here. 'import ssl, json, asyncio, hashlib, sqlite3, ctypes, lzma, bz2, zlib, decimal, socket, importlib.metadata', - // litellm 实际 completion 路径(mock_response,不走网络)——验证「删 lazy provider SDK」后核心 - // 评审链路不破:若误删了 litellm 共享路径需要的依赖,这里会 ImportError 失败。 + // litellm's real completion path (mock_response, no network) — verifies the core review chain is not broken after + // "deleting lazy provider SDKs": if a dependency needed by litellm's shared path was deleted, this fails with ImportError. 'import litellm', "r = litellm.completion(model='gpt-3.5-turbo', messages=[{'role':'user','content':'hi'}], mock_response='MEEBOX_PONG')", - "assert 'MEEBOX_PONG' in str(r), 'litellm mock completion 异常'", + "assert 'MEEBOX_PONG' in str(r), 'litellm mock completion failed'", 'print("MEEBOX_SMOKE_OK")', ].join('\n'); const out = pythonStdout(pythonExe, code); - if (!out.includes('MEEBOX_SMOKE_OK')) fail(`冒烟未通过(输出:${out.slice(0, 300)})`); - log('冒烟 OK:pr_agent 可导入 + shim 补丁生效 + 关键 stdlib + litellm completion 路径完好'); + if (!out.includes('MEEBOX_SMOKE_OK')) fail(`smoke test failed (output: ${out.slice(0, 300)})`); + log('smoke test OK: pr_agent importable + shim patch active + key stdlib + litellm completion path intact'); } async function main() { @@ -305,76 +305,76 @@ async function main() { const { triple, pythonRel } = hostTarget(); const pythonExe = join(VENDOR_DIR, ...pythonRel); - // 守卫:shim 的 monkeypatch 依赖 pr-agent 特定版本的内部实现,runtime.py 里用 - // _EXPECTED_PRAGENT_VERSION 做运行期版本守卫。这里在构建期强制它与 manifest pin 的版本 - // 一致——升级 pr-agent 时必须同步两处 + 重新验证 patch,否则直接 fail 不让出包。 + // Guard: the shim's monkeypatch depends on a specific pr-agent version's internals, and runtime.py uses + // _EXPECTED_PRAGENT_VERSION as a runtime version guard. Here we enforce at build time that it matches the manifest-pinned version — + // upgrading pr-agent must sync both places + re-verify the patch, otherwise fail outright and block shipping. const shimSrc = await readFile(SHIM_RUNTIME, 'utf8'); const shimVer = /_EXPECTED_PRAGENT_VERSION\s*=\s*["']([^"']+)["']/.exec(shimSrc)?.[1]; - if (!shimVer) fail(`meebox_pragent_shim/runtime.py 未找到 _EXPECTED_PRAGENT_VERSION 常量`); + if (!shimVer) fail(`meebox_pragent_shim/runtime.py: _EXPECTED_PRAGENT_VERSION constant not found`); if (shimVer !== prAgentVersion) fail( - `shim 版本(${shimVer}) ≠ manifest pr-agent(${prAgentVersion});升级 pr-agent 时同步 ` + - `runtime.py 的 _EXPECTED_PRAGENT_VERSION 并重新验证 monkeypatch`, + `shim version(${shimVer}) ≠ manifest pr-agent(${prAgentVersion}); when upgrading pr-agent, sync ` + + `runtime.py's _EXPECTED_PRAGENT_VERSION and re-verify the monkeypatch`, ); const versionKey = `pbs:${tag} py:${mm} triple:${triple} pr-agent:${prAgentVersion}`; const versionFile = join(VENDOR_DIR, 'VERSION'); - // 幂等:VERSION 命中且解释器在位 → 跳过 + // Idempotent: VERSION hit and interpreter present → skip if (!FORCE && existsSync(versionFile) && existsSync(pythonExe)) { const prev = JSON.parse(await readFile(versionFile, 'utf8')); if (prev.key === versionKey) { - // 整体跳过,但始终重新同步 shim + 补 .secrets.toml 占位:本地改了 sitecustomize.py - // 或修了占位逻辑后跑一次 prepare:pragent 即生效,无需 --force 全量重建(重下 - // CPython + 重装 pr-agent)。 + // Skip overall, but always re-sync the shim + fill the .secrets.toml placeholder: editing sitecustomize.py locally + // or fixing the placeholder logic takes effect after running prepare:pragent once, no --force full rebuild needed (re-download + // CPython + reinstall pr-agent). const sp = await syncShim(pythonExe); await ensureSecretsPlaceholders(sp); - // 瘦身幂等:已删则跳过,故快路径也跑——已组装的旧 vendor 跑一次 prepare:pragent 即变瘦。 + // Slimming is idempotent: skip if already deleted, so the fast path runs it too — an already-assembled old vendor slims after one prepare:pragent. await slimRuntime(VENDOR_DIR); smokeTest(pythonExe); - log(`已就绪,跳过重建(${versionKey});已重新同步 shim + 瘦身 + 冒烟 → ${sp}。--force 可强制全量重建。`); + log(`ready, skipping rebuild (${versionKey}); re-synced shim + slimmed + smoke tested → ${sp}. Use --force to force a full rebuild.`); return; } - log(`VERSION 不匹配(旧: ${prev.key}),重建。`); + log(`VERSION mismatch (old: ${prev.key}), rebuilding.`); } - // 1+2. 解析资产 - log(`解析 release ${repo}@${tag} 的 ${triple} ${variant} (py ${mm}) 资产…`); + // 1+2. Resolve the asset + log(`resolving ${triple} ${variant} (py ${mm}) asset from release ${repo}@${tag}…`); const assets = await listReleaseAssets(repo, tag); const mmEsc = mm.replace(/\./g, '\\.'); const assetRe = new RegExp(`^cpython-${mmEsc}\\.\\d+\\+${tag}-${triple}-${variant}\\.tar\\.gz$`); const asset = assets.find((a) => assetRe.test(a.name)); - if (!asset) fail(`release ${tag} 里找不到匹配 ${assetRe} 的资产`); + if (!asset) fail(`no asset matching ${assetRe} found in release ${tag}`); const shaAsset = assets.find((a) => a.name === `${asset.name}.sha256`); - log(`命中资产 ${asset.name}`); + log(`matched asset ${asset.name}`); - // 3. 下载到临时文件 + 校验 + // 3. Download to a temp file + verify const tarPath = join(tmpdir(), `meebox-${asset.name}`); - log('下载归档…'); + log('downloading archive…'); await downloadToFile(asset.browser_download_url, tarPath); const actualSha = await sha256File(tarPath); if (shaAsset) { const sidecar = await fetchText(shaAsset.browser_download_url); const expected = (sidecar.trim().match(/[a-f0-9]{64}/i) ?? [])[0]?.toLowerCase(); - if (!expected) fail(`sidecar 里没解析出 sha256: ${sidecar.slice(0, 80)}`); - if (expected !== actualSha) fail(`sha256 不匹配!期望 ${expected} 实际 ${actualSha}`); - log(`sha256 校验通过 (${actualSha.slice(0, 12)}…)`); + if (!expected) fail(`no sha256 parsed from sidecar: ${sidecar.slice(0, 80)}`); + if (expected !== actualSha) fail(`sha256 mismatch! expected ${expected} actual ${actualSha}`); + log(`sha256 verified (${actualSha.slice(0, 12)}…)`); } else { - log(`WARN: 无 .sha256 sidecar,仅记录实际值 ${actualSha.slice(0, 12)}…(未校验)`); + log(`WARN: no .sha256 sidecar, recording actual value only ${actualSha.slice(0, 12)}… (unverified)`); } - // 4. 清空 + 解压(node-tar,不依赖系统 tar) - log(`清空 ${VENDOR_DIR} 并解压…`); + // 4. Clear + extract (node-tar, no system tar dependency) + log(`clearing ${VENDOR_DIR} and extracting…`); await rm(VENDOR_DIR, { recursive: true, force: true }); await mkdir(VENDOR_DIR, { recursive: true }); await tarExtract({ file: tarPath, cwd: VENDOR_DIR }); await rm(tarPath, { force: true }); - if (!existsSync(pythonExe)) fail(`解压后找不到解释器 ${pythonExe}`); + if (!existsSync(pythonExe)) fail(`interpreter not found after extraction ${pythonExe}`); - // 5. pip install pr-agent(装进嵌入式解释器自己的 site-packages) - log('确保 pip…'); + // 5. pip install pr-agent (into the embedded interpreter's own site-packages) + log('ensuring pip…'); runPython(pythonExe, ['-m', 'ensurepip', '--upgrade'], { allowFail: true }); - log(`pip install pr-agent==${prAgentVersion}(依赖较多,耗时数分钟)…`); + log(`pip install pr-agent==${prAgentVersion} (many dependencies, takes several minutes)…`); runPython(pythonExe, [ '-m', 'pip', @@ -384,21 +384,21 @@ async function main() { `pr-agent==${prAgentVersion}`, ]); - // 6. 注入 shim(薄加载器 sitecustomize.py + meebox_pragent_shim 包) + // 6. Inject the shim (thin loader sitecustomize.py + meebox_pragent_shim package) const sitePackages = await syncShim(pythonExe); - log(`已注入 shim(sitecustomize.py + meebox_pragent_shim/)→ ${sitePackages}`); - // 7. 组装期补空 .secrets.toml 占位,烤进 vendor(只读安装目录运行期也无需再写) + log(`injected shim (sitecustomize.py + meebox_pragent_shim/) → ${sitePackages}`); + // 7. Fill the empty .secrets.toml placeholder at assemble time, baked into vendor (no runtime write needed even in a read-only install dir) await ensureSecretsPlaceholders(sitePackages); - log('已写入 pr_agent/settings(_prod)/.secrets.toml 空占位'); + log('wrote empty pr_agent/settings(_prod)/.secrets.toml placeholder'); - // 7. 瘦身(B)+ 冒烟(CI 安全网)+ 写 VERSION + // 7. Slim (B) + smoke test (CI safety net) + write VERSION await slimRuntime(VENDOR_DIR); smokeTest(pythonExe); await writeFile( versionFile, `${JSON.stringify({ key: versionKey, asset: asset.name, sha256: actualSha, builtOn: `${process.platform}/${process.arch}` }, null, 2)}\n`, ); - log(`完成 → ${VENDOR_DIR}`); + log(`done → ${VENDOR_DIR}`); } main().catch((e) => fail(e instanceof Error ? (e.stack ?? e.message) : String(e))); diff --git a/apps/desktop/scripts/pragent-shim/.gitignore b/apps/desktop/scripts/pragent-shim/.gitignore index a58874e7..2f4d5320 100644 --- a/apps/desktop/scripts/pragent-shim/.gitignore +++ b/apps/desktop/scripts/pragent-shim/.gitignore @@ -1,3 +1,3 @@ -# Python 编译产物(运行/冒烟时生成,不入库) +# Python bytecode (generated at run / smoke time, not committed) __pycache__/ *.pyc diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py index a0220dc9..d3e86794 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/__init__.py @@ -1,11 +1,13 @@ -"""meebox 嵌入式运行时 monkeypatch shim(按领域拆分)。 +"""meebox embedded runtime monkeypatch shim (split by domain). -入口 apply() 由薄 sitecustomize.py 调用,注册全部惰性 post-import hook:仅当目标 pr_agent -模块真正被 import(= 真实 run)时才打补丁。**绝不在此 eager import pr_agent**——本包的所有 -模块对 pr_agent 的 import 都在 patch 函数体内(惰性),故 import 本包不会触发 pr_agent 加载。 +Entry point apply() is called by the thin sitecustomize.py, registering all lazy post-import hooks: +patches are only applied when the target pr_agent module is actually imported (= a real run). +**Never eager import pr_agent here** -- every module in this package imports pr_agent inside the +patch function body (lazily), so importing this package does not trigger pr_agent load. -所有对 pr-agent 行为的改造集中在本包,上游源码保持原封。每个补丁用 try/except 包裹(见 -runtime._register_post_import),打不上则静默降级,绝不让 shim 异常阻断流程。 +All modifications to pr-agent behavior are concentrated in this package; upstream source stays +untouched. Each patch is wrapped in try/except (see runtime._register_post_import): if it can't be +applied it silently degrades, never letting a shim exception block the flow. """ from .patches.describe_assessment import patch as _patch_describe_assessment from .patches.litellm_handler import patch as _patch_litellm_handler @@ -15,23 +17,24 @@ def apply() -> None: - # local_git_provider 两个补丁合并在一个 patch_fn 里(同模块注册多个 finder 会互相遮蔽, - # 只有 meta_path[0] 那个生效):二进制安全 get_diff_files + get_line_link anchor。 + # local_git_provider two patches merged into one patch_fn (registering multiple finders for the + # same module shadows each other; only the meta_path[0] one takes effect): binary-safe + # get_diff_files + get_line_link anchor. _register_post_import( "pr_agent.git_providers.local_git_provider", _patch_local_git_provider, ) - # litellm handler:CLI 模式分发 + Anthropic 去 temperature + 包 _get_completion 采集 token usage。 + # litellm handler: CLI mode dispatch + Anthropic temperature removal + wrap _get_completion to collect token usage. _register_post_import( "pr_agent.algo.ai_handlers.litellm_ai_handler", _patch_litellm_handler, ) - # load_yaml 健壮化:解析失败时剥 anchor marker / 重排多行块标量后重试,避免 review 崩。 + # load_yaml hardening: on parse failure, strip anchor marker / rearrange multi-line block scalars and retry, to avoid review crashing. _register_post_import( "pr_agent.algo.utils", _patch_load_yaml, ) - # /describe 思路建议:往 describe prompt 注入 assessment 字段,产出「替代方案 + 倾向性建议」段。 + # /describe approach suggestion: inject an assessment field into the describe prompt, producing an "alternatives + opinionated recommendation" section. _register_post_import( "pr_agent.tools.pr_description", _patch_describe_assessment, diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py index 934ada6c..39ac4fa7 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py @@ -1,20 +1,21 @@ -"""编排器「独立 LLM 对话通道」的运行入口(见 docs/arch/02-agent/01-agent.md §3 + packages/pr-agent-bridge)。 +"""Run entry for the orchestrator's "standalone LLM chat channel" (see docs/arch/02-agent/01-agent.md §3 + packages/pr-agent-bridge). -由嵌入式运行时以 `python -m meebox_pragent_shim.chat` 启动,按 provider 分两条路: +Launched by the embedded runtime as `python -m meebox_pragent_shim.chat`, splitting into two paths by provider: - - **CLI 模式**(MEEBOX_CLI_MODE 置位,本机 claude / codex):直接调 `cli.run_cli_chat`,**不 import - pr_agent / litellm**。CLI 路径的真实调用本就绕过 litellm(见 cli/install.py),此处避免每次 chat 子进程 - 为拿一个用不到的 LiteLLMAIHandler 而白白付整套 pr_agent + litellm import 开销——编排自有步骤(路由 / - judge / summary)每流程调多次,累计可观。 + - **CLI mode** (MEEBOX_CLI_MODE set, local claude / codex): calls `cli.run_cli_chat` directly, **without importing + pr_agent / litellm**. The CLI path's real calls already bypass litellm (see cli/install.py); this avoids each chat subprocess + paying the full pr_agent + litellm import cost just to obtain an unused LiteLLMAIHandler -- orchestration has its own steps (routing / + judge / summary) invoked multiple times per flow, which adds up considerably. - - **API 模式**(anthropic / openai / deepseek …):litellm 即 HTTP 客户端、无法绕开,复用 pr-agent - **已被本 shim 补丁**的 `LiteLLMAIHandler.chat_completion`——provider 路由、Anthropic 去 temperature、 - 提示缓存、token usage 哨兵全部继承,无需在此重复实现。 + - **API mode** (anthropic / openai / deepseek ...): litellm is the HTTP client and cannot be bypassed, so reuse pr-agent's + `LiteLLMAIHandler.chat_completion` **already patched by this shim** -- provider routing, Anthropic temperature removal, + prompt caching, and the token usage sentinel are all inherited, no need to reimplement here. -约定:stdin 收一段 JSON `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`, -回复正文写 stdout,token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(主进程与 pr-agent run 同一套累加, -见 ipc.ts)。max_output_tokens 封顶输出(轻量路由判读用),经 env 中转给 litellm_handler 补丁注入 litellm -max_tokens——仅嵌入式 litellm 路径生效,CLI provider 忽略(其算力档由 MEEBOX_CLI_REASONING 控制)。 +Convention: stdin receives a JSON blob `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`, +the reply body is written to stdout, and token usage is emitted to stderr via the `@@MEEBOX_USAGE@@` sentinel (the main process uses +the same accumulation as a pr-agent run, see ipc.ts). max_output_tokens caps output (for lightweight routing decisions), relayed via env +to the litellm_handler patch which injects litellm max_tokens -- only effective on the embedded litellm path; CLI providers ignore it +(their reasoning tier is controlled by MEEBOX_CLI_REASONING). """ import asyncio import json @@ -36,22 +37,22 @@ def _read_payload() -> dict: async def _run(payload: dict) -> str: - # CLI 模式短路:直接调本机 CLI,绕过 litellm,且不 import pr_agent——省去整套 import 开销。 - # model / temperature / max_output_tokens 在 CLI 路径用不到(命令与算力档由 spec + MEEBOX_CLI_* - # env 决定),忽略即可。 + # CLI mode short-circuit: call the local CLI directly, bypass litellm, and don't import pr_agent -- saving the full import cost. + # model / temperature / max_output_tokens are unused on the CLI path (command and reasoning tier are decided by spec + MEEBOX_CLI_* + # env), so just ignore them. if os.environ.get("MEEBOX_CLI_MODE"): from .cli.install import run_cli_chat bin_name = (os.environ.get("MEEBOX_CLI_BIN") or "claude").strip() or "claude" return await run_cli_chat(bin_name, payload["system"], payload["user"]) - # 输出封顶:每次 chat 独立子进程,故置环境变量即「本次调用」级别——litellm_handler 补丁里 - # 的 _get_completion 包装读它注入 litellm max_tokens(见 patches/litellm_handler)。 + # Output cap: each chat is its own subprocess, so setting the env var is "this call" scoped -- the _get_completion + # wrapper in the litellm_handler patch reads it to inject litellm max_tokens (see patches/litellm_handler). mot = payload.get("max_output_tokens") if isinstance(mot, int) and mot > 0: os.environ["MEEBOX_CHAT_MAX_TOKENS"] = str(mot) - # 惰性 import:触发 shim 注册的 post-import 补丁(_get_completion usage 包装 / 提示缓存 / 去 temperature)。 + # Lazy import: triggers the post-import patches registered by the shim (_get_completion usage wrap / prompt caching / temperature removal). from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.config_loader import get_settings @@ -61,7 +62,7 @@ async def _run(payload: dict) -> str: if payload["temperature"] is not None: kwargs["temperature"] = payload["temperature"] result = await handler.chat_completion(**kwargs) - # chat_completion 返回 (resp_text, finish_reason) + # chat_completion returns (resp_text, finish_reason) if isinstance(result, tuple): return result[0] or "" return result or "" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py index ffc3a0db..de101cd4 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/__init__.py @@ -1,2 +1,2 @@ -"""本机 CLI provider:把评审请求转交本机已安装并授权的命令行工具代为调用模型,绕过 litellm。 -各命令的差异(argv / 输出解析 / 需剥离的计费 env)集中在 specs.py,按命令名取用。""" +"""Local CLI provider: delegate review requests to a locally installed and authorized command-line tool to invoke the model on our behalf, bypassing litellm. +Per-command differences (argv / output parsing / billing env to strip) are centralized in specs.py, looked up by command name.""" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py index 613011af..8d4c3e09 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py @@ -1,11 +1,11 @@ -"""CLI 模式:调本机 CLI 子进程跑一轮对话,完全绕过 litellm / 直连 API。 - -两个入口共用同一份子进程逻辑 `run_cli_chat`: - - `_install_cli_chat_completion`:把 LiteLLMAIHandler.chat_completion 整体替换为调 CLI 的版本, - 由 patches.litellm_handler 在 MEEBOX_CLI_MODE 置位时调用——服务 **pr-agent 工具 run**(/describe - /review /ask 经 `python -m pr_agent.cli`,其内部 LLM 调用必经 chat_completion)。 - - `run_cli_chat`:编排 **chat 通道**(`python -m meebox_pragent_shim.chat`)在 CLI 模式下直接调它, - 无需 import pr_agent / litellm(CLI 路径根本不用 litellm),省去每次 chat 子进程的整套 import 开销。 +"""CLI mode: invoke a local CLI subprocess to run one round of conversation, fully bypassing litellm / the direct API. + +Both entry points share the same subprocess logic `run_cli_chat`: + - `_install_cli_chat_completion`: replaces LiteLLMAIHandler.chat_completion wholesale with a CLI-invoking version, + called by patches.litellm_handler when MEEBOX_CLI_MODE is set — serves the **pr-agent tool run** (/describe + /review /ask via `python -m pr_agent.cli`, whose internal LLM calls all go through chat_completion). + - `run_cli_chat`: the orchestration **chat channel** (`python -m meebox_pragent_shim.chat`) calls it directly in CLI mode, + with no need to import pr_agent / litellm (the CLI path does not use litellm at all), saving the full import overhead of every chat subprocess. """ import os import sys @@ -16,8 +16,8 @@ def _resolve_cli_exe(bin_name): - """用 shutil.which 解析命令真实路径。Windows 据 PATHEXT 命中 .cmd/.bat(不能被 CreateProcess - 直接拉起,须经 cmd /c)。返回 (exe_path_or_None, needs_cmd_wrapper)。""" + """Resolve the command's real path via shutil.which. On Windows, PATHEXT may match .cmd/.bat (which cannot be + launched directly by CreateProcess and must go through cmd /c). Returns (exe_path_or_None, needs_cmd_wrapper).""" import shutil exe = shutil.which(bin_name) @@ -28,21 +28,21 @@ def _resolve_cli_exe(bin_name): async def run_cli_chat(bin_name, system, user) -> str: - """调本机 CLI 子进程跑一轮 system+user 对话,返回回复正文(usage 经哨兵打 stderr)。 - - pr-agent 只依赖 chat_completion 返回 (text, finish_reason) 这个稳定契约(base_ai_handler 定义), - 故 CLI 接管与 pr-agent 具体版本无关,**不受版本守卫限制**(区别于依赖内部实现的其它 patch)。本函数 - 自包含、不 import pr_agent / litellm,编排 chat 通道在 CLI 模式可直接调用以省去整套 import 开销。 - - 各命令差异(argv flags / 输出解析 / 需剥离的计费 env)集中在 _CLI_SPECS,按命令名取用: - - prompt 经 **stdin** 喂入:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限; - system / user 拼成一段(CLI 单轮无独立 system 槽)。 - - cwd 默认落到中性临时目录:避免吃到被评审仓库的上下文(CLAUDE.md / AGENTS.md 等)污染输出。 - 例外:主进程仅对 /ask 经 MEEBOX_CLI_WORKDIR 下发(已净化的)worktree 路径,让自由问答能读到 - 完整文件;describe/review 不下发该 env、维持中性临时目录。净化在主进程侧做(清空仓库自带指令文件)。 - - 子进程继承父 env(PATH / HOME / 代理变量),故能找到命令、复用其登录态、出站自动走代理。 - - **凭据隔离**:剥掉对应计费 key(claude: ANTHROPIC_*;codex: OPENAI_API_KEY / CODEX_API_KEY), - 让 CLI 使用其自身登录会话,而非环境里残留的 API key。模型与额度由该 CLI 账户与用户授权决定。 + """Invoke a local CLI subprocess to run one round of system+user conversation, returning the reply body (usage is emitted to stderr via a sentinel). + + pr-agent only depends on the stable contract that chat_completion returns (text, finish_reason) (defined by base_ai_handler), + so the CLI takeover is independent of the specific pr-agent version and **not subject to the version guard** (unlike other patches that depend on internal implementation). This function + is self-contained and does not import pr_agent / litellm; the orchestration chat channel can call it directly in CLI mode to save the full import overhead. + + Per-command differences (argv flags / output parsing / billing env to strip) are centralized in _CLI_SPECS, looked up by command name: + - The prompt is fed via **stdin**: the review prompt contains the full diff (tens of KB), and passing it via argv would hit the command-line length limit; + system / user are concatenated into one segment (a single CLI round has no separate system slot). + - cwd defaults to a neutral temp directory: to avoid picking up context from the repo under review (CLAUDE.md / AGENTS.md etc.) that would pollute the output. + Exception: the main process only passes a (sanitized) worktree path via MEEBOX_CLI_WORKDIR for /ask, so free-form Q&A can read + the full files; describe/review do not pass this env and keep the neutral temp directory. Sanitization is done on the main-process side (clearing the repo's own instruction files). + - The subprocess inherits the parent env (PATH / HOME / proxy variables), so it can find the command, reuse its login state, and route outbound traffic through the proxy automatically. + - **Credential isolation**: strip the corresponding billing key (claude: ANTHROPIC_*; codex: OPENAI_API_KEY / CODEX_API_KEY), + so the CLI uses its own login session rather than an API key lingering in the environment. The model and quota are determined by that CLI account and the user's authorization. """ import asyncio import tempfile @@ -51,18 +51,18 @@ async def run_cli_chat(bin_name, system, user) -> str: spec = _CLI_SPECS.get(name) if spec is None: raise RuntimeError( - f"不支持的本地 CLI 命令 '{bin_name}'(当前已适配 claude / codex)。" + f"unsupported local CLI command '{bin_name}' (currently adapted: claude / codex)." ) exe, needs_cmd = _resolve_cli_exe(bin_name) - # 命令前缀(cmd 包装 + exe);exe 解析失败为 None。 + # Command prefix (cmd wrapper + exe); None if exe resolution failed. cmd_prefix = (["cmd", "/c", exe] if needs_cmd else [exe]) if exe else None if cmd_prefix is None: raise RuntimeError( - f"找不到本地 CLI 命令 '{bin_name}':请确认已安装、已登录,且 '{bin_name}' 在 PATH 中。" + f"local CLI command '{bin_name}' not found: please confirm it is installed, logged in, and that '{bin_name}' is on PATH." ) - # 低算力档:仅 Agent 编排通道经 MEEBOX_CLI_REASONING=low/minimal 开启;把 low_effort_flags - # 插到尾部 `-`(stdin 占位)之前、保持 `-` 在末位;无尾部 `-` 则直接追加。 + # Low-effort tier: only the Agent orchestration channel enables it via MEEBOX_CLI_REASONING=low/minimal; insert low_effort_flags + # before the trailing `-` (stdin placeholder), keeping `-` last; if there is no trailing `-`, just append. flags = list(spec["flags"]) if os.environ.get("MEEBOX_CLI_REASONING", "").strip().lower() in ("low", "minimal"): extra = list(spec.get("low_effort_flags") or []) @@ -70,11 +70,11 @@ async def run_cli_chat(bin_name, system, user) -> str: flags = flags[:-1] + extra + ["-"] if flags and flags[-1] == "-" else flags + extra argv = cmd_prefix + flags - # CLI 单轮无独立 system 槽:system+user 拼一段。先剥除缓存断点标记(仅 Anthropic litellm 路径用于 - # 分块缓存;CLI 不缓存、标记不得进入 prompt)。 + # A single CLI round has no separate system slot: concatenate system+user into one segment. First strip cache-break markers (used only by the Anthropic litellm path for + # chunked caching; the CLI does not cache, and the markers must not enter the prompt). system = strip_cache_break(system) if system else system prompt = f"{system}\n\n\n{user}" if system else user - # 基于 os.environ 拷贝再剔除计费 key——其余(PATH/HOME/代理变量等)原样保留。 + # Copy from os.environ then remove the billing keys — everything else (PATH/HOME/proxy variables, etc.) is kept as-is. child_env = {k: v for k, v in os.environ.items() if k not in spec["strip_env"]} try: proc = await asyncio.create_subprocess_exec( @@ -86,20 +86,20 @@ async def run_cli_chat(bin_name, system, user) -> str: env=child_env, ) except Exception as exc: # noqa: BLE001 - raise RuntimeError(f"启动 CLI '{bin_name}' 失败: {exc}") from exc + raise RuntimeError(f"failed to start CLI '{bin_name}': {exc}") from exc out, err = await proc.communicate(prompt.encode("utf-8")) if proc.returncode != 0: raise RuntimeError( - f"CLI '{bin_name}' 退出码 {proc.returncode}: " + f"CLI '{bin_name}' exit code {proc.returncode}: " f"{(err or b'').decode('utf-8', 'replace')[:500]}" ) text, usage = spec["parser"]((out or b"").decode("utf-8", "replace")) if usage: - # prompt_tokens ≈ 输入侧总规模,output_tokens ≈ completion(input/output_tokens 两家同名)。 - # 缓存字段两家约定不同: - # - Anthropic(claude):input_tokens **不含**缓存,cache_read/创建需累加进总量; - # cache_read 用 cache_read_input_tokens。 - # - OpenAI(codex):input_tokens **已含**缓存,cached_input_tokens 仅作命中量、不再计入总量。 + # prompt_tokens ≈ total input-side size, output_tokens ≈ completion (input/output_tokens share the same names across both). + # The cache fields differ in convention between the two: + # - Anthropic(claude): input_tokens **excludes** cache, so cache_read/creation must be added into the total; + # cache_read uses cache_read_input_tokens. + # - OpenAI(codex): input_tokens **already includes** cache, and cached_input_tokens is only the hit count, not counted into the total again. prompt_tokens = usage.get("input_tokens") for k in ("cache_read_input_tokens", "cache_creation_input_tokens"): v = usage.get(k) @@ -107,7 +107,7 @@ async def run_cli_chat(bin_name, system, user) -> str: prompt_tokens = (prompt_tokens or 0) + v cache_read = usage.get("cache_read_input_tokens") if not isinstance(cache_read, int): - cache_read = usage.get("cached_input_tokens") # codex/OpenAI 风格 + cache_read = usage.get("cached_input_tokens") # codex/OpenAI style turns = usage.get("num_turns") _emit_usage_tokens( prompt_tokens, @@ -119,9 +119,9 @@ async def run_cli_chat(bin_name, system, user) -> str: def _install_cli_chat_completion(handler_cls, bin_name) -> None: - """把 chat_completion 换成调本机 CLI 子进程的版本(委托 run_cli_chat),服务 pr-agent 工具 run。 - chat_completion 的 model / temperature / img_path 在 CLI 路径里用不到(命令与算力档由 spec + env 决定), - 仅为满足 base_ai_handler 的方法签名而保留。""" + """Replace chat_completion with a version that invokes a local CLI subprocess (delegating to run_cli_chat), serving the pr-agent tool run. + chat_completion's model / temperature / img_path are unused on the CLI path (the command and effort tier are determined by spec + env); + they are kept only to satisfy base_ai_handler's method signature.""" async def chat_completion(self, model, system, user, temperature=0.2, img_path=None): text = await run_cli_chat(bin_name, system, user) diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py index 13d89d12..5dc7b27c 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py @@ -1,15 +1,15 @@ -"""本机 CLI 命令输出解析:把各 CLI 的 stdout 解析成 (text, usage_dict_or_None)。 -usage 统一用 input_tokens / output_tokens 字段名(两家恰好一致),供 install 的 token 采集复用。""" +"""Local CLI command output parsing: parse each CLI's stdout into (text, usage_dict_or_None). +usage uniformly uses the input_tokens / output_tokens field names (which happen to match across both), reused by install's token collection.""" def _parse_claude_output(stdout): - """解析 `claude -p --output-format json` 的 stdout,返回 (text, usage_dict_or_None)。 - 成功形如 {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., - "cache_read_input_tokens":..}, "is_error": false}。非 JSON / 缺字段退化为「整段 stdout 当文本、 - usage=None」;仅 is_error=True 时抛错。 + """Parse the stdout of `claude -p --output-format json`, returning (text, usage_dict_or_None). + On success it looks like {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., + "cache_read_input_tokens":..}, "is_error": false}. Non-JSON / missing fields degrade to "the whole stdout as text, + usage=None"; only raises when is_error=True. - claude -p 是 agentic 多轮:顶层 num_turns 为本次会话内部的模型轮次(可远大于 1),把它并入 - usage dict 的 num_turns 字段一并上抛(usage 同字段名供采集层统一读取,见 install.py)。""" + claude -p is agentic multi-turn: the top-level num_turns is the number of model turns within this session (which can be far greater than 1); merge it into + the usage dict's num_turns field and surface it together (usage uses the same field name for the collection layer to read uniformly, see install.py).""" import json s = (stdout or "").strip() @@ -17,12 +17,12 @@ def _parse_claude_output(stdout): return "", None try: obj = json.loads(s) - except Exception: # noqa: BLE001 - 非 JSON → 原样当文本 + except Exception: # noqa: BLE001 - non-JSON → treat as text as-is return s, None if not isinstance(obj, dict): return s, None if obj.get("is_error"): - raise RuntimeError(f"claude CLI 返回错误: {str(obj.get('result') or obj)[:500]}") + raise RuntimeError(f"claude CLI returned an error: {str(obj.get('result') or obj)[:500]}") text = obj.get("result") if not isinstance(text, str): text = s @@ -36,11 +36,11 @@ def _parse_claude_output(stdout): def _parse_codex_output(stdout): - """解析 `codex exec --json` 的 JSONL 事件流,返回 (text, usage_dict_or_None): - - type==item.completed 且 item.type==agent_message → item.text 为模型回复,取最后一条; - - type==turn.completed → usage {input_tokens, output_tokens} 为 token,并计一轮。 - turn.completed 出现次数作模型轮次 num_turns(并入 usage dict,与 claude 路径同字段名)。 - 逐行容错:非 JSON 行跳过、事件缺字段不致命;text 缺失退到空串(让上层 load_yaml 兜底)。""" + """Parse the JSONL event stream of `codex exec --json`, returning (text, usage_dict_or_None): + - type==item.completed and item.type==agent_message → item.text is the model reply, take the last one; + - type==turn.completed → usage {input_tokens, output_tokens} are the tokens, and count one turn. + The number of turn.completed occurrences serves as the model turn count num_turns (merged into the usage dict, same field name as the claude path). + Line-by-line tolerant: non-JSON lines are skipped, missing event fields are not fatal; missing text falls back to an empty string (let the upper-layer load_yaml handle the fallback).""" import json text = None @@ -52,7 +52,7 @@ def _parse_codex_output(stdout): continue try: ev = json.loads(line) - except Exception: # noqa: BLE001 - 非 JSON 行(日志等)跳过 + except Exception: # noqa: BLE001 - skip non-JSON lines (logs, etc.) continue if not isinstance(ev, dict): continue @@ -62,7 +62,7 @@ def _parse_codex_output(stdout): if isinstance(item, dict) and item.get("type") == "agent_message": txt = item.get("text") if isinstance(txt, str): - text = txt # 取最后一条 agent_message 作最终回复 + text = txt # take the last agent_message as the final reply elif etype == "turn.completed": turns += 1 u = ev.get("usage") diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py index d178c1ec..8145fd61 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/specs.py @@ -1,28 +1,28 @@ -"""已适配的本机 CLI 命令规格表:argv flags(prompt 一律走 stdin)+ 输出解析器 + 需剥离的 -计费 env。新增命令在此登记一套即可;renderer 侧白名单校验须同步(见 LlmProfileForm.validateProfile)。""" +"""Spec table for adapted local CLI commands: argv flags (the prompt always goes via stdin) + output parser + billing env to strip. +Registering one entry here is enough for a new command; the renderer-side whitelist validation must stay in sync (see LlmProfileForm.validateProfile).""" from .parsers import _parse_claude_output, _parse_codex_output -# `low_effort_flags`:低算力档要追加的 argv(仅 Agent 编排通道经 MEEBOX_CLI_REASONING 开启, -# 见 install.py)。含尾部 `-`(stdin)的命令会把这些 flags 插到 `-` 之前,保持 `-` 在末位。 +# `low_effort_flags`: argv to append for the low-effort tier (only enabled by the Agent orchestration channel via MEEBOX_CLI_REASONING, +# see install.py). Commands with a trailing `-` (stdin) insert these flags before the `-`, keeping `-` last. _CLI_SPECS = { - # claude:-p 单轮非交互 + JSON(一段含结果与 usage);默认不传 --model,用本机默认模型/登录态。 - # 低算力档:--model haiku(最快最省,适合编排通道的路由 / 判读 / 收尾 / 对话),与 /review 走默认 - # 模型形成差异化;haiku 别名自动解析到当前账户可用的最新 haiku。 + # claude: -p single-round non-interactive + JSON (one segment containing the result and usage); by default does not pass --model, using the local default model / login state. + # Low-effort tier: --model haiku (fastest and cheapest, suited to the orchestration channel's routing / interpretation / wrap-up / conversation), differentiated from /review which uses the default + # model; the haiku alias automatically resolves to the latest haiku available to the current account. "claude": { "flags": ["-p", "--output-format", "json"], "low_effort_flags": ["--model", "haiku"], "parser": _parse_claude_output, "strip_env": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"), }, - # codex:exec 非交互 + --json(JSONL 事件流);末位 `-` 让 stdin 作完整 prompt; - # --skip-git-repo-check 容许临时目录运行,--sandbox read-only 只读不改文件。 - # 默认禁用 web_search / image_gen:评审与编排在只读临时目录里跑,这两个工具用不到, - # 关掉既收敛工具面、又省 ~3K tokens(工具定义不再随每次请求下发)。键值: - # web_search 是字符串枚举(disabled / cached / live),用 `-c web_search=disabled`; - # image_gen 是 feature flag,用 `-c features.image_generation=false`(等价 --disable image_generation)。 - # 低算力档:-c model_reasoning_effort=low(codex 默认推理较重,编排通道无需,调低提速)。 - # 不用 minimal:gpt-5.x-codex 不支持 minimal(仅 none/low/medium/high/xhigh,传 minimal 报 400), - # 且 minimal 还与 web_search / image_gen 互斥;low 普遍受支持、与工具兼容,作低算力档更稳。 + # codex: exec non-interactive + --json (JSONL event stream); the trailing `-` makes stdin the full prompt; + # --skip-git-repo-check allows running in a temp directory, --sandbox read-only is read-only and does not modify files. + # Disable web_search / image_gen by default: review and orchestration run in a read-only temp directory where these two tools are unused, + # turning them off both narrows the tool surface and saves ~3K tokens (tool definitions are no longer sent with each request). Keys: + # web_search is a string enum (disabled / cached / live), use `-c web_search=disabled`; + # image_gen is a feature flag, use `-c features.image_generation=false` (equivalent to --disable image_generation). + # Low-effort tier: -c model_reasoning_effort=low (codex reasons heavily by default, the orchestration channel does not need it, lowering it speeds things up). + # Not using minimal: gpt-5.x-codex does not support minimal (only none/low/medium/high/xhigh, passing minimal returns a 400), + # and minimal is also mutually exclusive with web_search / image_gen; low is widely supported and tool-compatible, making it more reliable for the low-effort tier. "codex": { "flags": [ "exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py index 0b8edc00..318cb08d 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/__init__.py @@ -1,2 +1,3 @@ -"""对 pr-agent 的无侵入 monkeypatch,按目标模块分文件。每个模块导出一个 `patch(module)`, -由包根 apply() 经惰性 post-import hook 注册。pr_agent 的 import 一律在 patch 函数体内(惰性)。""" +"""Non-invasive monkeypatch for pr-agent, split into one file per target module. Each module exports a +`patch(module)`, registered by the package root apply() via a lazy post-import hook. All pr_agent imports live +inside the patch function body (lazy).""" diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py index 6cdcee0b..72771e17 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/describe_assessment.py @@ -1,17 +1,19 @@ -"""describe 增强(受版本守卫):往 /describe 的 prompt schema 注入一个 assessment 字段, -让社区版 /describe 产出「思路建议(替代实现方案 + 倾向性建议)」段——对齐 Qodo Merge 的 -High-Level Assessment(社区版原生无此字段)。 +"""describe enhancement (version-guarded): inject an assessment field into the /describe prompt schema, +making the community-edition /describe produce a "thinking suggestions (alternative implementation approaches + +opinionated recommendation)" section — mirroring Qodo Merge's High-Level Assessment (the community edition has +no such field natively). -无需改渲染:pr_description._prepare_pr_answer 对未知 key 走通用 `### **Key**` 分支, -assessment 会自动渲染成 `### **Assessment**` 段进 description.md,由 app 的 parse-output -按表头映射为 sectionKey='assessment'。 +No rendering change needed: pr_description._prepare_pr_answer routes unknown keys through the generic +`### **Key**` branch, so assessment auto-renders into a `### **Assessment**` section in description.md, mapped by +the app's parse-output to sectionKey='assessment' by header. -注入方式:运行期改写 get_settings().pr_description_prompt.system(dynaconf set)。锚点是 -schema 里 title 字段尾 + 示例输出 title 之后;锚点缺失则跳过(版本漂移安全降级)。 +Injection method: rewrite get_settings().pr_description_prompt.system at runtime (dynaconf set). The anchors are +the end of the title field in the schema + right after the title in the example output; if an anchor is missing, +skip (safe degradation on version drift). """ from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version -# 紧跟 PRDescription schema 的 title 字段之后插入(锚定 title 字段行尾的唯一子串) +# Insert right after the title field of the PRDescription schema (anchored to a unique substring at the end of the title field line) _SCHEMA_ANCHOR = 'that captures the PR\'s main theme")' _SCHEMA_FIELD = ( '\n assessment: str = Field(description="A high-level assessment in GFM markdown, mirroring ' @@ -26,7 +28,7 @@ 'Be objective and specific to this PR. Leave empty for trivial changes.")' ) -# 紧跟示例输出的 title 之后插入(best-effort,缺失不致命) +# Insert right after the title in the example output (best-effort; missing is not fatal) _EXAMPLE_ANCHOR = "title: |\n ...\n" _EXAMPLE_FIELD = "assessment: |\n ...\n" @@ -42,12 +44,12 @@ def patch(module) -> None: settings = get_settings() prompt = settings.pr_description_prompt.system except Exception as exc: # noqa: BLE001 - _debug(f"describe assessment: 读取 prompt 失败(跳过): {exc}") + _debug(f"describe assessment: failed to read prompt (skipped): {exc}") return if not isinstance(prompt, str) or "assessment:" in prompt or "assessment: str" in prompt: - return # 已注入 / 形态异常 → 幂等跳过 + return # already injected / abnormal shape → idempotent skip if _SCHEMA_ANCHOR not in prompt: - _debug("describe assessment: schema 锚点未命中(版本漂移),跳过") + _debug("describe assessment: schema anchor not matched (version drift), skipping") return new = prompt.replace(_SCHEMA_ANCHOR, _SCHEMA_ANCHOR + _SCHEMA_FIELD, 1) if _EXAMPLE_ANCHOR in new: @@ -55,6 +57,6 @@ def patch(module) -> None: try: settings.set("pr_description_prompt.system", new) except Exception as exc: # noqa: BLE001 - _debug(f"describe assessment: 写回 prompt 失败(跳过): {exc}") + _debug(f"describe assessment: failed to write back prompt (skipped): {exc}") return _debug("describe assessment field injected into /describe prompt") diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py index 7d057015..6f318036 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/litellm_handler.py @@ -1,8 +1,8 @@ -"""litellm handler 补丁(合并在一个 patch_fn,同模块注册多 finder 会互相遮蔽): - (0) CLI 模式(MEEBOX_CLI_MODE 置位):换 chat_completion 直接调本机 CLI,绕过 litellm,随后 - return。该分支在版本守卫之前,不受 pr-agent 版本限制。 - (1) Anthropic 新型号去 temperature(仅 pin 版本)。 - (2) 包 _get_completion inline 采集真实 token usage(仅 pin 版本)。 +"""litellm handler patch (merged into one patch_fn; registering multiple finders on the same module would shadow each other): + (0) CLI mode (MEEBOX_CLI_MODE set): swap chat_completion to call the local CLI directly, bypassing litellm, then + return. This branch runs before the version guard and is not bound to the pr-agent version. + (1) Drop temperature for new Anthropic models (pinned version only). + (2) Wrap _get_completion to inline-collect real token usage (pinned version only). """ import os @@ -15,15 +15,15 @@ ) from ..usage import _emit_usage -# Anthropic 提示缓存最小可缓存粒度约 1k token;稳定前缀低于此不标缓存(含极小的判读 system)。 +# Anthropic prompt caching has a minimum cacheable granularity of ~1k tokens; a stable prefix below this is not marked for caching (including a tiny classification system prompt). _CACHE_MIN_CHARS = 4000 -# 全局稳定前缀用 1h 扩展 TTL:跨所有 PR/运行在 1h 内命中,写入 2× 由大量命中摊薄;需带 beta 头。 +# Use the 1h extended TTL for the global stable prefix: hits across all PRs/runs within 1h, the 2x write cost amortized by many hits; requires the beta header. _CACHE_TTL = "1h" _CACHE_BETA_FLAG = "extended-cache-ttl-2025-04-11" def _add_cache_beta_header(kwargs: dict) -> None: - """合入 1h 缓存所需 anthropic-beta 头(不覆盖既有标志)。""" + """Merge in the anthropic-beta header required for 1h caching (without overwriting existing flags).""" headers = kwargs.get("extra_headers") if not isinstance(headers, dict): headers = {} @@ -36,18 +36,18 @@ def _add_cache_beta_header(kwargs: dict) -> None: def _apply_system_prompt_cache(kwargs: dict) -> None: - """为 Anthropic 给 system 的稳定前缀标 cache_control(1h 扩展 TTL);并在任何情况下剥除 CACHE_BREAK 标记。 - Anthropic 提示缓存按**前缀**、**服务端**生效(不依赖暖会话),跨所有 PR/运行在 1h 内命中(写入 2× 由命中摊薄)。 - 覆盖两类 Anthropic 调用: - - 1) 编排 chat 通道(MEEBOX_CHAT_CACHE 置位、system 含 assembleSystemContext 插入的 CACHE_BREAK):按断点把 - 全局稳定前缀(SOUL/AGENTS/工具/记忆/用户)单独标缓存、PR/运行相关尾部保持纯文本。 - 2) pr-agent 工具 run(/review /describe /improve /ask,**无** CACHE_BREAK):system 即 pr-agent 的指令 + - 输出格式(约 12k 字符,仅随配置/语言/规则变、跨 PR 稳定;可变的 diff 在 user 侧),整段标缓存 → 同配置下 - 跨运行 1h 内命中。 - - 非 Anthropic(OpenAI/DeepSeek 等):自带自动前缀缓存、无需显式标,仅剥除 CACHE_BREAK 标记拼回纯文本;前缀 - 过小(< _CACHE_MIN_CHARS,如精简判读 system)也不标。 + """Mark cache_control (1h extended TTL) on the stable prefix given to Anthropic as system; and strip the CACHE_BREAK marker in all cases. + Anthropic prompt caching works by **prefix**, **server-side** (no warm session needed), hitting across all PRs/runs within 1h (2x write amortized by hits). + Covers two kinds of Anthropic calls: + + 1) Orchestrated chat channel (MEEBOX_CHAT_CACHE set, system contains the CACHE_BREAK inserted by assembleSystemContext): split at the breakpoint, + marking the global stable prefix (SOUL/AGENTS/tools/memory/user) for caching on its own, keeping the PR/run-related tail as plain text. + 2) pr-agent tool run (/review /describe /improve /ask, **no** CACHE_BREAK): system is pr-agent's instructions + + output format (~12k chars, varies only with config/language/rules, stable across PRs; the variable diff is on the user side), mark the whole thing for caching → under the same config, + hits across runs within 1h. + + Non-Anthropic (OpenAI/DeepSeek etc.): come with automatic prefix caching, no explicit marking needed, just strip the CACHE_BREAK marker and stitch back to plain text; a prefix + too small (< _CACHE_MIN_CHARS, e.g. a slim classification system prompt) is not marked either. """ msgs = kwargs.get("messages") if not isinstance(msgs, list): @@ -59,7 +59,7 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: continue stable, variable = split_cache_break(m["content"]) if stable is not None: - # 含 CACHE_BREAK(编排 chat):稳定前缀标缓存、尾部纯文本 + # has CACHE_BREAK (orchestrated chat): mark stable prefix for caching, tail plain text if chat_cache_on and is_anthropic and len(stable) >= _CACHE_MIN_CHARS: m["content"] = [ { @@ -71,10 +71,10 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: ] _add_cache_beta_header(kwargs) else: - # 非 anthropic / 未开缓存 / 前缀过小:去标记拼回纯文本(自动前缀缓存仍可命中)。 + # non-anthropic / caching off / prefix too small: strip marker and stitch back to plain text (automatic prefix caching can still hit). m["content"] = f"{stable}\n\n---\n\n{variable}" return - # 无 CACHE_BREAK(pr-agent 工具 run):Anthropic 把整段稳定 system 标缓存(diff 在 user 侧、不进缓存)。 + # no CACHE_BREAK (pr-agent tool run): Anthropic marks the whole stable system for caching (diff is on the user side, not cached). if is_anthropic and len(m["content"]) >= _CACHE_MIN_CHARS: m["content"] = [ { @@ -88,31 +88,31 @@ def _apply_system_prompt_cache(kwargs: dict) -> None: def patch(module) -> None: - """(1) 新版 Anthropic 原厂模型(claude-opus-4-8 等)弃用 temperature 参数,但 pr-agent 默认 - 仍发 temperature=0.2 → Anthropic API 直接报 "temperature is deprecated for this model", - review/describe 全失败。 - - pr-agent 只对 NO_SUPPORT_TEMPERATURE_MODELS 里**精确命中**的型号不发 temperature,该列表 - 硬编码且只列了 OpenAI o系列/gpt-5 等,不含任何新 Claude(上游更新滞后)。custom_reasoning_model - 虽也能去 temperature 但会把 system 并进 user(劣化 Claude 的 system prompt),不用。 - - 这里把模块全局 NO_SUPPORT_TEMPERATURE_MODELS 换成"额外认所有 anthropic/* 前缀模型"的智能 - 容器:凡走 anthropic 原厂的模型一律不发 temperature。LiteLLMAIHandler.__init__ 里 - `self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS` 取的是模块全局名,故重绑 - 全局即对之后创建的 handler 生效;只动成员判定、不碰 system/user 合并。""" - # 抑制 litellm 往 **stdout** 打的「Provider List: …」等装饰性提示(ANSI 红字)。编排 chat 通道以子进程 - # stdout 作模型回复:litellm 在 cost/token 计量里对未进本地 model_cost 表的新模型(如 claude-opus-4-8) - # 调 get_llm_provider 失败时会先 print 该提示再抛错(错误被上游吞掉、不影响最终结果),但 print 已污染 - # stdout、漏进评审总结。置 suppress_debug_info=True 关掉这些 print(真实 usage 由我们自己的 hook 采集, - # 不依赖这些输出)。全局生效、与 pr-agent 版本无关,故放在版本守卫与 CLI 分支之前。 + """(1) New first-party Anthropic models (claude-opus-4-8 etc.) deprecate the temperature parameter, but pr-agent + by default still sends temperature=0.2 → the Anthropic API directly reports "temperature is deprecated for this model", + failing all review/describe. + + pr-agent only omits temperature for models that **exactly match** an entry in NO_SUPPORT_TEMPERATURE_MODELS, a list that is + hardcoded and lists only OpenAI o-series/gpt-5 etc., without any new Claude (upstream updates lag). custom_reasoning_model + could also drop temperature but merges system into user (degrading Claude's system prompt), so it's not used. + + Here we replace the module-global NO_SUPPORT_TEMPERATURE_MODELS with a smart container that "additionally recognizes all + anthropic/* prefixed models": any model going through first-party anthropic never sends temperature. In LiteLLMAIHandler.__init__, + `self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS` reads the module-global name, so rebinding + the global takes effect for handlers created afterward; only the membership test is touched, not the system/user merge.""" + # Suppress the decorative hints (ANSI red text) like "Provider List: …" that litellm prints to **stdout**. The orchestrated chat channel uses the subprocess + # stdout as the model reply: in cost/token accounting, when litellm calls get_llm_provider and fails for a new model not in the local model_cost table (e.g. claude-opus-4-8), + # it first prints that hint then raises (the error is swallowed upstream, not affecting the final result), but the print has already polluted + # stdout and leaks into the review summary. Set suppress_debug_info=True to turn off these prints (real usage is collected by our own hook, + # not dependent on this output). Globally effective, independent of the pr-agent version, so placed before the version guard and CLI branch. try: import litellm litellm.suppress_debug_info = True - except Exception: # noqa: BLE001 - litellm 未就绪等,纯装饰性抑制失败不致命 + except Exception: # noqa: BLE001 - litellm not ready etc.; failure of purely decorative suppression is not fatal pass - # (0) CLI 模式:换 chat_completion 直接调本机 CLI,绕过 litellm。放在版本守卫之前, - # 因为它只依赖 base_ai_handler 的稳定契约,跟 pr-agent 内部实现无关。装好即 return。 + # (0) CLI mode: swap chat_completion to call the local CLI directly, bypassing litellm. Placed before the version guard, + # because it only depends on the stable contract of base_ai_handler, unrelated to pr-agent internals. Return once installed. if os.environ.get("MEEBOX_CLI_MODE"): handler_cls = getattr(module, "LiteLLMAIHandler", None) if handler_cls is not None: @@ -122,7 +122,7 @@ def patch(module) -> None: installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: - # 版本不符:local_git_provider 补丁已 _warn 过总体降级,这里静默跳过避免重复噪音 + # version mismatch: the local_git_provider patch has already _warn'd about the overall degradation, so silently skip here to avoid duplicate noise _debug( f"skip no-temperature patch: pr-agent {installed} != {_EXPECTED_PRAGENT_VERSION}" ) @@ -134,23 +134,23 @@ class _NoTempModels(list): def __contains__(self, model) -> bool: if list.__contains__(self, model): return True - # 我们的 normalizeModel 给 anthropic provider 一律补 anthropic/ 前缀 + # our normalizeModel always prepends the anthropic/ prefix for the anthropic provider return (model or "").lower().startswith("anthropic/") module.NO_SUPPORT_TEMPERATURE_MODELS = _NoTempModels(orig) - # (2) 真实 token usage 采集:包 LiteLLMAIHandler._get_completion,从其返回的 - # (content, finish_reason, response) 里取 response.usage,inline 打哨兵到 stderr。 - # 不用 litellm 的 callback —— 那是后台 logging worker 异步触发,CLI 退出过快会丢; - # 这里 inline 在 pr-agent 的 await 链里,必在进程退出前执行,可靠。 + # (2) Real token usage collection: wrap LiteLLMAIHandler._get_completion, take response.usage from its returned + # (content, finish_reason, response), and inline-print a sentinel to stderr. + # Don't use litellm's callback —— that fires asynchronously on a background logging worker, and is lost if the CLI exits too fast; + # here it's inline in pr-agent's await chain, guaranteed to run before process exit, reliable. handler_cls = getattr(module, "LiteLLMAIHandler", None) if handler_cls is not None and hasattr(handler_cls, "_get_completion"): _orig_get_completion = handler_cls._get_completion async def _get_completion_with_usage(self, **kwargs): - # 输出封顶(编排器 chat 通道经 MEEBOX_CHAT_MAX_TOKENS 设;pr-agent 工具 run 的 env 不含 - # 该项,故 /describe /review 不受限)。"thinking" 在场(Claude 扩展思考)时不覆盖其 max_tokens - # (否则会低于 thinking budget 报错);已有 max_tokens 也不覆盖。 + # Output cap (set by the orchestrator chat channel via MEEBOX_CHAT_MAX_TOKENS; the pr-agent tool run's env does not include + # this, so /describe /review are uncapped). When "thinking" is present (Claude extended thinking), don't override its max_tokens + # (otherwise it would fall below the thinking budget and error); also don't override an already-set max_tokens. mt = os.environ.get("MEEBOX_CHAT_MAX_TOKENS") if mt and "thinking" not in kwargs and "max_tokens" not in kwargs: try: diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py index 47c59786..cd5322c9 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/load_yaml.py @@ -1,16 +1,16 @@ -"""load_yaml 健壮化补丁(受版本守卫):解析失败时剥 anchor marker / 重排多行块标量后重试, -避免模型偶发破格 YAML 让整个 /review 崩。""" +"""load_yaml robustness patch (version-guarded): on parse failure, strip the anchor marker / reflow multi-line block scalars and retry, +to keep an occasional malformed YAML from the model from crashing the whole /review.""" from ..runtime import _EXPECTED_PRAGENT_VERSION, _debug, _pragent_version def _reflow_unindented_multiline(text): - """修复模型最常见的破 YAML 写法:把多行自由文本值(中文 issue_content / issue_header 等) - 写成「key: 内联首行」+ 续行**顶格(第 1 列)**,没有用块标量 `|`。YAML 把续行当成新 key → - `could not find expected ':'`,pr-agent 自带 fallback 也救不回(其首个 fallback 只对固定 key - 列表补 `|`,且不重排续行缩进)。 + """Fix the model's most common malformed-YAML pattern: a multi-line free-text value (Chinese issue_content / issue_header etc.) + written as "key: inline first line" + continuation lines **flush left (column 1)**, without a block scalar `|`. YAML treats the + continuation as a new key → `could not find expected ':'`, and pr-agent's own fallback can't recover it (its first fallback only + appends `|` for a fixed key list, and doesn't reflow continuation indentation). - 这里把「key: 内联值 + 紧随其后的非 key / 非 list-item 行」整体重排成 `key: |-` 块标量并统一 - 缩进,使值完整保留为多行字符串。仅在原始解析失败后调用;任何不匹配都原样保留(无回归)。""" + Here we reflow "key: inline value + the immediately following non-key / non-list-item lines" as a whole into a `key: |-` block scalar + with uniform indentation, preserving the value intact as a multi-line string. Called only after the original parse fails; any non-match is kept as-is (no regression).""" import re key_re = re.compile(r"^(\s*)(-\s+)?([A-Za-z_][A-Za-z0-9_ ]*):(.*)$") @@ -26,7 +26,7 @@ def _reflow_unindented_multiline(text): continue indent, dash, key, rest = m.group(1), (m.group(2) or ""), m.group(3), m.group(4) rest_s = rest.strip() - # 收集续行:直到遇到下一个 key 行 / list-item / 文件结束 + # Collect continuation lines: until the next key line / list-item / end of file j = i + 1 cont = [] while j < n: @@ -37,7 +37,7 @@ def _reflow_unindented_multiline(text): j += 1 is_block = rest_s in ("|", "|-", "|2", ">", ">-") or rest_s.endswith(("|", "|-")) if any(c.strip() for c in cont) and not is_block: - ci = " " * (len(indent) + len(dash) + 2) # 块标量内容须比 key 列更深 + ci = " " * (len(indent) + len(dash) + 2) # block scalar content must be deeper than the key column out.append(f"{indent}{dash}{key}: |-") if rest_s: out.append(ci + rest_s) @@ -51,18 +51,18 @@ def _reflow_unindented_multiline(text): def patch(module) -> None: - """pr-agent 把 LLM 输出按 YAML 解析(pr_agent.algo.utils.load_yaml)。模型偶发产出破格 YAML → - safe_load + pr-agent 自带 fallback 全失败 → load_yaml 返回 None → pr_reviewer 迭代 None 崩 - (argument of type 'NoneType' is not iterable),整个 review 失败。两类高频破格: - 1. 我们注入的 anchor `[file: ...]` marker 独占一行落在 mapping 上下文,`[` 被当 flow 序列起始; - 2. 多行自由文本值(中文 issue_content 等)续行顶格、未用块标量 `|`(见 _reflow_unindented_multiline)。 + """pr-agent parses the LLM output as YAML (pr_agent.algo.utils.load_yaml). The model occasionally produces malformed YAML → + safe_load + pr-agent's own fallback all fail → load_yaml returns None → pr_reviewer iterating None crashes + (argument of type 'NoneType' is not iterable), failing the whole review. Two high-frequency malformations: + 1. our injected anchor `[file: ...]` marker occupies a line on its own in a mapping context, and `[` is taken as the start of a flow sequence; + 2. a multi-line free-text value (Chinese issue_content etc.) with continuation lines flush left, without a block scalar `|` (see _reflow_unindented_multiline). - 包一层 load_yaml:原逻辑成功就原样返回(不影响正常路径 + 行号兜底);失败时依次尝试 - 「剥 marker」「重排多行块标量」「两者叠加」,每个候选都交回原 load_yaml(含其自身 try_fix_yaml)。 - 全部失败才返回 None。inline marker / 合法 YAML 不受影响。 + Wrap load_yaml: if the original logic succeeds, return as-is (doesn't affect the normal path + line-number fallback); on failure, try in order + "strip marker", "reflow multi-line block scalar", "both combined", handing each candidate back to the original load_yaml (including its own try_fix_yaml). + Only return None if all fail. Inline markers / valid YAML are unaffected. - pr_reviewer 用 `from pr_agent.algo.utils import load_yaml`,utils 先于 tools 被 import,本 patch - 在 utils exec 完后替换 module.load_yaml,故 tools 后续 import 拿到的是包装版。""" + pr_reviewer uses `from pr_agent.algo.utils import load_yaml`; utils is imported before tools, and this patch + replaces module.load_yaml after utils finishes exec, so tools' later import gets the wrapped version.""" installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: _debug(f"skip load_yaml patch: pr-agent {installed} != {_EXPECTED_PRAGENT_VERSION}") @@ -70,27 +70,27 @@ def patch(module) -> None: import re as _re - # loguru 全局单例(pr_agent.log 用的同一个)。用于在「首探+修复」阶段临时压制 - # pr_agent.algo.utils 的失败日志;import 失败则降级为不压制(不致命)。 + # loguru global singleton (the same one pr_agent.log uses). Used to temporarily suppress + # pr_agent.algo.utils's failure logs during the "first probe + repair" phase; if import fails, degrade to no suppression (not fatal). try: from loguru import logger as _loguru_logger except Exception: # noqa: BLE001 _loguru_logger = None orig_load_yaml = module.load_yaml - # 整行仅为 `[file: ...]`(含缩进/path-only 形式),吃掉行尾换行 + # whole line is only `[file: ...]` (including indented / path-only forms), consuming the trailing newline marker_line_re = _re.compile(r"(?m)^[ \t]*\[file:[^\]\n]*\][ \t]*$\n?") - # 激进兜底:`[file:` 起、吃到 `]` 或行尾(闭合 `]` 可选),出现在**任意位置**都抹掉。 - # 统一覆盖:独占整行、行内(`issue text [file:...]`)、值位、以及模型截断漏 `]` 的未闭合 - # marker。marker_line_re(仅独占整行的闭合形式)漏掉的破格全归它收。 + # aggressive fallback: from `[file:`, consuming up to `]` or line end (closing `]` optional), removed wherever it appears **anywhere**. + # Uniformly covers: whole-line-only, inline (`issue text [file:...]`), value position, and unclosed + # markers where the model truncated and dropped `]`. Malformations missed by marker_line_re (only the whole-line-only closed form) all fall to it. marker_any_re = _re.compile(r"\[file:[^\]\n]*\]?") def _repair_candidates(response_text): - """生成修复候选,按代价从小到大;每个交回原 load_yaml(其内部还会再跑 try_fix_yaml)。 - marker 仅是行号兜底(anchor 主源是 get_line_link 的 meebox:/// 链接),recovery 路径剥掉 - 它不影响主锚点,优先保证整个 /review 不因一条破格 marker 整体失败。""" + """Generate repair candidates, from cheapest to most costly; each handed back to the original load_yaml (which internally reruns try_fix_yaml). + The marker is only a line-number fallback (the anchor's main source is the meebox:/// link from get_line_link); the recovery path stripping + it doesn't affect the main anchor, prioritizing keeping the whole /review from failing entirely over one malformed marker.""" stripped = marker_line_re.sub("", response_text) - # 更激进:任意位置 / 未闭合 marker 全清(marker_line_re 只清独占整行的闭合形式) + # more aggressive: clear markers anywhere / unclosed (marker_line_re only clears the whole-line-only closed form) aggressive = marker_any_re.sub("", response_text) attempts = [] if stripped != response_text: @@ -110,10 +110,10 @@ def _repair_candidates(response_text): return attempts def load_yaml(response_text, *args, **kwargs): - # 「首探 + 修复」阶段静默:orig 对带 marker 的原文必然解析失败并打 WARNING+ERROR,但这些 - # 破格我们随后能修复,那两条日志是误导噪音(让用户以为 /review 挂了)。故临时压制 - # pr_agent.algo.utils 的日志;仅当所有修复都失败,才放**原始报错**出来(真失败应可见)。 - # 本 shim 跑在单次 review 的独立 python 子进程、无并发,disable/enable 全局开关安全。 + # "first probe + repair" phase silence: orig will inevitably fail to parse the original text with a marker and log WARNING+ERROR, but these + # malformations we can repair afterward, so those two logs are misleading noise (making the user think /review crashed). So temporarily suppress + # pr_agent.algo.utils's logs; only when all repairs fail do we let the **original error** through (a real failure should be visible). + # This shim runs in an isolated python subprocess for a single review, with no concurrency, so the global disable/enable switch is safe. if _loguru_logger is not None: _loguru_logger.disable("pr_agent.algo.utils") try: @@ -124,7 +124,7 @@ def load_yaml(response_text, *args, **kwargs): for cand in _repair_candidates(response_text): try: d = orig_load_yaml(cand, *args, **kwargs) - except Exception: # noqa: BLE001 - 修复尝试失败不致命,继续下一个 + except Exception: # noqa: BLE001 - a repair attempt failing is not fatal, continue to the next d = None if d: _debug("load_yaml recovered via meebox repair") @@ -132,7 +132,7 @@ def load_yaml(response_text, *args, **kwargs): finally: if _loguru_logger is not None: _loguru_logger.enable("pr_agent.algo.utils") - # 全部修复失败 → 日志已恢复,重跑一次 orig 让真实报错可见,并返回其结果(None/空) + # all repairs failed → logs restored, rerun orig once to make the real error visible, and return its result (None/empty) return orig_load_yaml(response_text, *args, **kwargs) module.load_yaml = load_yaml diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py index 541fb196..809ca1c5 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/patches/local_git_provider.py @@ -1,18 +1,18 @@ -"""LocalGitProvider 补丁(受版本守卫):二进制安全 get_diff_files + get_line_link anchor。""" +"""LocalGitProvider patch (version-guarded): binary-safe get_diff_files + get_line_link anchor.""" from ..runtime import _EXPECTED_PRAGENT_VERSION, _pragent_version, _warn def patch(module) -> None: - """LocalGitProvider.get_diff_files 对每个 diff 文件无脑 .decode('utf-8'),遇到二进制 - 文件(图片 / 编译产物 / UTF-16 等,如 0xff 开头)抛 UnicodeDecodeError 崩掉整个 review。 - 换成二进制安全版:解码失败的文件跳过(review 不处理二进制),其余逻辑与上游一致。""" - # 版本守卫:只对 pin 的 pr-agent 版本打补丁;不符则整组跳过(含 get_line_link)。 + """LocalGitProvider.get_diff_files blindly .decode('utf-8') on every diff file, and on a binary + file (images / build artifacts / UTF-16 etc., e.g. starting with 0xff) throws UnicodeDecodeError, crashing the whole review. + Replace with a binary-safe version: files that fail to decode are skipped (review doesn't handle binaries), the rest of the logic identical to upstream.""" + # version guard: only patch the pinned pr-agent version; on mismatch skip the whole group (including get_line_link). installed = _pragent_version() if installed != _EXPECTED_PRAGENT_VERSION: _warn( - f"pr-agent {installed} 与 meebox 补丁适配的 {_EXPECTED_PRAGENT_VERSION} 不符," - "已跳过补丁(/review 行号定位、二进制安全 diff 失效)。如为有意升级,请同步 " - "runtime.py 的 _EXPECTED_PRAGENT_VERSION + pragent-runtime.json 并重新验证。" + f"pr-agent {installed} does not match the {_EXPECTED_PRAGENT_VERSION} that the meebox patch is adapted for; " + "patches skipped (/review line-number anchoring and binary-safe diff disabled). If this is an intentional upgrade, sync " + "runtime.py's _EXPECTED_PRAGENT_VERSION + pragent-runtime.json and re-verify." ) return from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo @@ -38,7 +38,7 @@ def get_diff_files(self): ) patch_str = diff_item.diff.decode("utf-8") except (UnicodeDecodeError, ValueError): - # 二进制文件无法 utf-8 解码 → 跳过该文件 + # binary file can't be utf-8 decoded → skip this file continue edit_type = EDIT_TYPE.MODIFIED if diff_item.new_file: @@ -52,10 +52,10 @@ def get_diff_files(self): original_file_content_str, new_file_content_str, patch_str, - # 被删除文件 b_path 为 None → FilePatchInfo.filename=None,下游 - # set_file_languages / extract_relevant_lines_str 的 filename.rsplit/strip - # 会崩,且一崩会中断整次 review 的行号片段抽取(连未删文件的 finding 也丢 - # 代码片段)。回退用 a_path 保证 filename 永不为 None。 + # a deleted file's b_path is None → FilePatchInfo.filename=None, and downstream + # set_file_languages / extract_relevant_lines_str's filename.rsplit/strip + # would crash, and one crash interrupts the whole review's line-snippet extraction (even findings for non-deleted files lose + # code snippets). Fall back to a_path to guarantee filename is never None. diff_item.b_path or diff_item.a_path, edit_type=edit_type, old_filename=None @@ -68,23 +68,23 @@ def get_diff_files(self): module.LocalGitProvider.get_diff_files = get_diff_files - # _prepare_repo: 上游在 repo.is_dirty() 时抛「repository is not in a clean state」。我们对 CLI 模式 - # /ask 的 worktree 会按需净化——截断仓库自带的 agent 指令文件(CLAUDE.md / AGENTS.md / .cursor 规则 - # 等,防 CLI 子进程自动加载污染回答);若这些文件被仓库纳入版本管理,净化即让工作区变「脏」,触发该守卫 - # → 整个 /ask 在取 git provider 阶段就崩、不写 review.md。而 diff 取自分支提交(head.commit vs - # merge-base,见 get_diff_files),与工作区是否脏无关,故脏检查对这套「一次性受控 worktree」是误报。 - # 只保留必需的「目标分支存在」校验,去掉脏检查。 + # _prepare_repo: upstream throws "repository is not in a clean state" when repo.is_dirty(). For CLI-mode + # /ask worktrees we sanitize as needed — truncating the repo's own agent instruction files (CLAUDE.md / AGENTS.md / .cursor rules + # etc., to prevent the CLI subprocess from auto-loading and polluting the answer); if these files are tracked by the repo, sanitizing makes the working tree "dirty", tripping this guard + # → the whole /ask crashes at the git-provider acquisition stage, never writing review.md. But the diff comes from branch commits (head.commit vs + # merge-base, see get_diff_files), independent of whether the working tree is dirty, so the dirty check is a false positive for this "one-shot controlled worktree" setup. + # Keep only the required "target branch exists" check, drop the dirty check. def _prepare_repo(self): if self.target_branch_name not in self.repo.heads: raise KeyError(f"Branch: {self.target_branch_name} does not exist") module.LocalGitProvider._prepare_repo = _prepare_repo - # get_line_link: 基类默认 `return ''`,LocalGitProvider 未实现 → /review 的 - # key_issues 渲染(convert_to_markdown_v2)走"无 link + 非 GFM"分支,把 - # relevant_file/start_line/end_line 抹掉(见 ROADMAP M5 anchor 根因)。补成 - # meebox:///<url-encoded-file>#L<s>-L<e>,使其走 [**header**](link) 分支, - # parse-output 据链接取结构化 anchor(与真实 provider 同源,不依赖模型自报 marker)。 + # get_line_link: the base class defaults to `return ''`, and LocalGitProvider doesn't implement it → /review's + # key_issues rendering (convert_to_markdown_v2) takes the "no link + non-GFM" branch, dropping + # relevant_file/start_line/end_line (see ROADMAP M5 anchor root cause). Fill in as + # meebox:///<url-encoded-file>#L<s>-L<e>, so it takes the [**header**](link) branch, + # and parse-output derives the structured anchor from the link (same source as real providers, not dependent on the model self-reporting a marker). from urllib.parse import quote def get_line_link(self, relevant_file, relevant_line_start, relevant_line_end=None): @@ -99,11 +99,11 @@ def get_line_link(self, relevant_file, relevant_line_start, relevant_line_end=No module.LocalGitProvider.get_line_link = get_line_link - # 统一启用 GFM:LocalGitProvider 默认对 'gfm_markdown' 报 False,导致 /describe 的 - # enable_pr_diagram(configuration.toml 默认开)被 `enable and is_supported(gfm_markdown)` - # 门控关掉、不产出 mermaid 架构图;/review 等也走非 GFM 简化分支。这里让 gfm_markdown - # 返回 True,使 describe 按需输出 mermaid 图、各工具走 GFM 富 markdown(details / 表格 / - # mermaid)。格式兼容由应用端 markdown 解析(rehype + mermaid 渲染)处理。其余能力保持原状。 + # uniformly enable GFM: LocalGitProvider defaults to False for 'gfm_markdown', causing /describe's + # enable_pr_diagram (on by default in configuration.toml) to be gated off by `enable and is_supported(gfm_markdown)`, + # not producing the mermaid architecture diagram; /review etc. also take the non-GFM simplified branch. Here we make gfm_markdown + # return True, so describe outputs mermaid diagrams as needed and each tool takes GFM rich markdown (details / tables / + # mermaid). Format compatibility is handled by the app-side markdown parsing (rehype + mermaid rendering). Other capabilities stay as-is. _orig_is_supported = module.LocalGitProvider.is_supported def is_supported(self, capability): @@ -113,11 +113,11 @@ def is_supported(self, capability): module.LocalGitProvider.is_supported = is_supported - # get_pr_labels: 基类未实现,LocalGitProvider 直接抛 NotImplementedError('Getting labels - # is not implemented for the local git provider')。/review 跑完会调 set_review_labels → - # get_pr_labels(update=True) 读现有标签做 merge,本地仓库无"标签"概念,异常被 pr_reviewer - # catch 后打成 ERROR 噪音(review 结果不受影响)。本地无远端标签,返回空列表即可: - # set_review_labels 据此走 publish_labels(LocalGitProvider 本就是 no-op),全程静默。 + # get_pr_labels: the base class doesn't implement it, and LocalGitProvider directly throws NotImplementedError('Getting labels + # is not implemented for the local git provider'). After /review runs it calls set_review_labels → + # get_pr_labels(update=True) reads existing labels to merge; a local repo has no "label" concept, so the exception is caught by pr_reviewer + # and logged as ERROR noise (the review result is unaffected). A local repo has no remote labels, so returning an empty list suffices: + # set_review_labels then takes publish_labels (which LocalGitProvider is already a no-op for), silent throughout. def get_pr_labels(self, update=False): return [] diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py index 7c4bb755..967b27ad 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/runtime.py @@ -1,30 +1,32 @@ -"""嵌入式运行时 monkeypatch 的基础设施:惰性 post-import hook 注册、版本守卫、日志。 +"""Infrastructure for the embedded runtime monkeypatch: lazy post-import hook registration, version guard, logging. -绝不在本模块(或其加载链)eager import pr_agent —— sitecustomize 在每次 python 启动都会 -加载本包,eager import 会拖慢每次调用、甚至在 pr-agent 尚未装好时报错。各 patch 对 pr_agent -的 import 一律放在 patch 函数体内(惰性),仅当目标模块真正被 import 时才执行。 +Never eager import pr_agent in this module (or its load chain) -- sitecustomize loads this package on every +python startup, so an eager import would slow every call, or even error when pr-agent isn't installed yet. Each patch's +import of pr_agent always sits inside the patch function body (lazily), executing only when the target module is actually imported. """ import importlib.abc import importlib.util import os import sys -# 本 shim 的 monkeypatch 依赖 pr-agent **特定版本**的内部实现(get_line_link 渲染分支、 -# get_diff_files 解码逻辑、load_yaml 等)。升级 pr-agent 可能让 patch 失配甚至误伤,故只对 -# 下面 pin 的版本生效;版本不符即跳过相关 patch(安全降级,宁可少打补丁也不乱打)。 -# 升级 pr-agent 时:同步此常量 + scripts/pragent-runtime.json 的 prAgent.version -# (assemble 脚本会校验两者一致,并据本文件抽取该常量),并重新验证 patch 行为。 +# This shim's monkeypatch depends on **a specific version** of pr-agent's internals (get_line_link render +# branch, get_diff_files decode logic, load_yaml, etc.). Upgrading pr-agent may make a patch mismatch or even +# misfire, so it only takes effect for the version pinned below; on version mismatch the relevant patch is +# skipped (safe degradation, preferring to apply fewer patches over applying wrong ones). +# When upgrading pr-agent: sync this constant + prAgent.version in scripts/pragent-runtime.json +# (the assemble script verifies the two match and extracts this constant from this file), and re-verify patch behavior. _EXPECTED_PRAGENT_VERSION = "0.36.0" -# 系统上下文「缓存断点」标记:assembleSystemContext(TS, packages/agent/src/assemble.ts)在**全局稳定 -# 前缀**(SOUL/AGENTS/工具目录/记忆/用户档)与 **PR/运行相关尾部** 之间插入此串(连同两侧 --- 分隔)。 -# shim 据此把稳定前缀单独标 Anthropic 提示缓存(1h),尾部保持纯文本;消费端(litellm 分块 / CLI 拼接) -# 分割或剥除后,标记**绝不**进入发给模型的 prompt。两处常量须逐字一致。 +# System context "cache break" marker: assembleSystemContext (TS, packages/agent/src/assemble.ts) inserts this string +# (along with the --- separators on both sides) between the **globally stable prefix** (SOUL/AGENTS/tool directory/memory/user +# profile) and the **PR/run-related tail**. Based on it, the shim marks the stable prefix alone with Anthropic prompt caching (1h), +# keeping the tail as plain text; after the consumers (litellm chunking / CLI concatenation) split or strip it, the marker **never** +# enters the prompt sent to the model. The two constants must match verbatim. CACHE_BREAK = "\n\n---\n\n[[MEEBOX:CACHE_BREAK]]\n\n---\n\n" def split_cache_break(system): - """按缓存断点切分 system → (stable_prefix, variable_tail)。无断点返回 (None, system)。""" + """Split system by cache break → (stable_prefix, variable_tail). Returns (None, system) if no break.""" stable, sep, variable = system.partition(CACHE_BREAK) if not sep: return None, system @@ -32,7 +34,7 @@ def split_cache_break(system): def strip_cache_break(system): - """剥除缓存断点标记(不分块的消费端用,如 CLI prompt 拼接),塌成单个 --- 分隔。""" + """Strip the cache break marker (for non-chunking consumers, e.g. CLI prompt concatenation), collapsing into a single --- separator.""" return system.replace(CACHE_BREAK, "\n\n---\n\n") @@ -42,30 +44,30 @@ def _debug(msg) -> None: def _warn(msg) -> None: - """始终输出到 stderr(不受 MEEBOX_SHIM_DEBUG 控制)。用于版本不符等"补丁静默失效"的降级 - 场景,必须让用户/日志看见。stderr 不影响 parse-output(它只解析 stdout)。""" + """Always writes to stderr (not gated by MEEBOX_SHIM_DEBUG). Used for degradation scenarios where "a patch + silently fails", such as version mismatch, which must be visible to the user/logs. stderr doesn't affect parse-output (it only parses stdout).""" print(f"[meebox] WARNING: {msg}", file=sys.stderr) def _pragent_version(): - """读已安装 pr-agent 版本(仅读 dist 元数据,不 import pr_agent)。拿不到返回 None。""" + """Read the installed pr-agent version (only reads dist metadata, doesn't import pr_agent). Returns None if unavailable.""" try: from importlib.metadata import version return version("pr-agent") - except Exception: # noqa: BLE001 - 未安装 / 元数据缺失(pip 装包途中等) + except Exception: # noqa: BLE001 - not installed / metadata missing (mid pip install, etc.) return None def _register_post_import(module_name, patch_fn) -> None: - """注册一个 meta_path finder:当 module_name 被 import 后立即执行 patch_fn(module)。 - 不在此处 import 该模块,保持 python 启动/探测/pip 轻量。""" + """Register a meta_path finder: run patch_fn(module) immediately after module_name is imported. + Doesn't import the module here, keeping python startup/probing/pip lightweight.""" class _Finder(importlib.abc.MetaPathFinder): def find_spec(self, fullname, path=None, target=None): if fullname != module_name: return None - # 临时摘掉自己,借默认机制拿到真实 spec,再包一层 loader 在 exec 后 patch + # Temporarily remove self, use the default mechanism to get the real spec, then wrap a loader to patch after exec sys.meta_path.remove(self) try: spec = importlib.util.find_spec(fullname) diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py index 9f8de2ab..bccf7e6b 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py @@ -1,13 +1,13 @@ -"""真实 token usage 采集:以哨兵行 `@@MEEBOX_USAGE@@ {json}` 打到 stderr,主进程 onLine -据此累加(见 apps/desktop/src/main/ipc.ts)。只取 token、不取 cost。全程容错。""" +"""Real token usage collection: emitted to stderr as a sentinel line `@@MEEBOX_USAGE@@ {json}`, which the main process onLine +accumulates from (see apps/desktop/src/main/ipc.ts). Takes only tokens, not cost. Fault-tolerant throughout.""" import sys from .runtime import _debug def _emit_usage(response) -> None: - """从 litellm response 读**真实 usage**(API 返回,非预估)。供 litellm handler 的 - _get_completion 包装调用。""" + """Read **real usage** from the litellm response (API-returned, not estimated). Called by the litellm handler's + _get_completion wrapper.""" try: usage = getattr(response, "usage", None) if usage is None and isinstance(response, dict): @@ -26,9 +26,9 @@ def _g(key): "total_tokens": _g("total_tokens"), } if rec["prompt_tokens"] is None and rec["completion_tokens"] is None: - return # 没有任何可用数字(如流式 MockResponse)→ 不打 - # 提示缓存读取量:Anthropic 走 cache_read_input_tokens;OpenAI 兼容走 - # prompt_tokens_details.cached_tokens。两路尽力采集、缺失则不带(UI 据有无决定是否展示)。 + return # no usable numbers at all (e.g. streaming MockResponse) → don't emit + # Prompt cache read amount: Anthropic uses cache_read_input_tokens; OpenAI-compatible uses + # prompt_tokens_details.cached_tokens. Best-effort collection on both paths, omitted if missing (UI decides whether to display based on presence). cache_read = _g("cache_read_input_tokens") if not isinstance(cache_read, int): details = _g("prompt_tokens_details") @@ -48,8 +48,8 @@ def _g(key): def _emit_usage_tokens( prompt_tokens, completion_tokens, cache_read_tokens=None, turns=None ) -> None: - """CLI 模式下从 CLI 返回的 JSON usage 直接构造哨兵(与 _emit_usage 同格式,主进程同一套 - 累加逻辑)。两个 token 数都为 None 则不打;cache_read / turns 仅在有值时附带。""" + """In CLI mode, construct the sentinel directly from the CLI-returned JSON usage (same format as _emit_usage, same + accumulation logic in the main process). Doesn't emit if both token counts are None; cache_read / turns are attached only when present.""" try: if prompt_tokens is None and completion_tokens is None: return diff --git a/apps/desktop/scripts/pragent-shim/sitecustomize.py b/apps/desktop/scripts/pragent-shim/sitecustomize.py index cdbd1fc5..13d0a8ae 100644 --- a/apps/desktop/scripts/pragent-shim/sitecustomize.py +++ b/apps/desktop/scripts/pragent-shim/sitecustomize.py @@ -1,18 +1,18 @@ -# meebox 嵌入式运行时 monkeypatch shim —— 薄加载器。 +# meebox embedded runtime monkeypatch shim -- thin loader. # -# CPython 启动时经 `site` 自动 import 本模块(名为 sitecustomize,无需 PYTHONPATH/挂载)。 -# 真正的补丁实现按领域拆在同目录的 `meebox_pragent_shim` 包里(patches/ 与 cli/)。 -# 本文件只负责:调用 apply() 注册全部惰性 post-import hook,并整体兜底——shim 绝不能让 -# 解释器/agent 崩。**绝不在此 eager import pr_agent**(本文件每次 python 启动都会跑: -# 探测 --version / find_spec / pip 装包等,eager import 会拖慢甚至在 pr-agent 未装好时报错)。 +# CPython auto-imports this module via `site` on startup (named sitecustomize, no PYTHONPATH/mount needed). +# The actual patch implementations are split by domain into the `meebox_pragent_shim` package in the same directory (patches/ and cli/). +# This file is only responsible for: calling apply() to register all lazy post-import hooks, and providing an overall fallback -- the shim +# must never crash the interpreter/agent. **Never eager import pr_agent here** (this file runs on every python startup: +# probing --version / find_spec / pip install, etc.; an eager import would slow it down or even error when pr-agent isn't installed yet). # -# 由 assemble-pragent-runtime.mjs 把本文件 + meebox_pragent_shim/ 整体拷进 site-packages。 -# 详见 docs/arch/02-agent/05-pragent-runtime.md。 +# assemble-pragent-runtime.mjs copies this file + meebox_pragent_shim/ wholesale into site-packages. +# See docs/arch/02-agent/05-pragent-runtime.md for details. try: from meebox_pragent_shim import apply apply() -except Exception as exc: # noqa: BLE001 - shim 绝不能让解释器/agent 崩 +except Exception as exc: # noqa: BLE001 - the shim must never crash the interpreter/agent try: import os import sys diff --git a/apps/desktop/src/main/adapters.ts b/apps/desktop/src/main/adapters.ts index f62a23d4..5dbe9aa7 100644 --- a/apps/desktop/src/main/adapters.ts +++ b/apps/desktop/src/main/adapters.ts @@ -17,9 +17,10 @@ export interface BuiltAdapter { } /** - * 可变的连接运行时持有:adapters(全量,IPC 按 id 查任意连接)+ adapterByHost - * (repo-mirror 按 host 找 adapter 取 clone url)。设置页改连接时 reconfigure 原地替换 - * 内容,IPC handler / repoMirror 经引用读到新值,无需重启。 + * Mutable connections runtime holding: adapters (full set, IPC looks up any connection by id) + + * adapterByHost (repo-mirror finds an adapter by host to get its clone url). When the settings page + * changes connections, reconfigure replaces the contents in place; IPC handler / repoMirror read the + * new values through the reference, no restart needed. */ export interface ConnectionRuntime { adapters: BuiltAdapter[]; @@ -27,9 +28,11 @@ export interface ConnectionRuntime { } /** - * 用草稿 base_url + token 临时起一个 adapter,仅供设置页 ping 测试用。kind 默认 - * bitbucket-server(向后兼容旧调用)。proxy 统一进连接层:把代理配置与工厂透传给 adapter,由连接层 - * 据 baseUrl host 一次解析(开关开且目标非 loopback 时 REST 经代理)。 + * Spin up a temporary adapter from a draft base_url + token, only for the settings page ping test. + * kind defaults to bitbucket-server (backward compatible with old calls). proxy is unified into the + * connection layer: pass the proxy config and factory through to the adapter, and the connection layer + * resolves once by the baseUrl host (REST goes through the proxy when the switch is on and the target + * is non-loopback). */ export function buildDraftAdapter( baseUrl: string, @@ -38,7 +41,7 @@ export function buildDraftAdapter( kind: PlatformKind = 'bitbucket-server', ): PlatformAdapter { if (kind === 'github') { - // GitHub 草稿 base_url 可留空 → 默认官方 api.github.com + // GitHub draft base_url can be left empty → defaults to the official api.github.com const ghBase = baseUrl.trim() || GITHUB_DOTCOM_API_BASE; return new GitHubAdapter({ baseUrl: ghBase, @@ -49,7 +52,7 @@ export function buildDraftAdapter( }); } if (kind === 'gitlab') { - // GitLab 草稿 base_url 可留空 → 默认官方 gitlab.com/api/v4 + // GitLab draft base_url can be left empty → defaults to the official gitlab.com/api/v4 const glBase = baseUrl.trim() || GITLAB_DOTCOM_API_BASE; return new GitLabAdapter({ baseUrl: glBase, @@ -69,9 +72,9 @@ export function buildDraftAdapter( } /** - * 把 config.connections 映射成可用的 Adapter 列表。M1 只支持 bitbucket-server kind; - * 未来扩 GitHub / GitLab 时在 switch 里加 case 即可。 - * proxy 透传到每个 adapter 的 REST fetch。 + * Map config.connections into a list of usable Adapters. M1 only supports the bitbucket-server kind; + * to extend to GitHub / GitLab later, just add a case in the switch. + * proxy is passed through to each adapter's REST fetch. */ export function buildAdapters( connections: readonly Connection[], @@ -84,8 +87,9 @@ export function buildAdapters( } function buildOne(conn: Connection, proxy: ProxyConfig): PlatformAdapter { - // 代理统一进连接层:透传 proxy 配置与工厂,由连接层据 baseUrl host 解析(开关开 + 目标非 - // loopback → 带 ProxyAgent 的 fetch;否则直连)。 + // proxy is unified into the connection layer: pass the proxy config and factory through, and the + // connection layer resolves by the baseUrl host (switch on + target non-loopback → fetch with a + // ProxyAgent; otherwise direct). const common = { baseUrl: conn.base_url, token: conn.auth.token, diff --git a/apps/desktop/src/main/bootstrap/connections-runtime.ts b/apps/desktop/src/main/bootstrap/connections-runtime.ts index a1b929ca..29240bdc 100644 --- a/apps/desktop/src/main/bootstrap/connections-runtime.ts +++ b/apps/desktop/src/main/bootstrap/connections-runtime.ts @@ -8,11 +8,13 @@ import { buildAdapters, type ConnectionRuntime } from '../adapters.js'; import { writeConnectionStates, type ConnectionState } from '../utils/connection-state.js'; /** - * 连接运行时控制器:把启动序列里的「连接接线 / ping / 热重配」从 index.ts 收口。「接线」与「ping」解耦, - * 实现「启动不依赖网络」——见各方法注释。运行态(runtime + 连接级本地状态)是实例可变状态,故以 class 封装。 + * Connections runtime controller: consolidates the startup sequence's "connection wiring / ping / + * hot-reconfigure" out of index.ts. "Wiring" and "ping" are decoupled to achieve "startup does not + * depend on the network" — see each method's comment. Runtime state (runtime + connection-level local + * state) is mutable instance state, hence wrapped in a class. */ export class ConnectionRuntimeController { - /** 可变持有的连接运行时(adapters 全量 + adapterByHost);IPC / repoMirror 经引用读到最新值。 */ + /** Mutably held connections runtime (full adapters + adapterByHost); IPC / repoMirror read the latest values through the reference. */ readonly runtime: ConnectionRuntime = { adapters: [], adapterByHost: new Map() }; constructor( @@ -20,23 +22,23 @@ export class ConnectionRuntimeController { private readonly stateStore: JsonFileStateStore, private readonly poller: Poller, private readonly logger: Logger, - /** 启动时载入的连接级本地状态(含上次 ping 的 currentUser);随 ping 增量回写。 */ + /** Connection-level local state loaded at startup (includes the last ping's currentUser); written back incrementally on each ping. */ private connectionStates: Record<string, ConnectionState>, ) {} - /** 当前启用连接的 id 列表(poller.archiveConnectionsExcept 用)。 */ + /** Id list of the currently enabled connections (used by poller.archiveConnectionsExcept). */ activeConnectionIds(): string[] { return this.runtime.adapters .filter((a) => a.connectionId === this.bootstrap.config.active_connection_id) .map((a) => a.connectionId); } - /** 重建 adapters/byHost、用本地持久化身份预热 currentUser、把活动连接喂给 poller(同步、无网络,可在建窗前调)。 */ + /** Rebuild adapters/byHost, prewarm currentUser from locally persisted identity, feed the active connection to the poller (synchronous, no network, callable before the window is created). */ wire(): void { const adapters = buildAdapters(this.bootstrap.config.connections, this.bootstrap.config.proxy); const byHost = new Map<string, PlatformAdapter>(); for (const { connectionId, adapter } of adapters) { - // 预热 currentUser:有本地记录就先填上(无记录则保持 null,由 ping 兜底)。 + // Prewarm currentUser: if there's a local record, fill it in first (if no record, keep null, ping is the fallback). const cachedUser = this.connectionStates[connectionId]?.user; if (cachedUser) adapter.connection.setCurrentUser?.(cachedUser); const conn = this.bootstrap.config.connections.find((c) => c.id === connectionId); @@ -49,51 +51,57 @@ export class ConnectionRuntimeController { } this.runtime.adapters = adapters; this.runtime.adapterByHost = byHost; - // 只轮询当前启用的连接(同时仅一条);其余仅保留配置不轮询。 + // Only poll the currently enabled connection (at most one at a time); the rest keep their config but are not polled. this.poller.setConnections( adapters.filter((a) => a.connectionId === this.bootstrap.config.active_connection_id), ); } - /** 全异步 ping:刷新远端身份并增量持久化;活动连接身份变化(含首次取得)则补一轮 poll(有网络,不在启动关键路径)。 */ + /** + * Fully async ping of the active connection only: refresh its remote identity and persist + * incrementally; if the identity changes (including first acquisition), run one extra poll. Non-active + * connections are not pinged — their identity has no UI consumer (app:connections filters to the active + * one) and is refreshed when they become active (settings save → reconfigure → ping). Has network, not + * on the startup critical path. + */ ping(): void { const activeId = this.bootstrap.config.active_connection_id; - for (const { connectionId, adapter } of this.runtime.adapters) { - const isActive = connectionId === activeId; - const beforeName = adapter.connection.getCurrentUser()?.name ?? null; - // 活动连接启动时无缓存身份 → poller.start(immediate=false) 没跑首轮;此处 ping settle 后必须触发 - // **首次同步**(无论 ping 成功与否):「先确认身份,再立即同步一次」。 - const hadIdentity = beforeName !== null; - void adapter.connection.ping().then( - async (r) => { - this.logger.info( - { connectionId, ok: r.ok, serverVersion: r.serverVersion, user: r.user?.name }, - 'adapter ping', - ); - const user = adapter.connection.getCurrentUser(); - await this.persistConnectionUser(connectionId, user); - // 触发重分类/首次同步:活动连接且(身份变化 含首次取得/换号,或本就无身份需补首轮)。 - if (isActive && (!hadIdentity || (user?.name ?? null) !== beforeName)) { - void this.poller.tick(); - } - }, - (err: unknown) => { - this.logger.warn({ err, connectionId }, 'adapter ping failed'); - // ping 失败但活动连接本就无缓存身份(首轮被跳过)→ 仍用 PAT 兜底同步一次,避免看似没同步。 - if (isActive && !hadIdentity) void this.poller.tick(); - }, - ); - } + const active = this.runtime.adapters.find((a) => a.connectionId === activeId); + if (!active) return; + const { connectionId, adapter } = active; + const beforeName = adapter.connection.getCurrentUser()?.name ?? null; + // If the active connection has no cached identity at startup → poller.start(immediate=false) skipped the first round; here, after ping settles, it must trigger + // the **first sync** (regardless of ping success): "confirm identity first, then sync once immediately". + const hadIdentity = beforeName !== null; + void adapter.connection.ping().then( + async (r) => { + this.logger.info( + { connectionId, ok: r.ok, serverVersion: r.serverVersion, user: r.user?.name }, + 'adapter ping', + ); + const user = adapter.connection.getCurrentUser(); + await this.persistConnectionUser(connectionId, user); + // Trigger reclassification/first sync when the identity changed (including first acquisition/account switch) or there was no identity yet and the first round is needed. + if (!hadIdentity || (user?.name ?? null) !== beforeName) { + void this.poller.tick(); + } + }, + (err: unknown) => { + this.logger.warn({ err, connectionId }, 'adapter ping failed'); + // ping failed but there was no cached identity (first round skipped) → still sync once with the PAT as fallback, to avoid appearing not synced. + if (!hadIdentity) void this.poller.tick(); + }, + ); } - /** 设置页改连接 / 代理后的热生效:重接线 + 归档非活动连接(本地 IO)+ 异步 ping。 */ + /** Hot-apply after the settings page changes connections / proxy: rewire + archive non-active connections (local IO) + async ping. */ async reconfigure(): Promise<void> { this.wire(); await this.poller.archiveConnectionsExcept(this.activeConnectionIds()); this.ping(); } - /** 持久化某连接的 currentUser(仅身份变化时写盘,避免无谓 IO)。写盘失败不影响运行。 */ + /** Persist a connection's currentUser (only writes to disk when identity changes, to avoid pointless IO). A write failure does not affect operation. */ private async persistConnectionUser( connectionId: string, user: PlatformUser | null, diff --git a/apps/desktop/src/main/bootstrap/index.ts b/apps/desktop/src/main/bootstrap/index.ts index 09c0647a..fbbd6d39 100644 --- a/apps/desktop/src/main/bootstrap/index.ts +++ b/apps/desktop/src/main/bootstrap/index.ts @@ -1,7 +1,8 @@ /** - * 应用启动装配域:OS/平台启动微调 + 各运行时(pr-agent / 连接 / 窗口 / 轮询 / 镜像 / 版本检测 / splash) - * 的 init/factory。index.ts 作为组合根从此处取用、装配;各模块只依赖 ../(context / services / utils / - * adapters)与库包。 + * Application startup assembly domain: OS/platform startup tweaks + init/factory for each runtime + * (pr-agent / connections / window / poller / mirror / version detection / splash). index.ts acts as + * the composition root that draws from here and assembles; each module only depends on ../ (context / + * services / utils / adapters) and library packages. */ export { applyOsStartupTweaks } from './os-startup-tweaks.js'; export { PrAgentRuntime } from './pragent-runtime.js'; diff --git a/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts index 6585b664..f92b1007 100644 --- a/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts +++ b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { app } from 'electron'; -// 常见 CLI 安装目录:覆盖 pip --user / npm global / homebrew(Apple Silicon + Intel)。 +// Common CLI install dirs: covers pip --user / npm global / homebrew (Apple Silicon + Intel). const COMMON_CLI_DIRS = [ path.join(os.homedir(), '.local', 'bin'), '/usr/local/bin', @@ -12,15 +12,19 @@ const COMMON_CLI_DIRS = [ ]; /** - * macOS GUI(Finder / Dock / LaunchServices)启动的 app 由 launchd 给出**最小 PATH** - * (`/usr/bin:/bin:/usr/sbin:/sbin`),**不读用户 shell 配置**(`.zshrc` / `.zprofile`)。 - * 而本机 CLI(claude / codex)常装在 `~/.local/bin`、homebrew 等**只由 shell 往 PATH 里加**的 - * 目录——于是嵌入式 python 的 `shutil.which(...)` 找不到命令、本地 CLI provider 失效,但从终端 - * `npm run dev` 启动却正常(继承了已加载配置的终端 PATH)。Windows 不受影响(GUI 进程继承用户 PATH)。 + * An app launched by the macOS GUI (Finder / Dock / LaunchServices) is given a **minimal PATH** by + * launchd (`/usr/bin:/bin:/usr/sbin:/sbin`) and **does not read the user's shell config** (`.zshrc` / + * `.zprofile`). But local CLIs (claude / codex) are often installed in `~/.local/bin`, homebrew, and + * other dirs that **only the shell adds to PATH** — so the embedded python's `shutil.which(...)` can't + * find the command and the local CLI provider fails, yet launching via `npm run dev` from a terminal + * works fine (it inherits the terminal PATH with config already loaded). Windows is unaffected (GUI + * processes inherit the user PATH). * - * 把常见目录前置进 `process.env.PATH`(去重,只补原 PATH 缺失的,保持原有顺序在后);之后所有子进程 - * (嵌入式 python 及其 spawn 的 CLI)都经 `{ ...process.env }` 继承到。静态目录已覆盖最常见的安装位置; - * 不跑登录 shell 解析(避免启动期子进程 / 超时 / 噪声)。仅由 applyMacStartupTweaks 调用。 + * Prepend the common dirs into `process.env.PATH` (deduplicated, only filling in what the original PATH + * lacks, keeping the original order after); afterward all child processes (the embedded python and the + * CLIs it spawns) inherit them via `{ ...process.env }`. The static dirs already cover the most common + * install locations; no login-shell resolution is run (avoiding startup-time child processes / timeouts + * / noise). Only called by applyMacStartupTweaks. */ function augmentMacPath(): void { const existing = (process.env.PATH ?? '').split(':').filter(Boolean); @@ -32,24 +36,27 @@ function augmentMacPath(): void { } /** - * Windows 专属启动微调:附着控制台默认本地化 OEM 页(简中 cp936/GBK),与 pino 的 UTF-8 字节对不上 → - * dev 终端中文日志乱码;chcp 65001 把输出代码页切到 UTF-8 对齐。无控制台(打包态)chcp 静默失败、已吞, - * 无副作用。 + * Windows-specific startup tweak: an attached console defaults to the localized OEM page (Simplified + * Chinese cp936/GBK), which doesn't line up with pino's UTF-8 bytes → garbled Chinese logs in the dev + * terminal; chcp 65001 switches the output code page to UTF-8 to align. Without a console (packaged), + * chcp silently fails and is swallowed, no side effects. */ function applyWindowsStartupTweaks(): void { try { execSync('chcp 65001', { stdio: 'ignore' }); } catch { - /* 无控制台 / chcp 不可用:忽略,日志仍按 UTF-8 字节写出 */ + /* no console / chcp unavailable: ignore, logs are still written as UTF-8 bytes */ } } /** - * macOS 专属启动微调: - * - use-mock-keychain:ad-hoc 签名身份不稳定(cdhash 每次构建变),os_crypt 每次启动弹「访问钥匙串」; - * mock 让其走内存不碰真钥匙串。代价:cookie 加密退化为静态 key,但密钥本就明文落盘,无实质损失。 - * 有正式 Developer ID 签名后可移除。须在 app.whenReady() 之前。 - * - PATH 前置常见 CLI 目录(见 augmentMacPath):须在 pr-agent 探测 / 运行前。 + * macOS-specific startup tweaks: + * - use-mock-keychain: the ad-hoc signing identity is unstable (cdhash changes every build), so + * os_crypt pops up "access keychain" on every launch; mock makes it use memory without touching the + * real keychain. Cost: cookie encryption degrades to a static key, but the key was already stored in + * plaintext, so no real loss. Removable once there's a proper Developer ID signature. Must be before + * app.whenReady(). + * - Prepend common CLI dirs to PATH (see augmentMacPath): must be before pr-agent probing / running. */ function applyMacStartupTweaks(): void { app.commandLine.appendSwitch('use-mock-keychain'); @@ -57,12 +64,14 @@ function applyMacStartupTweaks(): void { } /** - * 进程 / 平台启动微调(须在模块加载期、app.whenReady() 之前跑一次):先做跨平台的进程 env 调整,再按 - * 当前平台委托各自的专属初始化(见 applyWindowsStartupTweaks / applyMacStartupTweaks)。 + * Process / platform startup tweaks (must run once during module load, before app.whenReady()): first + * do cross-platform process env adjustments, then delegate to each platform's specific init based on + * the current platform (see applyWindowsStartupTweaks / applyMacStartupTweaks). * - * 跨平台:PYTHONDONTWRITEBYTECODE=1——嵌入式 python 子进程不落 .pyc(安装目录 per-user 可写,运行期会 - * 积累上万 __pycache__/.pyc 拖慢升级卸载);子进程经 spawn 继承本进程 env。代价:每次启动重编译(略慢), - * 影响有限。 + * Cross-platform: PYTHONDONTWRITEBYTECODE=1 — the embedded python child processes don't drop .pyc (the + * install dir is per-user writable, and at runtime it would accumulate tens of thousands of + * __pycache__/.pyc slowing down upgrades/uninstalls); child processes inherit this process's env via + * spawn. Cost: recompiles on every launch (slightly slower), limited impact. */ export function applyOsStartupTweaks(): void { process.env.PYTHONDONTWRITEBYTECODE = '1'; diff --git a/apps/desktop/src/main/bootstrap/poller.ts b/apps/desktop/src/main/bootstrap/poller.ts index f947430d..5cb1d822 100644 --- a/apps/desktop/src/main/bootstrap/poller.ts +++ b/apps/desktop/src/main/bootstrap/poller.ts @@ -8,23 +8,25 @@ import { broadcast } from '../services/broadcast.js'; import { showPollNotifications } from '../services/notifications.js'; /** - * 构造轮询器:tick 广播 poll:tick + 触发顺带副作用(onTickExtras);PR 变更顺手 syncMirror 跟本地镜像。 - * 启动时不带连接(connections:[]),由 connections-runtime 的 wire/setConnections 注入;run/agent 等 - * 后绑定依赖(ipcControl / repoMirror)经回调与 getter 延迟取用(它们在 poller 之后才建好)。 + * Construct the poller: each tick broadcasts poll:tick + triggers incidental side effects (onTickExtras); + * on PR changes it also syncMirror to keep the local mirror up to date. It starts with no connections + * (connections:[]), injected by connections-runtime's wire/setConnections; later-bound dependencies like + * run/agent (ipcControl / repoMirror) are taken lazily via callbacks and getters (they're built after + * the poller). */ export function createPoller(deps: { bootstrap: BootstrapResult; stateStore: JsonFileStateStore; - /** 归档 PR 冷存储(`archived/` 根,与 state/ 平级);退场 PR 整树搬入此处。 */ + /** Archive PR cold storage (`archived/` root, sibling of state/); departed PRs are moved here as a whole tree. */ archiveStore: JsonFileStateStore; logger: Logger; - /** poll tick 顺带的副作用(清理消失 PR 的 agent 操作 / 版本检测 / AutoPilot 准入),由 index 绑定。 */ + /** Incidental side effects of the poll tick (cleaning up agent operations for disappeared PRs / version detection / AutoPilot admission), bound by index. */ onTickExtras: () => void; - /** 延迟取 repoMirror(它在 poller 之后才建好)。 */ + /** Lazily get repoMirror (it's built after the poller). */ getRepoMirror: () => RepoMirrorManager; - /** 延迟取连接运行时(它在 poller 之后才建好):通知服务据此按 connectionId 取 adapter 拉发起人头像。 */ + /** Lazily get the connections runtime (it's built after the poller): the notification service uses it to get an adapter by connectionId to fetch the author's avatar. */ getConnectionRuntime: () => ConnectionRuntime; - /** 评论变更(回复 / 提及)顺手失效该 PR 评论缓存 + 广播 comments:changed(延迟经 ipcControl)。 */ + /** On comment changes (reply / mention), also invalidate that PR's comment cache + broadcast comments:changed (lazily via ipcControl). */ invalidateCommentsCache: (localId: string) => void; }): Poller { const { bootstrap, stateStore, archiveStore, logger } = deps; @@ -38,8 +40,8 @@ export function createPoller(deps: { broadcast('poll:tick', info); deps.onTickExtras(); }, - // 本轮新发生的提醒事件(新 PR / 被 @ / 被回复)→ 按通知配置弹系统通知(现读 bootstrap.config,与设置页热生效)。 - // 头像经连接运行时按 connectionId 取 adapter 拉取并落盘(Windows 富 toast 用)。 + // Newly occurring alert events this round (new PR / @-mentioned / replied) → pop system notifications per the notification config (now reads bootstrap.config, hot-applies with the settings page). + // Avatars are fetched via the connections runtime by getting an adapter by connectionId and written to disk (for Windows rich toasts). onNotify: (events) => { void showPollNotifications(events, bootstrap.config, logger, { cacheDir: bootstrap.paths.cacheDir, @@ -47,16 +49,16 @@ export function createPoller(deps: { deps.getConnectionRuntime().adapters.find((a) => a.connectionId === id)?.adapter ?? null, logger, }); - // 评论类事件(回复 / 提及)= 该 PR 评论已变 → 失效缓存 + 广播 comments:changed,刷新已打开视图。 - // 与系统通知是否真正弹出无关(通知设置可能关):只要轮询发现评论变更就刷新当前打开的 Diff / 活动时间线。 + // Comment-type events (reply / mention) = that PR's comments changed → invalidate cache + broadcast comments:changed, refreshing the already-open view. + // Independent of whether the system notification actually pops (notification setting may be off): as long as polling finds a comment change, refresh the currently open Diff / activity timeline. const commentPrIds = new Set( events.filter((e) => e.kind !== 'new_pr').map((e) => e.localId), ); for (const localId of commentPrIds) deps.invalidateCommentsCache(localId); }, - // PR 新增 / 内容变更时顺手 syncMirror 跟上本地镜像,让用户随后点开 PR 省一趟 fetch。失败不阻断 poll - //(mirror 有自己的全局队列 + 错误隔离)。identity 字段映射:poller 用 group/repo,repo-mirror 仍保留 - // Bitbucket-shaped projectKey/repoSlug(跟 git 路径布局一致,沿用便于排障)。 + // When a PR is added / its content changes, also syncMirror to keep the local mirror up to date, saving the user a fetch when they later open the PR. Failure doesn't block poll + // (the mirror has its own global queue + error isolation). identity field mapping: poller uses group/repo, repo-mirror still keeps + // Bitbucket-shaped projectKey/repoSlug (matches the git path layout, kept for easier troubleshooting). onPrsChanged: (repos) => { for (const r of repos) { const conn = bootstrap.config.connections.find((c) => c.id === r.connectionId); diff --git a/apps/desktop/src/main/bootstrap/pragent-runtime.ts b/apps/desktop/src/main/bootstrap/pragent-runtime.ts index 7ea566d6..cb83b313 100644 --- a/apps/desktop/src/main/bootstrap/pragent-runtime.ts +++ b/apps/desktop/src/main/bootstrap/pragent-runtime.ts @@ -6,16 +6,19 @@ import { app } from 'electron'; import type { Logger } from 'pino'; /** - * pr-agent 运行时:解析嵌入式解释器路径 + kick-off 探测(构造即开跑、不 await),结果异步回填。探测**不放 - * 在建窗关键路径**——它走 spawn 探测(auto 回退 local-cli 最坏 5s),await 会把首帧推迟数秒;改 kick-off - * 与 whenReady + 渲染层加载并发跑。bridge 由探测异步回填,故以 class 持有可变态。 - * - probe:app:prAgentStatus 据此 await 拿最终状态(boot 时序通常已完成)。 - * - getBridge():pragent run 入口读,未就绪时为 null → 走「未就绪」提示。 + * pr-agent runtime: resolves the embedded interpreter path + kicks off the probe (starts on + * construction, not awaited), result backfilled asynchronously. The probe is **kept off the + * window-creation critical path**—it runs a spawn probe (auto fallback to local-cli, worst case 5s), + * and awaiting it would delay the first paint by seconds; instead the kick-off runs concurrently with + * whenReady + renderer load. The bridge is backfilled asynchronously by the probe, so a class holds + * the mutable state. + * - probe: app:prAgentStatus awaits this for the final status (usually already done by boot time). + * - getBridge(): read at the pragent run entry, null when not ready → falls back to a "not ready" prompt. */ export class PrAgentRuntime { - /** 嵌入式解释器绝对路径(探测层据此判 embedded 是否可用,文件不存在则回退 local-cli)。 */ + /** Absolute path to the embedded interpreter (the probe layer uses it to judge embedded availability; falls back to local-cli if the file is missing). */ readonly embeddedPythonPath: string; - /** 探测 promise(构造逻辑保证恒 resolve、不 reject)。 */ + /** Probe promise (construction logic guarantees it always resolves, never rejects). */ readonly probe: Promise<PrAgentStatus>; private bridge: PrAgentBridge | null = null; @@ -27,16 +30,16 @@ export class PrAgentRuntime { this.probe = this.kickoffProbe(); } - /** 探测完成前为 null。 */ + /** null until the probe completes. */ getBridge(): PrAgentBridge | null { return this.bridge; } /** - * 嵌入式 pr-agent 运行时的解释器绝对路径。 - * - dev:`apps/desktop/vendor/pragent/...`(app.getAppPath() = apps/desktop) - * - 打包:`<resources>/pragent/...`(electron-builder extraResources) - * - `MEEBOX_PRAGENT_PYTHON` env 覆盖兜底 + * Absolute path to the interpreter of the embedded pr-agent runtime. + * - dev: `apps/desktop/vendor/pragent/...` (app.getAppPath() = apps/desktop) + * - packaged: `<resources>/pragent/...` (electron-builder extraResources) + * - `MEEBOX_PRAGENT_PYTHON` env override fallback */ private static resolveEmbeddedPython(): string { const override = process.env.MEEBOX_PRAGENT_PYTHON; diff --git a/apps/desktop/src/main/bootstrap/repo-mirror.ts b/apps/desktop/src/main/bootstrap/repo-mirror.ts index 24559563..1c1c2c4d 100644 --- a/apps/desktop/src/main/bootstrap/repo-mirror.ts +++ b/apps/desktop/src/main/bootstrap/repo-mirror.ts @@ -6,8 +6,10 @@ import { broadcast } from '../services/broadcast.js'; import { buildProxyEnv } from '../utils/proxy.js'; /** - * 构造本地仓库镜像管理器:clone url 经连接运行时按 host 取 adapter 求得(设置页改连接热生效,读 runtime 引用); - * 进度广播 sync:progress;出站代理 getter 每次远端 clone/fetch 求值(改代理即生效)。 + * Constructs the local repo mirror manager: the clone url is resolved via the connection runtime by + * looking up the adapter by host (connection changes in settings take effect live, by reading the + * runtime reference); progress is broadcast on sync:progress; the outbound proxy getter is evaluated + * on every remote clone/fetch (proxy changes take effect immediately). */ export function createRepoMirror(deps: { bootstrap: BootstrapResult; diff --git a/apps/desktop/src/main/bootstrap/splash.ts b/apps/desktop/src/main/bootstrap/splash.ts index ce97c1a4..e94d5689 100644 --- a/apps/desktop/src/main/bootstrap/splash.ts +++ b/apps/desktop/src/main/bootstrap/splash.ts @@ -3,11 +3,12 @@ import path from 'node:path'; import { readFileSync } from 'node:fs'; /** - * 读取品牌 logo 并转成 base64 data URI,内联进 splash data URL(splash 是独立 data URL - * 文档,无法走 file:// 相对路径引用资源,故必须内联)。两路探测: - * - 打包态:`<resources>/icon.png`(electron-builder extraResources copy) - * - dev:仓库 `assets/icons/icon.png` - * 两路都读不到(如 LFS 未拉取)则返回 null,splash 优雅回退为纯 spinner。 + * Reads the brand logo and converts it to a base64 data URI, inlined into the splash data URL (the + * splash is a standalone data URL document and cannot reference resources via file:// relative paths, + * so inlining is required). Two candidate paths are detected: + * - packaged: `<resources>/icon.png` (electron-builder extraResources copy) + * - dev: repo `assets/icons/icon.png` + * If neither can be read (e.g. LFS not pulled), returns null and the splash gracefully falls back to a plain spinner. */ function resolveSplashLogo(): string | null { const candidates = [ @@ -17,36 +18,43 @@ function resolveSplashLogo(): string | null { for (const p of candidates) { try { const buf = readFileSync(p); - // LFS 指针文件不是合法 PNG(无 \x89PNG magic)→ 跳过,避免 splash 显示裂图 + // An LFS pointer file is not a valid PNG (no \x89PNG magic) → skip, to avoid a broken image in the splash if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) continue; return `data:image/png;base64,${buf.toString('base64')}`; } catch { - /* 试下一个候选 */ + /* try the next candidate */ } } return null; } -// 闪屏明暗两套配色,跟随有效主题、对齐默认的 2026 主题(底 / 文字取 2026 editor background / foreground, -// accent 仍用语义 accent —— chrome-sync 不覆盖 accent): -// 暗 = dark-2026 底 #121314 + 文字 #BBBEBF + $vscode-blue-700 accent;浅 = light-2026 底 #FFFFFF + 深文字 + $vscode-blue-800。 +// Splash dark/light color sets, following the effective theme and aligned with the default 2026 theme +// (bg / text taken from 2026 editor background / foreground, accent still uses the semantic accent — +// chrome-sync does not override accent): +// dark = dark-2026 bg #121314 + text #BBBEBF + $vscode-blue-700 accent; light = light-2026 bg #FFFFFF + dark text + $vscode-blue-800. const SPLASH_COLORS = { dark: { bg: '#121314', text: '#BBBEBF', sub: '#6f7172', ring: 'rgba(255,255,255,.16)', accent: '#0e639c' }, light: { bg: '#FFFFFF', text: '#202020', sub: '#6e6e6e', ring: 'rgba(0,0,0,.14)', accent: '#005fb8' }, }; /** - * 启动闪屏:独立的无边框轻量窗口,加载内联 data URL(品牌 logo + 纯 CSS spinner), - * 几十 ms 即可呈现,遮住主窗口首帧前的渲染层加载空窗。主窗口 ready-to-show 时关闭。 - * logo 经 base64 内联(见 resolveSplashLogo),data URL 自包含、dev/打包行为一致。 - * 配色随有效主题(`dark`)切换,避免浅色主题下启动闪屏仍是深色。 + * Startup splash: a standalone frameless lightweight window loading an inline data URL (brand logo + + * pure-CSS spinner), presentable within tens of ms, covering the blank renderer-load gap before the + * main window's first paint. Closed when the main window is ready-to-show. + * The logo is inlined via base64 (see resolveSplashLogo); the data URL is self-contained, with + * identical dev/packaged behavior. Colors switch with the effective theme (`dark`), to avoid a dark + * splash under a light theme. + * Intentionally text-free (logo + brand name + spinner only): the splash renders before i18n loads, + * so it must not depend on any localized copy — the spinner conveys "loading" without words. */ export function createSplash(dark: boolean): BrowserWindow { const c = dark ? SPLASH_COLORS.dark : SPLASH_COLORS.light; const width = 280; const height = 240; - // 与主窗口同源:按光标所在显示器的 workArea 居中(workArea 已扣掉 mac 菜单栏 / 刘海)。不用 - // Electron 的 center:true——它按整屏 bounds(含顶部不可用区)算中点、且固定主显示器,会让 splash 偏高、多屏错位。 + // Same approach as the main window: center within the workArea of the display under the cursor + // (workArea already excludes the mac menu bar / notch). Do not use Electron's center:true—it computes + // the midpoint from the full-screen bounds (including the unusable top area) and pins to the primary + // display, which makes the splash sit too high and misalign across multiple screens. const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea; const x = Math.round(area.x + (area.width - width) / 2); const y = Math.round(area.y + (area.height - height) / 2); @@ -73,13 +81,12 @@ export function createSplash(dark: boolean): BrowserWindow { display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;} .logo{width:72px;height:72px;border-radius:16px;} .name{font-size:17px;font-weight:600;letter-spacing:.3px;} - .row{display:flex;align-items:center;gap:8px;color:${c.sub};font-size:12px;} - .ring{width:14px;height:14px;border-radius:50%;border:2px solid ${c.ring}; + .ring{width:16px;height:16px;border-radius:50%;border:2px solid ${c.ring}; border-top-color:${c.accent};animation:spin .8s linear infinite;} @keyframes spin{to{transform:rotate(360deg);}} </style></head><body> ${logoEl}<div class="name">Code Meeseeks</div> - <div class="row"><div class="ring"></div><span>启动中…</span></div> + <div class="ring"></div> </body></html>`; void splash.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)); splash.once('ready-to-show', () => { diff --git a/apps/desktop/src/main/bootstrap/updater.ts b/apps/desktop/src/main/bootstrap/updater.ts index a0acfcac..60ef50b8 100644 --- a/apps/desktop/src/main/bootstrap/updater.ts +++ b/apps/desktop/src/main/bootstrap/updater.ts @@ -4,13 +4,16 @@ import type { Logger } from 'pino'; import { checkForUpdate } from '../utils/update-check.js'; import { publishUpdateResult } from '../utils/update-state.js'; -// 至多每小时一次(复用 poller 周期,不另起定时器)。 +// At most once per hour (reuses the poller cycle, no separate timer). const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; /** - * 版本更新检测节流器:由 poller tick 顺带调 runIfDue,内部时间戳门控至多每小时一次。lastCheckMs 初值取 - * 构造时刻 → 首次检测落在启动后约 1h,刻意不在启动瞬间检测(避免占冷启动网络 / 打断启动)。仅检测 + 提示: - * 有新版才广播给所有窗口;失败静默(绝不推任何 IPC,对用户零打扰)。节流状态是实例字段,故以 class 封装。 + * Version-update check throttler: the poller tick incidentally calls runIfDue, and an internal + * timestamp gates it to at most once per hour. lastCheckMs is initialized to the construction moment → + * the first check lands about 1h after startup, deliberately not checking at the startup instant (to + * avoid taking cold-start network / interrupting startup). Detect + prompt only: broadcasts to all + * windows only when a new version exists; failures are silent (never pushes any IPC, zero user + * disturbance). The throttle state is an instance field, so it is wrapped in a class. */ export class Updater { private lastCheckMs = Date.now(); @@ -20,19 +23,19 @@ export class Updater { private readonly logger: Logger, ) {} - /** 满足开关 + 距上次满 1h 时发起一次检测。时间戳在 await 前更新,避免窗口内下一次 tick 重复发起。 */ + /** Fires a check when the toggle is on and 1h has elapsed since the last one. The timestamp is updated before the await, to avoid the next tick within the window firing again. */ async runIfDue(): Promise<void> { if (!this.bootstrap.config.update.check_enabled) return; if (Date.now() - this.lastCheckMs < UPDATE_CHECK_INTERVAL_MS) return; this.lastCheckMs = Date.now(); try { const result = await checkForUpdate(app.getVersion(), this.bootstrap.config.proxy); - // 获取失败(网络 / 解析 / 超时 / 限流,ok=false):只记 debug,**绝不推任何 IPC** → 用户无感。 + // Fetch failure (network / parse / timeout / rate-limit, ok=false): only log debug, **never push any IPC** → user unaware. if (!result.ok) { this.logger.debug({ error: result.error }, 'update check failed (silent, no prompt)'); return; } - // 交给单一真相源:缓存结果,仅在确有新版时广播(与设置页手动检查共用同一路径)。 + // Hand off to the single source of truth: cache the result, broadcast only when a new version truly exists (shares the same path as the settings-page manual check). publishUpdateResult(result); if (result.hasUpdate) { this.logger.info( @@ -41,7 +44,7 @@ export class Updater { ); } } catch (err) { - // 兜底:checkForUpdate 约定不抛;万一抛了也吞掉,绝不冒泡成任何用户可见行为。 + // Fallback: checkForUpdate is contracted not to throw; swallow it even if it does, never bubbling into any user-visible behavior. this.logger.debug({ err }, 'update check threw (silent, no prompt)'); } } diff --git a/apps/desktop/src/main/bootstrap/window-manager.ts b/apps/desktop/src/main/bootstrap/window-manager.ts index 1d6d2210..3cbb56da 100644 --- a/apps/desktop/src/main/bootstrap/window-manager.ts +++ b/apps/desktop/src/main/bootstrap/window-manager.ts @@ -12,49 +12,57 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// 默认窗口尺寸(无本地记录时)。最小尺寸保证核心三栏(sidebar 240 + file-tree 180 + diff 内容)在 -// chat-pane 折叠态下仍可用;高度兜住 pr-header + tabs + diff + statusbar。单位均为 DIP(设备无关像素)。 +// Default window size (when there is no local record). The minimum size guarantees the core three +// columns (sidebar 240 + file-tree 180 + diff content) remain usable with the chat-pane collapsed; +// the height accommodates pr-header + tabs + diff + statusbar. Units are all DIP (device-independent pixels). const DEFAULT_SIZE = { width: 1280, height: 800 }; const MIN_SIZE = { width: 960, height: 600 }; -// 自绘标题栏右侧的系统窗控按钮(Windows titleBarOverlay)配色:与 .app-titlebar 背景(--bg-app)同色, -// 接缝无感;symbol 取主文字色。明暗两套,跟随有效主题(nativeTheme.shouldUseDarkColors)。值对齐 palette -// 暗/浅 bg-app($vscode-gray-850 / $vscode-gray-30)与主文字($vscode-gray-200 / $vscode-gray-840)。 +// Colors of the system window-control buttons on the right of the self-drawn title bar (Windows +// titleBarOverlay): same color as the .app-titlebar background (--bg-app), so the seam is invisible; +// the symbol takes the primary text color. Dark/light sets, following the effective theme +// (nativeTheme.shouldUseDarkColors). Values align with palette dark/light bg-app +// ($vscode-gray-850 / $vscode-gray-30) and primary text ($vscode-gray-200 / $vscode-gray-840). const TITLE_BAR_OVERLAY = { dark: { color: '#1e1e1e', symbolColor: '#cccccc' }, light: { color: '#f8f8f8', symbolColor: '#1f1f20' }, }; -// 渲染层派生的窗控配色(跟随具体主题的 --bg-app/--text-primary,hex);null 时按 nativeTheme 深浅取通用色兜底。 +// Renderer-derived window-control colors (following the specific theme's --bg-app/--text-primary, hex); when null, falls back to generic colors by nativeTheme dark/light. let overlayColors: { color: string; symbolColor: string } | null = null; -/** 通用深/浅窗控配色(无渲染层派生色时的兜底,按 nativeTheme 有效深浅)。 */ +/** Generic dark/light window-control colors (fallback when there is no renderer-derived color, by nativeTheme effective dark/light). */ function genericOverlayColors(): { color: string; symbolColor: string } { return nativeTheme.shouldUseDarkColors ? TITLE_BAR_OVERLAY.dark : TITLE_BAR_OVERLAY.light; } -/** 当前窗控 overlay(渲染层派生色优先,否则通用色)+ 高度(与 .app-titlebar 一致 36px)。 */ +/** Current window-control overlay (renderer-derived color first, otherwise generic color) + height (36px, matching .app-titlebar). */ function currentOverlay(): { color: string; symbolColor: string; height: number } { return { ...(overlayColors ?? genericOverlayColors()), height: 36 }; } /** - * 由渲染层在主题应用后经 IPC 调用:把当前主题派生的窗控配色(color=--bg-app、symbolColor=--text-primary) - * 设给所有窗口;传 null 回退通用深/浅色。使窗控按钮与具体主题的标题栏底色精确同色,而非仅通用深/浅。 + * Called by the renderer via IPC after the theme is applied: sets the current theme-derived + * window-control colors (color=--bg-app, symbolColor=--text-primary) on all windows; passing null + * falls back to generic dark/light colors. Makes the window-control buttons exactly match the specific + * theme's title-bar background, rather than only generic dark/light. */ export function setWindowControlColors(colors: { color: string; symbolColor: string } | null): void { overlayColors = colors; - if (process.platform === 'darwin') return; // macOS 无 titleBarOverlay + if (process.platform === 'darwin') return; // macOS has no titleBarOverlay for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue; try { win.setTitleBarOverlay(currentOverlay()); } catch { - /* 平台不支持 setTitleBarOverlay → 忽略 */ + /* platform does not support setTitleBarOverlay → ignore */ } } } /** - * 解析建窗尺寸/位置:把期望尺寸(本地记录优先,回退默认)clamp 进**当前显示器工作区**,并据此压低最小 - * 尺寸——否则高 DPI 缩放后工作区(DIP)可能小于默认/最小值,窗口被撑大溢出屏幕(issue:缩放后默认尺寸超屏)。 - * 取光标所在显示器(多屏更贴合「在哪开窗」),按其工作区居中,保证整窗在屏内(不持久化 x/y,仅建窗定位)。 + * Resolves the window size/position: clamps the desired size (local record first, falling back to + * default) into the **current display's work area**, and lowers the minimum size accordingly—otherwise + * under high-DPI scaling the work area (DIP) may be smaller than the default/minimum, and the window + * gets stretched off-screen (issue: default size exceeds screen after scaling). Takes the display under + * the cursor (on multi-screen this better matches "where to open"), centers within its work area, and + * guarantees the whole window stays on-screen (does not persist x/y, only positions at creation). */ function resolveWindowBounds(state: WindowState): { x: number; @@ -75,25 +83,27 @@ function resolveWindowBounds(state: WindowState): { } /** - * 主窗口管理:建窗(恢复尺寸 + 自绘标题栏 + 外链路由 + 首帧显示/关 splash)+ resize/move/close 防抖回写。 - * windowState 是实例可变状态(跨多次 create——如 macOS activate 重建——保持最新),故以 class 封装; - * 异步载入由 loadWindowManager 完成(构造同步、不持异步)。 + * Main window manager: window creation (restore size + self-drawn title bar + external-link routing + + * first-paint show/close splash) + debounced write-back on resize/move/close. + * windowState is instance-mutable state (kept up to date across multiple create calls—e.g. macOS + * activate re-creation), so it is wrapped in a class; async loading is done by loadWindowManager + * (construction is synchronous, holds nothing async). */ export class WindowManager { constructor( private readonly stateStore: JsonFileStateStore, - /** state 目录绝对路径;关窗同步落盘兜底用(见 writeWindowStateSync)。 */ + /** Absolute path of the state directory; used for the synchronous write-to-disk fallback on window close (see writeWindowStateSync). */ private readonly stateDir: string, private readonly logger: Logger, - /** 进程启动时刻,用于度量到首帧(ready-to-show)的启动耗时。 */ + /** Process start moment, used to measure the startup elapsed time to the first paint (ready-to-show). */ private readonly startMs: number, - /** 当前窗口状态(尺寸/最大化);随 resize/maximize/close 回写。 */ + /** Current window state (size/maximized); written back on resize/maximize/close. */ private windowState: WindowState, ) {} - /** 创建主窗口(首个传 splash,主界面首帧就绪时关闭它;macOS activate 再建时不传)。 */ + /** Creates the main window (the first call passes splash, closed when the main UI's first paint is ready; macOS activate re-creation does not pass it). */ create(splash?: BrowserWindow): void { - // 尺寸/位置:本地记录优先、回退默认,并按当前显示器工作区 clamp + 居中(高 DPI 缩放下防溢出屏幕)。 + // Size/position: local record first, falling back to default, and clamped + centered within the current display's work area (prevents overflowing the screen under high-DPI scaling). const bounds = resolveWindowBounds(this.windowState); const win = new BrowserWindow({ x: bounds.x, @@ -103,15 +113,16 @@ export class WindowManager { minWidth: bounds.minWidth, minHeight: bounds.minHeight, show: false, - // 首帧前的窗口底色与 app 一致,避免显示瞬间白闪 + // Window background before the first paint matches the app, to avoid a white flash at the display instant backgroundColor: '#1e1e1e', - // 无边框 + 自绘标题栏(VS Code 风):macOS 保留红绿灯并下移到自绘标题栏内;Windows/Linux 用 - // titleBarOverlay 让系统继续画窗控按钮,渲染层只接管中间标题区。高度需与 .app-titlebar 一致(36px)。 + // Frameless + self-drawn title bar (VS Code style): macOS keeps the traffic lights and shifts them + // down into the self-drawn title bar; Windows/Linux use titleBarOverlay to let the system keep + // drawing the window-control buttons, with the renderer taking over only the middle title area. Height must match .app-titlebar (36px). titleBarStyle: 'hidden', ...(process.platform === 'darwin' ? { trafficLightPosition: { x: 12, y: 11 } } : { titleBarOverlay: currentOverlay() }), - // dev 下显式给窗口图标;打包态窗口/任务栏图标走 exe 内嵌(electron-builder),故仅 dev 设置。 + // In dev, explicitly set the window icon; in packaged mode the window/taskbar icon comes from the exe embed (electron-builder), so only set it in dev. icon: app.isPackaged ? undefined : path.join(app.getAppPath(), '../../assets/icons/icon.ico'), @@ -123,13 +134,13 @@ export class WindowManager { }, }); - // 快照当前窗口状态。getNormalBounds 取「非最大化」尺寸,故最大化时记录的仍是还原后的正常大小; - // 最大化态另存布尔位,下次启动据此恢复。 + // Snapshot the current window state. getNormalBounds takes the "non-maximized" size, so what is + // recorded while maximized is still the restored normal size; the maximized state is stored separately as a boolean, restored on next startup. const snapshot = (): WindowState => { const b = win.getNormalBounds(); return { width: b.width, height: b.height, maximized: win.isMaximized() }; }; - // 记住窗口大小 / 最大化:尺寸 resize 防抖回写,最大化切换即时回写(离散、低频)。写盘失败不影响使用。 + // Remember window size / maximized: size resize is written back debounced, maximize toggles are written back immediately (discrete, low frequency). Write failures do not affect usage. let saveTimer: ReturnType<typeof setTimeout> | undefined; const persist = (): void => { if (win.isDestroyed()) return; @@ -142,11 +153,11 @@ export class WindowManager { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(persist, 400); }; - // 不监听 move:仅存尺寸(不存 x/y),位置变化无需回写。 + // Do not listen to move: only size is stored (not x/y), so position changes need no write-back. win.on('resize', scheduleSave); win.on('maximize', persist); win.on('unmaximize', persist); - // 关窗同步落盘:close 后进程即退出,异步写来不及 flush(最大化 / resize 后秒关会丢状态)→ 同步写兜底。 + // Synchronous write-to-disk on close: after close the process exits immediately, and an async write cannot flush in time (closing within a second of maximize / resize would lose state) → synchronous write fallback. win.on('close', () => { if (win.isDestroyed()) return; if (saveTimer) clearTimeout(saveTimer); @@ -158,8 +169,8 @@ export class WindowManager { } }); - // 主界面首帧就绪:恢复最大化态 → 显示主窗口 → 关闭 splash,并记录进程启动→首帧耗时。 - // maximize 必须放到这里:建窗后即调用会让无边框窗口在内容就绪前以空白态抢先出现(盖过/早于 splash)。 + // Main UI first paint ready: restore maximized state → show main window → close splash, and record the process-start→first-paint elapsed time. + // maximize must go here: calling it right after window creation would make the frameless window appear prematurely in a blank state before content is ready (covering/preceding the splash). win.once('ready-to-show', () => { if (this.windowState.maximized) win.maximize(); win.show(); @@ -170,23 +181,24 @@ export class WindowManager { ); }); - // 'auto' 主题下 OS 深浅变化时 nativeTheme 发 'updated':按当前 overlay(渲染层派生色优先,否则通用色) - // 重置窗控配色兜底(macOS 无 titleBarOverlay,不注册)。具体主题的精确配色由渲染层经 setWindowControlColors - // 主动推送(见 useGlobalTheme),此处仅在渲染层未推送时按 nativeTheme 深浅回退。 + // Under the 'auto' theme, nativeTheme emits 'updated' on OS dark/light changes: reset the + // window-control colors as a fallback by the current overlay (renderer-derived color first, otherwise + // generic color) (macOS has no titleBarOverlay, not registered). The specific theme's exact colors are + // actively pushed by the renderer via setWindowControlColors (see useGlobalTheme); here it only falls back by nativeTheme dark/light when the renderer has not pushed. if (process.platform !== 'darwin') { const onThemeUpdated = (): void => { if (win.isDestroyed()) return; try { win.setTitleBarOverlay(currentOverlay()); } catch { - /* 平台不支持 setTitleBarOverlay → 忽略 */ + /* platform does not support setTitleBarOverlay → ignore */ } }; nativeTheme.on('updated', onThemeUpdated); win.on('closed', () => nativeTheme.off('updated', onThemeUpdated)); } - // 把 <a target="_blank"> / window.open 都路由到 OS 默认浏览器,不在 Electron 内开新窗口。 + // Route both <a target="_blank"> / window.open to the OS default browser, not opening new windows inside Electron. win.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); return { action: 'deny' }; @@ -200,10 +212,10 @@ export class WindowManager { } } -/** 载入窗口状态(缺失/损坏 → 空对象,回退默认尺寸)并构造 WindowManager。 */ +/** Loads the window state (missing/corrupt → empty object, falling back to default size) and constructs the WindowManager. */ export async function loadWindowManager(deps: { stateStore: JsonFileStateStore; - /** state 目录绝对路径(= JsonFileStateStore 的根目录);关窗同步落盘用。 */ + /** Absolute path of the state directory (= JsonFileStateStore's root); used for the synchronous write-to-disk on window close. */ stateDir: string; logger: Logger; startMs: number; diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts index 0cf24d49..802a2605 100644 --- a/apps/desktop/src/main/controllers/agent.ts +++ b/apps/desktop/src/main/controllers/agent.ts @@ -17,11 +17,11 @@ import { getContext } from '../services/context.js'; import type { IpcController } from './types.js'; /* - * Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 + * Agent interaction-domain controllers: rule matching / review orchestration / free planning / session and ledger reads / pr-agent run queue */ /** - * 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。 + * Look up the rules a PR currently matches (the ask tool takes no rules; returns null on no match). */ export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (_event, req) => { if (req.tool === 'ask') return []; @@ -46,7 +46,7 @@ export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (_event, }; /** - * 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。 + * Review micro-flow (describe→review→conditional follow-up→summary), finishing by writing the "review summary". */ export const runReview: IpcController<'agent:run'> = async (_event, req) => { const ctx = getContext(); @@ -54,7 +54,7 @@ export const runReview: IpcController<'agent:run'> = async (_event, req) => { }; /** - * 自由规划 Agent(自然语言「对话即委派」)。 + * Free-planning Agent (natural-language "conversation as delegation"). */ export const runPlanning: IpcController<'agent:ask'> = async (_event, req) => { const ctx = getContext(); @@ -66,7 +66,7 @@ export const runPlanning: IpcController<'agent:ask'> = async (_event, req) => { }; /** - * 运行期间追加一条用户消息:有 Agent 在跑则入队(下一周期并入重排),否则起一轮自由规划兜底。 + * Append a user message during a run: if an Agent is running, enqueue it (merged into the reorder next cycle); otherwise start a free-planning round as fallback. */ export const enqueueMessage: IpcController<'agent:enqueueMessage'> = async (_event, req) => { const ctx = getContext(); @@ -74,13 +74,13 @@ export const enqueueMessage: IpcController<'agent:enqueueMessage'> = async (_eve }; /** - * 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。 + * Pause a PR's Agent run (immediate abort at any thinking / execution stage). */ export const stopAgent: IpcController<'agent:stop'> = (_event, req) => getContext().orchestrator.stop(req.localId); /** - * 读指定 PR 已落盘的 Agent 会话(跨 PR 切换、重启后恢复)。 + * Read a given PR's persisted Agent session (restored across PR switches and restarts). */ export const getSession: IpcController<'agent:getSession'> = async (_event, req) => { const ctx = getContext(); @@ -88,7 +88,7 @@ export const getSession: IpcController<'agent:getSession'> = async (_event, req) }; /** - * 读指定 PR 的多轮对话消息。 + * Read a given PR's multi-turn conversation messages. */ export const getConversation: IpcController<'agent:getConversation'> = async (_event, req) => { const ctx = getContext(); @@ -96,7 +96,7 @@ export const getConversation: IpcController<'agent:getConversation'> = async (_e }; /** - * 读指定 PR 的 Agent 过程步骤(transcript)。 + * Read a given PR's Agent process steps (transcript). */ export const getTranscript: IpcController<'agent:getTranscript'> = async (_event, req) => { const ctx = getContext(); @@ -104,13 +104,13 @@ export const getTranscript: IpcController<'agent:getTranscript'> = async (_event }; /** - * 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。 + * Batch-read AutoPilot ledgers: return only recommendation where decision=review and a recommender exists (used for PR list badges). */ export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (_event, req) => { const ctx = getContext(); const out: Record<string, AgentRecommendationVerdict> = {}; for (const id of req.localIds) { - // 已关闭 PR 列表的台账徽标须从归档存储读(其台账随 PR 树搬入冷存储)。 + // Ledger badges for the closed-PR list must be read from archive storage (their ledgers move into cold storage along with the PR tree). const ledger = await getAutopilotLedger(await ctx.pr.storeForPr(id), id); if (ledger?.decision === 'review' && ledger.recommendation) { out[id] = ledger.recommendation; @@ -120,11 +120,11 @@ export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = asyn }; /* - * pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列) + * pr-agent run queue (review-tool execution layer; agent:run / AutoPilot and user manual runs share the same queue) */ /** - * 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 + * Trigger one run (queue-scheduled). /ask must carry a question; validate early to avoid erroring only after queuing. */ export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { const ctx = getContext(); @@ -147,18 +147,18 @@ export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { }; /** - * 取消一个 run(active SIGKILL / waiting 出队)。 + * Cancel a run (active SIGKILL / waiting dequeue). */ export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) => getContext().runQueue.cancel(req.runId); /** - * 当前队列快照(启动 / 重连兜底)。 + * Current queue snapshot (startup / reconnect fallback). */ export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot(); /** - * 列某 PR 历史 run(游标分页)。 + * List a PR's run history (cursor pagination). */ export const listRuns: IpcController<'pragent:listRuns'> = async (_event, req) => { const ctx = getContext(); @@ -169,7 +169,7 @@ export const listRuns: IpcController<'pragent:listRuns'> = async (_event, req) = }; /** - * 单条 run 查询。 + * Query a single run. */ export const getRun: IpcController<'pragent:getRun'> = async (_event, req) => { const ctx = getContext(); @@ -177,7 +177,7 @@ export const getRun: IpcController<'pragent:getRun'> = async (_event, req) => { }; /** - * 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 + * Clear all of a PR's run history, and clear the Agent session + AutoPilot ledger together (broadcast so the ★ badge disappears immediately). */ export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => { const ctx = getContext(); @@ -189,7 +189,7 @@ export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) }; /** - * 删除单条 run 记录(仅该 run,不动 Agent 会话 / 台账 / ★ 徽标)。renderer 乐观从列表移除。 + * Delete a single run record (only that run; leaves the Agent session / ledger / ★ badge untouched). The renderer optimistically removes it from the list. */ export const deleteRun: IpcController<'pragent:deleteRun'> = async (_event, req) => { const ctx = getContext(); diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts index 7ef49dc9..bd1f18e7 100644 --- a/apps/desktop/src/main/controllers/app.ts +++ b/apps/desktop/src/main/controllers/app.ts @@ -13,35 +13,35 @@ import { getLastUpdateResult, publishUpdateResult } from '../utils/update-state. import type { IpcController } from './types.js'; /* - * GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 + * GUI shell interaction-domain controllers: app info / shell window / external open / dialogs / log relay / connections and avatars */ /** - * 应用 / 运行时版本信息(关于页)。 + * App / runtime version info (About page). */ export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getContext().bootstrap); /** - * 关键目录路径(config / agent / 日志)。 + * Key directory paths (config / agent / logs). */ export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths; /** - * 渲染层在主题应用后推送窗控按钮配色(跟随具体主题的 --bg-app/--text-primary);null 回退通用深/浅。 + * The renderer pushes window-control button colors after applying a theme (follows the specific theme's --bg-app/--text-primary); null falls back to generic dark/light. */ export const setWindowControlColors: IpcController<'window:setControlColors'> = (_event, req) => { applyWindowControlColors(req); }; /** - * pr-agent 探测状态(是否就绪)。 + * pr-agent probe status (whether it is ready). */ export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = () => getContext().getPrAgentStatus(); let rendererLogger: Logger | undefined; /** - * 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。 + * Renderer errors / uncaught exceptions are forwarded to main and written to the renderer-scope log by level (into the same meebox.log). */ export const writeRendererLog: IpcController<'log:write'> = (_event, req) => { rendererLogger ??= getContext().logger.child({ scope: 'renderer' }); @@ -63,19 +63,19 @@ export const writeRendererLog: IpcController<'log:write'> = (_event, req) => { }; /** - * 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。 + * Per-connection post-ping cache (current user + display_name), used by the Header / status bar. */ export const listConnections: IpcController<'app:connections'> = () => { const { bootstrap, connectionRuntime } = getContext(); return buildConnectionSummaries(bootstrap, connectionRuntime.adapters); }; -// 头像两级缓存:进程内 Map(含 null 负缓存)+ 磁盘文件(TTL 7 天,按 mtime 判过期)。 +// Two-level avatar cache: in-process Map (including null negative cache) + disk file (TTL 7 days, expiry judged by mtime). const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; const avatarMem = new Map<string, { dataUrl: string } | null>(); /** - * 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。 + * Fetch the avatar dataUrl by (connectionId, slug): memory → disk → remote, returning null on failure. */ export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req) => { const { logger, connectionRuntime, bootstrap } = getContext(); @@ -86,7 +86,7 @@ export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); const filePath = path.join(avatarDir, `${hash}.bin`); - // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) + // 1) Disk cache hit and not expired? Do not log on hit (high-frequency path, avoid log noise) try { const stat = await fs.stat(filePath); const age = Date.now() - stat.mtimeMs; @@ -97,13 +97,13 @@ export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req avatarMem.set(memKey, result); return result; } - // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) + // Expired: delete and re-fetch. A failed delete is fine (writeFile will overwrite) await fs.unlink(filePath).catch(() => undefined); } catch { - // 文件不存在 / 读失败 → 走 fetch + // File missing / read failed → go to fetch } - // 2) 没缓存 / 已过期:去远端拉 + // 2) No cache / expired: fetch from remote const adapter = connectionRuntime.adapters.find( (a) => a.connectionId === req.connectionId, )?.adapter; @@ -121,7 +121,7 @@ export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req avatarMem.set(memKey, null); return null; } - // 落盘:best-effort,写失败不影响响应 + // Persist to disk: best-effort, a write failure does not affect the response try { await fs.mkdir(avatarDir, { recursive: true }); await fs.writeFile(filePath, img.bytes); @@ -144,7 +144,7 @@ export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req }; /** - * OS 默认编辑器打开 config.yaml。 + * Open config.yaml in the OS default editor. */ export const openConfigFile: IpcController<'app:openConfigFile'> = async () => { const err = await shell.openPath(getContext().bootstrap.paths.configFile); @@ -152,8 +152,8 @@ export const openConfigFile: IpcController<'app:openConfigFile'> = async () => { }; /** - * 文件管理器打开当前生效的 Agent 目录。先「用时补齐」上下文模版(ensureAgentDir 幂等、含建目录), - * 这样直接打开(未跑过任何评审时)也能看到 SOUL/AGENTS 等文件,而非空目录。 + * Open the currently effective Agent directory in the file manager. First lazily fill in the context templates (ensureAgentDir is idempotent and creates the directory), + * so opening it directly (when no review has ever run) still shows SOUL/AGENTS and other files rather than an empty directory. */ export const openAgentDir: IpcController<'app:openAgentDir'> = async () => { const dir = await getContext().ensureAgentDir(); @@ -162,21 +162,21 @@ export const openAgentDir: IpcController<'app:openAgentDir'> = async () => { }; /** - * 打开 DevTools(分离窗口)——需访问发起调用的 webContents。 + * Open DevTools (detached window) — needs access to the calling webContents. */ export const openDevTools: IpcController<'app:openDevTools'> = (event) => { event.sender.openDevTools({ mode: 'detach' }); }; /** - * 设置应用角标计数(本期仅 macOS dock)。renderer 已据 PR 列表 + 通知配置派生「待回应」计数,主进程仅落地。 + * Set the app badge count (currently macOS dock only). The renderer already derives the "awaiting response" count from the PR list + notification config; the main process only applies it. */ export const setBadgeCount: IpcController<'app:setBadgeCount'> = (_event, req) => { applyBadgeCount(req.count); }; /** - * 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。 + * Manual update check: gated by check_enabled; the result is handed to the single-source-of-truth cache + broadcast when a new version exists. */ export const checkUpdate: IpcController<'app:checkUpdate'> = async () => { const { bootstrap } = getContext(); @@ -194,12 +194,12 @@ export const checkUpdate: IpcController<'app:checkUpdate'> = async () => { }; /** - * 读 main 缓存的最近一次更新检测结果(不发请求)。 + * Read the main-cached most recent update-check result (issues no request). */ export const getUpdateStatus: IpcController<'app:getUpdateStatus'> = () => getLastUpdateResult(); /** - * 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。 + * Open an external link in the system browser (allowlist permits only http(s), guarding against file:// / javascript: injection). */ export const openExternal: IpcController<'app:openExternal'> = async (_event, req) => { if (!/^https?:\/\//.test(req.url)) return; @@ -207,9 +207,9 @@ export const openExternal: IpcController<'app:openExternal'> = async (_event, re }; /** - * 打开 macOS「系统设置 → 通知」面板,引导用户授予 / 开启通知权限(macOS 在系统层管控通知授权、应用无法 - * 代为开启)。非 macOS 为 no-op。通知面板的 pane id 随系统版本不同(Ventura+ 改名),逐个回退;全失败则 - * 退到系统设置根。 + * Open the macOS "System Settings → Notifications" panel to guide the user to grant / enable notification permission (macOS manages notification authorization at the system level; the app cannot + * enable it on the user's behalf). No-op on non-macOS. The notifications panel's pane id varies by system version (renamed in Ventura+), so fall back one by one; if all fail, + * fall back to the System Settings root. */ export const openNotificationSettings: IpcController< 'app:openNotificationSettings' @@ -217,25 +217,25 @@ export const openNotificationSettings: IpcController< if (process.platform !== 'darwin') return; const panes = [ 'x-apple.systempreferences:com.apple.Notifications-Settings.extension', // Ventura(13)+ - 'x-apple.systempreferences:com.apple.preference.notifications', // 旧版 - 'x-apple.systempreferences:', // 兜底:系统设置根 + 'x-apple.systempreferences:com.apple.preference.notifications', // older versions + 'x-apple.systempreferences:', // fallback: System Settings root ]; for (const url of panes) { try { await shell.openExternal(url); return; } catch { - // 该 pane id 在当前系统版本不识别 → 尝试下一个 + // This pane id is not recognized on the current system version → try the next one } } }; /** - * 系统原生目录选择对话框——需绑定到发起调用的窗口。 + * Native OS directory-picker dialog — must be bound to the calling window. */ export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (event, req) => { const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; - // 标题由前端按 UI 语言提供(目录选择属交互领域文案,统一在渲染层 i18n 维护,主进程不再 localize)。 + // The title is provided by the frontend per UI language (directory picking is interaction-domain text, maintained uniformly by renderer i18n; the main process no longer localizes it). const result = win ? await dialog.showOpenDialog(win, { title: req.title, diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index 5d555803..54f9da13 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -9,16 +9,16 @@ import { testProxyConnectivity } from '../utils/proxy.js'; import type { IpcController } from './types.js'; /* - * 配置操作域 controllers:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连 + * Config operation-domain controllers: read / write config.yaml (including hot-reload and draft staging) and connection / proxy test connections */ /** - * 读当前内存配置。 + * Read the current in-memory config. */ export const readConfig: IpcController<'config:read'> = () => getContext().bootstrap.config; /** - * 写 repos_dir(重启生效)。 + * Write repos_dir (takes effect on restart). */ export const setReposDir: IpcController<'config:setReposDir'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -31,7 +31,7 @@ export const setReposDir: IpcController<'config:setReposDir'> = async (_event, r }; /** - * 写 UI 语言并即时生效:内存同步 + 主进程 i18n changeLanguage。 + * Write the UI language and apply it immediately: in-memory sync + main-process i18n changeLanguage. */ export const setLanguage: IpcController<'config:setLanguage'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -43,9 +43,9 @@ export const setLanguage: IpcController<'config:setLanguage'> = async (_event, r }; /** - * 写外观(全局主题 = Monaco 主题 + 等宽字体 + 字号);内存同步。主题切换由 renderer 即时完成;主进程据 - * 主题设 nativeTheme.themeSource,让原生窗口 chrome(Windows 细边框 / 窗控按钮深浅)跟随——'auto' 主题 - * 交回 OS('system'),其余固定浅 / 深。窗控按钮配色由 WindowManager 监听 nativeTheme 'updated' 重置。 + * Write appearance (global theme = Monaco theme + monospace font + font size); in-memory sync. Theme switching is done instantly by the renderer; the main process sets + * nativeTheme.themeSource from the theme so native window chrome (Windows thin border / window-control button light-dark) follows — the 'auto' theme + * is handed back to the OS ('system'), the rest fixed light / dark. Window-control button colors are reset by WindowManager listening to nativeTheme 'updated'. */ export const setEditorAppearance: IpcController<'config:setEditorAppearance'> = async ( _event, @@ -72,7 +72,7 @@ export const setEditorAppearance: IpcController<'config:setEditorAppearance'> = }; /** - * 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。 + * Write LLM Provider config; in-memory sync, the next pragent:run uses the new values. */ export const setLlm: IpcController<'config:setLlm'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -86,9 +86,9 @@ export const setLlm: IpcController<'config:setLlm'> = async (_event, req) => { }; /** - * 写 agent 配置(含 agent.dir);内存同步即热生效——effectiveAgentDir 现读 in-memory 配置,下次 - * 加载上下文即用新目录(无资源绑定,无需重建)。新目录的模版初始化不在此做,交由「用时补齐」 - * (ensureAgentDir,见 context.ts)保证,避免把初始化绑死在设置交互这一条路径上。 + * Write agent config (including agent.dir); in-memory sync means hot-reload — effectiveAgentDir now reads the in-memory config, and the next + * context load uses the new directory (no resource binding, no rebuild needed). Template initialization for the new directory is not done here; it is guaranteed by lazy fill-in + * (ensureAgentDir, see context.ts), to avoid binding initialization to this single settings-interaction path. */ export const setAgent: IpcController<'config:setAgent'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -99,7 +99,7 @@ export const setAgent: IpcController<'config:setAgent'> = async (_event, req) => }; /** - * 写消息通知配置(总开关 + 分类型系统通知 + dock 角标);内存同步,下轮 poll 弹通知 / renderer 推角标即用新值。 + * Write message-notification config (master switch + per-type system notifications + dock badge); in-memory sync, the next poll popping notifications / renderer pushing the badge uses the new values. */ export const setNotifications: IpcController<'config:setNotifications'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -110,7 +110,7 @@ export const setNotifications: IpcController<'config:setNotifications'> = async }; /** - * 翻转 AutoPilot 开关;关→开时立即 poll 一轮按准入规则评估。 + * Toggle the AutoPilot switch; on off→on, immediately run one poll to evaluate against the admission rules. */ export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = async ( _event, @@ -131,7 +131,7 @@ export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = a }; /** - * 写连接列表 + 启用连接,热重建 adapter/poller 并立即 poll 一轮。 + * Write the connection list + active connection, hot-rebuild the adapter/poller and immediately run one poll. */ export const setConnections: IpcController<'config:setConnections'> = async (_event, req) => { const { bootstrap, logger, poller, reconfigureConnections } = getContext(); @@ -152,7 +152,7 @@ export const setConnections: IpcController<'config:setConnections'> = async (_ev }; /** - * 写代理配置,热重建 adapter(REST 经代理即时生效)。 + * Write proxy config, hot-rebuild the adapter (REST via proxy takes effect immediately). */ export const setProxy: IpcController<'config:setProxy'> = async (_event, req) => { const { bootstrap, logger, reconfigureConnections } = getContext(); @@ -167,13 +167,13 @@ export const setProxy: IpcController<'config:setProxy'> = async (_event, req) => }; /** - * 用给定代理试连,验证可用性;不写配置。 + * Test-connect with the given proxy to verify usability; does not write config. */ export const testProxy: IpcController<'config:testProxy'> = (_event, req) => testProxyConnectivity(req.proxy); /** - * 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason。 + * Spin up a temporary adapter ping with draft url/token, without persisting config; failures normalize to ok:false + reason. */ export const testConnection: IpcController<'config:testConnection'> = async (_event, req) => { try { @@ -189,8 +189,8 @@ export const testConnection: IpcController<'config:testConnection'> = async (_ev }; /** - * 写本地 API 服务监听配置(开关 / host / port / token);内存同步后热重建监听器(停旧起新)。 - * token 由请求体携带(设置页保存当前值);单独「重新生成 token」走 generateServiceToken。 + * Write local API service listener config (switch / host / port / token); after in-memory sync, hot-rebuild the listener (stop old, start new). + * The token is carried in the request body (the settings page saves the current value); the standalone "regenerate token" goes through generateServiceToken. */ export const setService: IpcController<'config:setService'> = async (_event, req) => { const { bootstrap, logger, reconfigureApiServer } = getContext(); @@ -205,16 +205,16 @@ export const setService: IpcController<'config:setService'> = async (_event, req }; /** - * 生成一枚高强度随机 bearer token(32 字节 → base64url,43 字符,字符集 [A-Za-z0-9-_],URL / 请求头安全) - * 并返回,**不落盘**——由前端置入设置草稿,随底栏「保存」经 config:setService 生效;不保存则丢弃 - * (与 host / port 同为草稿制)。 + * Generate and return a high-strength random bearer token (32 bytes → base64url, 43 chars, charset [A-Za-z0-9-_], URL / header safe), + * **without persisting** — the frontend places it into the settings draft and it takes effect via config:setService on the footer "Save"; discarded if not saved + * (draft-based like host / port). */ export const generateServiceToken: IpcController<'config:generateServiceToken'> = () => { return { token: randomBytes(32).toString('base64url') }; }; /** - * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 + * During configuration, write connection + LLM drafts to disk to avoid loss, but do not update the in-memory config and do not reconfigure (does not take effect). */ export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (_event, req) => { const { bootstrap, logger } = getContext(); @@ -232,7 +232,7 @@ export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (_even }; /** - * 写轮询间隔(clamp 60~900)并热替换 poller 定时器,无需重启。 + * Write the polling interval (clamp 60~900) and hot-swap the poller timer, no restart needed. */ export const setPoller: IpcController<'config:setPoller'> = async (_event, req) => { const { bootstrap, logger, poller } = getContext(); @@ -248,7 +248,7 @@ export const setPoller: IpcController<'config:setPoller'> = async (_event, req) }; /** - * 写评审任务并发数(clamp 1~8)并热替换 run 队列上限,无需重启。 + * Write the review-task concurrency (clamp 1~8) and hot-swap the run queue limit, no restart needed. */ export const setMaxConcurrency: IpcController<'config:setMaxConcurrency'> = async (_event, req) => { const { bootstrap, logger, runQueue } = getContext(); diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index d2435410..773c3e1b 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -34,11 +34,11 @@ import { getContext } from '../services/context.js'; import type { IpcController } from './types.js'; /* - * PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 + * PR operation-domain controllers: comments / list / status / merge / mirror / diff / drafts */ /** - * 对已有评论发回复,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + * Reply to an existing comment; on success clear the comments cache + broadcast comments:changed so the UI re-fetches. */ export const replyComment: IpcController<'comments:reply'> = async (_event, req) => { const ctx = getContext(); @@ -55,7 +55,7 @@ export const replyComment: IpcController<'comments:reply'> = async (_event, req) }; /** - * 在 PR 上新建一条 summary(顶层、不锚文件)评论,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + * Create a new summary (top-level, not file-anchored) comment on the PR; on success clear the comments cache + broadcast comments:changed so the UI re-fetches. */ export const createComment: IpcController<'comments:create'> = async (_event, req) => { const ctx = getContext(); @@ -71,7 +71,7 @@ export const createComment: IpcController<'comments:create'> = async (_event, re }; /** - * 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 + * Delete a remote comment authored by yourself (with a version optimistic lock). Failures are rethrown verbatim to the renderer; on success clear the cache + broadcast. */ export const deleteComment: IpcController<'comments:delete'> = async (_event, req) => { const ctx = getContext(); @@ -87,7 +87,7 @@ export const deleteComment: IpcController<'comments:delete'> = async (_event, re }; /** - * 编辑自己作者评论 body(带 version 乐观锁)。返回 updated 仅作乐观参考;清缓存 + 广播。 + * Edit the body of a comment authored by yourself (with a version optimistic lock). The returned updated is only an optimistic reference; clear the cache + broadcast. */ export const editComment: IpcController<'comments:edit'> = async (_event, req) => { const ctx = getContext(); @@ -105,7 +105,7 @@ export const editComment: IpcController<'comments:edit'> = async (_event, req) = }; /** - * 切换当前用户对一条评论的 emoji 反应(add 加 / 取下)。成功后清评论缓存 + 广播 comments:changed。 + * Toggle the current user's emoji reaction on a comment (add / remove). On success clear the comments cache + broadcast comments:changed. */ export const toggleReaction: IpcController<'comments:toggleReaction'> = async (_event, req) => { const ctx = getContext(); @@ -123,9 +123,9 @@ export const toggleReaction: IpcController<'comments:toggleReaction'> = async (_ }; /** - * 上传图片作为评论附件,返回可插入正文的 markdown;不支持的平台(GitHub)返回 null。 - * bytes 由 renderer 经 IPC 传 ArrayBuffer,这里转 Uint8Array 交 adapter 上传。不清缓存(仅产出 markdown, - * 评论尚未发布)。 + * Upload an image as a comment attachment, returning markdown insertable into the body; unsupported platforms (GitHub) return null. + * bytes come from the renderer via IPC as an ArrayBuffer, converted to Uint8Array here and handed to the adapter to upload. Does not clear the cache (only produces markdown, + * the comment is not yet published). */ export const uploadAttachment: IpcController<'comments:uploadAttachment'> = async (_event, req) => { const ctx = getContext(); @@ -139,7 +139,7 @@ export const uploadAttachment: IpcController<'comments:uploadAttachment'> = asyn }; /** - * 拉评论内嵌图片(私有实例需带 PAT,renderer 无法直接 fetch)→ 经 main 代理回 dataUrl。不缓存。 + * Fetch a comment's embedded image (private instances need a PAT, the renderer cannot fetch it directly) → proxied through main back to a dataUrl. Not cached. */ export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async (_event, req) => { try { @@ -147,7 +147,7 @@ export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterFor(pr); if (!adapter) return null; - // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL + // Pass pr.repo to the adapter — Bitbucket's attachment: protocol needs repo context to build the URL const res = await adapter.media.getAttachment(req.url, pr.repo); if (!res) return null; const base64 = Buffer.from(res.bytes).toString('base64'); @@ -158,7 +158,7 @@ export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async }; /** - * 只展示当前活动连接的 PR(状态库可能仍存切换前其他连接的历史 PR)。 + * Show only the current active connection's PRs (the state store may still hold historical PRs from other connections before the switch). */ export const listPrs: IpcController<'prs:list'> = async () => { const ctx = getContext(); @@ -168,7 +168,7 @@ export const listPrs: IpcController<'prs:list'> = async () => { }; /** - * 列已归档(退场)PR:「已关闭」视图用,只读浏览。同样仅展示当前活动连接的条目。 + * List archived (exited) PRs: used by the "closed" view, read-only browsing. Also shows only the current active connection's entries. */ export const listArchivedPrs: IpcController<'prs:listArchived'> = async () => { const ctx = getContext(); @@ -178,11 +178,11 @@ export const listArchivedPrs: IpcController<'prs:listArchived'> = async () => { }; /** - * 按 URL 打开当前平台的 PR(审查未正式被请求参与的他人 PR): - * ① 解析链接为 {group,repo,remoteId},对不上当前平台形态 → PR_URL_INVALID; - * ② 用确定性 localId 查索引:已存在则直接返回其所在范围(活跃 / 归档)让前端定位; - * ③ 否则远端拉取单个 PR(鉴权:403 → PR_FORBIDDEN、404 → PR_NOT_FOUND),存入归档冷存储 + - * 写索引条目(archivedAt=now,随归档生命周期 grace 到期清理),仓库镜像沿用打开详情时的懒拉取。 + * Open a PR of the current platform by URL (reviewing someone else's PR you were not formally asked to participate in): + * ① Parse the link into {group,repo,remoteId}; if it doesn't match the current platform's shape → PR_URL_INVALID; + * ② Look up the index by deterministic localId: if it already exists, return its scope (active / archived) so the frontend can locate it; + * ③ Otherwise fetch the single PR from remote (auth: 403 → PR_FORBIDDEN, 404 → PR_NOT_FOUND), store it in the archive cold storage + + * write an index entry (archivedAt=now, cleaned up on grace expiry per the archive lifecycle); the repo mirror reuses the lazy pull done when opening details. */ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) => { const ctx = getContext(); @@ -207,7 +207,7 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) = }; const localId = prHashId(identity); - // 已知则直接定位(不重复拉取):活跃 → 'active'(带其发现分类,供前端落到能展示它的 tab)、归档 → 'archived'。 + // If known, locate directly (no re-fetch): active → 'active' (with its discovery filters, so the frontend lands on a tab that can show it), archived → 'archived'. const existing = (await readPrIndex(ctx.stateStore))?.prs[localId]; if (existing) { if (existing.archivedAt) return { localId, location: 'archived', discoveryFilters: [] } as const; @@ -215,7 +215,7 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) = return { localId, location: 'active', discoveryFilters: meta?.pr.discoveryFilters ?? [] } as const; } - // 远端拉取(鉴权)。403/404 归一成错误码;其它错误(网络 / 5xx)原样冒泡由前端兜底展示。 + // Remote fetch (auth). 403/404 normalize to error codes; other errors (network / 5xx) bubble up verbatim for the frontend to fall back on. let pr; try { pr = await adapter.prs.getSinglePullRequest( @@ -229,7 +229,7 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) = throw err; } - // 存入归档冷存储(与「已关闭」同管理 / 同生命周期)。索引条目 archivedAt=now → grace 到期自动清理。 + // Store in the archive cold storage (managed / lifecycled the same as "closed"). Index entry archivedAt=now → auto-cleaned on grace expiry. const now = new Date().toISOString(); const stored: StoredPullRequest = { ...pr, @@ -242,7 +242,7 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) = lastSeenAt: now, }; await writePrMeta(ctx.archiveStore, localId, stored); - // 紧邻写前重读索引做 read-modify-write,尽量缩小与 poll 重写索引的竞态窗口。 + // Re-read the index right before writing for a read-modify-write, to minimize the race window with poll rewriting the index. const fresh = (await readPrIndex(ctx.stateStore)) ?? { schema_version: 1, prs: {} }; const next: PrIndexFile = { schema_version: 1, @@ -262,19 +262,19 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) = }; /** - * 立即跑一轮 poll。 + * Immediately run one poll. */ export const refreshPrs: IpcController<'prs:refresh'> = () => getContext().poller.tick(); /** - * Poller 最近一次完成时间(启动初始化用)。 + * The Poller's most recent completion time (used for startup initialization). */ export const getLastSync: IpcController<'prs:lastSync'> = () => ({ at: getContext().poller.getLastPollAt(), }); /** - * 设审阅状态:先写远端(失败前端不变),远端 OK 后落本地。 + * Set review status: write remote first (on failure the frontend is unchanged), and persist locally after the remote is OK. */ export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, req) => { const ctx = getContext(); @@ -295,13 +295,13 @@ export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, r }; /** - * 标记 PR 已读:推进已读水位 + 清未读标记(纯本地状态,无远端调用)。 + * Mark a PR read: advance the read watermark + clear the unread flag (pure local state, no remote call). */ export const markRead: IpcController<'prs:markRead'> = (_event, req) => markPrRead(getContext().stateStore, req.localId); /** - * 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 + * Merge a PR; do not persist locally here, relying on renderer refresh → poll soft-delete to finish, to avoid local and remote disagreeing. */ export const mergePr: IpcController<'prs:merge'> = async (_event, req) => { const ctx = getContext(); @@ -314,7 +314,7 @@ export const mergePr: IpcController<'prs:merge'> = async (_event, req) => { }; /** - * 确保 PR 所属 repo 镜像就位(快速路径命中即 noop)。 + * Ensure the PR's repo mirror is in place (fast-path hit is a noop). */ export const syncRepo: IpcController<'repo:sync'> = async (_event, req) => { const ctx = getContext(); @@ -323,8 +323,8 @@ export const syncRepo: IpcController<'repo:sync'> = async (_event, req) => { }; /** - * 列出变更文件(先确保镜像)。默认 PR merge-base..head 全部变更;传 base/head 则列该范围 - * (如某 commit 的 parent..sha),用于「查看特定 commit」。 + * List changed files (ensure the mirror first). Defaults to all changes in PR merge-base..head; if base/head are passed, list that range + * (e.g. a commit's parent..sha), used for "view a specific commit". */ export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_event, req) => { const ctx = getContext(); @@ -337,8 +337,8 @@ export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_ }; /** - * 列出合并会冲突的文件(文件树据此标三角警示)。仅当远端判定 PR 有冲突(pr.hasConflict)才实际跑 - * 本地 merge-tree 试合并——目标分支 tip ⟂ 源 head;无冲突的 PR 直接返回空,省一次试合并。 + * List files that would conflict on merge (the file tree marks a triangle warning from this). Only when the remote judges the PR has a conflict (pr.hasConflict) does it actually run + * a local merge-tree trial merge — target branch tip ⟂ source head; a conflict-free PR returns empty directly, saving a trial merge. */ export const listConflictFiles: IpcController<'diff:listConflictFiles'> = async (_event, req) => { const ctx = getContext(); @@ -350,8 +350,8 @@ export const listConflictFiles: IpcController<'diff:listConflictFiles'> = async }; /** - * 读 base / head 一侧文件内容。默认 PR merge-base / head;传 base/head 则按指定范围 - * (commit 视图:base=parent、head=commit)。 + * Read the file content on the base / head side. Defaults to PR merge-base / head; if base/head are passed, use the specified range + * (commit view: base=parent, head=commit). */ export const getFileContent: IpcController<'diff:getFileContent'> = async (_event, req) => { const ctx = getContext(); @@ -365,7 +365,7 @@ export const getFileContent: IpcController<'diff:getFileContent'> = async (_even }; /** - * 仅读评论缓存条数(tab 角标懒展示),不打远端。 + * Read only the comment cache count (lazy display for the tab badge), without hitting remote. */ export const getCommentCountCached: IpcController<'diff:commentCountCached'> = async ( _event, @@ -377,16 +377,16 @@ export const getCommentCountCached: IpcController<'diff:commentCountCached'> = a return { count: cache.comments.length }; }; -// In-flight dedup: 打开 PR 时多个组件并行调 listComments(force:true),合并到同一 Promise,远端只打一次。 +// In-flight dedup: when opening a PR, multiple components call listComments(force:true) in parallel; merge into the same Promise so remote is hit only once. const listCommentsInFlight = new Map<string, Promise<PrComment[]>>(); /** - * 拉评论:cache + pr_updated_at stale 比对;force=true 跳缓存。同 localId in-flight 去重。 + * Fetch comments: cache + pr_updated_at staleness comparison; force=true skips the cache. Dedups in-flight per localId. */ export const listComments: IpcController<'diff:listComments'> = async (_event, req) => { const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); - // per-PR 缓存按归档状态路由(已关闭 PR 的缓存落归档存储,不写活跃存储以免被对账误删)。 + // Route the per-PR cache by archive status (a closed PR's cache goes to archive storage, not the active store, to avoid being mistakenly deleted during reconciliation). const store = await ctx.pr.storeForPr(pr.localId); const cache = await readCommentsCache(store, pr.localId); if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { @@ -395,10 +395,10 @@ export const listComments: IpcController<'diff:listComments'> = async (_event, r const existing = listCommentsInFlight.get(pr.localId); if (existing) return existing; const adapter = ctx.pr.adapterForOrThrow(pr); - // dedup 要求把 in-flight Promise **同步**存进 map 后再 await:故显式构造 Promise(内部用 async - // IIFE 顺序 await)并 set,再 return。不能整体写成顶层 async 函数体内直接 await——首个 await 挂起前 - // Promise 还没注册进 map,落在这窗口内的并发请求就会各自再打一次远端。.finally 绑在 Promise 上做 - // 清理(与具体 await 方无关,成功 / 失败都摘除 map 项)。 + // Dedup requires storing the in-flight Promise into the map **synchronously** before awaiting: so explicitly construct the Promise (using an async + // IIFE that awaits in sequence) and set it, then return. It cannot be written as a direct await in a top-level async function body — before the first await suspends, + // the Promise is not yet registered in the map, so concurrent requests falling in that window would each hit remote again. .finally is bound on the Promise for + // cleanup (independent of the specific awaiter; removes the map entry on both success and failure). const fetchPromise = (async () => { const raw = await adapter.comments.listPullRequestComments( { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, @@ -419,13 +419,13 @@ export const listComments: IpcController<'diff:listComments'> = async (_event, r }; /** - * 拉 commits(不缓存,量少 + 进 commits 标签页 / 活动时间线才拉)。 + * Fetch commits (not cached, small volume + fetched only when entering the commits tab / activity timeline). * - * 平台 `/commits` 端点返回 `target..source` 全集——长期分支 / fork 同步分支历史上反复把别的分支 - * merge 进源分支,会把大量 merge 提交与合入的他人提交一并带出,淹没本 PR 真正引入的提交。这里用本地 - * 镜像按 first-parent 主干算出「本 PR 自产提交」SHA 集合做交集过滤(与提交数角标同口径,见 - * {@link RepoMirrorManager.listIntroducedCommitShas})。镜像未就位 / 算不出 → 退回未过滤的平台列表, - * 至少不丢信息。 + * The platform `/commits` endpoint returns the full `target..source` set — long-lived branches / fork-sync branches repeatedly + * merge other branches into the source branch over their history, dragging in many merge commits and other people's merged-in commits, drowning out the commits this PR truly introduced. Here the local + * mirror computes the "PR's own commits" SHA set along the first-parent trunk for intersection filtering (same criterion as the commit-count badge, see + * {@link RepoMirrorManager.listIntroducedCommitShas}). If the mirror is not in place / cannot be computed → fall back to the unfiltered platform list, + * at least losing no information. */ export const listCommits: IpcController<'diff:listCommits'> = async (_event, req) => { const ctx = getContext(); @@ -453,8 +453,8 @@ export const listCommits: IpcController<'diff:listCommits'> = async (_event, req }; /** - * 拉评审决断活动事件(approve / needs-work / unapprove / dismiss)。不缓存,量小; - * 进活动时间线时与评论 / 提交归并。平台取不到历史决断时 adapter 返回 []。 + * Fetch review-decision activity events (approve / needs-work / unapprove / dismiss). Not cached, small volume; + * merged with comments / commits when entering the activity timeline. When the platform cannot retrieve historical decisions, the adapter returns []. */ export const listActivity: IpcController<'diff:listActivity'> = async (_event, req) => { const ctx = getContext(); @@ -467,8 +467,8 @@ export const listActivity: IpcController<'diff:listActivity'> = async (_event, r }; /** - * 本地 git 算 PR 引入提交数(base=merge-base,first-parent 主干口径,与 listCommits 过滤集一致, - * 排除合入的目标提交与历史 merge 带进的他人提交);镜像未齐返回 null。 + * Local git computes the PR's introduced commit count (base=merge-base, first-parent trunk criterion, consistent with the listCommits filter set, + * excluding merged-in target commits and other people's commits dragged in by historical merges); returns null if the mirror is not complete. */ export const getCommitCount: IpcController<'diff:commitCount'> = async (_event, req) => { const ctx = getContext(); @@ -480,7 +480,7 @@ export const getCommitCount: IpcController<'diff:commitCount'> = async (_event, }; /** - * head 侧 blame;PR 引入行单独返回供 BlameColumn 画色带占位。 + * head-side blame; PR-introduced lines are returned separately for BlameColumn to draw color-band placeholders. */ export const getBlame: IpcController<'diff:getBlame'> = async (_event, req) => { const ctx = getContext(); @@ -499,7 +499,7 @@ export const getBlame: IpcController<'diff:getBlame'> = async (_event, req) => { }; /** - * 本地所有 repo 镜像总占用字节数(按 host|projectKey|repoSlug 去重)。 + * Total bytes used by all local repo mirrors (deduped by host|projectKey|repoSlug). */ export const getTotalSize: IpcController<'repo:getTotalSize'> = async () => { const ctx = getContext(); @@ -523,7 +523,7 @@ export const getTotalSize: IpcController<'repo:getTotalSize'> = async () => { }; /** - * 列某 PR 全部草稿。 + * List all of a PR's drafts. */ export const getDrafts: IpcController<'drafts:list'> = async (_event, req) => { const ctx = getContext(); @@ -531,16 +531,16 @@ export const getDrafts: IpcController<'drafts:list'> = async (_event, req) => { }; /** - * 创建草稿;IPC 边界再挡一道 origin/source 约束避免脏数据进盘。 + * Create a draft; the IPC boundary enforces the origin/source constraint again to keep dirty data off disk. */ export const addDraft: IpcController<'drafts:create'> = async (_event, req) => { const ctx = getContext(); const { draft, localId } = req; if (draft.origin === 'finding' && !draft.source) { - throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); + throw new Error('drafts:create: origin=finding requires source { runId, findingId }'); } if (draft.origin === 'manual' && draft.source) { - throw new Error('drafts:create: origin=manual 不应该传 source'); + throw new Error('drafts:create: origin=manual must not pass source'); } const created = await createDraft(await ctx.pr.storeForPr(localId), localId, draft); ctx.broadcast('drafts:changed', { localId }); @@ -548,7 +548,7 @@ export const addDraft: IpcController<'drafts:create'> = async (_event, req) => { }; /** - * 部分更新草稿(pending 编辑 body 自动转 edited;找不到返回 null)。 + * Partially update a draft (editing a pending body auto-transitions it to edited; returns null if not found). */ export const patchDraft: IpcController<'drafts:update'> = async (_event, req) => { const ctx = getContext(); @@ -559,7 +559,7 @@ export const patchDraft: IpcController<'drafts:update'> = async (_event, req) => }; /** - * 删除草稿。 + * Delete a draft. */ export const removeDraft: IpcController<'drafts:delete'> = async (_event, req) => { const ctx = getContext(); @@ -567,13 +567,13 @@ export const removeDraft: IpcController<'drafts:delete'> = async (_event, req) = ctx.broadcast('drafts:changed', { localId: req.localId }); }; -/** finding 关闭关系:列出本 PR 全部(复评 /ask 取代/撤销原 finding 的关闭记录)。 */ +/** finding closure relations: list all for this PR (closure records where a re-review /ask supersedes/revokes the original finding). */ export const getFindingClosures: IpcController<'findingClosures:list'> = async (_event, req) => { const ctx = getContext(); return listFindingClosures(await ctx.pr.storeForPr(req.localId), req.localId); }; -/** 记一条关闭关系(复评卡片的「采纳并关闭原 / 关闭原」动作);广播让 finding 卡片重拉换关闭态。 */ +/** Record a closure relation (the re-review card's "adopt and close original / close original" action); broadcast so finding cards re-fetch and switch to the closed state. */ export const addClosure: IpcController<'findingClosures:create'> = async (_event, req) => { const ctx = getContext(); const created = await addFindingClosure(await ctx.pr.storeForPr(req.localId), req.localId, { @@ -586,7 +586,7 @@ export const addClosure: IpcController<'findingClosures:create'> = async (_event return created; }; -/** 撤销关闭(finding 卡片的「撤销关闭」动作)。 */ +/** Revoke a closure (the finding card's "undo close" action). */ export const removeClosure: IpcController<'findingClosures:delete'> = async (_event, req) => { const ctx = getContext(); await removeFindingClosure( @@ -599,8 +599,8 @@ export const removeClosure: IpcController<'findingClosures:delete'> = async (_ev }; /** - * 批量发布草稿:逐条 publishInlineComment,单条失败不中断;成功即删本地草稿。 - * 整批跑完广播 drafts:changed;有任一成功则 force-refresh 评论 + 广播 comments:changed。 + * Batch-publish drafts: publishInlineComment one by one, a single failure does not interrupt; on success delete the local draft. + * After the whole batch runs, broadcast drafts:changed; if any succeeded, force-refresh comments + broadcast comments:changed. */ export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_event, req) => { const ctx = getContext(); @@ -608,7 +608,7 @@ export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_e const adapter = ctx.pr.adapterForOrThrow(pr); const store = await ctx.pr.storeForPr(req.localId); - // 拉一次当前草稿池:localId → id → draft,避免循环里反复 listDrafts 的 O(N²) IO + // Fetch the current draft pool once: localId → id → draft, avoiding O(N²) IO from repeated listDrafts in the loop const allDrafts = await listDrafts(store, req.localId); const draftById = new Map(allDrafts.map((d) => [d.id, d])); @@ -620,15 +620,15 @@ export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_e results.push({ draftId, ok: false, error: errorCodeMessage(ERROR_CODES.PR_DRAFT_NOT_FOUND) }); continue; } - // rejected 不发(用户决断不发)。posted 不守卫:发布成功即删本地草稿,不存历史 posted 态。 + // rejected is not sent (the user decided not to send). posted is not guarded: on successful publish the local draft is deleted, no historical posted state is kept. if (draft.status === 'rejected') { results.push({ draftId, ok: false, error: errorCodeMessage(ERROR_CODES.PR_DRAFT_REJECTED) }); continue; } try { - // ReviewDraftAnchor → PrCommentAnchor:side 保守映射 new→added / old→removed; - // 多行落 endLine(评论出现在标注范围下方,不打断从上往下阅读)。命中 context 行 - // Bitbucket 回 400,错误收进 results 给用户看。 + // ReviewDraftAnchor → PrCommentAnchor: side maps conservatively new→added / old→removed; + // multi-line lands on endLine (the comment appears below the annotated range, not interrupting top-down reading). Hitting a context line + // makes Bitbucket return 400; the error is collected into results for the user to see. const posted = await adapter.comments.publishInlineComment( { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, pr.remoteId, @@ -640,7 +640,7 @@ export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_e }, draft.body, ); - // 发布成功 = 本地草稿使命完成,直接删掉(远端评论由下面 force-refresh 拉回承接显示)。 + // Successful publish = the local draft's mission is done, delete it directly (the remote comment is pulled back and takes over display via the force-refresh below). await deleteDraft(store, req.localId, draftId); anyPublished = true; results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); diff --git a/apps/desktop/src/main/controllers/types.ts b/apps/desktop/src/main/controllers/types.ts index b8d2303e..8481d19f 100644 --- a/apps/desktop/src/main/controllers/types.ts +++ b/apps/desktop/src/main/controllers/types.ts @@ -2,14 +2,14 @@ import type { IpcMainInvokeEvent } from 'electron'; import type { IpcChannelName, IpcChannels } from '@meebox/ipc'; /** - * IPC controller 的类型:原生 `ipcMain.handle` 监听器形态 `(event, req)`,直接 - * `ipcMain.handle('channel', controller)` 注册、无包装层。通道字符串与 controller 的匹配由 - * ipcMain.handle 的宽松签名兜不住,靠注册处命名 + 注释保证。 + * Type of an IPC controller: the native `ipcMain.handle` listener shape `(event, req)`, registered directly via + * `ipcMain.handle('channel', controller)` with no wrapper layer. The match between the channel string and the controller is not + * caught by ipcMain.handle's loose signature; it is guaranteed by naming + comments at the registration site. * - * @template K 通道名(约束 `extends IpcChannelName` 必需:req/response 由 `IpcChannels[K]` 索引取出)。 - * @param event electron IpcMainInvokeEvent;仅少数需窗口上下文的 controller 用(对话框 / DevTools),其余以 `_event` 占位。 - * @param req 该通道的强类型请求体。 - * @returns 该通道的 response(同步或异步)。 + * @template K Channel name (the `extends IpcChannelName` constraint is required: req/response are indexed out of `IpcChannels[K]`). + * @param event electron IpcMainInvokeEvent; used only by the few controllers needing window context (dialogs / DevTools), the rest use `_event` as a placeholder. + * @param req The channel's strongly-typed request body. + * @returns The channel's response (sync or async). */ export type IpcController<K extends IpcChannelName> = ( event: IpcMainInvokeEvent, diff --git a/apps/desktop/src/main/i18n/index.ts b/apps/desktop/src/main/i18n/index.ts index 72a55326..e019e49d 100644 --- a/apps/desktop/src/main/i18n/index.ts +++ b/apps/desktop/src/main/i18n/index.ts @@ -6,27 +6,30 @@ import jaJP from './locales/ja-JP.json'; import deDE from './locales/de-DE.json'; /** - * 主进程国际化(独立的 i18next 实例,纯 Node,无 React)。 + * Main process i18n (standalone i18next instance, pure Node, no React). * - * - 与渲染层各持一份资源:main 的面向用户文本(dialog 标题、抛给渲染层并最终在 - * toast/界面展示的错误消息)走这里。 - * - 语言在启动时由 `bootstrap.config.language` 一次性定下(`initMainI18n`)。 - * main 进程的文案不随设置实时切换——改语言后重启生效,符合主进程文案的性质。 - * - key 命名沿用「区域命名空间」:dialog / prAgent / drafts / proxy / update。 + * - Holds its own copy of resources separate from the renderer: main's user-facing text + * (dialog titles, error messages thrown to the renderer and ultimately shown in toast/UI) + * goes through here. + * - Language is fixed once at startup by `bootstrap.config.language` (`initMainI18n`). + * Main process text does not switch live with settings—changing the language takes effect + * after restart, fitting the nature of main process text. + * - key naming follows the "area namespace" convention: dialog / prAgent / drafts / proxy / update. */ const instance: I18n = createInstance(); -// 当前生效语言:由 initMainI18n 定档(传入的已是 resolveLanguage 解析后的有效值)。 -// 供 pr-agent 响应语言(CONFIG__RESPONSE_LANGUAGE)等与 UI 保持一致地复用。 +// Currently effective language: fixed by initMainI18n (the passed value is already the effective +// result resolved by resolveLanguage). Reused to keep pr-agent response language +// (CONFIG__RESPONSE_LANGUAGE) etc. consistent with the UI. let currentLanguage: SupportedLanguage = 'en-US'; -/** 主进程当前生效语言,供 pr-agent 响应语言等复用,保证与 UI 一致。 */ +/** Main process currently effective language, reused for pr-agent response language etc., kept consistent with the UI. */ export function getMainLanguage(): SupportedLanguage { return currentLanguage; } -/** 启动时调用一次:按已解析的有效语言(resolveLanguage 的结果)初始化主进程 i18n。 */ +/** Called once at startup: initialize main process i18n with the resolved effective language (result of resolveLanguage). */ export function initMainI18n(language: string): void { currentLanguage = matchSupportedLanguage(language) ?? 'en-US'; void instance.init({ @@ -37,7 +40,7 @@ export function initMainI18n(language: string): void { 'de-DE': { translation: deDE }, }, lng: currentLanguage, - // 兜底取 en-US(国际化标准,与渲染层一致):缺 key 回退英文而非中文。 + // Fallback to en-US (i18n standard, consistent with the renderer): missing key falls back to English rather than Chinese. fallbackLng: 'en-US', load: 'currentOnly', interpolation: { escapeValue: false }, @@ -46,16 +49,17 @@ export function initMainI18n(language: string): void { } /** - * 运行时切换主进程语言(设置页 / 首启向导即时改语言时调用)。所有 locale 已静态打包, - * changeLanguage 同步生效;同步更新 currentLanguage,使 getMainLanguage()(pr-agent 响应 - * 语言)随之。已弹出的 dialog 不回溯,新产生的文案与下次 run 用新语言。 + * Switch the main process language at runtime (called when changing language live from settings / + * first-run wizard). All locales are statically bundled, so changeLanguage takes effect synchronously; + * updates currentLanguage in sync so getMainLanguage() (pr-agent response language) follows. + * Already-shown dialogs are not retroactively updated; newly produced text and the next run use the new language. */ export function setMainLanguage(language: string): void { currentLanguage = matchSupportedLanguage(language) ?? 'en-US'; void instance.changeLanguage(currentLanguage); } -/** 主进程翻译函数。未 init 时退化为返回 key(不抛错,保证健壮)。 */ +/** Main process translation function. Degrades to returning the key before init (no throw, keeps it robust). */ export const t: TFunction = ((key: string, options?: Record<string, unknown>) => instance.isInitialized ? instance.t(key, options) : key) as TFunction; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4d140825..bf33fc6f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -24,11 +24,12 @@ import { registerIpcHandlers } from './ipc.js'; import { ApiServer } from './services/api-server/index.js'; import { readConnectionStates } from './utils/connection-state.js'; -// 进程(模块加载)起点:用于度量到主窗口首帧(ready-to-show)的启动耗时。 +// Process (module load) start point: used to measure startup time to the main window's first frame (ready-to-show). const PROCESS_START_MS = Date.now(); -// registerIpcHandlers 返回的运行时控制句柄:退出时据此中止所有进行中的 pr-agent run(触发其 -// 子进程树清理,见 before-quit);poll tick 顺带做 AutoPilot 准入与清理已消失 PR 的在跑操作。 +// Runtime control handle returned by registerIpcHandlers: on exit, used to abort all in-progress pr-agent runs +// (triggering their child process tree cleanup, see before-quit); the poll tick also does AutoPilot admission and +// cleanup of in-flight operations on PRs that have disappeared. type IpcControl = { abortAllActiveRuns: () => number; runAutopilotIfDue: () => void; @@ -37,15 +38,16 @@ type IpcControl = { }; /** - * 应用组合根:持有各子系统实例(fields),分域初始化(workspace/日志 → 各 runtime → 连接/poller/IPC → - * 窗口 → 启动轮询)并挂接 app 生命周期。唯一入口 main():进程微调 → 单例锁 → new App().run()。 - * 各 runtime 的构造/工厂均在 bootstrap/ 域内,本类只负责装配与生命周期编排。 + * Application composition root: holds each subsystem instance (fields), initializes by domain (workspace/logging → + * each runtime → connections/poller/IPC → window → start polling) and hooks into the app lifecycle. Single entry + * main(): process tweaks → single-instance lock → new App().run(). Each runtime's construction/factory lives within + * the bootstrap/ domain; this class only handles assembly and lifecycle orchestration. */ class App { private bootstrap!: BootstrapResult; private logger!: Logger; private stateStore!: JsonFileStateStore; - /** 归档 PR 冷存储(`archived/` 根,与 state/ 平级);退场 PR 整树搬入、按 grace 期清理。 */ + /** Archived PR cold storage (`archived/` root, sibling of state/); exiting PRs are moved in as a whole tree, cleaned by grace period. */ private archiveStore!: JsonFileStateStore; private poller!: Poller; private repoMirror!: RepoMirrorManager; @@ -54,31 +56,33 @@ class App { private conns!: ConnectionRuntimeController; private windowManager!: WindowManager; private ipcControl?: IpcControl; - /** 本地 API 服务监听器(默认关闭;按 config.service 决定是否 listen)。 */ + /** Local API service listener (off by default; config.service decides whether to listen). */ private apiServer?: ApiServer; private quitCleanupDone = false; constructor(private readonly startMs: number) {} /** - * 唯一启动序列:挂接生命周期 → 分域初始化;任一阶段抛错则记 fatal 并退出。 + * The single startup sequence: hook into the lifecycle → initialize by domain; if any stage throws, log fatal and exit. */ async run(): Promise<void> { this.registerLifecycle(); try { await this.bootstrapCore(); this.initRuntimes(); - // 清扫上次会话残留的原子写临时文件(进程在 write↔rename 之间退出留下的孤儿 tmp)。 - // 启动早期、任何写入之前清扫,单写者前提下安全(见 JsonFileStateStore.sweepStaleTmpFiles)。 + // Sweep atomic-write temp files left over from the last session (orphan tmp left when the process exited + // between write↔rename). Swept early at startup, before any write, safe under the single-writer premise + // (see JsonFileStateStore.sweepStaleTmpFiles). await this.stateStore .sweepStaleTmpFiles() .catch((err: unknown) => this.logger.warn({ err }, 'state-store: tmp sweep failed')); - // 归档冷存储同样清扫上次会话残留的原子写 tmp(搬迁中途退出可能留下孤儿)。 + // Archive cold storage likewise sweeps atomic-write tmp left over from the last session (exiting mid-migration may leave orphans). await this.archiveStore .sweepStaleTmpFiles() .catch((err: unknown) => this.logger.warn({ err }, 'archive-store: tmp sweep failed')); - // 归档孤儿清扫:统一索引丢失 / 重建后,归档数据失去索引条目、按索引遍历的硬清够不到 → 永久孤儿。 - // 启动期(任何写入之前)按「索引无条目 + 目录 mtime 超 grace」无索引兜底回收(见 docs/arch/99-core/01-state-storage)。 + // Archive orphan sweep: after the unified index is lost / rebuilt, archived data loses its index entries and the + // index-driven hard cleanup can't reach it → permanent orphans. At startup (before any write), reclaim via an + // index-less fallback keyed on "no index entry + directory mtime past grace" (see docs/arch/99-core/01-state-storage). await sweepOrphanedArchivedPrs({ stateStore: this.stateStore, archiveStore: this.archiveStore, @@ -95,16 +99,17 @@ class App { } /** - * ① workspace 落定 + i18n 定档 + 日志就绪 + Agent 目录脚手架 + macOS PATH 补全 + 全局兜底。 + * ① Settle workspace + fix i18n + ready logging + scaffold Agent dir + macOS PATH completion + global fallback. */ private async bootstrapCore(): Promise<void> { this.bootstrap = await ensureWorkspace(); - // 主进程 i18n 定档(dialog 标题、错误消息等面向用户文本)。config.language 为空时 - // 按操作系统偏好语言解析、无合适项回落英语;结果同时供 pr-agent 响应语言复用,与 UI 一致。 + // Fix main process i18n (user-facing text such as dialog titles, error messages). When config.language is empty, + // resolve by the OS preferred language and fall back to English if there's no match; the result is also reused for + // the pr-agent response language, consistent with the UI. initMainI18n( resolveLanguage(this.bootstrap.config.language, app.getPreferredSystemLanguages()), ); - // pretty 仅非打包态开:dev 控制台单行 + ISO8601 + 上色;打包态保持原始 JSON。 + // pretty only on in non-packaged mode: dev console single-line + ISO8601 + colored; packaged mode keeps raw JSON. this.logger = await createLogger({ logsDir: this.bootstrap.paths.logsDir, pretty: !app.isPackaged, @@ -114,10 +119,12 @@ class App { 'meebox main process started', ); - // Agent 目录脚手架:补齐**生效目录**的模版(用户配置的 agent.dir 优先,未配置回落默认 agent/——与 - // ipc.ts effectiveAgentDir 同口径)。此前误用默认目录,配置了自定义目录时该目录不会被初始化、启动加载 - // 到空目录。幂等(已存在不覆盖),使首次使用即有 SOUL/AGENTS 等上下文文件可读。失败不阻断启动 - // (运行期 loadAgentContext 仍会按缺失文件降级 + warn)。改 agent.dir 后的补齐见 config.setAgent。 + // Agent dir scaffold: fill in the template for the **effective directory** (user-configured agent.dir takes + // priority, falls back to the default agent/ when unconfigured—same basis as ipc.ts effectiveAgentDir). Previously + // the default directory was mistakenly used, so a configured custom directory wouldn't be initialized and startup + // would load an empty directory. Idempotent (won't overwrite what exists), so context files like SOUL/AGENTS are + // readable on first use. Failure doesn't block startup (at runtime loadAgentContext still degrades on missing files + // + warns). For filling in after changing agent.dir, see config.setAgent. const agentDir = this.bootstrap.config.agent.dir || this.bootstrap.paths.agentDir; void scaffoldAgentDir(agentDir) .then((created) => { @@ -127,7 +134,7 @@ class App { this.logger.warn({ err }, 'scaffold agent dir failed'); }); - // main 进程全局兜底:未捕获异常 / 未处理 rejection 至少留一条日志,不静默崩溃。 + // Main process global fallback: leave at least one log for uncaught exceptions / unhandled rejections, no silent crash. process.on('uncaughtException', (err) => { this.logger.fatal({ err }, 'uncaughtException'); }); @@ -137,23 +144,24 @@ class App { } /** - * ② pr-agent 运行时(解释器 + kick-off 探测)+ 版本更新器 + 状态存储。 + * ② pr-agent runtime (interpreter + kick-off probe) + version updater + state store. */ private initRuntimes(): void { - // pr-agent 运行时:解析嵌入式解释器 + kick-off 探测(不 await,不阻塞建窗首帧),结果异步回填。 + // pr-agent runtime: resolve embedded interpreter + kick-off probe (not awaited, doesn't block the window's first frame), result backfilled asynchronously. this.prAgent = new PrAgentRuntime(this.bootstrap, this.logger); - // 版本更新器:由 poller tick 顺带调 runIfDue(至多每小时一次,复用 poller 周期、不另起定时器)。 + // Version updater: runIfDue is called along with the poller tick (at most once per hour, reusing the poller cycle, no separate timer). this.updater = new Updater(this.bootstrap, this.logger); this.stateStore = new JsonFileStateStore(this.bootstrap.paths.stateDir, this.logger); this.archiveStore = new JsonFileStateStore(this.bootstrap.paths.archivedDir, this.logger); } /** - * ③ 连接级本地状态 → poller → 连接运行时(接线)→ repoMirror → IPC handlers。 + * ③ Connection-level local state → poller → connection runtime (wiring) → repoMirror → IPC handlers. */ private async initConnectionsAndIpc(): Promise<void> { - // 载入连接级本地状态(含上次 ping 的 currentUser)。缺失(首跑)/ 损坏 → 降级空表 + warn;后果仅首轮 - // poll 无预热身份,待异步 ping 补到 currentUser 后自动重分类,功能不受损(接线 / ping 见 connections-runtime)。 + // Load connection-level local state (including currentUser from the last ping). Missing (first run) / corrupt → + // degrade to an empty table + warn; the only consequence is the first poll has no pre-warmed identity, which is + // auto-reclassified once the async ping fills in currentUser, without loss of function (wiring / ping see connections-runtime). const connectionStates = await readConnectionStates(this.stateStore).catch((err: unknown) => { this.logger.warn( { err }, @@ -168,20 +176,20 @@ class App { archiveStore: this.archiveStore, logger: this.logger, onTickExtras: () => { - // 本轮已被移除 / purge 的 PR:终止其上仍在执行的 agent 操作(先于 AutoPilot,避免给已消失的 PR 起新评审)。 + // PRs removed / purged this round: terminate agent operations still running on them (before AutoPilot, to avoid starting new reviews for disappeared PRs). this.ipcControl?.terminateAgentsForGonePrs(); - // 顺带做版本更新检测:内部时间戳门控成每小时至多一次,复用 poller 周期、不另起定时器。 + // Also do version update detection: internally timestamp-gated to at most once per hour, reusing the poller cycle, no separate timer. void this.updater.runIfDue(); - // AutoPilot 预评审:满足开关 + 最小间隔 + 候选时跑一遍 pass(内部门控,复用 poller 周期)。 + // AutoPilot pre-review: when switch + minimum interval + candidates are met, run a pass (internally gated, reusing the poller cycle). this.ipcControl?.runAutopilotIfDue(); }, getRepoMirror: () => this.repoMirror, getConnectionRuntime: () => this.conns.runtime, - // 评论类提醒(回复 / 提及)顺手失效该 PR 评论缓存 + 广播 comments:changed,让正打开它的视图即时重拉。 + // Comment-type alerts (reply / mention) also invalidate that PR's comment cache + broadcast comments:changed, so a view currently showing it refetches immediately. invalidateCommentsCache: (localId) => this.ipcControl?.invalidateCommentsCache(localId), }); - // 连接运行时(接线 / ping / 热重配):依赖已建好的 poller;repoMirror 经 conns.runtime 读 adapterByHost。 + // Connection runtime (wiring / ping / hot reconfigure): depends on the already-built poller; repoMirror reads adapterByHost via conns.runtime. this.conns = new ConnectionRuntimeController( this.bootstrap, this.stateStore, @@ -196,14 +204,15 @@ class App { connectionRuntime: this.conns.runtime, }); - // 建窗前同步把连接接好(无网络):构建 adapters、用本地持久化身份预热 currentUser、喂 poller。 - // 这样 app:connections 与首轮判 approved 都不依赖网络;ping 留到建窗后全异步刷新。 + // Wire connections synchronously before creating the window (no network): build adapters, pre-warm currentUser with + // locally persisted identity, feed the poller. This way app:connections and the first-round approved check don't + // depend on network; ping is left to refresh fully asynchronously after the window is created. this.conns.wire(); this.ipcControl = registerIpcHandlers({ bootstrap: this.bootstrap, logger: this.logger, - // 惰性读取:探测异步回填后,handler 调用时才取到最新值(注册时探测可能尚未完成) + // Lazy read: after the probe backfills asynchronously, the handler picks up the latest value only when called (the probe may not be done at registration time) getPrAgentStatus: () => this.prAgent.probe, getPrAgentBridge: () => this.prAgent.getBridge(), embeddedPythonPath: this.prAgent.embeddedPythonPath, @@ -213,43 +222,50 @@ class App { connectionRuntime: this.conns.runtime, reconfigureConnections: () => this.conns.reconfigure(), repoMirror: this.repoMirror, - // 惰性引用:ApiServer 在 registerIpcHandlers 之后才构造(其请求处理依赖此刻才安装的 - // ControllerContext 单例);闭包在 config:setService 调用时才取最新实例。 + // Lazy reference: ApiServer is constructed only after registerIpcHandlers (its request handling depends on the + // ControllerContext singleton installed only at this moment); the closure picks up the latest instance only when config:setService is called. reconfigureApiServer: () => this.apiServer?.reconfigure() ?? Promise.resolve(), }); - // 本地 API 服务监听器:ControllerContext 已由 registerIpcHandlers 安装,可安全处理请求。 - // 按 config.service 决定是否实际 listen(默认关闭);监听失败为非致命(内部已兜底记录)。 + // Local API service listener: ControllerContext is already installed by registerIpcHandlers, so requests can be handled safely. + // config.service decides whether to actually listen (off by default); a listen failure is non-fatal (internally logged as a fallback). this.apiServer = new ApiServer({ bootstrap: this.bootstrap, logger: this.logger }); await this.apiServer.start(); } /** - * ④ whenReady 后的原生 chrome(菜单/Dock 图标/深色)+ splash + 主窗口 + activate 重建。 + * ④ Native chrome after whenReady (menu/Dock icon/dark) + splash + main window + activate rebuild. */ private async initWindow(): Promise<void> { - // 不要 Electron 默认菜单栏(File/Edit/View/...),meebox 自己提供工具栏 + // No Electron default menu bar (File/Edit/View/...), meebox provides its own toolbar Menu.setApplicationMenu(null); await app.whenReady(); - // dev 下 Dock 图标走通用 Electron.app(未经 electron-builder 烤 icns)→ 手动设成 mac 专用图标。 - // 打包态 Dock 图标由 bundle 的 icns 决定,无需且不应在此覆盖。仅 mac 有 app.dock。 + // In dev the Dock icon uses the generic Electron.app (not baked into icns by electron-builder) → manually set the mac-specific icon. + // In packaged mode the Dock icon is decided by the bundle's icns, no need and should not override here. Only mac has app.dock. if (process.platform === 'darwin' && !app.isPackaged) { app.dock?.setIcon(path.join(app.getAppPath(), '../../assets/icons/icon-mac.png')); } - // Windows toast 通知需 AppUserModelId 与安装包 appId 一致,否则系统可能不显示 / 归属错乱。 + // Windows toast notifications require AppUserModelId to match the installer's appId, otherwise the system may not + // show them / attribute them wrongly. dev uses a .dev suffix: AUMID is Windows's persistent cache key for taskbar + // icon/grouping/pinning, and dev runs electron.exe with its own default icon; sharing the same AUMID with packaged + // mode would cache the Electron icon under that key → the released build after install still reuses the old cache + // and shows the Electron icon in the taskbar. Separating them keeps them from polluting each other. if (process.platform === 'win32') { - app.setAppUserModelId('com.huhamhire.code-meeseeks'); + app.setAppUserModelId( + app.isPackaged ? 'com.huhamhire.code-meeseeks' : 'com.huhamhire.code-meeseeks.dev', + ); } - // 原生窗口 chrome 跟随全局主题:Windows 据 nativeTheme 设 DWMWA_USE_IMMERSIVE_DARK_MODE(原生 - // 细边框 / 窗控按钮深浅)。themeSource 由主题反推——'auto' 主题交回 OS('system'),其余固定浅 / 深。 - // 窗控按钮配色由 WindowManager 监听 nativeTheme 'updated' 同步(见 window-manager)。 + // Native window chrome follows the global theme: Windows sets DWMWA_USE_IMMERSIVE_DARK_MODE based on nativeTheme + // (native thin border / window-control button light-dark). themeSource is inferred back from the theme—the 'auto' + // theme hands it back to the OS ('system'), the rest are fixed light / dark. Window-control button colors are synced + // by WindowManager listening to nativeTheme 'updated' (see window-manager). nativeTheme.themeSource = editorThemeNativeSource(this.bootstrap.config.appearance.editor_theme); - // 主窗口管理(载入窗口状态 + 建窗 + 尺寸回写)。 + // Main window management (load window state + create window + write back size). this.windowManager = await loadWindowManager({ stateStore: this.stateStore, stateDir: this.bootstrap.paths.stateDir, @@ -257,8 +273,9 @@ class App { startMs: this.startMs, }); - // 先弹轻量 splash(data URL,几十 ms 即可见),遮住主窗口首帧前的 ~2s 加载空窗。主窗口 ready-to-show - // 时关闭它。配色跟随有效主题(shouldUseDarkColors 已据上面的 themeSource 解析 'system')。 + // Pop a lightweight splash first (data URL, visible within tens of ms) to cover the ~2s loading blank before the main + // window's first frame. Close it when the main window is ready-to-show. Colors follow the effective theme + // (shouldUseDarkColors already resolves 'system' based on the themeSource above). const splash = createSplash(nativeTheme.shouldUseDarkColors); this.windowManager.create(splash); @@ -268,15 +285,15 @@ class App { } /** - * ⑤ 启动关键路径已无网络:归档(本地 IO)后启动 poller,再异步 ping 刷新远端身份。 + * ⑤ The startup critical path is already network-free: after archiving (local IO), start the poller, then async ping to refresh remote identity. */ private async startPolling(): Promise<void> { await this.poller.archiveConnectionsExcept(this.conns.activeConnectionIds()); - // 活动连接是否已有缓存身份(conns.wire 已用本地持久化身份预热): - // - 有 → poller 立即跑首轮(me 就绪,分类正确); - // - 无 → **不跑 me=null 的半成品首轮**,只装定时器;首次同步改由下面 conns.ping 在 ping - // 确认身份后立即触发(无身份的活动连接 ping settle 后必 tick 一次)。 - // 这样「首次启动 / state 缺失」时也是「先确认身份,再同步一次」,避免首轮全标 pending 或看似没同步。 + // Whether the active connection already has a cached identity (conns.wire pre-warmed with locally persisted identity): + // - Yes → poller runs the first round immediately (me is ready, classification correct); + // - No → **don't run a half-baked first round with me=null**, only install the timer; the first sync is instead + // triggered by conns.ping below right after ping confirms identity (an active connection with no identity must tick once after ping settles). + // This way "first startup / missing state" is also "confirm identity first, then sync once", avoiding a first round that marks everything pending or looks like it didn't sync. const activeHasIdentity = this.conns.runtime.adapters.some( (a) => a.connectionId === this.bootstrap.config.active_connection_id && @@ -291,26 +308,27 @@ class App { }, 'poller started', ); - // ping 全异步刷新远端身份(不在启动关键路径):刷新/持久化 currentUser,身份变化则补一轮 poll。 - // 这是「本地无身份记录」(首跑 / state 缺失)时的兜底来源;ping 慢或不可达都不影响已启动的 UI。 + // ping refreshes remote identity fully asynchronously (not on the startup critical path): refreshes/persists + // currentUser, and does an extra poll round if identity changes. This is the fallback source when there's "no local + // identity record" (first run / missing state); a slow or unreachable ping doesn't affect the already-started UI. this.conns.ping(); } /** - * app 级生命周期:退出清理(停轮询 + 终止在跑 run 的子进程树)、关窗退出、二次启动聚焦。 + * App-level lifecycle: exit cleanup (stop polling + terminate the child process tree of running runs), quit on window close, focus on second launch. */ private registerLifecycle(): void { - // 退出清理:停轮询 + 终止所有进行中的 pr-agent run 的子进程树(python + litellm 等孙进程)。 - // 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 + // Exit cleanup: stop polling + terminate the child process tree of all in-progress pr-agent runs (python + litellm and other grandchild processes). + // Not cleaning up leaves orphan processes locking the install directory → NSIS reports "the app cannot be closed" during upgrade. app.on('before-quit', (event) => { if (this.poller) this.poller.stop(); - // 停本地 API 监听(停止接收新连接);fire-and-forget,关闭很快、不阻塞退出。 + // Stop the local API listener (stop accepting new connections); fire-and-forget, closes quickly, doesn't block exit. void this.apiServer?.stop(); if (this.quitCleanupDone) return; const aborted = this.ipcControl?.abortAllActiveRuns() ?? 0; - if (aborted === 0) return; // 无进行中 run,直接退出 - // 有 run 在跑:abort 已触发各自 exec 的 killTree(win32=taskkill /T /F,异步)。延后真正退出, - // 给 taskkill 跑完,避免主进程先退出、孙进程没杀干净。 + if (aborted === 0) return; // No in-progress run, exit directly + // Runs are in progress: abort has triggered each exec's killTree (win32=taskkill /T /F, async). Defer the actual + // exit to let taskkill finish, avoiding the main process exiting first while grandchild processes aren't fully killed. event.preventDefault(); this.quitCleanupDone = true; if (this.logger) { @@ -323,7 +341,7 @@ class App { if (process.platform !== 'darwin') app.quit(); }); - // 二次启动(用户再点图标 / 命令行再拉起)→ 聚焦已有窗口,最小化则先还原。 + // Second launch (user clicks the icon again / relaunches from command line) → focus the existing window, restore first if minimized. app.on('second-instance', () => { const win = BrowserWindow.getAllWindows()[0]; if (win) { @@ -335,11 +353,12 @@ class App { } /** - * 进程入口: - * ① OS/平台启动微调(须在 whenReady 前;含 Windows 控制台编码 / macOS keychain + PATH 补全)。 - * ② 单例锁——同一时刻只允许一个实例,多实例会共享同一份 config.yaml / repos 镜像 / state store - * 导致写竞争,拿不到锁者直接退出(由已有实例的 second-instance 回调聚焦窗口)。 - * ③ 拿到锁则构造 App 并启动。 + * Process entry: + * ① OS/platform startup tweaks (must be before whenReady; includes Windows console encoding / macOS keychain + PATH completion). + * ② Single-instance lock—only one instance allowed at a time; multiple instances would share the same config.yaml / + * repos mirror / state store causing write contention, so whoever fails to get the lock exits directly (the existing + * instance's second-instance callback focuses the window). + * ③ If the lock is acquired, construct App and start. */ function main(): void { applyOsStartupTweaks(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 25cb790a..5d61aefc 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -15,11 +15,13 @@ import { RunQueue } from './services/pr-agent/index.js'; export type { RegisterDeps } from './services/context.js'; /** - * 注册全部 IPC handler。薄入口:构建共享上下文 → 建两个跨域 service(run 队列 / Agent 编排) - * → 合成 controller 上下文并安装为进程级单例 → 按业务领域逐个绑定通道 → 返回运行时控制句柄。 + * Register all IPC handlers. Thin entry: build the shared context → create two cross-domain services (run queue / Agent + * orchestration) → compose the controller context and install it as a process-level singleton → bind channels one by + * one per business domain → return the runtime control handle. * - * controller 是原生 ipcMain.handle 监听器(具名函数 `(event, req) => …`,见 controllers/<域>.ts), - * 依赖经 getContext() 取用、不带 ctx 参数;下方直接 `ipcMain.handle('channel', controller)` 注册,无包装层。 + * A controller is a native ipcMain.handle listener (named function `(event, req) => …`, see controllers/<domain>.ts), + * with dependencies taken via getContext() and no ctx parameter; below they're registered directly as + * `ipcMain.handle('channel', controller)`, with no wrapper layer. */ export function registerIpcHandlers(deps: RegisterDeps): { abortAllActiveRuns: () => number; @@ -28,133 +30,135 @@ export function registerIpcHandlers(deps: RegisterDeps): { invalidateCommentsCache: (localId: string) => void; } { const base = createServiceContext(deps); - // run 队列:pragent:run(PR 域)、Agent 编排、AutoPilot 三方共用。 + // run queue: shared by pragent:run (PR domain), Agent orchestration, and AutoPilot. const runQueue = new RunQueue(base); - // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 + // Agent orchestration: reuses the run queue to dispatch tool runs (agent low-priority lane). const orchestrator = new Orchestrator(base, runQueue); - // controller 层统一上下文:基础上下文 + 两个跨域 service,安装为进程级单例(controller 经 getContext() 取用)。 + // Controller-layer unified context: base context + two cross-domain services, installed as a process-level singleton (controllers take it via getContext()). const ctx: ControllerContext = { ...base, runQueue, orchestrator }; setControllerContext(ctx); /* - * GUI 框架交互 - * 应用信息 / 窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 + * GUI framework interaction + * App info / window / external open / dialog / log relay / connections and avatars */ - ipcMain.handle('app:info', app.readAppInfo); // 应用 / 运行时版本信息(关于页) - ipcMain.handle('app:paths', app.readAppPaths); // 关键目录路径(config / agent / 日志) - ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) - ipcMain.handle('log:write', app.writeRendererLog); // 渲染层日志回传落盘 - ipcMain.handle('window:setControlColors', app.setWindowControlColors); // 渲染层推送主题派生窗控配色 - ipcMain.handle('app:connections', app.listConnections); // 当前活动连接摘要(Header / 状态栏) - ipcMain.handle('app:userAvatar', app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) - ipcMain.handle('app:openConfigFile', app.openConfigFile); // 打开 config.yaml - ipcMain.handle('app:openAgentDir', app.openAgentDir); // 打开 Agent 目录 - ipcMain.handle('app:openDevTools', app.openDevTools); // 打开 DevTools(分离窗口) - ipcMain.handle('app:setBadgeCount', app.setBadgeCount); // 设应用角标计数(macOS dock) - ipcMain.handle('app:checkUpdate', app.checkUpdate); // 手动检查更新 - ipcMain.handle('app:getUpdateStatus', app.getUpdateStatus); // 读缓存的更新检测结果(水合) - ipcMain.handle('app:openExternal', app.openExternal); // 系统浏览器打开外链 - ipcMain.handle('app:openNotificationSettings', app.openNotificationSettings); // macOS 打开系统通知设置 - ipcMain.handle('dialog:pickDirectory', app.pickDirectory); // 原生目录选择对话框 + ipcMain.handle('app:info', app.readAppInfo); // App / runtime version info (About page) + ipcMain.handle('app:paths', app.readAppPaths); // Key directory paths (config / agent / logs) + ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent probe status (whether ready) + ipcMain.handle('log:write', app.writeRendererLog); // Relay renderer logs to disk + ipcMain.handle('window:setControlColors', app.setWindowControlColors); // Renderer pushes theme-derived window-control colors + ipcMain.handle('app:connections', app.listConnections); // Current active connection summary (Header / status bar) + ipcMain.handle('app:userAvatar', app.getUserAvatar); // User avatar (two-level memory + disk cache) + ipcMain.handle('app:openConfigFile', app.openConfigFile); // Open config.yaml + ipcMain.handle('app:openAgentDir', app.openAgentDir); // Open Agent directory + ipcMain.handle('app:openDevTools', app.openDevTools); // Open DevTools (detached window) + ipcMain.handle('app:setBadgeCount', app.setBadgeCount); // Set app badge count (macOS dock) + ipcMain.handle('app:checkUpdate', app.checkUpdate); // Manually check for updates + ipcMain.handle('app:getUpdateStatus', app.getUpdateStatus); // Read cached update-check result (hydration) + ipcMain.handle('app:openExternal', app.openExternal); // Open external link in system browser + ipcMain.handle('app:openNotificationSettings', app.openNotificationSettings); // macOS open system notification settings + ipcMain.handle('dialog:pickDirectory', app.pickDirectory); // Native directory picker dialog /* - * PR 操作 - * 评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 + * PR operations + * Comments / list / status / merge / mirror / diff / drafts / pr-agent run queue */ - ipcMain.handle('comments:reply', pr.replyComment); // 回复评论 - ipcMain.handle('comments:create', pr.createComment); // 新建 summary 评论 - ipcMain.handle('comments:delete', pr.deleteComment); // 删除自己的评论 - ipcMain.handle('comments:edit', pr.editComment); // 编辑自己的评论 - ipcMain.handle('comments:toggleReaction', pr.toggleReaction); // 切换评论 emoji 反应 - ipcMain.handle('comments:uploadAttachment', pr.uploadAttachment); // 上传评论图片附件 - ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) - ipcMain.handle('prs:list', pr.listPrs); // PR 列表(仅活动连接) - ipcMain.handle('prs:listArchived', pr.listArchivedPrs); // 已关闭(归档)PR 列表(只读浏览) - ipcMain.handle('prs:openByUrl', pr.openPrByUrl); // 按 URL 打开当前平台 PR(定位 / 拉取存档) - ipcMain.handle('prs:refresh', pr.refreshPrs); // 立即轮询刷新 - ipcMain.handle('prs:lastSync', pr.getLastSync); // 最近一次同步时间 - ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // 设置审阅状态(先远端后本地) - ipcMain.handle('prs:markRead', pr.markRead); // 标记 PR 已读(推进未读水位) - ipcMain.handle('prs:merge', pr.mergePr); // 合并 PR - ipcMain.handle('repo:sync', pr.syncRepo); // 同步 PR 所属 repo 本地镜像 - ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // 变更文件列表 - ipcMain.handle('diff:listConflictFiles', pr.listConflictFiles); // 合并冲突文件列表(文件树警示) - ipcMain.handle('diff:getFileContent', pr.getFileContent); // 文件内容(base / head 一侧) - ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // 评论数角标(仅缓存) - ipcMain.handle('diff:listComments', pr.listComments); // 拉评论(缓存 + in-flight 去重) - ipcMain.handle('diff:listCommits', pr.listCommits); // 提交列表 - ipcMain.handle('diff:listActivity', pr.listActivity); // 评审决断活动事件(时间线) - ipcMain.handle('diff:commitCount', pr.getCommitCount); // 提交数角标(本地 git) - ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR 引入行 - ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // 本地镜像总占用(设置页) - ipcMain.handle('drafts:list', pr.getDrafts); // 草稿列表 - ipcMain.handle('drafts:create', pr.addDraft); // 新建草稿 - ipcMain.handle('drafts:update', pr.patchDraft); // 更新草稿 - ipcMain.handle('drafts:delete', pr.removeDraft); // 删除草稿 - ipcMain.handle('drafts:publishBatch', pr.publishDraftBatch); // 批量发布草稿到远端 - ipcMain.handle('findingClosures:list', pr.getFindingClosures); // finding 关闭关系列表 - ipcMain.handle('findingClosures:create', pr.addClosure); // 复评取代/撤销 → 关闭原 finding - ipcMain.handle('findingClosures:delete', pr.removeClosure); // 撤销关闭 + ipcMain.handle('comments:reply', pr.replyComment); // Reply to a comment + ipcMain.handle('comments:create', pr.createComment); // Create a summary comment + ipcMain.handle('comments:delete', pr.deleteComment); // Delete your own comment + ipcMain.handle('comments:edit', pr.editComment); // Edit your own comment + ipcMain.handle('comments:toggleReaction', pr.toggleReaction); // Toggle a comment emoji reaction + ipcMain.handle('comments:uploadAttachment', pr.uploadAttachment); // Upload a comment image attachment + ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // Fetch a comment inline image (proxied with PAT) + ipcMain.handle('prs:list', pr.listPrs); // PR list (active connection only) + ipcMain.handle('prs:listArchived', pr.listArchivedPrs); // Closed (archived) PR list (read-only browsing) + ipcMain.handle('prs:openByUrl', pr.openPrByUrl); // Open a current-platform PR by URL (locate / fetch archive) + ipcMain.handle('prs:refresh', pr.refreshPrs); // Poll and refresh immediately + ipcMain.handle('prs:lastSync', pr.getLastSync); // Most recent sync time + ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // Set review status (remote first, then local) + ipcMain.handle('prs:markRead', pr.markRead); // Mark PR read (advance unread watermark) + ipcMain.handle('prs:merge', pr.mergePr); // Merge PR + ipcMain.handle('repo:sync', pr.syncRepo); // Sync the local mirror of the PR's repo + ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // Changed files list + ipcMain.handle('diff:listConflictFiles', pr.listConflictFiles); // Merge conflict files list (file tree warning) + ipcMain.handle('diff:getFileContent', pr.getFileContent); // File content (base / head side) + ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // Comment count badge (cache only) + ipcMain.handle('diff:listComments', pr.listComments); // Fetch comments (cache + in-flight dedup) + ipcMain.handle('diff:listCommits', pr.listCommits); // Commit list + ipcMain.handle('diff:listActivity', pr.listActivity); // Review-decision activity events (timeline) + ipcMain.handle('diff:commitCount', pr.getCommitCount); // Commit count badge (local git) + ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR-introduced lines + ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // Total local mirror usage (settings page) + ipcMain.handle('drafts:list', pr.getDrafts); // Draft list + ipcMain.handle('drafts:create', pr.addDraft); // Create a draft + ipcMain.handle('drafts:update', pr.patchDraft); // Update a draft + ipcMain.handle('drafts:delete', pr.removeDraft); // Delete a draft + ipcMain.handle('drafts:publishBatch', pr.publishDraftBatch); // Batch publish drafts to remote + ipcMain.handle('findingClosures:list', pr.getFindingClosures); // finding closure relations list + ipcMain.handle('findingClosures:create', pr.addClosure); // Re-review supersede/revoke → close the original finding + ipcMain.handle('findingClosures:delete', pr.removeClosure); // Undo closure /* - * 配置操作 - * 读写 config.yaml(热生效 / 草稿暂存)及连接 / 代理试连 + * Config operations + * Read/write config.yaml (hot effect / draft staging) and connection / proxy test connections */ - ipcMain.handle('config:read', config.readConfig); // 读当前内存配置 - ipcMain.handle('config:setReposDir', config.setReposDir); // 设仓库目录(重启生效) - ipcMain.handle('config:setLanguage', config.setLanguage); // 设 UI 语言(热生效) - ipcMain.handle('config:setEditorAppearance', config.setEditorAppearance); // 设全局主题 + 字体(前端即时生效,主进程据主题设原生 themeSource) - ipcMain.handle('config:setLlm', config.setLlm); // 设 LLM Provider 配置 - ipcMain.handle('config:setAgent', config.setAgent); // 设 Agent 配置(含 agent.dir) - ipcMain.handle('config:setNotifications', config.setNotifications); // 设消息通知配置(系统通知 + dock 角标) - ipcMain.handle('agent:setAutopilotEnabled', config.setAutopilotEnabled); // AutoPilot 开关 - ipcMain.handle('config:setConnections', config.setConnections); // 设连接(热重建 adapter/poller) - ipcMain.handle('config:setProxy', config.setProxy); // 设代理(热重建 adapter) - ipcMain.handle('config:testProxy', config.testProxy); // 试连代理(不写配置) - ipcMain.handle('config:testConnection', config.testConnection); // 试连连接(不写配置) - ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) - ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) - ipcMain.handle('config:setMaxConcurrency', config.setMaxConcurrency); // 设评审并发数(热替换队列上限) - ipcMain.handle('config:setService', config.setService); // 设本地 API 服务监听(热重建监听器) - ipcMain.handle('config:generateServiceToken', config.generateServiceToken); // 重新生成 API bearer token + ipcMain.handle('config:read', config.readConfig); // Read current in-memory config + ipcMain.handle('config:setReposDir', config.setReposDir); // Set repos directory (takes effect on restart) + ipcMain.handle('config:setLanguage', config.setLanguage); // Set UI language (hot effect) + ipcMain.handle('config:setEditorAppearance', config.setEditorAppearance); // Set global theme + font (frontend takes effect immediately, main process sets native themeSource per theme) + ipcMain.handle('config:setLlm', config.setLlm); // Set LLM Provider config + ipcMain.handle('config:setAgent', config.setAgent); // Set Agent config (including agent.dir) + ipcMain.handle('config:setNotifications', config.setNotifications); // Set notification config (system notifications + dock badge) + ipcMain.handle('agent:setAutopilotEnabled', config.setAutopilotEnabled); // AutoPilot switch + ipcMain.handle('config:setConnections', config.setConnections); // Set connections (hot-rebuild adapter/poller) + ipcMain.handle('config:setProxy', config.setProxy); // Set proxy (hot-rebuild adapter) + ipcMain.handle('config:testProxy', config.testProxy); // Test proxy connection (doesn't write config) + ipcMain.handle('config:testConnection', config.testConnection); // Test connection (doesn't write config) + ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // Save connection / LLM draft to disk (doesn't take effect) + ipcMain.handle('config:setPoller', config.setPoller); // Set poll interval (hot-swap timer) + ipcMain.handle('config:setMaxConcurrency', config.setMaxConcurrency); // Set review concurrency (hot-swap queue cap) + ipcMain.handle('config:setService', config.setService); // Set local API service listening (hot-rebuild listener) + ipcMain.handle('config:generateServiceToken', config.generateServiceToken); // Regenerate API bearer token /* - * Agent 交互 - * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 + * Agent interaction + * Rule matching / review orchestration / free planning / session and ledger reads / pr-agent run queue */ - ipcMain.handle('rules:matchForPr', agent.matchRuleForPr); // 查 PR 命中的规则 - ipcMain.handle('agent:run', agent.runReview); // 一键评审编排(describe→review→总结) - ipcMain.handle('agent:ask', agent.runPlanning); // 自由规划 Agent(对话即委派) - ipcMain.handle('agent:enqueueMessage', agent.enqueueMessage); // 运行中追加用户消息(入队 / 起新轮) - ipcMain.handle('agent:stop', agent.stopAgent); // 停止某 PR 的 Agent 运行 - ipcMain.handle('agent:getSession', agent.getSession); // 读已落盘评审会话 - ipcMain.handle('agent:getConversation', agent.getConversation); // 读多轮对话消息 - ipcMain.handle('agent:getTranscript', agent.getTranscript); // 读 Agent 过程步骤 - ipcMain.handle('agent:autopilotLedgers', agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 - ipcMain.handle('pragent:run', agent.runPragent); // 触发一次 pr-agent run(入队) - ipcMain.handle('pragent:cancel', agent.cancelPragent); // 取消一个 run - ipcMain.handle('pragent:queue', agent.getQueue); // 队列快照(active + waiting) - ipcMain.handle('pragent:listRuns', agent.listRuns); // 历史 run 列表(游标分页) - ipcMain.handle('pragent:getRun', agent.getRun); // 单条 run 查询 - ipcMain.handle('pragent:clearRuns', agent.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 - ipcMain.handle('pragent:deleteRun', agent.deleteRun); // 删除单条 run 记录 + ipcMain.handle('rules:matchForPr', agent.matchRuleForPr); // Look up rules a PR matches + ipcMain.handle('agent:run', agent.runReview); // One-click review orchestration (describe→review→summarize) + ipcMain.handle('agent:ask', agent.runPlanning); // Free-planning Agent (conversation is delegation) + ipcMain.handle('agent:enqueueMessage', agent.enqueueMessage); // Append a user message while running (enqueue / start a new round) + ipcMain.handle('agent:stop', agent.stopAgent); // Stop the Agent run for a PR + ipcMain.handle('agent:getSession', agent.getSession); // Read a persisted review session + ipcMain.handle('agent:getConversation', agent.getConversation); // Read multi-round conversation messages + ipcMain.handle('agent:getTranscript', agent.getTranscript); // Read Agent process steps + ipcMain.handle('agent:autopilotLedgers', agent.getAutopilotLedgers); // Batch read AutoPilot review ledgers + ipcMain.handle('pragent:run', agent.runPragent); // Trigger one pr-agent run (enqueue) + ipcMain.handle('pragent:cancel', agent.cancelPragent); // Cancel a run + ipcMain.handle('pragent:queue', agent.getQueue); // Queue snapshot (active + waiting) + ipcMain.handle('pragent:listRuns', agent.listRuns); // Historical run list (cursor pagination) + ipcMain.handle('pragent:getRun', agent.getRun); // Query a single run + ipcMain.handle('pragent:clearRuns', agent.clearRuns); // Clear run history + Agent sessions / ledgers + ipcMain.handle('pragent:deleteRun', agent.deleteRun); // Delete a single run record base.logger.debug('IPC handlers registered'); return { /** - * 应用退出时调用:中止所有进行中的 run。每个 run 的 AbortController.abort() 会触发 exec 的 - * onAbort → killTree(进程树级杀),连带终止 python 及其 litellm 等孙进程,避免孤儿进程锁住 - * 安装目录导致升级安装失败。返回被中止的 run 数,供调用方决定是否需要短暂等待 taskkill 跑完。 + * Called on app exit: abort all in-progress runs. Each run's AbortController.abort() triggers exec's + * onAbort → killTree (process-tree-level kill), also terminating python and its grandchild processes like litellm, + * avoiding orphan processes locking the install directory and causing upgrade install failures. Returns the number + * of aborted runs, so the caller can decide whether it needs to wait briefly for taskkill to finish. */ abortAllActiveRuns: () => runQueue.abortAllActiveRuns(), - /** 每次 poll tick 由 index.ts 调用:满足开关 + 候选时跑一遍 AutoPilot pass。 */ + /** Called by index.ts on each poll tick: when switch + candidates are met, run one AutoPilot pass. */ runAutopilotIfDue: () => orchestrator.runAutopilotIfDue(), - /** 每次 poll tick 由 index.ts 调用:终止已被移除 / purge 的 PR 上仍在执行的 agent 操作。 */ + /** Called by index.ts on each poll tick: terminate agent operations still running on PRs that were removed / purged. */ terminateAgentsForGonePrs: () => void orchestrator.terminateAgentsForGonePrs(), /** - * 轮询发现某 PR 评论变更(回复 / 提及)时由 index.ts 调用:清该 PR 评论缓存 + 广播 comments:changed, - * 让正打开该 PR 的 Diff 内嵌评论 / 活动时间线即时重拉(轮询期评论变更此前只弹通知、不刷新已打开视图)。 + * Called by index.ts when polling finds a PR's comments changed (reply / mention): clear that PR's comment cache + + * broadcast comments:changed, so the Diff inline comments / activity timeline currently showing that PR refetch + * immediately (comment changes during polling previously only popped a notification, without refreshing the open view). */ invalidateCommentsCache: (localId: string) => void ctx.pr.invalidateCommentsCache(localId), }; diff --git a/apps/desktop/src/main/services/agent/flows/autopilot.ts b/apps/desktop/src/main/services/agent/flows/autopilot.ts index 1183cc5a..6a4b3983 100644 --- a/apps/desktop/src/main/services/agent/flows/autopilot.ts +++ b/apps/desktop/src/main/services/agent/flows/autopilot.ts @@ -16,9 +16,12 @@ import type { OrchestratorRuntime } from '../runtime.js'; import { runReviewForPr } from './review.js'; /** - * 跑一遍 AutoPilot pass(busy 锁置位 / 复位包住全程)。仅由 Orchestrator.runAutopilotIfDue 通过准入后触发。 - * 候选准入(硬性门控,自上而下):① 仅「待我评审 + 待处理」;② 已有 describe/review 产出(成功 / 进行中) - * → 已评审过 / 评审中,排除;③ 本版本已被判 skip 的台账去重。再按 batch_size 截断,批量判定后并行编排评审。 + * Runs one AutoPilot pass (the busy lock set / reset wraps the whole run). Only triggered by + * Orchestrator.runAutopilotIfDue after passing admission. + * Candidate admission (hard gates, top-down): (1) only "review-requested + pending"; (2) already has + * describe/review output (succeeded / in progress) → already reviewed / reviewing, excluded; (3) ledger + * dedup for this version already judged skip. Then truncate by batch_size, batch-judge, and orchestrate + * reviews in parallel. */ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> { const { bootstrap, stateStore, ensureAgentDir, logger } = runtime.ctx; @@ -27,10 +30,11 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> try { const prs = await listStoredPullRequests(stateStore); const candidates: StoredPullRequest[] = []; - // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 - let reviewReqPending = 0; // 命中「待我评审 + 待处理」 - let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 - let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 + // Admission funnel counters (to locate which gate blocked when there are 0 candidates — helps debug + // "why does it no longer trigger"). + let reviewReqPending = 0; // matched "review-requested + pending" + let alreadyReviewed = 0; // among those, excluded for already having describe/review output (succeeded / in progress) + let skipDeduped = 0; // among those, excluded for already being judged skip this version for (const pr of prs) { if (candidates.length >= ap.batch_size) break; if (!pr.discoveryFilters.includes('review-requested')) continue; @@ -48,7 +52,7 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> candidates.push(pr); } if (candidates.length === 0) { - // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 + // Still evaluating on schedule, just no new eligible PR right now — log the funnel counters to avoid being misread as "not running". logger.info( { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, 'autopilot pass: no eligible candidates', @@ -59,9 +63,11 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> const agentContext = await loadAgentContext(await ensureAgentDir(), { onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), }); - // 第一步 judge 的背景输入:逐候选判「纯分支合并」。判定**以实际提交结构为准**——拉一次 commits API - // 看「提交是否全为 merge」;分支名只作 sourceMainline 背景信号、不单独定论。并行跑,整体约一轮 round-trip。 - // 失败不阻断(无 commits → inconclusive、按非合并处理,仍带 sourceMainline 信号交 judge 权衡)。 + // Background input for the first judge step: classify each candidate as "pure branch merge". The + // decision is **based on the actual commit structure** — one commits API call to see "whether all + // commits are merges"; the branch name is only a sourceMainline background signal, never decisive alone. + // Runs in parallel, roughly one round-trip overall. + // Failure does not block (no commits → inconclusive, treated as non-merge, still carrying the sourceMainline signal for the judge to weigh). const branchMergeByPr = new Map<string, BranchMergeVerdict>(); await Promise.all( candidates.map(async (p) => { @@ -86,7 +92,7 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> ); await runtime.withAgentChat(async (chat) => { - // 批量判定(例外规则来自 AGENTS.md;分支信息 + 分支合并信号作背景输入)。 + // Batch judgment (exception rules come from AGENTS.md; branch info + branch-merge signal as background input). const { decisions } = await judgeAutopilotBatch(chat, { candidates: candidates.map((p) => { const v = branchMergeByPr.get(p.localId); @@ -103,12 +109,12 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> agentsRules: agentContext.files.agents, }); const byId = new Map(candidates.map((p) => [p.localId, p] as const)); - // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策(连同其执行计划)待并行编排。 + // Persist "skip" decisions first (no tool cost, sequential write is fine); collect "review" decisions (along with their execution plan) for parallel orchestration. const toReview: Array<{ pr: StoredPullRequest; plan?: ReviewPlan }> = []; for (const d of decisions) { const pr = byId.get(d.prLocalId); if (!pr) continue; - // 每条决策都记日志(含 review/skip + 原因 + 计划 + 分支合并信号),便于排查「judge 是否在按规则跑」。 + // Log every decision (with review/skip + reason + plan + branch-merge signal) to help debug "whether the judge runs by the rules". logger.info( { prLocalId: pr.localId, @@ -129,13 +135,13 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> }); continue; } - // d.plan 省略 → 微流程走默认全集;规则驱动计划的注入点(见 JudgeDecision.plan)。 + // d.plan omitted → micro-flow uses the default full set; the injection point for rule-driven plans (see JudgeDecision.plan). toReview.push({ pr, plan: d.plan }); } - // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让 run-queue 并发尽量被填满。 + // Parallel orchestration of multi-PR reviews: each orchestration awaits its own tool run without blocking the others, filling up run-queue concurrency as much as possible. await Promise.all( toReview.map(async ({ pr, plan }) => { - // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 + // AutoPilot background review has no AbortController, but is still marked "running" — the pure thinking phase also shows on the PR list item. runtime.markRunning(pr.localId); try { const session = await runReviewForPr( @@ -147,7 +153,7 @@ export async function autopilotPass(runtime: OrchestratorRuntime): Promise<void> true, plan, ); - // done:落「评审总结」消息 + 台账(含 verdict)+ 广播(与手动评审一致)。失败 / 暂停不落台账。 + // done: persist the "review summary" message + ledger (with verdict) + broadcast (same as manual review). Failure / pause does not write the ledger. await runtime.recordReviewSummaryMessage(pr, session); } finally { runtime.unmarkRunning(pr.localId); diff --git a/apps/desktop/src/main/services/agent/flows/planning.ts b/apps/desktop/src/main/services/agent/flows/planning.ts index da8ded62..93ac2a56 100644 --- a/apps/desktop/src/main/services/agent/flows/planning.ts +++ b/apps/desktop/src/main/services/agent/flows/planning.ts @@ -7,7 +7,7 @@ import { getMainLanguage } from '../../../i18n/index.js'; import { runPlanning } from '../planning.js'; import type { AgentChat, OrchestratorRuntime } from '../runtime.js'; -/** 自由规划编排(agent:ask):现读现装配上下文 + 注册 AbortController + 标记执行中,跑规划 ReAct。 */ +/** Free planning orchestration (agent:ask): assemble context on demand + register AbortController + mark running, then run the planning ReAct. */ export async function planningFlow( runtime: OrchestratorRuntime, pr: StoredPullRequest, @@ -22,7 +22,7 @@ export async function planningFlow( const ac = new AbortController(); runtime.registerController(pr.localId, ac); runtime.markRunning(pr.localId); - // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 + // Do not log the user input body (avoid leakage / flooding): log only the initiation itself; the input is already persisted to the conversation. logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); try { const session = await runtime.withAgentChat( @@ -45,7 +45,7 @@ export async function planningFlow( } } -/** 对一个 PR 跑自由规划(组装 PlanningDeps + 调 runner):含中途输入 drain、计划持久化、主动记忆落盘。 */ +/** Run free planning for a PR (assemble PlanningDeps + call the runner): includes mid-run input drain, plan persistence, and proactive memory persistence. */ export async function runPlanningForPr( runtime: OrchestratorRuntime, pr: StoredPullRequest, @@ -57,7 +57,7 @@ export async function runPlanningForPr( ): Promise<AgentSession> { const { bootstrap, effectiveAgentDir, logger } = runtime.ctx; const agentCfg = bootstrap.config.agent; - // per-PR 存储路由:已归档(已关闭范围)PR 上的对话 / 计划落归档冷存储,不污染活跃存储。 + // per-PR storage routing: conversation / plan for archived (closed-scope) PRs go to archive cold storage, not polluting active storage. const store = await runtime.ctx.pr.storeForPr(pr.localId); const matchedRules = pickMatchingRules(agentContext.rules, { projectKey: pr.repo.projectKey, @@ -84,13 +84,15 @@ export async function runPlanningForPr( matchedRuleInstructions: combineRuleInstructions(matchedRules), language: getMainLanguage(), maxSteps: agentCfg.max_steps, - // /ask 预算:自由规划里连续 /ask 各为一次 agentic 探索、成本高,按配置「追问数量」封顶(与「自动追问」 - // 开关无关——开关仅约束评审微流程;此处始终生效,遵循配置的追问数量上限)。 + // /ask budget: in free planning each consecutive /ask is one agentic exploration and costly, capped by the + // configured "follow-up ask count" (unrelated to the "auto follow-up" switch — the switch only constrains the + // review micro-flow; this always applies, following the configured follow-up ask cap). maxFollowupAsks: agentCfg.strategy.max_followup_asks, signal, onStep: (sessionId, step) => runtime.emitStep(pr, sessionId, step), - // 中途输入转向:planner 每轮取出排队消息时在此落盘进会话 + 广播刷新(即时显示为用户气泡), - // planner 再把它们并入当轮 progress、据最新指令重排下一步。 + // Mid-run input redirection: each round the planner pulls queued messages and here persists them to the + // conversation + broadcasts a refresh (instantly shown as user bubbles), then the planner merges them into the + // current round's progress and re-plans the next step per the latest instructions. drainPendingInput: async () => { const msgs = runtime.takePending(pr.localId); for (const m of msgs) { @@ -99,14 +101,14 @@ export async function runPlanningForPr( if (msgs.length) runtime.ctx.broadcast('agent:conversationChanged', { prLocalId: pr.localId }); return msgs; }, - // 计划(todo)更新:planner 给出 plan 即持久化进会话 + 广播刷新计划面板;切 PR / 重启经 - // agent:getSession 水合。 + // Plan (todo) update: once the planner provides a plan, persist it to the conversation + broadcast a refresh of + // the plan panel; hydrated via agent:getSession on PR switch / restart. recordPlan: async (todo) => { await updateAgentSession(store, pr.localId, { todo }); runtime.ctx.broadcast('agent:planUpdated', { prLocalId: pr.localId, todo }); }, - // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); - // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 + // Persist the non-private entries the Agent proactively noted to each writable file in the current Agent + // directory (USER/MEMORY/AGENTS); SOUL.md is never written. The next loadAgentContext reads it live (cross-session memory). recordMemory: async (notes) => { const dir = effectiveAgentDir(); for (const kind of ['user', 'memory', 'agents'] as const) { diff --git a/apps/desktop/src/main/services/agent/flows/review.ts b/apps/desktop/src/main/services/agent/flows/review.ts index f2b72bab..1555cbff 100644 --- a/apps/desktop/src/main/services/agent/flows/review.ts +++ b/apps/desktop/src/main/services/agent/flows/review.ts @@ -9,9 +9,10 @@ import type { AgentChat, OrchestratorRuntime } from '../runtime.js'; import { planningFlow } from './planning.js'; /** - * 手动评审编排(agent:run):现读现装配上下文 + 注册 AbortController + 标记执行中,跑评审微流程,收尾把 - * 「评审总结」落多轮对话与台账。评审微流程是固定模板、无法中途转向:跑完后把运行期间排队的用户消息作为 - * 一轮自由规划接续处理(fire-and-forget,本次评审会话照常返回)。 + * Manual review orchestration (agent:run): assemble context on demand + register AbortController + mark running, + * run the review micro-flow, and at the end persist the "review summary" to the conversation and ledger. The review + * micro-flow is a fixed template and cannot be redirected mid-run: after it finishes, the user messages queued during + * the run are handled as a follow-up free-planning round (fire-and-forget; this review session returns as usual). */ export async function reviewFlow( runtime: OrchestratorRuntime, @@ -19,11 +20,11 @@ export async function reviewFlow( ): Promise<AgentSession> { const { getPrAgentBridge, ensureAgentDir, logger } = runtime.ctx; if (!getPrAgentBridge()) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); - // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存;加载前先确保目录已初始化。 + // Assemble the Agent context on demand (SOUL/AGENTS/MEMORY/USER + rules), no cache; ensure the directory is initialized before loading. const agentContext = await loadAgentContext(await ensureAgentDir(), { onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), }); - // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 + // Register the AbortController so the stop button (agent:stop) can immediately abort this review at any thinking / execution phase. const ac = new AbortController(); runtime.registerController(pr.localId, ac); runtime.markRunning(pr.localId); @@ -38,13 +39,13 @@ export async function reviewFlow( { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, 'agent review done', ); - // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 + // The wrap-up summary is added to the conversation (assistant review message) → UI renders the "review summary" card. await runtime.recordReviewSummaryMessage(pr, session); } finally { runtime.clearController(pr.localId); runtime.unmarkRunning(pr.localId); } - // 评审微流程无法中途转向:跑完后把排队的用户消息作为一轮自由规划接续处理(fire-and-forget)。 + // The review micro-flow cannot be redirected mid-run: after it finishes, the queued user messages are handled as a follow-up free-planning round (fire-and-forget). const pending = runtime.takePending(pr.localId); if (pending.length) { void planningFlow(runtime, pr, pending.join('\n\n')).catch((err: unknown) => { @@ -55,8 +56,9 @@ export async function reviewFlow( } /** - * 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。手动评审与 AutoPilot 背景评审共用。 - * 编排派发的 run 走 agent 低优先级泳道;修改类工具按 grants 门控(红线见 buildToolCatalog)。 + * Run the review micro-flow for a PR (shared enqueue queue / persistence / step broadcast). Shared by manual review + * and AutoPilot background review. + * Runs dispatched by the orchestration use the agent low-priority lane; modifying tools are gated by grants (red line see buildToolCatalog). */ export async function runReviewForPr( runtime: OrchestratorRuntime, @@ -65,14 +67,14 @@ export async function runReviewForPr( chat: AgentChat, signal?: AbortSignal, autopilot = false, - /** 评审执行计划(仅 AutoPilot 按规则注入);省略 → 微流程走默认全集。 */ + /** Review execution plan (only injected by AutoPilot per rules); omitted → micro-flow uses the default full set. */ plan?: ReviewPlan, ): Promise<AgentSession> { const agentCfg = runtime.ctx.bootstrap.config.agent; - // per-PR 存储路由:已归档(已关闭范围)PR 补跑评审时,会话 / run / 关闭关系都落归档冷存储。 + // per-PR storage routing: when re-running review on an archived (closed-scope) PR, the conversation / run / closure relations all go to archive cold storage. const store = await runtime.ctx.pr.storeForPr(pr.localId); - // 自动追问关闭:评审微流程跳过 judge + asks(不判读、不条件追问),直接总结——省一次 judge LLM - // 调用与潜在追问开销。覆盖默认计划与 AutoPilot 规则注入计划两种来源。 + // Auto follow-up off: the review micro-flow skips judge + asks (no interpretation, no conditional follow-up ask) and + // summarizes directly — saving one judge LLM call and the potential follow-up ask cost. Covers both sources: the default plan and the AutoPilot rule-injected plan. const effectivePlan: ReviewPlan | undefined = agentCfg.strategy.auto_followup ? plan : { @@ -95,7 +97,7 @@ export async function runReviewForPr( ); return runReview(pr, { stateStore: store, - // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。复评 /ask 携引用上下文 + 前向链。 + // Runs dispatched by the orchestration use the agent low-priority lane: a user clicking /review at any time jumps ahead of them. Re-review /ask carries the referenced context + forward chain. enqueueRun: (p, tool, question, referencedContext, referencedFinding) => runtime.runQueue.enqueuePragentRun( p, @@ -105,7 +107,7 @@ export async function runReviewForPr( referencedContext, referencedFinding, ), - // 复评裁决 replace/drop → 关闭被取代的原 review finding(写 FindingClosure + 广播刷新卡片)。 + // Re-review verdict replace/drop → close the superseded original review finding (write FindingClosure + broadcast a card refresh). closeFinding: async (p, call) => { await addFindingClosure(store, p.localId, call); runtime.ctx.broadcast('findingClosures:changed', { localId: p.localId }); diff --git a/apps/desktop/src/main/services/agent/index.ts b/apps/desktop/src/main/services/agent/index.ts index effdb345..c919e7d7 100644 --- a/apps/desktop/src/main/services/agent/index.ts +++ b/apps/desktop/src/main/services/agent/index.ts @@ -1,5 +1,6 @@ /** - * Agent 编排域:会话 Agent(手动评审 / 自由规划 / AutoPilot)的主进程接线。Orchestrator 为对外能力入口, - * review / planning(微流程与规划的主进程 runner)+ labels(i18n 文案注入)为域内协作件,不外暴露。 + * Agent orchestration domain: main-process wiring for the conversational Agent (manual review / free planning / + * AutoPilot). Orchestrator is the outward capability entry; review / planning (the main-process runners for the + * micro-flow and planning) + labels (i18n text injection) are in-domain collaborators, not exposed externally. */ export { Orchestrator } from './orchestrator.js'; diff --git a/apps/desktop/src/main/services/agent/labels.ts b/apps/desktop/src/main/services/agent/labels.ts index 510ccbda..c96d4b64 100644 --- a/apps/desktop/src/main/services/agent/labels.ts +++ b/apps/desktop/src/main/services/agent/labels.ts @@ -2,12 +2,13 @@ import type { AgentStepLabels } from '@meebox/agent'; import { t } from '../../i18n/index.js'; /** - * 把 agent 步骤展示文案 / 总结骨架 / 中止原因从主进程 i18n 资源(locales/*.json 的 `agent.*`)解析出来, - * 注入纯逻辑的 agent 编排器——agent 内仅留 en-US 兜底,多语言译文统一在 i18n 资源维护。 - * 经会话语言(= getMainLanguage,t() 当前语言)解析,与 UI 一致;步骤文案在生成时落地、随 transcript 持久化。 + * Resolve agent step display text / summary skeleton / abort reason from the main-process i18n resources + * (`agent.*` in locales/*.json) and inject them into the pure-logic agent orchestrator — the agent keeps only the + * en-US fallback, and multilingual translations are maintained uniformly in the i18n resources. + * Resolved via the session language (= getMainLanguage, t()'s current language), consistent with the UI; step text is materialized at generation time and persisted with the transcript. */ -/** 从 i18n 资源构造步骤展示文案(judgeSevere 走 i18next 复数 count)。 */ +/** Build step display text from i18n resources (judgeSevere uses i18next plural count). */ export function buildStepLabels(): AgentStepLabels { return { describeReview: t('agent.steps.describeReview'), @@ -20,7 +21,7 @@ export function buildStepLabels(): AgentStepLabels { }; } -/** 从 i18n 资源构造评审总结三段骨架标题(概述 / 关键发现 / 建议)。 */ +/** Build the three-section review summary skeleton titles from i18n resources (overview / key findings / suggestions). */ export function buildSummarySections(): [string, string, string] { return [ t('agent.summarySections.overview'), @@ -30,8 +31,9 @@ export function buildSummarySections(): [string, string, string] { } /** - * 把 agent 返回的中止原因稳定 code 映射为本地化文案:'aborted' / 'max_steps' 经 i18n 资源;其它(failed - * 分支的具体错误信息)原样返回。落盘前调用,使 session.terminationReason 即为目标语言文本(渲染层逐字显示)。 + * Map the stable abort-reason code returned by the agent to localized text: 'aborted' / 'max_steps' via the i18n + * resources; others (the specific error message from the failed branch) are returned as-is. Called before persisting so + * that session.terminationReason is already target-language text (the renderer displays it verbatim). */ export function mapTerminationReason(code: string | undefined): string | undefined { if (code === 'aborted') return t('agent.termination.aborted'); diff --git a/apps/desktop/src/main/services/agent/orchestrator.ts b/apps/desktop/src/main/services/agent/orchestrator.ts index 79458112..bb0438ed 100644 --- a/apps/desktop/src/main/services/agent/orchestrator.ts +++ b/apps/desktop/src/main/services/agent/orchestrator.ts @@ -22,20 +22,23 @@ import { reviewFlow } from './flows/review.js'; import type { AgentChat, OrchestratorRuntime } from './runtime.js'; /** - * Agent 编排服务(有状态协调器):手动评审(agent:run)、自由规划(agent:ask)、AutoPilot 后台预评审, - * 以及随 poll tick 清理已消失 PR 的在跑操作。运行态(每 PR 的 AbortController、「执行中」集合、AutoPilot - * busy 锁、中途输入队列)是实例可变状态,故以 class 封装并实现 OrchestratorRuntime——把状态访问 + 共享 - * helper(withAgentChat / 收尾落地 / 步骤广播等)暴露给按「一任务一文件」拆分的各 flow(见 ./flows)。 + * Agent orchestration service (stateful coordinator): manual review (agent:run), free-form planning + * (agent:ask), AutoPilot background pre-review, plus cleanup of in-flight ops on PRs that have vanished + * on each poll tick. Runtime state (per-PR AbortController, "running" set, AutoPilot busy lock, mid-run + * input queue) is instance-mutable state, so it is wrapped in a class implementing OrchestratorRuntime—— + * exposing state access + shared helpers (withAgentChat / summary landing / step broadcast, etc.) to the + * flows split one-task-per-file (see ./flows). */ export class Orchestrator implements OrchestratorRuntime { - // 编排 Agent 每 PR 至多一个在跑,AbortController 供 agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 + // At most one orchestrator agent runs per PR; AbortController lets agent:stop abort instantly——can stop at any stage of thinking / tool execution. private readonly agentControllers = new Map<string, AbortController>(); - // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。手动 run/ask 与 - // AutoPilot 后台评审一并计入,让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 + // Set of PRs whose orchestrator agent is running (thinking or dispatching tools), broadcast "running" to + // renderer. Manual run/ask and AutoPilot background review both count, so PR list items show the running + // mark even in the pure-thinking stage (no active tool run). private readonly runningAgentPrs = new Set<string>(); - // Agent 编排层全局单并发:一次只跑一遍 AutoPilot pass(busy 锁),防止上一遍未完又叠跑。 + // Global single-concurrency at the agent orchestration layer: run at most one AutoPilot pass at a time (busy lock), preventing a new pass stacking on an unfinished one. private autopilotBusy = false; - // 中途输入转向:每 PR 一个待处理用户消息队列(运行中追加 → 入队,下一周期 drain 注入)。 + // Mid-run input redirect: one pending user-message queue per PR (appended while running → enqueued, injected via drain next cycle). private readonly pendingInputByPr = new Map<string, string[]>(); constructor( @@ -43,14 +46,14 @@ export class Orchestrator implements OrchestratorRuntime { readonly runQueue: RunQueue, ) {} - // ── 公共 API(IPC 入口;委托给 ./flows 下按任务拆分的各 flow)── + // ── Public API (IPC entry points; delegates to the per-task flows under ./flows) ── - /** 对指定 PR 跑评审微流程(agent:run)。 */ + /** Run the review micro-flow on the given PR (agent:run). */ runReview(pr: StoredPullRequest): Promise<AgentSession> { return reviewFlow(this, pr); } - /** 对指定 PR 跑自由规划 Agent(agent:ask)。 */ + /** Run the free-form planning agent on the given PR (agent:ask). */ runPlanning( pr: StoredPullRequest, question: string, @@ -60,8 +63,9 @@ export class Orchestrator implements OrchestratorRuntime { } /** - * 运行期间追加用户消息(agent:enqueueMessage):有 Agent 在跑 → 入队,下一主 Agent 周期 drain 注入 - * (queued=true);无在跑 → 直接起一轮自由规划兜底(queued=false,fire-and-forget,不丢消息)。 + * Append a user message during a run (agent:enqueueMessage): an agent is running → enqueue, injected via + * drain on the next main-agent cycle (queued=true); none running → directly start a free-form planning + * fallback (queued=false, fire-and-forget, no message dropped). */ enqueueMessage(pr: StoredPullRequest, message: string): { queued: boolean } { const text = message.trim(); @@ -76,14 +80,14 @@ export class Orchestrator implements OrchestratorRuntime { ); return { queued: true }; } - // 竞态兜底:检查到没有在跑 → 直接起一轮自由规划(UI 经 step / conversation 事件更新)。 + // Race fallback: found none running → directly start a free-form planning round (UI updates via step / conversation events). void this.runPlanning(pr, text).catch((err: unknown) => { this.ctx.logger.warn({ err, prLocalId: pr.localId }, 'enqueueMessage fallback planning failed'); }); return { queued: false }; } - /** 暂停某 PR 的 Agent 运行(agent:stop):abort 其 AbortController。 */ + /** Pause a PR's agent run (agent:stop): abort its AbortController. */ stop(localId: string): { ok: boolean } { const ac = this.agentControllers.get(localId); if (!ac) return { ok: false }; @@ -92,23 +96,27 @@ export class Orchestrator implements OrchestratorRuntime { } /** - * poll tick:满足开关 + 未在跑 + bridge 就绪时跑一遍 AutoPilot pass(准入门控 + 台账去重在 autopilotPass - * 内)。busy 锁防止上一遍未完又叠跑。见 docs/arch/02-agent/03-autopilot.md「AutoPilot」。 + * poll tick: when switch on + not running + bridge ready, run one AutoPilot pass (admission gating + + * ledger dedup live inside autopilotPass). The busy lock prevents a new pass stacking on an unfinished + * one. See docs/arch/02-agent/03-autopilot.md "AutoPilot". */ runAutopilotIfDue(): void { const ap = this.ctx.bootstrap.config.agent.autopilot; if (!ap.enabled || this.autopilotBusy || !this.ctx.getPrAgentBridge()) return; - // 准入通过 → fire-and-forget 异步 pass(poll tick 不阻塞);busy 锁在 autopilotPass 内成对管理。 + // Admission passed → fire-and-forget async pass (poll tick not blocked); busy lock managed in pairs inside autopilotPass. void autopilotPass(this); } /** - * poll tick 后调用:把已被 purge(彻底从索引移除)的 PR 上仍在执行的 agent 操作一律终止——PR 都没了, - * 继续评审无意义且浪费 LLM / 占用 worktree。 + * Called after a poll tick: terminate any agent ops still in flight on PRs that have been purged (fully + * removed from the index)——the PR is gone, so continuing the review is pointless and wastes LLM / + * occupies a worktree. * - * 「在场」判定用**索引全集**(活跃 + 归档,readPrIndex 覆盖二者)、而非仅活跃集——否则对**已归档**(已关闭范围) - * PR 补跑 AI 评审时,下轮 poll 会把它误判为「已移除」而中途掐断(归档 PR 仍在索引、只是 archivedAt 非空)。 - * 仅当条目彻底不在索引里(grace 期满硬清)才终止。 + * "Presence" is judged against the **full index** (active + archived, readPrIndex covers both), not just + * the active set——otherwise, when re-running AI review on an **archived** (closed-scope) PR, the next poll + * would mistake it for "removed" and cut it off midway (an archived PR is still in the index, just with a + * non-empty archivedAt). Terminate only when an entry is entirely absent from the index (hard-cleaned + * after the grace period). */ async terminateAgentsForGonePrs(): Promise<void> { const { stateStore, logger } = this.ctx; @@ -126,7 +134,7 @@ export class Orchestrator implements OrchestratorRuntime { } } - // ── OrchestratorRuntime 实现(供 ./flows 复用的状态访问 + 共享 helper)── + // ── OrchestratorRuntime implementation (state access + shared helpers reused by ./flows) ── registerController(localId: string, ac: AbortController): void { this.agentControllers.set(localId, ac); @@ -149,7 +157,7 @@ export class Orchestrator implements OrchestratorRuntime { this.autopilotBusy = busy; } - /** 取出并清空某 PR 的待处理用户消息队列。 */ + /** Take and clear a PR's pending user-message queue. */ takePending(localId: string): string[] { const q = this.pendingInputByPr.get(localId); if (!q || q.length === 0) return []; @@ -158,8 +166,10 @@ export class Orchestrator implements OrchestratorRuntime { } /** - * 每个编排步骤的统一出口:① 后台日志(kind / tool / 用时,便于排障与离线回看;thought 与 result 不入 - * 日志避免刷屏 + 泄漏,完整步骤已落 transcript.json);② 广播给渲染层(agent:stepProgress)做过程化展示。 + * Unified exit for every orchestration step: ① background log (kind / tool / elapsed, for troubleshooting + * and offline replay; thought and result are not logged to avoid flooding + leakage, the full step is + * already persisted to transcript.json); ② broadcast to the renderer (agent:stepProgress) for progressive + * display. */ emitStep(pr: StoredPullRequest, sessionId: string, step: AgentStep): void { this.ctx.logger.info( @@ -175,15 +185,17 @@ export class Orchestrator implements OrchestratorRuntime { this.ctx.broadcast('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); } - /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 - * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ + /** Set up LLM env + temp chat cwd + chat function, run fn, then clean up the temp dir on finish. + * signal: on user stop, abort → kill the running LLM chat subprocess, so the thinking stage can also abort immediately (no need to wait for the model to return). */ async withAgentChat<T>(fn: (chat: AgentChat) => Promise<T>, signal?: AbortSignal): Promise<T> { const { getPrAgentBridge, bootstrap } = this.ctx; const bridge = getPrAgentBridge(); if (!bridge) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); - // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。代理 env 先铺底(非 - // pr-agent 范畴);LLM 凭据/模型 + 编排 chat 专属档(响应语言 / 低推理档 / 提示缓存)由 buildChatEnv 按 - // 意图组装。低档与缓存仅作用于本 chat spawn:pr-agent 工具 run(/review 等)的 env 不含 → 仍满档推理。 + // Reuse the same LLM env as a pr-agent run (provider credentials / model / proxy / response language). + // Proxy env is laid down first (outside pr-agent scope); LLM credentials/model + orchestration-chat + // specific settings (response language / low-reasoning tier / prompt cache) are assembled by buildChatEnv + // per intent. The low tier and cache apply only to this chat spawn: the env of pr-agent tool runs + // (/review etc.) does not include them → still full-tier reasoning. const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); const env: Record<string, string> = { ...buildProxyEnv(bootstrap.config.proxy), @@ -194,7 +206,7 @@ export class Orchestrator implements OrchestratorRuntime { maxModelTokens: bootstrap.config.llm.context_tokens, }), }; - // chat 子进程落到中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 + // The chat subprocess runs in a neutral temp dir (in cli mode, avoids picking up the reviewed repo's CLAUDE.md). const chatCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'meebox-agent-chat-')); try { const chat: AgentChat = async ({ system, user, maxOutputTokens }) => { @@ -210,13 +222,15 @@ export class Orchestrator implements OrchestratorRuntime { } /** - * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— - * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 - * updatedAt,给 PR 列表建议徽标 + AutoPilot 同版本去重)。失败 / 用户停止(paused)不落,便于重试。 + * Unified landing for review summary finish (shared by manual one-click review and AutoPilot background + * review): only when finished successfully (done) and a summary exists—— + * ① append one assistant review message (UI renders the "review summary" card); ② write the review ledger + * (recommendation + current updatedAt, feeding the PR list suggestion badge + AutoPilot same-version + * dedup). On failure / user stop (paused) nothing is landed, for easy retry. */ async recordReviewSummaryMessage(pr: StoredPullRequest, session: AgentSession): Promise<void> { if (session.status !== 'done' || !session.summary) return; - // per-PR 存储路由:已归档(已关闭范围)PR 补跑评审的总结消息 / 台账落归档冷存储。 + // per-PR storage routing: for a re-run review on an archived (closed-scope) PR, the summary message / ledger lands in archived cold storage. const store = await this.ctx.pr.storeForPr(pr.localId); await appendAgentMessage(store, pr.localId, { role: 'assistant', @@ -230,17 +244,17 @@ export class Orchestrator implements OrchestratorRuntime { recommendation: session.recommendation?.verdict, at: new Date().toISOString(), }); - // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 + // Notify the renderer: if this PR is open, reloading the conversation makes the background review's "review summary" card appear instantly (manual review reloads itself, so the duplicate is harmless). this.ctx.broadcast('agent:conversationChanged', { prLocalId: pr.localId }); } - // ── 私有 helper ── + // ── Private helpers ── private broadcastAgentRunning(): void { this.ctx.broadcast('agent:runningChanged', { prLocalIds: [...this.runningAgentPrs] }); } - /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ + /** Terminate all agent ops on a PR: abort orchestration (agent:run/ask) + cancel the tool runs it dispatched. */ private terminateAgentForPr(localId: string): void { this.agentControllers.get(localId)?.abort(); this.runQueue.cancelRunsForPr(localId); diff --git a/apps/desktop/src/main/services/agent/planning.ts b/apps/desktop/src/main/services/agent/planning.ts index 1a241dd4..ef420bc8 100644 --- a/apps/desktop/src/main/services/agent/planning.ts +++ b/apps/desktop/src/main/services/agent/planning.ts @@ -26,9 +26,11 @@ import type { StateStore } from '@meebox/state-store'; import { buildStepLabels, buildSummarySections, mapTerminationReason } from './labels.js'; /** - * 把自由规划编排器(runPlanningAgent)接到主进程:自然语言入口的「对话即委派」。 - * runTool 把读类工具映射到既有 pr-agent 运行队列;红线由编排器经 assertToolAllowed 把关。 - * signal 支持用户暂停(Stop);暂停 → 会话置 paused 保态。 + * Wires the free-form planning orchestrator (runPlanningAgent) to the main process: "conversation as + * delegation" from a natural-language entry point. + * runTool maps read tools onto the existing pr-agent run queue; red lines are gated by the orchestrator + * via assertToolAllowed. signal supports user pause (Stop); on pause → the session is set to paused to + * preserve state. */ const STDOUT_LOG_SEP = '\n\n---\n[pr-agent stdout log]\n'; @@ -36,8 +38,10 @@ function reviewRunText(run: ReviewRun): string { return (run.stdout ?? '').split(STDOUT_LOG_SEP)[0]?.trim() ?? ''; } -// 会话压缩:存储超阈值时把较早消息摘要成一条 digest、仅留最近若干条原文,控制存储与后续注入规模 -// (约定会话上下文不超 LLM 半窗:先压缩/裁剪再注入)。阈值高于注入预算,超出才触发、不频繁。 +// Conversation compaction: when storage exceeds the threshold, summarize earlier messages into a single +// digest and keep only the most recent few verbatim, controlling storage and later injection size (the +// convention is conversation context stays under half the LLM window: compact/trim before injecting). The +// threshold is above the injection budget, so it triggers only when exceeded, not frequently. const CONVO_COMPACT_THRESHOLD_CHARS = 80000; const CONVO_KEEP_RECENT = 6; const COMPACT_SYSTEM = @@ -45,7 +49,7 @@ const COMPACT_SYSTEM = 'concise digest. Preserve key facts, decisions, the user’s stated preferences / 称呼, and any open ' + 'threads. Reply in the same language as the conversation. Output plain text only, no preamble.'; -/** 存储超阈值时,把较早消息摘要为一条 digest 替换之;未超阈值 / 失败则原样保留。 */ +/** When storage exceeds the threshold, summarize earlier messages into a single digest and replace them; below the threshold / on failure, keep them as-is. */ async function maybeCompactConversation( stateStore: PlanningDeps['stateStore'], chat: PlanningDeps['chat'], @@ -66,12 +70,12 @@ async function maybeCompactConversation( const digest: AgentMessage = { role: 'assistant', content: `(早期对话摘要)\n${text.trim()}`, - // 用最早消息的时间戳,保证 digest 仍排在时间线最前。 + // Use the earliest message's timestamp so the digest still sorts first on the timeline. at: older[0]?.at ?? now().toISOString(), }; await writeAgentConversation(stateStore, prLocalId, [digest, ...recent]); } catch { - /* 压缩失败:保留原对话,下次再试(读时仍有预算裁剪兜底) */ + /* Compaction failed: keep the original conversation, retry next time (read-time budget trimming still provides a fallback) */ } } @@ -81,24 +85,26 @@ export interface PlanningDeps { chat: (input: { system: string; user: string }) => Promise<PlanningToolResult>; agentContext: AgentContext; toolCatalog: ToolCatalogEntry[]; - /** 命中规则的已拼接正文(多条经 combineRuleInstructions 拼成);无命中传空 / null。 */ + /** Concatenated body of matched rules (multiple joined via combineRuleInstructions); pass empty / null when no match. */ matchedRuleInstructions?: string | null; language: string; maxSteps: number; - /** 本会话 /ask 数量上限(配置「追问数量」max_followup_asks):连续 agentic 探索成本高,按此封顶。 */ + /** /ask count cap for this session (config "follow-up count" max_followup_asks): continuous agentic exploration is costly, so cap it here. */ maxFollowupAsks: number; - /** 用户选中的代码引用(隐式上下文):注入规划 LLM 当轮提示,不进持久化用户消息。 */ + /** User-selected code reference (implicit context): injected into the planning LLM's current-round prompt, not stored as a user message. */ referencedContext?: string; signal?: AbortSignal; onStep?: (sessionId: string, step: AgentStep) => void; - /** 持久化 Agent 主动记下的非隐私条目到各可写上下文文件(USER/MEMORY/AGENTS)。 */ + /** Persist non-private items the agent proactively noted into the writable context files (USER/MEMORY/AGENTS). */ recordMemory?: (notes: AgentMemoryNotes) => Promise<void>; /** - * 取出运行期间排队的用户新消息(中途输入转向):每轮顶部由 planner 调用。实现方(orchestrator) - * 负责持久化进会话并广播刷新;此处直接透传给 planner,由其并入当轮 progress。 + * Take the new user messages queued during the run (mid-run input redirect): called by the planner at the + * top of each round. The implementer (orchestrator) is responsible for persisting them into the + * conversation and broadcasting a refresh; here they are passed straight through to the planner, which + * merges them into the current round's progress. */ drainPendingInput?: () => Promise<string[]> | string[]; - /** 计划(todo)更新回调:planner 给出 / 更新 plan 时调用,由 orchestrator 持久化 + 广播。 */ + /** Plan (todo) update callback: called when the planner produces / updates a plan, persisted + broadcast by the orchestrator. */ recordPlan?: (todo: AgentTodoItem[]) => void | Promise<void>; } @@ -108,12 +114,12 @@ export async function runPlanning( deps: PlanningDeps, now: () => Date = () => new Date(), ): Promise<AgentSession> { - // 多轮对话:先读既往消息(注入规划上下文),再把本轮用户输入追加为一条消息(持久化)。 + // Multi-turn conversation: first read prior messages (inject planning context), then append this round's user input as a message (persisted). const history = await getAgentConversation(deps.stateStore, pr.localId); await appendAgentMessage( deps.stateStore, pr.localId, - // 带 Diff 选区引用时一并持久化,供 UI 在气泡下方折叠展示「引用的代码」。 + // When a Diff selection reference is present, persist it alongside, for the UI to show the "referenced code" collapsed below the bubble. { role: 'user', content: userRequest, referencedContext: deps.referencedContext }, now, ); @@ -130,10 +136,10 @@ export async function runPlanning( chat: deps.chat, runTool: async ({ tool, question }) => { const bare = tool.replace(/^\//, ''); - if (!READ_RUN_TOOL_IDS.has(bare)) throw new Error(`不支持的工具:${tool}`); + if (!READ_RUN_TOOL_IDS.has(bare)) throw new Error(`Unsupported tool: ${tool}`); const run = await deps.enqueueRun(pr, bare as ReviewRunTool, question); if (run.status !== 'succeeded') { - throw new Error(`pr-agent ${bare} 未成功:${run.errorMessage ?? run.status}`); + throw new Error(`pr-agent ${bare} did not succeed: ${run.errorMessage ?? run.status}`); } return { text: reviewRunText(run), usage: run.tokenUsage }; }, @@ -161,7 +167,7 @@ export async function runPlanning( }, ); - // 把 Agent 收尾回答追加为一条助手消息(评审类带 recommendation);暂停 / 空回答不记。 + // Append the agent's closing answer as an assistant message (review-type carries recommendation); paused / empty answers are not recorded. if (result.finalText && result.terminationReason !== 'aborted') { await appendAgentMessage( deps.stateStore, @@ -171,13 +177,13 @@ export async function runPlanning( ); } - // 持久化本轮主动记忆(非隐私)到各可写文件;失败不阻断会话收尾。 + // Persist this round's proactive memories (non-private) to the writable files; failure does not block session finish. const mem = result.memories; if (deps.recordMemory && (mem.user.length || mem.memory.length || mem.agents.length)) { await deps.recordMemory(mem); } - // 会话超阈值时压缩较早消息(best-effort,不阻断收尾)。 + // When the conversation exceeds the threshold, compact earlier messages (best-effort, does not block finish). await maybeCompactConversation(deps.stateStore, deps.chat, pr.localId, now); return ( @@ -190,7 +196,7 @@ export async function runPlanning( })) ?? session ); } catch (err) { - // 用户停止(abort 杀掉在跑的 chat / 工具子进程 → 抛错)→ 干净的 paused 收尾,不当失败报错。 + // User stop (abort kills the running chat / tool subprocess → throws) → clean paused finish, not reported as a failure. const aborted = deps.signal?.aborted || (err instanceof Error && err.message === 'aborted'); return ( (await updateAgentSession(deps.stateStore, pr.localId, { diff --git a/apps/desktop/src/main/services/agent/review.ts b/apps/desktop/src/main/services/agent/review.ts index def0fa23..c8370e3c 100644 --- a/apps/desktop/src/main/services/agent/review.ts +++ b/apps/desktop/src/main/services/agent/review.ts @@ -14,23 +14,23 @@ import type { StateStore } from '@meebox/state-store'; import { buildStepLabels, buildSummarySections, mapTerminationReason } from './labels.js'; /** - * 把纯逻辑的 `runReviewMicroflow` 接到主进程能力上(见 docs/arch/02-agent/01-agent.md - * 「AutoPilot」有界微流程): - * - runTool:经既有 pr-agent 运行队列跑 describe/review/ask,取产物文本回喂; - * - chat:经嵌入式运行时的独立 LLM 通道做受限判断 / 总结; - * - 持久化 + 步骤流式:startAgentSession / appendAgentStep / updateAgentSession + onStep 广播。 + * Wires the pure-logic `runReviewMicroflow` onto main-process capabilities (see + * docs/arch/02-agent/01-agent.md "AutoPilot" bounded micro-flow): + * - runTool: run describe/review/ask via the existing pr-agent run queue, take the artifact text and feed it back; + * - chat: make constrained judgments / summaries via the embedded runtime's independent LLM channel; + * - persistence + step streaming: startAgentSession / appendAgentStep / updateAgentSession + onStep broadcast. */ const STDOUT_LOG_SEP = '\n\n---\n[pr-agent stdout log]\n'; -/** 取一次 run 的「LLM 真实产出」(剥掉 ipc 拼在后面的 pr-agent stdout 日志段)。 */ +/** Take a run's "real LLM output" (strip the pr-agent stdout log segment ipc appended at the end). */ function reviewRunText(run: ReviewRun): string { return (run.stdout ?? '').split(STDOUT_LOG_SEP)[0]?.trim() ?? ''; } export interface ReviewDeps { stateStore: StateStore; - /** 入队一个 pr-agent run,resolve 完成的 ReviewRun(与用户手动 run 共用队列)。复评 /ask 携引用上下文 + 前向链。 */ + /** Enqueue a pr-agent run, resolving the completed ReviewRun (shares the queue with user manual runs). Re-review /ask carries reference context + forward chain. */ enqueueRun: ( pr: StoredPullRequest, tool: ReviewRunTool, @@ -38,48 +38,49 @@ export interface ReviewDeps { referencedContext?: string, referencedFinding?: ReviewRun['referencedFinding'], ) => Promise<ReviewRun>; - /** 复评裁决 replace/drop → 关闭被取代的原 review finding(写 FindingClosure + 广播)。缺省 = 不关。 */ + /** Re-review verdict replace/drop → close the superseded original review finding (write FindingClosure + broadcast). Default = don't close. */ closeFinding?: ( pr: StoredPullRequest, call: { runId: string; findingId: string; byAskRunId: string; verdict: AskVerdict }, ) => Promise<void>; - /** 经独立 LLM 通道做一次受限对话(判严重性 / 出总结)。 */ + /** Run one constrained conversation via the independent LLM channel (judge severity / produce summary). */ chat: (input: { system: string; user: string }) => Promise<{ text: string; usage?: TokenUsage }>; agentContext: AgentContext; - /** 命中规则的已拼接正文(多条经 combineRuleInstructions 拼成);无命中传空 / null。 */ + /** Concatenated body of matched rules (multiple joined via combineRuleInstructions); pass empty / null when no match. */ matchedRuleInstructions?: string | null; language: string; - /** 工具目录(含修改红线标注);注入编排器系统上下文。 */ + /** Tool catalog (with modification red-line annotations); injected into the orchestrator's system context. */ toolCatalog?: ToolCatalogEntry[]; maxFollowupAsks: number; summaryMaxChars: number; - /** 评审执行计划(步骤序列);省略 / 非法时微流程回落默认全集。仅 AutoPilot 按规则注入,手动评审省略。 */ + /** Review execution plan (step sequence); when omitted / invalid the micro-flow falls back to the default full set. Injected by rule only for AutoPilot, omitted for manual review. */ plan?: ReviewPlan; - /** 步骤流式回调(广播给渲染层)。 */ + /** Step streaming callback (broadcast to the renderer). */ onStep?: (sessionId: string, step: AgentStep) => void; - /** 用户停止:透传给微流程,思考 / 执行任意阶段都能立即中止(停止按钮 → agent:stop)。 */ + /** User stop: passed through to the micro-flow, can abort immediately at any stage of thinking / execution (stop button → agent:stop). */ signal?: AbortSignal; - /** 是否 AutoPilot 后台派发:标到本次评审的**首步**上,UI 据此在步骤行打机器人 chip。 */ + /** Whether this is an AutoPilot background dispatch: tagged onto this review's **first step**, so the UI marks a robot chip on the step row. */ autopilot?: boolean; } /** - * 对一个 PR 跑评审微流程并落盘会话。返回收尾后的 AgentSession(成功 done / 失败 failed)。 - * 微流程内部工具失败会抛错,这里兜成 failed 会话而非向上抛(背景自动化不该崩主流程)。 + * Run the review micro-flow on a PR and persist the session. Returns the finished AgentSession (done on + * success / failed on failure). Tool failures inside the micro-flow throw; here they are caught into a + * failed session rather than re-thrown (background automation shouldn't crash the main flow). */ export async function runReview( pr: StoredPullRequest, deps: ReviewDeps, now: () => Date = () => new Date(), ): Promise<AgentSession> { - // 步数上限按微流程模板推导:describe + review + ≤N 追问 + 总结(+判定余量)。 + // The step cap is derived from the micro-flow template: describe + review + ≤N follow-up asks + summary (+ judgment margin). const session = await startAgentSession( deps.stateStore, { prLocalId: pr.localId, maxSteps: 3 + deps.maxFollowupAsks + 1 }, now, ); - // AutoPilot 触发时,机器人标记只打在本次评审的**首步**上(首步即「生成 PR 描述与审查发现」)。 + // On AutoPilot trigger, the robot mark is placed only on this review's **first step** (the first step being "generate PR description and review findings"). let firstStep = true; try { const result = await runReviewMicroflow( @@ -93,9 +94,9 @@ export async function runReview( referencedFinding, ); if (run.status !== 'succeeded') { - throw new Error(`pr-agent ${tool} 未成功:${run.errorMessage ?? run.status}`); + throw new Error(`pr-agent ${tool} did not succeed: ${run.errorMessage ?? run.status}`); } - // 回带 runId / findings / askVerdict:供 judge 按 id 点名 finding、asks 复评关联与自动关闭。 + // Carry back runId / findings / askVerdict: lets the judge name findings by id, and links asks re-review with auto-close. return { text: reviewRunText(run), usage: run.tokenUsage, @@ -137,7 +138,7 @@ export async function runReview( })) ?? session ); } catch (err) { - // 用户停止(abort)→ 干净的 paused 收尾,不当失败报错;其余异常仍记为 failed。 + // User stop (abort) → clean paused finish, not reported as a failure; other exceptions are still recorded as failed. const aborted = deps.signal?.aborted || (err instanceof Error && err.message === 'aborted'); return ( (await updateAgentSession(deps.stateStore, pr.localId, { diff --git a/apps/desktop/src/main/services/agent/runtime.ts b/apps/desktop/src/main/services/agent/runtime.ts index 44fbefec..ae926a4f 100644 --- a/apps/desktop/src/main/services/agent/runtime.ts +++ b/apps/desktop/src/main/services/agent/runtime.ts @@ -2,38 +2,40 @@ import type { AgentSession, AgentStep, StoredPullRequest, TokenUsage } from '@me import type { ServiceContext } from '../context.js'; import type { RunQueue } from '../pr-agent/index.js'; -/** 共享 chat 通道:system + user → 文本 + usage。agent:run 评审与 AutoPilot 都用。 */ +/** Shared chat channel: system + user → text + usage. Used by both agent:run review and AutoPilot. */ export type AgentChat = (input: { system: string; user: string; - /** 输出 token 上限(轻量路由判读封顶用,见 ChatRunOptions.maxOutputTokens)。 */ + /** Output token cap (for capping lightweight routing reads, see ChatRunOptions.maxOutputTokens). */ maxOutputTokens?: number; }) => Promise<{ text: string; usage?: TokenUsage }>; /** - * 编排运行时:有状态协调器(Orchestrator)暴露给各 flow(review / planning / autopilot)的状态访问 + - * 共享 helper 面。flow 以自由函数形式按「一任务一文件」拆分,经此 runtime 复用协调器的运行态与公共能力, - * 而不各自持有可变状态。Orchestrator 实现本接口、把 `this` 作为 runtime 传入各 flow。 + * Orchestration runtime: the state-access + shared-helper surface the stateful coordinator (Orchestrator) + * exposes to the flows (review / planning / autopilot). Flows are split one-task-per-file as free + * functions, reusing the coordinator's runtime state and common capabilities via this runtime rather than + * each holding mutable state. Orchestrator implements this interface and passes `this` as the runtime into + * each flow. */ export interface OrchestratorRuntime { readonly ctx: ServiceContext; readonly runQueue: RunQueue; - /** 注册某 PR 的 AbortController(停止按钮 agent:stop 用)。 */ + /** Register a PR's AbortController (used by the stop button agent:stop). */ registerController(localId: string, ac: AbortController): void; - /** 清除某 PR 的 AbortController(收尾)。 */ + /** Clear a PR's AbortController (on finish). */ clearController(localId: string): void; - /** 标记某 PR「执行中」并广播(纯思考阶段也显示)。 */ + /** Mark a PR "running" and broadcast (shown even in the pure-thinking stage). */ markRunning(localId: string): void; - /** 取消某 PR「执行中」标记并广播。 */ + /** Clear a PR's "running" mark and broadcast. */ unmarkRunning(localId: string): void; - /** 步骤统一出口:后台日志 + agent:stepProgress 广播。 */ + /** Unified step exit: background log + agent:stepProgress broadcast. */ emitStep(pr: StoredPullRequest, sessionId: string, step: AgentStep): void; - /** 取出并清空某 PR 的待处理用户消息(中途输入转向)。 */ + /** Take and clear a PR's pending user messages (mid-run input redirect). */ takePending(localId: string): string[]; - /** 设置 LLM env + 临时 chat cwd + chat 函数后运行 fn,收尾清理临时目录。 */ + /** Set up LLM env + temp chat cwd + chat function, run fn, then clean up the temp dir on finish. */ withAgentChat<T>(fn: (chat: AgentChat) => Promise<T>, signal?: AbortSignal): Promise<T>; - /** 评审收尾统一落地:成功且有总结时追加 assistant 总结消息 + 写台账 + 广播会话变更。 */ + /** Unified review summary landing: on success with a summary, append the assistant summary message + write the ledger + broadcast the conversation change. */ recordReviewSummaryMessage(pr: StoredPullRequest, session: AgentSession): Promise<void>; - /** AutoPilot 单并发 busy 锁置位 / 复位。 */ + /** Set / clear the AutoPilot single-concurrency busy lock. */ setAutopilotBusy(busy: boolean): void; } diff --git a/apps/desktop/src/main/services/api-server/compat.ts b/apps/desktop/src/main/services/api-server/compat.ts index 25c91428..dc020fb0 100644 --- a/apps/desktop/src/main/services/api-server/compat.ts +++ b/apps/desktop/src/main/services/api-server/compat.ts @@ -1,35 +1,35 @@ import { coerce as semverCoerce, lt as semverLt, valid as semverValid } from 'semver'; /** - * CLI ↔ server 兼容性门控(见 docs/arch/04-integration/01-service-api.md)。 + * CLI ↔ server compatibility gating (see docs/arch/04-integration/01-service-api.md). * - * CLI 每个请求带上自身版本头,服务端据集中管理的**最低可兼容版本**统一拦截过旧的 CLI——对所有 API 调用 - * 一视同仁,不做按端点的差异化兼容。默认宽松:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 - * `dev` 构建)均放行,保证既有 CLI 默认可用;仅当版本头**可解析且低于下限**时才门控。 + * The CLI carries its own version header on every request, and the server uniformly blocks too-old CLIs per a centrally managed **minimum compatible version** — treating all API calls + * alike, without per-endpoint differentiated compatibility. Lenient by default: a missing version header (old CLI / non-CLI client) or an unparseable version (e.g. local + * `dev` builds) is let through, ensuring existing CLIs work by default; gating applies only when the version header is **parseable and below the lower bound**. * - * **按版本线(major.minor.patch)比对,忽略预发布后缀**:CLI 与 app 同源发版,预发布构建(如 - * `0.9.0-alpha.1`)与其正式版属同一版本线、共享同一线协议。semver 里预发布**低于**其正式版 - * (`0.9.0-alpha.1` < `0.9.0`),若直接比对会把与下限同线的预发布 CLI 误判为过旧(下限 `0.9.0` 时连 - * `0.9.0-alpha.1` 都被拦)。故先把版本 coerce 到 `major.minor.patch` 再比——下限是版本线粒度的 - * 破坏性变更闸门,不区分同线内的预发布序号。 + * **Compare by version line (major.minor.patch), ignoring the prerelease suffix**: the CLI and app ship from the same source, and a prerelease build (e.g. + * `0.9.0-alpha.1`) belongs to the same version line as its release and shares the same line protocol. In semver a prerelease is **lower than** its release + * (`0.9.0-alpha.1` < `0.9.0`), so a direct comparison would misjudge a prerelease CLI on the same line as the lower bound as too old (with lower bound `0.9.0`, even + * `0.9.0-alpha.1` is blocked). So coerce the version to `major.minor.patch` before comparing — the lower bound is a version-line-grained + * breaking-change gate, not distinguishing prerelease ordinals within the same line. */ -/** CLI 在此请求头声明自身版本(Node 会小写化头名)。与 CLI 端手写常量对齐(无代码级共享)。 */ +/** The CLI declares its own version in this request header (Node lowercases header names). Aligned with the CLI-side hand-written constant (no code-level sharing). */ export const CLI_VERSION_HEADER = 'x-meebox-cli-version'; /** - * 服务端可兼容的最低 CLI 版本。破坏性线协议变更时上调此值,即门控掉更旧的 CLI。 - * 取当前 CLI 首发版本为下限(此前无更旧的已发布 CLI),默认不拦截任何在用版本。 + * The minimum CLI version the server is compatible with. Raise this on a breaking line protocol change to gate out older CLIs. + * Set to the current CLI's first-release version as the lower bound (no older published CLI exists before it), blocking no in-use version by default. */ export const MIN_CLI_VERSION = '0.9.0'; -/** 请求携带的 CLI 版本是否过旧(应拦截)。缺头 / 不可解析 → false(放行)。 */ +/** Whether the request's carried CLI version is too old (should be blocked). Missing header / unparseable → false (let through). */ export function isClientTooOld(rawHeader: string | string[] | undefined): boolean { const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; if (!raw) return false; const v = semverValid(raw.trim()); - if (!v) return false; // 不可解析(dev 等)→ 放行 - // 剥离预发布后缀,按版本线(major.minor.patch)比对,使同线预发布不低于下限(见文件头注释)。 + if (!v) return false; // unparseable (dev etc.) → let through + // Strip the prerelease suffix and compare by version line (major.minor.patch), so a same-line prerelease is not below the lower bound (see file-header comment). const line = semverCoerce(v); if (!line) return false; return semverLt(line, MIN_CLI_VERSION); diff --git a/apps/desktop/src/main/services/api-server/http.ts b/apps/desktop/src/main/services/api-server/http.ts index c40737bb..2b1fb5ff 100644 --- a/apps/desktop/src/main/services/api-server/http.ts +++ b/apps/desktop/src/main/services/api-server/http.ts @@ -2,13 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { AppError, ERROR_CODES, type AppErrorMeta, type ErrorCode } from '@meebox/shared'; /** - * 本地 API 的 HTTP 工具:统一响应封套({ ok, data } / { ok:false, error })、请求体读取、 - * 错误 → HTTP 状态码映射。见 docs/arch/04-integration/01-service-api.md。 + * HTTP utilities for the local API: unified response envelope ({ ok, data } / { ok:false, error }), + * request body reading, error → HTTP status code mapping. See docs/arch/04-integration/01-service-api.md. */ -const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB 请求体上限 +const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB request body limit -/** API 层错误(鉴权 / 路由 / 校验 / 写禁止等):自带 HTTP 状态码与错误码。 */ +/** API-layer error (auth / route / validation / write-forbidden, etc.): carries its own HTTP status code and error code. */ export class HttpError extends Error { constructor( readonly status: number, @@ -26,12 +26,12 @@ function writeJson(res: ServerResponse, status: number, payload: unknown): void res.end(body); } -/** 成功响应:200 + { ok:true, data }。 */ +/** Success response: 200 + { ok:true, data }. */ export function sendOk(res: ServerResponse, data: unknown): void { writeJson(res, 200, { ok: true, data: data ?? null }); } -/** 失败响应:按错误映射状态码 + { ok:false, error:{ code, meta } };返回所选状态码 / 码供日志。 */ +/** Failure response: map error to status code + { ok:false, error:{ code, meta } }; return the chosen status / code for logging. */ export function sendError(res: ServerResponse, err: unknown): { status: number; code: string } { const mapped = mapError(err); writeJson(res, mapped.status, { @@ -47,7 +47,7 @@ function mapError(err: unknown): { status: number; code: ErrorCode; meta?: AppEr return { status: 500, code: ERROR_CODES.SV_UNCLASSIFIED }; } -/** 把控制器抛出的 AppError 业务码映射到合适的 HTTP 状态码(未覆盖者归 500)。 */ +/** Map the AppError business code thrown by controllers to a suitable HTTP status code (uncovered ones fall back to 500). */ function statusForAppCode(code: ErrorCode): number { switch (code) { case ERROR_CODES.PR_NOT_FOUND: @@ -66,7 +66,7 @@ function statusForAppCode(code: ErrorCode): number { } } -/** 读取并解析 JSON 请求体(空体 → undefined);超限 413、非法 JSON 400,均归一为 SV 错误码。 */ +/** Read and parse the JSON request body (empty body → undefined); over-limit 413, invalid JSON 400, both normalized to SV error codes. */ export async function readJsonBody(req: IncomingMessage): Promise<unknown> { const chunks: Buffer[] = []; let total = 0; diff --git a/apps/desktop/src/main/services/api-server/routes/agent.ts b/apps/desktop/src/main/services/api-server/routes/agent.ts index dcfb8848..ef7bed23 100644 --- a/apps/desktop/src/main/services/api-server/routes/agent.ts +++ b/apps/desktop/src/main/services/api-server/routes/agent.ts @@ -5,11 +5,12 @@ import { toPrAgentRuns } from '../views.js'; import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; /** - * 评审 Agent 领域端点:状态 / 会话(浏览),auto review / 指令 / 聊天 / 中断(写入型,复用既有 run 队列), - * 以及按 run 的发现与取消。Agent `instruct` **仅只读工具**,变更类工具(publish 等)在 API 层硬拒绝。 + * Review agent domain endpoints: status / conversation (browsing), auto review / instruct / chat / stop + * (write-type, reusing the existing run queue), plus per-run discovery and cancellation. Agent `instruct` + * is **read-only tools only**; mutating tools (publish, etc.) are hard-rejected at the API layer. */ -/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ +/** Read-only Agent instructions the API allows (matching the tool registry's isRun read-only family; write tools are excluded). */ const READ_ONLY_TOOLS: ReadonlySet<ReviewRunTool> = new Set([ 'describe', 'review', @@ -25,7 +26,7 @@ const agentHistory: RouteHandler = ({ params }) => const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); -/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ +/** Send a read-only Agent instruction (describe / review / ask / improve); write tools hard-rejected (403), no confirmation. */ const agentInstruct: RouteHandler = ({ params, body }) => { const b = (body ?? {}) as { command?: string; args?: string }; const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; @@ -38,7 +39,7 @@ const agentInstruct: RouteHandler = ({ params, body }) => { return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); }; -/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ +/** Send a natural-language chat (may trigger an Agent task): enqueue if running, otherwise start a free-planning fallback round. */ const agentChat: RouteHandler = ({ params, body }) => { const b = (body ?? {}) as { message?: string }; if (!b.message?.trim()) { @@ -47,17 +48,17 @@ const agentChat: RouteHandler = ({ params, body }) => { return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); }; -/** 中断该 PR 正在运行的 Agent(思考 / 执行任意阶段即时停)。PR 级停,非按单个工具 run。 */ +/** Stop the Agent currently running on this PR (immediate stop at any thinking / execution stage). PR-level stop, not per-tool run. */ const agentStop: RouteHandler = ({ params }) => agentCtl.stopAgent(NO_EVENT, { localId: params.id }); -/** 该 PR 在运行队列里的 pr-agent runs(active + waiting),供按 run 取消前的发现。 */ +/** This PR's pr-agent runs in the run queue (active + waiting), for discovery before per-run cancellation. */ const agentRuns: RouteHandler = async ({ params }) => { const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); return toPrAgentRuns(snapshot, params.id); }; -/** 取消该 PR 的某个 pr-agent run(active SIGKILL / waiting 出队)。先校验 run 归属该 PR。 */ +/** Cancel one of this PR's pr-agent runs (active SIGKILL / waiting dequeue). Validate the run belongs to this PR first. */ const agentRunCancel: RouteHandler = async ({ params }) => { const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); const belongs = [...snapshot.active, ...snapshot.waiting].some( diff --git a/apps/desktop/src/main/services/api-server/routes/index.ts b/apps/desktop/src/main/services/api-server/routes/index.ts index 45293411..a5683cd2 100644 --- a/apps/desktop/src/main/services/api-server/routes/index.ts +++ b/apps/desktop/src/main/services/api-server/routes/index.ts @@ -4,15 +4,15 @@ import { seg, type Route } from './shared.js'; import { systemRoutes } from './system.js'; /** - * 本地 API 的路由**聚合注册 + 匹配**。各业务领域的处理器分置于同目录的 system / pr / agent 模块 - * (均复用 IPC controller 同源逻辑);本文件只做注册与路径匹配,不含业务逻辑。 - * 端点全表与写边界见 docs/arch/04-integration/01-service-api.md。 + * Route **aggregate registration + matching** for the local API. Handlers for each business domain live in the sibling + * system / pr / agent modules (all reusing the same logic as the IPC controllers); this file only does registration and path + * matching, no business logic. Full endpoint table and write boundaries: docs/arch/04-integration/01-service-api.md. */ export const routes: Route[] = [...systemRoutes, ...prRoutes, ...agentRoutes]; export type { Route, RouteContext, RouteHandler } from './shared.js'; -/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ +/** Match a route by method + path, extracting `:param` path parameters; return null when no match. */ export function matchRoute( method: string, pathname: string, diff --git a/apps/desktop/src/main/services/api-server/routes/pr.ts b/apps/desktop/src/main/services/api-server/routes/pr.ts index 60666364..b7e23dc0 100644 --- a/apps/desktop/src/main/services/api-server/routes/pr.ts +++ b/apps/desktop/src/main/services/api-server/routes/pr.ts @@ -13,15 +13,16 @@ import { toPrListItem } from '../views.js'; import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; /** - * PR 领域端点:列表 / 详情 / diff / 动态 / 提交 / 评审人(浏览),刷新(refresh)与分类词表(categories), - * 以及评审写动作(approve / needswork / comment,真实远端写,复用 GUI 同源 controller)。 - * 仍**不**暴露 merge(合并)。写边界见 docs/arch/04-integration/01-service-api.md。 + * PR domain endpoints: list / detail / diff / activity / commits / reviewers (browsing), refresh and + * category vocabulary (categories), plus review write actions (approve / needswork / comment, real remote + * writes, reusing the GUI's same-source controller). Still does **not** expose merge. Write boundary see + * docs/arch/04-integration/01-service-api.md. */ -/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */ +/** List pagination default page size (used when `limit` is missing / invalid / ≤0). */ const DEFAULT_LIMIT = 100; -/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */ +/** Category labels available under the currently active platform: `categories` (platform discovery filters) + `statuses` (status / merge-state filters). */ const categories: RouteHandler = () => { const ctx = getContext(); const activeId = ctx.bootstrap.config.active_connection_id; @@ -40,16 +41,18 @@ const categories: RouteHandler = () => { }; /** - * 触发一次立即轮询刷新(等价 GUI 的手动刷新 / 窗口聚焦刷新):拉取所有连接的最新 PR、落本地, - * 返回本轮计数汇总(fetched / changed / added / removed / errors)。复用 GUI 同源 poller.tick - * (`prs:refresh`)。无远端写副作用(纯读远端 + 落本地),列为安全的开放动作。 + * Trigger an immediate polling refresh (equivalent to the GUI's manual refresh / window-focus refresh): + * fetch the latest PRs across all connections, persist locally, and return this round's count summary + * (fetched / changed / added / removed / errors). Reuses the GUI's same-source poller.tick + * (`prs:refresh`). No remote write side effects (pure remote read + local persist), listed as a safe open action. */ const refresh: RouteHandler = () => prCtl.refreshPrs(NO_EVENT, undefined); /** - * PR 列表:`category`(一级发现分类)+ `status`(二级状态 / 合并态)过滤 + `q` 检索 + - * `skip`/`limit` 分页(默认 limit 100)。过滤语义复用 @meebox/shared 的纯谓词(与渲染层侧栏同源); - * 返回**精简列表投影**({@link toPrListItem},去 description 明细、人员仅 slug),此处仅解析参数 + 委派。 + * PR list: `category` (primary discovery filter) + `status` (secondary status / merge-state) filtering + `q` + * search + `skip`/`limit` pagination (default limit 100). Filter semantics reuse @meebox/shared's pure + * predicates (same source as the renderer sidebar); returns a **compact list projection** ({@link toPrListItem}, + * drops description detail, people as slug only). Here only parses params + delegates. */ const listPrs: RouteHandler = async ({ query }) => { const all = await prCtl.listPrs(NO_EVENT, undefined); @@ -69,7 +72,7 @@ const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(param const reviewers: RouteHandler = async ({ params }) => (await getContext().pr.findPrOrThrow(params.id)).reviewers; -/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ +/** No path → changed file list; with path → get that file's content on one side (default head). */ const diff: RouteHandler = ({ params, query }) => { const path = query.get('path'); if (path) { @@ -84,15 +87,15 @@ const activity: RouteHandler = ({ params }) => const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); -/** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */ +/** Review verdict "approve": write the remote review status first, then persist locally (reuses the GUI's same-source setPrStatus). */ const approve: RouteHandler = ({ params }) => prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' }); -/** 评审决断「需修改」:先写远端评审状态、再落本地。 */ +/** Review verdict "needs work": write the remote review status first, then persist locally. */ const needswork: RouteHandler = ({ params }) => prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'needs_work' }); -/** 发一条顶层(不锚文件)评论到远端 PR。body.body 为评论正文,空则 400。 */ +/** Post a top-level (not file-anchored) comment to the remote PR. body.body is the comment text; empty → 400. */ const comment: RouteHandler = ({ params, body }) => { const b = (body ?? {}) as { body?: string }; if (!b.body?.trim()) { diff --git a/apps/desktop/src/main/services/api-server/routes/shared.ts b/apps/desktop/src/main/services/api-server/routes/shared.ts index 0e9c27a7..61037594 100644 --- a/apps/desktop/src/main/services/api-server/routes/shared.ts +++ b/apps/desktop/src/main/services/api-server/routes/shared.ts @@ -1,12 +1,13 @@ import type { IpcMainInvokeEvent } from 'electron'; /** - * 路由框架原语,供同目录各业务领域模块(system / pr / agent)与聚合器(index)共用。 - * 各域处理器**复用 IPC controller 同源逻辑**——controller 形态为 `(event, req)` 且这些路径不触碰 - * event,故以 {@link NO_EVENT} 占位调用,避免在 HTTP 侧另起一套实现。 + * Route framework primitives, shared by the same-directory business domain modules (system / pr / agent) + * and the aggregator (index). Each domain handler **reuses the IPC controller's same-source logic**—the + * controller shape is `(event, req)` and these paths never touch event, so they call with {@link NO_EVENT} + * as a placeholder, avoiding a separate implementation on the HTTP side. */ -/** 单条路由处理器的入参:路径参数 / 查询串 / 已解析 body。 */ +/** A single route handler's inputs: path params / query string / parsed body. */ export interface RouteContext { params: Record<string, string>; query: URLSearchParams; @@ -21,10 +22,10 @@ export interface Route { handler: RouteHandler; } -/** 把 `/api/v1/prs/:id` 切成非空段数组(注册与匹配共用)。 */ +/** Split `/api/v1/prs/:id` into a non-empty segment array (shared by registration and matching). */ export function seg(path: string): string[] { return path.split('/').filter(Boolean); } -/** controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 */ +/** The controller's event parameter is unused across the reused read-only / queue paths, so a placeholder suffices. */ export const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; diff --git a/apps/desktop/src/main/services/api-server/routes/system.ts b/apps/desktop/src/main/services/api-server/routes/system.ts index 3296fe1d..5b25659e 100644 --- a/apps/desktop/src/main/services/api-server/routes/system.ts +++ b/apps/desktop/src/main/services/api-server/routes/system.ts @@ -3,13 +3,14 @@ import { getContext } from '../../context.js'; import { seg, type Route, type RouteHandler } from './shared.js'; /** - * 系统性 / 会话级端点:与具体 PR / Agent 无关的工具层信息——身份(whoami)与版本(version)。 - * 对应 CLI 的根层级系统性命令。 + * System / session-level endpoints: tool-layer info unrelated to any specific PR / Agent—identity (whoami) + * and version (version). Corresponds to the CLI's root-level system commands. */ /** - * 当前身份与集成平台:活动连接的 PAT 所属用户(name / displayName / slug)+ 平台种类 + - * 连接显示名。无活动连接时各项为 null。刻意收窄——不带 capabilities(那是 GUI 降级用的大对象)。 + * Current identity and integration platform: the active connection's PAT owner (name / displayName / slug) + + * platform kind + connection display name. All null when there's no active connection. Deliberately narrowed—no + * capabilities (that's the large object the GUI uses for degradation). */ const whoami: RouteHandler = () => { const ctx = getContext(); @@ -30,7 +31,7 @@ const whoami: RouteHandler = () => { }; }; -/** 服务端(桌面应用)版本,供 CLI `version` 同时展示客户端 + 服务端版本。 */ +/** Server (desktop app) version, for CLI `version` to show client + server versions together. */ const version: RouteHandler = () => ({ version: buildAppInfo(getContext().bootstrap).appVersion }); export const systemRoutes: Route[] = [ diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts index 2437611f..ec493dc8 100644 --- a/apps/desktop/src/main/services/api-server/server.ts +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -8,11 +8,12 @@ import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; import { matchRoute } from './routes/index.js'; /** - * 本地 API 服务监听器(见 docs/arch/04-integration/01-service-api.md)。 + * Local API service listener (see docs/arch/04-integration/01-service-api.md). * - * 主进程内置 HTTP listener,作为渲染层 IPC 之外的「第二前端」:复用同一 ControllerContext 与 service 层, - * 把只读 PR / Agent 能力暴露给外部 CLI / 工具。默认关闭;开启即强制 bearer token 鉴权。生命周期由 main 装配: - * start(按 config 决定是否 listen)/ stop(退出时优雅关闭)/ reconfigure(配置变更停旧起新)。 + * A built-in HTTP listener in the main process, acting as a "second frontend" beyond the renderer IPC: it reuses the same + * ControllerContext and service layer to expose read-only PR / Agent capabilities to external CLI / tools. Off by default; + * enabling it enforces bearer token auth. Lifecycle wired up by main: + * start (decides whether to listen per config) / stop (graceful close on exit) / reconfigure (stop old, start new on config change). */ export interface ApiServerDeps { bootstrap: BootstrapResult; @@ -24,12 +25,12 @@ export class ApiServer { constructor(private readonly deps: ApiServerDeps) {} - /** 实时读内存 service 配置(token 变更无需重建即生效)。 */ + /** Read the in-memory service config live (token changes take effect without rebuild). */ private get cfg() { return this.deps.bootstrap.config.service; } - /** 按配置启动监听(未启用 / token 为空则不启动)。监听失败为非致命:记录后不抛,不拖垮应用启动。 */ + /** Start listening per config (skip if disabled / token empty). Listen failure is non-fatal: log and don't throw, so it won't drag down app startup. */ async start(): Promise<void> { if (this.server) return; const cfg = this.cfg; @@ -59,7 +60,7 @@ export class ApiServer { }); } - /** 优雅关闭:停止接收新连接、放行 in-flight 后落定。 */ + /** Graceful close: stop accepting new connections, let in-flight requests drain, then settle. */ async stop(): Promise<void> { const server = this.server; if (!server) return; @@ -68,13 +69,13 @@ export class ApiServer { this.deps.logger.info('local API server stopped'); } - /** 配置(开关 / host / port)变更:停旧起新。 */ + /** Config (toggle / host / port) change: stop old, start new. */ async reconfigure(): Promise<void> { await this.stop(); await this.start(); } - /** 常数时间比对 bearer token;缺 token 配置 / 非 Bearer 头 / 长度不符均判失败。 */ + /** Constant-time compare of the bearer token; missing token config / non-Bearer header / length mismatch all fail. */ private authorized(req: IncomingMessage): boolean { const token = this.cfg.token; if (!token) return false; @@ -97,7 +98,7 @@ export class ApiServer { let outcome: { status: number; code?: string }; try { if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED); - // 兼容性门控:对所有 API 调用统一拦截过旧的 CLI(缺版本头 / 不可解析 → 放行)。 + // Compatibility gate: uniformly reject too-old CLIs on all API calls (missing version header / unparseable → let through). if (isClientTooOld(req.headers[CLI_VERSION_HEADER])) { throw new HttpError(426, ERROR_CODES.SV_CLIENT_TOO_OLD, { minVersion: MIN_CLI_VERSION, diff --git a/apps/desktop/src/main/services/api-server/views.ts b/apps/desktop/src/main/services/api-server/views.ts index 4f9677e3..8cde3a55 100644 --- a/apps/desktop/src/main/services/api-server/views.ts +++ b/apps/desktop/src/main/services/api-server/views.ts @@ -9,59 +9,59 @@ import type { } from '@meebox/shared'; /** - * PR 列表视图项:`GET /prs` 对外暴露的**精简投影**。这是「请求接口视图层的树结构约束方法」—— - * 单一投影函数 {@link toPrListItem} 定义列表返回的字段集合与次序,避免直接把整条 - * StoredPullRequest(含 description 明细、完整人员对象等)泄给列表消费方。 + * PR list view item: the **slim projection** exposed by `GET /prs`. This is the view-layer tree-structure constraint for the + * request interface — a single projection function {@link toPrListItem} defines the field set and order returned by the list, + * avoiding leaking the entire StoredPullRequest (with description details, full people objects, etc.) to list consumers. * - * 收窄原则: - * - 只给标识与概览,**去掉 description 明细**(详情走 `GET /prs/{id}`); - * - **人员信息只留 slug**(reviewer 另带 status);头像 / 展示名等留给详情; - * - **字段顺序即输出顺序**:id / title / author / createdAt 优先,再给其余概览字段。 + * Narrowing principles: + * - Give only identifiers and overview, **drop description details** (details go through `GET /prs/{id}`); + * - **Keep only slug for people info** (reviewer additionally carries status); avatar / display name, etc. left to details; + * - **Field order is output order**: id / title / author / createdAt first, then the remaining overview fields. */ export interface PrListItem { - /** PR 的本地稳定标识(== StoredPullRequest.localId);写操作与详情端点均按此定位。 */ + /** The PR's local stable identifier (== StoredPullRequest.localId); write operations and the details endpoint both locate by this. */ id: string; title: string; - /** 作者 slug(缺失时回退 name);不含展示名 / 头像。 */ + /** Author slug (falls back to name when missing); no display name / avatar. */ author: string; createdAt: string; - /** 本人评审决断(pending / approved / needs_work)。 */ + /** Own review verdict (pending / approved / needs_work). */ status: LocalPrStatus; state: 'open' | 'merged' | 'declined'; draft: boolean; platform: PlatformKind; - /** `projectKey/repoSlug`。 */ + /** `projectKey/repoSlug`. */ repo: string; - /** 远端平台 PR 编号。 */ + /** Remote platform PR number. */ remoteId: string; updatedAt: string; hasConflict: boolean; - /** 远端判定可直接合并(== mergeStatus.canMerge)。 */ + /** Remote-determined directly mergeable (== mergeStatus.canMerge). */ mergeable: boolean; - /** 命中的发现分类(一级 category)。 */ + /** Matched discovery categories (top-level category). */ categories: PrDiscoveryFilter[]; - /** 评审人:仅 slug + status。 */ + /** Reviewers: slug + status only. */ reviewers: Array<{ slug: string; status: ReviewerStatus }>; unread: boolean; unreadMentionCount: number; } /** - * 某 PR 在运行队列里的一个 pr-agent run 视图项:`GET /prs/{id}/agent/runs` 的投影。用于让调用方 - * 发现可取消的 run(runId + tool + 运行 / 排队态),配合 `…/runs/{runId}/cancel` 做按 run 取消。 + * View item for one pr-agent run of a PR in the run queue: the projection of `GET /prs/{id}/agent/runs`. Lets the caller + * discover cancelable runs (runId + tool + running / queued state), paired with `…/runs/{runId}/cancel` for per-run cancel. */ export interface PrAgentRunItem { runId: string; tool: ReviewRunTool; - /** active = 正在执行;waiting = 排队中。 */ + /** active = executing; waiting = queued. */ state: 'active' | 'waiting'; - /** 开始执行时间(ISO);waiting 为 null。 */ + /** Execution start time (ISO); null when waiting. */ startedAt: string | null; enqueuedAt: string; question?: string; } -/** 从队列快照筛出属于该 PR 的 run(active 在前、waiting 在后),投影为精简项。 */ +/** Filter the runs belonging to this PR from the queue snapshot (active first, waiting after), projected to slim items. */ export function toPrAgentRuns( queue: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }, prId: string, @@ -80,7 +80,7 @@ export function toPrAgentRuns( ]; } -/** 把存储态 PR 投影为列表视图项。对象字面量的键序即 JSON 输出顺序(CLI 视图层据此渲染)。 */ +/** Project a stored-state PR to a list view item. The object literal's key order is the JSON output order (the CLI view layer renders accordingly). */ export function toPrListItem(pr: StoredPullRequest): PrListItem { return { id: pr.localId, diff --git a/apps/desktop/src/main/services/app.ts b/apps/desktop/src/main/services/app.ts index 96c97e1b..9b6cabff 100644 --- a/apps/desktop/src/main/services/app.ts +++ b/apps/desktop/src/main/services/app.ts @@ -4,7 +4,7 @@ import type { ConnectionSummary } from '@meebox/ipc'; import type { AppInfo } from '@meebox/shared'; import type { BuiltAdapter } from '../adapters.js'; -/** 应用 / 运行时版本信息(app:info)。纯数据组装,不依赖 controller 上下文。 */ +/** App / runtime version info (app:info). Pure data assembly, no controller context needed. */ export function buildAppInfo(bootstrap: BootstrapResult): AppInfo { return { appVersion: app.getVersion(), @@ -17,12 +17,12 @@ export function buildAppInfo(bootstrap: BootstrapResult): AppInfo { }; } -/** 当前活动连接的状态摘要(app:connections)。 */ +/** Status summary of the currently active connection (app:connections). */ export function buildConnectionSummaries( bootstrap: BootstrapResult, adapters: readonly BuiltAdapter[], ): ConnectionSummary[] { - // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 + // Single-active-connection model: the status bar only shows the enabled state of the current active connection (consistent with poller only polling the active connection). const activeId = bootstrap.config.active_connection_id; return adapters .filter(({ connectionId }) => connectionId === activeId) diff --git a/apps/desktop/src/main/services/avatar.ts b/apps/desktop/src/main/services/avatar.ts index cc8a28f0..34194e3c 100644 --- a/apps/desktop/src/main/services/avatar.ts +++ b/apps/desktop/src/main/services/avatar.ts @@ -5,7 +5,7 @@ import type { Logger } from 'pino'; import type { PlatformAdapter } from '@meebox/platform-core'; import { sniffImageContentType } from '../utils/image.js'; -// 与 app.ts 头像缓存同约定:目录 <cacheDir>/avatars/,键 sha256(connectionId|slug) 前 24 hex;原始字节存 .bin。 +// Same convention as app.ts avatar cache: directory <cacheDir>/avatars/, key is first 24 hex of sha256(connectionId|slug); raw bytes stored as .bin. const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; const EXT_BY_CONTENT_TYPE: Record<string, string> = { 'image/png': 'png', @@ -16,17 +16,17 @@ const EXT_BY_CONTENT_TYPE: Record<string, string> = { export interface AvatarFileDeps { cacheDir: string; - /** 取指定连接的 adapter(拉头像用);找不到回 null。 */ + /** Get the adapter for the given connection (for fetching avatars); returns null if not found. */ getAdapter: (connectionId: string) => PlatformAdapter | null; logger: Logger; } /** - * 确保 (connectionId, slug) 的头像已落盘,并返回一个**带正确图片扩展名**的本地文件绝对路径。 + * Ensures the avatar for (connectionId, slug) is persisted to disk, and returns the absolute path of a local file **with the correct image extension**. * - * 背景:Windows toast 的 `<image src>` 需要本地文件且按扩展名识别格式;而头像缓存只存裸字节 `.bin`。 - * 故此处在 `.bin` 之外按嗅探到的 content-type 旁挂一份 `<hash>.<ext>` 供 toast 引用。命中且未过期的缓存复用, - * 缺失 / 过期经 adapter 拉取并落盘。无 adapter / 拉取失败 / 非位图(svg 等 toast 不支持)→ 返回 null(调用方降级为无头像)。 + * Background: Windows toast's `<image src>` needs a local file and identifies the format by extension; but the avatar cache only stores raw bytes as `.bin`. + * So besides the `.bin`, this attaches a `<hash>.<ext>` copy based on the sniffed content-type for the toast to reference. A hit that has not expired is reused, + * a miss / expiry is fetched via the adapter and persisted. No adapter / fetch failure / non-bitmap (svg etc. not supported by toast) → returns null (caller falls back to no avatar). */ export async function ensureAvatarFile( deps: AvatarFileDeps, @@ -47,7 +47,7 @@ export async function ensureAvatarFile( const stat = await fs.stat(binPath); if (Date.now() - stat.mtimeMs < AVATAR_TTL_MS) bytes = await fs.readFile(binPath); } catch { - // .bin 不存在 / 读失败 → 走拉取 + // .bin missing / read failed → go fetch } if (!bytes) { const adapter = deps.getAdapter(connectionId); @@ -65,15 +65,15 @@ export async function ensureAvatarFile( } const ext = EXT_BY_CONTENT_TYPE[sniffImageContentType(bytes)]; - if (!ext) return null; // svg / 未知格式:Windows toast 不可靠,降级无头像 + if (!ext) return null; // svg / unknown format: unreliable for Windows toast, fall back to no avatar const imgPath = path.join(avatarDir, `${hash}.${ext}`); try { let needWrite = true; try { const [binStat, imgStat] = await Promise.all([fs.stat(binPath), fs.stat(imgPath)]); - needWrite = imgStat.mtimeMs < binStat.mtimeMs; // 副本比原始字节旧 → 重写 + needWrite = imgStat.mtimeMs < binStat.mtimeMs; // copy older than raw bytes → rewrite } catch { - needWrite = true; // 副本不存在 + needWrite = true; // copy does not exist } if (needWrite) await fs.writeFile(imgPath, bytes); return imgPath; diff --git a/apps/desktop/src/main/services/broadcast.ts b/apps/desktop/src/main/services/broadcast.ts index 7e74b809..bf5a0a08 100644 --- a/apps/desktop/src/main/services/broadcast.ts +++ b/apps/desktop/src/main/services/broadcast.ts @@ -2,9 +2,9 @@ import { BrowserWindow } from 'electron'; import type { IpcEvents } from '@meebox/ipc'; /** - * 向所有窗口广播一条 main → renderer 推送事件。收口原先散落各处的 - * `for (const win of BrowserWindow.getAllWindows()) win.webContents.send(...)`, - * 并按 IpcEvents 强类型约束 event ↔ payload。 + * Broadcasts a main → renderer push event to all windows. Consolidates the previously + * scattered `for (const win of BrowserWindow.getAllWindows()) win.webContents.send(...)`, + * and strongly constrains event ↔ payload via IpcEvents. */ export function broadcast<E extends keyof IpcEvents>(event: E, payload: IpcEvents[E]): void { for (const win of BrowserWindow.getAllWindows()) { diff --git a/apps/desktop/src/main/services/comments.ts b/apps/desktop/src/main/services/comments.ts index 0221087f..a14130ed 100644 --- a/apps/desktop/src/main/services/comments.ts +++ b/apps/desktop/src/main/services/comments.ts @@ -2,23 +2,23 @@ import type { PrComment } from '@meebox/shared'; import type { PlatformAdapter } from '@meebox/platform-core'; /** - * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。不依赖 controller 上下文。 + * Tags each comment (including the replies subtree) with canDelete / canEdit flags. No controller context needed. * - * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version - * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) - * - canEdit: author.name === 当前 PAT 用户 && 有 version - * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) + * - canDelete: author.name === current PAT user && no reply && has version + * (Bitbucket refuses to delete ones with a reply; DELETE must carry a version optimistic lock) + * - canEdit: author.name === current PAT user && has version + * (Bitbucket allows editing comments with a reply; PUT also carries a version) * - * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 - * 自己比对 author / version / replies,链路最短最稳。 + * If the current user is unavailable (ping not done / failed) → all false. The renderer reads the flag directly and no longer + * compares author / version / replies itself, keeping the path shortest and most stable. */ export function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { const me = adapter.connection.getCurrentUser(); if (!me) { return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); } - // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 - // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 + // "Comments with a reply cannot be deleted" is a Bitbucket limitation (deleting a parent comment would orphan child comments); GitHub / GitLab allow deleting + // one's own comments (including those with a reply). Use the optimistic-lock capability bit as a proxy for Bitbucket. const noDeleteWithReplies = adapter.connection.capabilities().commentOptimisticLock; return setOwnershipRecursive(comments, (c) => { const isMine = c.author.name === me.name; diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index 90b3c9e9..71284f4d 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -12,53 +12,53 @@ import { broadcast } from './broadcast.js'; import { PrService } from './pr-service.js'; import type { RunQueue } from './pr-agent/index.js'; -/** registerIpcHandlers 的外部依赖(由 main/index.ts 注入)。 */ +/** External dependencies of registerIpcHandlers (injected by main/index.ts). */ export interface RegisterDeps { bootstrap: BootstrapResult; logger: Logger; - /** 惰性取 pr-agent 探测状态:探测异步进行(不阻塞建窗),await 拿最终结果 */ + /** Lazily get pr-agent probe status: probing runs async (does not block window creation); await for the final result */ getPrAgentStatus: () => Promise<PrAgentStatus>; - /** 惰性取 bridge 实例;探测未完成 / 不可用 (embedded / CLI 都没有) 时为 null */ + /** Lazily get the bridge instance; null when probing is unfinished / unavailable (neither embedded nor CLI) */ getPrAgentBridge: () => PrAgentBridge | null; - /** 嵌入式运行时解释器路径(embedded 策略下执行期补 .secrets.toml 用),非 embedded 可空 */ + /** Embedded runtime interpreter path (used to patch .secrets.toml at execution time under the embedded strategy); may be empty for non-embedded */ embeddedPythonPath?: string; stateStore: JsonFileStateStore; - /** 归档 PR 冷存储(archived/ 根,与 state/ 平级):「已关闭」视图列表 + 打开已归档 PR 详情时读。 */ + /** Archived PR cold storage (archived/ root, sibling of state/): read for the "closed" view list + when opening an archived PR's details. */ archiveStore: JsonFileStateStore; poller: Poller; - /** 可变连接运行时(全量 adapters + adapterByHost);设置页改连接后被 reconfigure 原地替换 */ + /** Mutable connection runtime (full adapters + adapterByHost); replaced in place by reconfigure after connections change in the settings page */ connectionRuntime: ConnectionRuntime; - /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ + /** Rebuild adapters/poller so connection changes take effect hot (called after config:setConnections is written to disk) */ reconfigureConnections: () => Promise<void>; repoMirror: RepoMirrorManager; - /** 重建本地 API 监听器使 service 配置(开关 / host / port)变更热生效(config:setService 写盘后调用)。 */ + /** Rebuild the local API listener so service config (toggle / host / port) changes take effect hot (called after config:setService is written to disk). */ reconfigureApiServer: () => Promise<void>; } /** - * 各 service 共享的运行时上下文:外部依赖 + 跨域工具(广播 / Agent 目录)+ PR 领域服务。 - * 跨域服务(run 队列 / Agent 编排)在此之上由 ipc.ts 合成 ControllerContext,避免构造环。 + * Runtime context shared by all services: external dependencies + cross-cutting utilities (broadcast / Agent dir) + PR domain service. + * On top of this, cross-cutting services (run queue / Agent orchestration) are composed by ipc.ts into ControllerContext, avoiding a construction cycle. */ export interface ServiceContext extends RegisterDeps { - /** 向所有窗口广播 main → renderer 事件(按 IpcEvents 强类型)。 */ + /** Broadcast main → renderer events to all windows (strongly typed by IpcEvents). */ broadcast: typeof broadcast; - /** 生效的 Agent 目录:用户配置优先,未配置则回落默认位置(~/.code-meeseeks/agent)。 */ + /** The effective Agent dir: user config takes precedence, falling back to the default location (~/.code-meeseeks/agent) when unset. */ effectiveAgentDir(): string; /** - * 取生效 Agent 目录并**幂等补齐**上下文模版(SOUL/AGENTS/MEMORY/USER + rules/)后返回其路径。 - * 「用时初始化」:与现读现装配同口径——无论目录经启动默认 / 应用内热切换 / 直改配置文件后重启而来, - * 每次加载前都先确保已初始化,不依赖首启或设置交互这类一次性时机。幂等(已存在不覆盖);失败仅告警、 - * 不抛(loadAgentContext / loadAgentRules 仍会按缺失文件降级)。 + * Get the effective Agent dir, **idempotently backfill** the context templates (SOUL/AGENTS/MEMORY/USER + rules/), then return its path. + * "Initialize on use": same approach as read-and-assemble-on-demand — regardless of whether the dir comes from the startup default / in-app hot switch / a restart after directly editing the config file, + * always ensure it is initialized before each load, not relying on one-off moments like first launch or settings interaction. Idempotent (does not overwrite if present); on failure only warns, + * does not throw (loadAgentContext / loadAgentRules still degrade per missing files). */ ensureAgentDir(): Promise<string>; - /** PR 领域服务:PR 定位 / adapter / 镜像 / diff base / 评论缓存。 */ + /** PR domain service: PR lookup / adapter / mirror / diff base / comments cache. */ pr: PrService; } /** - * controller 层统一上下文:在 ServiceContext 之上再挂两个跨域 service(run 队列 / Agent 编排), - * 使所有 controller 共享同一 `ctx` 入参即可拿到全部能力,签名统一为 `(ctx, req, evt)`。 - * 两个跨域服务以基础 ServiceContext 构建(见 ipc.ts 装配顺序),构建完成后合成本上下文。 + * Unified controller-layer context: on top of ServiceContext, attach two more cross-cutting services (run queue / Agent orchestration), + * so all controllers share the same `ctx` input to access every capability, with a uniform `(ctx, req, evt)` signature. + * The two cross-cutting services are built from the base ServiceContext (see ipc.ts assembly order), and this context is composed once they are built. */ export interface ControllerContext extends ServiceContext { runQueue: RunQueue; @@ -92,21 +92,21 @@ export function createServiceContext(deps: RegisterDeps): ServiceContext { }; } -// === controller 层进程级单例上下文 === -// registerIpcHandlers 启动时合成一次 ControllerContext(base + runQueue + orchestrator)并安装; -// controller 经 getContext() 取用,从而 handler 签名回归标准 ipcMain.handle 形态 (req, evt)、不带 ctx。 -// 单一真相、随进程生命周期存活;测试可先 setControllerContext(mock) 再调 controller。 +// === process-level singleton context of the controller layer === +// At startup registerIpcHandlers composes a ControllerContext once (base + runQueue + orchestrator) and installs it; +// controllers access it via getContext(), so handler signatures return to the standard ipcMain.handle form (req, evt) without ctx. +// Single source of truth, lives for the process lifecycle; tests can setControllerContext(mock) first then call the controller. let currentContext: ControllerContext | undefined; -/** 由 registerIpcHandlers 在装配完成后调用,安装进程级 controller 上下文单例。 */ +/** Called by registerIpcHandlers after assembly completes, to install the process-level controller context singleton. */ export function setControllerContext(ctx: ControllerContext): void { currentContext = ctx; } -/** 取 controller 上下文单例;未初始化(registerIpcHandlers 之前 / 模块加载期)即抛错兜住时序。 */ +/** Get the controller context singleton; throws if uninitialized (before registerIpcHandlers / during module load) to guard timing. */ export function getContext(): ControllerContext { if (!currentContext) { - throw new Error('ControllerContext 尚未初始化(registerIpcHandlers 未调用)'); + throw new Error('ControllerContext not initialized (registerIpcHandlers was not called)'); } return currentContext; } diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index 09e999da..baaa8ec5 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -7,19 +7,19 @@ import { broadcast } from './broadcast.js'; import { ensureAvatarFile, type AvatarFileDeps } from './avatar.js'; /** - * 系统通知 + 应用角标。两条路径: - * - 系统通知(toast):poll 投影的本轮事件按类型开关弹原生通知;受 OS 权限约束,用户在系统设置关闭后静默降级。 - * Windows 走 toastXml 富样式(圆形发起人头像 + 类型 emoji + 仓库行);其他平台用 title/body 文本(含仓库,无头像, - * 因 Electron 在 macOS 固定显示应用图标、不支持 per-notification 头像)。 - * - dock 角标(本期仅 macOS):renderer 据 PR 列表派生「待回应」计数后推送,主进程落地到 dock 图标。 + * System notifications + app badge. Two paths: + * - System notification (toast): this round's poll-projected events fire native notifications per type toggle; subject to OS permission, silently degrades after the user disables it in system settings. + * Windows uses toastXml rich style (circular initiator avatar + type emoji + repo line); other platforms use title/body text (with repo, no avatar, + * because Electron fixes the app icon on macOS and does not support per-notification avatars). + * - dock badge (macOS only this iteration): renderer derives the "awaiting response" count from the PR list and pushes it, the main process lands it on the dock icon. * - * 文案走主进程 i18n(与 dialog / pr-agent 同一实例,语言随启动配置定档)。 + * Text goes through main-process i18n (same instance as dialog / pr-agent, language fixed by startup config). */ -/** 一轮最多单独弹的通知条数(各带定位);超出部分折叠为一条「查看更多」提示,避免涌入时的通知风暴。 */ +/** Max number of notifications fired individually per round (each with anchoring); the overflow is collapsed into one "see more" prompt, avoiding a notification storm on influx. */ const INDIVIDUAL_LIMIT = 5; -/** 通知事件类型 → i18n 文案分组名(new_pr 的 key 为 newPr,authored_* 转驼峰,其余同名)。 */ +/** Notification event type → i18n text group name (new_pr's key is newPr, authored_* is camel-cased, the rest same name). */ const I18N_GROUP: Record<PollNotificationEvent['kind'], string> = { new_pr: 'newPr', mention: 'mention', @@ -29,7 +29,7 @@ const I18N_GROUP: Record<PollNotificationEvent['kind'], string> = { authored_conflict: 'authoredConflict', }; -/** 类型 emoji(Windows toast 单图标槽给了头像,故类型用 emoji 在标题前标记)。 */ +/** Type emoji (the Windows toast single icon slot is given to the avatar, so the type is marked with an emoji before the title). */ const TYPE_EMOJI: Record<PollNotificationEvent['kind'], string> = { new_pr: '🔀', mention: '💬', @@ -39,7 +39,7 @@ const TYPE_EMOJI: Record<PollNotificationEvent['kind'], string> = { authored_conflict: '⚠️', }; -/** 点击通知:唤起并聚焦主窗口(最小化则先还原)。 */ +/** On notification click: raise and focus the main window (restore first if minimized). */ function focusMainWindow(): void { const win = BrowserWindow.getAllWindows()[0]; if (!win) return; @@ -57,7 +57,7 @@ function showNotification( n.show(); } -/** 点击通知 → 聚焦窗口 + 推导航意图给 renderer(选中 PR / 跳 diff 行 / 开活动标签,见 IpcEvents['notification:activate'])。 */ +/** On notification click → focus the window + push a navigation intent to the renderer (select PR / jump to diff line / open activity tab, see IpcEvents['notification:activate']). */ function activateOnClick(e: PollNotificationEvent): () => void { return () => { focusMainWindow(); @@ -80,7 +80,7 @@ function escapeXml(s: string): string { .replace(/'/g, '''); } -/** 构造 Windows ToastGeneric XML:标题(emoji+类型)+ 正文(#编号 标题)+ 归属行(仓库)+ 圆形头像(可选)。 */ +/** Build Windows ToastGeneric XML: title (emoji+type) + body (#number title) + attribution line (repo) + circular avatar (optional). */ function buildToastXml(line1: string, line2: string, attribution: string, avatarPath: string | null): string { const logo = avatarPath ? `<image placement="appLogoOverride" hint-crop="circle" src="${escapeXml(pathToFileURL(avatarPath).href)}"/>` @@ -95,7 +95,7 @@ function buildToastXml(line1: string, line2: string, attribution: string, avatar ); } -/** 弹单条通知:Windows 富样式(头像 + emoji + 仓库),失败 / 其他平台回退为 title/body 文本(含仓库)。 */ +/** Fire a single notification: Windows rich style (avatar + emoji + repo), falling back to title/body text (with repo) on failure / other platforms. */ async function showOne( e: PollNotificationEvent, avatarDeps: AvatarFileDeps, @@ -128,9 +128,9 @@ async function showOne( } /** - * 按通知配置弹系统通知。总开关关 / 平台不支持通知 → 直接返回(静默降级)。按类型开关过滤后: - * 最多前 {@link INDIVIDUAL_LIMIT} 条逐条弹(各带头像富样式 + 点击定位);超出部分(第 6 条起)折叠为一条 - * 「查看更多最新动态」提示,点击仅打开主界面、不做定位。 + * Fire system notifications per notification config. Master toggle off / platform does not support notifications → return directly (silent degrade). After filtering by type toggle: + * fire at most the first {@link INDIVIDUAL_LIMIT} one by one (each with avatar rich style + click anchoring); the overflow (from the 6th on) is collapsed into one + * "see more recent activity" prompt, whose click only opens the main UI without anchoring. */ export async function showPollNotifications( events: ReadonlyArray<PollNotificationEvent>, @@ -158,7 +158,7 @@ export async function showPollNotifications( } const overflow = filtered.length - shown.length; if (overflow > 0) { - // 溢出提示:点击走默认 focusMainWindow(仅打开主界面、不定位),让用户自行查看更多最新动态。 + // Overflow prompt: click goes to the default focusMainWindow (only opens the main UI, no anchoring), letting users browse more recent activity themselves. showNotification({ title: t('notifications.more.title'), body: t('notifications.more.body', { count: overflow }), @@ -170,7 +170,7 @@ export async function showPollNotifications( } /** - * 设置应用角标计数(本期仅 macOS dock)。count≤0 清除角标。renderer 已据通知配置派生计数,主进程仅落地。 + * Set the app badge count (macOS dock only this iteration). count≤0 clears the badge. The renderer has already derived the count per notification config; the main process only lands it. */ export function applyBadgeCount(count: number): void { if (process.platform !== 'darwin') return; diff --git a/apps/desktop/src/main/services/pr-agent/index.ts b/apps/desktop/src/main/services/pr-agent/index.ts index 4b4d278f..265dcd97 100644 --- a/apps/desktop/src/main/services/pr-agent/index.ts +++ b/apps/desktop/src/main/services/pr-agent/index.ts @@ -1,6 +1,7 @@ /** - * pr-agent run 子系统:调度(RunQueue:并发 / 优先级 / 取消)+ 执行(RunExecutor,内部协作件,不外暴露)。 - * 对外只暴露 RunQueue 与队列相关类型。 + * pr-agent run subsystem: scheduling (RunQueue: concurrency / priority / cancel) + execution + * (RunExecutor, an internal collaborator, not exposed). Only RunQueue and queue-related types + * are exposed outward. */ export { RunQueue } from './run-queue.js'; export type { QueueItem, RunPriority } from './run-queue.js'; diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts index 15ed6636..c7e28658 100644 --- a/apps/desktop/src/main/services/pr-agent/run-executor.ts +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -41,44 +41,48 @@ import { } from './usage.js'; import { neutralizeWorktreeInstructions } from './worktree-sanitize.js'; -/** finishReviewRun 的收尾 patch 类型(收尾 helper 的返回)。 */ +/** Finalization patch type for finishReviewRun (return of the finalization helper). */ type FinishPatch = Parameters<typeof finishReviewRun>[3]; /** - * pr-agent run 的**执行器**(与队列调度 RunQueue 分离):给定一个已 dequeue 的队列项,跑完一个 run。 - * 调度(并发 / 优先级 / 取消 / 泵)归 RunQueue;本类只管「怎么跑一个 run」,无队列状态。 + * The **executor** for a pr-agent run (separate from queue scheduling in RunQueue): given an + * already-dequeued queue item, it runs one run to completion. Scheduling (concurrency / priority / + * cancel / pump) belongs to RunQueue; this class only handles "how to run one run", with no queue state. * - * execute 编排五个阶段:startRun(落盘 + 标记开始)→ prepareWorkspace(镜像 + worktree)→ - * buildInvocation(env + 提示词组装)→ bridge.run(spawn)→ collectOutput(读产物 + 解析)→ 收尾落盘。 + * execute orchestrates five stages: startRun (persist + mark started) → prepareWorkspace (mirror + worktree) + * → buildInvocation (env + prompt assembly) → bridge.run (spawn) → collectOutput (read artifacts + parse) → finalize persist. */ export class RunExecutor { private readonly execFileP = promisify(execFile); - /** embedded .secrets.toml 兜底的 memo(只在首个 embedded run 解析一次目录 + 写文件)。 */ + /** Memo for the embedded .secrets.toml fallback (resolve dir + write file only once, on the first embedded run). */ private embeddedSecretsEnsured: Promise<void> | null = null; constructor(private readonly ctx: ServiceContext) {} /** - * 真正执行一个 queue item:startRun → worktree → bridge.run → finishWith。 - * 由 RunQueue.pump() 调用;任何抛错都被调度层兜成 Promise reject,外层 pragent:run 调用方收到。 - * notifyStarted:startedAt 落定后回调调度层广播队列变化(执行器不持队列态)。 + * Actually execute one queue item: startRun → worktree → bridge.run → finishWith. + * Called by RunQueue.pump(); any thrown error is caught by the scheduling layer into a Promise reject, + * received by the outer pragent:run caller. + * notifyStarted: once startedAt is settled, calls back into the scheduling layer to broadcast queue + * changes (the executor holds no queue state). */ async execute(item: QueueItem, notifyStarted: () => void): Promise<ReviewRun> { const { getPrAgentBridge, embeddedPythonPath, broadcast } = this.ctx; const bridge = getPrAgentBridge(); if (!bridge) throw new AppError(ERROR_CODES.AG_PR_AGENT_NOT_READY); const { req, pr } = item; - // per-PR 存储路由:对已归档(已关闭范围)的合并 / 仍开放 PR 补跑评审时,run 数据落归档冷存储, - // 不写活跃存储(否则被下轮 poll 对账连同归档数据误删,见 PrService.storeForPr)。 + // per-PR storage routing: when re-running review for an already-archived (closed-scope) merged / + // still-open PR, run data goes to archived cold storage, not the active store (otherwise the next + // poll reconciliation would wrongly delete it along with archived data, see PrService.storeForPr). const stateStore = await this.ctx.pr.storeForPr(pr.localId); const run = await this.startRun(item, bridge, notifyStarted); const t0 = Date.now(); - // 真实 token 用量累加器:sitecustomize 的 litellm callback 把每次调用的 usage 以 - // `@@MEEBOX_USAGE@@ {json}` 哨兵行打到 stderr,下面 onLine 拦截累加(无需临时文件 / env)。 + // Real token usage accumulator: sitecustomize's litellm callback emits each call's usage as a + // `@@MEEBOX_USAGE@@ {json}` sentinel line to stderr; onLine below intercepts and accumulates (no temp file / env needed). const usageAcc = newUsageAcc(); const onLine = (line: string, stream: 'stdout' | 'stderr'): void => { - // 拦截 usage 哨兵行:累加后不转发给 renderer(避免污染实时日志)。 + // Intercept usage sentinel lines: accumulate then don't forward to the renderer (avoid polluting live logs). if (stream === 'stderr' && accumulateUsageSentinel(line, usageAcc)) return; broadcast('pragent:runProgress', { runId: run.id, line, stream }); }; @@ -96,14 +100,16 @@ export class RunExecutor { wt.path, ); - // CLI 模式 /ask 把子进程 cwd 落到 worktree(取完整文件上下文,buildInvocation 已设 MEEBOX_CLI_WORKDIR)。 - // 落 cwd 前先清空仓库自带的 agent 指令文件,避免被 CLI 自动加载污染回答。env key 在 = 走此路径。 + // In CLI mode /ask sets the subprocess cwd to the worktree (for full file context; buildInvocation + // already set MEEBOX_CLI_WORKDIR). Before landing cwd, clear the repo's own agent instruction files + // to avoid the CLI auto-loading them and polluting the answer. Presence of the env key gates this path. if (env['MEEBOX_CLI_WORKDIR']) { await neutralizeWorktreeInstructions(env['MEEBOX_CLI_WORKDIR'], this.ctx.logger); } - // embedded 策略:执行期在嵌入式安装目录补空 .secrets.toml 压掉启动告警(memo 化只首次做)。 - // local-cli 不需要(pipx 装的 pr-agent 路径不同,告警也不出)。 + // embedded strategy: at execution time write an empty .secrets.toml into the embedded install dir + // to suppress the startup warning (memoized, done only on the first run). + // local-cli doesn't need it (pipx-installed pr-agent has a different path and the warning doesn't appear). if (bridge.strategy === 'embedded' && embeddedPythonPath) { await this.ensureEmbeddedSecrets(embeddedPythonPath); } @@ -118,7 +124,7 @@ export class RunExecutor { extraArgs, signal: item.ac!.signal, }); - // 真实 token 用量(onLine 累加的 stderr 哨兵行),落到 succeeded / llm-failed 收尾。 + // Real token usage (stderr sentinel lines accumulated by onLine), carried into succeeded / llm-failed finalization. const tokenUsage = finalizeUsage(usageAcc); const { parsed, fileContent } = await this.collectOutput( wt, @@ -135,7 +141,7 @@ export class RunExecutor { const finished = await finishWith( this.finishPatchForError(err, tokenUsage, t0, run.id), ); - // 非预期异常(非 PrAgentRunError):落 failed 后仍把异常往上抛,避免吞掉。 + // Unexpected exception (not PrAgentRunError): after persisting failed, still rethrow to avoid swallowing it. if (!(err instanceof PrAgentRunError)) throw err; return finished; } finally { @@ -144,10 +150,10 @@ export class RunExecutor { } /** - * 成功路径收尾 patch:parsed.llmFailure → failed(reason=llm-error),否则 succeeded。 - * pr-agent CLI 可能 exit 0 但 stdout 其实是 LLM 调用全失败(litellm AuthenticationError / - * "Failed to generate prediction with any model" 等 marker)→ 不算 succeeded,UI 用红色失败 chip 渲染。 - * stdout 持久化「LLM 真实产出」(文件内容);原 stdout 留作日志在折叠区供排障。 + * Success-path finalization patch: parsed.llmFailure → failed(reason=llm-error), otherwise succeeded. + * The pr-agent CLI may exit 0 while stdout is actually a total LLM-call failure (litellm AuthenticationError / + * "Failed to generate prediction with any model" and similar markers) → not counted as succeeded, UI renders a red failure chip. + * stdout persists the "real LLM output" (file content); the original stdout is kept as a log in a collapsed area for troubleshooting. */ private finishPatchForResult( result: { exitCode: number; stdout: string; stderr: string }, @@ -173,7 +179,7 @@ export class RunExecutor { { runId, reason: parsed.llmFailure.message }, 'pragent exit 0 but LLM call failed; marking run as failed', ); - // 失败任务不做结构化采集——findings 置空,UI 只展示原始输出(不转 chatpane finding 卡)。 + // Failed runs get no structured collection — findings set empty, UI shows only raw output (no chatpane finding card). return { ...base, status: 'failed', @@ -187,14 +193,15 @@ export class RunExecutor { status: 'succeeded', findings: parsed.findings, summary: parsed.summary, - // 复评裁决(解析自复评 /ask 的 <verdict>);非复评 / 未给则 undefined。 + // Re-review verdict (parsed from the re-review /ask's <verdict>); undefined if not a re-review / not given. askVerdict: parsed.askVerdict, }; } /** - * 异常路径收尾 patch:PrAgentRunError → cancelled(用户取消)/ failed(其它 reason),尽量解析已收集的 - * 部分 stdout + 记已产生的 token 用量;其它非预期异常 → failed(仅 errorMessage,避免 run 卡在 running)。 + * Error-path finalization patch: PrAgentRunError → cancelled (user cancel) / failed (other reason), parsing + * whatever partial stdout was collected + recording token usage already produced; other unexpected exceptions + * → failed (errorMessage only, to avoid a run stuck in running). */ private finishPatchForError( err: unknown, @@ -203,13 +210,13 @@ export class RunExecutor { runId: string, ): FinishPatch { if (err instanceof PrAgentRunError) { - // 用户主动取消 → cancelled,其它 reason → failed;二者都落盘让 UI 能从历史 run 里看到该事件。 + // User-initiated cancel → cancelled, other reason → failed; both are persisted so the UI can see the event in run history. const status: ReviewRunStatus = err.reason === 'cancelled' ? 'cancelled' : 'failed'; this.ctx.logger.warn( { runId, reason: err.reason, exitCode: err.result.exitCode }, `pragent run ${status}`, ); - // 失败 / 取消的任务不做结构化采集——只保留原始输出(stdout/stderr)供展示,不解析成 finding 卡。 + // Failed / cancelled runs get no structured collection — keep only raw output (stdout/stderr) for display, not parsed into finding cards. return { status, finishedAt: new Date().toISOString(), @@ -231,7 +238,7 @@ export class RunExecutor { }; } - /** 阶段①:落盘 startReviewRun(用入队预分配 runId)+ 标记 startedAt 并通知调度层广播 + 记日志。 */ + /** Stage 1: persist startReviewRun (using the runId pre-assigned at enqueue) + mark startedAt + notify scheduling layer to broadcast + log. */ private async startRun( item: QueueItem, bridge: PrAgentBridge, @@ -240,10 +247,10 @@ export class RunExecutor { const { bootstrap, logger } = this.ctx; const { req, pr } = item; const stateStore = await this.ctx.pr.storeForPr(pr.localId); - // 提前 resolve active LLM profile — model 字段要随 startReviewRun 一起落盘,让 UI 在 meta 行展示 - // "这次 run 用的什么模型"(持久化用 profile.model 原文,不做 normalizeModel 前缀处理,跟 Settings 一致)。 + // Resolve the active LLM profile early — the model field must be persisted together with startReviewRun so the + // UI shows "which model this run used" in the meta row (persist profile.model verbatim, no normalizeModel prefix handling, consistent with Settings). const activeLlmForRecord = resolveActiveLlmProfile(bootstrap.config.llm); - // 用入队预分配的 runId 覆盖 startReviewRun 的自生 id,让 cancel(runId) 在 active 状态也能精确定位。 + // Override startReviewRun's self-generated id with the runId pre-assigned at enqueue, so cancel(runId) can precisely locate it even in active state. const run = await startReviewRun(stateStore, { id: item.info.runId, prLocalId: pr.localId, @@ -252,14 +259,14 @@ export class RunExecutor { prAgentVersion: bridge.version, strategy: bridge.strategy, model: activeLlmForRecord?.model || undefined, - // 复评引用前向链:随 run 落盘,UI 据此在 /ask 卡上展示「复评自…」徽标 + 裁决动作。 + // Re-review reference forward chain: persisted with the run, the UI uses it to show a "re-reviewed from…" badge + verdict action on the /ask card. referencedFinding: req.tool === 'ask' ? req.referencedFinding : undefined, - // 触发来源随 run 落盘:user 来源的 run 由 ChatPane 补命令回显气泡;agent 子 run 不回显。 + // Trigger origin persisted with the run: user-origin runs get a command echo bubble added by ChatPane; agent sub-runs don't echo. origin: item.priority, - // 单 commit 评审范围随 run 落盘:结果卡据此展示范围徽标。 + // Single-commit review scope persisted with the run: the result card uses it to show a scope badge. scope: req.scope, }); - // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播(经调度层)。 + // Upgrade the info (startedAt=null at enqueue) to active form + broadcast (via the scheduling layer). item.info = { ...item.info, startedAt: run.startedAt }; notifyStarted(); logger.info( @@ -270,30 +277,31 @@ export class RunExecutor { } /** - * 阶段②:同步镜像 + 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 - * 缺省按固定 merge-base 定界 PR 全量(head=PR 源 sha,base=merge-base);传入单 commit 范围(scope)时 - * 改按该 commit 自身改动定界(head=scope.sha,base=scope.parent),pr-agent 只见 parent..sha 的 diff。 + * Stage 2: sync mirror + materialize worktree (same source as the UI diff; review is based on the PR's forked changes). + * By default bounds the full PR by a fixed merge-base (head=PR source sha, base=merge-base); when a single-commit scope + * is passed, bounds by that commit's own changes instead (head=scope.sha, base=scope.parent), so pr-agent sees only the parent..sha diff. */ private async prepareWorkspace(pr: QueueItem['pr'], scope?: QueueItem['req']['scope']) { const { repoMirror, pr: prService } = this.ctx; const repoId = prService.repoIdentityFor(pr); - // 走 ensureMirrorReadyForPr(而非裸 syncMirror):与 UI diff 同源,且复用其自愈——源分支被删 / 强推后 - // 按平台精确 fetch PR 头引用补齐 head sha,否则 materializeWorktree 建 head 分支会因对象缺失失败。 + // Use ensureMirrorReadyForPr (rather than bare syncMirror): same source as the UI diff, and reuses its self-healing — + // after the source branch is deleted / force-pushed, it precisely fetches the PR head ref per platform to fill in the head sha, + // otherwise materializeWorktree building the head branch would fail on the missing object. await prService.ensureMirrorReadyForPr(pr); if (scope) { - // 单 commit 范围:head=目标 commit,base=其父 commit → LOCAL__TARGET_BRANCH 指向 parent, - // pr-agent 只见该 commit 自身改动。parent 是 head 的祖先、随镜像同步而在,无需另取。 + // Single-commit scope: head=target commit, base=its parent commit → LOCAL__TARGET_BRANCH points at parent, + // pr-agent sees only that commit's own changes. parent is an ancestor of head and present via mirror sync, no extra fetch needed. return repoMirror.materializeWorktree(repoId, scope.sha, scope.parent, pr.localId); } - // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base,而非 targetRef.sha 漂移后混入别的 PR 的两点对比。 + // pr-agent's LOCAL__TARGET_BRANCH uses a fixed merge-base, rather than a two-dot comparison that would mix in another PR after targetRef.sha drifts. const diffBase = await prService.resolveDiffBaseSha(pr); return repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase, pr.localId); } /** - * 阶段③:组装 bridge.run 的 env + 位置参数。代理 env 铺底 + buildToolEnv(凭据/模型/响应语言/per-tool), - * 再注入 EXTRA_INSTRUCTIONS(PR 上下文 + 命中规则,local provider 不会自己拉,须现读;/ask 跳过)。 - * /ask 把问题作位置参数并在末尾追加目标语言要求(近因位置提升按 UI 语言作答的遵循度)。 + * Stage 3: assemble bridge.run's env + positional args. Proxy env as the base + buildToolEnv (credentials/model/response language/per-tool), + * then inject EXTRA_INSTRUCTIONS (PR context + matched rules; the local provider won't fetch them itself, must read now; /ask skips this). + * /ask passes the question as a positional arg and appends the target-language requirement at the end (recency position improves adherence to answering in the UI language). */ private async buildInvocation( req: QueueItem['req'], @@ -307,8 +315,8 @@ export class RunExecutor { }> { const { bootstrap, logger, ensureAgentDir, pr: prService } = this.ctx; const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); - // 代理 env 先铺底(非 pr-agent 范畴,仅 HTTP(S)_PROXY 类);LLM 凭据/模型 + 响应语言 + per-tool 配置 - // 由 bridge 的 buildToolEnv 按意图组装——契约 key 收口在 @meebox/pr-agent-bridge。 + // Proxy env as the base first (not pr-agent's domain, just HTTP(S)_PROXY-type); LLM credentials/model + response language + per-tool config + // are assembled by intent via the bridge's buildToolEnv — contract keys are consolidated in @meebox/pr-agent-bridge. const env: Record<string, string> = { ...buildProxyEnv(bootstrap.config.proxy), ...buildToolEnv(activeLlm, { @@ -319,8 +327,8 @@ export class RunExecutor { }), }; - // CLI 模式 /ask:把子进程 cwd 落到(待净化的)worktree,让自由问答能读完整文件(shim cli/install.py - // 据此 env 切 cwd)。describe/review 不下发、维持中性临时目录;API 模式不涉及(远程接口只有 diff)。 + // CLI mode /ask: set the subprocess cwd to the (to-be-sanitized) worktree so free-form Q&A can read full files + // (shim cli/install.py switches cwd based on this env). describe/review don't set it and keep a neutral temp dir; API mode is unaffected (the remote interface only has the diff). if (req.tool === 'ask' && activeLlm?.provider === 'cli') { env['MEEBOX_CLI_WORKDIR'] = wtPath; } @@ -354,34 +362,36 @@ export class RunExecutor { matchedRuleInstructions = combineRuleInstructions(matched); matchedRuleIds = matched.map((r) => r.id); } - // 始终记一条:让用户从日志确认规则加载/命中情况(0 命中也输出,便于排查「为何规则没生效」)。 + // Always log one line: let users confirm rule loading/matching from logs (output even on 0 matches, to help debug "why the rule didn't take effect"). logger.info( { runId, tool: req.tool, rulesLoaded: rules.length, rulesMatched: matched.length, ruleIds: matchedRuleIds }, 'pragent run: rules', ); } - // 提示词组装收口到 @meebox/pr-agent-bridge 的 prompts:语言指示 / anchor marker / 排版 / PR 上下文 / 命中规则。 + // Prompt assembly is consolidated into @meebox/pr-agent-bridge's prompts: language directive / anchor marker / formatting / PR context / matched rules. const extraInstructions = buildExtraInstructions({ tool: req.tool, language: getMainLanguage(), prContext, matchedRuleInstructions, - // /ask 选中行引用 + 复评裁决:拼进「问题」(user turn),见下方 askQuestion 组装。 + // User-defined code-suggestion spec (settings): injected for /improve /review /ask (gated inside buildExtraInstructions); /describe excluded. + codeSuggestionSpec: bootstrap.config.agent.strategy.code_suggestion_spec, + // /ask selected-line reference + re-review verdict: spliced into the "question" (user turn), see askQuestion assembly below. referencedContext: req.tool === 'ask' ? req.referencedContext : undefined, - // /ask 复评模式:引用了某条 finding 时注入裁决(replace/keep/drop)指示。 + // /ask re-review mode: inject verdict (replace/keep/drop) directive when a finding is referenced. referencedFinding: req.tool === 'ask' ? !!req.referencedFinding : undefined, - // /ask 代码建议数量软约束(与 /review /improve 共用同一设置)。 + // /ask code-suggestion count soft constraint (shares the same setting as /review /improve). maxCodeSuggestions: req.tool === 'ask' ? bootstrap.config.agent.strategy.max_code_suggestions : undefined, - // /ask 代码检索指引:仅 CLI 提供方(子进程 cwd 落在完整 worktree、可用文件工具)注入,引导定向检索 - // (内置只读搜索 / grep 查符号 · 只读所需行段)替代整文件通读,降低 agentic 探索成本。刻意只用只读工具集 - // (headless default 模式下非只读工具会中止会话,故不诱导 rg)。API 提供方无文件访问、不注入。 + // /ask code-retrieval guidance: injected only for the CLI provider (subprocess cwd is in the full worktree, file tools available), steering targeted retrieval + // (built-in read-only search / grep for symbols · read only the needed line ranges) instead of reading whole files, lowering agentic exploration cost. Deliberately uses only the read-only tool set + // (in headless default mode non-read-only tools abort the session, so don't induce rg). The API provider has no file access, not injected. worktreeRetrieval: req.tool === 'ask' && activeLlm?.provider === 'cli', }); - // /ask 的 pr_questions prompt **不渲染 extra_instructions**(与 describe/review/improve 不同), - // 经 env 注入对 /ask 是死字段。故 /ask 的指令改为拼进「问题」(user turn,见下方 askQuestion), - // env 注入仅用于其它三个工具。 + // /ask's pr_questions prompt **does not render extra_instructions** (unlike describe/review/improve), + // so env injection is a dead field for /ask. Thus /ask's instructions are instead spliced into the "question" + // (user turn, see askQuestion below); env injection is only used for the other three tools. if (extraInstructions && req.tool !== 'ask') { env[extraInstructionsEnvKey(req.tool)] = extraInstructions; } @@ -392,15 +402,15 @@ export class RunExecutor { ); } - // ask 工具:问题作为位置参数(user turn,spawn args 单元素,含空格也是一个 arg 不切分),并在问题 - // **末尾**硬性追加语言要求。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量 - // 英文 diff 盖过 → 模型用英文作答;在 user turn 末尾(近因位置、用目标语言书写)再要求一次。en-US 返回空。 + // ask tool: the question is a positional arg (user turn, a single spawn-args element; spaces don't split it into multiple args), + // and the language requirement is hard-appended at the **end** of the question. System-side CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS + // for free-form Q&A are often drowned out by the large English diff → the model answers in English; ask again at the end of the user turn (recency position, written in the target language). en-US returns empty. const askLangSuffix = req.tool === 'ask' ? askLanguageSuffixFor(getMainLanguage()) : ''; let askQuestion: string | undefined; if (req.tool === 'ask' && req.question) { - // /ask 的指令(结构化分段 / anchor marker / 复评裁决 / 引用上下文)拼进 user turn——pr_questions - // 不读 extra_instructions,唯有问题文本真正到达模型。语言后缀放最末(近因位置最促使按目标语言作答)。 - // 回显(pr-agent 把问题原样写进产物)由 collectOutput 的 stripAskQuestionEcho 整段剥掉。 + // /ask's instructions (structured sections / anchor marker / re-review verdict / referenced context) are spliced into the user turn — + // pr_questions doesn't read extra_instructions, only the question text actually reaches the model. The language suffix goes last (recency position most encourages answering in the target language). + // The echo (pr-agent writes the question verbatim into the artifact) is stripped entirely by collectOutput's stripAskQuestionEcho. const parts = [req.question]; if (extraInstructions) parts.push(extraInstructions); if (askLangSuffix) parts.push(askLangSuffix); @@ -411,9 +421,9 @@ export class RunExecutor { } /** - * 阶段⑤:读 local provider 写到 worktree 根的产物文件(落盘文件名见 PRAGENT_LOCAL_OUTPUT),/ask 去掉 - * 回显的问题行,解析为 findings/summary;/review 成功时丢弃旧 pending 草稿(让本轮 finding 成新候选源)。 - * 文件缺失则回退用 stdout 解析。返回解析结果 + 原始文件内容(供收尾拼日志)。 + * Stage 5: read the artifact file the local provider wrote to the worktree root (persisted filename see PRAGENT_LOCAL_OUTPUT), /ask removes + * the echoed question line, parse into findings/summary; on /review success drop old pending drafts (letting this round's findings become the new candidate source). + * If the file is missing, fall back to parsing stdout. Returns the parse result + raw file content (for finalization log splicing). */ private async collectOutput( wt: { path: string }, @@ -424,7 +434,7 @@ export class RunExecutor { ): Promise<{ parsed: ReturnType<typeof parseReviewOutput>; fileContent: string }> { const { logger, broadcast } = this.ctx; const stateStore = await this.ctx.pr.storeForPr(req.localId); - // cleanup 前必须先把文件读出来(与 buildToolEnv 的 LOCAL__REVIEW_PATH 同源)。 + // The file must be read out before cleanup (same source as buildToolEnv's LOCAL__REVIEW_PATH). const outFile = PRAGENT_LOCAL_OUTPUT[req.tool]; let fileContent = ''; try { @@ -435,22 +445,22 @@ export class RunExecutor { 'pr-agent local provider output file missing; fall back to stdout', ); } - // /ask 输出里 pr-agent 把问题原样回显在 answer body 顶部(跟 chat 输入气泡重复);解析前逐字删掉。 + // In /ask output pr-agent echoes the question verbatim at the top of the answer body (duplicating the chat input bubble); delete it verbatim before parsing. const cleanedContent = req.tool === 'ask' && req.question?.trim() ? stripAskQuestionEcho(fileContent, req.question, askLangSuffix) : fileContent; const parsed = parseReviewOutput(cleanedContent || resultStdout, req.tool); - // 复评 /ask(引用了某条 finding): - // - 裁决 replace → 把建议提升为带定位的代码评论(取原 finding 的 anchor),渲染 / 采纳同 /review 代码反馈; - // - 裁决 replace / drop → 静默关闭被引用的原 finding(建立关闭关系 + 广播),无需用户手动点关闭。 - // keep / 无裁决:原评论保留、不动。 + // Re-review /ask (a finding was referenced): + // - verdict replace → promote the suggestion to a positioned code comment (taking the original finding's anchor), rendered / adopted like /review code feedback; + // - verdict replace / drop → silently close the referenced original finding (establish the closure relation + broadcast), no need for the user to manually click close. + // keep / no verdict: the original comment is kept, untouched. if (req.tool === 'ask' && req.referencedFinding && parsed.askVerdict && !parsed.llmFailure) { const ref = req.referencedFinding; const anchor = ref.anchor; if (parsed.askVerdict === 'replace' && anchor && typeof anchor.startLine === 'number') { - // 已自带定位的 code-suggestion(模型按 marker 锚到引用处)保持不动;否则把建议(退到 summary) - // 兜底锚到被引用评论的原位置并升为代码反馈,保证取代评论始终带定位、可采纳。 + // A code-suggestion that already carries positioning (the model anchored to the reference via the marker) is left untouched; otherwise the suggestion (fallen back to summary) + // is fallback-anchored to the referenced comment's original position and promoted to code feedback, ensuring the replacing comment always carries positioning and is adoptable. const sug = parsed.findings.find( (f) => f.sectionKey === 'code-suggestion' || f.sectionKey === 'ask-suggestions', @@ -475,7 +485,7 @@ export class RunExecutor { } } } - // M4 草稿再摄入:/review 成功完成时丢掉 pending+finding 旧草稿(edited/posted/rejected/manual 保留)。 + // M4 draft re-ingestion: on successful /review completion drop old pending+finding drafts (edited/posted/rejected/manual are kept). if (req.tool === 'review') { try { const dropped = await dropPendingFindingDrafts(stateStore, req.localId); @@ -494,10 +504,10 @@ export class RunExecutor { } /** - * embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 .secrets.toml - * (pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥不用 secrets.toml,写个空 - * 文件压掉告警)。memo 化:只在首个 embedded run 解析一次目录 + 写文件,后续直接复用。 - * importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 + * embedded strategy: at execution time write an empty .secrets.toml into the embedded install dir's settings/ and settings_prod/ + * (pr-agent looks for this file at startup and prints a WARNING when missing; we pass secrets via env and don't use secrets.toml, so write an empty + * file to suppress the warning). Memoized: resolve dir + write file only once on the first embedded run, reused afterwards. + * importlib.util.find_spec only locates pr_agent without importing it, fast; on failure only warn, don't block the run. */ private ensureEmbeddedSecrets(pythonPath: string): Promise<void> { this.embeddedSecretsEnsured ??= (async () => { diff --git a/apps/desktop/src/main/services/pr-agent/run-queue.ts b/apps/desktop/src/main/services/pr-agent/run-queue.ts index 44a3164c..1d09b71e 100644 --- a/apps/desktop/src/main/services/pr-agent/run-queue.ts +++ b/apps/desktop/src/main/services/pr-agent/run-queue.ts @@ -10,12 +10,12 @@ import { import type { ServiceContext } from '../context.js'; import { RunExecutor } from './run-executor.js'; -/** pr-agent run 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ +/** pr-agent run priority lane: user (manually initiated, high) / agent (orchestration / AutoPilot dispatch, low). */ export type RunPriority = 'user' | 'agent'; /** - * 队列项:一次入队的 pr-agent run 的全部上下文(含 resolve/reject 回原始调用方)。归调度器所有;执行器 - * (run-executor)仅以 `import type` 引用本类型,类型在运行时被擦除,故不构成运行时循环依赖。 + * Queue item: all context of one enqueued pr-agent run (including resolve/reject back to the original caller). Owned by the scheduler; the executor + * (run-executor) references this type only via `import type`, and the type is erased at runtime, so it forms no runtime circular dependency. */ export interface QueueItem { info: PragentRunInfo; @@ -30,35 +30,35 @@ export interface QueueItem { pr: StoredPullRequest; resolve: (run: ReviewRun) => void; reject: (err: Error) => void; - /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ + /** Priority lane: user (manually initiated, high) / agent (orchestration / AutoPilot dispatch, low). */ priority: RunPriority; - /** 仅 active 状态填;用于 cancel SIGKILL */ + /** Filled only in active state; used for cancel SIGKILL */ ac?: AbortController; } /** - * pr-agent run 队列服务。 + * pr-agent run queue service. * - * FIFO 队列,并发上限 maxConcurrency(post-Docker 下每个 run 独立 worktree + 独立子进程, - * 并发安全)。其余在 waiting 排队;每次 active 完成 / 取消 → 自动泵下一条。 + * FIFO queue, concurrency cap maxConcurrency (post-Docker, each run has an independent worktree + independent subprocess, + * concurrency-safe). The rest queue in waiting; each time an active run completes / cancels → automatically pump the next one. * - * 设计要点: - * - runId 在入队时就分配(跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 active / waiting - * 两种状态都能精确定位 - * - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact - * - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent - * - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 + * Design points: + * - runId is assigned at enqueue (consistent with the finally-persisted ReviewRun.id), so cancel(runId) can precisely locate it + * in both active / waiting states + * - queued state is not persisted; when cancelled it directly rejects the original Promise, leaving no disk artifact + * - only on actual dequeue does startReviewRun write disk + run pr-agent + * - each queue change broadcasts 'pragent:queueChanged', syncing the renderer store * - * 队列与运行态(waiting / active / 并发上限)是实例可变状态,故以 class 封装;PR 领域操作 - * (镜像 / diff base / adapter)经注入的 ctx.pr 取用。 + * The queue and running state (waiting / active / concurrency cap) are instance-mutable state, hence encapsulated in a class; PR domain operations + * (mirror / diff base / adapter) are accessed via the injected ctx.pr. */ export class RunQueue { private readonly waiting: QueueItem[] = []; - /** 并发运行中的 run(runId → item);上限 maxConcurrency。 */ + /** Concurrently running runs (runId → item); capped at maxConcurrency. */ private readonly active = new Map<string, QueueItem>(); - /** 并发上限;可经 setMaxConcurrency 热替换(config:setMaxConcurrency)。 */ + /** Concurrency cap; hot-swappable via setMaxConcurrency (config:setMaxConcurrency). */ private maxConcurrency: number; - /** run 执行器(落盘 / worktree / spawn / 解析收尾);调度与执行分离,本类只负责并发 / 优先级 / 取消。 */ + /** Run executor (persist / worktree / spawn / parse finalize); scheduling and execution are separated, this class only handles concurrency / priority / cancel. */ private readonly executor: RunExecutor; constructor(private readonly ctx: ServiceContext) { @@ -67,8 +67,8 @@ export class RunQueue { } /** - * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR - * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 + * Enqueue a pr-agent run (shares the same queue / concurrency / cancel mechanism as a user's manual run). dedup: if the same PR + * with the same tool is already executing / queued, throw (/ask is unrestricted). Resolves the completed ReviewRun. */ enqueuePragentRun( pr: StoredPullRequest, @@ -80,8 +80,8 @@ export class RunQueue { scope?: ReviewRun['scope'], ): Promise<ReviewRun> { const { logger } = this.ctx; - // dedup 仅约束「PR 全量」的同工具重复;/ask 每次问题不同、单 commit 范围(scope)是定向动作,均放行 - // (允许全量 review 之外再对某 commit 单独 review,互不视作重复)。 + // dedup only constrains same-tool duplicates for the "full PR"; /ask (a different question each time) and single-commit scope (a targeted action) are both let through + // (allowing a per-commit review in addition to the full review, without treating them as duplicates). if (tool !== 'ask' && !scope) { const sameTask = (q: QueueItem): boolean => q.info.prLocalId === pr.localId && q.info.tool === tool; @@ -89,7 +89,7 @@ export class RunQueue { throw new AppError(ERROR_CODES.AG_DUPLICATE_TASK, { tool }); } } - // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 + // Assign runId at enqueue; a later cancel(runId) can locate it in both waiting / active const runId = makeRunId(new Date()); return new Promise<ReviewRun>((resolve, reject) => { const item: QueueItem = { @@ -105,15 +105,15 @@ export class RunQueue { enqueuedAt: new Date().toISOString(), startedAt: null, }, - // referencedContext / referencedFinding 仅入 req(内存态,不进 info/PragentRunInfo)→ 不进队列广播。 - // referencedFinding 会在 run-executor startRun 时落到 ReviewRun(前向链持久化)。 + // referencedContext / referencedFinding only go into req (in-memory state, not into info/PragentRunInfo) → not in the queue broadcast. + // referencedFinding is persisted to the ReviewRun during run-executor startRun (forward-chain persistence). req: { localId: pr.localId, tool, question, referencedContext: tool === 'ask' ? referencedContext : undefined, referencedFinding: tool === 'ask' ? referencedFinding : undefined, - // 单 commit 范围对所有工具生效(不限 ask):executor 据此物化 parent..sha 的 worktree。 + // Single-commit scope applies to all tools (not just ask): the executor uses it to materialize the parent..sha worktree. scope, }, pr, @@ -121,7 +121,7 @@ export class RunQueue { resolve, reject, }; - // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 + // Priority jump-in: user tasks queue ahead of all agent tasks (still FIFO within the same lane); does not interrupt a running run. if (priority === 'user') { const firstAgentIdx = this.waiting.findIndex((q) => q.priority === 'agent'); if (firstAgentIdx >= 0) this.waiting.splice(firstAgentIdx, 0, item); @@ -138,25 +138,25 @@ export class RunQueue { } /** - * 热替换并发上限(config:setMaxConcurrency)。调大后立即泵队列填满新名额;调小不打断在跑的 run, - * 自然随其完成收敛(pump 仅在 active 降到新上限以下才起跑后续)。 + * Hot-swap the concurrency cap (config:setMaxConcurrency). After raising it, immediately pump the queue to fill the new slots; lowering it does not interrupt running runs, + * converging naturally as they complete (pump only starts subsequent runs once active drops below the new cap). */ setMaxConcurrency(max: number): void { this.maxConcurrency = max; this.pump(); } - /** 取消一个 run(pragent:cancel):active→SIGKILL;waiting→出队 + reject;都不匹配→ok:false。 */ + /** Cancel one run (pragent:cancel): active→SIGKILL; waiting→dequeue + reject; neither matches→ok:false. */ cancel(runId: string): { ok: boolean } { const { logger } = this.ctx; - // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) + // active hit → SIGKILL (finally will write cancelled to disk) const running = this.active.get(runId); if (running) { logger.info({ runId }, 'pragent run cancel: active'); running.ac?.abort(); return { ok: true }; } - // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) + // waiting hit → remove from queue + reject the original Promise, no disk write (never actually ran) const idx = this.waiting.findIndex((q) => q.info.runId === runId); if (idx >= 0) { const [removed] = this.waiting.splice(idx, 1); @@ -168,7 +168,7 @@ export class RunQueue { return { ok: false }; } - /** 当前队列快照(pragent:queue / 广播用)。 */ + /** Current queue snapshot (for pragent:queue / broadcast). */ snapshot(): { active: PragentRunInfo[]; waiting: PragentRunInfo[] } { return { active: [...this.active.values()].map((q) => q.info), @@ -176,7 +176,7 @@ export class RunQueue { }; } - /** 取消某 PR 的全部 run:active 的 SIGKILL,waiting 的出队 + reject。 */ + /** Cancel all runs for a PR: SIGKILL the active ones, dequeue + reject the waiting ones. */ cancelRunsForPr(localId: string): void { for (const item of this.active.values()) if (item.req.localId === localId) item.ac?.abort(); let removed = false; @@ -190,7 +190,7 @@ export class RunQueue { if (removed) this.broadcastQueueChanged(); } - /** active + waiting 涉及的 PR localId 集合(terminateAgentsForGonePrs 用)。 */ + /** Set of PR localIds involved in active + waiting (used by terminateAgentsForGonePrs). */ queuedPrLocalIds(): string[] { const ids: string[] = []; for (const item of this.active.values()) ids.push(item.req.localId); @@ -198,7 +198,7 @@ export class RunQueue { return ids; } - /** 应用退出时中止所有进行中的 run,返回被中止的 run 数。 */ + /** Abort all in-progress runs on app exit, returning the number of aborted runs. */ abortAllActiveRuns(): number { let n = 0; for (const item of this.active.values()) { @@ -213,8 +213,8 @@ export class RunQueue { } /** - * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 - * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 + * Queue pump: while concurrency is below the cap and waiting is non-empty, continuously dequeue and start runs until maxConcurrency is filled. + * After each run ends (success/failure/cancel) it is removed from active and the pump runs again, naturally continuing subsequent tasks. */ private pump(): void { while (this.active.size < this.maxConcurrency && this.waiting.length > 0) { @@ -230,7 +230,7 @@ export class RunQueue { .finally(() => { this.active.delete(item.info.runId); this.broadcastQueueChanged(); - // 放微任务里再泵,避免递归栈累积 + // Pump again in a microtask to avoid recursive stack buildup queueMicrotask(() => this.pump()); }); } diff --git a/apps/desktop/src/main/services/pr-agent/usage.ts b/apps/desktop/src/main/services/pr-agent/usage.ts index 7b48bc63..33ba4076 100644 --- a/apps/desktop/src/main/services/pr-agent/usage.ts +++ b/apps/desktop/src/main/services/pr-agent/usage.ts @@ -1,6 +1,6 @@ import type { TokenUsage } from '@meebox/shared'; -// litellm usage 哨兵行前缀(与 sitecustomize.py 的 _emit 保持一致)。 +// litellm usage sentinel-line prefix (kept consistent with sitecustomize.py's _emit). export const USAGE_SENTINEL = '@@MEEBOX_USAGE@@'; export interface UsageAcc { @@ -8,22 +8,22 @@ export interface UsageAcc { completion: number; total: number; calls: number; - /** 累计提示缓存读取 token(cache_read),是 prompt 的一部分 */ + /** Cumulative prompt-cache read tokens (cache_read), part of prompt */ cacheRead: number; - /** 累计模型交互轮次:CLI agentic 模式来自各次哨兵的 num_turns(一次 run 内可累加多段) */ + /** Cumulative model interaction turns: in CLI agentic mode comes from each sentinel's num_turns (can accumulate multiple segments within one run) */ turns: number; any: boolean; } -/** 新建一个空 usage 累加器。 */ +/** Create a new empty usage accumulator. */ export function newUsageAcc(): UsageAcc { return { prompt: 0, completion: 0, total: 0, calls: 0, cacheRead: 0, turns: 0, any: false }; } /** - * 解析一行 stderr:若含 usage 哨兵(`@@MEEBOX_USAGE@@ {json}`,sitecustomize 注入)则累加到 - * acc 并返回 true(调用方据此吞掉该行、不转发给 renderer / 不入日志)。普通行返回 false。 - * 坏 JSON 也返回 true(仍吞掉,避免漏进实时日志),只是不计数。容错优先。 + * Parse one stderr line: if it contains a usage sentinel (`@@MEEBOX_USAGE@@ {json}`, injected by sitecustomize), accumulate into + * acc and return true (the caller thus swallows the line, not forwarding to the renderer / not logging). Normal lines return false. + * Bad JSON also returns true (still swallowed, to avoid leaking into live logs), just not counted. Fault-tolerance first. */ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { const i = line.indexOf(USAGE_SENTINEL); @@ -52,29 +52,29 @@ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { if (typeof r.cache_read_tokens === 'number') acc.cacheRead += r.cache_read_tokens; if (typeof r.turns === 'number') acc.turns += r.turns; } catch { - // 坏哨兵行:仍吞掉,不计数 + // Bad sentinel line: still swallowed, not counted } return true; } -/** 累加器 → TokenUsage;无任何有效数据返回 undefined(未捕获到,如非 embedded / 流式 / 未调 LLM)。 */ +/** Accumulator → TokenUsage; returns undefined if there's no valid data (nothing captured, e.g. non-embedded / streaming / LLM never called). */ export function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { if (!acc.any) return undefined; return { promptTokens: acc.prompt, completionTokens: acc.completion, - // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 + // Prefer accumulating each call's total; fall back to prompt+completion when an individual call lacks total totalTokens: acc.total || acc.prompt + acc.completion, calls: acc.calls, - // cache_read 无命中(0)则不带;turns 优先 CLI 上报的轮次,缺失回退为调用次数 + // Omit cache_read when there's no hit (0); turns prefers the CLI-reported turns, falling back to the call count when missing cacheReadTokens: acc.cacheRead || undefined, turns: acc.turns || acc.calls, }; } /** - * 持久化前从 stderr 去掉 usage 哨兵行:onLine 实时已拦截不转发,但 exec 内部把全量 stderr - * 累加进 result.stderr(含哨兵),落盘前清掉这些噪声行。 + * Strip usage sentinel lines from stderr before persistence: onLine already intercepts them in real time without forwarding, but exec internally + * accumulates all stderr into result.stderr (including sentinels), so clear these noise lines before persisting. */ export function stripUsageSentinels(stderr: string | undefined): string | undefined { if (!stderr) return stderr; diff --git a/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts b/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts index e3f29a99..c54ec405 100644 --- a/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts +++ b/apps/desktop/src/main/services/pr-agent/worktree-sanitize.ts @@ -3,33 +3,33 @@ import path from 'node:path'; import type { Logger } from 'pino'; /** - * CLI 模式下 /ask 会把子进程 cwd 落到一次性 worktree 以取得完整文件上下文(见 run-executor + - * pragent-shim cli/install.py)。但被评审仓库可能自带 agent 指令文件(claude / codex / gemini / - * cursor / copilot 的项目记忆),在 cwd 命中后会被 CLI 自动加载、污染 /ask 回答(含潜在 prompt 注入)。 + * In CLI mode /ask sets the subprocess cwd to a one-shot worktree to obtain full file context (see run-executor + + * pragent-shim cli/install.py). But the reviewed repo may carry its own agent instruction files (project memory for + * claude / codex / gemini / cursor / copilot), which after the cwd lands are auto-loaded by the CLI and pollute the /ask answer (including potential prompt injection). * - * 这里把 worktree 内这些指令文件**清空(present-but-blank)**:文件仍在、内容为空 → CLI 加载到空指令, - * 等同未配置。worktree 用后即弃(cleanup 直接 rm -rf),就地改写无副作用;pr-agent 的 diff 走 commit 级 - * merge-base,不读工作树状态,故清空不影响评审 diff。仅 /ask 走此路径,describe/review 维持中性临时目录。 + * Here we **blank out (present-but-blank)** these instruction files within the worktree: the file remains, content empty → the CLI loads empty instructions, + * equivalent to unconfigured. The worktree is discarded after use (cleanup just rm -rf), so in-place rewriting has no side effects; pr-agent's diff uses a commit-level + * merge-base and doesn't read working-tree state, so blanking doesn't affect the review diff. Only /ask takes this path; describe/review keep a neutral temp dir. */ -/** 按文件名匹配、任意层级都清空的项目记忆文件(claude / codex / gemini)。 */ +/** Project-memory files matched by filename and blanked at any level (claude / codex / gemini). */ const INSTRUCTION_BASENAMES = new Set(['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules']); -/** 递归时跳过的目录(体积大 / 与指令无关)。 */ +/** Directories skipped during recursion (large / unrelated to instructions). */ const SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor']); -/** 根级固定路径的指令资源(相对 worktree 根,path.sep 归一)。 */ +/** Fixed-path instruction resource at the root level (relative to worktree root, path.sep normalized). */ const GITHUB_COPILOT = path.join('.github', 'copilot-instructions.md'); -/** rel(相对 worktree 根)是否属于需清空的指令文件。 */ +/** Whether rel (relative to worktree root) is an instruction file that needs blanking. */ function isInstructionFile(rel: string): boolean { if (INSTRUCTION_BASENAMES.has(path.basename(rel))) return true; if (rel === GITHUB_COPILOT) return true; - // cursor 规则目录 `.cursor/rules/**` 下任意文件。 + // Any file under the cursor rules directory `.cursor/rules/**`. const parts = rel.split(path.sep); if (parts[0] === '.cursor' && parts[1] === 'rules') return true; return false; } -/** 递归收集 worktree 内全部文件路径(跳过 SKIP_DIRS)。 */ +/** Recursively collect all file paths within the worktree (skipping SKIP_DIRS). */ async function collectFiles(dir: string, acc: string[]): Promise<void> { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const e of entries) { @@ -43,8 +43,8 @@ async function collectFiles(dir: string, acc: string[]): Promise<void> { } /** - * 清空一次性 worktree 内被评审仓库自带的 agent 指令文件。Best-effort:整体或单文件失败仅 warn, - * 不阻断 /ask(最坏退回「可能读到仓库指令」,不致命)。 + * Blank out the reviewed repo's own agent instruction files within the one-shot worktree. Best-effort: on overall or single-file failure only warn, + * don't block /ask (worst case falls back to "might read repo instructions", not fatal). */ export async function neutralizeWorktreeInstructions(dir: string, logger?: Logger): Promise<void> { try { diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts index b1b35c53..d972c2a0 100644 --- a/apps/desktop/src/main/services/pr-service.ts +++ b/apps/desktop/src/main/services/pr-service.ts @@ -19,38 +19,38 @@ import type { JsonFileStateStore } from '@meebox/state-store'; import type { ConnectionRuntime } from '../adapters.js'; import { broadcast } from './broadcast.js'; -/** PrService 构造依赖(由 context 注入)。 */ +/** PrService construction dependencies (injected by context). */ export interface PrServiceDeps { bootstrap: BootstrapResult; stateStore: JsonFileStateStore; - /** 归档 PR 冷存储:定位 PR 时活跃库未命中后兜底(「已关闭」视图打开已归档 PR 详情)。 */ + /** Archived PR cold storage: fallback when the active store misses during PR lookup (opening an archived PR's details from the "closed" view). */ archiveStore: JsonFileStateStore; - /** 可变连接运行时;reconfigure 原地替换内容,本服务经引用读到最新 adapters。 */ + /** Mutable connection runtime; reconfigure replaces the contents in place, and this service reads the latest adapters via the reference. */ connectionRuntime: ConnectionRuntime; repoMirror: RepoMirrorManager; } /** - * PR 领域服务:PR 定位 / 连接 adapter 解析 / 仓库镜像就位 / diff base 解析 / 评论缓存失效。 + * PR domain service: PR lookup / connection adapter resolution / repo mirror readiness / diff base resolution / comments cache invalidation. * - * 把原先散落在 common/ 的 pr-lookup·mirror·comments-cache 收拢为单一强领域类,依赖经构造注入、 - * 各方法共享 `this.deps`,避免逐函数透传。controller 一律经 `ctx.pr.<method>()` 调用;调用方 - * 应以实例方法形式调用(勿解构方法,否则丢失 this 绑定)。 + * Collects the pr-lookup·mirror·comments-cache previously scattered in common/ into a single strong domain class, with dependencies injected via the constructor and + * each method sharing `this.deps`, avoiding per-function pass-through. Controllers always call via `ctx.pr.<method>()`; callers + * should invoke as instance methods (do not destructure methods, or the this binding is lost). */ export class PrService { /** - * 按 localId 索引正在跑的 resolveDiffBaseSha。打开 PR 时 listChangedFiles / getFileContent / - * getBlame / listCommits / getCommitCount 等多个 handler 会并发解析同一 PR 的 diff-base:去重后 - * 只算一次 merge-base、只写一次 diff-base.json,避免对同一 key 的并发写(Windows 上会触发 rename - * EPERM,见 JsonFileStateStore 自愈)。 + * Indexes in-flight resolveDiffBaseSha by localId. When opening a PR, multiple handlers such as listChangedFiles / getFileContent / + * getBlame / listCommits / getCommitCount concurrently resolve the same PR's diff-base: after dedup, + * merge-base is computed once and diff-base.json is written once, avoiding concurrent writes to the same key (which triggers rename + * EPERM on Windows, see JsonFileStateStore self-heal). */ private readonly diffBaseInFlight = new Map<string, Promise<string>>(); constructor(private readonly deps: PrServiceDeps) {} /** - * 按 localId 在状态库定位 PR,找不到抛错(统一错误文案)。先查活跃库;未命中再兜底归档冷存储, - * 使「已关闭」视图打开已归档 PR 时其 diff / 评论等路径仍可解析。 + * Locate a PR by localId in the state store, throwing if not found (uniform error text). Query the active store first; on miss fall back to archived cold storage, + * so an archived PR's diff / comments paths still resolve when opened from the "closed" view. */ async findPrOrThrow(localId: string): Promise<StoredPullRequest> { const prs = await listStoredPullRequests(this.deps.stateStore); @@ -62,18 +62,18 @@ export class PrService { } /** - * 解析某 PR 的 per-PR 存储根:已归档(索引 `archivedAt` 非空)→ 归档冷存储,否则活跃存储。 + * Resolve a PR's per-PR storage root: archived (index `archivedAt` non-empty) → archived cold storage, otherwise the active store. * - * 所有 per-PR 子树读写(评论缓存 / 草稿 / 关闭关系 / 评审 run / 会话 / 台账 / diff-base 缓存)都应 - * 经此解析后落到正确的根——否则对已归档 PR 的写会落进活跃存储,被下轮 poll 对账(`relocateTree` 源覆盖 - * 目的、先清空目的)连同归档数据一并误删(见 docs/arch/99-core/01-state-storage)。索引始终只在活跃存储维护,故据它判定。 + * All per-PR subtree reads/writes (comments cache / drafts / close relations / review run / sessions / ledger / diff-base cache) should + * land on the correct root after this resolution — otherwise a write for an archived PR lands in the active store and gets erroneously deleted along with archived data by the next poll reconciliation (`relocateTree` source overwrites + * destination, clearing destination first) (see docs/arch/99-core/01-state-storage). The index is always maintained only in the active store, so decide by it. */ async storeForPr(localId: string): Promise<JsonFileStateStore> { const index = await readPrIndex(this.deps.stateStore); return index?.prs[localId]?.archivedAt ? this.deps.archiveStore : this.deps.stateStore; } - /** PR → RepoIdentity(host / projectKey / repoSlug);connection 缺失抛错。 */ + /** PR → RepoIdentity (host / projectKey / repoSlug); throws if the connection is missing. */ repoIdentityFor(pr: StoredPullRequest): RepoIdentity { const conn = this.deps.bootstrap.config.connections.find((c) => c.id === pr.connectionId); if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); @@ -84,13 +84,13 @@ export class PrService { }; } - /** PR 对应连接的 adapter;连接无 adapter 时返回 undefined。 */ + /** The adapter of the PR's connection; returns undefined when the connection has no adapter. */ adapterFor(pr: StoredPullRequest): PlatformAdapter | undefined { return this.deps.connectionRuntime.adapters.find((a) => a.connectionId === pr.connectionId) ?.adapter; } - /** 同 adapterFor,但无 adapter 时抛错(绝大多数 handler 走它)。 */ + /** Same as adapterFor, but throws when there is no adapter (the vast majority of handlers use it). */ adapterForOrThrow(pr: StoredPullRequest): PlatformAdapter { const adapter = this.adapterFor(pr); if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); @@ -98,14 +98,14 @@ export class PrService { } /** - * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha - * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR - * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 + * Guarantees mirror readiness when opening a PR. Prefer the fast path: local bare already contains both head+base shas + * → return mirrorPath directly, no remote call. Both shas present means the last sync already covered this PR's + * commit range (PR sha is immutable), so the renderer can compute the diff locally. * - * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 + * Missing sha (either one) → go through syncMirror falling back to git fetch. * - * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 - * 快速路径命中率应该很高。 + * Background poll actively syncMirror after getting PR status updates, so on a normal PR open + * the fast-path hit rate should be high. */ async ensureMirrorReadyForPr( pr: StoredPullRequest, @@ -116,13 +116,13 @@ export class PrService { this.deps.repoMirror.hasCommit(id, pr.targetRef.sha), ]); if (hasHead && hasBase) { - // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log + // Fast path: mirror already contains head + base, return directly without a remote call. Frequently hit, so no log. return { mirrorPath: this.deps.repoMirror.mirrorPath(id), freshClone: false }; } const r = await this.deps.repoMirror.syncMirror(id); - // 自愈:源分支被删 / 强推后 head sha 不在 refs/heads,syncMirror(只抓 heads + Bitbucket 通配 PR 引用) - // 仍补不齐 → 按平台 + PR 号精确 fetch PR 头引用(GitHub refs/pull/<n>/head 等,通配取不到,必须精确)。 - // 补齐后 diff base...head 才不报 "Invalid symmetric difference"。best-effort,仍缺则由下游 diff 抛可读错误。 + // Self-heal: after the source branch is deleted / force-pushed, head sha is not in refs/heads, and syncMirror (only fetches heads + Bitbucket wildcard PR refs) + // still cannot backfill → precisely fetch the PR head ref by platform + PR number (GitHub refs/pull/<n>/head etc., unreachable by wildcard, must be precise). + // Only after backfill does diff base...head not report "Invalid symmetric difference". Best-effort; if still missing, downstream diff throws a readable error. if (!(await this.deps.repoMirror.hasCommit(id, pr.sourceRef.sha))) { const refspec = pullRequestHeadRefspec(pr.platform, pr.remoteId); if (refspec) await this.deps.repoMirror.fetchRefspecs(id, [refspec]); @@ -131,24 +131,24 @@ export class PrService { } /** - * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 + * Resolve a PR diff's fixed base (merge-base) — see `@meebox/poller` diff-base-cache. * - * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, - * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs/<localId>/diff-base.json`, - * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: - * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; - * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 + * A PR diff's semantic baseline is "where the source branch forked from the target branch" = `merge-base(targetRef.sha, sourceRef.sha)`, + * not the target branch's current tip (which advances as other PRs merge in). Once computed, it is fixed in `prs/<localId>/diff-base.json`, + * and thereafter listChangedFiles / file content / commitCount / blame / pr-agent worktree all use it as base: + * - content (Monaco left column) anchored to merge-base → the editor is a true three-dot diff, and target drift no longer back-hangs other PRs' changes; + * - line anchors (comment / finding) have a fixed reference, so target drift does not misalign them. * - * 失效重算: - * - 固化 base 不再是当前 head 的祖先(源分支被 rebase); - * - 当前 target 已经成为 head 的祖先,说明源分支把目标分支 merge 进来了,旧分叉点会把 merge - * 带来的目标分支内容也算进 PR diff。 - * 算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 + * Invalidate and recompute when: + * - the fixed base is no longer an ancestor of the current head (source branch was rebased); + * - the current target has become an ancestor of head, meaning the source branch merged the target branch in, and the old fork point would count the merge-brought + * target branch content into the PR diff too. + * Uncomputable (missing object / no common ancestor) → fall back to targetRef.sha and **do not fix it**, retry next time. * - * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 + * Precondition: mirror already contains head + targetRef.sha (the diff entry has already done ensureMirrorReadyForPr / syncMirror). */ async resolveDiffBaseSha(pr: StoredPullRequest): Promise<string> { - // 并发去重:同一 PR 的多路并发解析复用同一 in-flight Promise,只算一次、只写一次 diff-base.json。 + // Concurrency dedup: multiple concurrent resolutions of the same PR reuse the same in-flight Promise, computing once and writing diff-base.json once. const existing = this.diffBaseInFlight.get(pr.localId); if (existing) return existing; const promise = this.computeDiffBaseSha(pr).finally(() => { @@ -161,7 +161,7 @@ export class PrService { private async computeDiffBaseSha(pr: StoredPullRequest): Promise<string> { const id = this.repoIdentityFor(pr); const head = pr.sourceRef.sha; - // 已归档 PR(已关闭范围打开看 diff)其 diff-base 缓存须落归档存储,避免写活跃存储被对账误删。 + // For an archived PR (opened from the closed scope to view diff), its diff-base cache must land in archived storage, to avoid a write to the active store being erroneously deleted by reconciliation. const store = await this.storeForPr(pr.localId); const cached = await readDiffBaseCache(store, pr.localId); if ( @@ -187,16 +187,16 @@ export class PrService { } /** - * 清掉某 PR 的评论缓存并广播 `comments:changed`,让 CommentsPanel / DiffView 内嵌评论重拉刷新。 - * 收口 comments reply/delete/edit 与 drafts:publishBatch 共用的链路(清 `prs/<localId>/comments` - * 缓存 → 下次 listComments force 拉远端 → 广播触发重拉)。cache miss 无所谓,吞掉异常。 + * Clear a PR's comments cache and broadcast `comments:changed`, so CommentsPanel / DiffView inline comments re-fetch and refresh. + * Consolidates the path shared by comments reply/delete/edit and drafts:publishBatch (clear `prs/<localId>/comments` + * cache → next listComments force-fetches remote → broadcast triggers a re-fetch). A cache miss is fine, swallow the exception. */ async invalidateCommentsCache(localId: string): Promise<void> { try { const store = await this.storeForPr(localId); await store.delete(`prs/${localId}/comments`); } catch { - /* cache miss 也无所谓 */ + /* cache miss is fine */ } broadcast('comments:changed', { localId }); } diff --git a/apps/desktop/src/main/utils/agent.ts b/apps/desktop/src/main/utils/agent.ts index ede5b86f..b9b99f15 100644 --- a/apps/desktop/src/main/utils/agent.ts +++ b/apps/desktop/src/main/utils/agent.ts @@ -1,6 +1,6 @@ import type { LlmProfile } from '@meebox/shared'; -/** 从 llm config 拿当前选中的 profile;active_id 空或找不到都返回 null。 */ +/** Get the currently selected profile from llm config; returns null when active_id is empty or not found. */ export function resolveActiveLlmProfile(llm: { profiles: LlmProfile[]; active_id: string; diff --git a/apps/desktop/src/main/utils/connection-state.ts b/apps/desktop/src/main/utils/connection-state.ts index c82bc07e..af2d3d3e 100644 --- a/apps/desktop/src/main/utils/connection-state.ts +++ b/apps/desktop/src/main/utils/connection-state.ts @@ -2,24 +2,26 @@ import type { PlatformUser } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * 连接级本地状态(按 connectionId 持久化在 state store)。当前只存「上次 ping 拿到的当前 - * 用户身份」用于预热 poller 判 approved(首轮即正确、不必等 ping);结构刻意留作可扩展, - * 后续可在此追加其它连接级交互状态(如最近查看时间、列表偏好等)。 + * Connection-level local state (persisted per connectionId in the state store). Currently only + * stores the "current user identity from the last ping" to warm up the poller's approved check + * (correct from the first round, no need to wait for a ping); the structure is intentionally left + * extensible so other connection-level interaction state (e.g. last-viewed time, list preferences) + * can be appended here later. */ export interface ConnectionState { - /** 上次 ping 得到的当前 PAT 所属用户;用于建连接时预热 adapter 的 currentUser 缓存。 */ + /** The user owning the current PAT from the last ping; used to warm up the adapter's currentUser cache when establishing a connection. */ user?: PlatformUser | null; } interface ConnectionStateFile { schema_version: 1; - /** connectionId → 该连接的本地状态 */ + /** connectionId → local state of that connection */ connections: Record<string, ConnectionState>; } const KEY = 'connections/state'; -/** 读取全部连接状态;无文件返回空表。 */ +/** Read all connection states; returns an empty table when there is no file. */ export async function readConnectionStates( store: StateStore, ): Promise<Record<string, ConnectionState>> { @@ -27,7 +29,7 @@ export async function readConnectionStates( return file?.connections ?? {}; } -/** 整表写回连接状态。 */ +/** Write the whole table of connection states back. */ export async function writeConnectionStates( store: StateStore, connections: Record<string, ConnectionState>, diff --git a/apps/desktop/src/main/utils/image.ts b/apps/desktop/src/main/utils/image.ts index e7552162..be9719a3 100644 --- a/apps/desktop/src/main/utils/image.ts +++ b/apps/desktop/src/main/utils/image.ts @@ -1,9 +1,11 @@ /** - * 按文件头魔数嗅探常见图片格式;嗅不出退到 octet-stream(浏览器仍可能解码 PNG)。 + * Sniff common image formats by file-header magic numbers; falls back to octet-stream when it + * can't be sniffed (the browser may still decode PNG). * - * 用于 Bitbucket 头像本地落盘后回读时还原 content-type —— 落盘时只存裸字节,回读后 - * 还要拼 data URL,content-type 缺失会导致 <img src> 不渲染。Bitbucket 实际只可能 - * 返回 PNG / JPEG / GIF / WebP / SVG 其中之一,但作为防御性嗅探留全。 + * Used to restore the content-type when reading back a Bitbucket avatar after it was persisted + * locally — only raw bytes are stored on write, and reading back still needs to build a data URL, + * so a missing content-type would keep <img src> from rendering. Bitbucket can in practice only + * return one of PNG / JPEG / GIF / WebP / SVG, but the full set is kept as defensive sniffing. */ export function sniffImageContentType(bytes: Uint8Array): string { if ( @@ -34,7 +36,7 @@ export function sniffImageContentType(bytes: Uint8Array): string { ) { return 'image/webp'; } - // SVG / XML 以 '<' 起;blame avatar 应该不会出 SVG 但兜底 + // SVG / XML starts with '<'; a blame avatar shouldn't be SVG but this is a fallback if (bytes.length >= 1 && bytes[0] === 0x3c) { return 'image/svg+xml'; } diff --git a/apps/desktop/src/main/utils/pr-context.ts b/apps/desktop/src/main/utils/pr-context.ts index d8deedb9..061e10e1 100644 --- a/apps/desktop/src/main/utils/pr-context.ts +++ b/apps/desktop/src/main/utils/pr-context.ts @@ -6,26 +6,29 @@ interface BuildPrContextOpts { pr: StoredPullRequest; adapter: PlatformAdapter; logger?: Logger; - /** 取最近 N 条 comment (top-level + 嵌套 replies 合计),默认 20 */ + /** Take the most recent N comments (top-level + nested replies combined), default 20 */ maxComments?: number; - /** 每条 comment body 截到 N 字符,默认 300 */ + /** Truncate each comment body to N characters, default 300 */ maxCommentLen?: number; } /** - * 把 PR 自身的非 diff 维度信息拼成一段 markdown,注给 pr-agent 作 EXTRA_INSTRUCTIONS。 + * Assemble the PR's own non-diff information into a markdown block, injected into pr-agent as + * EXTRA_INSTRUCTIONS. * - * 包含: - * - 标题 - * - 描述(PR.description,作者写的;为空时跳过 —— 这正是 /describe 要生成的内容) - * - 已有评论(按 top-level 时间倒序取最多 N 条 + 嵌套 replies) + * Includes: + * - Title + * - Description (PR.description, written by the author; skipped when empty — this is exactly what + * /describe is meant to generate) + * - Existing comments (up to N, top-level in reverse chronological order + nested replies) * - * pr-agent local provider 自己不会去 Bitbucket 拉这些信息(local provider 只看 worktree - * 的 git diff),所以必须我们这边主动提供。这段会跟 rules.instructions 拼接,让 - * /describe / /review 都能感知背景。 + * The pr-agent local provider won't fetch this information from Bitbucket itself (the local + * provider only looks at the worktree's git diff), so we must provide it proactively. This block + * is concatenated with rules.instructions so that both /describe and /review can perceive the + * background. * - * 失败 safe:comments fetch 失败 → warn + 跳过;标题以外字段都缺 → 返回空串 - * (调用方可以判断是否要发)。 + * Fail-safe: comments fetch fails → warn + skip; every field but the title missing → return an + * empty string (the caller can decide whether to send it). */ export async function buildPrContext({ pr, @@ -62,8 +65,9 @@ export async function buildPrContext({ ); } - // 只有 title 一项时(无描述、无评论)通常意味着 PR 刚开,没什么背景信息可言; - // 让调用方拿到空串决定是否注入,避免给 prompt 加无用前缀 + // When there's only the title (no description, no comments) it usually means the PR was just + // opened with no background to speak of; let the caller get an empty string and decide whether + // to inject, avoiding a useless prefix on the prompt if (sections.length === 1 && !desc && comments.length === 0) { return ''; } @@ -71,11 +75,11 @@ export async function buildPrContext({ } /** - * 把嵌套评论树拍扁为 markdown 列表: - * - top-level 按 createdAt 倒序 (最新在前) - * - 同一 thread 内 replies 跟父节点,保留原顺序 - * - 每行:`<indent>- @<author>[ (file:line)] <YYYY-MM-DD>: <body>` - * - body 中的换行替换成空格,截到 maxLen 字符 + * Flatten the nested comment tree into a markdown list: + * - top-level sorted by createdAt in reverse order (newest first) + * - replies within the same thread follow their parent node, preserving the original order + * - each line: `<indent>- @<author>[ (file:line)] <YYYY-MM-DD>: <body>` + * - newlines in body replaced with spaces, truncated to maxLen characters */ function formatComments( comments: ReadonlyArray<PrComment>, diff --git a/apps/desktop/src/main/utils/proxy.ts b/apps/desktop/src/main/utils/proxy.ts index 97d559bc..d28e8c69 100644 --- a/apps/desktop/src/main/utils/proxy.ts +++ b/apps/desktop/src/main/utils/proxy.ts @@ -1,21 +1,21 @@ -// 出站网络代理 plumbing。读 config.proxy,产出三种形态: -// - buildProxyEnv:子进程 env(给 ① pr-agent、③ git HTTPS,litellm/git 认 HTTP(S)_PROXY) -// - buildProxyDispatcher:undici ProxyAgent(给 ② Bitbucket Server REST 的 fetch) -// - shouldBypass:loopback/本地是否直连(② 在调用点据此决定要不要挂 dispatcher) -// 一期仅 HTTP 代理;enabled=false 时全部产出「空/直连」,调用点无需各自判断开关。 +// Outbound network proxy plumbing. Reads config.proxy and produces three forms: +// - buildProxyEnv: child-process env (for ① pr-agent, ③ git HTTPS; litellm/git honor HTTP(S)_PROXY) +// - buildProxyDispatcher: undici ProxyAgent (for ② the fetch to Bitbucket Server REST) +// - shouldBypass: whether loopback/local goes direct (② decides at the call site whether to attach a dispatcher) +// Phase one is HTTP proxy only; when enabled=false all forms yield "empty/direct connection", so call sites need not each check the switch. import { ProxyAgent, type Dispatcher } from 'undici'; import { ERROR_CODES, errorCodeMessage, type ProxyConfig } from '@meebox/shared'; -// loopback / 本地:始终直连,不经代理。env 路径靠 NO_PROXY,dispatcher 路径靠 shouldBypass。 +// loopback / local: always direct connection, never through the proxy. The env path relies on NO_PROXY, the dispatcher path on shouldBypass. const NO_PROXY = 'localhost,127.0.0.1,::1'; -/** loopback / 本地 host → true(应直连,不走代理)。 */ +/** loopback / local host → true (should go direct connection, not through the proxy). */ export function shouldBypass(host: string): boolean { - const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // 去掉 IPv6 字面量方括号 + const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 literal brackets return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'; } -/** 拼标准代理 URL:`<protocol>://[user:pass@]host:port`。关闭 / 无 host 时 undefined。 */ +/** Build a standard proxy URL: `<protocol>://[user:pass@]host:port`. undefined when disabled / no host. */ export function proxyUrl(proxy: ProxyConfig): string | undefined { if (!proxy.enabled || !proxy.host) return undefined; const auth = proxy.username @@ -25,9 +25,9 @@ export function proxyUrl(proxy: ProxyConfig): string | undefined { } /** - * 子进程 env:HTTP_PROXY/HTTPS_PROXY/ALL_PROXY + NO_PROXY(大小写都给——不同库读法不一, - * httpx/git/curl 多认小写,部分认大写)。NO_PROXY 把 loopback/本地排除在代理外。 - * 关闭时返回 {},调用点 spread 即无副作用。 + * Child-process env: HTTP_PROXY/HTTPS_PROXY/ALL_PROXY + NO_PROXY (both cases given — libraries read differently, + * httpx/git/curl mostly honor lowercase, some honor uppercase). NO_PROXY excludes loopback/local from the proxy. + * Returns {} when disabled, so spreading at the call site has no side effect. */ export function buildProxyEnv(proxy: ProxyConfig): Record<string, string> { const url = proxyUrl(proxy); @@ -45,8 +45,8 @@ export function buildProxyEnv(proxy: ProxyConfig): Record<string, string> { } /** - * undici ProxyAgent(含 Basic Auth,凭据嵌在 URL)。关闭时 undefined。 - * 注意:ProxyAgent 自身不认 NO_PROXY —— loopback 绕过由调用点先过 shouldBypass 决定。 + * undici ProxyAgent (includes Basic Auth, credentials embedded in the URL). undefined when disabled. + * Note: ProxyAgent itself does not honor NO_PROXY — loopback bypass is decided by the call site running shouldBypass first. */ export function buildProxyDispatcher(proxy: ProxyConfig): Dispatcher | undefined { const url = proxyUrl(proxy); @@ -54,12 +54,12 @@ export function buildProxyDispatcher(proxy: ProxyConfig): Dispatcher | undefined return new ProxyAgent(url); } -// 测试连通用的中性外部端点:返回 204、体积极小。代理能转发到它即说明出网正常。 +// Neutral external endpoint for connectivity testing: returns 204, extremely small. If the proxy can forward to it, outbound networking works. const PROXY_TEST_URL = 'https://www.google.com/generate_204'; /** - * 用给定代理配置试连一个外部地址,验证代理是否可用(设置页「测试连通」)。 - * 拿到任意 HTTP 响应即视为代理转发成功;407 视为认证失败;超时/网络错误归一成 reason。 + * Try connecting to an external address with the given proxy config to verify the proxy is usable (the settings page "test connectivity"). + * Any HTTP response counts as a successful proxy forward; 407 counts as auth failure; timeout/network errors are normalized into reason. */ export async function testProxyConnectivity( proxy: ProxyConfig, @@ -84,9 +84,9 @@ export async function testProxyConnectivity( } /** - * 给某目标 host 造一个「代理感知」的 fetch,注入 BitbucketClient 的 opts.fetch。 - * host 命中 loopback/本地 → 返回 undefined(调用点用默认全局 fetch 直连)。 - * 否则返回带 dispatcher 的 fetch 包装。代理关闭也返回 undefined。 + * Build a "proxy-aware" fetch for a target host, to inject into BitbucketClient's opts.fetch. + * host hits loopback/local → returns undefined (the call site uses the default global fetch for a direct connection). + * Otherwise returns a fetch wrapper carrying the dispatcher. Also returns undefined when the proxy is disabled. */ export function proxyFetchForHost( proxy: ProxyConfig, diff --git a/apps/desktop/src/main/utils/update-check.ts b/apps/desktop/src/main/utils/update-check.ts index fc0fd025..552082f9 100644 --- a/apps/desktop/src/main/utils/update-check.ts +++ b/apps/desktop/src/main/utils/update-check.ts @@ -1,6 +1,6 @@ -// 版本更新检测(仅检测 + 提示,不下载 / 安装)。查 GitHub Releases 最新**稳定版** -// (/releases/latest 天然排除 prerelease/alpha),与当前版本做 semver 比对。 -// 走配置的出站代理(企业内网友好);匿名 GitHub API(低频,启动 + 手动),无需 token。 +// Version update check (check + notify only, no download / install). Queries the latest **stable release** +// from GitHub Releases (/releases/latest naturally excludes prerelease/alpha) and does a semver comparison against the current version. +// Goes through the configured outbound proxy (enterprise-intranet friendly); anonymous GitHub API (low frequency, startup + manual), no token needed. import type { ProxyConfig, UpdateCheckResult } from '@meebox/shared'; import { gt as semverGt, valid as semverValid } from 'semver'; @@ -21,7 +21,7 @@ interface GithubRelease { } /** - * 检测是否有新版本。任何网络 / 解析失败都收敛成 ok=false + error,不抛。 + * Check whether a new version exists. Any network / parse failure collapses into ok=false + error, never throws. */ export async function checkForUpdate( currentVersion: string, @@ -34,11 +34,11 @@ export async function checkForUpdate( error, }); - // semver.valid 容忍前缀 v、拒绝尾部垃圾(1.2.3beta / 1.2.3.4 → null) + // semver.valid tolerates a leading v, rejects trailing garbage (1.2.3beta / 1.2.3.4 → null) const current = semverValid(currentVersion); if (!current) return fail(`Unable to parse the current version: ${currentVersion}`); - // 代理感知 fetch(命中本地/代理关闭则用全局 fetch 直连) + // proxy-aware fetch (hits local / proxy disabled → uses global fetch for a direct connection) const doFetch = proxyFetchForHost(proxy, API_HOST) ?? fetch; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); @@ -47,14 +47,14 @@ export async function checkForUpdate( signal: ctrl.signal, headers: { Accept: 'application/vnd.github+json', - // GitHub API 要求 UA;用内部代号(OWNER/REPO 是真实仓库路径,属对外内容,保留) + // GitHub API requires a UA; use the internal codename (OWNER/REPO are the real repo path, outward-facing content, kept) 'User-Agent': 'meebox-updater', }, }); if (!res.ok) return fail(`GitHub API ${String(res.status)}`); const data = (await res.json()) as GithubRelease; - // 只提示正式版:/releases/latest 本就排除 prerelease/draft;此处再防御一道, - // 万一拿到 prerelease/draft 一律视为「无更新」,不引导用户升到预发布。 + // Only notify for stable releases: /releases/latest already excludes prerelease/draft; defend once more here, + // in case a prerelease/draft comes through treat it uniformly as "no update", never steering the user to a prerelease. if (data.prerelease || data.draft) { return { ok: true, hasUpdate: false, currentVersion }; } diff --git a/apps/desktop/src/main/utils/update-state.ts b/apps/desktop/src/main/utils/update-state.ts index 3d2984b5..10737d90 100644 --- a/apps/desktop/src/main/utils/update-state.ts +++ b/apps/desktop/src/main/utils/update-state.ts @@ -1,22 +1,22 @@ -// 版本更新检测的**单一真相源**:手动检查(设置页 app:checkUpdate)与定时检查 -// (runUpdateCheckIfDue)都把结果交给这里,统一缓存 + 在确有新版时广播给所有窗口。 -// 这样手动查到的新版能同步到状态栏,且任意窗口 / 状态栏挂载时可经 app:getUpdateStatus 水合 -// 已知结果(不必等下一次广播 / 重新发起网络)。进程内缓存,不落盘——重启后由下次检查重填。 +// **Single source of truth** for the version update check: both the manual check (settings page app:checkUpdate) +// and the scheduled check (runUpdateCheckIfDue) hand their result here, for unified caching + broadcasting to all windows when a new version is confirmed. +// This way a manually found new version syncs to the status bar, and any window / status bar can, on mount, hydrate +// the known result via app:getUpdateStatus (no need to wait for the next broadcast / re-initiate a network call). In-process cache, not persisted — refilled by the next check after restart. import { BrowserWindow } from 'electron'; import type { UpdateCheckResult } from '@meebox/shared'; let lastResult: UpdateCheckResult | null = null; -/** 最近一次**成功**(ok=true)的检测结果;尚未成功检测过时为 null。 */ +/** The most recent **successful** (ok=true) check result; null when no successful check has occurred yet. */ export function getLastUpdateResult(): UpdateCheckResult | null { return lastResult; } /** - * 记录一次检测结果并按需广播。失败(ok=false)不覆盖已知好结果、也不广播——保证 - * 「网络拿不到」对用户零打扰;成功结果(无论 hasUpdate)覆盖缓存,仅 hasUpdate 才推 - * app:updateAvailable(与既有「仅有新版才提示」的设计一致)。 + * Record a check result and broadcast as needed. A failure (ok=false) neither overwrites a known good result nor broadcasts — guaranteeing + * "network unreachable" causes zero disturbance to the user; a successful result (regardless of hasUpdate) overwrites the cache, but only hasUpdate pushes + * app:updateAvailable (consistent with the existing "notify only on a new version" design). */ export function publishUpdateResult(result: UpdateCheckResult): void { if (!result.ok) return; diff --git a/apps/desktop/src/main/utils/window-state.ts b/apps/desktop/src/main/utils/window-state.ts index 192e938f..2278d91b 100644 --- a/apps/desktop/src/main/utils/window-state.ts +++ b/apps/desktop/src/main/utils/window-state.ts @@ -3,32 +3,32 @@ import path from 'node:path'; import type { StateStore } from '@meebox/state-store'; /** - * 主窗口的本地状态(持久化在 state store),让下次启动沿用上次的窗口大小。 - * 只存尺寸 + 最大化态,不存 x/y 位置 —— 多显示器/分辨率变化时按坐标恢复易把窗口摆到屏幕外, - * 尺寸恢复无此风险,也契合「记住窗口大小」的诉求(建窗时按当前显示器工作区居中,见 window-manager)。 + * Local state of the main window (persisted in the state store), so the next launch reuses the previous window size. + * Stores only size + maximized state, not x/y position — with multiple displays / resolution changes, restoring by coordinates easily places the window off-screen, + * whereas size restoration has no such risk and matches the "remember window size" intent (on window creation, centered within the current display's work area, see window-manager). */ export interface WindowState { width?: number; height?: number; - /** 上次关闭时是否最大化;是则下次启动以正常尺寸建窗后再 maximize。 */ + /** Whether it was maximized at last close; if so, the next launch creates the window at normal size then maximizes. */ maximized?: boolean; } const KEY = 'window/state'; -/** 读取窗口状态;无文件 / 读取失败由调用方兜底为空。 */ +/** Read the window state; on no file / read failure the caller falls back to empty. */ export async function readWindowState(store: StateStore): Promise<WindowState> { return (await store.read<WindowState>(KEY)) ?? {}; } -/** 写回窗口状态(in-session 防抖回写走 store,并发安全 + 原子)。 */ +/** Write the window state back (in-session debounced writeback goes through the store, concurrency-safe + atomic). */ export async function writeWindowState(store: StateStore, state: WindowState): Promise<void> { await store.write<WindowState>(KEY, state); } /** - * 关窗同步落盘:`close` 事件后进程即退出(Windows/Linux 关最后一个窗口即 quit),异步写来不及 flush - * → 尺寸丢失。故关窗走同步写兜底。路径与 JsonFileStateStore 的 key→path 映射一致(`<stateDir>/window/state.json`)。 + * Synchronous write to disk on window close: after the `close` event the process exits immediately (Windows/Linux quit on closing the last window), an async write cannot flush in time + * → size lost. So window close uses a synchronous write as fallback. The path matches JsonFileStateStore's key→path mapping (`<stateDir>/window/state.json`). */ export function writeWindowStateSync(stateDir: string, state: WindowState): void { const file = path.join(stateDir, `${KEY}.json`); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 486f0fa6..9ed94a3e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,6 @@ /// <reference lib="dom" /> -// preload 跑在渲染进程,可用 window / DOM 事件类型;tsconfig.node 默认无 DOM lib, -// 故在此显式引入(仅类型,不影响产物)。 +// preload runs in the renderer process, so window / DOM event types are available; tsconfig.node has no DOM lib by default, +// hence the explicit reference here (types only, no impact on output). import { contextBridge, ipcRenderer } from 'electron'; import type { IpcBridge, @@ -40,12 +40,12 @@ try { console.error('[preload] failed to expose window.api:', e); } -// 渲染层全局错误兜底:转发到 main 落进 meebox.log(renderer 自己的 console 不进文件)。 -// 在 preload 装监听 → 能捕获 React 挂载前的早期错误;用 ipcRenderer.invoke 直连, -// 不经 contextBridge。转发失败静默(避免错误处理自身再抛)。 +// Renderer global error fallback: forward to main so it lands in meebox.log (renderer's own console does not go to file). +// Installing the listener in preload → can capture early errors before React mounts; uses ipcRenderer.invoke directly, +// not via contextBridge. Forwarding failures are silent (to avoid the error handling itself throwing again). function reportRendererError(msg: string, meta: Record<string, unknown>): void { void ipcRenderer.invoke('log:write', { level: 'error', msg, meta }).catch(() => { - /* main 未就绪 / 通道异常时静默 */ + /* silent when main is not ready / channel error */ }); } window.addEventListener('error', (e: ErrorEvent) => { diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index cc230b0b..8bc011ba 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -29,7 +29,7 @@ import { useGlobalTheme, useEditorAppearanceSync } from './hooks/useTheme'; export default function App() { const { t } = useTranslation(); const { toast, notifyError, dismiss: dismissToast } = useToast(); - // PR 列表 / 选中 / 审批 / 合并 / 刷新 —— 领域逻辑归 usePullRequests + // PR list / selection / approve / merge / refresh — domain logic lives in usePullRequests const { prs, setPrs, @@ -43,10 +43,10 @@ export default function App() { mergeSelectedPr, markRead, } = usePullRequests({ notifyError }); - // 应用启动 / 全局生命周期(boot 加载、语言、poll / focus 刷新、向导完成、连接热生效) + // App startup / global lifecycle (boot load, language, poll / focus refresh, wizard completion, connection hot-apply) const { boot, fatalError, lastSyncAt, needsOnboarding, completeOnboarding, refreshBootAndPrs, patchConfig } = useBootstrap({ setPrs, reloadPrs }); - // 布局态(左右两栏宽度 / 折叠)、版本更新提示、store 接线、外链防护——各自成 app 级 hook + // Layout state (left/right column widths / collapse), version update notice, store wiring, external link guard — each its own app-level hook const { sidebarWidth, setSidebarWidth, @@ -60,8 +60,8 @@ export default function App() { const updateInfo = useUpdateNotice(); useAppStores(); useExternalLinkGuard(); - // 外观(全局主题 + 编辑器字体):跟随 config 同步到运行时 store + 字体 CSS 变量。boot 前用默认值, - // 模块导入时已按 localStorage 缓存定下首帧主题,boot 到达后切到 config 主题。 + // Appearance (global theme + editor font): sync from config to runtime store + font CSS variables. Use defaults before boot, + // module import already pins the first-frame theme from the localStorage cache, switch to config theme once boot arrives. useEditorAppearanceSync( boot?.config.appearance ?? { editor_theme: 'auto', @@ -69,27 +69,27 @@ export default function App() { editor_font_size: 14, }, ); - // 全局主题:订阅 store 的主题,反推浅 / 深写 data-theme(驱动语义色板)+ 派生 chrome 结构色 + - // 持久化 localStorage;'auto' 主题下跟随 OS 深浅切换。 + // Global theme: subscribe to the store's theme, derive light / dark to write data-theme (drives the semantic palette) + derive chrome structural colors + + // persist to localStorage; under the 'auto' theme follow the OS light/dark switch. useGlobalTheme(); const [showSettings, setShowSettings] = useState(false); - // 设置面板初始分区(命令面板「打开关于 / 模型」等深链用);缺省由 SettingsModal 落 'general'。 + // Settings panel initial section (used by command palette deep links like "open About / Model"); default falls to 'general' in SettingsModal. const [settingsCategory, setSettingsCategory] = useState<SettingsCategory | undefined>(undefined); const openSettings = useCallback((category?: SettingsCategory) => { setSettingsCategory(category); setShowSettings(true); }, []); - // PR 状态筛选(待处理 / 全部 / 冲突 / 可合并等):提升到 App 以便命令面板亦可驱动、折叠侧栏不丢选择。 + // PR status filter (pending / all / conflict / mergeable etc.): lifted to App so the command palette can also drive it and a collapsed sidebar doesn't lose the selection. const [statusFilter, setStatusFilter] = useState<FilterKey>('pending'); - // 当前 Diff 视图选中的单 commit 范围(DiffView 上报):作为聊天区命令的隐式范围(见 ChatPane)。 - // 按 sha 去重,避免 DiffView 每次 render 传新对象引发的重渲染回环。 + // The single-commit scope currently selected in the Diff view (reported by DiffView): serves as the implicit scope for chat commands (see ChatPane). + // Dedupe by sha to avoid a re-render loop from DiffView passing a new object on every render. const [viewCommitScope, setViewCommitScope] = useState<ReviewRunCommitScope | null>(null); const handleViewCommitScope = useCallback((s: ReviewRunCommitScope | null) => { setViewCommitScope((prev) => (prev?.sha === s?.sha ? prev : s)); }, []); - // PR 导航 / 范围领域(发现分类 / 活跃·归档切换 / 归档懒加载 / 按 URL 打开 / 定位跳转 / 通知点击导航 + - // 跨组件 Diff·Tab 跳转意图)——领域逻辑归 usePrNavigation;选中态 / 已读仍由 usePullRequests 拥有。 + // PR navigation / scope domain (discovery filters / active·archived switch / archived lazy load / open by URL / locate jump / notification-click navigation + + // cross-component Diff·Tab jump intent) — domain logic lives in usePrNavigation; selection state / read status is still owned by usePullRequests. const { scope, discoveryFilter, @@ -106,16 +106,16 @@ export default function App() { pendingTab, setPendingTab, } = usePrNavigation({ prs, selectedId, setSelectedId, markRead, notifyError }); - // macOS dock 角标:活跃 PR「@我 / 回复我」待回应总数 → 主进程落到 dock 图标(系统行为,逻辑见 useDockBadge)。 + // macOS dock badge: total count of active PRs "@me / replied to me" awaiting response → main process writes it to the dock icon (system behavior, logic in useDockBadge). useDockBadge({ prs, platform: boot?.info.platform, notifications: boot?.config.notifications, }); const archived = scope === 'archived'; - // 状态栏「待审 PR」计数:仅计**需我评审且本人尚未评审**的 PR(discovery=review-requested 且 localStatus=pending)。 - // 不能简单数 localStatus==='pending'——「我创建的」等分类的 PR 本人非评审人、localStatus 恒为 pending,会把计数撑大。 - // 无发现分类的平台(单一「待我评审」发现)discoveryFilters 为空,视作 review-requested。 + // Status bar "PRs to review" count: only counts PRs that **need my review and I have not yet reviewed** (discovery=review-requested and localStatus=pending). + // Cannot simply count localStatus==='pending' — PRs in categories like "created by me" have me as a non-reviewer, localStatus stays pending forever, inflating the count. + // On platforms without discovery categories (a single "awaiting my review" discovery) discoveryFilters is empty, treated as review-requested. const pendingReviewCount = useMemo( () => prs.filter( @@ -125,10 +125,10 @@ export default function App() { ).length, [prs], ); - // 已关闭范围的「可参与」判定:合并 / 仍开放的 PR 可补充评论 + AI 评审;decline 仅浏览。活跃范围恒可参与。 + // "Can engage" determination for the closed scope: merged / still-open PRs allow adding comments + AI review; declined ones are browse-only. The active scope is always engageable. const canEngage = !archived || (selectedPr ? selectedPr.state !== 'declined' : false); - // 窗口级全局快捷键(F5 自动评审 / DevTools / 查看已关闭 / Ctrl-Cmd+B·J 布局开关)——领域逻辑归 useGlobalShortcuts。 + // Window-level global shortcuts (F5 auto review / DevTools / view closed / Ctrl-Cmd+B·J layout toggles) — domain logic lives in useGlobalShortcuts. useGlobalShortcuts({ platform: boot?.info.platform, selectedId, @@ -163,26 +163,26 @@ export default function App() { ); } - // 选中 PR 所属连接:能力位(审批按钮降级)+ 当前 PAT 用户(判「是否自己的 PR」)。 + // Connection the selected PR belongs to: capability bits (approve-button downgrade) + current PAT user (to judge "is my own PR"). const selectedConn = selectedPr ? boot.connections.find((c) => c.connectionId === selectedPr.connectionId) : undefined; - // 有 active 连接但 LLM 未配置 → ChatPane 给出「需配置才能启用」提示并禁用输入 + // Has an active connection but LLM not configured → ChatPane shows a "configure to enable" hint and disables input const llmConfigured = boot.config.llm.profiles.some((p) => p.id === boot.config.llm.active_id); - // 发现分类标签由活动连接的能力决定(GitHub 四类、Bitbucket 两类、其余无)。 + // Discovery category tabs are determined by the active connection's capabilities (GitHub four, Bitbucket two, others none). const activeConnSummary = boot.connections.find( (c) => c.connectionId === boot.config.active_connection_id, ); const availableDiscoveryFilters = activeConnSummary?.capabilities.discoveryFilters ?? []; const showDiscoveryFilter = availableDiscoveryFilters.length > 0; - // 平台是否支持 needs_work(「需修改」)评审态:GitHub / Bitbucket 支持、GitLab(二元审批)不支持。 - // 决定非「待我评审」发现分类下是否保留「待处理」状态筛选(见 Sidebar.visibleFilters)。 + // Whether the platform supports the needs_work ("needs changes") review state: GitHub / Bitbucket support it, GitLab (binary approval) does not. + // Determines whether the "pending" status filter is kept under discovery categories other than "awaiting my review" (see Sidebar.visibleFilters). const supportsNeedsWork = activeConnSummary?.capabilities.reviewStatuses.includes('needsWork') ?? false; - // 选中的分类可能因切换连接而对当前平台无效 → 回落首个可用。 + // The selected category may be invalid for the current platform after switching connections → fall back to the first available. const effectiveDiscoveryFilter = availableDiscoveryFilters.includes(discoveryFilter) ? discoveryFilter : availableDiscoveryFilters[0]; - // 命令面板「分类筛选」可选的状态项:与侧栏一致——有发现分类时隐藏决断类(通过 / 需修改)。 + // Status items selectable in the command palette "category filter": consistent with the sidebar — hide decision types (approved / needs changes) when discovery categories exist. const visibleStatusFilters = showDiscoveryFilter ? PR_STATUS_FILTERS.filter((f) => !DECISION_STATUS_FILTERS.has(f.value)) : PR_STATUS_FILTERS; @@ -193,7 +193,7 @@ export default function App() { platform={boot.info.platform} title={selectedPr?.title} config={boot.config} - // 不可参与(decline / 无选中)时「运行自动评审」命令应隐藏:以 null 关掉其 when 门控。 + // When not engageable (declined / no selection) the "run auto review" command should hide: pass null to close its when gate. selectedPrId={canEngage ? selectedId : null} patchConfig={patchConfig} openSettings={openSettings} @@ -214,7 +214,7 @@ export default function App() { selectedId={selectedId} onSelect={(pr) => { setSelectedId(pr.localId); - // 已关闭范围无未读概念,无需推进已读水位。 + // The closed scope has no unread concept, no need to advance the read watermark. if (!archived) void markRead(pr.localId); }} width={sidebarWidth} @@ -240,7 +240,7 @@ export default function App() { merging={merging} capabilities={selectedConn?.capabilities} currentUserName={selectedConn?.user?.name ?? null} - // 已关闭范围隐藏 PR 生命周期操作(合并 / 审批);decline / 不可参与再隐藏评论 / 草稿写入。 + // The closed scope hides PR lifecycle actions (merge / approve); declined / not-engageable further hides comment / draft writes. hideLifecycle={archived} readOnly={!canEngage} pendingDiffNav={pendingDiffNav} @@ -254,13 +254,13 @@ export default function App() { <PrEmpty hasConnections={boot.config.connections.length > 0} /> )} </MainPane> - {/* ChatPane 始终挂载,折叠只是 CSS 隐藏:保住运行中的 run 生命周期(计时器 / runProgress 订阅)。 */} + {/* ChatPane is always mounted, collapse is just CSS hiding: preserves the lifecycle of a running run (timers / runProgress subscription). */} <ChatPane pr={selectedPr} prAgent={boot.prAgent} width={chatWidth} onResize={setChatWidth} - // 不可参与(decline / 无选中)时强制折叠对话面板、隐去 AI 评审入口;合并 / 仍开放 PR 仍可补评审。 + // When not engageable (declined / no selection) force-collapse the chat panel and hide the AI review entry; merged / still-open PRs can still add reviews. collapsed={chatCollapsed || !canEngage} llmConfigured={llmConfigured} onOpenSettings={() => setShowSettings(true)} @@ -272,6 +272,7 @@ export default function App() { boot.config.llm.profiles.find((p) => p.id === boot.config.llm.active_id)?.model ?? null } viewCommitScope={viewCommitScope} + codeSuggestionLayout={boot.config.agent.strategy.code_suggestion_layout} /> </div> <StatusBar diff --git a/apps/desktop/src/renderer/src/components/common/Avatar.tsx b/apps/desktop/src/renderer/src/components/common/Avatar.tsx index f8fbc1d0..c845a009 100644 --- a/apps/desktop/src/renderer/src/components/common/Avatar.tsx +++ b/apps/desktop/src/renderer/src/components/common/Avatar.tsx @@ -3,24 +3,24 @@ import { invoke } from '../../api'; interface AvatarProps { connectionId: string; - /** 平台用户 slug(Bitbucket 的 user.name 即 slug) */ + /** Platform user slug (Bitbucket's user.name is the slug) */ slug: string; - /** 给 initials 兜底用;也用作 title / alt */ + /** Fallback for initials; also used as title / alt */ displayName: string; - /** 头像直链(平台 avatar_url);有则优先按它拉——GitHub 机器人靠它才取得到。 */ + /** Direct avatar link (platform avatar_url); if present, prefer fetching by it — GitHub bots are only reachable via it. */ avatarUrl?: string; size?: number; } /** - * 圆形用户头像。优先用 main 进程拉的平台 avatar(in-memory cache 命中即同步返回), - * 拉失败 / 加载中 / null 时回退到 initials + hash 色块。 + * Circular user avatar. Prefers the platform avatar fetched by the main process (returns synchronously on in-memory cache hit), + * falling back to initials + hash color block on fetch failure / loading / null. */ export function Avatar({ connectionId, slug, displayName, avatarUrl, size = 22 }: AvatarProps) { const [dataUrl, setDataUrl] = useState<string | null>(() => readCached(connectionId, slug)); useEffect(() => { - if (dataUrl !== null) return; // 已有缓存或本组件已加载 + if (dataUrl !== null) return; // already cached or already loaded by this component let cancelled = false; fetchAvatar(connectionId, slug, avatarUrl).then((url) => { if (!cancelled && url) setDataUrl(url); @@ -28,7 +28,7 @@ export function Avatar({ connectionId, slug, displayName, avatarUrl, size = 22 } return () => { cancelled = true; }; - // 仅在 (connectionId, slug) 变化时重拉 + // re-fetch only when (connectionId, slug) changes }, [connectionId, slug, avatarUrl, dataUrl]); const style = { width: size, height: size, fontSize: Math.round(size * 0.42) }; @@ -58,11 +58,11 @@ export function Avatar({ connectionId, slug, displayName, avatarUrl, size = 22 } ); } -/** "Kyle Wong" → "KW";中文「张三」→「张」;单字 fallback 首字符大写 */ +/** "Kyle Wong" → "KW"; Chinese「张三」→「张」; single-word fallback uppercases the first character */ function initialsOf(name: string): string { const trimmed = name.trim(); if (!trimmed) return '?'; - // 含 CJK:取第一个 CJK 字符 + // Contains CJK: take the first CJK character const cjk = /[一-鿿]/.exec(trimmed); if (cjk) return cjk[0]!; const parts = trimmed.split(/[\s.\-_]+/).filter(Boolean); @@ -72,7 +72,7 @@ function initialsOf(name: string): string { return trimmed.slice(0, 2).toUpperCase(); } -/** 名字 → 稳定的 HSL 背景色(同名永远同色,对比度足够白字) */ +/** Name → stable HSL background color (same name always same color, enough contrast for white text) */ function colorFromName(name: string): string { let h = 0; for (let i = 0; i < name.length; i++) { @@ -82,7 +82,7 @@ function colorFromName(name: string): string { return `hsl(${String(hue)}, 45%, 38%)`; } -// 模块级缓存:跨组件去重,避免同一作者每个 PrItem 各发一次 IPC +// Module-level cache: dedup across components, avoid one IPC per PrItem for the same author const cache = new Map<string, string | null>(); const inflight = new Map<string, Promise<string | null>>(); diff --git a/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx b/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx index 9ff3decf..0ad08474 100644 --- a/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx +++ b/apps/desktop/src/renderer/src/components/common/BitbucketImage.tsx @@ -4,27 +4,27 @@ import { useTranslation } from 'react-i18next'; import { invoke } from '../../api'; /** - * react-markdown 默认 url sanitize 只允许 http/https/mailto/tel 协议, - * 非白名单协议 → src 被吞成空。Bitbucket 评论 markdown 用 `attachment:9/16854` 引用 - * 内嵌附件,必须直通让 BitbucketImage 收到原始 src 走 IPC 代理拉 + * react-markdown's default url sanitize only allows the http/https/mailto/tel protocols; + * non-whitelisted protocols → src is swallowed to empty. Bitbucket comment markdown references + * inline attachments with `attachment:9/16854`, which must pass through so BitbucketImage receives the raw src and fetches via the IPC proxy */ export function transformBitbucketUrl(url: string): string { if (url.startsWith('attachment:')) return url; - // 其他保持 react-markdown 默认安全行为 + // Others keep react-markdown's default safe behavior return /^(https?:|mailto:|tel:|#|\/)/.test(url) ? url : ''; } /** - * 评论 body 内嵌图片:Bitbucket 私有 attachment URL 需要 PAT 鉴权,原生 `<img>` - * 没法发 Authorization 头取私有资源 → 走 main 端 IPC `comments:fetchAttachment` - * 代理拉 bytes 转 data URL 显示。 + * Inline image in the comment body: Bitbucket private attachment URLs require PAT auth, and a native `<img>` + * cannot send an Authorization header to fetch private resources → goes through the main-side IPC `comments:fetchAttachment` + * to proxy-fetch the bytes and convert to a data URL for display. * - * **不缓存** (用户决策 — 评论图片重复加载概率低,跟头像走磁盘缓存不同)。每次 - * mount 调一次 IPC。 + * **Not cached** (user decision — comment images have a low chance of repeated loading, unlike avatars which use disk cache). Calls IPC once per + * mount. * - * 用工厂 makeBitbucketImageFor(localId, prWebUrl) 包出 ReactMarkdown components.img 用的 - * 组件 — 闭包捕获 localId 给 IPC 用;prWebUrl 为 PR 网页地址,代理失败时降级链接指向它 - * (在系统浏览器里带 session 渲染评论与图片),而非指向拉不到的资产 URL(相对路径会落到 localhost) + * Uses the factory makeBitbucketImageFor(localId, prWebUrl) to wrap the component used as ReactMarkdown components.img — + * the closure captures localId for the IPC; prWebUrl is the PR web address, and on proxy failure the fallback link points to it + * (rendering comments and images with session in the system browser) rather than to the unreachable asset URL (relative paths would resolve to localhost) */ export function makeBitbucketImageFor(localId: string, prWebUrl?: string) { return function BitbucketImage(props: React.ImgHTMLAttributes<HTMLImageElement>) { @@ -46,7 +46,7 @@ export function makeBitbucketImageFor(localId: string, prWebUrl?: string) { setResolvedSrc(null); void (async () => { try { - // 8s timeout 防 main 端 fetch 卡死 / handler 没注册让 loading 占位永久挂着 + // 8s timeout to prevent the main-side fetch from hanging / an unregistered handler keeping the loading placeholder up forever const ipcPromise = invoke('comments:fetchAttachment', { localId, url: src }); const timeoutPromise = new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000), @@ -65,8 +65,8 @@ export function makeBitbucketImageFor(localId: string, prWebUrl?: string) { }, [src]); if (failed && src) { - // attachment: 协议浏览器无法加载,fail 时显示明确的"加载失败"文本而不是 - // 损坏图标;http/https URL fail 时退回原生 <img> (可能是跨 host 公网图) + // The attachment: protocol cannot be loaded by the browser; on fail show explicit "load failed" text instead of + // a broken icon; on http/https URL fail, fall back to native <img> (may be a cross-host public image) if (src.startsWith('attachment:')) { return ( <span className="bitbucket-image-failed muted" aria-label={t('bitbucketImage.loadFailedAria')}> @@ -74,9 +74,9 @@ export function makeBitbucketImageFor(localId: string, prWebUrl?: string) { </span> ); } - // 代理拉不到(如 GitLab <17.4 私有上传仅认浏览器 session,PAT 无法代理):先试浏览器原生 - // 加载(公网图可成);原生也失败则降级成链接 —— 指向 PR 网页(在系统浏览器带 session 看 - // 评论与图片),避免破图标,也避免相对 /uploads 路径被解析成 localhost。 + // Proxy fetch fails (e.g. GitLab <17.4 private uploads only recognize the browser session, PAT cannot proxy): first try native browser + // loading (public images can succeed); if native also fails, degrade to a link — pointing to the PR web page (viewing + // comments and images with session in the system browser), avoiding a broken icon and avoiding relative /uploads paths being resolved to localhost. if (nativeError) { const fallbackHref = prWebUrl ?? (/^https?:\/\//.test(src) ? src : null); if (fallbackHref) { @@ -134,8 +134,8 @@ export function makeBitbucketImageFor(localId: string, prWebUrl?: string) { } /** - * 全屏大图预览:点击 BitbucketImage 缩略图 → portal 渲染到 document.body 的全屏 - * overlay。点击背景 / Esc 关闭。img 自身 stopPropagation 防点 img 关闭 + * Full-screen large image preview: clicking a BitbucketImage thumbnail → portal renders a full-screen + * overlay into document.body. Click the background / Esc to close. The img itself stopPropagation to prevent closing on img click */ function ImageZoomOverlay({ src, diff --git a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx index 33bd4b32..5da3343e 100644 --- a/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx +++ b/apps/desktop/src/renderer/src/components/common/ConfirmModal.tsx @@ -7,19 +7,19 @@ interface ConfirmModalProps { message: string; confirmLabel?: string; cancelLabel?: string; - /** 危险操作(删除等)时确认按钮显红色 */ + /** Confirm button shows red for dangerous operations (delete, etc.) */ danger?: boolean; - /** 从二层嵌套子模态弹出时置 true:用嵌套 backdrop(z-index 抬到 nested 层),叠在子模态之上 */ + /** Set true when popping from a second-level nested child modal: uses a nested backdrop (z-index raised to the nested layer), stacked above the child modal */ nested?: boolean; onConfirm: () => void; onCancel: () => void; } /** - * 通用确认模态框,基于 Modal 壳。经 portal 渲染到 body 避开调用者层级(特别是 Monaco view - * zone 内的 React tree,那一层 z-index 比 modal 低)。 + * Generic confirm modal, based on the Modal shell. Renders into body via portal to avoid the caller's layering (especially the React tree inside a Monaco view + * zone, whose z-index is lower than the modal). * - * 键盘:Esc 取消,Enter 确认(焦点默认在取消按钮,避免误触) + * Keyboard: Esc to cancel, Enter to confirm (focus defaults to the cancel button to avoid mis-clicks) */ export function ConfirmModal({ title, @@ -37,8 +37,8 @@ export function ConfirmModal({ const resolvedCancelLabel = cancelLabel ?? t('common.cancel'); useEffect(() => { const onKey = (e: KeyboardEvent): void => { - // 忽略 OS 按键自动重复:若本模态由「按 Enter」打开(如 chat /merge 提交),按住 Enter 期间的 - // 重复 keydown 会在监听器挂载后触发 → 立即 onConfirm、模态一闪而过。仅响应全新按下(repeat=false)。 + // Ignore OS key auto-repeat: if this modal was opened by「pressing Enter」(e.g. chat /merge submit), the + // repeated keydown while holding Enter fires after the listener mounts → immediate onConfirm, modal flashes past. Only respond to a fresh press (repeat=false). if (e.repeat) return; if (e.key === 'Escape') { e.preventDefault(); diff --git a/apps/desktop/src/renderer/src/components/common/ErrorBoundary.tsx b/apps/desktop/src/renderer/src/components/common/ErrorBoundary.tsx index 2f683b96..9488882e 100644 --- a/apps/desktop/src/renderer/src/components/common/ErrorBoundary.tsx +++ b/apps/desktop/src/renderer/src/components/common/ErrorBoundary.tsx @@ -2,9 +2,9 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'; interface ErrorBoundaryProps { children: ReactNode; - /** 可选自定义 fallback;不传走默认灰底提示卡 */ + /** Optional custom fallback; if omitted, uses the default gray-background notice card */ fallback?: (err: Error, reset: () => void) => ReactNode; - /** 命名出错区域便于 console 定位 (例: "DiffPane") */ + /** Name the failing region for easier console locating (e.g. "DiffPane") */ label?: string; } @@ -13,11 +13,11 @@ interface ErrorBoundaryState { } /** - * React 渲染期错误屏障。catch 子树 render / effect-mount 阶段抛出的同步错误, - * 显示 fallback 而不是整页白屏;不 catch 异步 promise rejection / window onerror - * (那些走 monaco-setup.ts 的全局过滤器)。 + * React render-phase error boundary. Catches synchronous errors thrown during the subtree's render / effect-mount phase, + * showing a fallback instead of a whole-page white screen; does not catch async promise rejection / window onerror + * (those go through monaco-setup.ts's global filter). * - * 仅放在"可隔离"的子树边界(如 DiffPane 区域),避免一个面板挂掉拖死全应用。 + * Only placed at "isolatable" subtree boundaries (e.g. the DiffPane region), to avoid one panel crashing dragging down the whole app. */ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { override state: ErrorBoundaryState = { err: null }; diff --git a/apps/desktop/src/renderer/src/components/common/LlmProviderIcon.tsx b/apps/desktop/src/renderer/src/components/common/LlmProviderIcon.tsx index de0d73a0..ffb74831 100644 --- a/apps/desktop/src/renderer/src/components/common/LlmProviderIcon.tsx +++ b/apps/desktop/src/renderer/src/components/common/LlmProviderIcon.tsx @@ -1,9 +1,9 @@ import type { JSX } from 'react'; import type { LlmProvider } from '@meebox/shared'; -// LLM provider 品牌图标。OpenAI / Anthropic 用官方几何标记;其余几家无干净的 -// 官方单色 SVG 资源,按品牌意象手绘为简洁可辨的彩色图标(鲸鱼 / 云 / 火山 / -// 通用 API 字形)。后续如放入正式 logo 资源,替换这里对应分支即可。 +// LLM provider brand icons. OpenAI / Anthropic use official geometric marks; the others have no clean +// official monochrome SVG assets, so they are hand-drawn per brand imagery into concise, recognizable color icons (whale / cloud / volcano / +// generic API glyph). If official logo assets are added later, just replace the corresponding branch here. function OpenAiGlyph({ size }: { size: number }) { return ( @@ -16,7 +16,7 @@ function OpenAiGlyph({ size }: { size: number }) { ); } -// Anthropic → Claude:放射状 burst 标记(12 道花瓣自中心向外辐射) +// Anthropic → Claude: radial burst mark (12 petals radiating outward from the center) function ClaudeGlyph({ size }: { size: number }) { return ( <svg width={size} height={size} viewBox="0 0 24 24" fill="#D97757" aria-hidden="true"> @@ -35,7 +35,7 @@ function ClaudeGlyph({ size }: { size: number }) { ); } -// OpenAI 兼容:通用 API 字形(< / >,代表「任何兼容协议」) +// OpenAI-compatible: generic API glyph (< / >, representing「any compatible protocol」) function CompatibleGlyph({ size }: { size: number }) { return ( <svg @@ -56,29 +56,29 @@ function CompatibleGlyph({ size }: { size: number }) { ); } -// DeepSeek:鲸鱼吉祥物(圆身 + 白肚 + 右上分叉尾鳍 + 眼睛,手绘简化) +// DeepSeek: whale mascot (round body + white belly + top-right forked tail fin + eye, hand-drawn simplified) function DeepSeekGlyph({ size }: { size: number }) { return ( <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"> - {/* 圆形身体 */} + {/* Round body */} <circle cx="10.4" cy="13.8" r="8" fill="#4D6BFE" /> - {/* 上翘的分叉尾鳍 */} + {/* Upturned forked tail fin */} <path fill="#4D6BFE" d="M15.8 9.4c2-1.5 3.5-3.9 4.3-6.3.3 1.5.4 2.8-.2 4 .9-.4 1.9-.3 2.8.1-1.1 1.5-2.7 2.5-4.5 2.9-.8.2-1.6.1-2.4-.7Z" /> - {/* 白色肚腩月牙 */} + {/* White belly crescent */} <path fill="#fff" d="M4.6 14.6c0 3.4 2.7 6 6 6 .5 0 1-.1 1.4-.2-1.8-.5-3.3-1.7-4-3.6-.5-1.3-.3-2.6.4-3.6-.5-.3-1.1-.5-1.8-.5-1.1 0-2 .9-2 1.9Z" /> - {/* 眼睛 */} + {/* Eye */} <circle cx="12.6" cy="11.2" r="1" fill="#fff" /> </svg> ); } -// 阿里百炼(DashScope)→ 千问(Qwen):紫色「Q」标记 +// Alibaba Bailian (DashScope) → Qwen: purple「Q」mark function QwenGlyph({ size }: { size: number }) { return ( <svg @@ -97,7 +97,7 @@ function QwenGlyph({ size }: { size: number }) { ); } -// 火山方舟(Volcengine Ark):火山意象(山体 + 顶部火苗) +// Volcengine Ark: volcano imagery (mountain body + flame at the top) function VolcengineGlyph({ size }: { size: number }) { return ( <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"> @@ -110,7 +110,7 @@ function VolcengineGlyph({ size }: { size: number }) { ); } -// 本地 CLI:终端窗口 + 提示符(>_),代表「调本机命令行工具」 +// Local CLI: terminal window + prompt (>_), representing「invoking a local command-line tool」 function CliGlyph({ size }: { size: number }) { return ( <svg diff --git a/apps/desktop/src/renderer/src/components/common/Loading.tsx b/apps/desktop/src/renderer/src/components/common/Loading.tsx index 6baf7ae9..8e2b9fa5 100644 --- a/apps/desktop/src/renderer/src/components/common/Loading.tsx +++ b/apps/desktop/src/renderer/src/components/common/Loading.tsx @@ -1,15 +1,16 @@ import { useEffect, useState } from 'react'; /** - * 延迟显示的居中 loading:组件挂载后 delayMs 内不出现,超过才显 spinner。 + * Delayed centered loading: does not appear within delayMs after mount, only shows the spinner past that. * - * 重型组件(Monaco、ChatPane 内容、diff 文件树)在 async init 完成前先渲染空骨架、 - * 各段 ready 时间错开 → 多次布局跳变(可感知抖动)。本组件在 init 期间盖住该区域, - * ready 后由调用方卸载它即可一次性 reveal。 + * Heavy components (Monaco, ChatPane content, diff file tree) render an empty skeleton before async init + * completes, and their ready times are staggered → multiple layout shifts (perceptible jitter). This component + * covers the area during init; once ready, the caller unmounts it to reveal everything at once. * - * **延迟显示**是关键:桌面端切 PR 多在 150ms 内命中本地缓存,快路径下本组件挂载即 - * 很快被卸载、spinner 从不出现 → 零闪烁;只有真慢的场景(Monaco 冷挂载 / 大 diff) - * 才落到 spinner。复用既有 `.spinner` 视觉,不引入新语言。 + * **Delayed display** is key: switching PRs on desktop mostly hits the local cache within 150ms, and on the + * fast path this component is mounted then quickly unmounted with the spinner never appearing → zero flicker; + * only the truly slow cases (Monaco cold mount / large diff) fall through to the spinner. Reuses the existing + * `.spinner` visual, introduces no new language. */ export function PaneLoading({ delayMs = 150, @@ -18,10 +19,10 @@ export function PaneLoading({ }: { delayMs?: number; label?: string; - /** 绝对定位铺满父容器(父需 position:relative),用于盖在 Monaco 编辑器之上。 */ + /** Absolutely positioned to fill the parent (parent needs position:relative), used to cover the Monaco editor. */ overlay?: boolean; }) { - // delayMs<=0:确定要盖(如 Monaco overlay),第 0 帧即显,不走延迟。 + // delayMs<=0: definitely covering (e.g. Monaco overlay), show at frame 0, skip the delay. const [show, setShow] = useState(delayMs <= 0); useEffect(() => { if (delayMs <= 0) return; diff --git a/apps/desktop/src/renderer/src/components/common/MermaidDiagram.tsx b/apps/desktop/src/renderer/src/components/common/MermaidDiagram.tsx index 70741e08..37238e25 100644 --- a/apps/desktop/src/renderer/src/components/common/MermaidDiagram.tsx +++ b/apps/desktop/src/renderer/src/components/common/MermaidDiagram.tsx @@ -4,18 +4,18 @@ import { useTranslation } from 'react-i18next'; import { useResolvedTheme } from '../../hooks/useTheme'; /** - * Mermaid 渲染:把 ```mermaid 代码块渲染成 SVG 图(Qodo `/describe` 常生成架构图)。 + * Mermaid rendering: renders ```mermaid code blocks into SVG diagrams (Qodo `/describe` often generates architecture diagrams). * - * - **懒加载**:mermaid 体积大(含 d3 等),仅当真正出现 mermaid 块、组件挂载时才 - * `import('mermaid')`,不进入口包、不增加启动成本(与 Monaco 懒加载同思路)。 - * - **securityLevel: 'strict'**:内容来自 AI / 远端 PR 描述,strict 下 mermaid 转义 - * 标签文本、禁用点击脚本,产出的 SVG 可安全注入。 - * - **失败回退**:语法错 / 渲染异常时回退展示原始代码块,图画错也能看源码。 - * - 主题随应用深 / 浅色切换(`dark` / `default`):mermaid 主题为全局态、不走 CSS 自定义属性, - * 故每次渲染前按当前解析主题 re-initialize,并把主题纳入渲染 effect 依赖、切换时重绘。 + * - **Lazy load**: mermaid is large (includes d3 etc.), only `import('mermaid')` when a mermaid block actually + * appears and the component mounts, keeping it out of the entry bundle and adding no startup cost (same idea as Monaco lazy loading). + * - **securityLevel: 'strict'**: content comes from AI / remote PR descriptions; under strict, mermaid escapes + * label text and disables click scripts, so the produced SVG is safe to inject. + * - **Failure fallback**: on syntax errors / render exceptions, fall back to showing the original code block, so the source is readable even when the diagram fails. + * - Theme follows the app dark / light switch (`dark` / `default`): mermaid theme is global state and does not go through CSS custom properties, + * so re-initialize with the current resolved theme before each render, and include the theme in the render effect deps to redraw on switch. */ -// 仅声明本组件用到的最小接口,避免 import() 类型注解(且与 mermaid 内部类型解耦)。 +// Declare only the minimal interface this component uses, avoiding import() type annotations (and decoupling from mermaid's internal types). interface MermaidApi { initialize(config: Record<string, unknown>): void; render(id: string, text: string): Promise<{ svg: string }>; @@ -23,12 +23,12 @@ interface MermaidApi { let mermaidLoader: Promise<MermaidApi> | null = null; function loadMermaid(): Promise<MermaidApi> { - // 失败时重置缓存:否则 rejected promise 被永久缓存,后续调用永远拿到同一个失败结果、 - // 无法重试(典型「缓存 Promise」陷阱)。 + // Reset the cache on failure: otherwise the rejected promise is cached forever, and later calls always get the same + // failed result with no retry (the classic "cached Promise" trap). mermaidLoader ??= import('mermaid') .then((m) => { const mermaid = m.default as unknown as MermaidApi; - // 主题不在此固定:每次渲染前按当前应用主题 re-initialize(见组件渲染 effect)。 + // Theme is not fixed here: re-initialize with the current app theme before each render (see the component render effect). mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' }); return mermaid; }) @@ -41,10 +41,10 @@ function loadMermaid(): Promise<MermaidApi> { export function MermaidDiagram({ source }: { source: string }) { const { t } = useTranslation(); - // mermaid 主题为全局态、不走 CSS 自定义属性:随应用解析主题切换(深 'dark' / 浅 'default')。 + // mermaid theme is global state and does not go through CSS custom properties: switches with the app resolved theme (dark 'dark' / light 'default'). const mermaidTheme = useResolvedTheme() === 'light' ? 'default' : 'dark'; - // mermaid.render 需要唯一 id(内部建临时 DOM 节点);useId 保证每个实例稳定唯一, - // 去掉 `:`(mermaid 用作 DOM id / CSS 选择器,冒号非法) + // mermaid.render needs a unique id (it creates temporary DOM nodes internally); useId guarantees each instance is stably unique, + // strip `:` (mermaid uses it as a DOM id / CSS selector, where the colon is invalid) const renderId = `mmd-${useId().replace(/:/g, '')}`; const [svg, setSvg] = useState<string | null>(null); const [failed, setFailed] = useState(false); @@ -57,12 +57,12 @@ export function MermaidDiagram({ source }: { source: string }) { void (async () => { try { const mermaid = await loadMermaid(); - // 渲染前按当前主题 re-initialize(全局态,幂等):主题切换后重渲即换配色。 + // Re-initialize with the current theme before rendering (global state, idempotent): re-rendering after a theme switch swaps the color scheme. mermaid.initialize({ startOnLoad: false, theme: mermaidTheme, securityLevel: 'strict' }); const out = await mermaid.render(renderId, source); if (!cancelled) setSvg(out.svg); } catch (e) { - // 记一次日志便于排查(语法错 / 加载失败);UI 回退到原始代码块 + // Log once to aid debugging (syntax error / load failure); UI falls back to the original code block console.error('[mermaid] render failed', e); if (!cancelled) setFailed(true); } @@ -73,7 +73,7 @@ export function MermaidDiagram({ source }: { source: string }) { }, [source, renderId, mermaidTheme]); if (failed) { - // 回退:保留原始 mermaid 源码,至少可读 + // Fallback: keep the original mermaid source, at least it stays readable return ( <pre className="mermaid-fallback"> <code>{source}</code> @@ -83,7 +83,7 @@ export function MermaidDiagram({ source }: { source: string }) { if (svg === null) { return <div className="mermaid-loading muted">{t('mermaidDiagram.rendering')}</div>; } - // mermaid strict 模式产出的 SVG 已转义不可信内容,可安全注入。点击图表 → 模态预览。 + // SVG produced by mermaid strict mode has escaped untrusted content and is safe to inject. Click the diagram → modal preview. return ( <> <div @@ -103,7 +103,7 @@ export function MermaidDiagram({ source }: { source: string }) { {zoomed && createPortal( <MermaidZoomModal - // 重写 id(含 <style> 选择器 / 箭头 marker 引用),避免与内联副本同 id 冲突 + // Rewrite the id (which appears in <style> selectors / arrow marker references) to avoid id collisions with the inline copy svgHtml={svg.replaceAll(renderId, `${renderId}-zoom`)} onClose={() => setZoomed(false)} />, @@ -118,20 +118,21 @@ const MAX_SCALE = 8; const clampScale = (s: number): number => Math.min(MAX_SCALE, Math.max(MIN_SCALE, s)); /** - * mermaid 模态预览:固定纯色背景的预览区 + 可缩放/拖拽视图。 + * mermaid modal preview: a preview area with a fixed solid background + a zoomable/draggable view. * - * 不再手动测量 svg 内容并算缩放 —— mermaid 的 svg 在「width:100% / 内联 max-width / 绝对定位 - * 容器 / transform」相互作用下,渲染像素尺寸在「测量时」与「绘制后」并不一致,手算 fit 必然偏差。 - * 改为让浏览器原生处理「适应」:svg `width/height:100%` 充满预览区 + `preserveAspectRatio` - * (默认 xMidYMid meet)自动等比缩放并居中;缩放/拖拽只是在其上叠加一层 transform。 - * - 默认(scale=1):图自动适应窗口、居中(原生)。 - * - 滚轮缩放(锚定光标)、左键拖拽平移、工具栏放大/缩小/复位、Esc / 点遮罩关闭。 + * No longer manually measures the svg content and computes the scale — under the interaction of mermaid's svg + * "width:100% / inline max-width / absolutely positioned container / transform", the rendered pixel size differs + * between "at measurement" and "after paint", so a hand-computed fit is bound to be off. Instead let the browser + * natively handle "fit": svg `width/height:100%` fills the preview area + `preserveAspectRatio` + * (default xMidYMid meet) auto-scales proportionally and centers; zoom/drag just layer a transform on top. + * - Default (scale=1): the diagram auto-fits the window and centers (native). + * - Wheel zoom (anchored to the cursor), left-button drag pan, toolbar zoom in/out/reset, Esc / click backdrop to close. */ function MermaidZoomModal({ svgHtml, onClose }: { svgHtml: string; onClose: () => void }) { const { t } = useTranslation(); const stageRef = useRef<HTMLDivElement>(null); const drag = useRef<{ x: number; y: number } | null>(null); - // scale=1 即「原生适应窗口」基准;缩放/平移在其上叠加。 + // scale=1 is the "native fit-to-window" baseline; zoom/pan layer on top of it. const [scale, setScale] = useState(1); const [tx, setTx] = useState(0); const [ty, setTy] = useState(0); @@ -142,7 +143,7 @@ function MermaidZoomModal({ svgHtml, onClose }: { svgHtml: string; onClose: () = setTy(0); }; - // Esc 关闭 + // Esc to close useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose(); @@ -151,7 +152,7 @@ function MermaidZoomModal({ svgHtml, onClose }: { svgHtml: string; onClose: () = return () => window.removeEventListener('keydown', onKey); }, [onClose]); - // 以预览区内 (px,py) 为锚点缩放:保持光标下的点不动。 + // Zoom anchored at (px,py) within the preview area: keep the point under the cursor fixed. const zoomAt = (px: number, py: number, factor: number): void => { const ns = clampScale(scale * factor); const k = ns / scale; diff --git a/apps/desktop/src/renderer/src/components/common/Modal.tsx b/apps/desktop/src/renderer/src/components/common/Modal.tsx index 5819e5ff..863f0aa1 100644 --- a/apps/desktop/src/renderer/src/components/common/Modal.tsx +++ b/apps/desktop/src/renderer/src/components/common/Modal.tsx @@ -14,29 +14,29 @@ const SIZE_CLASS: Record<ModalSize, string> = { interface ModalProps { onClose: () => void; - /** 弹窗尺寸 → 容器类名:md=modal · sm=modal-sm · confirm=modal-confirm */ + /** Modal size → container class name: md=modal · sm=modal-sm · confirm=modal-confirm */ size?: ModalSize; - /** 二层嵌套模态:加 modal-backdrop-nested,背景点击 stopPropagation 防冒泡关掉外层模态 */ + /** Second-level nested modal: adds modal-backdrop-nested, backdrop-click stopPropagation prevents bubbling that would close the outer modal */ nested?: boolean; - /** 经 createPortal 渲染到 body:避开调用者所在层级(如 Monaco view zone 内 z-index 偏低) */ + /** Rendered to body via createPortal: avoids the caller's stacking context (e.g. low z-index inside a Monaco view zone) */ portal?: boolean; - /** 点背景是否关闭(默认 true) */ + /** Whether clicking the backdrop closes (default true) */ closeOnBackdrop?: boolean; - /** 标题(不传则不渲染 header) */ + /** Title (header is not rendered if omitted) */ title?: ReactNode; - /** 标题元素 id,配合 aria-labelledby */ + /** Title element id, paired with aria-labelledby */ titleId?: string; - /** header 右侧关闭按钮样式:图标按钮 / 文案按钮(不传则无) */ + /** Close button style on the right of the header: icon button / text button (none if omitted) */ headerClose?: 'icon' | 'text'; - /** header 右侧、关闭键左侧的自定义动作(如「打开目录」按钮);与关闭键同组右对齐。 */ + /** Custom actions on the right of the header, left of the close button (e.g. an "open directory" button); right-aligned in the same group as the close button. */ headerActions?: ReactNode; - /** modal-body 追加类名(如 confirm-body) */ + /** Extra class name appended to modal-body (e.g. confirm-body) */ bodyClassName?: string; - /** 底部 footer 区内容(作为 modal-body 的兄弟渲染);不传则无 footer 区 */ + /** Bottom footer area content (rendered as a sibling of modal-body); no footer area if omitted */ footer?: ReactNode; - /** footer 容器类名(默认 modal-footer-bar;确认框用 modal-actions) */ + /** Footer container class name (default modal-footer-bar; confirm dialogs use modal-actions) */ footerClassName?: string; - /** 容器内联样式(个别弹窗自定宽度用) */ + /** Container inline style (used by individual modals with custom widths) */ style?: CSSProperties; ariaLabel?: string; ariaLabelledby?: string; @@ -44,8 +44,8 @@ interface ModalProps { } /** - * 通用模态壳:统一 backdrop(点外关闭 + 嵌套防冒泡)、dialog 容器、可选 header(标题 + 关闭键)、 - * modal-body 包裹、可选 footer 区。键盘交互(Esc / Enter)由各调用方按需自管——壳只负责结构与样式语言。 + * Generic modal shell: unified backdrop (click-outside to close + nested bubbling guard), dialog container, optional header (title + close button), + * modal-body wrapper, optional footer area. Keyboard interaction (Esc / Enter) is managed by each caller as needed — the shell only handles structure and the styling language. */ export function Modal({ onClose, @@ -69,8 +69,8 @@ export function Modal({ const tree = ( <div className={`modal-backdrop${nested ? ' modal-backdrop-nested' : ''}`} - // 背景点击只关本层:stopPropagation 防止冒泡到外层模态 backdrop(嵌套时不拦会连外层一起关)。 - // 顶层用法下 stopPropagation 无副作用。 + // Backdrop click closes only this layer: stopPropagation prevents bubbling to the outer modal backdrop (without it, nesting would close the outer one too). + // In top-level usage stopPropagation has no side effect. onClick={(e) => { e.stopPropagation(); if (closeOnBackdrop) onClose(); diff --git a/apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx b/apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx index 01828d66..29077f87 100644 --- a/apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx +++ b/apps/desktop/src/renderer/src/components/common/PlatformIcon.tsx @@ -1,5 +1,5 @@ -// 代码平台品牌图标。沿用 icons.tsx 的内联 SVG 惯例,但平台 logo 用各自品牌色 -// (而非 currentColor),让首启向导的平台选择列表一眼可辨。viewBox 统一 24。 +// Code platform brand icons. Follows the inline SVG convention of icons.tsx, but platform logos use their own brand colors +// (rather than currentColor), so the first-launch wizard's platform selection list is instantly distinguishable. viewBox unified at 24. import type { JSX } from 'react'; import type { PlatformKind } from '@meebox/shared'; @@ -8,7 +8,7 @@ interface PlatformIconProps { size?: number; } -/** Bitbucket:蓝色「桶」标记(简化字形,非官方资源) */ +/** Bitbucket: blue "bucket" mark (simplified glyph, not an official asset) */ export function BitbucketIcon({ size = 24 }: PlatformIconProps) { return ( <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"> @@ -25,9 +25,9 @@ export function BitbucketIcon({ size = 24 }: PlatformIconProps) { } /** - * GitHub:Octocat 猫头剪影(品牌单色 logo)。随主题切换 fill —— 暗底官方反白 invertocat、 - * 浅底官方近黑(见 _theme.scss 的 --github-logo)。SVG fill **属性**不解析 var(),故经 CSS - * fill **属性**(style)注入变量。 + * GitHub: Octocat cat-head silhouette (monochrome brand logo). fill switches with the theme — on dark the official inverted-white invertocat, + * on light the official near-black (see --github-logo in _theme.scss). The SVG fill **attribute** does not resolve var(), so the variable is injected + * via the CSS fill **property** (style). */ export function GitHubIcon({ size = 24 }: PlatformIconProps) { return ( @@ -40,7 +40,7 @@ export function GitHubIcon({ size = 24 }: PlatformIconProps) { ); } -/** GitLab:tanuki 狐狸标记(simple-icons 路径,品牌橙 #FC6D26 一眼可辨) */ +/** GitLab: tanuki fox mark (simple-icons path, brand orange #FC6D26 instantly recognizable) */ export function GitLabIcon({ size = 24 }: PlatformIconProps) { return ( <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"> @@ -52,12 +52,12 @@ export function GitLabIcon({ size = 24 }: PlatformIconProps) { ); } -// 平台展示顺序的唯一准绳:GitHub → Bitbucket → GitLab,新增平台一律追加在末尾。 -// 设置页下拉、使用文档(docs/guide/01-code-platform.md)等各处展示顺序均以此为准。 +// The single source of truth for platform display order: GitHub → Bitbucket → GitLab, new platforms are always appended at the end. +// The settings-page dropdown, usage docs (docs/guide/01-code-platform.md), and all other display orders follow this. export const PLATFORM_META: ReadonlyArray<{ kind: PlatformKind; label: string; - /** i18n key(在消费端用 t() 翻译;含技术性副标题与「即将支持」等状态文案) */ + /** i18n key (translated with t() on the consumer side; covers the technical subtitle and status text like "coming soon") */ subKey: string; available: boolean; Icon: (p: PlatformIconProps) => JSX.Element; diff --git a/apps/desktop/src/renderer/src/components/common/StatusChip.tsx b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx index 40e2c8be..1921297f 100644 --- a/apps/desktop/src/renderer/src/components/common/StatusChip.tsx +++ b/apps/desktop/src/renderer/src/components/common/StatusChip.tsx @@ -3,11 +3,11 @@ import type { ReactNode } from 'react'; type ChipTone = 'ok' | 'err'; interface StatusChipProps { - /** 渲染元素:默认有 onClick 时为 button、否则 span */ + /** Render element: defaults to button when onClick is present, otherwise span */ as?: 'span' | 'button'; - /** 语义色调 → statusbar-chip-ok / statusbar-chip-err */ + /** Semantic tone → statusbar-chip-ok / statusbar-chip-err */ tone?: ChipTone; - /** 追加的专属类名(如 statusbar-pragent-chip / statusbar-llm-chip) */ + /** Appended dedicated class name (e.g. statusbar-pragent-chip / statusbar-llm-chip) */ className?: string; title?: string; ariaLabel?: string; @@ -18,9 +18,9 @@ interface StatusChipProps { } /** - * 状态栏 chip 通用壳:统一 `statusbar-chip` 基类 + 可选语义色调(ok/err),按是否可点 - * 渲染为 button / span,并透传 title / aria / disabled。各 chip 内部结构(图标 / 文案 / - * 下拉)作为 children 自管,专属样式经 className 追加。 + * Generic status-bar chip shell: unified `statusbar-chip` base class + optional semantic tone (ok/err), rendered as + * button / span depending on whether it is clickable, passing through title / aria / disabled. Each chip's internal + * structure (icon / text / dropdown) is managed as children, with dedicated styles appended via className. */ export function StatusChip({ as, diff --git a/apps/desktop/src/renderer/src/components/common/Switch.tsx b/apps/desktop/src/renderer/src/components/common/Switch.tsx index a93107f9..4bb8371f 100644 --- a/apps/desktop/src/renderer/src/components/common/Switch.tsx +++ b/apps/desktop/src/renderer/src/components/common/Switch.tsx @@ -1,6 +1,6 @@ /** - * 通用开关(switch):受控布尔输入,无障碍走 role="switch" + aria-checked。点击 / 空格 / 回车切换。 - * 视觉为轨道 + 滑块,开态走 accent 色(样式见 base.scss .switch)。 + * Generic switch: controlled boolean input, accessibility via role="switch" + aria-checked. Toggles on click / space / enter. + * Visually a track + knob, on-state uses accent color (styles in base.scss .switch). */ export function Switch({ checked, diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index 7fa94b40..7adf362f 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -1,15 +1,15 @@ -// 跨组件复用的内联 SVG 图标。统一 currentColor 描边,跟随主题色 / 父元素文字色, -// 离线无网络依赖。需要不同尺寸时传 size(viewBox 固定 16,缩放即可)。 +// Inline SVG icons reused across components. Uniform currentColor stroke, follows theme color / parent element's text color, +// offline with no network dependency. Pass size when a different size is needed (viewBox fixed at 16, just scale). interface IconProps { size?: number; - /** 个别图标需要外部 class(如 ChevronIcon 的 tree-chevron 旋转动画);其余忽略。 */ + /** Some icons need an external class (e.g. ChevronIcon's tree-chevron rotation animation); the rest ignore it. */ className?: string; } /** - * git pull-request / 分支合并字形:两条分支汇入 + 指向合并点的箭头。 - * 既用于 PR 列表分支行前缀,也用于"合并"按钮 / 可合并 chip —— 同一语义同一图形。 + * git pull-request / branch merge glyph: two branches converging + an arrow pointing to the merge point. + * Used both as the PR list branch row prefix and for the "merge" button / mergeable chip — same semantics, same graphic. */ export function PullRequestIcon({ size = 12 }: IconProps) { return ( @@ -34,7 +34,7 @@ export function PullRequestIcon({ size = 12 }: IconProps) { ); } -/** 关闭叉号:模态框右上角通用关闭按钮用,图标免国际化。 */ +/** Close cross: for the modal top-right generic close button, icon needs no i18n. */ export function CloseIcon({ size = 16 }: IconProps) { return ( <svg @@ -53,7 +53,7 @@ export function CloseIcon({ size = 16 }: IconProps) { ); } -/** 三角警示(叹号):合并冲突等需要用户注意的状态。文件树冲突文件行用。 */ +/** Triangle warning (exclamation): states needing user attention such as merge conflicts. Used on file tree conflict file rows. */ export function ConflictIcon({ size = 14, className }: IconProps) { return ( <svg @@ -75,7 +75,7 @@ export function ConflictIcon({ size = 14, className }: IconProps) { ); } -/** 文件夹:选择目录按钮用 */ +/** Folder: for the choose directory button */ export function FolderIcon({ size = 14 }: IconProps) { return ( <svg @@ -93,7 +93,7 @@ export function FolderIcon({ size = 14 }: IconProps) { ); } -/** 铅笔:编辑按钮用 */ +/** Pencil: for the edit button */ export function PencilIcon({ size = 14 }: IconProps) { return ( <svg @@ -113,7 +113,7 @@ export function PencilIcon({ size = 14 }: IconProps) { ); } -/** 睁眼:密钥/令牌「显示」状态用 */ +/** Open eye: for the key/token "shown" state */ export function EyeIcon({ size = 14 }: IconProps) { return ( <svg @@ -133,7 +133,7 @@ export function EyeIcon({ size = 14 }: IconProps) { ); } -/** 闭眼(带斜杠):密钥/令牌「隐藏」状态用 */ +/** Closed eye (with slash): for the key/token "hidden" state */ export function EyeOffIcon({ size = 14 }: IconProps) { return ( <svg @@ -154,7 +154,7 @@ export function EyeOffIcon({ size = 14 }: IconProps) { ); } -/** 垃圾桶:删除按钮用 */ +/** Trash can: for the delete button */ export function TrashIcon({ size = 14 }: IconProps) { return ( <svg @@ -177,7 +177,7 @@ export function TrashIcon({ size = 14 }: IconProps) { ); } -/** 纸飞机(横向,Lucide send-horizontal 风格):发送 / 提交按钮用 */ +/** Paper plane (horizontal, Lucide send-horizontal style): for the send / submit button */ export function SendIcon({ size = 14 }: IconProps) { return ( <svg @@ -197,7 +197,7 @@ export function SendIcon({ size = 14 }: IconProps) { ); } -/** 实心圆角方块:停止 / 取消(媒体停止键视觉惯例)。fill 版,无描边 */ +/** Solid rounded square: stop / cancel (media stop-key visual convention). Fill version, no stroke */ export function StopIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> @@ -206,7 +206,7 @@ export function StopIcon({ size = 14 }: IconProps) { ); } -/** 圆圈内 `?`:/ask 用户提问 chip 前缀(跟答案区分) */ +/** `?` inside a circle: prefix for the /ask user question chip (distinguishes it from the answer) */ export function QuestionIcon({ size = 14 }: IconProps) { return ( <svg @@ -227,7 +227,7 @@ export function QuestionIcon({ size = 14 }: IconProps) { ); } -/** 循环箭头(refresh-cw 风格):重试动作,chip 内嵌小尺寸。与 SyncIcon(双箭头)区分语义 */ +/** Loop arrow (refresh-cw style): retry action, small size embedded in chips. Distinct in semantics from SyncIcon (double arrow) */ export function RetryIcon({ size = 14 }: IconProps) { return ( <svg @@ -247,7 +247,7 @@ export function RetryIcon({ size = 14 }: IconProps) { ); } -/** 转发 / 分享箭头(社交媒体「转发」实心弯箭头):finding 卡片「引用」按钮用。 */ +/** Forward / share arrow (social media "share" solid curved arrow): for the finding card "quote" button. */ export function ShareIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> @@ -256,7 +256,7 @@ export function ShareIcon({ size = 14 }: IconProps) { ); } -/** 拼图块(extension / plugin):集成 / 扩展 的通用隐喻。设置「集成」分区导航用。 */ +/** Puzzle piece (extension / plugin): common metaphor for integration / extension. For the settings "integrations" section navigation. */ export function PuzzleIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> @@ -265,7 +265,7 @@ export function PuzzleIcon({ size = 14 }: IconProps) { ); } -/** 评论:带文字行的对话气泡。finding 卡「编辑成评论草稿」动作用(与 ChatIcon 区分:内含文字行)。 */ +/** Comment: a speech bubble with text lines. For the finding card "edit into comment draft" action (distinct from ChatIcon: contains text lines). */ export function CommentIcon({ size = 14 }: IconProps) { return ( <svg @@ -286,7 +286,7 @@ export function CommentIcon({ size = 14 }: IconProps) { ); } -/** 圆形禁止(no-entry):圆 + 斜杠。finding 卡「拒绝」动作用。 */ +/** Circular ban (no-entry): circle + slash. For the finding card "reject" action. */ export function BanIcon({ size = 14 }: IconProps) { return ( <svg @@ -305,7 +305,7 @@ export function BanIcon({ size = 14 }: IconProps) { ); } -/** 对话气泡:chat 面板触发 / 空态。large 场景传 size(如 28) */ +/** Speech bubble: chat panel trigger / empty state. Pass size for large scenarios (e.g. 28) */ export function ChatIcon({ size = 14 }: IconProps) { return ( <svg @@ -324,7 +324,7 @@ export function ChatIcon({ size = 14 }: IconProps) { ); } -/** 图片占位(相框 + 山峦 + 太阳):评论「上传图片附件」按钮。 */ +/** Image placeholder (frame + mountains + sun): the comment "upload image attachment" button. */ export function ImageIcon({ size = 14 }: IconProps) { return ( <svg @@ -345,7 +345,7 @@ export function ImageIcon({ size = 14 }: IconProps) { ); } -/** 笑脸 + 加号:评论「添加表情反应」按钮(viewBox 24 以对齐 lucide 笔形比例)。 */ +/** Smiley + plus: the comment "add emoji reaction" button (viewBox 24 to align with lucide stroke proportions). */ export function SmilePlusIcon({ size = 14 }: IconProps) { return ( <svg @@ -369,7 +369,7 @@ export function SmilePlusIcon({ size = 14 }: IconProps) { ); } -/** 文件树(三横线带项目符号):DiffView 退出搜索 / tree 模式指示 */ +/** File tree (three horizontal lines with bullets): DiffView exit search / tree mode indicator */ export function FileTreeIcon({ size = 12 }: IconProps) { return ( <svg @@ -392,7 +392,7 @@ export function FileTreeIcon({ size = 12 }: IconProps) { ); } -/** 放大镜:进入搜索模式 */ +/** Magnifying glass: enter search mode */ export function SearchIcon({ size = 12 }: IconProps) { return ( <svg @@ -412,7 +412,7 @@ export function SearchIcon({ size = 12 }: IconProps) { ); } -/** 右向折角箭头:树节点展开 / 折叠。className 供旋转动画(FileTree 传 tree-chevron) */ +/** Right-pointing chevron: tree node expand / collapse. className for rotation animation (FileTree passes tree-chevron) */ export function ChevronIcon({ size = 10, className }: IconProps) { return ( <svg @@ -432,7 +432,7 @@ export function ChevronIcon({ size = 10, className }: IconProps) { ); } -/** 地球经纬网格:在远端浏览器打开 */ +/** Globe with lat/long grid: open in remote browser */ export function GlobeIcon({ size = 14 }: IconProps) { return ( <svg @@ -453,7 +453,7 @@ export function GlobeIcon({ size = 14 }: IconProps) { ); } -/** 头肩剪影:作者行前缀 / blame 视图 / 账户指示(统一的「人」图标,合并原 PersonIcon、BlameIcon、UserIcon) */ +/** Head-and-shoulders silhouette: author row prefix / blame view / account indicator (unified "person" icon, merging the original PersonIcon, BlameIcon, UserIcon) */ export function PersonIcon({ size = 12 }: IconProps) { return ( <svg @@ -473,7 +473,7 @@ export function PersonIcon({ size = 12 }: IconProps) { ); } -/** 空白字符可视化(·→·):显示 space / tab */ +/** Whitespace visualization (·→·): show space / tab */ export function WhitespaceIcon({ size = 12 }: IconProps) { return ( <svg @@ -492,7 +492,7 @@ export function WhitespaceIcon({ size = 12 }: IconProps) { ); } -/** 圆圈内对勾:审批通过 */ +/** Checkmark inside a circle: approved */ export function ApproveIcon({ size = 14 }: IconProps) { return ( <svg @@ -512,7 +512,7 @@ export function ApproveIcon({ size = 14 }: IconProps) { ); } -/** 圆圈内感叹号:需要修改 */ +/** Exclamation inside a circle: needs work */ export function NeedsWorkIcon({ size = 14 }: IconProps) { return ( <svg @@ -533,7 +533,7 @@ export function NeedsWorkIcon({ size = 14 }: IconProps) { ); } -/** 纯对勾字形(无外圆环):用于实心彩底角标,只留内部符号。 */ +/** Plain checkmark glyph (no outer ring): for solid colored-background badges, keeping only the inner symbol. */ export function CheckGlyphIcon({ size = 14 }: IconProps) { return ( <svg @@ -552,7 +552,7 @@ export function CheckGlyphIcon({ size = 14 }: IconProps) { ); } -/** 复制字形:前景方片 + 左上后衬纸,标准「复制到剪贴板」语义。 */ +/** Copy glyph: foreground sheet + top-left backing sheet, standard "copy to clipboard" semantics. */ export function CopyIcon({ size = 14 }: IconProps) { return ( <svg @@ -572,7 +572,7 @@ export function CopyIcon({ size = 14 }: IconProps) { ); } -/** 纯感叹号字形(无外圆环):用于实心彩底角标,只留内部符号。 */ +/** Plain exclamation glyph (no outer ring): for solid colored-background badges, keeping only the inner symbol. */ export function AlertGlyphIcon({ size = 14 }: IconProps) { return ( <svg @@ -592,7 +592,7 @@ export function AlertGlyphIcon({ size = 14 }: IconProps) { ); } -/** git commit 字形:横线上一个实心节点(活动时间线提交事件用)。 */ +/** git commit glyph: a solid node on a horizontal line (for activity timeline commit events). */ export function CommitIcon({ size = 14 }: IconProps) { return ( <svg @@ -613,7 +613,7 @@ export function CommitIcon({ size = 14 }: IconProps) { ); } -/** 机器人头像:AutoPilot 启用态。天线 + 头框 + 双眼 + 两侧耳。 */ +/** Robot head: AutoPilot enabled state. Antenna + head frame + two eyes + ears on both sides. */ export function RobotIcon({ size = 14 }: IconProps) { return ( <svg @@ -638,7 +638,7 @@ export function RobotIcon({ size = 14 }: IconProps) { ); } -/** 铃铛:设置页「通知」分区图标。钟体 + 顶钮 + 底部摆锤。 */ +/** Bell: the settings page "notifications" section icon. Bell body + top button + bottom clapper. */ export function BellIcon({ size = 14 }: IconProps) { return ( <svg @@ -659,7 +659,7 @@ export function BellIcon({ size = 14 }: IconProps) { ); } -/** CPU / 芯片:设置页「模型」分区图标(LLM 模型)。外框 + 内核 + 四边引脚。 */ +/** CPU / chip: the settings page "model" section icon (LLM model). Outer frame + core + pins on all four sides. */ export function CpuIcon({ size = 14 }: IconProps) { return ( <svg @@ -680,8 +680,8 @@ export function CpuIcon({ size = 14 }: IconProps) { ); } -/** 实心四角星(AI 常见 sparkle):评审建议徽标(手动 / AutoPilot 一视同仁)。四条边向中心内凹, - * 四个尖角居中对称,SVG 保证字形居中、跨平台一致。 */ +/** Solid four-pointed star (AI's common sparkle): review suggestion badge (manual / AutoPilot treated alike). The four edges curve inward toward the center, + * the four points are centered and symmetric, and the SVG guarantees the glyph is centered and consistent across platforms. */ export function StarIcon({ size = 11 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> @@ -690,7 +690,7 @@ export function StarIcon({ size = 11 }: IconProps) { ); } -/** 机器人头像 + 斜杠:AutoPilot 关闭态。 */ +/** Robot head + slash: AutoPilot disabled state. */ export function RobotOffIcon({ size = 14 }: IconProps) { return ( <svg @@ -716,7 +716,7 @@ export function RobotOffIcon({ size = 14 }: IconProps) { ); } -/** 双星火花(sparkles):AI 自动评审动作。两颗四角星,区别于工具命令的 `/` 触发器 */ +/** Double sparkles: AI auto-review action. Two four-pointed stars, distinct from the tool commands' `/` trigger */ export function AutoReviewIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> @@ -726,7 +726,7 @@ export function AutoReviewIcon({ size = 14 }: IconProps) { ); } -/** 双向循环箭头(Lucide refresh-cw-2 风格):同步状态。与 RetryIcon(单箭头,重试动作)区分语义 */ +/** Bidirectional loop arrows (Lucide refresh-cw-2 style): sync status. Distinct in semantics from RetryIcon (single arrow, retry action) */ export function SyncIcon({ size = 12 }: IconProps) { return ( <svg @@ -748,7 +748,7 @@ export function SyncIcon({ size = 12 }: IconProps) { ); } -/** 数据库柱体(Lucide database,viewBox 24):表示提示缓存命中量(cache_read) */ +/** Database cylinder (Lucide database, viewBox 24): represents prompt cache hit volume (cache_read) */ export function DatabaseIcon({ size = 12, className }: IconProps) { return ( <svg @@ -770,7 +770,7 @@ export function DatabaseIcon({ size = 12, className }: IconProps) { ); } -/** 循环箭头(Lucide repeat,viewBox 24):表示模型交互轮次(agentic 多轮) */ +/** Loop arrow (Lucide repeat, viewBox 24): represents model interaction rounds (agentic multi-turn) */ export function RepeatIcon({ size = 12, className }: IconProps) { return ( <svg @@ -793,7 +793,7 @@ export function RepeatIcon({ size = 12, className }: IconProps) { ); } -/** 齿轮(Lucide settings,viewBox 24):设置按钮 */ +/** Gear (Lucide settings, viewBox 24): settings button */ export function SettingsIcon({ size = 14 }: IconProps) { return ( <svg @@ -814,8 +814,8 @@ export function SettingsIcon({ size = 14 }: IconProps) { } /** - * 面板开关:矩形 + 分隔细条。`side` 决定细条 / 收起态实心块在左(侧栏)还是右(chat 面板)—— - * 原 SidebarIcon 与镜像版 ChatPanelIcon 合并为一个参数化图标。collapsed 时细条侧变实心。 + * Panel toggle: rectangle + thin divider bar. `side` decides whether the divider / collapsed-state solid block is on the left (sidebar) or right (chat panel) — + * the original SidebarIcon and its mirrored ChatPanelIcon merged into one parameterized icon. When collapsed, the divider side becomes solid. */ export function PanelToggleIcon({ side, @@ -842,8 +842,8 @@ export function PanelToggleIcon({ } /** - * 完成徽章:大圆环 + 对勾。onboarding 完成步使用。path 带 `onboarding-check-path` class - * 供 CSS 描边动画(stroke-dashoffset)。默认 76px(viewBox 52)。 + * Completion badge: large ring + checkmark. Used by the onboarding completion step. The path carries the `onboarding-check-path` class + * for the CSS stroke animation (stroke-dashoffset). Defaults to 76px (viewBox 52). */ export function SuccessBadgeIcon({ size = 76 }: IconProps) { return ( @@ -861,7 +861,7 @@ export function SuccessBadgeIcon({ size = 76 }: IconProps) { ); } -/** GitHub Octocat 标记(随文字色,用于「关于」链接的 GitHub / Star 入口)。 */ +/** GitHub Octocat mark (follows text color, for the "about" link's GitHub / Star entry). */ export function GitHubMarkIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"> @@ -873,7 +873,7 @@ export function GitHubMarkIcon({ size = 14 }: IconProps) { ); } -/** GitHub issue 字形:空心圆 + 实心圆点。用于「提交反馈 / Issue」入口。 */ +/** GitHub issue glyph: hollow circle + solid dot. For the "submit feedback / Issue" entry. */ export function IssueIcon({ size = 14 }: IconProps) { return ( <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true"> @@ -883,7 +883,7 @@ export function IssueIcon({ size = 14 }: IconProps) { ); } -/** 历史字形:时钟 + 逆时针回溯箭头。用于「已关闭 / 历史」PR 范围切换。 */ +/** History glyph: clock + counterclockwise rewind arrow. For the "closed / history" PR scope toggle. */ export function HistoryIcon({ size = 14, className }: IconProps) { return ( <svg @@ -905,7 +905,7 @@ export function HistoryIcon({ size = 14, className }: IconProps) { ); } -/** 标签 / 发布字形:带孔的 tag。用于「发布记录」入口。 */ +/** Tag / release glyph: a tag with a hole. For the "release history" entry. */ export function TagIcon({ size = 14 }: IconProps) { return ( <svg diff --git a/apps/desktop/src/renderer/src/components/common/index.ts b/apps/desktop/src/renderer/src/components/common/index.ts index 286f3d43..d49478f4 100644 --- a/apps/desktop/src/renderer/src/components/common/index.ts +++ b/apps/desktop/src/renderer/src/components/common/index.ts @@ -1,7 +1,7 @@ -// components/common 对外公共 API barrel:通用展示组件 + 图标 + markdown 渲染工具。 -// 跨域消费方(features/* · layout/* · App 等)经此 barrel 引入;common 内部模块相互引用 -// (markdownMermaid → MermaidDiagram、Modal → icons、ConfirmModal → Modal)走相对路径, -// 不经此 barrel,避免循环依赖。 +// components/common public API barrel: generic presentational components + icons + markdown rendering utilities. +// Cross-domain consumers (features/* · layout/* · App etc.) import via this barrel; common's internal modules reference each other +// (markdownMermaid → MermaidDiagram, Modal → icons, ConfirmModal → Modal) via relative paths, +// not through this barrel, to avoid circular dependencies. export * from './Avatar'; export * from './BitbucketImage'; export * from './ConfirmModal'; diff --git a/apps/desktop/src/renderer/src/components/common/markdownMermaid.tsx b/apps/desktop/src/renderer/src/components/common/markdownMermaid.tsx index 63cf59c0..6449aae0 100644 --- a/apps/desktop/src/renderer/src/components/common/markdownMermaid.tsx +++ b/apps/desktop/src/renderer/src/components/common/markdownMermaid.tsx @@ -4,17 +4,17 @@ import { MermaidDiagram } from './MermaidDiagram'; const MERMAID_LANG = /\blanguage-mermaid\b/; /** - * react-markdown components 覆盖:把 ```mermaid 代码块渲染成图(见 MermaidDiagram), - * 其余代码块保持默认。合并进各 markdown 面的 `components`(评论 / PR 描述 / chat 输出)。 + * react-markdown components override: render ```mermaid code blocks as diagrams (see MermaidDiagram), + * keep other code blocks at their default. Merged into the `components` of each markdown surface (comments / PR description / chat output). * - * - `code`:命中 language-mermaid → 渲染图;否则原样 <code>。 - * - `pre`:mermaid 块去掉外层 <pre>(图自带容器,避免被代码块的 monospace / 边框框住)。 + * - `code`: matches language-mermaid → render diagram; otherwise verbatim <code>. + * - `pre`: mermaid blocks drop the outer <pre> (the diagram brings its own container, avoiding the code block's monospace / border framing). */ export const mermaidComponents: Components = { code({ node: _node, className, children, ...rest }) { if (className && MERMAID_LANG.test(className)) { - // children 可能是数组(react-markdown 常见形态):String(array) 会用逗号拼接破坏 - // mermaid DSL,需先拼接其中字符串项(非字符串忽略)再传入。 + // children may be an array (a common react-markdown shape): String(array) joins with commas and breaks + // the mermaid DSL, so concatenate its string items (ignoring non-strings) before passing them in. const text = Array.isArray(children) ? children.map((c) => (typeof c === 'string' ? c : '')).join('') : typeof children === 'string' @@ -35,7 +35,7 @@ export const mermaidComponents: Components = { ? (first as { props?: { className?: unknown } }).props?.className : undefined; if (typeof cls === 'string' && MERMAID_LANG.test(cls)) { - // mermaid:直接渲染 code 覆盖产出的图,不套 <pre> + // mermaid: render the diagram produced by the code override directly, without wrapping in <pre> return <>{children}</>; } return <pre {...rest}>{children}</pre>; @@ -43,9 +43,9 @@ export const mermaidComponents: Components = { }; /** - * describe「文件变更」walkthrough 专用 components:在 mermaid 覆盖之上再去掉 <details> 的 open - * 属性。pr-agent 把各文件分类(功能增强 / 配置变更 …)输出为 <details open> 默认展开,文件多时正文 - * 很长;去掉 open 让每个分类默认折叠收起,点 <summary> 标题按需展开(原生 <details> 交互、不持久化)。 + * Dedicated components for the describe "file changes" walkthrough: on top of the mermaid override, also strip the <details> open + * attribute. pr-agent outputs each file category (feature enhancement / config change …) as <details open> expanded by default; with many files the body + * gets very long; removing open makes each category collapse by default, click the <summary> title to expand on demand (native <details> interaction, not persisted). */ export const walkthroughMdComponents: Components = { ...mermaidComponents, diff --git a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx index 31b6c4c3..3399984b 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx @@ -37,56 +37,58 @@ interface ChatPaneProps { prAgent: PrAgentStatus; width: number; onResize: (next: number) => void; - /** 折叠时仍然挂载组件 (保住进行中的 run 计时器 / runProgress 订阅), - 只用 CSS 隐藏。展开后用户看到的就是当前实时状态 */ + /** Keep the component mounted when collapsed (preserves the in-progress run timer / runProgress + subscription); hide with CSS only. On expand the user sees the current live state */ collapsed?: boolean; /** - * 跳到 Diff 视图编辑某条 finding 对应的草稿 (M4)。父组件 (MainPane) - * 实现:切 tab='diff' + DiffView scroll/highlight/open edit zone + 懒创建 draft - * 如果还没有。anchor 已由 finding.anchor 直接给到。 + * Jump to the Diff view to edit the draft for a finding (M4). Parent (MainPane) + * implements: switch tab='diff' + DiffView scroll/highlight/open edit zone + lazily create draft + * if it doesn't exist yet. anchor is passed directly via finding.anchor. */ onJumpToDraftEditor?: (target: { runId: string; findingId: string; anchor: { path: string; startLine: number; endLine: number }; }) => void; - /** /approve /needswork 命令触发的 PR review 决断;由 MainPane 接到 prs:setLocalStatus */ + /** PR review verdict triggered by the /approve /needswork commands; wired to prs:setLocalStatus by MainPane */ onSetReviewStatus?: (status: LocalPrStatus) => void; - /** /merge 命令触发的合并(弹二次确认后调用,跟 PR header 合并按钮共用 prs:merge);仅 canMerge 时可用。 */ + /** Merge triggered by the /merge command (called after the confirm dialog, shares prs:merge with the PR header merge button); only available when canMerge. */ onMerge?: () => void; /** - * 点击 finding 的文件行锚点 → 仅跳转到 Diff 对应行(scroll+highlight,不进编辑态)。 - * 跟 onJumpToDraftEditor 的区别:不带 runId/findingId,不创建 / 打开草稿。 + * Click a finding's file-line anchor → only jump to the corresponding Diff line (scroll+highlight, no edit mode). + * Difference from onJumpToDraftEditor: no runId/findingId, does not create / open a draft. */ onNavigateToAnchor?: (anchor: { path: string; startLine: number; endLine: number }) => void; /** - * 当前 active LLM profile 的 model 名 — RunningView meta chip 显示。 - * null = 无 active profile / 还在加载,UI 不展示 model chip + * Model name of the currently active LLM profile — shown in the RunningView meta chip. + * null = no active profile / still loading, UI does not show the model chip */ currentLlmModel?: string | null; /** - * 是否已配置可用的 LLM(存在与 active_id 匹配的 profile)。false 时即便 pr-agent - * 运行时就绪,也无法发起调用 —— 空态 / 输入栏给出「需配置」提示并禁用。 + * Whether a usable LLM is configured (a profile matching active_id exists). When false, even if the + * pr-agent runtime is ready, no call can be started — the empty state / input bar shows a "needs config" hint and is disabled. */ llmConfigured?: boolean; - /** 打开设置面板(LLM 未配置提示里的「去设置」按钮用) */ + /** Open the settings panel (used by the "Go to settings" button in the LLM-not-configured hint) */ onOpenSettings?: () => void; /** - * 当前 Diff 视图选中的单 commit 范围(无 / root commit 为 null):作为本 PR 聊天区命令的**隐式范围**—— - * 直接键入的 /describe /review /improve /ask 自动限定在该 commit(输入栏显示可撤销范围 chip)。撤销该 chip - * 后本会话不再随视图范围(直到切换到别的 commit)。auto review 微流程不受此影响、恒作用于 PR 全量。 + * The single-commit scope currently selected in the Diff view (null when none / root commit): serves as the **implicit scope** + * for this PR's chat commands — directly typed /describe /review /improve /ask are automatically limited to that commit (the input bar shows a dismissible scope chip). After + * dismissing that chip, this session no longer follows the view scope (until switching to another commit). The auto review micro-flow is unaffected and always operates on the full PR. */ viewCommitScope?: ReviewRunCommitScope | null; + /** User-defined code-suggestion draft layout (settings → agent.strategy.code_suggestion_layout); empty falls back to the built-in prefix. */ + codeSuggestionLayout: string; } /** - * pr-agent 调用面板(M3-D1)。 - * - 头部:两个动作按钮 (/describe /review),pr-agent 不可用时禁用并指引到 Settings - * - 运行中:实时滚动 stdout(main 通过 pragent:runProgress 流式推送) - * - 运行后:展示最新 ReviewRun 的 findings 列表(markdown body + 可选 anchor) + * pr-agent invocation panel (M3-D1). + * - Header: two action buttons (/describe /review), disabled and pointing to Settings when pr-agent is unavailable + * - Running: live-scrolling stdout (streamed from main via pragent:runProgress) + * - After running: shows the latest ReviewRun's findings list (markdown body + optional anchor) * - * 本组件是「容器」:状态与生命周期归 useChatSession,业务动作归 useChatActions,时间线归并归 - * useChatTimeline;展示与工具方法拆到 ./components 与 ./utils。这里只做布局编排与少量纯 UI 态。 + * This component is the "container": state and lifecycle go to useChatSession, business actions to useChatActions, timeline merging to + * useChatTimeline; presentation and utility methods are split into ./components and ./utils. Here we only do layout orchestration and a bit of pure UI state. */ export function ChatPane({ pr, @@ -102,13 +104,14 @@ export function ChatPane({ llmConfigured = true, onOpenSettings, viewCommitScope, + codeSuggestionLayout, }: ChatPaneProps) { const { t } = useTranslation(); const startResize = (e: React.MouseEvent): void => { e.preventDefault(); const startX = e.clientX; const startWidth = width; - // 拖右边 = 缩小 chat (远离左侧的 dx 是正) + // Dragging the right edge = shrink chat (dx away from the left is positive) const onMove = (ev: MouseEvent): void => { const dx = ev.clientX - startX; const next = Math.min(CHAT_MAX_WIDTH, Math.max(CHAT_MIN_WIDTH, startWidth - dx)); @@ -128,34 +131,34 @@ export function ChatPane({ const prLocalId = pr?.localId; - // 全局活动 run + 实时 stdout 缓存。store 来源于 main 的 'pragent:activeChanged' - // / 'pragent:runProgress' 事件,PR 切换不丢,所以这里只读,不在本组件维护 + // Global active runs + live stdout cache. The store is fed by main's 'pragent:activeChanged' + // / 'pragent:runProgress' events and survives PR switches, so this is read-only here, not maintained in this component const { active, waiting, linesByRunId } = useChatRunStore(); - // 并发模型:active 是多条并发运行中的 run。本 PR 的运行中 run 可能 >1(用户对同一 - // PR 连发多个工具);其它 PR 的并发数用于「别处在跑」提示。 + // Concurrency model: active is a list of concurrently running runs. This PR's running runs may be >1 (user fires multiple + // tools at the same PR); other PRs' concurrency count feeds the "running elsewhere" hint. const myActiveRuns = active.filter((a) => a.prLocalId === pr?.localId); const hasMyActive = myActiveRuns.length > 0; - // 本 PR 排队中的任务(FIFO,前面的先跑),在 chat 末尾以「排队中」卡片展示 + // This PR's queued tasks (FIFO, earlier ones run first), shown as "queued" cards at the end of the chat const myWaiting = waiting.filter((w) => w.prLocalId === pr?.localId); const myActiveIds = myActiveRuns.map((a) => a.runId); - // M4 草稿池:从 main 进程拉本 PR 的草稿,跟 finding 通过 source 字段反查关联 + // M4 draft pool: fetch this PR's drafts from the main process, associated with findings via the source field const drafts = useDraftsForPr(prLocalId); - // 复评关闭关系池(只读):复评 /ask 裁决 replace/drop 时由后端自动关闭原 finding(见 asks-step → - // closeFinding)并广播;这里据 (runId,findingId) 反查、在 FindingCard 上以只读 chip 标注「已被复评取代/关闭」。 + // Re-review closure-relation pool (read-only): when a re-review /ask verdict is replace/drop, the backend automatically closes the original finding (see asks-step → + // closeFinding) and broadcasts it; here we look up by (runId,findingId) and mark it on FindingCard with a read-only chip "superseded/closed by re-review". const closures = useFindingClosuresForPr(prLocalId) ?? []; - // 复评引用态:点 finding「引用」→ 仅挂到输入栏(chip);不自动填写问题,用户自行输入。发送时携带该引用。 + // Re-review reference state: clicking a finding's "reference" → only attaches to the input bar (chip); does not auto-fill the question, the user types it. The reference is carried on send. const [refFinding, setRefFinding] = useState<{ finding: Finding; run: ReviewRun } | null>(null); - // 「脱离视图范围」态:用户 ✕ 掉范围 chip 后,本会话命令不再随 Diff 视图选中的 commit(直到切到别的 commit - // 或切 PR 才复位)。默认跟随视图范围。 + // "Detached from view scope" state: after the user ✕'s the scope chip, this session's commands no longer follow the commit selected in the Diff view (until switching to another commit + // or switching PR resets it). Follows the view scope by default. const [scopeDetached, setScopeDetached] = useState(false); - // PR 切换清掉引用态、复位脱离态,避免跨 PR 残留。 + // On PR switch, clear the reference state and reset the detached state to avoid cross-PR residue. useEffect(() => { setRefFinding(null); setScopeDetached(false); }, [prLocalId]); - // 切到别的 commit(或清空视图范围)时复位脱离态:新选中的 commit 重新作为隐式范围生效。 + // On switching to another commit (or clearing the view scope), reset the detached state: the newly selected commit becomes the implicit scope again. useEffect(() => { setScopeDetached(false); }, [viewCommitScope?.sha]); @@ -163,29 +166,29 @@ export function ChatPane({ setRefFinding({ finding, run }); }; - // Diff 选区(归属当前 PR):用于输入栏「N 行已选中」角标 + 把选中代码作为隐式上下文带进提问。 + // Diff selection (belonging to the current PR): used for the input bar's "N lines selected" badge + carrying the selected code as implicit context into a question. const { selection: diffSelection, ignored: selectionIgnored } = useDiffSelection(prLocalId); - // 未忽略时把选区拼成引用串;/ask 与自然语言提问共用。忽略 / 无选区 → undefined(本条不带引用)。 + // When not ignored, format the selection into a reference string; shared by /ask and natural-language questions. Ignored / no selection → undefined (this message carries no reference). const referencedContext = diffSelection && !selectionIgnored ? formatReferencedContext(diffSelection) : undefined; - // 本 PR 聊天区命令的生效范围:跟随 Diff 视图选中的 commit,除非用户已脱离(scopeDetached)。 - // 同一时刻只允许一个 scope 生效——存在 Diff 选区时以选区为准(更细粒度),commit 范围暂挂起、其 chip - // 亦隐藏(见 commitScopeChip),取消选区后自动还原。 + // The effective scope for this PR's chat commands: follows the commit selected in the Diff view, unless the user has detached (scopeDetached). + // Only one scope may be in effect at a time — when a Diff selection exists it takes precedence (finer-grained), the commit scope is suspended and its chip + // is hidden too (see commitScopeChip), auto-restored after the selection is cleared. const effectiveScope = diffSelection || scopeDetached ? null : (viewCommitScope ?? null); - // 会话态 + 生命周期(切 PR 重载 / 流式步骤 / 分页 / 自动滚动) + // Session state + lifecycle (reload on PR switch / streaming steps / pagination / auto-scroll) const session = useChatSession(prLocalId, myActiveIds); - // 切走再回来时,正在跑的 run 已落盘 (status=running) → listRuns 把它读进 runs, - // 同时它又是实时运行中 run,会重复渲染 (历史卡片 + RunningView 各一条)。这里把 - // 所有运行中 run 从历史列表剔除,运行中的展示统一交给下方 RunningView 负责。 + // When switching away and back, the running run has been persisted (status=running) → listRuns reads it into runs, + // while it is also a live running run, causing duplicate rendering (a history card + a RunningView). Here we + // remove all running runs from the history list; running ones are shown solely by the RunningView below. const myActiveIdSet = new Set(myActiveIds); const visibleRuns = hasMyActive ? session.runs.filter((r) => !myActiveIdSet.has(r.id)) : session.runs; - // 业务动作集合(触发工具 / 自动评审 / 对话 / 取消 / 清空 / finding→草稿) + // Business action set (trigger tool / auto review / conversation / cancel / clear / finding→draft) const actions = useChatActions({ pr, prAgent, @@ -194,6 +197,8 @@ export function ChatPane({ myActiveRuns, myWaiting, drafts, + codeSuggestionLayout, + currentLlmModel: currentLlmModel ?? null, setError: session.setError, setRuns: session.setRuns, setHasMoreOlder: session.setHasMoreOlder, @@ -207,8 +212,8 @@ export function ChatPane({ }); const { agentRunningHere } = actions; - // 发送一条复评 /ask:携带被引用 finding 的结构化引用 + 正文上下文,走 /ask 直达工具(出裁决 + - // 采纳/关闭动作);发送后清空引用态。 + // Send a re-review /ask: carries the referenced finding's structured reference + body context, going through the /ask direct tool (produces a verdict + + // adopt/close action); clears the reference state after sending. const sendReferencedAsk = (q: string): void => { if (!refFinding) return; const { finding, run } = refFinding; @@ -220,13 +225,13 @@ export function ChatPane({ setRefFinding(null); }; - // 发送一条限定在当前视图 commit 的 /ask:把生效范围随问题带下去(限定 parent..sha 的 diff)。 + // Send an /ask limited to the currently viewed commit: carries the effective scope along with the question (limited to the parent..sha diff). const sendScopedAsk = (q: string): void => { if (!effectiveScope) return; void actions.handleRun('ask', q, undefined, undefined, effectiveScope); }; - // 历史时间线归并 + 「思考中」实时计时锚点 + // History timeline merge + live timing anchor for "thinking" const { timeline, thinkingSince } = useChatTimeline({ visibleRuns, myActiveRuns, @@ -236,16 +241,16 @@ export function ChatPane({ prLocalId, }); - // 纯 UI 态:规则预览弹窗 / 清空确认弹窗 / 合并确认弹窗 + // Pure UI state: rule preview modal / clear confirm modal / merge confirm modal const [showRulePreview, setShowRulePreview] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [showMergeConfirm, setShowMergeConfirm] = useState(false); const { runs, error, loadingSession, matchedRules, bodyRef, hasMoreOlder, loadingOlder } = session; - // 复评卡 ↔ 原 finding 卡互链:滚动定位 + 短暂高亮。flash class 因目标而异:run 卡用 chat-run-flash - // (背景渐隐,run 卡本身透明底可见);finding 卡用 chat-finding-flash(覆盖式高亮环——finding 卡有 - // 实底 $bg-elev,背景渐隐会被洗掉、看不出闪烁)。 + // Re-review card ↔ original finding card cross-link: scroll to and briefly highlight. The flash class differs by target: run cards use chat-run-flash + // (fading background, visible on the run card's transparent base); finding cards use chat-finding-flash (an overlay highlight ring — finding cards have + // a solid $bg-elev base, so a fading background would be washed out and the flash unnoticeable). const flash = (el: Element, cls: 'chat-run-flash' | 'chat-finding-flash'): void => { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add(cls); @@ -255,8 +260,8 @@ export function ChatPane({ const el = bodyRef.current?.querySelector(`[data-run-id="${CSS.escape(runId)}"]`); if (el) flash(el, 'chat-run-flash'); }; - // 点击复评卡顶部引用徽标:精确定位到原 run 内被引用的那条 finding 卡片并闪烁高亮(找不到该卡片—— - // 如已分页移出 / 折叠——回退到整条 run 高亮,至少给出定位反馈)。 + // Clicking the reference badge at the top of a re-review card: precisely locate and flash-highlight the referenced finding card within the original run (if the card isn't found — + // e.g. paged out / collapsed — fall back to highlighting the whole run, giving at least positional feedback). const scrollToFinding = (runId: string, findingId: string): void => { const runEl = bodyRef.current?.querySelector(`[data-run-id="${CSS.escape(runId)}"]`); if (!runEl) return; @@ -286,7 +291,7 @@ export function ChatPane({ #{pr.remoteId} </span> )} - {/* 运行时策略 chip 撤掉:部署细节用户不关心,状态栏已有 PR Agent 版本 chip */} + {/* Runtime strategy chip removed: users don't care about deployment details, and the status bar already has a PR Agent version chip */} {pr && runs.length > 0 && ( <button type="button" @@ -300,8 +305,8 @@ export function ChatPane({ )} </header> - {/* 当前 PR 命中的规则 chip:rules.dir 未配置 / 整体禁用 / 无命中 → 不显示。 - 点击展开正文预览,让用户能确认本次 review 会被哪条规则约束 */} + {/* Chip for rules matched by the current PR: not shown when rules.dir is unconfigured / globally disabled / no match. + Click to expand the body preview so the user can confirm which rule will constrain this review */} {matchedRules.length > 0 && ( <button type="button" @@ -318,16 +323,16 @@ export function ChatPane({ </button> )} - {/* 规划 Agent 的计划面板:运行中据 agent:planUpdated 实时刷新、随新输入重排;空计划不渲染。 - 置于 header 之下、滚动区之上,始终可见。 */} + {/* The planning Agent's plan panel: refreshes live from agent:planUpdated while running and re-orders with new input; empty plans are not rendered. + Placed below the header and above the scroll area, always visible. */} <PlanPanel todo={session.todo} /> <div className="chat-pane-body" ref={bodyRef}> - {/* 初次拉取会话期间盖延迟 loading(>150ms 才显),遮住「清空 → 内容 pop-in」抖动; - 加载完成才落到下方的真实空态,避免空 PR 误显 loading。 */} + {/* Overlay a delayed loading indicator during the initial session fetch (shown only after >150ms), masking the "clear → content pop-in" jitter; + only fall through to the real empty state below once loading completes, avoiding an empty PR falsely showing loading. */} {loadingSession && <PaneLoading />} - {/* 使用提示仅在「全无会话内容」时显示:一旦有用户输入气泡 / run / 步骤 / 收尾结果, - 或 Agent 正在运行 / 有排队任务,即隐藏,避免输入后仍残留提示。 */} + {/* The usage hint shows only when there is "no session content at all": as soon as there's a user input bubble / run / step / final result, + or the Agent is running / has queued tasks, it hides, to avoid the hint lingering after input. */} {!loadingSession && timeline.length === 0 && !agentRunningHere && @@ -340,25 +345,25 @@ export function ChatPane({ onOpenSettings={onOpenSettings} /> )} - {/* 还有更早的 run 未拉到本地 → 顶部出加载提示。继续向上滚自动游标拉一页 */} + {/* There are still older runs not fetched locally → show a loading hint at the top. Keep scrolling up to auto-cursor-fetch a page */} {(hasMoreOlder || loadingOlder) && ( <div className="chat-run-more-hint muted" role="status"> {loadingOlder ? t('common.loading') : t('chatPane.scrollUpForOlder')} </div> )} - {/* 历史 run 按时间升序堆叠,每条独立卡片 (内部维护自己的 raw stdout 折叠状态)。 - 初始只拉最新 RUNS_PAGE_SIZE 条;向上滚到顶后再用游标拉更早一批 */} + {/* History runs stacked in ascending time order, each an independent card (maintaining its own raw stdout collapse state). + Initially only the latest RUNS_PAGE_SIZE are fetched; after scrolling up to the top, fetch an earlier batch by cursor */} {timeline.map((entry, i) => entry.run ? ( - // data-run-id:供复评卡 ↔ 原 finding 卡互链滚动定位(scrollToRun)。 + // data-run-id: for re-review card ↔ original finding card cross-link scroll targeting (scrollToRun). <div key={entry.key} data-run-id={entry.run.id}> <RunResultView run={entry.run} onRetry={actions.handleRetry} onDelete={actions.handleDeleteRun} - // 只有"时间线里最后一条 run + 没有正在跑的"这一种情形下,失败 / 取消的 run - // 才可重试;用户已经发起新动作 (无论成功或正在跑) → 旧失败不再展示重试, - // 避免回头再点重新插队、打乱对话顺序 + // Only in the single case of "the last run in the timeline + nothing running" can a failed / cancelled run + // be retried; once the user has started a new action (whether succeeded or running) → old failures no longer show retry, + // avoiding a back-click re-queue that would disrupt conversation order canRetry={i === timeline.length - 1 && !hasMyActive} drafts={drafts ?? []} closures={closures} @@ -371,8 +376,8 @@ export function ChatPane({ /> </div> ) : entry.active ? ( - // 正在跑:进度条 + 实时 stdout 流,按启动时间穿插在时间线里(startedAt 入队时为 null、 - // 起跑时设值,fallback enqueuedAt)。prAgent 未就绪时不渲染。 + // Running: progress bar + live stdout stream, interleaved into the timeline by start time (startedAt is null when enqueued, + // set when it starts, falling back to enqueuedAt). Not rendered when prAgent is not ready. prAgent.available ? ( <RunningView key={entry.key} @@ -391,8 +396,8 @@ export function ChatPane({ <ConversationMessage key={entry.key} message={entry.message} /> ) : null, )} - {/* 本 PR 排队中的任务:贴在运行中之后,可单条取消。位次取**全局**队列位序(队列跨 PR 共享, - 否则每个 PR 都显示「第 1 位」会误导)——以 runId 在全局 waiting 数组里的下标 +1 为序。 */} + {/* This PR's queued tasks: placed after running ones, each cancellable individually. The position uses the **global** queue order (the queue is shared across PRs, + otherwise every PR showing "position 1" would be misleading) — the runId's index in the global waiting array +1. */} {myWaiting.map((w) => ( <QueuedView key={w.runId} @@ -402,10 +407,10 @@ export function ChatPane({ onCancel={() => void actions.handleCancel(w.runId)} /> ))} - {/* 过程化跟踪(类 Claude Code):已完成的思考步骤已按时间穿插进上面的时间线(AgentStepRow), - 此处只在 Agent 自身 LLM 正在推理(无 pr-agent 工具 run 占用 / 排队)时补一条实时「思考中」 - 指示——等待工具调用不算思考。计时锚定到「最近一次活动结束」(run 起点 / 末步 / 末个完成 - run 的结束时刻取最晚者)而非组件挂载——切换 PR 再切回不会清零(runningPrs 与 run 历史持久)。 */} + {/* Procedural tracking (Claude Code-like): completed thinking steps are already interleaved into the timeline above by time (AgentStepRow), + here we only add a live "thinking" indicator when the Agent's own LLM is reasoning (no pr-agent tool run occupying / queued) — + waiting on a tool call doesn't count as thinking. The timer is anchored to "the end of the most recent activity" (the latest of run start / last step / last completed + run's end time) rather than component mount — switching PR and back doesn't reset it (runningPrs and run history persist). */} {agentRunningHere && !hasMyActive && myWaiting.length === 0 && ( <ThinkingLive since={thinkingSince} /> )} @@ -421,11 +426,11 @@ export function ChatPane({ pr={pr} prAgent={prAgent} llmConfigured={llmConfigured} - // 队列模型下输入永远开启 (新提交进队列 / 并发执行);runningTool 仅决定是否额外 - // 渲染 stop 按钮 (本 PR 有运行中 run 时可点终止)。多并发时 stop 终止最近一条。 + // Under the queue model input is always enabled (new submissions enter the queue / execute concurrently); runningTool only decides whether to additionally + // render the stop button (clickable to terminate when this PR has a running run). With multiple concurrent, stop terminates the most recent one. runningTool={myActiveRuns[myActiveRuns.length - 1]?.tool ?? null} - // referencedContext 仅 /ask 与自然语言提问携带选区引用(describe/review 不带)。 - // 引用了 finding 时:本条强制走复评 /ask(携带 finding 引用 + 正文上下文),发送后清空引用。 + // referencedContext carries the selection reference only for /ask and natural-language questions (not describe/review). + // When a finding is referenced: this message is forced through re-review /ask (carrying the finding reference + body context), clearing the reference after sending. onRun={(tool, q) => { if (tool === 'ask' && refFinding) { sendReferencedAsk(q ?? ''); @@ -435,7 +440,7 @@ export function ChatPane({ sendScopedAsk(q ?? ''); return; } - // describe/review/improve 亦跟随视图 commit 范围(effectiveScope);无范围时为 PR 全量。 + // describe/review/improve also follow the view commit scope (effectiveScope); when no scope, the full PR. void actions.handleRun( tool, q, @@ -449,7 +454,7 @@ export function ChatPane({ sendReferencedAsk(q); return; } - // 视图选中某 commit 时,自然语言提问也走该 commit 范围的 /ask(限定该 commit 的 diff)。 + // When a commit is selected in the view, natural-language questions also go through /ask scoped to that commit (limited to that commit's diff). if (effectiveScope) { sendScopedAsk(q); return; @@ -458,18 +463,18 @@ export function ChatPane({ }} onCancel={hasMyActive || agentRunningHere ? actions.handleStopAll : undefined} onSetReviewStatus={onSetReviewStatus} - // /merge:仅远端可直接合并时在命令菜单出现;触发先弹二次确认,确认后才实际合并。 + // /merge: appears in the command menu only when the remote can be merged directly; triggering first shows a confirm dialog, merging only after confirmation. canMerge={pr?.mergeStatus?.canMerge ?? false} onMerge={onMerge ? () => setShowMergeConfirm(true) : undefined} - // 一键自动评审:图标按钮置于 `/` 命令触发器右侧。runningHere=跑在当前 PR(高亮 / 运行中文案 + - // 禁用重复发起);其它 PR 在跑不禁用本 PR 的触发(可并发 / 排队)。 + // One-click auto review: icon button placed to the right of the `/` command trigger. runningHere=running on the current PR (highlight / running text + + // disable re-triggering); another PR running does not disable this PR's trigger (can run concurrently / queue). agentRunningHere={agentRunningHere} onAgentReview={() => void actions.handleAgentReview()} - // Diff 选区角标:N 行已选中 / 点击切忽略;无选区时 null(不渲染)。 + // Diff selection badge: N lines selected / click to toggle ignore; null when no selection (not rendered). selectionLineCount={diffSelection?.lineCount ?? null} selectionIgnored={selectionIgnored} onToggleSelection={() => selectionStore.toggleIgnored()} - // 复评引用:chip(直接显示引用定位 <file:line> + 清除);点 finding「引用」时挂上,不自动填写问题。 + // Re-review reference: chip (directly shows the reference location <file:line> + clear); attached when clicking a finding's "reference", does not auto-fill the question. referenceChip={ refFinding ? { @@ -480,13 +485,13 @@ export function ChatPane({ } : null } - // 单 commit 范围 chip:视图选中某 commit 时显示(选中态源自视图);点击切换启用/禁用—— - // 禁用(scopeDetached)时命令回到 PR 全量、chip 置灰,切到别的 commit 或切 PR 复位为启用。 - // 同一时刻只允许一个 scope:存在 Diff 选区时让位于选区 chip(隐藏本 chip),取消选区后自动还原。 + // Single-commit scope chip: shown when a commit is selected in the view (the selected state comes from the view); click to toggle enabled/disabled — + // when disabled (scopeDetached) commands revert to the full PR and the chip greys out; switching to another commit or switching PR resets it to enabled. + // Only one scope at a time: when a Diff selection exists it yields to the selection chip (hides this chip), auto-restored after the selection is cleared. commitScopeChip={ viewCommitScope && !diffSelection ? { - // 仅展示短 hash,不带主题(避免 chip 内容过长);主题在 Diff 视图与结果卡徽标已可见。 + // Show only the short hash, without the subject (to avoid overly long chip content); the subject is already visible in the Diff view and result-card badge. label: viewCommitScope.abbreviatedSha, disabled: scopeDetached, onToggle: () => { diff --git a/apps/desktop/src/renderer/src/components/features/chat/commands.ts b/apps/desktop/src/renderer/src/components/features/chat/commands.ts index 09ed4b8f..4ae07cdc 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/commands.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/commands.ts @@ -1,20 +1,20 @@ import type { LocalPrStatus, ReviewRunTool } from '@meebox/shared'; -/** 槽位定义:键盘操作 / 命令按钮 / 自动补全菜单都从这里取 */ +/** Slot definitions: keyboard actions / command buttons / autocomplete menu all draw from here */ /** - * Chat 命令分三类: - * - 'pragent': pr-agent 工具 (review / describe / ask),触发 pragent:run - * - 'review-action': PR review 决断 (approve / needswork),写 Bitbucket reviewer status - * 通过 prs:setLocalStatus 触发,跟 PR header 按钮共用同一路径 - * - 'pr-action': PR 远端动作 (merge),触发 prs:merge,跟 PR header 合并按钮共用同一路径; - * 仅 mergeStatus.canMerge 时可用,输入栏会弹二次确认 + * Chat commands fall into three kinds: + * - 'pragent': pr-agent tools (review / describe / ask), trigger pragent:run + * - 'review-action': PR review verdicts (approve / needswork), write Bitbucket reviewer status + * triggered via prs:setLocalStatus, sharing the same path as the PR header buttons + * - 'pr-action': PR remote actions (merge), trigger prs:merge, sharing the same path as the PR header merge button; + * only available when mergeStatus.canMerge, the input bar shows a confirm dialog */ export type CommandSpec = | { kind: 'pragent'; name: ReviewRunTool; label: string; - /** i18n key (chatPane 命名空间) 解析命令描述,渲染时用 t(descKey) */ + /** i18n key (chatPane namespace) resolving the command description, rendered via t(descKey) */ descKey: string; insertAs: string; } @@ -22,7 +22,7 @@ export type CommandSpec = kind: 'review-action'; name: 'approve' | 'needswork'; label: string; - /** i18n key (chatPane 命名空间) 解析命令描述,渲染时用 t(descKey) */ + /** i18n key (chatPane namespace) resolving the command description, rendered via t(descKey) */ descKey: string; insertAs: string; reviewStatus: LocalPrStatus; @@ -31,12 +31,12 @@ export type CommandSpec = kind: 'pr-action'; name: 'merge'; label: string; - /** i18n key (chatPane 命名空间) 解析命令描述,渲染时用 t(descKey) */ + /** i18n key (chatPane namespace) resolving the command description, rendered via t(descKey) */ descKey: string; insertAs: string; }; -// 分组顺序:pr-agent 工具 → 分隔线 → review 决断 +// Group order: pr-agent tools → separator → review verdicts export const COMMANDS: ReadonlyArray<CommandSpec> = [ // pr-agent { @@ -53,9 +53,9 @@ export const COMMANDS: ReadonlyArray<CommandSpec> = [ descKey: 'chatPane.cmdDescribeDesc', insertAs: '/describe', }, - // /improve:shim 强制 gfm_markdown=True 后,improve 走「汇总建议 → publish_comment → - // review.md」路径(非 committable,inline 模式仍不可用),parse-output 按 - // generate_summarized_suggestions 的 <details> 模板解析出带重要度评分的 finding。 + // /improve: after the shim forces gfm_markdown=True, improve takes the "summarized suggestions → publish_comment → + // review.md" path (non-committable, inline mode still unavailable), and parse-output parses findings with importance scores + // per the <details> template of generate_summarized_suggestions. { kind: 'pragent', name: 'improve', @@ -70,7 +70,7 @@ export const COMMANDS: ReadonlyArray<CommandSpec> = [ descKey: 'chatPane.cmdAskDesc', insertAs: '/ask ', }, - // review 决断 (跟 PR header 按钮共用 prs:setLocalStatus,写 Bitbucket reviewer status) + // review verdicts (share prs:setLocalStatus with the PR header buttons, write Bitbucket reviewer status) { kind: 'review-action', name: 'approve', @@ -87,7 +87,7 @@ export const COMMANDS: ReadonlyArray<CommandSpec> = [ insertAs: '/needswork', reviewStatus: 'needs_work', }, - // PR 远端动作:合并(跟 PR header 合并按钮共用 prs:merge)。仅 canMerge 时在输入栏可见,弹二次确认。 + // PR remote action: merge (shares prs:merge with the PR header merge button). Visible in the input bar only when canMerge, shows a confirm dialog. { kind: 'pr-action', name: 'merge', diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx index c9744086..005128e1 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx @@ -6,15 +6,18 @@ import { formatElapsed } from '../utils/format'; import { Md, Spinner, TokenStat } from './shared'; /** - * 内联思考步骤(类 Claude Code「先思考→定步骤→执行步骤」):穿插在时间线里、排在所选工具的 run - * 卡片之前。两行展示——首行带 bullet 标记的「已思考 xx s」(单步思考耗时,非总累计),次行另起展示 - * 步骤结果(思考内容 / 判读结论)。不展示选了哪个工具(由随后的 run 卡片体现);工具执行的进度 / - * 计时也归 run 卡片。 + * Inline thinking step (Claude Code-like "think first → decide steps → execute steps"): interleaved + * in the timeline, ordered before the selected tool's run card. Two-line display — the first line has + * a bullet marker "thought for xx s" (single-step thinking time, not cumulative total), the second + * line separately shows the step result (thinking content / judgment conclusion). Does not show which + * tool was selected (reflected by the subsequent run card); tool execution progress / timing also + * belong to the run card. */ export function AgentStepRow({ step }: { step: AgentStep }) { const { t } = useTranslation(); - // 首行始终带 bullet 标记:有思考计时 → 「已思考 xx s」;无计时(如微流程固定派发步)→ 用思考内容 - // 当首行,保证每一步都可见、都有分段标记,绝不渲染成空行。 + // The first line always has a bullet marker: with thinking timer → "thought for xx s"; without a + // timer (e.g. a micro-flow's fixed dispatch step) → use the thinking content as the first line, + // ensuring every step is visible and has a segment marker, never rendered as an empty line. const hasTime = step.thinkMs != null; const headText = hasTime ? t('chatPane.agent.thoughtFor', { time: formatElapsed(step.thinkMs ?? 0) }) @@ -25,16 +28,17 @@ export function AgentStepRow({ step }: { step: AgentStep }) { <span className="chat-agent-step-bullet" aria-hidden> • </span> - {/* AutoPilot 后台评审的首步打机器人 chip,标识「这次评审由 AutoPilot 触发」。 */} + {/* The first step of an AutoPilot background review gets a robot chip, marking "this review was triggered by AutoPilot". */} {step.autopilot && ( <span className="chat-agent-step-autopilot" title={t('chatPane.autopilotRun')}> <RobotIcon size={12} /> </span> )} {headText && <span>{headText}</span>} - {/* 本步**单独**的 token 用量(不累计):judge / 总结 / 规划等经独立 LLM 通道的推理步带值; - 与 run 卡片同款 ↑输入(绿)[⛁缓存]/↓输出(红),输入输出各自独立 hover、靠行尾对齐。 - describe/review/ask 的开销在各自 run 卡片上。 */} + {/* This step's **standalone** token usage (not cumulative): reasoning steps via an independent LLM + channel like judge / summary / planning carry a value; same style as the run card ↑input(green) + [⛁cache]/↓output(red), input and output hover independently, aligned to the line end. + describe/review/ask costs are on their respective run cards. */} {step.usage && (step.usage.promptTokens !== undefined || step.usage.completionTokens !== undefined) ? ( <span className="chat-agent-step-tokens"> @@ -47,8 +51,9 @@ export function AgentStepRow({ step }: { step: AgentStep }) { </span> ) : null} </div> - {/* 思考 / 判读正文走 markdown:保留换行并渲染预格式化内容(代码块 / 列表 / 内联代码), - 与助手回复一致;纯文本内容渲染结果不变。 */} + {/* Thinking / judgment body goes through markdown: preserves line breaks and renders preformatted + content (code blocks / lists / inline code), consistent with assistant replies; plain-text + content renders unchanged. */} {hasTime && step.thought && ( <div className="chat-agent-step-body markdown"> <Md>{step.thought}</Md> @@ -64,10 +69,13 @@ export function AgentStepRow({ step }: { step: AgentStep }) { } /** - * 实时「思考中」指示:仅在 Agent 自身 LLM 正在推理(无工具 run 占用 / 排队)时挂载。计时锚定到传入的 - * `since`(最近一次活动结束时刻,由父级从持久数据算出),而非组件挂载——切走再切回不清零;新一步产生 - * 后 since 前移 → 计时回到当前步从零起算(仍是单步思考时长,非总累计)。 - * 首行布局与已完成步骤对齐:spinner 充当进行中的 bullet 标记,「思考中」后紧贴计时。 + * Live "thinking" indicator: mounted only when the Agent's own LLM is reasoning (no tool run occupying / + * queued). The timer is anchored to the passed-in `since` (the moment the most recent activity ended, + * computed by the parent from persisted data) rather than component mount — switching away and back does + * not reset it; once a new step is produced, since advances forward → the timer restarts from zero for + * the current step (still single-step thinking duration, not cumulative total). + * The first-line layout aligns with completed steps: the spinner acts as the in-progress bullet marker, + * with the timer right after "thinking". */ export function ThinkingLive({ since }: { since: number }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatEmpty.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatEmpty.tsx index 4e246969..6e56f00f 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatEmpty.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatEmpty.tsx @@ -26,7 +26,7 @@ export function ChatEmpty({ </div> ); } - // pr-agent 运行时就绪但没有可用 LLM → 引导去设置配置一条模型 + // pr-agent runtime is ready but no usable LLM → guide the user to settings to configure a model if (!llmConfigured) { return ( <div className="chat-empty"> diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index a2e15fd5..06d5979a 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -19,55 +19,58 @@ import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; interface ChatInputBarProps { pr: StoredPullRequest | null; prAgent: PrAgentStatus; - /** LLM 是否已配置;未配置时禁用输入(即便 pr-agent 运行时就绪也无法调用) */ + /** Whether the LLM is configured; when not, the input is disabled (even if the pr-agent runtime is ready it cannot be invoked) */ llmConfigured: boolean; /** - * 本 PR 上的活动 run 工具;非空时在 send 按钮旁额外渲染 stop 按钮。 - * 队列模型下输入永不因此禁用 (新提交进队列)。 + * The active run tool on this PR; when non-null, an extra stop button is rendered beside the send button. + * Under the queue model the input is never disabled because of this (new submissions enter the queue). */ runningTool: ReviewRunTool | null; onRun: (tool: ReviewRunTool, question?: string) => void; - /** 无 '/' 前缀的自然语言输入 → 交给自由规划 Agent(对话即委派,见设计「会话 Agent 化」)。 */ + /** Natural-language input without a '/' prefix → handed to the free-planning Agent (conversation is delegation, see design "conversation as Agent"). */ onAgentAsk: (question: string) => void; /** - * 终止当前活动 run。仅 runningTool 非空时有意义;ChatPane 已绑好对应 runId。 - * stop 按钮跟 send 共用槽位:runningTool 时点击触发此回调而非 onRun + * Terminates the current active run. Only meaningful when runningTool is non-null; ChatPane has already bound the corresponding runId. + * The stop button shares a slot with send: when runningTool is set, a click triggers this callback instead of onRun */ onCancel?: () => void; - /** /approve /needswork 命令触发的 review 决断,跟 PR header 按钮共用 prs:setLocalStatus */ + /** review decision triggered by the /approve /needswork commands, shares prs:setLocalStatus with the PR header buttons */ onSetReviewStatus?: (status: LocalPrStatus) => void; - /** PR 远端可直接合并(mergeStatus.canMerge):决定 /merge 是否在命令菜单 / 补全出现。 */ + /** The PR can be merged directly on the remote (mergeStatus.canMerge): decides whether /merge appears in the command menu / completion. */ canMerge: boolean; - /** /merge 命令触发(弹二次确认后实际合并,跟 PR header 合并按钮共用 prs:merge)。 */ + /** Triggered by the /merge command (actually merges after a confirmation dialog, shares prs:merge with the PR header merge button). */ onMerge?: () => void; - /** Agent 是否跑在当前 PR:决定图标按钮高亮 + 运行中文案 + 禁用重复发起(其它 PR 在跑不禁用本 PR)。 */ + /** Whether the Agent is running on the current PR: decides the icon button highlight + running text + disabling re-invocation (an Agent running on another PR does not disable this PR). */ agentRunningHere: boolean; - /** 触发一键自动评审微流程(describe→review→条件追问→总结)。 */ + /** Triggers the one-click auto-review micro-flow (describe→review→conditional follow-up ask→summary). */ onAgentReview: () => void; /** - * 当前 Diff 选区行数;null = 无选区(不渲染选区角标)。角标位于 AutoReview 右侧,提示「N 行已选中」, - * 发送时把选中代码作为隐式上下文带进提问。 + * Current Diff selection line count; null = no selection (the selection badge is not rendered). The badge sits to the + * right of AutoReview, hinting "N lines selected", and carries the selected code into the question as implicit context on send. */ selectionLineCount: number | null; - /** 选区忽略态:true 时本条消息不带选区引用(角标置灰 + eye-slash)。 */ + /** Selection ignored state: when true this message carries no selection reference (badge greyed out + eye-slash). */ selectionIgnored: boolean; - /** 点击选区角标 → 切换忽略态。 */ + /** Click the selection badge → toggle the ignored state. */ onToggleSelection: () => void; - /** 复评引用 chip:引用了某条 finding 时展示「复评 <file:line>」+ 清除;null = 不渲染。 */ + /** Re-review reference chip: shows "re-review <file:line>" + clear when a finding is referenced; null = not rendered. */ referenceChip?: { label: string; onClear: () => void } | null; /** - * 单 commit 范围 chip:跟随 Diff 视图选中的 commit 展示「短 SHA · 主题」。存在选中即显示,点击**切换启用/禁用** - * (禁用不删除 chip,本会话命令回到 PR 全量;禁用态置灰 + eye-slash)——选中态源自视图,可手动禁用。 + * Single-commit scope chip: follows the commit selected in the Diff view, showing "short SHA · subject". Shown whenever there is + * a selection, click to **toggle enable/disable** (disabling does not remove the chip, this session's commands revert to the whole + * PR; disabled state greyed out + eye-slash) — the selected state originates from the view and can be disabled manually. */ commitScopeChip?: { label: string; disabled: boolean; onToggle: () => void } | null; } /** - * 输入栏:textarea + 命令按钮 + `/` 触发的自动补全。状态机(输入 / 命令解析 / 补全 / 历史回放 / - * 停止)见 [useChatInput](../hooks/useChatInput.ts);命令解析纯逻辑见 ../utils/parse-command。 + * Input bar: textarea + command button + autocomplete triggered by `/`. State machine (input / command + * parsing / completion / history replay / stop) see [useChatInput](../hooks/useChatInput.ts); the pure + * command-parsing logic is in ../utils/parse-command. * - * 提交语义:空不提交;`/describe` `/review` 等触发对应工具;`/ask <文本>` 触发 ask;未知 `/xxx` 报错; - * 不以 `/` 开头 = 自然语言委派给自由规划 Agent。Shift+Enter 换行,Enter 提交。 + * Submit semantics: empty does not submit; `/describe` `/review` etc. trigger the corresponding tool; + * `/ask <text>` triggers ask; unknown `/xxx` errors; not starting with `/` = natural language delegated + * to the free-planning Agent. Shift+Enter for a newline, Enter to submit. */ export function ChatInputBar({ pr, @@ -147,7 +150,7 @@ export function ChatInputBar({ onClick={() => handleInsertCommand(c)} onMouseEnter={() => setAutocompleteIdx(i)} onMouseDown={(e) => { - // 防止 textarea 失焦后 blur 处理把菜单收掉 + // Prevent the blur handler from collapsing the menu after the textarea loses focus e.preventDefault(); }} role="option" @@ -162,7 +165,7 @@ export function ChatInputBar({ </ul> )} <div className="chat-pane-textarea-wrap"> - {/* 顶边拖动 handle:向上拖 → textarea 高度增加,跟视觉扩展方向一致 */} + {/* Top-edge drag handle: drag up → textarea height increases, consistent with the visual expansion direction */} <div className="chat-pane-textarea-resize-handle" onMouseDown={handleTextareaResizeStart} @@ -203,7 +206,7 @@ export function ChatInputBar({ <ul className="chat-cmd-menu" role="menu"> {visibleCommands.map((c, i) => { const prev = visibleCommands[i - 1]; - // pragent → review-action 边界插一道分隔线 + // Insert a divider at the pragent → review-action boundary const needDivider = prev !== undefined && prev.kind !== c.kind; return ( <li key={c.name} className={needDivider ? 'chat-cmd-menu-group' : undefined}> @@ -222,9 +225,11 @@ export function ChatInputBar({ </ul> )} </div> - {/* 自动评审:图标按钮紧贴 `/` 命令触发器右侧。仅 pr-agent 就绪时出现,LLM 未配置 / 本 PR 评审 - 进行中则禁用触发(其它 PR 在跑不禁用——可并发 / 排队)。停止统一由发送区的停止按钮负责(取消 - 进行中的子任务即终止流程),不再单独提供 Agent 停止按钮,避免两个语义重叠的停止入口。 */} + {/* Auto-review: icon button right beside the `/` command trigger. Appears only when pr-agent is ready; + disabled when the LLM is not configured / a review on this PR is in progress (a review running on another + PR does not disable it — concurrency / queueing allowed). Stopping is handled uniformly by the stop button + in the send area (cancelling the in-progress subtask terminates the flow); no separate Agent stop button is + provided, avoiding two semantically overlapping stop entry points. */} {pr && prAgent.available && ( <button type="button" @@ -241,8 +246,9 @@ export function ChatInputBar({ <AutoReviewIcon /> </button> )} - {/* Diff 选区角标:竖线分隔后展示「N 行已选中」。点击切忽略态(eye-slash + 置灰)——忽略时 - 本条消息不带选区引用。选中代码以隐式上下文随提问发出,不进入会话气泡。 */} + {/* Diff selection badge: shows "N lines selected" after a vertical divider. Click to toggle the ignored + state (eye-slash + greyed out) — when ignored this message carries no selection reference. The selected + code is sent as implicit context with the question and does not enter the conversation bubble. */} {selectionLineCount !== null && ( <> <span className="chat-cmd-divider" aria-hidden="true" /> @@ -261,8 +267,8 @@ export function ChatInputBar({ </button> </> )} - {/* 复评引用 chip:引用了某条 review/improve finding 时展示「复评 <file:line>」,点 ✕ 清除引用。 - 发送时本条 /ask 会携带该 finding 引用走复评模式(出裁决 + 采纳/关闭动作)。 */} + {/* Re-review reference chip: shows "re-review <file:line>" when a review/improve finding is referenced, click ✕ to clear the reference. + On send this /ask carries the finding reference into re-review mode (produces a verdict + adopt/close actions). */} {referenceChip && ( <> <span className="chat-cmd-divider" aria-hidden="true" /> @@ -281,8 +287,8 @@ export function ChatInputBar({ </span> </> )} - {/* 单 commit 范围 chip:跟随视图选中的 commit 展示「短 SHA · 主题」。点击切换启用/禁用(不删除)—— - 启用时命令限定在该 commit(parent..sha),禁用则回到 PR 全量、置灰 + eye-slash。 */} + {/* Single-commit scope chip: follows the commit selected in the view, showing "short SHA · subject". Click to toggle enable/disable (without removing) — + when enabled the commands are scoped to that commit (parent..sha), when disabled they revert to the whole PR, greyed out + eye-slash. */} {commitScopeChip && ( <> <span className="chat-cmd-divider" aria-hidden="true" /> @@ -302,8 +308,8 @@ export function ChatInputBar({ </> )} </div> - {/* 队列模型下 send 永远在 (新提交进队列);本 PR active 时 stop 紧贴 send 左侧。 - 包到一个 group 里避免 input-row 的 space-between 把 stop 推到中央 */} + {/* Under the queue model send is always present (new submissions enter the queue); when this PR is active, stop sits right to the left of send. + Wrapped in a group to prevent input-row's space-between from pushing stop to the center */} <div className="chat-pane-send-group"> {running && onCancel && ( <button diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ConversationMessage.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ConversationMessage.tsx index aab19876..10eb2c7f 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ConversationMessage.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ConversationMessage.tsx @@ -5,8 +5,9 @@ import { VERDICT_LABEL_KEY } from '../constants'; import { Md } from './shared'; /** - * 一条多轮对话消息的展示:用户 → 右对齐气泡;助手评审类(带 recommendation)→「评审总结」卡片 + - * 判定徽标;助手对话类(无 recommendation)→ 左对齐专属对话回复包装。 + * Display of a single multi-turn conversation message: user → right-aligned bubble; assistant review type + * (with recommendation) → "review summary" card + verdict badge; assistant conversation type (no + * recommendation) → left-aligned dedicated conversation reply wrapper. */ export function ConversationMessage({ message }: { message: AgentMessage }) { const { t } = useTranslation(); @@ -14,8 +15,8 @@ export function ConversationMessage({ message }: { message: AgentMessage }) { return ( <div className="chat-user-row"> <div className="chat-user-bubble">{message.content}</div> - {/* 提问携带的引用上下文(Diff 选区代码):气泡下方折叠展示,默认收起保持紧凑。 - referencedContext 自带路径 / 行范围 / 代码围栏,走 markdown 渲染。 */} + {/* Reference context carried by the question (Diff selection code): collapsed display below the bubble, collapsed by default to stay compact. + referencedContext comes with its own path / line range / code fence, rendered via markdown. */} {message.referencedContext && ( <details className="chat-user-ref markdown"> <summary>{t('chatPane.referencedContextLabel')}</summary> diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx index d3634dca..90e729bd 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/FindingCard.tsx @@ -23,13 +23,13 @@ import { } from '../utils/findings'; import { BreakablePath, MdInline, withInlineSummary } from './shared'; -// 折叠标题(<details><summary>)支持内联 markdown:思路建议各方案标题里的 `代码` / **强调** 等生效。 -// 预算在模块级,避免每次渲染重建 components 对象。 +// Collapsible titles (<details><summary>) support inline markdown: `code` / **emphasis** etc. in the suggestion option titles take effect. +// Budgeted at module level to avoid rebuilding the components object on every render. const DEFAULT_MD_COMPONENTS = withInlineSummary(mermaidComponents); const WALKTHROUGH_MD_COMPONENTS = withInlineSummary(walkthroughMdComponents); -// chip 配色 tone → chat-chip-<tone>(色板见 styles/features/chat/chip.scss)。 -// finding 类别:元信息/图/工作量→accent,内容/测试/安全→approved,代码反馈/建议→warning,评分/兜底→neutral。 +// chip color tone → chat-chip-<tone> (palette see styles/features/chat/chip.scss). +// finding categories: meta-info/diagram/effort→accent, content/tests/security→approved, code-feedback/suggestion→warning, score/fallback→neutral. const CAT_TONE: Record<PrDocSectionKey, 'accent' | 'approved' | 'warning' | 'neutral'> = { title: 'accent', 'pr-type': 'accent', @@ -43,14 +43,14 @@ const CAT_TONE: Record<PrDocSectionKey, 'accent' | 'approved' | 'warning' | 'neu security: 'approved', 'code-feedback': 'warning', 'code-suggestion': 'warning', - // /ask 结构化分段:结论高亮(绿)、建议高亮(琥珀)、过程分析中性(灰,默认收起) + // /ask structured segments: conclusion highlight (green), suggestion highlight (amber), process analysis neutral (grey, collapsed by default) 'ask-summary': 'approved', 'ask-analysis': 'neutral', 'ask-suggestions': 'warning', score: 'neutral', general: 'neutral', }; -// 草稿状态:待处理/已编辑→accent,已发布→approved,已拒绝→neutral +// Draft status: pending/edited→accent, posted→approved, rejected→neutral const DRAFT_TONE: Record<NonNullable<ReviewDraft['status']>, 'accent' | 'approved' | 'neutral'> = { pending: 'accent', edited: 'accent', @@ -59,9 +59,9 @@ const DRAFT_TONE: Record<NonNullable<ReviewDraft['status']>, 'accent' | 'approve }; /** - * Finding 卡头部右上角的操作图标栏:编辑(评论气泡)/ 拒绝(圆形禁止)+ 引用(转发箭头)。 - * 仅代码类 finding(/review code-feedback 与 /improve code-suggestion)+ anchor 完整、未关闭时出现, - * 排在折叠 chevron 之左。编辑动作随草稿状态切换语义(编辑 / 查看 / 撤销);posted / rejected 不出拒绝。 + * The action icon bar in the top-right of the Finding card header: edit (comment bubble) / reject (circular ban) + reference (forward arrow). + * Appears only for code-type findings (/review code-feedback and /improve code-suggestion) + complete anchor, not closed, + * ordered to the left of the collapse chevron. The edit action switches semantics with draft status (edit / view / undo); posted / rejected do not show reject. */ function FindingHeadActions({ relatedDraft, @@ -76,11 +76,11 @@ function FindingHeadActions({ }) { const { t } = useTranslation(); const status = relatedDraft?.status; - // posted(远端已存,不撤销)/ rejected(已是拒绝态)不出拒绝按钮。 + // posted (already exists on the remote, not undone) / rejected (already in the rejected state) do not show the reject button. const canReject = status !== 'posted' && status !== 'rejected'; return ( <div className="chat-finding-head-actions"> - {/* 编辑→评论草稿。posted 跳转即「查看」;rejected 跳转即「撤销并继续编辑」。 */} + {/* Edit→comment draft. For posted, jumping means "view"; for rejected, jumping means "undo and keep editing". */} {onJump && ( <button type="button" @@ -115,7 +115,7 @@ function FindingHeadActions({ <BanIcon size={16} /> </button> )} - {/* 引用:发起复评 /ask(挂到输入栏),社媒「转发」箭头图标,排在编辑 / 拒绝之右。 */} + {/* Reference: initiate a re-review /ask (attached to the input bar), social-media "forward" arrow icon, ordered to the right of edit / reject. */} {onReference && ( <button type="button" @@ -132,10 +132,11 @@ function FindingHeadActions({ } /** - * Finding 卡 anchor 行右侧的状态展示。仅代码类 finding(/review code-feedback 与 /improve - * code-suggestion)+ anchor 完整时出现。动作按钮已上移到头部图标栏(见 FindingHeadActions), - * 此处只承载**状态**:被复评裁决 replace/drop 自动关闭时的只读关闭 chip(+「查看复评」导航,不提供 - * 撤销/关闭等用户操作——关闭由后端 ask 任务驱动),否则草稿状态 chip(待处理 / 已编辑 / 已发布 / 已拒绝)。 + * Status display to the right of the Finding card's anchor row. Appears only for code-type findings (/review + * code-feedback and /improve code-suggestion) + complete anchor. The action buttons have been moved up to the + * header icon bar (see FindingHeadActions), so this only carries **status**: a read-only closed chip when auto-closed + * by a re-review verdict of replace/drop (+ "view re-review" navigation, no user operations like undo/close — closing + * is driven by the backend ask task), otherwise the draft status chip (pending / edited / posted / rejected). */ function FindingDraftActions({ relatedDraft, @@ -143,13 +144,13 @@ function FindingDraftActions({ onViewAsk, }: { relatedDraft?: ReviewDraft; - /** 被复评裁决自动关闭/取代时的关闭关系(只读展示驱动)。 */ + /** The closure relationship when auto-closed/replaced by a re-review verdict (drives read-only display). */ closure?: FindingClosure; - /** 「查看复评」导航回调:滚动定位到关闭它的复评 /ask 卡片(只读导航,非关闭操作)。 */ + /** "View re-review" navigation callback: scroll to and locate the re-review /ask card that closed it (read-only navigation, not a close operation). */ onViewAsk?: () => void; }) { const { t } = useTranslation(); - // 已被复评取代/关闭:只读 chip(+ 查看复评导航)。关闭由后端 ask 裁决驱动,不提供撤销按钮。 + // Already replaced/closed by a re-review: read-only chip (+ view re-review navigation). Closing is driven by the backend ask verdict, no undo button is provided. if (closure) { return ( <div className="chat-finding-draft-actions"> @@ -198,50 +199,50 @@ export function FindingCard({ onViewAsk, }: { finding: Finding; - /** 该 finding 关联的草稿;undefined = 尚未交互过;不为空 = 已 pending / edited / rejected / posted */ + /** The draft associated with this finding; undefined = not interacted with yet; non-null = already pending / edited / rejected / posted */ relatedDraft?: ReviewDraft; - /** 「→ 跳到代码编辑」按钮回调 */ + /** "→ Jump to code editing" button callback */ onJump?: () => void; - /** 「✗ 拒绝」按钮回调 */ + /** "✗ Reject" button callback */ onReject?: () => void; - /** 点击锚点:仅导航到 Diff 对应行(不进编辑态) */ + /** Click the anchor: only navigate to the corresponding Diff line (does not enter edit state) */ onNavigate?: () => void; - /** 「引用」按钮回调:把本 finding 挂到输入栏发起复评 /ask(仅 code 类 finding 出现)。 */ + /** "Reference" button callback: attaches this finding to the input bar to initiate a re-review /ask (only appears for code-type findings). */ onReference?: () => void; - /** 本 finding 被复评裁决 replace/drop 自动关闭时的关闭关系(只读展示驱动)。 */ + /** The closure relationship when this finding is auto-closed by a re-review verdict of replace/drop (drives read-only display). */ closure?: FindingClosure; - /** 「查看复评」导航回调:滚动定位到关闭它的复评 /ask 卡片。 */ + /** "View re-review" navigation callback: scroll to and locate the re-review /ask card that closed it. */ onViewAsk?: () => void; }) { const { t } = useTranslation(); - // 已拒绝:左色条 + 类别 chip 置灰,卡片默认折叠收起(仅留头部 chip + 锚点行)。点头部的展开/收起 - // 切换可临时回看正文,不影响草稿状态。 + // Rejected: left color bar + category chip greyed out, card collapsed by default (only the header chip + anchor row remain). Clicking the + // header's expand/collapse toggle can temporarily review the body without affecting the draft status. const isRejected = relatedDraft?.status === 'rejected'; - // 被复评取代/关闭:同样收起降饱和(与已拒绝同套视觉),anchor 行出只读关闭 chip。 + // Replaced/closed by a re-review: likewise collapsed and desaturated (same visual as rejected), the anchor row shows a read-only closed chip. const isClosed = !!closure; - // sectionKey 优先(新解析的),fallback 到 category (旧持久化的 run) + // sectionKey takes priority (newly parsed), fallback to category (from an older persisted run) const key: PrDocSectionKey = finding.sectionKey ?? 'general'; - // 可操作的代码类 finding(/review code-feedback、/improve code-suggestion 且 anchor 带行号): - // 才出头部编辑 / 拒绝 / 引用图标栏 + anchor 行的草稿状态 / 关闭态。 + // Actionable code-type findings (/review code-feedback, /improve code-suggestion with an anchor line number): + // only these show the header edit / reject / reference icon bar + the anchor row's draft status / closed state. const isActionableCode = (key === 'code-feedback' || key === 'code-suggestion') && finding.anchor?.startLine !== undefined; - // 默认折叠:已拒绝 / 被复评关闭 finding,或 /ask「分析过程」段(过程性讨论默认收起、可展开)。 + // Collapsed by default: rejected / re-review-closed findings, or the /ask "analysis process" segment (process discussion collapsed by default, expandable). const collapsibleByDefault = isRejected || isClosed || key === 'ask-analysis'; const [expanded, setExpanded] = useState(false); const collapsed = collapsibleByDefault && !expanded; const label = sectionLabel(key, t); - // 标题在已知 sectionKey 上**通常**跟 chip label 内容重复 (h4 显示 "PR Type" + chip - // 显示 "类型"),所以默认只有 general 段才出 title。但 pr-agent 把若干段的"值"放在 - // 标题里 (e.g., `Estimated effort to review: 3 🔵🔵🔵⚪⚪` / `Score: 85 🟢🟢...`), - // body 是空的;这种情况强制把 title 渲染出来,否则卡片只剩 chip 一片空白。 - // 先剥 [file:...] 末尾 marker (pr-agent /review 的 anchor 注入用,用户不可见) - // 再走 pr-agent 模板翻译。bodyEmpty 也按 stripped 后判断 + // On a known sectionKey the title **usually** duplicates the chip label content (h4 shows "PR Type" + chip + // shows "类型"), so by default only the general segment shows the title. But pr-agent puts some segments' "values" + // in the title (e.g., `Estimated effort to review: 3 🔵🔵🔵⚪⚪` / `Score: 85 🟢🟢...`), + // with an empty body; in this case force the title to render, otherwise the card is left with just the chip and blank space. + // First strip the trailing [file:...] marker (used for pr-agent /review's anchor injection, invisible to the user) + // then run pr-agent template translation. bodyEmpty is also judged after stripping const strippedBody = stripFindingMarker(finding.body); const bodyEmpty = !strippedBody.trim(); const showTitle = !!finding.title && (key === 'general' || bodyEmpty); - // pr-agent 把若干 section 标题 / 固定模板字符串硬编码成英文 (CONFIG__RESPONSE_LANGUAGE - // 只翻译 LLM 内容值),渲染前替换成中文。工作量已用 emoji 圆点表分值,去掉冗余的数字分数。 + // pr-agent hard-codes some section titles / fixed template strings in English (CONFIG__RESPONSE_LANGUAGE + // only translates LLM content values), replaced with Chinese before rendering. Effort already uses emoji dots for the score value, so drop the redundant numeric score. const translatedBody = key === 'effort' ? stripEffortScoreNumber(translatePrAgentLabels(strippedBody)) @@ -253,15 +254,15 @@ export function FindingCard({ : undefined; return ( <li - // data-finding-id:供复评卡顶部引用徽标点击后在原 run 内精确定位到这条原 finding 卡片并闪烁高亮。 + // data-finding-id: lets the reference badge at the top of the re-review card, when clicked, precisely locate this original finding card within the original run and flash-highlight it. data-finding-id={finding.id} className={`chat-finding chat-finding-${key}${isRejected || isClosed ? ' chat-finding-rejected' : ''}${collapsed ? ' chat-finding-collapsed' : ''}`} > <header className={`chat-finding-head${collapsibleByDefault ? ' chat-finding-head-toggle' : ''}`} - // 可折叠卡(分析过程 / 已拒绝 / 被复评关闭的代码反馈):整行标题区即展开/收起热区,扩大可点面积。 - // 忽略来自内部按钮(编辑/拒绝/引用/chevron)的点击——它们各自处理、不应误触折叠(chevron 的 - // 点击经此处冒泡,由它自身的 onClick 处理一次即可,故 closest('button') 命中时直接跳过)。 + // Collapsible cards (analysis process / rejected / re-review-closed code feedback): the whole title row is the expand/collapse hot zone, enlarging the clickable area. + // Ignore clicks from inner buttons (edit/reject/reference/chevron) — they handle themselves and should not accidentally trigger collapse (the chevron's + // click bubbles through here, its own onClick handles it once, so skip directly when closest('button') hits). onClick={ collapsibleByDefault ? (e) => { @@ -271,13 +272,13 @@ export function FindingCard({ : undefined } > - {/* 已知 sectionKey 用中文标签 chip;general / 未知不显示,避免 UI 噪音 */} + {/* Known sectionKey uses a Chinese label chip; general / unknown are not shown, avoiding UI noise */} {label && ( <span className={`chat-chip chat-chip-md chat-finding-cat chat-chip-${CAT_TONE[key]}`}> {label} </span> )} - {/* PR Type 段:值胶囊与「类型」标签同排、右对齐(不再上下两排,提升空间利用率) */} + {/* PR Type segment: value pills on the same row as the "type" label, right-aligned (no longer two stacked rows, improving space utilization) */} {key === 'pr-type' && ( <div className="chat-finding-pills chat-finding-pills-inline"> {splitTypeLabels(translatedBody).map((t) => ( @@ -292,8 +293,8 @@ export function FindingCard({ <MdInline>{translatedTitle}</MdInline> </h4> )} - {/* 头部操作图标栏:编辑(评论)/ 拒绝(圆形禁止)/ 引用(转发箭头)。仅可锚定的 code 类 - finding 且未被复评关闭时出现,排在折叠 chevron 之左;与标题同排、右上角成组。 */} + {/* Header action icon bar: edit (comment) / reject (circular ban) / reference (forward arrow). Appears only for anchorable code-type + findings not closed by a re-review, ordered to the left of the collapse chevron; on the same row as the title, grouped in the top-right. */} {isActionableCode && !isClosed && (onJump || onReject || onReference) && ( <FindingHeadActions relatedDraft={relatedDraft} @@ -302,7 +303,7 @@ export function FindingCard({ onReference={onReference} /> )} - {/* 可默认折叠的段(已拒绝 / ask 分析过程)出现展开 / 收起切换:chevron 收起态指右、展开态转下 */} + {/* Segments collapsible by default (rejected / ask analysis process) get an expand / collapse toggle: chevron points right when collapsed, turns down when expanded */} {collapsibleByDefault && ( <button type="button" @@ -317,7 +318,7 @@ export function FindingCard({ {finding.anchor && ( <div className="chat-finding-anchor muted"> {finding.anchor.startLine !== undefined && onNavigate ? ( - // 可点击:跳转到 Diff 对应行(scroll+highlight,不进编辑态) + // Clickable: jump to the corresponding Diff line (scroll+highlight, does not enter edit state) <button type="button" className="chat-finding-anchor-link" @@ -349,7 +350,7 @@ export function FindingCard({ )} </> )} - {/* /improve 建议带的 1-10 重要度评分;高分加 warning 着色提示 reviewer */} + {/* The 1-10 importance score carried by /improve suggestions; high scores get warning coloring to alert the reviewer */} {typeof finding.score === 'number' && ( <span className={`chat-finding-score${finding.score >= 8 ? ' chat-finding-score-high' : ''}`} @@ -358,8 +359,8 @@ export function FindingCard({ {finding.score}/10 </span> )} - {/* M4 草稿状态 chip / 复评关闭态:锚到具体行的代码类 finding 才展示(操作按钮已上移到头部 - 图标栏)。仅在有草稿状态或被复评关闭时出现,否则不占位。 */} + {/* M4 draft status chip / re-review closed state: only shown for code-type findings anchored to a specific line (action buttons + have been moved up to the header icon bar). Appears only when there is a draft status or it is closed by a re-review, otherwise takes no space. */} {isActionableCode && (isClosed || relatedDraft?.status) && ( <FindingDraftActions relatedDraft={relatedDraft} @@ -369,30 +370,30 @@ export function FindingCard({ )} </div> )} - {/* 可折叠内容(正文 + 代码对比):grid-rows 0fr↔1fr 平滑收展(auto 高度可动画)。内容始终挂载, - 由 CSS 按 .chat-finding-collapsed 收起、inner overflow:hidden 裁切——故折叠/展开有高度过渡动画。 - pr-type 的值胶囊已并入头部行、无正文段;无 codeChange 时整体不渲染。 */} + {/* Collapsible content (body + code comparison): grid-rows 0fr↔1fr for smooth collapse/expand (auto height can be animated). Content is always mounted, + collapsed by CSS via .chat-finding-collapsed with inner overflow:hidden clipping — hence the height transition animation on collapse/expand. + pr-type's value pills have been merged into the header row, no body segment; when there is no codeChange the whole thing does not render. */} {(key !== 'pr-type' || finding.codeChange) && ( <div className="chat-finding-collapsible"> <div className="chat-finding-collapsible-inner"> {key !== 'pr-type' && ( <div className="chat-finding-body markdown"> - {/* remarkBreaks 把 finding body 里的单换行也当成 <br>。pr-agent 的 trace、 - 或一般段落里 reviewer 习惯按软换行折行,不加 remarkBreaks 会被 markdown - 合并成长一行。Findings 主要是富文本说明,不存在"故意软换行连接"的场景 */} + {/* remarkBreaks treats single line breaks in the finding body as <br> too. In pr-agent's trace, + or in general paragraphs where reviewers habitually wrap by soft line breaks, without remarkBreaks markdown + would merge them into one long line. Findings are mainly rich-text descriptions, there is no "deliberate soft-break joining" scenario */} <ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={REMOTE_REHYPE_PLUGINS} - // 「文件变更」walkthrough 用去掉 <details open> 的覆盖,使各文件分类默认折叠收起。 - // 两套均叠加「<summary> 内联 markdown」(折叠标题支持 `代码` 等预格式化)。 + // The "file changes" walkthrough uses an override without <details open>, so each file category is collapsed by default. + // Both sets add "<summary> inline markdown" on top (collapsible titles support preformatting like `code`). components={key === 'walkthrough' ? WALKTHROUGH_MD_COMPONENTS : DEFAULT_MD_COMPONENTS} > {translatedBody} </ReactMarkdown> </div> )} - {/* /improve 给的 existing → improved 代码对比。两段都是片段,独立 <pre> 块 - + 红/绿背景 模拟 diff 视觉 (不用 Monaco DiffEditor 节省开销) */} + {/* The existing → improved code comparison given by /improve. Both are fragments, independent <pre> blocks + + red/green background to mimic the diff visual (not using Monaco DiffEditor, saving overhead) */} {finding.codeChange && ( <div className="chat-finding-code-change"> {finding.codeChange.existing && ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/PlanPanel.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/PlanPanel.tsx index a228f6cc..a49ac68a 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/PlanPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/PlanPanel.tsx @@ -2,8 +2,9 @@ import { useTranslation } from 'react-i18next'; import type { AgentTodoItem } from '@meebox/shared'; /** - * 规划 Agent 的计划面板:展示当前 todo(勾选 = 已完成)。运行中据 agent:planUpdated 实时刷新、随新输入 - * 重排;切 PR / 重启经 agent:getSession 水合。空计划不渲染。 + * Plan panel for the planning Agent: shows the current todo (checked = done). Refreshes live from + * agent:planUpdated while running, reorders with new input; hydrates via agent:getSession on PR switch / + * restart. Empty plan is not rendered. */ export function PlanPanel({ todo }: { todo: AgentTodoItem[] }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/QueuedView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/QueuedView.tsx index c6692309..8d6e7476 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/QueuedView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/QueuedView.tsx @@ -5,8 +5,8 @@ import { CloseIcon } from '../../../common'; import { AskQuestion } from './shared'; /** - * 排队中的任务卡片:贴在运行中之后,按队列顺序展示 tool / 位置 / (ask 的提问), - * 提供单条取消。跟 RunningView / RunMeta 共用 chat-run-meta 骨架,视觉一致。 + * Queued task card: sits after the running one, showing tool / position / (ask's question) in queue order, + * with per-item cancel. Shares the chat-run-meta skeleton with RunningView / RunMeta for visual consistency. */ export function QueuedView({ tool, diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx index aa1d63c8..8be28315 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx @@ -9,8 +9,9 @@ import { invoke } from '../../../../api'; import type { MatchedRules } from '../types'; /** - * 命中规则预览。单条沿用「Rule: <id>」标题;多条时标题改为计数,正文按 `Ruleset N` 分段逐条列出 - * (与评审注入的拼接口径一致),让用户确认本次 review 会被哪些规约约束。 + * Matched rule preview. A single match keeps the「Rule: <id>」title; multiple matches switch the title to a + * count and list each in the body split by `Ruleset N` (matching the review injection's concatenation), + * letting the user confirm which rules will constrain this review. */ export function RulePreviewModal({ rules, @@ -53,7 +54,7 @@ export function RulePreviewModal({ )} <div className="modal-kv"> <div className="modal-kv-key">{t('chatPane.ruleFilePath')}</div> - {/* 相对 Agent 目录展示(`rules/<id>`),避免暴露冗长的机器绝对路径;打开目录按钮在标题栏统一提供。 */} + {/* Shown relative to the Agent dir (`rules/<id>`), to avoid exposing the long machine absolute path; the open-dir button is provided uniformly in the title bar. */} <div className="modal-kv-val"> <code>rules/{rule.id}</code> </div> diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx index 4e62bd39..40f4634d 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx @@ -11,8 +11,8 @@ import { FindingCard } from './FindingCard'; function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { const { t } = useTranslation(); const duration = run.durationMs ? `${(run.durationMs / 1000).toFixed(1)}s` : '—'; - // 优先用 run.tokenUsage(litellm callback 捕获的 API 真实 usage,见 sitecustomize); - // 历史 run 没这字段时回退到从 stdout 抓取的旧估算,保持向后兼容。 + // Prefer run.tokenUsage (the real API usage captured by the litellm callback, see sitecustomize); + // fall back to the old estimate scraped from stdout when historical runs lack this field, for backward compatibility. const usage: TokenUsage = run.tokenUsage ? { prompt: run.tokenUsage.promptTokens, @@ -30,8 +30,8 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { <span className={`chat-chip chat-run-status chat-run-status-${run.status}`}> {runStatusLabel(run.status, t)} </span> - {/* 单 commit 评审范围徽标:本次 run 限定在某 commit 自身改动(parent..sha)时展示短 SHA; - 全量 PR 范围(无 scope)不渲染。 */} + {/* Single-commit review scope badge: shows the short SHA when this run is limited to a commit's own changes (parent..sha); + not rendered for full PR scope (no scope). */} {run.scope && ( <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-scope" @@ -41,8 +41,8 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { {run.scope.abbreviatedSha} </span> )} - {/* 模型 chip 取代运行时策略 chip — strategy 是部署细节用户不 - 关心,model 是真正影响 review 质量的变量 */} + {/* Model chip replaces the runtime strategy chip — strategy is a deployment detail users don't + care about, model is the variable that actually affects review quality */} {run.model && ( <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-model" @@ -51,7 +51,7 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { {run.model} </span> )} - {/* 输入(↑绿)[⛁缓存]/输出(↓红):输入输出各自独立 hover;缓存为输入一部分、无命中不显示。旧 run 可能只有 prompt */} + {/* input(↑green)[⛁cache]/output(↓red): input and output each hover independently; cache is part of input, hidden on no match. Old runs may have only prompt */} {usage.prompt !== undefined || usage.completion !== undefined ? ( <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-tokens"> <TokenStat @@ -61,7 +61,7 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { /> </span> ) : null} - {/* 模型交互轮次:循环箭头图标 + 次数(取代「N 轮」文案,省空间 / 免复数);仅多轮(agentic) 时展示 */} + {/* Model interaction turns: loop arrow icon + count (replaces the「N turns」text, saving space / avoiding plurals); shown only for multi-turn (agentic) */} {usage.turns !== undefined && usage.turns > 1 ? ( <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-turns" @@ -74,15 +74,15 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-duration"> {duration} </span> - {/* 开始时间:纯文本不带胶囊背景,margin-left:auto 顶到最右 — 跟左侧 - tool/status/strategy chip 拉开距离,视觉权重比 chip 轻一档 */} + {/* Start time: plain text with no pill background, margin-left:auto pushes it to the far right — spaced apart from the left-side + tool/status/strategy chips, one visual weight lighter than a chip */} <span className="chat-run-time" title={t('chatPane.startedAtTitle', { time: new Date(run.startedAt).toLocaleString() })} > {formatStartTime(run.startedAt)} </span> - {/* 删除本条 run 记录:状态行最右的小垃圾桶按钮(仅删该 run,不影响其它记录 / 徽标)。 */} + {/* Delete this run record: the small trash button at the far right of the status row (deletes only this run, no effect on other records / badges). */} <button type="button" className="chat-run-delete" @@ -112,45 +112,45 @@ export function RunResultView({ }: { run: ReviewRun; onRetry: (run: ReviewRun) => void; - /** 删除本条 run 记录(仅该 run)。 */ + /** Delete this run record (this run only). */ onDelete: (runId: string) => void; - /** 由父组件按"最后一条 + 无活动 run"判定;false 时失败 / 取消 run 也不显示重试键 */ + /** Determined by the parent as "last one + no active run"; when false, failed / cancelled runs also don't show the retry button */ canRetry: boolean; - /** 本 PR 当前草稿池快照;FindingCard 据此显示 status chip + 决定 reject 行为 */ + /** Snapshot of this PR's current draft pool; FindingCard uses it to show the status chip + decide reject behavior */ drafts: ReadonlyArray<ReviewDraft>; - /** 本 PR 的 finding 关闭关系快照(只读);据 (run.id,finding.id) 反查复评关闭态,标注只读 chip。 */ + /** Snapshot of this PR's finding closure relations (read-only); looks up the review-closed state by (run.id,finding.id), marking the read-only chip. */ closures: ReadonlyArray<FindingClosure>; - /** 点击 finding card 上"→ 跳到代码编辑"时触发。父组件做懒创建 + 跳转 */ + /** Fired when clicking "→ jump to code edit" on a finding card. The parent does lazy creation + jump */ onJumpToDraft: (finding: Finding, run: ReviewRun) => void; - /** 拒绝某条 finding:创建 / 更新草稿到 status='rejected' */ + /** Reject a finding: create / update a draft to status='rejected' */ onRejectFinding: (finding: Finding, run: ReviewRun) => void; - /** 点击 finding 锚点:仅导航到 Diff 对应行(不进编辑态) */ + /** Click a finding anchor: only navigate to the corresponding Diff line (no edit mode) */ onNavigateToFinding: (finding: Finding) => void; - /** 「引用」一条 code finding 发起复评 /ask(挂到输入栏)。 */ + /** 「Reference」a code finding to start a re-review /ask (attach to the input bar). */ onReferenceFinding: (finding: Finding, run: ReviewRun) => void; - /** 滚动定位到指定 run 卡片(复评关闭态「查看复评」→ 关闭它的 ask run)。 */ + /** Scroll to a given run card (review-closed state「view re-review」→ the ask run that closed it). */ onScrollToRun: (runId: string) => void; - /** 滚动定位到指定 run 内的某条 finding 卡片并闪烁高亮(复评卡顶部引用徽标 → 原 finding 卡)。 */ + /** Scroll to a given finding card within a run and flash-highlight it (reference badge at the top of the re-review card → original finding card). */ onScrollToFinding: (runId: string, findingId: string) => void; }) { const { t } = useTranslation(); const findings = run.findings ?? []; - // 失败 + 取消都用红 banner 提示。取消是用户主动行为,UI 用更轻文案区分 + // Both failed + cancelled use a red banner. Cancel is a deliberate user action, so the UI distinguishes it with lighter wording const isFailed = run.status === 'failed'; const isCancelled = run.status === 'cancelled'; const isFailedOrCancelled = isFailed || isCancelled; const stdout = run.stdout ?? ''; - // "原始输出" 折叠区独立 per-run 维护状态,互不影响。失败 / 取消默认展开方便排障, - // 成功默认关闭只是诊断兜底 + // The "raw output" collapse region maintains per-run state independently, unaffecting each other. Failed / cancelled default to expanded for easier troubleshooting, + // success defaults to closed as just a diagnostic fallback const [showRawStdout, setShowRawStdout] = useState(isFailedOrCancelled); - // /ask 工具:把用户提问展示在 meta 行**下方**,跟 /ask 这个动作绑成一组; - // 上方再放用户气泡会跟 meta 行重复信息源,移到动作下方更符合"动作 → 输入"语序 + // /ask tool: show the user's question **below** the meta row, grouped with the /ask action; + // putting a user bubble above would duplicate the meta row's info source, moving it below the action better fits the "action → input" order const userMessage = run.tool === 'ask' ? run.question?.trim() : undefined; return ( <div className="chat-run-result"> <RunMeta run={run} onDelete={() => onDelete(run.id)} /> - {/* 复评 /ask:顶部引用定位徽标(转发箭头 + 完整路径:行号),点击滚动定位到被引用的原 finding 所在 run。 - 直接显示完整定位信息(不再用「复评自」文案,省 i18n);路径换行规则同代码建议定位(BreakablePath 软断点)。 */} + {/* Re-review /ask: top reference-locator badge (forward arrow + full path:line), click to scroll to the run of the referenced original finding. + Shows the full locator info directly (no more「re-reviewed from」text, saving i18n); path wrapping follows code-suggestion location (BreakablePath soft break points). */} {run.referencedFinding && ( <button type="button" @@ -180,8 +180,8 @@ export function RunResultView({ </button> )} {userMessage && <AskQuestion text={userMessage} />} - {/* 原始输出:始终紧跟 meta 行,让用户在任何状态下都能在固定位置找到日志。 - 失败 / 取消默认展开,成功默认收起 */} + {/* Raw output: always right after the meta row, so users can find logs in a fixed position in any state. + Failed / cancelled default to expanded, success defaults to collapsed */} {stdout.length > 0 && ( <details className="chat-run-raw" @@ -204,8 +204,8 @@ export function RunResultView({ : run.errorReason ? t('chatPane.runFailedReason', { reason: run.errorReason }) : t('chatPane.runFailed')} - {/* llm-error 时 exitCode 是 0 (pr-agent 自己 catch 了),显示出来反而 - 让用户误以为没出错,所以跳过 */} + {/* On llm-error the exitCode is 0 (pr-agent caught it itself), showing it would instead + mislead users into thinking nothing went wrong, so skip it */} {run.exitCode != null && !isCancelled && run.errorReason !== 'llm-error' && @@ -229,28 +229,28 @@ export function RunResultView({ {run.errorMessage && !isCancelled && ( <pre className="chat-error-detail">{run.errorMessage}</pre> )} - {/* 失败 / 取消不再单独展示输出区块:pr-agent 日志已在上方可折叠的「原始输出」(stdout 含 - [pr-agent stdout log] 段,与 stderr 同源),避免同一份日志重复成两块。 */} + {/* Failed / cancelled no longer show a separate output block: the pr-agent log is already in the collapsible「raw output」above (stdout contains + the [pr-agent stdout log] segment, same source as stderr), avoiding duplicating the same log into two blocks. */} </div> )} - {/* 失败 / 取消不渲染 findings:取消的 /describe 会把部分 stdout 解析成「段落」误当结果展示。 - 失败 / 取消统一只保留上方可折叠的「原始输出」+ 状态横幅,不在下方另起输出区块。 */} + {/* Failed / cancelled don't render findings: a cancelled /describe would parse part of stdout into「paragraphs」and mistakenly show them as results. + Failed / cancelled uniformly keep only the collapsible「raw output」above + the status banner, not opening another output block below. */} {!isFailedOrCancelled && (findings.length > 0 ? ( <ul className="chat-finding-list"> {orderFindings(findings).map((f) => { - // 同 run 内 finding 跟草稿一对一:source.runId+findingId 反查。命中后 - // FindingCard 据此显示状态 chip + 跳转/拒绝按钮行为分支 + // Findings within the same run map one-to-one to drafts: looked up by source.runId+findingId. On match, + // FindingCard uses it to show the status chip + branch the jump/reject button behavior const relatedDraft = drafts.find( (d) => d.source !== undefined && d.source.runId === run.id && d.source.findingId === f.id, ); - // 复评关闭态(只读):该 finding 被复评 /ask 裁决 replace/drop 自动关闭时反查到。 + // Review-closed state (read-only): looked up when this finding was auto-closed by a re-review /ask ruling replace/drop. const closure = closures.find((c) => c.runId === run.id && c.findingId === f.id); - // 「引用」仅对可锚定的 code 类 finding(review/improve)提供——它们才是可被复评的代码评论。 + // 「Reference」is only offered for anchorable code-type findings (review/improve) — they are the code comments that can be re-reviewed. const canReference = (f.sectionKey === 'code-feedback' || f.sectionKey === 'code-suggestion') && typeof f.anchor?.startLine === 'number'; diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx index 087de70b..5ede0534 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunningView.tsx @@ -16,25 +16,25 @@ export function RunningView({ }: { tool: ReviewRunTool; runId: string; - /** /ask 的提问:执行中也直接展示(与排队 / 完成态一致;问题在派发时已生成)。 */ + /** /ask's question: shown directly while running too (consistent with queued / done state; the question was generated at dispatch). */ question?: string; - /** 单 commit 评审范围(parent..sha);限定在某 commit 时展示范围徽标,与完成态卡片一致。 */ + /** Single-commit review scope (parent..sha); shows the scope badge when limited to a commit, consistent with the done-state card. */ scope?: ReviewRunCommitScope; lines: ReadonlyArray<string>; startedAt: number; - /** 当前 active LLM profile.model — 跟 RunMeta 同源放在 chip 行,让 running - 跟 succeeded 视觉一致;可选 (无 active profile 时不显示) */ + /** The current active LLM profile.model — placed in the chip row from the same source as RunMeta, keeping running + visually consistent with succeeded; optional (not shown when there's no active profile) */ model: string | null; }) { const { t } = useTranslation(); - // 末行追加时自动滚到底 + // Auto-scroll to bottom when the last line is appended const ref = useRef<HTMLPreElement | null>(null); useEffect(() => { const el = ref.current; if (el) el.scrollTop = el.scrollHeight; }, [lines.length]); - // 计时器:pr-agent stdout 长间隔时让用户感知到不是卡死。1s 粒度即可 + // Timer: lets users perceive it isn't stuck during long gaps in pr-agent stdout. 1s granularity is enough const [elapsedMs, setElapsedMs] = useState(0); useEffect(() => { setElapsedMs(Date.now() - startedAt); @@ -45,9 +45,9 @@ export function RunningView({ const phase = useMemo(() => inferPhase(lines, t), [lines, t]); const text = useMemo(() => lines.join('\n'), [lines]); - // 跟 RunMeta 完全同结构的 chip 行。running 跟 succeeded/failed 共享一套视觉 - // 骨架,用户从列表扫一眼能在固定位置看到 tool / 状态 / 模型 / 时长。strategy - // 运行时策略是部署细节用户不关心,撤掉;model 是真正影响 review 质量的变量 + // A chip row structurally identical to RunMeta. running shares one visual + // skeleton with succeeded/failed, so a glance down the list shows tool / status / model / duration in fixed positions. strategy + // runtime strategy is a deployment detail users don't care about, removed; model is the variable that actually affects review quality return ( <div className="chat-run-running" data-run-id={runId}> <header className="chat-run-meta"> @@ -56,7 +56,7 @@ export function RunningView({ <Spinner /> {runStatusLabel('running', t)} </span> - {/* 单 commit 范围徽标:与完成态 RunMeta 一致,让运行中也能看到本次限定的提交。 */} + {/* Single-commit scope badge: consistent with the done-state RunMeta, so the limited commit is visible while running too. */} {scope && ( <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-scope" @@ -77,8 +77,8 @@ export function RunningView({ <span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-duration"> {formatElapsed(elapsedMs)} </span> - {/* 开始时间:跟 RunMeta 同模 — 纯文本右对齐,让 running 跟 succeeded - 两态最右侧元素位置稳定 */} + {/* Start time: same pattern as RunMeta — plain text right-aligned, keeping the far-right element's position stable + across the running and succeeded states */} <span className="chat-run-time" title={t('chatPane.startedAtTitle', { time: new Date(startedAt).toLocaleString() })} @@ -86,14 +86,14 @@ export function RunningView({ {formatStartTime(startedAt)} </span> </header> - {/* /ask 的提问执行中也直接展示(问题已生成,不必等排队 / 完成才可见)。 */} + {/* /ask's question is shown directly while running too (the question is already generated, no need to wait for queued / done to be visible). */} {tool === 'ask' && question?.trim() && <AskQuestion text={question.trim()} />} {phase && ( <div className="chat-chip chat-chip-md chat-chip-quiet chat-chip-accent chat-run-phase"> {phase} </div> )} - {/* 控制台输出:执行中默认折叠收起、可手动展开(与完成态「原始输出」同款折叠效果)。 */} + {/* Console output: collapsed by default while running, manually expandable (same collapse effect as the done-state「raw output」). */} <details className="chat-run-raw"> <summary>{t('chatPane.rawOutput', { n: text.length })}</summary> <AnsiPre diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx index 0fedc631..9c03d81c 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx @@ -21,7 +21,7 @@ export function Bullet({ children }: { children: ReactNode }) { ); } -/** chat 区统一的 markdown 渲染(与 finding 卡片同套 remark/rehype 配置)。 */ +/** Unified markdown rendering for the chat area (same remark/rehype config as finding cards). */ export function Md({ children }: { children: string }) { return ( <ReactMarkdown @@ -34,10 +34,10 @@ export function Md({ children }: { children: string }) { ); } -/** 行内 markdown:用于标题等单行文本,渲染内联代码 / 强调,去掉块级 <p> 包裹保持行内排版。 */ +/** Inline markdown: for single-line text such as titles, rendering inline code / emphasis, dropping the block-level <p> wrapper to keep inline layout. */ export function MdInline({ children }: { children: string }) { - // 标题以「2. 」「- 」这类列表标记开头时,markdown 会把它解析成 <ol>/<ul> 块塞进 <h4>,撑破内联布局 - // 并溢出。转义行首列表标记,按字面渲染序号 / 符号(保留文字),不再生成列表块。 + // When a title starts with a list marker like "2. " or "- ", markdown parses it into an <ol>/<ul> block stuffed into <h4>, breaking the inline layout + // and overflowing. Escape leading list markers to render the number / symbol literally (keeping the text), no longer generating a list block. const inlineSafe = children .replace(/^(\s*)(\d+)\.(\s)/, '$1$2\\.$3') .replace(/^(\s*)([-*+])(\s)/, '$1\\$2$3'); @@ -53,9 +53,9 @@ export function MdInline({ children }: { children: string }) { } /** - * `<summary>` 内联 markdown 渲染:raw HTML 的折叠标题(如「思路建议」各方案的 <details><summary>)内的 - * 文本不会被 markdown 二次解析,反引号 / 强调等会原样漏出。这里把其纯文本走 {@link MdInline},让标题里的 - * `代码` / **强调** 生效。children 多为纯文本串;含非文本节点时原样渲染兜底。 + * `<summary>` inline markdown rendering: text inside raw-HTML collapse titles (e.g. the <details><summary> of each option under "suggestions") + * is not re-parsed by markdown, so backticks / emphasis leak through verbatim. Here we route its plain text through {@link MdInline}, so that + * `code` / **emphasis** in the title take effect. children is usually a plain-text string; falls back to rendering verbatim when it contains non-text nodes. */ const SummaryInlineMd: Components['summary'] = ({ children }) => { const text = @@ -73,14 +73,14 @@ const SummaryInlineMd: Components['summary'] = ({ children }) => { ); }; -/** 在给定 markdown components 之上叠加「<summary> 内联 markdown」渲染(折叠标题支持 md 预格式化)。 */ +/** Layer "<summary> inline markdown" rendering on top of the given markdown components (collapse titles support md pre-formatting). */ export function withInlineSummary(base: Components): Components { return { ...base, summary: SummaryInlineMd }; } /** - * 代码路径折行优化:在分隔符 `/` 与连接符 `.` `_` `-` 之后插入 <wbr> 软断点,配合 CSS - * `word-break: normal`,让长路径优先按这些字符折断(而非从单词中间断开),保证可读性。 + * Code-path line-break optimization: insert <wbr> soft break points after the separator `/` and the connectors `.` `_` `-`, combined with CSS + * `word-break: normal`, so long paths preferentially break at these characters (rather than mid-word), ensuring readability. */ export function BreakablePath({ path }: { path: string }) { const parts = path.split(/(?<=[/._-])/); @@ -92,7 +92,7 @@ export function BreakablePath({ path }: { path: string }) { return <>{nodes}</>; } -/** 把含 ANSI 转义的 stdout 文本渲染成带颜色的 <pre>。空文本时显示占位 */ +/** Render ANSI-escaped stdout text into a colored <pre>. Shows a placeholder when the text is empty */ export function AnsiPre({ className, text, @@ -124,9 +124,9 @@ export function AnsiPre({ } /** - * Token 用量内联展示:↑输入(绿) [⛁缓存命中] / ↓输出(红)。输入、输出**各自独立 hover 提示**; - * 缓存命中(cache_read)为输入的一部分,柱体图标拆分展示(间距在 cache 前,无命中时整段不渲染、不留空), - * 悬浮另给说明。run 卡片(RunMeta) 与思考步骤(AgentStep) 共用;分隔符按上下文传入(chip 内 ` / `、步骤行空格)。 + * Inline token-usage display: ↑input(green) [⛁cache hit] / ↓output(red). Input and output **each get their own hover tooltip**; + * the cache hit (cache_read) is part of the input, shown split out with a bar icon (spacing before cache, the whole segment not rendered and leaving no gap when there's no hit), + * with its own tooltip on hover. Shared by the run card (RunMeta) and the thinking step (AgentStep); the separator is passed by context (` / ` inside a chip, a space on step rows). */ export function TokenStat({ prompt, @@ -169,7 +169,7 @@ export function TokenStat({ ); } -/** /ask 提问行:问号图标 + markdown 渲染的提问内容(Agent 自拟的追问常含内联代码 / 列表)。 */ +/** /ask question row: a question-mark icon + markdown-rendered question content (the Agent's self-composed follow-up asks often contain inline code / lists). */ export function AskQuestion({ text }: { text: string }) { const { t } = useTranslation(); return ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/constants.ts b/apps/desktop/src/renderer/src/components/features/chat/constants.ts index 016dab58..c440e4b0 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/constants.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/constants.ts @@ -1,9 +1,9 @@ export const CHAT_MIN_WIDTH = 280; export const CHAT_MAX_WIDTH = 720; -/** 历史 run 的分页大小:进入 PR 默认展示最新 N 条,向上滚动到顶端再追加一批 */ +/** Page size for history runs: on entering a PR, show the latest N by default, then append another batch when scrolling to the top */ export const RUNS_PAGE_SIZE = 10; -/** Agent 建议 verdict → i18n key(chatPane.agent.*)。 */ +/** Agent suggestion verdict → i18n key (chatPane.agent.*). */ export const VERDICT_LABEL_KEY: Record<string, string> = { approve: 'chatPane.agent.verdictApprove', needs_work: 'chatPane.agent.verdictNeedsWork', diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatActions.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatActions.ts index 42bac0ea..b5999b14 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatActions.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatActions.ts @@ -12,22 +12,31 @@ import type { ReviewRunTool, StoredPullRequest, } from '@meebox/shared'; +import { PRODUCT_HOME_URL } from '@meebox/shared'; import { invoke } from '../../../../api'; import { useChatRunStore } from '../../../../stores/chat-run-store'; -import { htmlInlineToMarkdown, stripFindingMarker } from '../utils/findings'; +import { + htmlInlineToMarkdown, + renderCodeSuggestionDraft, + stripFindingMarker, +} from '../utils/findings'; interface UseChatActionsParams { pr: StoredPullRequest | null; prAgent: PrAgentStatus; llmConfigured: boolean; prLocalId: string | undefined; - /** 本 PR 运行中的活动 run(去重 / 停止全部用)。 */ + /** Active runs in progress for this PR (used for dedup / stop-all). */ myActiveRuns: ReadonlyArray<PragentRunInfo>; - /** 本 PR 排队中的任务(去重用)。 */ + /** Queued tasks for this PR (used for dedup). */ myWaiting: ReadonlyArray<PragentRunInfo>; - /** 本 PR 当前草稿池快照;finding ↔ draft 反查。 */ + /** Current draft pool snapshot for this PR; finding ↔ draft reverse lookup. */ drafts: ReadonlyArray<ReviewDraft> | null | undefined; - // 会话态写入口(由 useChatSession 提供) + /** User-defined code-suggestion draft layout (settings → agent.strategy.code_suggestion_layout); empty falls back to the built-in prefix. */ + codeSuggestionLayout: string; + /** Current active model name (for the `<MODEL>` layout placeholder); null when none configured. */ + currentLlmModel: string | null; + // Session-state write entry points (provided by useChatSession) setError: Dispatch<SetStateAction<string | null>>; setRuns: Dispatch<SetStateAction<ReviewRun[]>>; setHasMoreOlder: Dispatch<SetStateAction<boolean>>; @@ -36,7 +45,7 @@ interface UseChatActionsParams { setTodo: Dispatch<SetStateAction<AgentTodoItem[]>>; currentPrIdRef: MutableRefObject<string | undefined>; reloadConversation: (localId: string) => Promise<void>; - // 跨组件跳转回调(由 MainPane / App 注入) + // Cross-component jump callbacks (injected by MainPane / App) onJumpToDraftEditor?: (target: { runId: string; findingId: string; @@ -46,9 +55,9 @@ interface UseChatActionsParams { } export interface ChatActions { - /** Agent 运行态(自动评审微流程 / 自由规划对话):记录各 PR 的起跑时刻(localId → since)。 */ + /** Agent running state (auto-review micro-flow / free-planning conversation): records the start time of each PR (localId → since). */ runningPrs: Map<string, number>; - /** 仅「跑在当前 PR」才在本会话显示运行态 / 思考中。 */ + /** Only when "running on the current PR" does this session show running / thinking state. */ agentRunningHere: boolean; handleRun: ( tool: ReviewRunTool, @@ -70,9 +79,10 @@ export interface ChatActions { } /** - * ChatPane 的业务动作集合:触发 pr-agent 工具 / 自动评审 / 对话即委派、取消与停止、清空历史、 - * 以及 finding → 草稿的懒创建 / 拒绝 / 导航。并发模型——不同 PR 的 agent 任务可并发 / 排队,仅禁止 - * 对**同一 PR**重复发起;运行态按发起 PR 归属,不串到其它 PR 会话。 + * ChatPane's set of business actions: trigger pr-agent tools / auto-review / conversation-as-delegation, + * cancel and stop, clear history, plus lazy creation / rejection / navigation of finding → draft. Concurrency + * model — agent tasks on different PRs can run concurrently / queue; only repeated triggers on the **same PR** + * are forbidden; running state belongs to the initiating PR and does not bleed into other PR sessions. */ export function useChatActions(params: UseChatActionsParams): ChatActions { const { @@ -83,6 +93,8 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { myActiveRuns, myWaiting, drafts, + codeSuggestionLayout, + currentLlmModel, setError, setRuns, setHasMoreOlder, @@ -96,21 +108,21 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { } = params; const { t } = useTranslation(); - // 记录**各 PR** 的起跑时刻(localId → since)。不同 PR 的 agent 任务可并发 / 排队,仅禁止对同一 PR 重复发起。 - // 本地态只承载**用户手动发起**的乐观即时反馈(不等 main 广播回环);AutoPilot 后台评审不经此处。 + // Records the start time of **each PR** (localId → since). Agent tasks on different PRs can run concurrently / queue; only repeated triggers on the same PR are forbidden. + // Local state only carries the optimistic immediate feedback of **user manual triggers** (does not wait for the main broadcast round-trip); AutoPilot background review does not go through here. const [runningPrs, setRunningPrs] = useState<Map<string, number>>(() => new Map()); - // 编排 Agent 运行中的 PR 集合(含纯思考阶段)——来自 store 的 `agent:runningChanged`,手动与 AutoPilot - // 一并计入,是「是否在跑」的权威来源。 + // Set of PRs with the orchestrating Agent running (including pure thinking phase) — from the store's `agent:runningChanged`, counting both manual and AutoPilot; + // it is the authoritative source for "is it running". const { agentPrs } = useChatRunStore(); - // 仅「跑在当前 PR」才在本会话显示运行态 / 思考中;其它 PR 在跑不影响本会话发起(可并发 / 排队)。 - // 取「本地乐观态 ∪ store 权威态」:手动发起即时点亮(本地),AutoPilot 后台评审经 store 点亮—— - // 否则后台评审的纯思考阶段(工具 run 跑完后的 judge / 总结)因 agentRunningHere=false 不显示「思考中」。 + // Only when "running on the current PR" does this session show running / thinking state; other PRs running does not affect triggering in this session (can run concurrently / queue). + // Takes "local optimistic state ∪ store authoritative state": manual triggers light up immediately (local), AutoPilot background review lights up via the store — + // otherwise the pure thinking phase of background review (judge / summary after the tool run finishes) would not show "thinking" because agentRunningHere=false. const agentRunningHere = prLocalId !== undefined && (runningPrs.has(prLocalId) || agentPrs.includes(prLocalId)); - // 触发 /describe / /review / /ask。队列模型下 active 非空也允许提交,新 run 进 - // 队列,main 端先后串行执行。失败抛 banner;成功不需要手动 setRuns,session effect - // 会在 active 切换时自动 refresh + // Triggers /describe / /review / /ask. Under the queue model, submitting is allowed even when active is non-empty; the new run enters + // the queue and main executes them serially in order. Failures throw a banner; success needs no manual setRuns, the session effect + // auto-refreshes when active changes const handleRun = async ( tool: ReviewRunTool, question?: string, @@ -119,8 +131,8 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { scope?: ReviewRun['scope'], ): Promise<void> => { if (!pr || !prAgent.available || !llmConfigured) return; - // 去重(即时反馈):同一 PR 同一工具已在执行 / 排队 → 阻止重复触发(main 端亦有 - // 权威校验兜底)。/ask 每次问题不同、单 commit 范围(scope)是定向动作,均不限制(与后端 dedup 同口径)。 + // Dedup (immediate feedback): if the same tool for the same PR is already executing / queued → block the repeated trigger (main also has + // authoritative validation as a fallback). /ask has a different question each time, and single-commit scope is a targeted action, so neither is restricted (same criteria as backend dedup). if ( tool !== 'ask' && !scope && @@ -144,10 +156,10 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { } }; - // 一键自动评审:触发 main 的 agent:run(评审微流程)。describe/review/ask 子 run 经既有运行 - // 队列展示在历史里;收尾评审作为一条 assistant 消息落入多轮对话,完成后重载对话呈现。 + // One-click auto-review: triggers main's agent:run (review micro-flow). The describe/review/ask sub-runs are shown in history via the existing run + // queue; the concluding review lands as an assistant message in the multi-turn conversation, and the conversation is reloaded for display after completion. const handleAgentReview = async (): Promise<void> => { - // 仅禁止对同一 PR 重复发起;其它 PR 在跑不阻塞(并发 / 排队)。 + // Only repeated triggers on the same PR are forbidden; other PRs running does not block (concurrent / queued). if (!pr || !prAgent.available || !llmConfigured || runningPrs.has(pr.localId)) return; const startedId = pr.localId; setError(null); @@ -172,15 +184,15 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { } }; - // 自然语言「对话即委派」:交给自由规划 Agent(agent:ask)。用户输入即时 optimistic 回显, - // 收尾后以落盘对话(含用户 + 助手消息)整体对齐。 + // Natural-language "conversation-as-delegation": hand off to the free-planning Agent (agent:ask). User input is echoed optimistically at once, + // and after completion aligned wholesale against the persisted conversation (including user + assistant messages). const handleAgentAsk = async (question: string, referencedContext?: string): Promise<void> => { if (!pr || !prAgent.available || !llmConfigured) return; const startedId = pr.localId; - // 中途输入(已有 Agent 在跑)走 enqueueMessage,后端不持久化引用上下文 → 该路径不带 ref, - // 避免重载对齐时引用块闪烁消失;仅新一轮提问的气泡附带引用上下文。 + // Mid-flight input (an Agent already running) goes through enqueueMessage; the backend does not persist referenced context → this path carries no ref, + // to avoid the reference block flickering away on reload alignment; only the bubble of a new-round ask carries referenced context. const enqueueing = runningPrs.has(startedId); - // 即时 optimistic 回显用户气泡(运行中 / 新轮都先冒泡,不再静默丢弃中途输入)。 + // Echo the user bubble optimistically at once (both running / new-round bubble first, no longer silently discarding mid-flight input). setMessages((prev) => [ ...prev, { @@ -190,7 +202,7 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { at: new Date().toISOString(), }, ]); - // 本 PR 已有 Agent 在跑:不另起一轮,入队到下一主 Agent 周期并入、据最新指令重排(中途输入转向)。 + // An Agent is already running for this PR: do not start another round; enqueue to be merged into the next main Agent cycle and reordered per the latest instruction (mid-flight input redirect). if (enqueueing) { try { await invoke('agent:enqueueMessage', { localId: startedId, message: question }); @@ -227,9 +239,9 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { } }; - // 清空当前 PR 的执行历史(仅该 PR):删远端记录 + 清本地列表,并一并清掉 Agent 收尾结果 / - // 步骤 / 错误横幅(含「已停止 / 失败」提示),避免清空后仍残留陈旧反馈。进行中的 run 不受影响 - // (在 chatRunStore,跑完会重新落盘)。 + // Clear this PR's execution history (this PR only): delete remote records + clear the local list, and also clear the Agent conclusion result / + // steps / error banner (including "stopped / failed" hints), to avoid stale feedback lingering after clearing. In-progress runs are unaffected + // (they live in chatRunStore and are re-persisted on completion). const handleClearRuns = async (): Promise<void> => { if (!prLocalId) return; try { @@ -245,7 +257,7 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { } }; - // 取消 / 重试 在 store 模型里就是简单两步:cancel 走 IPC,retry 调 handleRun + // Cancel / retry are just two simple steps in the store model: cancel goes through IPC, retry calls handleRun const handleCancel = async (runId: string): Promise<void> => { try { await invoke('pragent:cancel', { runId }); @@ -253,8 +265,8 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { setError(e instanceof Error ? e.message : String(e)); } }; - // 删除单条已结束的 run 记录(成功 / 失败 / 取消):删远端记录后乐观从本地列表移除该条。 - // 仅删该 run,不动 Agent 会话 / 台账 / 徽标(与「清空」区分)。 + // Delete a single finished run record (success / failed / cancelled): after deleting the remote record, optimistically remove it from the local list. + // Deletes only that run, not touching the Agent session / ledger / badge (distinct from "clear"). const handleDeleteRun = async (runId: string): Promise<void> => { if (!prLocalId) return; try { @@ -264,36 +276,44 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { setError(e instanceof Error ? e.message : String(e)); } }; - // 停止本 PR 会话内进行中的全部任务:逐条取消所有活动 run(Agent 并行多选时可能 >1), - // 并中止 Agent 编排(abort,阻止其在子任务取消后继续后续步骤)。 + // Stop all in-progress tasks in this PR session: cancel every active run one by one (may be >1 with Agent parallel multi-select), + // and abort the Agent orchestration (abort, preventing it from continuing subsequent steps after sub-tasks are cancelled). const handleStopAll = (): void => { for (const r of myActiveRuns) void handleCancel(r.runId); if (agentRunningHere && prLocalId) void invoke('agent:stop', { localId: prLocalId }); }; const handleRetry = (run: ReviewRun): void => { - // 重试沿用原 run 的单 commit 范围(若有),保证复跑仍限定同一 commit。 + // Retry reuses the original run's single-commit scope (if any), ensuring the rerun stays limited to the same commit. void handleRun(run.tool, run.question, undefined, undefined, run.scope); }; /** - * 把 AI finding body 转成草稿初始 body:先 stripFindingMarker 去掉 [file:...] - * 末尾 marker,再把 pr-agent GFM 里的内联 HTML 标签归一成 markdown(草稿编辑器是 - * 纯文本,裸 `<code>`/`<br>` 会露馅),最后加 `[AI 建议]` 前缀 — 让远端 reviewer - * 看到时知道这条评论来自 pr-agent + * Turns an AI finding into the draft's initial body: first stripFindingMarker removes the trailing [file:...] marker, + * then normalizes inline HTML tags in pr-agent's GFM into markdown (the draft editor is plain text, so bare + * `<code>`/`<br>` would leak through), and finally applies the user's deterministic code-suggestion template + * (settings → agent.strategy.code_suggestion_layout). When the template is empty, falls back to + * `DEFAULT_CODE_SUGGESTION_LAYOUT` (an AI-suggestion badge + model name) — so the remote reviewer knows this came from pr-agent. */ - const buildDraftBodyFromFinding = (body: string): string => - `${t('chatPane.aiSuggestionPrefix')} ${htmlInlineToMarkdown(stripFindingMarker(body))}`; + const buildDraftBodyFromFinding = (finding: Finding): string => + renderCodeSuggestionDraft({ + template: codeSuggestionLayout, + body: htmlInlineToMarkdown(stripFindingMarker(finding.body)), + title: t('chatPane.aiSuggestionLabel'), + homeUrl: PRODUCT_HOME_URL, + prUrl: pr?.url ?? '', + modelName: currentLlmModel ?? '', + }); /** - * ChatPane finding card 上点"编辑"按钮的处理: - * - 已有关联草稿 → 直接 onJumpToDraftEditor,DiffView 打开它 - * - 没有关联草稿 → 懒创建一条 pending + onJumpToDraftEditor - * - 关联草稿是 rejected → update 回 pending (撤销拒绝) + 跳转 + * Handler for clicking the "Edit" button on a ChatPane finding card: + * - Already has an associated draft → directly onJumpToDraftEditor, DiffView opens it + * - No associated draft → lazily create a pending one + onJumpToDraftEditor + * - Associated draft is rejected → update back to pending (undo rejection) + jump */ const handleJumpToDraft = async (finding: Finding, run: ReviewRun): Promise<void> => { if (!pr) return; if (!finding.anchor || typeof finding.anchor.startLine !== 'number') { - return; // 没 anchor 行号 → 没法变 inline,按钮本不该出现,兜底 + return; // No anchor line number → cannot become inline; the button should not appear, fallback } const startLine = finding.anchor.startLine; const endLine = finding.anchor.endLine ?? startLine; @@ -303,19 +323,19 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { ); try { if (!existing) { - // 懒创建:从 finding 拷贝 body 作初始内容;side 默认 'new' (head 侧 inline 评论惯例) + // Lazy create: copy the body from the finding as initial content; side defaults to 'new' (head-side inline comment convention) await invoke('drafts:create', { localId: pr.localId, draft: { anchor: { path: finding.anchor.path, startLine, endLine, side: 'new' }, - body: buildDraftBodyFromFinding(finding.body), + body: buildDraftBodyFromFinding(finding), origin: 'finding', source: { runId: run.id, findingId: finding.id }, status: 'pending', }, }); } else if (existing.status === 'rejected') { - // 撤销 reject 决断 → 回到 pending,让用户重新编辑 + // Undo the reject decision → back to pending, letting the user edit again await invoke('drafts:update', { localId: pr.localId, draftId: existing.id, @@ -333,7 +353,7 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { }); }; - // 点击 finding 锚点:仅导航到 Diff 对应行(不创建/打开草稿),便于快速核对上下文 + // Click a finding anchor: only navigate to the corresponding Diff line (does not create/open a draft), for quickly checking context const handleNavigateToFinding = (finding: Finding): void => { if (!finding.anchor || typeof finding.anchor.startLine !== 'number') return; const startLine = finding.anchor.startLine; @@ -365,7 +385,7 @@ export function useChatActions(params: UseChatActionsParams): ChatActions { localId: pr.localId, draft: { anchor: { path: finding.anchor.path, startLine, endLine, side: 'new' }, - body: buildDraftBodyFromFinding(finding.body), + body: buildDraftBodyFromFinding(finding), origin: 'finding', source: { runId: run.id, findingId: finding.id }, status: 'rejected', diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatInput.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatInput.ts index 6a431aeb..b8e90b7a 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatInput.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatInput.ts @@ -20,15 +20,15 @@ export interface UseChatInputParams { onAgentAsk: (question: string) => void; onCancel?: () => void; onSetReviewStatus?: (status: LocalPrStatus) => void; - /** PR 远端可直接合并(mergeStatus.canMerge):false 时 /merge 不出现在补全/命令菜单,误输入也拒绝。 */ + /** PR is directly mergeable on the remote (mergeStatus.canMerge): when false, /merge does not appear in autocomplete/command menu, and mistyped input is also rejected. */ canMerge?: boolean; - /** /merge 触发:交由 ChatPane 弹二次确认后再实际合并。 */ + /** /merge trigger: hand off to ChatPane to pop a second confirmation before actually merging. */ onMerge?: () => void; } /** - * 输入栏状态机:输入 / `/` 命令解析与提交 / 自动补全浮层 / 历史回放(shell 式 Up/Down)/ 停止请求。 - * 命令解析纯逻辑见 ../utils/parse-command;历史栈见 ../utils/chat-history。ChatInputBar 只消费返回值渲染。 + * Input bar state machine: input / `/` command parsing and submission / autocomplete overlay / history replay (shell-style Up/Down) / stop request. + * Pure command-parsing logic in ../utils/parse-command; history stack in ../utils/chat-history. ChatInputBar only consumes the return value to render. */ export function useChatInput({ pr, @@ -46,54 +46,54 @@ export function useChatInput({ const { t } = useTranslation(); const [input, setInput] = useState(''); const [parseError, setParseError] = useState<string | null>(null); - // PR 切换时清掉异常提示 + 输入框残留 (避免跨 PR 显示陈旧的错误"未知命令" 等) + // On PR switch, clear the error hint + leftover input (to avoid showing a stale error "unknown command" etc. across PRs) useEffect(() => { setParseError(null); setInput(''); }, [pr?.localId]); const [cmdMenuOpen, setCmdMenuOpen] = useState(false); - // 自动补全菜单选中项索引 (textarea 输入 / 时显示的浮层) + // Selected-item index of the autocomplete menu (the overlay shown when the textarea contains /) const [autocompleteIdx, setAutocompleteIdx] = useState(0); - // 已经为某个特定输入值关闭过菜单 (Esc / 选中后插入)。input 一变就失效 - // → 用户继续打字时菜单会自然重新出现,但选中 / Esc 后不会立刻重弹 + // Menu was already dismissed for a specific input value (Esc / inserted after selecting). Invalidated as soon as input changes + // → the menu naturally reappears as the user keeps typing, but does not immediately re-pop right after selecting / Esc const [dismissedFor, setDismissedFor] = useState<string | null>(null); - // 历史回放:从最新到最老的栈;historyIdx 表示当前正在浏览的位置 (-1 = 不在浏览态) + // History replay: stack from newest to oldest; historyIdx indicates the position currently being browsed (-1 = not in browsing state) const [history, setHistory] = useState<string[]>(() => loadChatHistory()); const [historyIdx, setHistoryIdx] = useState(-1); - // 进入历史浏览前用户正在编辑的内容;按 Down 回到底端时还原回去,模仿 shell 行为 + // The content the user was editing before entering history browsing; restored when pressing Down back to the bottom, mimicking shell behavior const draftBeforeHistoryRef = useRef<string>(''); const textareaRef = useRef<HTMLTextAreaElement | null>(null); const cmdMenuRef = useRef<HTMLDivElement | null>(null); - // 队列模型:仅 !pr / pr-agent 未就绪 时禁用 input。activeRun / busyOnOtherPr - // 不再阻塞新提交 (会排队 by main)。running 决定是否渲染 stop 按钮:除活动工具 run 外, - // Agent 自身执行阶段(思考 / 编排,无工具 run 占用)也算「运行中」,以便随时取消。 + // Queue model: disable input only when !pr / pr-agent not ready. activeRun / busyOnOtherPr + // no longer block new submissions (queued by main). running decides whether to render the stop button: besides an active tool run, + // the Agent's own execution phase (thinking / orchestration, with no tool run occupied) also counts as "running", so it can be cancelled anytime. const running = runningTool !== null || agentRunningHere; - // LLM 未配置时一并禁用:即便 pr-agent 运行时就绪,没有模型也无法发起调用 + // Also disable when the LLM is not configured: even if the pr-agent runtime is ready, without a model no call can be made const disabled = !pr || !prAgent.available || !llmConfigured; - // stop 按钮点过后等 main 回 queueChanged 才会改变状态;中间这段时间二次点击 - // 应失效,避免反复 spam abort + // After the stop button is clicked, the state only changes once main returns queueChanged; a second click during this interval + // should be a no-op, to avoid repeatedly spamming abort const [stopRequested, setStopRequested] = useState(false); - // running → false 时 (run 结束了) 重置 stopRequested,下次起 run 又能取消 + // When running → false (the run has finished), reset stopRequested so the next run can be cancelled again useEffect(() => { if (!running) setStopRequested(false); }, [running]); const trimmed = input.trim(); - // 可见命令集合:PR 不可直接合并时隐去 /merge(不在补全 / 命令菜单提示不可用的动作)。 + // Visible command set: hide /merge when the PR is not directly mergeable (don't hint an unavailable action in autocomplete / command menu). const visibleCommands = canMerge ? COMMANDS : COMMANDS.filter((c) => c.kind !== 'pr-action'); - // `/` 开头 + 命令名还没敲完整 (没空格) → 显示候选;已为当前 input dismiss 过则隐藏 + // Starts with `/` + command name not fully typed yet (no space) → show candidates; hidden if already dismissed for the current input const showAutocomplete = !disabled && dismissedFor !== input && input.startsWith('/') && !input.includes(' '); const filtered = showAutocomplete ? visibleCommands.filter((c) => c.label.startsWith(input.split(' ')[0] ?? '')) : []; - // 输入变化时重置选中项到首条 (候选集变了) + // Reset the selected item to the first when input changes (the candidate set changed) useEffect(() => { setAutocompleteIdx(0); }, [input]); - // `/` 命令按钮触发的弹出菜单:点击外部 / Esc / 选中命令时关闭 + // Popup menu triggered by the `/` command button: closes on outside click / Esc / selecting a command useEffect(() => { if (!cmdMenuOpen) return; const onDown = (e: MouseEvent): void => { @@ -122,8 +122,8 @@ export function useChatInput({ setInput(cmd.insertAs); setParseError(null); setCmdMenuOpen(false); - // 选中后立即关掉补全菜单 (insertAs 可能 "/describe" 没空格,否则会一直撑着)。 - // dismissedFor 绑当前 input 值,用户继续打字 input 变了菜单会重新打开 + // Close the autocomplete menu immediately after selecting (insertAs may be "/describe" with no space, otherwise it would keep it propped open). + // dismissedFor is bound to the current input value; as the user keeps typing and input changes, the menu reopens setDismissedFor(cmd.insertAs); const el = textareaRef.current; if (el) { @@ -134,7 +134,7 @@ export function useChatInput({ } }; - // 提交成功路径共用:写历史栈 + 退出浏览态 + 清空输入 + // Shared by the successful-submit path: write the history stack + exit browsing state + clear input const pushHistoryAndReset = (): void => { setHistory(pushChatHistory(input)); setHistoryIdx(-1); @@ -162,19 +162,19 @@ export function useChatInput({ setParseError(t('chatPane.askNeedsQuestion')); return; case 'reviewAction': - if (!onSetReviewStatus) return; // 没装回调直接忽略 (保护性) + if (!onSetReviewStatus) return; // No callback wired → just ignore (protective) pushHistoryAndReset(); onSetReviewStatus(parsed.status); return; case 'mergeAction': - if (!onMerge) return; // 没装回调直接忽略 (保护性) - // canMerge 门控:不可直接合并时拒绝并提示(输入框已不补全 /merge,此处兜底手输)。 + if (!onMerge) return; // No callback wired → just ignore (protective) + // canMerge gate: reject and hint when not directly mergeable (the input no longer autocompletes /merge; this is the fallback for manual typing). if (!canMerge) { setParseError(t('chatPane.notMergeable')); return; } pushHistoryAndReset(); - onMerge(); // 交由 ChatPane 弹确认后实际合并 + onMerge(); // Hand off to ChatPane to pop confirmation before actually merging return; case 'run': pushHistoryAndReset(); @@ -187,11 +187,11 @@ export function useChatInput({ } }; - // 历史回放工具:根据 idx 设 textarea 内容;idx = -1 表示退出浏览态,恢复 draft + // History replay helper: set textarea content by idx; idx = -1 means exit browsing state, restore draft const applyHistoryIdx = (nextIdx: number): void => { setHistoryIdx(nextIdx); setInput(nextIdx < 0 ? draftBeforeHistoryRef.current : (history[nextIdx] ?? '')); - // 光标移到末尾,下一次 Up/Down 行为可预期 + // Move the cursor to the end so the next Up/Down behavior is predictable const el = textareaRef.current; if (el) { requestAnimationFrame(() => { @@ -202,8 +202,8 @@ export function useChatInput({ } }; - // 判断是否应让 Up/Down 触发历史回放:textarea 光标必须在首行 / 末行边缘, - // 否则让 Up/Down 走原生光标移动 (多行编辑时还在行内导航不能被劫持) + // Decide whether Up/Down should trigger history replay: the textarea cursor must be at the first-line / last-line edge, + // otherwise let Up/Down do native cursor movement (in multi-line editing, in-line navigation must not be hijacked) const atFirstLine = (): boolean => { const el = textareaRef.current; if (!el) return false; @@ -216,10 +216,10 @@ export function useChatInput({ }; const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { - // 输入法 composing 中:所有快捷键都不拦截,交给 IME 处理 + // During IME composing: intercept no shortcuts, hand off to the IME if (e.nativeEvent.isComposing) return; - // 自动补全菜单打开时:拦截 Up/Down/Enter/Tab/Esc 用于菜单导航,避免落到 textarea + // When the autocomplete menu is open: intercept Up/Down/Enter/Tab/Esc for menu navigation, to avoid falling through to the textarea if (showAutocomplete && filtered.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); @@ -244,11 +244,11 @@ export function useChatInput({ } } - // 历史回放:菜单未打开时,Up/Down 在边缘行 → 翻历史。中间行让原生光标移动接管 + // History replay: when the menu is closed, Up/Down on an edge line → page through history. Middle lines let native cursor movement take over if (e.key === 'ArrowUp' && history.length > 0 && atFirstLine()) { e.preventDefault(); if (historyIdx < 0) { - // 首次进浏览态:把当前编辑内容存为 draft,方便 Down 回到底端时复原 + // First entering browsing state: save the current editing content as draft, so Down can restore it back at the bottom draftBeforeHistoryRef.current = input; } applyHistoryIdx(Math.min(historyIdx + 1, history.length - 1)); diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatSession.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatSession.ts index 637f652b..da114362 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatSession.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatSession.ts @@ -18,53 +18,53 @@ export interface ChatSession { setAgentSteps: React.Dispatch<React.SetStateAction<AgentStep[]>>; messages: AgentMessage[]; setMessages: React.Dispatch<React.SetStateAction<AgentMessage[]>>; - /** 规划 Agent 的计划(todo):随 agent:planUpdated 实时刷新,切 PR 经 getSession 水合。 */ + /** The planning Agent's plan (todo): refreshed in real time with agent:planUpdated, hydrated via getSession on PR switch. */ todo: AgentTodoItem[]; setTodo: React.Dispatch<React.SetStateAction<AgentTodoItem[]>>; bodyRef: MutableRefObject<HTMLDivElement | null>; - /** 当前展示中的 PR id(每渲染同步):异步任务 resolve 时据此判断是否仍停在发起 PR。 */ + /** The PR id currently displayed (synced every render): used when an async task resolves to decide whether we're still on the initiating PR. */ currentPrIdRef: MutableRefObject<string | undefined>; - /** 从 main 重载某 PR 的多轮对话(落盘版为准);仅当仍停在该 PR 才落到当前视图。 */ + /** Reload a PR's multi-turn conversation from main (persisted version is authoritative); only applied to the current view if still on that PR. */ reloadConversation: (localId: string) => Promise<void>; } /** - * ChatPane 的会话态与生命周期:切 PR 时重载 run 历史 / 规则 / 多轮对话 / 过程步骤, - * 订阅流式步骤与对话变更,跑完的 run 逐条插入,向上滚动游标分页,新内容自动滚到底。 + * ChatPane's session state and lifecycle: on PR switch, reload run history / rules / multi-turn conversation / process steps, + * subscribe to streaming steps and conversation changes, insert finished runs one by one, cursor-paginate on scroll up, auto-scroll to bottom on new content. * - * 入参 `myActiveIds` 是本 PR 运行中 run 的 runId 列表(来源于全局 store),用于: - * 检测「某条跑完了」→ 单独 fetch 插入;以及新 run / 活动集变化时滚到底。 + * The `myActiveIds` param is the list of runIds of runs in progress for this PR (sourced from the global store), used for: + * detecting "one finished" → fetch and insert it individually; and scrolling to bottom when a new run / the active set changes. */ export function useChatSession( prLocalId: string | undefined, myActiveIds: string[], ): ChatSession { - // runs 按 startedAt 升序保存 (chat 习惯:旧在上 / 新在下)。分页:进入 PR 默认拉 - // 最新 RUNS_PAGE_SIZE 条,向上滚到顶端用 runs[0].id 当游标向 main 要更早一批 + // runs kept in ascending startedAt order (chat convention: old on top / new at bottom). Pagination: entering a PR pulls + // the latest RUNS_PAGE_SIZE by default; scrolling up to the top uses runs[0].id as the cursor to request an earlier batch from main const [runs, setRuns] = useState<ReviewRun[]>([]); const [hasMoreOlder, setHasMoreOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); - // 切 PR 时初次拉取(runs / 规则 / 会话 / transcript)在飞标志:期间盖延迟 loading, - // 避免「清空 → 空白 → 内容 pop-in」的抖动。延迟显示让快路径(缓存命中)零闪烁。 + // In-flight flag for the initial fetch on PR switch (runs / rules / session / transcript): during it, cover with delayed loading + // to avoid the "clear → blank → content pop-in" jitter. Delayed display gives the fast path (cache hit) zero flicker. const [loadingSession, setLoadingSession] = useState(false); const [error, setError] = useState<string | null>(null); - // 当前 PR 命中的全部规则 (针对 /review 工具;缺省 tools=[review] 是规则最常生效的场景) + // All rules matched for the current PR (for the /review tool; default tools=[review] is the scenario where rules most often apply) const [matchedRules, setMatchedRules] = useState<MatchedRules>([]); const [agentSteps, setAgentSteps] = useState<AgentStep[]>([]); - // 多轮对话消息(用户输入 + Agent 回答),跨回合保留、由 main 落盘 conversation.json, - // 切回该 PR 恢复。用户消息含临时 optimistic 项(提交即回显),收尾后整体以落盘版重载对齐。 + // Multi-turn conversation messages (user input + Agent replies), kept across turns, persisted by main to conversation.json, + // restored when switching back to that PR. User messages include temporary optimistic items (echoed on submit), aligned wholesale against the persisted version on reload after completion. const [messages, setMessages] = useState<AgentMessage[]>([]); - // 规划 Agent 的计划(todo):随 agent:planUpdated 实时刷新;切 PR 经 agent:getSession 水合。 + // The planning Agent's plan (todo): refreshed in real time with agent:planUpdated; hydrated via agent:getSession on PR switch. const [todo, setTodo] = useState<AgentTodoItem[]>([]); const bodyRef = useRef<HTMLDivElement | null>(null); - // 当前展示中的 PR id(每渲染同步):异步 Agent 任务 resolve 时据此判断是否仍停在发起 PR, - // 避免把收尾结果 / 错误串台到切换后打开的别的 PR 会话。 + // The PR id currently displayed (synced every render): used when an async Agent task resolves to decide whether we're still on the initiating PR, + // to avoid crossing the conclusion result / error into a different PR session opened after switching. const currentPrIdRef = useRef<string | undefined>(undefined); currentPrIdRef.current = prLocalId; - // PR 切换:重置面板状态 + 拉该 PR 的 run 历史 (含切走前还在跑、现在已落盘的 run)。 - // 依赖用 pr?.localId 而不是 pr 对象引用:App 在 poll tick / window focus 时会 - // reloadPrs → 新 prs 数组 → selected 是新对象引用;localId 是稳定字符串,同 PR 刷新不触发。 + // PR switch: reset panel state + pull this PR's run history (including runs still running before the switch, now persisted). + // Depend on pr?.localId rather than the pr object reference: App does reloadPrs on poll tick / window focus + // → new prs array → selected is a new object reference; localId is a stable string, so refreshing the same PR does not trigger. useEffect(() => { setRuns([]); setHasMoreOlder(false); @@ -80,9 +80,9 @@ export function useChatSession( setLoadingSession(true); void (async () => { try { - // listRuns 默认返回 newest-first;这里只拉最新一页 (RUNS_PAGE_SIZE)。 - // 同时拉已落盘的多轮对话 + 过程步骤(transcript):把会话恢复到其 PR,跨切换 / 重启不丢失, - // 过程化跟踪的思考步骤也随之恢复(步骤随产生增量落盘)。 + // listRuns returns newest-first by default; here we only pull the latest page (RUNS_PAGE_SIZE). + // Also pull the persisted multi-turn conversation + process steps (transcript): restore the session to its PR, not lost across switch / restart, + // and the process-tracked thinking steps are restored along with it (steps are persisted incrementally as produced). const [list, rules, conversation, transcript, session] = await Promise.all([ invoke('pragent:listRuns', { localId: prLocalId, limit: RUNS_PAGE_SIZE }), invoke('rules:matchForPr', { localId: prLocalId, tool: 'review' }), @@ -91,7 +91,7 @@ export function useChatSession( invoke('agent:getSession', { localId: prLocalId }), ]); if (cancelled) return; - // 反转为升序 (chat 习惯),UI 直接读 runs 即可 + // Reverse to ascending order (chat convention); the UI can read runs directly setRuns([...list].reverse()); setHasMoreOlder(list.length === RUNS_PAGE_SIZE); setMatchedRules(rules); @@ -109,28 +109,28 @@ export function useChatSession( }; }, [prLocalId]); - // Agent 步骤流式:订阅 main 的 agent:stepProgress,按当前 PR 过滤实时追加。 + // Agent step streaming: subscribe to main's agent:stepProgress, filter by current PR and append in real time. useEffect(() => { if (!prLocalId) return; return subscribe('agent:stepProgress', (ev) => { - // 流式步骤可能未带 at(编排器广播在落盘 stamp 之前)→ 到达即补一个时间戳, - // 供下方与 run 卡片按时间归并排序(自然时间顺序展示)。 + // Streaming steps may arrive without at (the orchestrator broadcasts before the persist stamp) → stamp one on arrival, + // so the section below can merge-sort by time with run cards (displayed in natural time order). if (ev.prLocalId === prLocalId) setAgentSteps((s) => [...s, { ...ev.step, at: ev.step.at ?? new Date().toISOString() }]); }); }, [prLocalId]); - // 从 main 重载某 PR 的多轮对话(落盘版为准);仅当仍停在该 PR 才落到当前视图,避免串台。 + // Reload a PR's multi-turn conversation from main (persisted version is authoritative); only applied to the current view if still on that PR, to avoid crossing. const reloadConversation = async (localId: string): Promise<void> => { try { const conversation = await invoke('agent:getConversation', { localId }); if (currentPrIdRef.current === localId) setMessages(conversation); } catch { - /* 忽略:下次 PR 切换 effect 会重载 */ + /* Ignore: the next PR-switch effect will reload */ } }; - // 后台评审(AutoPilot)收尾追加「评审总结」消息时,若正打开该 PR 则重载会话,让总结卡片即时出现。 + // When background review (AutoPilot) appends a "review summary" message on conclusion, reload the session if that PR is open, so the summary card appears immediately. useEffect(() => { if (!prLocalId) return; return subscribe('agent:conversationChanged', (ev) => { @@ -138,7 +138,7 @@ export function useChatSession( }); }, [prLocalId]); - // 计划(todo)实时刷新:规划 Agent 每轮给出 / 更新 plan 即广播,按当前 PR 过滤更新计划面板。 + // Plan (todo) real-time refresh: the planning Agent broadcasts each round when it gives / updates the plan; filter by current PR and update the plan panel. useEffect(() => { if (!prLocalId) return; return subscribe('agent:planUpdated', (ev) => { @@ -146,9 +146,9 @@ export function useChatSession( }); }, [prLocalId]); - // 本 PR 的运行中 run 集合发生「移除」→ 那条跑完了:单独 fetch 它 + 按 runId 升序 - // 插入 runs(不重拉整页,避免毁掉用户已向上加载的更早历史)。lines 缓存的回收已 - // 上移到 store 层(setQueue 全局处理),这里不再负责。多并发下逐条 diff 处理。 + // When this PR's set of in-progress runs sees a "removal" → that one finished: fetch it individually + insert into runs + // in ascending runId order (don't refetch the whole page, to avoid destroying the earlier history the user loaded upward). Reclaiming the lines cache has + // moved up to the store layer (handled globally by setQueue) and is no longer done here. Diff processed one by one under concurrency. const myActiveIdsKey = myActiveIds.join(','); const prevMyActiveRef = useRef<string[]>(myActiveIds); const prevPrRef = useRef<string | undefined>(prLocalId); @@ -157,7 +157,7 @@ export function useChatSession( prevPrRef.current = prLocalId; const prev = prevMyActiveRef.current; prevMyActiveRef.current = myActiveIds; - // PR 切换:prev 属于旧 PR,不能当本 PR 的「跑完」处理,仅同步 ref + // PR switch: prev belongs to the old PR, so it must not be treated as this PR's "finished"; only sync the ref if (prevPr !== prLocalId || !prLocalId) return; const current = new Set(myActiveIds); for (const runId of prev) { @@ -169,14 +169,14 @@ export function useChatSession( setRuns((prevRuns) => { const idx = prevRuns.findIndex((r) => r.id === finished.id); if (idx >= 0) { - // 已在列表(重复事件 / 重连)→ 就地更新 + // Already in the list (duplicate event / reconnect) → update in place const next = prevRuns.slice(); next[idx] = finished; return next; } - // 并发完成顺序 ≠ runId 顺序:按 runId 升序插入而非无条件 append, - // 维持 runs 始终有序(loadOlderRuns 以 runs[0].id 作游标拉更早历史, - // 依赖此不变量)。runId 字典序即时序,可直接字符串比较。 + // Concurrent completion order ≠ runId order: insert in ascending runId order rather than unconditional append, + // keeping runs always ordered (loadOlderRuns uses runs[0].id as the cursor to pull earlier history, + // relying on this invariant). runId lexical order is the time order, so a direct string comparison works. const insertAt = prevRuns.findIndex((r) => r.id > finished.id); if (insertAt < 0) return [...prevRuns, finished]; const next = prevRuns.slice(); @@ -192,9 +192,9 @@ export function useChatSession( // eslint-disable-next-line react-hooks/exhaustive-deps }, [myActiveIdsKey, prLocalId]); - // 向上滚到顶端 → 用 runs[0].id 当游标,向 main 要更早一批,prepend 到 runs。 - // 保留视觉滚动位置:插入新内容后把 scrollTop 推到 (newHeight - prevHeight) - // 抵消,用户看上去像"接着原来位置" + // Scroll up to the top → use runs[0].id as the cursor, request an earlier batch from main, prepend to runs. + // Preserve the visual scroll position: after inserting new content, push scrollTop by (newHeight - prevHeight) + // to offset, so it looks to the user like "continuing from the original position" const loadOlderRuns = async (): Promise<void> => { if (loadingOlder || !hasMoreOlder || !prLocalId || runs.length === 0) return; setLoadingOlder(true); @@ -207,10 +207,10 @@ export function useChatSession( limit: RUNS_PAGE_SIZE, beforeId: runs[0]!.id, }); - // older 是 newest-first,反转后整段塞到 runs 前面 + // older is newest-first; reverse and stuff the whole batch in front of runs setRuns((prev) => [...[...older].reverse(), ...prev]); setHasMoreOlder(older.length === RUNS_PAGE_SIZE); - // 下一帧补齐滚动位置 + // Restore the scroll position on the next frame requestAnimationFrame(() => { if (!bodyRef.current) return; bodyRef.current.scrollTop = prevTop + (bodyRef.current.scrollHeight - prevHeight); @@ -222,13 +222,13 @@ export function useChatSession( } }; - // 新 run 完成 / 运行中 run 集合变化时自动滚到底,让最新消息浮上来 + // Auto-scroll to bottom when a new run completes / the set of in-progress runs changes, so the latest message surfaces useEffect(() => { const el = bodyRef.current; if (el) el.scrollTop = el.scrollHeight; }, [runs.length, myActiveIdsKey]); - // 向上滚到顶端 → 触发 loadOlderRuns 拉更早一批 (cursor = runs[0].id) + // Scroll up to the top → trigger loadOlderRuns to pull an earlier batch (cursor = runs[0].id) useEffect(() => { const el = bodyRef.current; if (!el) return; @@ -238,7 +238,7 @@ export function useChatSession( }; el.addEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll); - // loadOlderRuns 是稳定的语义包装,依赖项放足够即可 + // loadOlderRuns is a stable semantic wrapper; listing enough deps is sufficient // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasMoreOlder, loadingOlder, prLocalId, runs.length]); diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts index 7c63da19..b3ae8a9e 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import type { PragentRunInfo } from '@meebox/ipc'; import type { AgentMessage, AgentStep, ReviewRun } from '@meebox/shared'; -/** 时间线一项:四类内容(run 卡片 / 运行中 run / 思考步骤 / 对话消息)按启动时间归并后的承载体。 */ +/** One timeline entry: the carrier for the four content kinds (run card / in-progress run / thinking step / conversation message) merged by start time. */ export interface TimelineEntry { key: string; sortTime: number; @@ -18,8 +18,8 @@ function ms(iso: string | null | undefined): number { } /** - * 用户直接发起的斜杠命令的回显文案:/ask 显示问题正文(更贴近对话;空问题回退 `/ask`), - * describe/review/improve 显示 `/工具名`。仅用于命令回显气泡,不做 i18n(就是用户键入的命令)。 + * Echo text for slash commands directly triggered by the user: /ask shows the question body (closer to conversation; empty question falls back to `/ask`), + * describe/review/improve show `/toolName`. Used only for the command echo bubble, no i18n (it's literally the command the user typed). */ function echoContent(tool: string, question: string | undefined): string { if (tool === 'ask') { @@ -30,16 +30,16 @@ function echoContent(tool: string, question: string | undefined): string { } /** - * 历史时间线 + 实时「思考中」计时锚点。 + * History timeline + real-time "thinking" timing anchor. * - * timeline:把已完成 run、正在执行的 run、Agent 思考步骤、对话消息统一按**启动时间**归并排序, - * 顺序固定——即便后启动的任务先完成,也排在先启动(仍在执行)的任务下方,不因完成先后跳序。 - * 类 Claude Code「先思考→定步骤→执行步骤」:思考步骤(plan/judge)是工具选择的前因,排在所选工具的 - * run 卡片之前;工具执行的进度 / 计时由 run 卡片承载,不重复。排队中(未启动)的任务不入此列,另置末尾。 + * timeline: merge-sorts completed runs, in-progress runs, Agent thinking steps, and conversation messages uniformly by **start time**, + * with a fixed order — even if a later-started task finishes first, it still sits below the earlier-started (still-executing) task, never jumping order by completion. + * Like Claude Code's "think first → decide steps → execute steps": thinking steps (plan/judge) are the antecedent of tool selection, placed before the chosen tool's + * run card; the tool execution's progress / timing is carried by the run card, not duplicated. Queued (not-yet-started) tasks are not in this list, placed separately at the end. * - * thinkingSince:「思考中」实时计时的锚点,取「最近一次活动结束」——{本 PR run 起点, 末个思考步 at, - * 末个完成 run 的结束时刻} 三者最晚者。锚到持久数据(runningPrs 跨 PR 切换不清、run 历史会重载)而非 - * 组件挂载,故切走再切回不清零;用 run 结束而非步骤记录时刻,避免把工具执行时间算进当前思考。 + * thinkingSince: the anchor for real-time "thinking" timing, taking "the most recent activity end" — the latest of {this PR's run start, last thinking step at, + * last completed run's finish time}. Anchored to persistent data (runningPrs is not cleared across PR switch, run history is reloaded) rather than + * component mount, so switching away and back does not reset it; uses run end rather than the step record time, to avoid counting tool execution time into the current thinking. */ export function useChatTimeline(params: { visibleRuns: ReviewRun[]; @@ -82,10 +82,10 @@ export function useChatTimeline(params: { sortTime: ms(m.at), message: m as AgentMessage | null, })); - // 命令回显气泡:仅对**用户直接发起**(origin==='user')的 run 补一条 user 消息,紧贴其卡片之上 - // (sortTime 取起跑时刻 -1ms)。编排 / AutoPilot 子 run(origin==='agent')不回显——其用户输入已由 - // 编排会话的用户消息承载。历史 run 无 origin(undefined)→ 不回显。active 与完成态同一 runId 互斥, - // key 统一为 `echo-<runId>`,运行中→完成的切换平滑不重挂。 + // Command echo bubble: only for runs **directly triggered by the user** (origin==='user'), add a user message right above its card + // (sortTime takes the start time -1ms). Orchestration / AutoPilot sub-runs (origin==='agent') are not echoed — their user input is already + // carried by the orchestration session's user message. Historical runs have no origin (undefined) → not echoed. active and completed states are mutually exclusive on the same runId, + // key uniformly `echo-<runId>`, so the running→completed switch is smooth without remounting. const echoOf = ( runId: string, tool: string, diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useTextareaAutosizeDrag.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useTextareaAutosizeDrag.ts index 15d1afd5..7902d1e9 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useTextareaAutosizeDrag.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useTextareaAutosizeDrag.ts @@ -1,9 +1,9 @@ import { useState, type RefObject } from 'react'; /** - * textarea 顶边拖拽调高。不用 CSS `resize: vertical`(handle 在右下角、向下拖才放大, - * 但 input 钉在面板底部、视觉是向上扩展,反直觉);改顶边自绘 handle,向上拖 = 放大。 - * 边界跟 css min-height(2 行) / max-height(8 行) 一致;height 为 null 时不写 inline style。 + * Drag the textarea's top edge to resize its height. Does not use CSS `resize: vertical` (its handle is at the bottom-right and only grows when dragged down, + * but the input is pinned to the panel bottom and visually expands upward, which is counterintuitive); instead draws a custom handle on the top edge, dragging up = grow. + * Bounds match css min-height (2 lines) / max-height (8 lines); when height is null, no inline style is written. */ export function useTextareaAutosizeDrag(textareaRef: RefObject<HTMLTextAreaElement | null>): { textareaHeightPx: number | null; @@ -16,11 +16,11 @@ export function useTextareaAutosizeDrag(textareaRef: RefObject<HTMLTextAreaEleme if (!el) return; const startY = e.clientY; const startHeight = el.getBoundingClientRect().height; - // 跟 css token: $fs-md=13 * $lh-normal=1.4 = 18.2 px/line;$space-3=6 px padding 上下 = 12 px + // Matches css tokens: $fs-md=13 * $lh-normal=1.4 = 18.2 px/line; $space-3=6 px padding top+bottom = 12 px const MIN = Math.round(13 * 1.4 * 2 + 12); const MAX = Math.round(13 * 1.4 * 8 + 12); const onMove = (ev: MouseEvent): void => { - // 上拖 dy < 0 → 高度增加;下拖反之 + // Drag up dy < 0 → height increases; drag down is the reverse const dy = ev.clientY - startY; const next = Math.min(MAX, Math.max(MIN, startHeight - dy)); setTextareaHeightPx(next); diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx index 6d56cc54..18104cb7 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/AutopilotChip.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { RobotIcon, RobotOffIcon, StatusChip } from '../../../common'; /** - * AutoPilot 开关 chip:默认关,点击切换(持久化到 agent.autopilot.enabled,下次 poll 生效)。 + * AutoPilot toggle chip: off by default, click to toggle (persisted to agent.autopilot.enabled, takes effect on next poll). */ export function AutopilotChip({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx index b9d2d6c8..20bf988c 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/statusbar/PrAgentActiveChip.tsx @@ -6,8 +6,8 @@ import { formatElapsed } from '../../../../utils/time'; import { StatusChip } from '../../../common'; /** - * 队列弹出菜单:状态栏 chip 上方弹出。先列全部运行中(active)行,再列 waiting 行。 - * waiting 行右侧 × 按钮取消。最多 6 个 item 高度,超出内部滚动。 + * Queue popover: pops up above the status bar chip. Lists all active rows first, then waiting rows. + * The × button on the right of a waiting row cancels it. Max 6 items tall, scrolls internally beyond that. */ function QueuePopover({ active, @@ -88,32 +88,32 @@ function QueuePopover({ } /** - * pr-agent 活动状态 chip:active 时显示运行中工具 + elapsed (可点跳 PR);idle 时 - * 显示"空闲"占位。PR 切换不会丢运行中状态,由 chatRunStore 跨实例维护。 + * pr-agent activity chip: when active, shows the running tool + elapsed (clickable to jump to PR); + * when idle, shows an "idle" placeholder. Switching PRs won't lose running state, which chatRunStore maintains across instances. * - * 调用方应在 pr-agent 实际可用 (PrAgentStatus.available) 时才挂这条。 + * Callers should only mount this when pr-agent is actually available (PrAgentStatus.available). */ export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: string) => void }) { const { t } = useTranslation(); const { active, waiting } = useChatRunStore(); - // 并发模型:active 是运行中 run 列表。chip 主体展示第一条(primary)的 tool + elapsed, - // 多于一条时用徽标显示并发总数;点开 popover 列出全部运行中 + 排队中。 + // Concurrency model: active is the list of running runs. The chip body shows the first (primary) run's tool + elapsed, + // with a badge showing the concurrent total when there's more than one; opening the popover lists all running + queued. const primary = active[0] ?? null; const runningCount = active.length; - // 计时器:1s 粒度,跟 ChatPane 的 elapsed 同步。仅有 primary 时启 + // Timer: 1s granularity, synced with ChatPane's elapsed. Only started when there's a primary. const [elapsedMs, setElapsedMs] = useState(0); - // startedAt 入队时为 null,executeRun 起跑时设值;fallback 到 enqueuedAt 即可 + // startedAt is null while queued, set when executeRun starts; fallback to enqueuedAt is fine const startMs = primary ? new Date(primary.startedAt ?? primary.enqueuedAt).getTime() : 0; useEffect(() => { if (!primary) return; setElapsedMs(Date.now() - startMs); const id = setInterval(() => setElapsedMs(Date.now() - startMs), 1000); return () => clearInterval(id); - // 仅依赖 primary runId + startMs:其它字段变化不影响计时 + // Only depend on primary runId + startMs: changes to other fields don't affect timing // eslint-disable-next-line react-hooks/exhaustive-deps }, [primary?.runId, startMs]); - // 队列弹出菜单:点开 (active chip + 队列 ≥1) 显示 waiting 列表 + × 取消 + // Queue popover: opening it (active chip + queue ≥1) shows the waiting list + × to cancel const [queueOpen, setQueueOpen] = useState(false); const queueRef = useRef<HTMLDivElement | null>(null); useEffect(() => { @@ -131,7 +131,7 @@ export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: strin document.removeEventListener('keydown', onKey); }; }, [queueOpen]); - // 无可展开内容(无排队 且 运行中 ≤1)→ 自动收起菜单 + // Nothing to expand (no queue and running ≤1) → auto-collapse the menu useEffect(() => { if (queueOpen && waiting.length === 0 && active.length <= 1) setQueueOpen(false); }, [queueOpen, waiting.length, active.length]); @@ -141,7 +141,7 @@ export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: strin }; if (!primary) { - // Idle:静态灰点 + "空闲" 文案。让用户一眼看到"agent 可用 + 当前没活儿" + // Idle: static gray dot + "idle" text. Lets the user see at a glance "agent available + nothing running right now" return ( <StatusChip className="statusbar-pragent-chip statusbar-pragent-chip-idle" @@ -153,7 +153,7 @@ export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: strin ); } - // 可展开(运行中 >1 或有排队)→ chip 变 button 点开 popover;否则按 onJumpToPr 跳 PR。 + // Expandable (running >1 or has queue) → chip becomes a button that opens the popover; otherwise jumps to PR via onJumpToPr. const expandable = waiting.length > 0 || runningCount > 1; const clickable = expandable || Boolean(onJumpToPr); const handleClick = (): void => { @@ -163,7 +163,7 @@ export function PrAgentActiveChip({ onJumpToPr }: { onJumpToPr?: (localId: strin onJumpToPr?.(primary.prLocalId); } }; - // 徽标数 = 其它并发运行中(runningCount-1) + 排队中(waiting) + // Badge count = other concurrent running (runningCount-1) + queued (waiting) const extraCount = runningCount - 1 + waiting.length; const title = expandable ? t('statusBar.prAgentExpandableTitle', { running: runningCount, waiting: waiting.length }) diff --git a/apps/desktop/src/renderer/src/components/features/chat/types.ts b/apps/desktop/src/renderer/src/components/features/chat/types.ts index 57fe7521..3087ca39 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/types.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/types.ts @@ -1,6 +1,6 @@ import type { IpcChannels } from '@meebox/ipc'; -/** 当前 PR 命中的全部规则(针对 /review 工具);空数组 = 未配置 / 无命中。 */ +/** All rules matched by the current PR (for the /review tool); empty array = unconfigured / no match. */ export type MatchedRules = IpcChannels['rules:matchForPr']['response']; -/** 单条命中规则。 */ +/** A single matched rule. */ export type MatchedRule = MatchedRules[number]; diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/chat-history.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/chat-history.ts index 3e4e61db..2f2708db 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/chat-history.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/chat-history.ts @@ -1,5 +1,5 @@ -// 输入历史:最近 5 次成功提交,localStorage 持久化。Up/Down 按键在 textarea 末尾 -// 输入位置时回放。命中 / dismissed 后焦点保持在 textarea 上 +// Input history: last 5 successful submissions, persisted to localStorage. Up/Down keys +// replay it when the caret is at the end of the textarea. Focus stays on the textarea after a hit / dismiss const CHAT_HISTORY_KEY = 'meebox.chatHistory'; export const CHAT_HISTORY_MAX = 5; @@ -9,7 +9,7 @@ export function loadChatHistory(): string[] { if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; - // 防御性筛掉非 string 项,并截到上限 (历史 schema 改过也不爆) + // Defensively filter out non-string items and cap to the limit (won't blow up if the history schema changed) return parsed.filter((v): v is string => typeof v === 'string').slice(0, CHAT_HISTORY_MAX); } catch { return []; @@ -20,14 +20,14 @@ export function pushChatHistory(value: string): string[] { const trimmed = value.trim(); if (!trimmed) return loadChatHistory(); const prev = loadChatHistory(); - // 去重:跟最近一条一样不重复入栈 (用户连续打同样命令很常见)。也清掉历史里 - // 重复的旧条目,让最新的那条上移到顶 + // Dedupe: don't push again if identical to the most recent entry (users typing the same command repeatedly is common). Also + // remove duplicate older entries from history so the latest one moves to the top const deduped = prev.filter((v) => v !== trimmed); const next = [trimmed, ...deduped].slice(0, CHAT_HISTORY_MAX); try { localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(next)); } catch { - /* quota / private mode → 内存里历史能继续工作就行 */ + /* quota / private mode → fine as long as in-memory history keeps working */ } return next; } diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/findings.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/findings.ts index 285056a0..69fe383a 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/findings.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/findings.ts @@ -1,15 +1,15 @@ import type { CSSProperties } from 'react'; import type { TFunction } from 'i18next'; -import type { Finding, PrDocSectionKey } from '@meebox/shared'; +import { DEFAULT_CODE_SUGGESTION_LAYOUT, type Finding, type PrDocSectionKey } from '@meebox/shared'; /** - * pr-agent /review 输出的 issue body 尾部含 `[file: <path>, lines: <s>-<e>]` - * marker — 是我们注入的 prompt directive 让 parser 抽 anchor 的,对用户无意义。 - * FindingCard 渲染前 / 转 draft 时统一清洗 + * The issue body from pr-agent /review has a trailing `[file: <path>, lines: <s>-<e>]` + * marker — injected by our prompt directive so the parser can extract the anchor, meaningless to users. + * Cleaned uniformly before FindingCard renders / when converting to draft */ export function stripFindingMarker(body: string): string { - // 路径可能含 `[]`:带 lines 时用惰性 `.+?` + 必现 `, lines:` 后缀界定(`.` 匹配 `]`,不被 - // 路径里的 `]` 误截);无 lines 时回退到不含 `]` 的旧式。末尾锚定,只清尾部 marker。 + // The path may contain `[]`: with lines, use lazy `.+?` + the required `, lines:` suffix to delimit (`.` matches `]`, so it isn't + // truncated by a `]` in the path); without lines, fall back to the old style that excludes `]`. Anchored at the end, only strips the trailing marker. return body .replace( /\s*\[\s*file\s*:\s*(?:.+?\s*,\s*lines?\s*:\s*\d+(?:\s*[-–—]\s*\d+)?|[^\]\n]*?)\s*\]\s*$/i, @@ -19,11 +19,11 @@ export function stripFindingMarker(body: string): string { } /** - * 把 pr-agent GFM 输出里的内联 HTML 标签归一成 markdown。finding 卡片走 ReactMarkdown - * (允许 HTML) 能正常渲染这些标签,但转成草稿正文落进编辑器 textarea / 发布到远端后, - * 裸 `<code>` `<br>` 不一定被渲染,会暴露成字面标签。这里把常见内联标签转成等价 - * markdown:`<code>x</code>`→`` `x` ``、`<br>`→换行、`<b>/<strong>`→`**`、`<i>/<em>`→`*`。 - * 空 `<code></code>` 直接丢弃,避免产出孤立的空反引号。 + * Normalize inline HTML tags in pr-agent GFM output into markdown. Finding cards use ReactMarkdown + * (HTML allowed) which renders these tags fine, but once converted to draft body landing in the editor textarea / published to the remote, + * bare `<code>` `<br>` aren't necessarily rendered and get exposed as literal tags. Here we convert common inline tags to equivalent + * markdown: `<code>x</code>`→`` `x` ``, `<br>`→newline, `<b>/<strong>`→`**`, `<i>/<em>`→`*`. + * Empty `<code></code>` is simply dropped to avoid producing isolated empty backticks. */ export function htmlInlineToMarkdown(text: string): string { return text @@ -36,9 +36,44 @@ export function htmlInlineToMarkdown(text: string): string { } /** - * sectionKey → 中文标签 + 渲染顺序。把 pr-agent 输出按已知段落排成标准文档骨架: - * 建议标题 → 类型 → 总结 → 描述 → 走查 → 测试 → 安全 → 代码反馈 → 工作量 → 评分 → 其他 - * 未识别 (sectionKey === undefined 或 'general') 走兜底,按解析顺序放到末尾。 + * Render an AI code-suggestion finding into a review draft body, applying the user's deterministic layout template + * (settings → agent.strategy.code_suggestion_layout). The author controls the whole comment layout (line breaks, + * sections, extra content). Placeholders (uppercase, angle-bracketed): + * `<TITLE>` — localized "AI suggestion" label + * `<SUGGESTIONS>` — the (already cleaned) suggestion body + * `<HOME>` — project site url + * `<PR>` — current PR url + * `<MODEL>` — current active model name (empty when unknown), e.g. for an attribution suffix + * If the template contains `<SUGGESTIONS>` it is used as a full body template (the author places the body); otherwise + * the whole string is prepended as a prefix before the body. An empty template falls back to + * `DEFAULT_CODE_SUGGESTION_LAYOUT`. All substitution is deterministic — no dependency on model compliance. + */ +export function renderCodeSuggestionDraft(input: { + template: string; + /** The already-cleaned suggestion body (stripFindingMarker + htmlInlineToMarkdown applied by the caller). */ + body: string; + /** Localized "AI suggestion" label for `<TITLE>`. */ + title: string; + homeUrl: string; + prUrl: string; + /** Current active model name for `<MODEL>` (empty string when unknown). */ + modelName: string; +}): string { + const tpl = input.template.trim() || DEFAULT_CODE_SUGGESTION_LAYOUT; + const filled = tpl + .replaceAll('<TITLE>', input.title) + .replaceAll('<HOME>', input.homeUrl) + .replaceAll('<PR>', input.prUrl) + .replaceAll('<MODEL>', input.modelName) + .replaceAll('<SUGGESTIONS>', input.body); + // Contains the body placeholder → full body template; otherwise treat as a prefix and append the body after it. + return tpl.includes('<SUGGESTIONS>') ? filled : `${filled}\n\n${input.body}`; +} + +/** + * sectionKey → label + render order. Arranges pr-agent output by known sections into a standard document skeleton: + * suggested title → type → summary → description → walkthrough → tests → security → code feedback → effort → score → other + * Unrecognized (sectionKey === undefined or 'general') goes through the fallback, placed at the end in parse order. */ const SECTION_ORDER: Record<PrDocSectionKey, number> = { title: 0, @@ -46,16 +81,16 @@ const SECTION_ORDER: Record<PrDocSectionKey, number> = { summary: 2, description: 3, diagram: 4, - assessment: 5, // 思路建议紧随架构图(对齐 Qodo:Description → Diagram → Assessment) + assessment: 5, // assessment follows the diagram (aligned with Qodo: Description → Diagram → Assessment) walkthrough: 6, 'relevant-tests': 7, security: 8, 'code-feedback': 9, - 'code-suggestion': 9, // 跟 code-feedback 一组,UI 顺序无优先关系 + 'code-suggestion': 9, // grouped with code-feedback, no ordering preference between them in the UI effort: 10, score: 11, general: 12, - // /ask 结构化分段(仅出现在 /ask run 内,彼此相对顺序:概述 → 分析 → 建议) + // /ask structured sections (only appear within an /ask run, relative order among them: summary → analysis → suggestions) 'ask-summary': 13, 'ask-analysis': 14, 'ask-suggestions': 15, @@ -74,7 +109,7 @@ const SECTION_LABEL_KEY: Record<PrDocSectionKey, string | null> = { 'code-suggestion': 'chatPane.sectionCodeSuggestion', effort: 'chatPane.sectionEffort', score: 'chatPane.sectionScore', - general: null, // general / 未知段无 chip 标签 + general: null, // general / unknown sections have no chip label 'ask-summary': 'chatPane.sectionAskSummary', 'ask-analysis': 'chatPane.sectionAskAnalysis', 'ask-suggestions': 'chatPane.sectionAskSuggestions', @@ -85,17 +120,17 @@ export function sectionLabel(key: PrDocSectionKey, t: TFunction): string { } /** - * 工作量段已用 emoji 圆点(🔵🔵🔵⚪⚪)直观表示 1-5 分,去掉前面冗余的数字分数: - * "3 🔵🔵🔵⚪⚪" → "🔵🔵🔵⚪⚪";"工作量: 3 🔵🔵" → "工作量: 🔵🔵" - * 仅在数字后紧跟圆点 emoji 时才剥,避免误删正文里的普通数字。 + * The effort section already uses emoji dots (🔵🔵🔵⚪⚪) to intuitively represent a 1-5 score, so drop the redundant leading numeric score: + * "3 🔵🔵🔵⚪⚪" → "🔵🔵🔵⚪⚪"; "Effort: 3 🔵🔵" → "Effort: 🔵🔵" + * Only strips when the number is immediately followed by a dot emoji, to avoid removing ordinary numbers in the body. */ export function stripEffortScoreNumber(s: string): string { return s.replace(/(^|[::]\s*)\d+\s*(?=[🔵⚪⚫🟢🔴🟠🟡🟣🟤])/u, '$1'); } /** - * Stable sort by sectionKey 排序 + 同 key 保留原顺序 (兼容 Array.sort 非 stable JS 引擎)。 - * effort(评估工作量)段直接过滤掉:「Estimated effort to review」实用价值低,不展示。 + * Stable sort by sectionKey + preserve original order within the same key (compatible with JS engines where Array.sort isn't stable). + * The effort section is filtered out entirely: "Estimated effort to review" has low practical value, not shown. */ export function orderFindings(findings: Finding[]): Finding[] { return findings @@ -109,7 +144,7 @@ export function orderFindings(findings: Finding[]): Finding[] { .map((x) => x.f); } -/** 锚点短标签 `<basename>:<startLine>`(复评徽标 / 引用 chip 用),无锚点返回空串。 */ +/** Anchor short label `<basename>:<startLine>` (used by re-review badges / reference chips), returns empty string when there's no anchor. */ export function anchorShortLabel(anchor?: { path: string; startLine?: number; @@ -121,8 +156,8 @@ export function anchorShortLabel(anchor?: { } /** - * 把一条待复评的 finding 拼成 /ask 的隐式引用上下文(referencedContext):让模型看到原评论正文 + 位置, - * 据此复评。与 diff 选区引用(formatReferencedContext)同走 EXTRA_INSTRUCTIONS 注入,不进问题位置参数。 + * Assemble a finding pending re-review into /ask's implicit reference context (referencedContext): let the model see the original comment body + location, + * and re-review accordingly. Like the diff selection reference (formatReferencedContext), it's injected via EXTRA_INSTRUCTIONS, not into the question position args. */ export function formatFindingReference(finding: Finding): string { const a = finding.anchor; @@ -139,8 +174,8 @@ export function formatFindingReference(finding: Finding): string { } /** - * 字符串 → HSL 色相。djb2 简化版,稳定 → 同一标签每次都同色。用于 PR Type 胶囊 - * 自动配色("Bug fix" / "Enhancement" / "Tests" 各拿不同的色,不需要硬编码字典)。 + * String → HSL hue. Simplified djb2, stable → the same label is always the same color. Used for PR Type pill + * auto-coloring ("Bug fix" / "Enhancement" / "Tests" each get a different color, no need for a hardcoded dictionary). */ function labelHue(s: string): number { let h = 0; @@ -148,14 +183,14 @@ function labelHue(s: string): number { return Math.abs(h) % 360; } export function pillStyle(s: string): CSSProperties { - // 仅注入标签 hue(--pill-hue);明暗两套的饱和 / 明度由 CSS 按主题定(见 .pr-type-pill)—— - // 避免在 JS 里写死暗色 HSL(底色 L=22%)导致浅色主题下胶囊过深、不协调。 + // Only inject the label hue (--pill-hue); saturation / lightness for the light and dark sets are decided by CSS per theme (see .pr-type-pill) — + // avoids hardcoding a dark HSL in JS (background L=22%) that would make pills too dark and jarring in the light theme. return { ['--pill-hue']: labelHue(s) } as CSSProperties; } /** - * 把 "Bug fix, Enhancement\nTests" 拆成 ["Bug fix", "Enhancement", "Tests"]。 - * parser 层已经剥过 HR,这里再加一层防御:纯标点 / 长度 ≤1 的项直接 filter 掉, - * 避免 markdown 装饰符号溜进胶囊("---" 这种实际遇到过) + * Split "Bug fix, Enhancement\nTests" into ["Bug fix", "Enhancement", "Tests"]. + * The parser layer already stripped HRs; add another defensive layer here: filter out pure-punctuation / length ≤1 items + * to keep markdown decoration symbols out of the pills ("---" has actually been seen) */ export function splitTypeLabels(body: string): string[] { return body diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts index 4584a7f2..a612d549 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/format.ts @@ -1,10 +1,10 @@ import type { TFunction } from 'i18next'; import type { ReviewRun } from '@meebox/shared'; -// 时长格式化统一在 utils/time(状态栏紧凑版用 compact 选项);此处再导出,chat 各组件就近引用。 +// Duration formatting lives in utils/time (status-bar compact version uses the compact option); re-exported here so chat components import it nearby. export { formatElapsed } from '../../../../utils/time'; -/** 1234 → "1.2k";保留 1 位小数;< 1000 直接返回数字 */ +/** 1234 → "1.2k"; keeps 1 decimal place; < 1000 returns the number as-is */ export function formatTokens(n: number): string { if (n < 1000) return String(n); return `${(n / 1000).toFixed(1)}k`; @@ -24,10 +24,12 @@ export function runStatusLabel(status: ReviewRun['status'], t: TFunction): strin } /** - * 把时间戳格式化为 "HH:MM:SS" (当天) 或 "MM-DD HH:MM" (跨日)。用户主要看"哪一次 - * 跑的",秒粒度足够区分相邻 run;隔天的 run 加日期标识让历史 run 列表里能立刻 - * 分组。接受 ISO 字符串 (持久化 ReviewRun.startedAt) 或毫秒时间戳 (RunningView - * 端把 ISO 转过的 Date.getTime()) + * Format a timestamp as "HH:MM:SS" (same day) or "MM-DD HH:MM" (crossing days). + * The user mainly cares about "which run"; second granularity is enough to tell + * adjacent runs apart; runs from another day get a date marker so the history run + * list groups them at a glance. Accepts an ISO string (persisted + * ReviewRun.startedAt) or a millisecond timestamp (RunningView side passes the + * Date.getTime() of the parsed ISO) */ export function formatStartTime(input: string | number): string { const d = new Date(input); @@ -48,15 +50,17 @@ export function formatStartTime(input: string | number): string { } /** - * 从 stdout 已收到的行里推断 pr-agent 当前在哪个阶段。pr-agent 在 LLM 调用前会 - * 打几条 INFO 标志位 ("Reviewing PR..." / "Tokens: ... returning full diff" - * / ...),LLM 调用本身是几分钟静默;从最近行命中已知模式来给用户更准的状态提示。 + * Infer which phase pr-agent is currently in from the stdout lines received so + * far. Before an LLM call pr-agent prints a few INFO markers ("Reviewing PR..." / + * "Tokens: ... returning full diff" / ...), while the LLM call itself is minutes + * of silence; matching known patterns against recent lines gives the user a more + * accurate status hint. * - * 大仓库 /review 总时长可能 5min+,没有这个推断只看到 spinner + elapsed 容易 - * 误以为卡住。 + * A /review on a large repo can total 5min+, and without this inference seeing + * only spinner + elapsed makes it easy to think it is stuck. */ export function inferPhase(lines: ReadonlyArray<string>, t: TFunction): string { - // 从后往前找最近的命中标志,越靠后的标志代表更"晚"的阶段 + // Scan from the end backward for the most recent matching marker; a later marker means a "later" phase for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]!; if (/returning full diff|tokens?\s*[::]\s*\d+/i.test(line)) diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/parse-command.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/parse-command.ts index 3d55d655..d3190ba6 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/parse-command.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/parse-command.ts @@ -2,14 +2,14 @@ import type { LocalPrStatus, ReviewRunTool } from '@meebox/shared'; import { COMMANDS } from '../commands'; /** - * 解析输入栏提交内容的判别式结果(纯函数,不做 i18n / 副作用): - * - `unknown` —— `/` 起手但命令名未知(head 用于错误提示) - * - `commandNoArgs` —— review 决断命令带了多余参数 - * - `askNeedsQuestion` —— `/ask` 未带问题 - * - `reviewAction` —— `/approve` `/needswork` → 写 reviewer status - * - `mergeAction` —— `/merge` → 合并 PR(输入栏弹确认 + canMerge 门控) - * - `run` —— pr-agent 工具(review / describe / improve / ask) - * - `agentAsk` —— 无 `/` 前缀,自然语言「对话即委派」交给自由规划 Agent + * Discriminated result of parsing the input-bar submission (pure function, no i18n / side effects): + * - `unknown` —— starts with `/` but the command name is unknown (head used for the error hint) + * - `commandNoArgs` —— a review-decision command was given extra arguments + * - `askNeedsQuestion` —— `/ask` without a question + * - `reviewAction` —— `/approve` `/needswork` → write reviewer status + * - `mergeAction` —— `/merge` → merge the PR (input bar pops a confirm + canMerge gating) + * - `run` —— pr-agent tool (review / describe / improve / ask) + * - `agentAsk` —— no `/` prefix, natural-language "conversation as delegation" handed to the free-planning Agent */ export type ParsedCommand = | { kind: 'unknown'; head: string } @@ -21,30 +21,30 @@ export type ParsedCommand = | { kind: 'agentAsk'; question: string }; export function parseChatCommand(trimmed: string): ParsedCommand { - // 解析命令头:'/' 起手 → COMMANDS 表里找;无 '/' → 等价自然语言委派 + // Parse the command head: starts with '/' → look it up in the COMMANDS table; no '/' → equivalent to natural-language delegation if (trimmed.startsWith('/')) { const spaceIdx = trimmed.indexOf(' '); const head = spaceIdx < 0 ? trimmed : trimmed.slice(0, spaceIdx); const rest = spaceIdx < 0 ? '' : trimmed.slice(spaceIdx + 1).trim(); const found = COMMANDS.find((c) => c.label === head); if (!found) return { kind: 'unknown', head }; - // review-action:/approve /needswork 没有参数,多余文本拒绝以免误用 + // review-action: /approve /needswork take no arguments; extra text is rejected to avoid misuse if (found.kind === 'review-action') { if (rest) return { kind: 'commandNoArgs', cmd: found.label }; return { kind: 'reviewAction', status: found.reviewStatus }; } - // pr-action:/merge 无参数;canMerge 门控与二次确认在输入栏层(useChatInput)处理 + // pr-action: /merge takes no arguments; canMerge gating and the confirmation are handled at the input-bar layer (useChatInput) if (found.kind === 'pr-action') { if (rest) return { kind: 'commandNoArgs', cmd: found.label }; return { kind: 'mergeAction' }; } - // pragent:/ask 必须带问题,其他工具空 question + // pragent: /ask must carry a question, other tools have an empty question if (found.name === 'ask') { if (!rest) return { kind: 'askNeedsQuestion' }; return { kind: 'run', name: found.name, question: rest }; } return { kind: 'run', name: found.name }; } - // 无 '/' → 自然语言「对话即委派」:交给自由规划 Agent(而非 /ask)。 + // No '/' → natural-language "conversation as delegation": handed to the free-planning Agent (not /ask). return { kind: 'agentAsk', question: trimmed }; } diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts index e1c7fb6d..51284d84 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts @@ -1,36 +1,36 @@ export interface TokenUsage { - /** 输入侧 (prompt) token 数。LITELLM_LOG=INFO 时来自 litellm;fallback 用 pr-agent - 自己打的 "Tokens: N" (tiktoken 预估) */ + /** Input-side (prompt) token count. Comes from litellm when LITELLM_LOG=INFO; fallback uses + pr-agent's own "Tokens: N" (tiktoken estimate) */ prompt?: number; - /** 输出侧 (completion) token 数。仅 LITELLM_LOG=INFO 时可拿到 */ + /** Output-side (completion) token count. Only available when LITELLM_LOG=INFO */ completion?: number; - /** 总 token;优先用 litellm 给的,缺时算 prompt+completion */ + /** Total tokens; prefers what litellm gives, computes prompt+completion when missing */ total?: number; - /** 提示缓存读取量(cache_read),是 prompt 的一部分;缺/0 时不展示「(cache N)」括号 */ + /** Prompt-cache read amount (cache_read), part of prompt; when missing/0 the "(cache N)" parenthesis is not shown */ cacheRead?: number; - /** 模型交互轮次;≤1 时不单独展示 */ + /** Model interaction turns; not shown separately when ≤1 */ turns?: number; } /** - * 从 pr-agent stdout 解析 token 用量。多源累加: + * Parse token usage from pr-agent stdout. Multi-source accumulation: * - * 1. litellm INFO 模式 (我们默认开):每次 LLM 调用后会打类似 + * 1. litellm INFO mode (on by default for us): after each LLM call it prints something like * `usage={'prompt_tokens': 8423, 'completion_tokens': 1234, 'total_tokens': 9657}` - * 多轮调用 → 各项累加;这条最准 + * multiple call rounds → each field accumulates; this one is the most accurate * - * 2. pr-agent 自己的 prompt 预估:`Tokens: 8423, total tokens under limit: ...` - * 没 litellm 日志兜底;只反映输入侧,按最大值取代表 + * 2. pr-agent's own prompt estimate: `Tokens: 8423, total tokens under limit: ...` + * fallback when there is no litellm log; only reflects the input side, taking the max as representative * - * 优先用 (1);(1) 没命中再退到 (2)。 + * Prefers (1); falls back to (2) when (1) does not match. */ export function extractTokenUsage(stdout: string): TokenUsage { let prompt = 0; let completion = 0; let total = 0; let hasLitellm = false; - // litellm 的 usage dict 在 stdout 里以 Python repr 形式出现,单引号字符串 - // (兼容 JSON 双引号也匹配)。一次 run 多轮 LLM 调用全部累加 + // litellm's usage dict appears in stdout as a Python repr, single-quoted strings + // (also matches JSON double quotes for compatibility). All LLM call rounds in one run accumulate const usageRe = /['"]prompt_tokens['"]\s*:\s*(\d+)[\s,]*['"]completion_tokens['"]\s*:\s*(\d+)[\s,]*['"]total_tokens['"]\s*:\s*(\d+)/g; let m: RegExpExecArray | null; @@ -43,7 +43,7 @@ export function extractTokenUsage(stdout: string): TokenUsage { if (hasLitellm) { return { prompt, completion, total: total || prompt + completion }; } - // Fallback: pr-agent 的 prompt 预估 + // Fallback: pr-agent's prompt estimate const fallbackRe = /Tokens:\s*(\d+)/gi; let maxPrompt: number | undefined; while ((m = fallbackRe.exec(stdout)) !== null) { diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx b/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx index 3a9bc706..f07eee57 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx +++ b/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx @@ -8,46 +8,46 @@ import type { FilterKey } from '../../layout/Sidebar'; import type { SettingsCategory } from '../settings'; interface CommandPaletteProps { - /** 运行平台:决定打开快捷键修饰键(mac = Cmd+Shift+P,其余 = Ctrl+Shift+P)。 */ + /** Running platform: decides the open shortcut modifier (mac = Cmd+Shift+P, others = Ctrl+Shift+P). */ platform: Platform; config: Config; - /** 当前选中 PR 的 localId(上下文相关命令用,如运行自动评审)。 */ + /** localId of the currently selected PR (for context-relevant commands, e.g. run auto review). */ selectedPrId: string | null; patchConfig: (updater: (c: Config) => Config) => void; openSettings: (category?: SettingsCategory) => void; - /** 切换对话面板折叠(评审域命令用)。 */ + /** Toggle the chat panel collapse (used by review-domain command). */ toggleChatPanel: () => void; - /** 切换 PR 列表(侧栏)折叠(PR 域命令用)。 */ + /** Toggle the PR list (sidebar) collapse (used by PR-domain command). */ togglePrList: () => void; - /** 当前平台支持的发现分类(PR 域「一级分类」命令门控用)。 */ + /** Discovery filters supported by the current platform (gates the PR-domain "top-level filter" commands). */ discoveryFilters: readonly PrDiscoveryFilter[]; setDiscoveryFilter: (filter: PrDiscoveryFilter) => void; - /** 切到「已关闭」(归档)范围(PR 域「查看已关闭」命令用)。 */ + /** Switch to the "closed" (archived) scope (used by the PR-domain "view closed" command). */ viewArchived: () => void; - /** 按 URL 打开当前平台 PR(PR 域「打开 URL」自由文本命令用)。 */ + /** Open a PR of the current platform by URL (used by the PR-domain "open URL" free-text command). */ openPrByUrl: (url: string) => void | Promise<void>; - /** 可选的 PR 状态筛选项(PR 域「分类筛选」二级选项用)。 */ + /** Selectable PR status filters (used by the PR-domain "filter by category" second-level options). */ prStatusFilters: ReadonlyArray<{ value: FilterKey; labelKey: string }>; setPrStatusFilter: (filter: FilterKey) => void; } -/** 当前层(顶层 / 二级)展开后用于渲染的扁平项。 */ +/** Flat item used for rendering after the current level (top / second) is expanded. */ interface FlatItem { id: string; title: string; - /** 英文标题(缺省=title):非英语界面作次行展示,并恒参与检索 */ + /** English title (defaults to title): shown as a secondary line in non-English UI, and always searchable */ titleEn: string; category?: string; categoryEn?: string; active?: boolean; - /** 快捷键按键 token(一键一框,如 ['⌘','B']),右侧展示 */ + /** Shortcut key tokens (one key per box, e.g. ['⌘','B']), shown on the right */ shortcut?: string[]; onSelect: () => void; } /** - * 把文本里匹配查询的(连续)子串包成高亮 `<mark>`,与列表的 `includes` 子串过滤一致。 - * 空查询原样返回;大小写不敏感;同一文本里多处命中都高亮。 + * Wraps the (contiguous) substring of the text matching the query in a highlight `<mark>`, matching the list's `includes` substring filter. + * Empty query returns as-is; case-insensitive; all hits within the same text are highlighted. */ function highlight(text: string, query: string): React.ReactNode { const q = query.trim(); @@ -73,9 +73,9 @@ function highlight(text: string, query: string): React.ReactNode { } /** - * 标题栏命令面板(VS Code 风):标题栏内嵌输入框 + 下拉结果。快捷键 mac Cmd+Shift+P / - * 其余 Ctrl+Shift+P 打开聚焦。**最多两级**——顶层命令选中后若有二级选项则原地替换为选项列表, - * 不支持返回上级(Esc 退出后重进)。搜索按当前界面语言匹配命令文案。设计见 docs/arch/03-gui/02-command-palette。 + * Title-bar command palette (VS Code style): input box embedded in the title bar + dropdown results. Shortcut mac Cmd+Shift+P / + * others Ctrl+Shift+P to open and focus. **At most two levels** — after a top-level command is selected, if it has second-level options they replace the list in place, + * with no going back up (Esc to exit then re-enter). Search matches command text by the current UI language. Design: docs/arch/03-gui/02-command-palette. */ export function CommandPalette({ platform, @@ -93,7 +93,7 @@ export function CommandPalette({ setPrStatusFilter, }: CommandPaletteProps) { const { t, i18n } = useTranslation(); - // 重入保护:调用时取实时运行中 PR 集合(编排 Agent),稳定引用避免命令清单频繁重建 + // Reentrancy guard: read the live set of running PRs (orchestration Agent) on call; stable reference avoids frequent command-list rebuilds const isPrRunning = useCallback( (id: string) => chatRunStore.getSnapshot().agentPrs.includes(id), [], @@ -104,17 +104,17 @@ export function CommandPalette({ const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef<HTMLInputElement>(null); const activeItemRef = useRef<HTMLButtonElement>(null); - // 上次指针坐标:用于区分「真实鼠标移动」与「面板在静止光标下出现 / 滚动产生的合成 hover」。 + // Last pointer coordinates: used to distinguish "real mouse movement" from "panel appearing under a stationary cursor / synthetic hover from scrolling". const pointerRef = useRef({ x: -1, y: -1 }); const blurTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); - // 固定英文翻译器(en-US 静态打包、恒可用):非英语界面作次行 + 恒按英文检索 + // Fixed English translator (en-US statically bundled, always available): secondary line in non-English UI + always searchable in English const tEn = useMemo(() => i18n.getFixedT('en-US'), [i18n]); const isEnglish = i18n.language === 'en-US'; const roots = useMemo( () => { - // 显式引用 i18n.language:t 引用在切语言后不变,需以语言为 key 重建命令文案(搜索按当前语言匹配) + // Explicitly reference i18n.language: the t reference stays unchanged after a language switch, so command text must be rebuilt keyed by language (search matches the current language) void i18n.language; return buildRootCommands({ platform, @@ -164,11 +164,11 @@ export function CommandPalette({ inputRef.current?.blur(); }; - // 经 ref 取最新 roots,让 mruActiveIndex / openPalette 保持稳定引用(供快捷键 effect 依赖、不反复重订阅) + // Get the latest roots via ref so mruActiveIndex / openPalette keep stable references (for the shortcut effect's deps, avoiding repeated re-subscription) const rootsRef = useRef(roots); rootsRef.current = roots; - // 打开(空查询、顶层)时默认选中「最近用过且当前仍存在」的命令,回车即重复上次;查无回落第一条。 + // On open (empty query, top level) default-select the "most recently used and still present" command, so Enter repeats last; fall back to the first if none found. const mruActiveIndex = useCallback((): number => { for (const id of readMru()) { const i = rootsRef.current.findIndex((r) => r.id === id); @@ -185,7 +185,7 @@ export function CommandPalette({ inputRef.current?.focus(); }, [mruActiveIndex]); - // 直接打开并进入某条「自由文本输入」命令的输入层(供「打开 URL」快捷键直达,省去先开面板再选)。 + // Open directly into a "free-text input" command's input level (for the "open URL" shortcut to jump straight in, skipping open-palette-then-select). const openInputCommand = useCallback((id: string): void => { const cmd = rootsRef.current.find((r) => r.id === id); if (!cmd?.input) return; @@ -198,10 +198,10 @@ export function CommandPalette({ const items: FlatItem[] = useMemo(() => { const q = query.trim().toLowerCase(); - // haystack 恒含英文(本地化 + 英文一起匹配):非英语界面下也始终支持英文检索 + // haystack always includes English (localized + English matched together): English search stays available even in non-English UI const has = (hay: string): boolean => !q || hay.toLowerCase().includes(q); if (level) { - // 自由文本输入模式(如「打开 URL」):二级无选项列表,输入框接受任意文本、回车提交。 + // Free-text input mode (e.g. "open URL"): second level has no options list; input box accepts arbitrary text, submitted on Enter. if (!level.options) return []; return level .options() @@ -226,9 +226,9 @@ export function CommandPalette({ categoryEn: r.categoryEn, shortcut: r.shortcut, onSelect: () => { - pushMru(r.id); // 记最近使用(顶层命令;进容器 / 叶子执行都记) + pushMru(r.id); // record MRU (top-level command; recorded whether entering a container or executing a leaf) if (r.options || r.input) { - // 容器(二级选项)或自由文本输入:原地进入二级层 + // Container (second-level options) or free-text input: enter the second level in place setLevel(r); setQuery(''); setActiveIndex(0); @@ -239,21 +239,21 @@ export function CommandPalette({ } }, })) - // 顶层按「领域前缀 + 命令名」(中英一起)匹配:搜领域名(如「设置」/「Settings」)可归类筛出该域全部命令 + // Top level matches by "domain prefix + command name" (Chinese and English together): searching a domain name (e.g. "设置" / "Settings") filters out all commands in that domain .filter((it) => has(`${it.category} ${it.title} ${it.categoryEn} ${it.titleEn}`)); }, [level, query, roots]); - // 列表变化后把高亮项夹在范围内 + // After the list changes, clamp the highlighted item within range useEffect(() => { setActiveIndex((i) => Math.min(Math.max(0, i), Math.max(0, items.length - 1))); }, [items.length]); - // 高亮项滚入可视区:打开时默认选中的是 MRU 项(可能在列表中段),需自动滚到;方向键导航同理。 + // Scroll the highlighted item into view: on open the default selection is the MRU item (may be mid-list) and needs auto-scroll; same for arrow-key navigation. useEffect(() => { if (open) activeItemRef.current?.scrollIntoView({ block: 'nearest' }); }, [open, activeIndex, items.length]); - // 全局快捷键:mac Cmd+Shift+P / 其余 Ctrl+Shift+P 打开 + // Global shortcut: mac Cmd+Shift+P / others Ctrl+Shift+P to open useEffect(() => { const isMac = platform === 'darwin'; const onKey = (e: KeyboardEvent): void => { @@ -264,7 +264,7 @@ export function CommandPalette({ e.preventDefault(); openPalette(); } else if (k === 'u') { - // ⌘⇧U / Ctrl+Shift+U:直达「打开 URL」输入层(U = URL) + // ⌘⇧U / Ctrl+Shift+U: jump straight to the "open URL" input level (U = URL) e.preventDefault(); openInputCommand('open-pr-url'); } @@ -274,15 +274,15 @@ export function CommandPalette({ }, [platform, openPalette, openInputCommand]); const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { - // 输入法合成中(IME 候选未确认):该次按键(尤其 Enter)只用于确认候选词,不触发命令选择 / 导航。 - // 否则中文等输入时,确认候选的 Enter 会同时选中命令进二级层,而 compositionend 的 onChange 又把候选词 - // 写回 query → 二级层被残留词过滤(预期外的筛选)。确认后用户再按一次才动作,与各通用搜索框一致。 + // During IME composition (candidate not yet confirmed): this keypress (especially Enter) is only for confirming the candidate, not triggering command selection / navigation. + // Otherwise, when typing Chinese etc., the Enter that confirms the candidate would also select the command and enter the second level, while compositionend's onChange writes the candidate + // back into query → the second level gets filtered by leftover text (unexpected filtering). The user presses once more after confirming to act, consistent with common search boxes. if (e.nativeEvent.isComposing) return; if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Backspace' && level && query === '') { - // 二级层(提示符状态)下空查询按 Backspace 回退到上一级(顶层命令列表) + // At the second level (prompt state) with an empty query, Backspace goes back up one level (top-level command list) e.preventDefault(); setLevel(null); setQuery(''); @@ -295,7 +295,7 @@ export function CommandPalette({ setActiveIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); - // 自由文本输入模式:回车把当前文本提交给命令(非空才提交)。 + // Free-text input mode: Enter submits the current text to the command (only if non-empty). if (level?.input) { const text = query.trim(); if (text) { @@ -311,7 +311,7 @@ export function CommandPalette({ return ( <div className="cmdk"> <div className="cmdk-field"> - {/* 二级层前缀提示符:进入子层后显示简短前缀(prefixLabel,如「URL」)或回退命令名,稳住语境 */} + {/* Second-level prefix prompt: after entering a sub-level, show a short prefix (prefixLabel, e.g. "URL") or fall back to the command name, to anchor context */} {level && <span className="cmdk-field-prefix">{level.prefixLabel ?? level.title}</span>} <input ref={inputRef} @@ -330,26 +330,26 @@ export function CommandPalette({ }} onFocus={() => { if (blurTimer.current) clearTimeout(blurTimer.current); - // 点击聚焦打开(空查询、顶层)时同样预选最近用过的命令 + // On click-focus open (empty query, top level), likewise pre-select the most recently used command if (query === '' && level === null) setActiveIndex(mruActiveIndex()); setOpen(true); }} onBlur={() => { - // 延迟关闭:让下拉项的 click 先于 blur 生效(项的 onMouseDown 已 preventDefault 保住焦点) + // Delayed close: let a dropdown item's click take effect before blur (the item's onMouseDown already preventDefault to keep focus) blurTimer.current = setTimeout(close, 120); }} onKeyDown={onInputKeyDown} aria-label={t('commandPalette.placeholder')} /> </div> - {/* 自由文本输入模式(如「打开 URL」)不出下拉层——没有可选项,纯输入 + 回车(占位已说明) */} + {/* Free-text input mode (e.g. "open URL") shows no dropdown — no options, pure input + Enter (the placeholder explains) */} {open && !level?.input && ( <div className="cmdk-panel" role="listbox"> {items.length === 0 ? ( <div className="cmdk-empty">{t('commandPalette.empty')}</div> ) : ( items.map((it, i) => { - // 非英语界面且英文与本地化不同 → 次行显示英文(对齐 VS Code 显示语言) + // Non-English UI and English differs from localized → show English on a secondary line (aligns with VS Code display language) const showEn = !isEnglish && (it.titleEn !== it.title || (it.categoryEn ?? '') !== (it.category ?? '')); @@ -362,8 +362,8 @@ export function CommandPalette({ aria-selected={i === activeIndex} className={`cmdk-item${i === activeIndex ? ' is-active' : ''}`} onMouseDown={(e) => e.preventDefault()} - // 只认真实的鼠标移动来改高亮:面板在静止光标下弹出(mouseenter)、或键盘导航滚动把项 - // 移到光标下(合成事件、坐标不变),都不应抢走 MRU 默认选中 / 键盘选中。坐标真正变了才接管。 + // Only real mouse movement changes the highlight: the panel popping up under a stationary cursor (mouseenter), or keyboard-navigation scrolling moving an item + // under the cursor (synthetic event, coordinates unchanged), must not steal the MRU default / keyboard selection. Only take over when coordinates truly change. onMouseMove={(e) => { if (e.clientX === pointerRef.current.x && e.clientY === pointerRef.current.y) { return; diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/index.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/index.ts index 412f7cc9..95b7c5fb 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/index.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/index.ts @@ -6,9 +6,9 @@ import { buildSettingsCommands } from './settings'; export * from './types'; /** - * 命令注册表:聚合各领域命令构建器。**新增领域 = 加一个 `<domain>.ts` 文件 + 在此登记**, - * 上层(CommandPalette)只认 `buildRootCommands`。命令在面板里按此数组顺序、各自的 category 前缀分组; - * 领域按**英文名字典序**固定排列(PR < Review < Settings),新增领域按此序插入对应位置。 + * Command registry: aggregates each domain's command builder. **Adding a domain = add one `<domain>.ts` file + register it here**; + * the upper layer (CommandPalette) only knows `buildRootCommands`. Commands are grouped in the palette by this array's order, each under its own category prefix; + * domains are fixed in **English-name lexicographic order** (PR < Review < Settings), and new domains are inserted at the corresponding position by this order. */ const DOMAIN_BUILDERS: ReadonlyArray<(ctx: CommandContext) => RootCommand[]> = [ buildPrCommands, @@ -17,9 +17,9 @@ const DOMAIN_BUILDERS: ReadonlyArray<(ctx: CommandContext) => RootCommand[]> = [ ]; export function buildRootCommands(ctx: CommandContext): RootCommand[] { - // 领域按 DOMAIN_BUILDERS 固定顺序(PR < Review < Settings);**域内统一按英文标题字典序**排序, - // 使「查看 X / 分类筛选 / 切换…」等不依赖各域手工排列、动态生成的命令(如随平台能力的发现分类)也自动归位。 - // 统一门控:命令声明 when 谓词,注册表在此统一过滤(各领域不再各写 if)。 + // Domains follow DOMAIN_BUILDERS' fixed order (PR < Review < Settings); **within a domain, sort uniformly by English-title lexicographic order**, + // so dynamically generated commands (like discovery filters that vary by platform capability) such as "view X / filter by category / toggle…" also fall into place without per-domain manual ordering. + // Unified gating: commands declare a when predicate, and the registry filters here uniformly (domains no longer each write their own if). return DOMAIN_BUILDERS.flatMap((build) => build(ctx) .filter((c) => !c.when || c.when()) diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts index 363eecd4..3c4b96a2 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts @@ -2,7 +2,7 @@ import type { PrDiscoveryFilter } from '@meebox/shared'; import type { CommandContext, RootCommand } from './types'; import { formatChord } from './shortcuts'; -/** 发现分类标签 i18n key(与 Sidebar 发现 tabs 同源;具体可用哪几类由 platform 能力决定)。 */ +/** i18n keys for discovery filter labels (same source as the Sidebar discovery tabs; which ones are available is decided by platform capability). */ const DISCOVERY_LABEL_KEYS: Record<PrDiscoveryFilter, string> = { 'review-requested': 'sidebar.discoveryReviewRequested', created: 'sidebar.discoveryCreated', @@ -11,11 +11,11 @@ const DISCOVERY_LABEL_KEYS: Record<PrDiscoveryFilter, string> = { }; /** - * 「PR」领域命令: - * - **一级分类**:每个发现分类(待我评审 / 我创建 / 指派我 / 提及我)各成一条一级命令,直接跳转;选项随 - * 当前 platform 能力门控,无分类的平台不提供。不显示当前选中态。 - * - **分类筛选(二级)**:筛选 PR 状态(待处理 / 全部 / 冲突 / 可合并等,与侧栏一致、随平台门控)。 - * - **切换 PR 列表**:折叠 / 展开侧栏。 + * "PR" domain commands: + * - **Top-level filters**: each discovery filter (review requested / created / assigned / mentioned) becomes one top-level command that jumps directly; options are + * gated by the current platform's capability, and platforms without a filter don't offer it. No current-selection state shown. + * - **Filter by category (second level)**: filter PR status (pending / all / conflict / mergeable etc., consistent with the sidebar, gated by platform). + * - **Toggle PR list**: collapse / expand the sidebar. */ export function buildPrCommands(ctx: CommandContext): RootCommand[] { const { t, tEn, discoveryFilters, setDiscoveryFilter, prStatusFilters, setPrStatusFilter } = ctx; @@ -23,7 +23,7 @@ export function buildPrCommands(ctx: CommandContext): RootCommand[] { const categoryEn = tEn('commandPalette.categoryPr'); const out: RootCommand[] = []; - // 一级分类:按平台能力提供的顺序各成一条「查看「XXX」」命令(与 Sidebar 发现 tabs 同序),不显示选中态 + // Top-level filters: in the order the platform capability provides, each becomes a "view «XXX»" command (same order as the Sidebar discovery tabs), no selection state shown for (const f of discoveryFilters) { const label = t(DISCOVERY_LABEL_KEYS[f]); const labelEn = tEn(DISCOVERY_LABEL_KEYS[f]); @@ -37,8 +37,8 @@ export function buildPrCommands(ctx: CommandContext): RootCommand[] { }); } - // 查看已关闭:切到归档(已关闭)范围浏览。快捷键取 H(history);mac 用 ⌘⇧H 避开系统「隐藏应用」(⌘H), - // 其余平台 Ctrl+H(浏览器历史惯例)。见 App 窗口级快捷键。 + // View closed: switch to the archived (closed) scope to browse. Shortcut uses H (history); mac uses ⌘⇧H to avoid the system "Hide App" (⌘H), + // other platforms Ctrl+H (browser history convention). See App window-level shortcuts. out.push({ id: 'view-archived', category, @@ -49,7 +49,7 @@ export function buildPrCommands(ctx: CommandContext): RootCommand[] { run: () => ctx.viewArchived(), }); - // 分类筛选:二级选择 PR 状态 + // Filter by category: second level selects PR status out.push({ id: 'filter-pr', category, @@ -66,16 +66,16 @@ export function buildPrCommands(ctx: CommandContext): RootCommand[] { })), }); - // 打开 URL(当前平台):自由文本二级层,粘贴 / 输入 PR 链接回车打开(审查未正式被请求参与的他人 PR) + // Open URL (current platform): free-text second level, paste / type a PR link and Enter to open (review others' PRs you weren't formally requested on) out.push({ id: 'open-pr-url', category, categoryEn, title: t('commandPalette.cmdOpenPrUrl'), titleEn: tEn('commandPalette.cmdOpenPrUrl'), - // 快捷键直达输入层(U = URL);mac ⌘⇧U / 其余 Ctrl+Shift+U,见 CommandPalette 窗口级监听 + // Shortcut jumps straight to the input level (U = URL); mac ⌘⇧U / others Ctrl+Shift+U, see CommandPalette window-level listener shortcut: formatChord(ctx.platform, 'U', { shift: true }), - // 输入层前缀只用简短「URL」(通用、免 i18n),不占整条命令标题 + // The input-level prefix uses just a short "URL" (generic, no i18n), not the full command title prefixLabel: 'URL', input: { placeholder: t('commandPalette.openPrUrlPlaceholder'), diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts index ebe87e9b..d515b9b5 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts @@ -12,9 +12,9 @@ function toggleAutopilot(ctx: CommandContext): void { } /** - * 「评审」领域命令:开关 AutoPilot、对当前 PR 运行自动评审。运行自动评审走与 ChatPane「一键自动评审」 - * 同一通道(`agent:run`),运行态与会话经事件 / store 反映;需当前选中 PR 且 LLM 已配置才列出 - * (无 `when` 机制前先按上下文裁剪)。 + * "Review" domain commands: toggle AutoPilot, run auto review on the current PR. Running auto review goes through the same channel + * (`agent:run`) as ChatPane's "one-click auto review"; run state and session are reflected via events / store; listed only when a PR is selected and the LLM is configured + * (trimmed by context until a `when` mechanism exists). */ export function buildReviewCommands(ctx: CommandContext): RootCommand[] { const { t, tEn, selectedPrId, isPrRunning } = ctx; @@ -26,15 +26,15 @@ export function buildReviewCommands(ctx: CommandContext): RootCommand[] { title: t(key), titleEn: tEn(key), }); - // 域内按英文名字典序:Run Auto Review < Toggle AutoPilot < Toggle Chat Panel + // Within-domain English-name lexicographic order: Run Auto Review < Toggle AutoPilot < Toggle Chat Panel return [ { id: 'run-auto-review', ...cmd('commandPalette.cmdRunAutoReview'), - // 门控:有选中 PR 才出现(无 PR 无意义)。执行时再做重入保护:同一 PR 已在跑则忽略。走与 ChatPane - // 一键评审同通道,运行态 / 会话经事件 + store 反映;LLM 未配置 / pr-agent 未就绪由后端按失败回流到会话。 + // Gating: only appears when a PR is selected (meaningless without one). Reentrancy guard at execution: ignore if the same PR is already running. Uses the same channel as ChatPane's + // one-click review; run state / session reflected via events + store; LLM not configured / pr-agent not ready flow back into the session as a failure from the backend. when: () => Boolean(selectedPrId), - shortcut: ['F5'], // 运行(IDE 惯例);单键避开组合冲突,见 App 窗口级快捷键 + shortcut: ['F5'], // Run (IDE convention); single key avoids combo conflicts, see App window-level shortcuts run: () => { if (selectedPrId && !isPrRunning(selectedPrId)) { void invoke('agent:run', { localId: selectedPrId }); diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/settings.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/settings.ts index abe53108..9b3b441d 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/settings.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/settings.ts @@ -7,15 +7,15 @@ import type { CommandContext, CommandOption, RootCommand } from './types'; import { formatChord } from './shortcuts'; function switchLanguage(ctx: CommandContext, next: SupportedLanguage): void { - void i18n.changeLanguage(next); // 渲染层实时切换 - persistLanguage(next); // localStorage 缓存,下次启动命中 - ctx.patchConfig((c) => ({ ...c, language: next })); // 同步 boot.config - void invoke('config:setLanguage', { language: next }); // 写盘 + 主进程 i18n + void i18n.changeLanguage(next); // live switch in the render layer + persistLanguage(next); // localStorage cache, hit on next launch + ctx.patchConfig((c) => ({ ...c, language: next })); // sync boot.config + void invoke('config:setLanguage', { language: next }); // write to disk + main-process i18n } function switchTheme(ctx: CommandContext, next: EditorTheme): void { const ap = ctx.config.appearance; - // 实时应用:写共享 store → App 的 useGlobalTheme 派生 data-theme / chrome / 持久化;字体不变沿用现值。 + // Apply live: write the shared store → App's useGlobalTheme derives data-theme / chrome / persistence; font unchanged, keeps current value. setEditorAppearance({ editorTheme: next, fontFamily: ap.editor_font_family, @@ -42,15 +42,15 @@ function toggleProxy(ctx: CommandContext): void { } /** - * 「设置」领域命令(P1):切换语言 / 主题 / 模型、开关代理、打开设置 / 关于 / DevTools。 - * title 经当前语言本地化(搜索按当前语言匹配);二级选项惰性求值、读当前 config 标注生效项。 + * "Settings" domain commands (P1): switch language / theme / model, toggle proxy, open settings / about / DevTools. + * title is localized in the current language (search matches the current language); second-level options are lazily evaluated, reading the current config to mark the active item. */ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { const { t, tEn, config } = ctx; const category = t('commandPalette.categorySettings'); const categoryEn = tEn('commandPalette.categorySettings'); const currentLang = resolveUiLanguage(config.language); - // 本地化 + 英文双标题(含领域前缀),供两行展示 + 恒按英文检索 + // Dual title, localized + English (including domain prefix), for two-line display + always searchable in English const cmd = (key: string): Pick<RootCommand, 'title' | 'titleEn' | 'category' | 'categoryEn'> => ({ category, categoryEn, @@ -62,7 +62,7 @@ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { id: 'switch-language', ...cmd('commandPalette.cmdSwitchLanguage'), optionsPlaceholder: t('commandPalette.pickLanguage'), - // 语言展示用 endonym(各 UI 语言下一致、不翻译)→ 无需 titleEn + // Languages display by endonym (consistent across all UI languages, not translated) → no titleEn needed options: () => LANGUAGE_OPTIONS.map((o) => ({ id: o.code, @@ -75,7 +75,7 @@ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { id: 'switch-theme', ...cmd('commandPalette.cmdSwitchTheme'), optionsPlaceholder: t('commandPalette.pickTheme'), - // 主题用专名(GitHub Dark / Monokai…,不翻译);仅 'auto' 走 i18n(与设置页一致) + // Themes use proper names (GitHub Dark / Monokai…, not translated); only 'auto' goes through i18n (consistent with the settings page) options: () => EDITOR_THEME_OPTIONS.map((o) => ({ id: o.id, @@ -96,7 +96,7 @@ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { active: p.id === config.llm.active_id, run: () => switchModel(ctx, p.id), })); - // 末尾固定「添加模型…」入口:打开设置的「模型」分区新建预设(无预设时即唯一项) + // Fixed "add model…" entry at the end: opens the settings "model" section to create a new profile (the only item when there are no profiles) items.push({ id: '__add_model__', title: t('commandPalette.addModel'), @@ -108,7 +108,7 @@ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { }, { id: 'toggle-proxy', - // 切换型命令用单一文案(不随状态翻转);当前开关状态在设置页查看 + // Toggle commands use a single label (not flipped by state); check the current toggle state on the settings page ...cmd('commandPalette.cmdToggleProxy'), run: () => toggleProxy(ctx), }, @@ -125,7 +125,7 @@ export function buildSettingsCommands(ctx: CommandContext): RootCommand[] { { id: 'open-devtools', ...cmd('commandPalette.cmdOpenDevtools'), - // DevTools 惯例:mac ⌥⌘I / 其余 Ctrl+Shift+I(见 App 窗口级快捷键) + // DevTools convention: mac ⌥⌘I / others Ctrl+Shift+I (see App window-level shortcuts) shortcut: formatChord(ctx.platform, 'I', ctx.platform === 'darwin' ? { alt: true } : { shift: true }), run: () => { void invoke('app:openDevTools', undefined); diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts index c84c3524..6c07ed22 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts @@ -1,9 +1,9 @@ import type { Platform } from '@meebox/shared'; /** - * 把快捷键拆成**按键 token 数组**(一键一框、VS Code 风):macOS 用符号(`⌥`/`⇧`/`⌘`/`B`), - * 其余平台用文字(`Ctrl`/`Shift`/`Alt`/`B`)。仅用于命令面板右侧的提示展示;实际按键匹配在窗口级 - * 监听里另行判定(见 App 的快捷键 effect)。 + * Split a shortcut into a **key token array** (one box per key, VS Code style): macOS uses symbols (`⌥`/`⇧`/`⌘`/`B`), + * other platforms use text (`Ctrl`/`Shift`/`Alt`/`B`). Only for the hint display on the right side of the command palette; the actual key matching + * is judged separately in the window-level listener (see App's shortcut effect). */ export function formatChord( platform: Platform, diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts index 777b4a63..2f7d80ad 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts @@ -4,79 +4,79 @@ import type { SettingsCategory } from '../../settings'; import type { FilterKey } from '../../../layout/Sidebar'; /** - * 命令面板的执行上下文:当前配置 + 同步父级状态的钩子 + 打开设置面板 + 当前语言的 t。 - * 命令的「即时生效」复用设置页同一套原语(i18n.changeLanguage / editor-appearance store / config:* IPC), - * 不另起一套,保证与设置页行为一致。各领域命令构建器都接收它。 + * The command palette's execution context: current config + hooks to sync parent state + open settings panel + current-language t. + * The command's "instant effect" reuses the same primitives as the settings page (i18n.changeLanguage / editor-appearance store / config:* IPC), + * without spinning up a separate set, ensuring behavior consistent with the settings page. Every domain's command builder receives it. */ export interface CommandContext { - /** 运行平台:命令格式化快捷键提示(mac ⌘ / 其余 Ctrl)等用。 */ + /** Running platform: used by commands to format shortcut hints (mac ⌘ / others Ctrl), etc. */ platform: Platform; config: Config; - /** 当前选中 PR 的 localId(无选中为 null);上下文相关命令(如运行自动评审)据此裁剪 / 取目标。 */ + /** localId of the currently selected PR (null if none selected); context-relevant commands (such as running auto review) use it to trim / pick the target. */ selectedPrId: string | null; - /** 某 PR 是否有编排 Agent 运行中(重入保护用,调用时取实时态)。 */ + /** Whether a PR has an orchestration Agent running (for re-entry protection; reads the live state at call time). */ isPrRunning: (localId: string) => boolean; - /** 切换对话面板折叠(命令面板的「切换对话面板」用)。 */ + /** Toggle chat panel collapse (used by the command palette's "toggle chat panel"). */ toggleChatPanel: () => void; - /** 切换 PR 列表(侧栏)折叠(命令面板的「切换 PR 列表」用)。 */ + /** Toggle PR list (sidebar) collapse (used by the command palette's "toggle PR list"). */ togglePrList: () => void; - /** 当前 platform 能力支持的 PR 发现分类(空=该平台无分类,对应一级命令不提供)。 */ + /** PR discovery categories supported by the current platform's capabilities (empty = the platform has no categories, the corresponding top-level command is not provided). */ discoveryFilters: readonly PrDiscoveryFilter[]; - /** 跳到某发现分类(PR 域「查看 X」命令用,如「待我评审」);隐含切回「进行中」范围。 */ + /** Jump to a discovery category (used by PR-domain "view X" commands, such as "awaiting my review"); implicitly switches back to the "in progress" scope. */ setDiscoveryFilter: (filter: PrDiscoveryFilter) => void; - /** 切到「已关闭」(归档)范围(PR 域「查看已关闭」命令用)。 */ + /** Switch to the "closed" (archived) scope (used by the PR-domain "view closed" command). */ viewArchived: () => void; - /** 按 URL 打开当前平台的 PR(PR 域「打开 URL」自由文本命令用):定位本地或拉取存档后跳转,失败弹 toast。 */ + /** Open a PR of the current platform by URL (used by the PR-domain "open URL" free-text command): locate locally or fetch the archive then jump, popping a toast on failure. */ openPrByUrl: (url: string) => void | Promise<void>; - /** 可选的 PR 状态筛选项(待处理 / 全部 / 冲突 / 可合并等,已按平台门控)。 */ + /** Optional PR status filter items (pending / all / conflict / mergeable, etc., already gated by platform). */ prStatusFilters: ReadonlyArray<{ value: FilterKey; labelKey: string }>; - /** 设置 PR 状态筛选(PR 域「分类筛选」二级选项用)。 */ + /** Set the PR status filter (used by the PR-domain "category filter" second-level options). */ setPrStatusFilter: (filter: FilterKey) => void; patchConfig: (updater: (c: Config) => Config) => void; openSettings: (category?: SettingsCategory) => void; - /** 当前界面语言的翻译函数 */ + /** Translation function for the current UI language */ t: TFunction; - /** 固定英文(en-US)翻译函数:非英语界面下作次行展示,并恒参与检索(对齐 VS Code) */ + /** Fixed English (en-US) translation function: shown as a secondary line under a non-English UI, and always participates in search (aligning with VS Code) */ tEn: TFunction; } -/** 二级选项(叶子,直接执行)。`active` 标注当前生效项(打勾)。 */ +/** Second-level option (leaf, executes directly). `active` marks the currently effective item (checked). */ export interface CommandOption { id: string; title: string; - /** 英文名(缺省=title,即各语言一致的专名/数据项);非英语界面作次行 + 恒参与检索 */ + /** English name (default = title, i.e. a proper name / data item consistent across languages); shown as a secondary line under a non-English UI + always participates in search */ titleEn?: string; active?: boolean; run: () => void; } /** - * 顶层命令:要么直接执行(`run`),要么进入二级选项(`options`)。**最多两级**、不支持返回上级 - * (Esc 退出后重进,见 docs/arch/03-gui/02-command-palette)。`title` / `category` 已按当前界面语言本地化,供按当前语言搜索。 + * Top-level command: either executes directly (`run`), or enters second-level options (`options`). **At most two levels**, going back up is not supported + * (exit with Esc then re-enter, see docs/arch/03-gui/02-command-palette). `title` / `category` are already localized to the current UI language, for searching in the current language. */ export interface RootCommand { id: string; - /** 上下文门控(可选):返回 false 则该命令不出现在列表。缺省=恒可见。由注册表统一过滤,各领域只需声明。 */ + /** Context gating (optional): returning false hides this command from the list. Default = always visible. Filtered uniformly by the registry; each domain only needs to declare it. */ when?: () => boolean; title: string; - /** 英文标题:非英语界面作次行展示,并恒参与检索(对齐 VS Code 显示语言 + 英文检索) */ + /** English title: shown as a secondary line under a non-English UI, and always participates in search (aligning with VS Code display language + English search) */ titleEn: string; category: string; - /** 英文领域前缀:同 titleEn,用于次行展示与英文检索 */ + /** English domain prefix: same as titleEn, used for secondary-line display and English search */ categoryEn: string; - /** 快捷键按键 token 列表(一键一框,如 `['⌘','B']` / `['Ctrl','B']`),在命令项右侧展示;缺省=无 */ + /** Shortcut key token list (one box per key, e.g. `['⌘','B']` / `['Ctrl','B']`), shown on the right side of the command item; default = none */ shortcut?: string[]; - /** 进入二级后的输入框占位提示 */ + /** Input placeholder hint after entering the second level */ optionsPlaceholder?: string; - /** 进入二级后输入框左侧的简短前缀提示符(如「URL」);缺省回退到命令标题。 */ + /** Short prefix indicator to the left of the input after entering the second level (e.g. "URL"); default falls back to the command title. */ prefixLabel?: string; - /** 二级选项(惰性求值,读当前 config 标注 active);与 run / input 三选一 */ + /** Second-level options (lazily evaluated, reads current config to mark active); one of run / input / this three */ options?: () => CommandOption[]; /** - * 自由文本输入(二级):选中后二级层不是选项列表,而是把输入框转为接受任意文本,回车提交给 `run`。 - * 用于「按 URL 打开 PR」这类需用户粘贴 / 输入的命令。与 options / 顶层 run 互斥。 + * Free-text input (second level): once selected, the second level is not an option list but turns the input box into one that accepts arbitrary text, submitting to `run` on Enter. + * Used for commands like "open PR by URL" that require the user to paste / type. Mutually exclusive with options / top-level run. */ input?: { placeholder: string; run: (text: string) => void | Promise<void> }; - /** 叶子命令的执行;与 options / input 二选一 */ + /** Execution of a leaf command; one of options / input two */ run?: () => void; } diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/index.ts b/apps/desktop/src/renderer/src/components/features/command-palette/index.ts index fb9c9ad1..5927456a 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/index.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/index.ts @@ -1,3 +1,3 @@ -// features/command-palette 对外公共 API:标题栏命令面板组件。 +// features/command-palette public API: title-bar command palette component. export { CommandPalette } from './CommandPalette'; export type { CommandContext, RootCommand, CommandOption } from './commands'; diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/mru.ts b/apps/desktop/src/renderer/src/components/features/command-palette/mru.ts index b33f145e..4f3d592a 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/mru.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/mru.ts @@ -1,11 +1,11 @@ -// 命令面板 MRU(最近使用):仅存顶层命令 id 的小列表,用于「打开即预选上次用的命令」。 -// 纯本机 UI 顺手优化,存 localStorage(与主题 / 语言持久化同套,无 IPC);丢失无碍(下次重新积累)。 -// 存成小列表(非单值)以便将来升级到「最近使用置顶分组」时不必改存储格式。 +// Command palette MRU (most recently used): stores only a small list of top-level command ids, used to "preselect the last-used command on open". +// A purely local UI convenience optimization, stored in localStorage (same setup as theme / language persistence, no IPC); losing it is harmless (re-accumulated next time). +// Stored as a small list (not a single value) so that a future upgrade to "MRU pinned group" won't require changing the storage format. const KEY = 'meebox.commandPalette.mru'; const CAP = 8; -/** 读 MRU(最近在前);缺失 / 损坏 / localStorage 不可用一律当空。 */ +/** Read the MRU (most recent first); missing / corrupt / localStorage unavailable are all treated as empty. */ export function readMru(): string[] { try { const raw = localStorage.getItem(KEY); @@ -17,12 +17,12 @@ export function readMru(): string[] { } } -/** 记一次使用:把 id 提到最前、去重、封顶后写回。localStorage 不可用时静默跳过。 */ +/** Record a use: move the id to the front, dedupe, cap, then write back. Silently skips when localStorage is unavailable. */ export function pushMru(id: string): void { try { const next = [id, ...readMru().filter((x) => x !== id)].slice(0, CAP); localStorage.setItem(KEY, JSON.stringify(next)); } catch { - // 仅为顺手优化,写失败忽略 + // Just a convenience optimization; ignore write failures } } diff --git a/apps/desktop/src/renderer/src/components/features/onboarding/OnboardingWizard.tsx b/apps/desktop/src/renderer/src/components/features/onboarding/OnboardingWizard.tsx index 6f3495d9..3d753cb2 100644 --- a/apps/desktop/src/renderer/src/components/features/onboarding/OnboardingWizard.tsx +++ b/apps/desktop/src/renderer/src/components/features/onboarding/OnboardingWizard.tsx @@ -15,27 +15,27 @@ import { PlatformStep } from './steps/PlatformStep'; import { LlmStep } from './steps/LlmStep'; import { DoneStep } from './steps/DoneStep'; -/** 向导收集到的配置,交由 App 落盘(config:setConnections 等)后切入主界面 */ +/** Config collected by the wizard, handed to App to persist (config:setConnections etc.) before switching into the main UI */ export interface OnboardingResult { connection: ConnEntry; - /** 用户填了并通过校验的 LLM 预设;跳过时为 null */ + /** LLM profile the user filled in and that passed validation; null when skipped */ llm: LlmProfile | null; - /** 缓存目录原始输入(含 `~`);App 与初值比较后决定是否 config:setReposDir */ + /** Raw cache directory input (may contain `~`); App compares against the initial value to decide whether to config:setReposDir */ reposDir: string; } interface OnboardingWizardProps { - /** 唯一性校验用(首启通常为空) */ + /** For uniqueness validation (usually empty on first launch) */ existingLlmProfiles: LlmProfile[]; - /** 缓存目录初值(config.workspace.repos_dir,未展开的 `~/...` 形态) */ + /** Initial cache directory (config.workspace.repos_dir, unexpanded `~/...` form) */ initialReposDir: string; - /** UI 语言初值(config.language 原始值,空串=自动);欢迎页据此回显,空则按 OS 偏好 */ + /** Initial UI language (config.language raw value, empty string = auto); the welcome page echoes it back, falling back to OS preference when empty */ initialLanguage: string; - /** 全部配置完成 → App 落盘 + 切主界面。reject 时向导展示错误允许重试 */ + /** All config done → App persists + switches to main UI. On reject the wizard shows the error and allows a retry */ onComplete: (result: OnboardingResult) => Promise<void>; } -// 步骤:欢迎 → 平台(必填)→ LLM(可跳过)→ 完成 +// Steps: welcome → platform (required) → LLM (skippable) → done const STEP_KEYS = [ 'onboarding.stepWelcome', 'onboarding.stepPlatform', @@ -52,8 +52,8 @@ export function OnboardingWizard({ const { t } = useTranslation(); const [step, setStep] = useState(0); - // 界面语言:即时生效(写盘 + 渲染层切换)。初值取当前生效语言——无配置时按 OS 偏好匹配。 - // 选择项放在欢迎页底部 nav(复用其分割线),仅 step 0 显示。 + // UI language: takes effect immediately (persist + renderer switch). Initial value is the currently effective language — falls back to matching OS preference when unconfigured. + // The picker sits in the welcome page's bottom nav (reusing its divider), shown only on step 0. const [language, setLanguage] = useState<SupportedLanguage>(() => resolveUiLanguage(initialLanguage), ); @@ -63,7 +63,7 @@ export function OnboardingWizard({ void i18n.changeLanguage(next); persistLanguage(next); void invoke('config:setLanguage', { language: next }).catch(() => { - /* 写盘失败不阻断向导:渲染层已切、localStorage 已存,完成向导后随整体落盘兜底 */ + /* A persist failure does not block the wizard: the renderer already switched, localStorage is already stored, and the overall persist on wizard completion is the fallback */ }); }; @@ -87,7 +87,7 @@ export function OnboardingWizard({ api_key: '', })); const [llmValid, setLlmValid] = useState(false); - // null = 还没决定;true = 带 LLM 进入;false = 跳过。Done 页据此显示摘要 + // null = not yet decided; true = enter with LLM; false = skip. The Done page shows the summary based on this const [includeLlm, setIncludeLlm] = useState<boolean | null>(null); const [submitting, setSubmitting] = useState(false); @@ -105,7 +105,7 @@ export function OnboardingWizard({ llm: includeLlm ? llmDraft : null, reposDir, }); - // 成功后由 App 卸载本组件,无需复位本地状态 + // On success App unmounts this component, so no need to reset local state } catch (e) { setSubmitError(e instanceof Error ? e.message : String(e)); setSubmitting(false); @@ -161,8 +161,8 @@ export function OnboardingWizard({ </button> )} </div> - {/* 欢迎页:语言选择放在底部 nav(复用其分割线),居于两端(空)之间 → 居中显示。 - 选项用各语言自身 endonym,不随 UI 翻译;选择即时生效。 */} + {/* Welcome page: the language picker sits in the bottom nav (reusing its divider), between the two (empty) ends → centered. + Options use each language's own endonym, not the UI translation; selection takes effect immediately. */} {step === 0 && ( <div className="onboarding-nav-language"> <span className="muted">{t('onboarding.languageLabel')}</span> diff --git a/apps/desktop/src/renderer/src/components/features/onboarding/index.ts b/apps/desktop/src/renderer/src/components/features/onboarding/index.ts index aee3ed5b..a14d8faf 100644 --- a/apps/desktop/src/renderer/src/components/features/onboarding/index.ts +++ b/apps/desktop/src/renderer/src/components/features/onboarding/index.ts @@ -1,3 +1,3 @@ -// features/onboarding 对外公共 API:首启向导组件 + 其收集结果类型。 +// features/onboarding public API: onboarding wizard component + its collected result type. export { OnboardingWizard } from './OnboardingWizard'; export type { OnboardingResult } from './OnboardingWizard'; diff --git a/apps/desktop/src/renderer/src/components/features/onboarding/steps/LlmStep.tsx b/apps/desktop/src/renderer/src/components/features/onboarding/steps/LlmStep.tsx index cb8bbfdc..b592a646 100644 --- a/apps/desktop/src/renderer/src/components/features/onboarding/steps/LlmStep.tsx +++ b/apps/desktop/src/renderer/src/components/features/onboarding/steps/LlmStep.tsx @@ -16,7 +16,7 @@ export function LlmStep({ onValidityChange: (valid: boolean) => void; }) { const { t } = useTranslation(); - // 两阶段:先选 provider(居中滚动列表)→ 选定后列表收到左侧、右侧展开配置 + // Two phases: first pick a provider (centered scrolling list) → once chosen the list collapses to the left and the config expands on the right const [chosen, setChosen] = useState(false); const pick = (provider: LlmProfile['provider']): void => { onChange({ ...draft, provider }); @@ -28,9 +28,9 @@ export function LlmStep({ <p className="muted onboarding-step-sub">{t('onboarding.llmSub')}</p> {!chosen ? ( - // 阶段一:居中的 provider 选择列表(滚动) + // Phase one: centered provider pick list (scrolling) <div className="onboarding-provider-pick"> - {/* 阶段一沿用中性配置选择器视觉,但每项尾随「›」提示可进入、且不预选高亮 */} + {/* Phase one reuses the neutral config picker visuals, but each item trails a "›" hinting it's enterable and has no pre-selected highlight */} <div className="config-pick-list" role="radiogroup" @@ -58,7 +58,7 @@ export function LlmStep({ </div> </div> ) : ( - // 阶段二:左侧列表(图标左移)+ 右侧配置 + // Phase two: left list (icons shifted left) + right config <div className="onboarding-llm-grid"> <LlmProviderPicker value={draft.provider} diff --git a/apps/desktop/src/renderer/src/components/features/onboarding/steps/PlatformStep.tsx b/apps/desktop/src/renderer/src/components/features/onboarding/steps/PlatformStep.tsx index 414be2f7..76170b6e 100644 --- a/apps/desktop/src/renderer/src/components/features/onboarding/steps/PlatformStep.tsx +++ b/apps/desktop/src/renderer/src/components/features/onboarding/steps/PlatformStep.tsx @@ -24,14 +24,14 @@ export function PlatformStep({ <h2 className="onboarding-step-title">{t('onboarding.platformTitle')}</h2> <p className="muted onboarding-step-sub">{t('onboarding.platformSub')}</p> <div className="config-pick-grid"> - {/* 左:平台方案选择 */} + {/* Left: platform choice */} <PlatformPicker value={connDraft.kind} onChange={(kind) => onConnChange({ ...connDraft, kind })} ariaLabel={t('onboarding.platformGroupAria')} /> - {/* 右:连接表单 + 折叠缓存目录 */} + {/* Right: connection form + collapsible cache directory */} <div className="config-pick-form"> <ConnectionForm draft={connDraft} onChange={onConnChange} autoFocus={false} /> diff --git a/apps/desktop/src/renderer/src/components/features/onboarding/steps/WelcomeStep.tsx b/apps/desktop/src/renderer/src/components/features/onboarding/steps/WelcomeStep.tsx index c9296c07..9621a490 100644 --- a/apps/desktop/src/renderer/src/components/features/onboarding/steps/WelcomeStep.tsx +++ b/apps/desktop/src/renderer/src/components/features/onboarding/steps/WelcomeStep.tsx @@ -5,8 +5,8 @@ import { PullRequestIcon } from '../../../common'; export function WelcomeStep({ onStart }: { onStart: () => void }) { const { t } = useTranslation(); - // 隐藏后门:连续快速点击 logo 7 次打开 DevTools(每次间隔 > 800ms 则计数清零)。 - // 首启向导下没有菜单 / 状态栏入口,给开发排障留一个不显眼的手势。 + // Hidden backdoor: 7 rapid clicks on the logo open DevTools (an interval > 800ms between clicks resets the count). + // The onboarding wizard has no menu / status bar entry, so this leaves an unobtrusive gesture for dev troubleshooting. const tapRef = useRef<{ count: number; last: number }>({ count: 0, last: 0 }); const onLogoTap = (): void => { const now = performance.now(); diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrEmpty.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrEmpty.tsx index 3824bb74..8466d46c 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrEmpty.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrEmpty.tsx @@ -1,8 +1,9 @@ import { useTranslation } from 'react-i18next'; /** - * PR 工作区空态:未选中 PR 时的主区占位。按是否已配置连接给不同引导 - * (有连接 → 提示去左侧选 PR;无连接 → 提示去设置加连接)。 + * PR workspace empty state: main-area placeholder when no PR is selected. Shows + * different guidance based on whether a connection is configured (has connection → + * prompt to pick a PR on the left; no connection → prompt to add one in settings). */ export function PrEmpty({ hasConnections }: { hasConnections: boolean }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx index ec92ddae..4d24d41c 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx @@ -10,8 +10,8 @@ import { ApproveIcon, GlobeIcon, NeedsWorkIcon, PullRequestIcon } from '../../co import { ReviewerStack } from './ReviewerStack'; /** - * PR 详情头:标题 / 元信息 + 动作区(浏览器打开 · 提交评论 N · 合并 · 通过 / 需修改)。 - * 审批按钮按平台能力位降级;自己作者的 PR 不能审批(灰显 + 原因)。 + * PR detail header: title / meta + action area (open in browser · publish comments N · merge · approve / needs work). + * Approval buttons degrade by platform capability bits; you cannot approve your own PR (disabled + reason). */ export function PrHeader({ pr, @@ -31,21 +31,21 @@ export function PrHeader({ merging: boolean; onMerge: () => void; onSetStatus: (status: LocalPrStatus) => void; - /** 隐藏 PR 生命周期操作(合并 + 评审决断):已关闭范围恒置。 */ + /** Hide PR lifecycle actions (merge + review decision): always set for the closed scope. */ hideLifecycle?: boolean; - /** 内容只读(decline / 不可参与):隐藏「提交评论 (N)」发布入口。 */ + /** Content read-only (declined / cannot participate): hides the "publish comments (N)" entry. */ readOnly?: boolean; publishableCount: number; onPublish: () => void; }) { const { t } = useTranslation(); - // 能力位降级:reviewStatuses 决定审批按钮显隐;capabilities undefined(旧数据/无连接)时不降级。 + // Capability-bit degradation: reviewStatuses drives approval-button visibility; no degradation when capabilities is undefined (old data / no connection). const reviewAllowed = (s: ReviewerStatus): boolean => !capabilities || capabilities.reviewStatuses.includes(s); const isOwnPr = !!currentUserName && pr.author.name === currentUserName; const ownPrReason = isOwnPr ? t('mainPane.ownPrReason') : undefined; - // 「我的评审」:取当前用户的 reviewer 条目(头像),角标状态用本地决断 localStatus(审批按钮即时更新, - // 比远端 reviewer.status 更跟手)。自己作者的 PR 不可评审 → 不展示。 + // "My review": take the current user's reviewer entry (avatar); the badge status uses the local decision localStatus + // (approval buttons update instantly, more responsive than the remote reviewer.status). Your own PR is not reviewable → not shown. const selfReviewer = !isOwnPr && currentUserName ? pr.reviewers.find((r) => r.name === currentUserName) : undefined; const selfReviewStatus: ReviewerStatus = @@ -88,7 +88,7 @@ export function PrHeader({ </span> </div> </div> - {/* reviewer 头像栈:标题 + 元信息 band 右侧、按钮行之上,垂直居中 */} + {/* reviewer avatar stack: right of the title + meta band, above the button row, vertically centered */} <ReviewerStack reviewers={pr.reviewers} connectionId={pr.connectionId} @@ -106,10 +106,10 @@ export function PrHeader({ > <GlobeIcon /> {t('mainPane.openInBrowser')} </a> - {/* approve / needs work:当前状态 = 高亮;点已高亮的回退到 pending(撤销远端标记)。 - 「提交评论 (N)」放在决断按钮左边 — 评审动作分两步:先发评论 (左),再下决断 (右)。 */} + {/* approve / needs work: current status = highlighted; clicking an already-highlighted one falls back to pending (revokes the remote mark). + "Publish comments (N)" sits to the left of the decision buttons — reviewing is two steps: post comments first (left), then make the decision (right). */} <div className="pr-header-actions-right"> - {/* "提交评论" 仅在有待发布草稿时渲染:N=0 时整按钮隐藏,减少 header 视觉噪音。 */} + {/* "Publish comments" renders only when there are drafts pending publish: at N=0 the whole button hides, reducing header visual noise. */} {!readOnly && publishableCount > 0 && ( <button type="button" @@ -120,7 +120,7 @@ export function PrHeader({ {t('mainPane.publishComments', { n: publishableCount })} </button> )} - {/* 合并按钮:仅在服务端判定可合并 (canMerge) 时出现。点击直接合并(无二次确认)。只读隐藏。 */} + {/* Merge button: appears only when the server deems it mergeable (canMerge). Clicking merges directly (no confirmation). Hidden when read-only. */} {!hideLifecycle && pr.mergeStatus?.canMerge && ( <button type="button" diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx index 956f3a19..174e4ca5 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrItem.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import type { AgentRecommendationVerdict, StoredPullRequest } from '@meebox/shared'; import { Avatar, PersonIcon, PullRequestIcon, StarIcon } from '../../common'; -/** 评审建议 verdict → 复用 chatPane.agent.* 文案(不另加 i18n)。 */ +/** Review recommendation verdict → reuse chatPane.agent.* strings (no extra i18n). */ const VERDICT_TITLE: Record<string, string> = { approve: 'chatPane.agent.verdictApprove', needs_work: 'chatPane.agent.verdictNeedsWork', @@ -13,9 +13,9 @@ interface PrItemProps { pr: StoredPullRequest; selected: boolean; onClick: () => void; - /** 评审建议倾向(手动 / AutoPilot 评审写入的台账,一视同仁);无则不显示 ★ 徽标。 */ + /** Review recommendation leaning (ledger written by manual / AutoPilot review, treated alike); absent → no ★ badge. */ reviewVerdict?: AgentRecommendationVerdict | null; - /** 该 PR 当前有在执行的 agent 任务(工具 run 在跑 / 排队):同位置显示蓝色「执行中」动画指示。 */ + /** This PR currently has a running agent task (tool run running / queued): shows a blue "executing" animated indicator in the same spot. */ executing?: boolean; } @@ -23,11 +23,11 @@ export function PrItem({ pr, selected, onClick, reviewVerdict, executing }: PrIt const { t } = useTranslation(); const approvedCount = pr.reviewers.filter((r) => r.status === 'approved').length; const needsWorkCount = pr.reviewers.filter((r) => r.status === 'needsWork').length; - // 「@我 / 回复我」未读条数:>0 时标题左的未读圆点替换为中性数字 chip(封顶 10 → 显示「10+」); - // 仅新到达 / 新 commit 的未读(计数为 0)仍显示圆点。二者在该槽位互斥。 + // "@me / replied to me" unread count: when >0, the unread dot left of the title is replaced by a neutral number chip (capped at 10 → shows "10+"); + // unread from only new arrivals / new commits (count 0) still shows the dot. The two are mutually exclusive in that slot. const mentionCount = pr.unreadMentionCount ?? 0; - // 服务端判定可直接合并:列表里用分支合并图标 chip 标注(纯状态,无数值)。 - // 可选链兜底:升级前持久化的 meta.json 可能尚无 mergeStatus,下一轮 poll 会补齐 + // Server deems it directly mergeable: marked in the list with a branch-merge icon chip (pure status, no number). + // Optional-chaining fallback: meta.json persisted before the upgrade may not yet have mergeStatus; the next poll fills it in const canMerge = pr.mergeStatus?.canMerge ?? false; return ( <div @@ -83,8 +83,8 @@ export function PrItem({ pr, selected, onClick, reviewVerdict, executing }: PrIt reviewVerdict || executing) && ( <span className="pr-item-review-chips"> - {/* 执行中优先占位(同 ★ 位置):复用运行卡片同款 .spinner(蓝色环旋转、中心对称), - 裸图标无 chip 外框,表示该 PR 有在跑的 agent 任务。 */} + {/* Executing takes priority in the slot (same ★ position): reuses the run card's .spinner (blue rotating ring, centrally symmetric), + a bare icon with no chip frame, indicating this PR has a running agent task. */} {executing && ( <span className="spinner pr-item-spinner" diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx index e230efd0..d2e27763 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx @@ -13,8 +13,8 @@ import { useDraftsForPr } from '../../../stores/drafts-store'; import { PaneLoading } from '../../common'; import { ActivityPanel } from './tabs/activity/ActivityPanel'; import { CommitsPanel } from './tabs/CommitsPanel'; -// Monaco 编辑器(~10MB)懒加载:只有真正切到 Diff tab 才拉取 DiffView chunk, -// 不阻塞窗口首帧 / PR 列表 / 首启向导。 +// Monaco editor (~10MB) lazy-loaded: the DiffView chunk is fetched only when actually switching to the Diff tab, +// so it doesn't block the window's first frame / PR list / first-run wizard. const DiffView = lazy(() => import('./tabs/diff/DiffView').then((m) => ({ default: m.DiffView }))); import type { PendingCommitView } from './tabs/diff/DiffView'; import { DraftsPanel } from './tabs/drafts/DraftsPanel'; @@ -30,9 +30,9 @@ export interface PrPanelProps { merging?: boolean; capabilities?: PlatformCapabilities; currentUserName?: string | null; - /** 隐藏 PR 生命周期操作(合并 / 审批):已关闭范围恒置(退场 PR 不再做评审决断 / 合并)。 */ + /** Hide PR lifecycle actions (merge / approval): always set for the closed scope (departed PRs no longer take review decisions / merges). */ hideLifecycle?: boolean; - /** 内容只读(decline / 不可参与):隐藏评论 / 草稿等写入入口,仅供浏览。合并 / 仍开放的归档 PR 为 false。 */ + /** Content read-only (declined / cannot participate): hides comment / draft and other write entries, browse-only. False for merged / still-open archived PRs. */ readOnly?: boolean; pendingDiffNav?: { runId?: string; @@ -45,17 +45,17 @@ export interface PrPanelProps { findingId?: string; anchor: { path: string; startLine: number; endLine: number }; }) => void; - /** 外部请求切到指定标签(如通知点击 summary 评论 → 'activity');消费后经 onPendingTabConsumed 清空。 */ + /** External request to switch to a given tab (e.g. clicking a summary comment notification → 'activity'); cleared via onPendingTabConsumed after consumption. */ pendingTab?: PrTab | null; onPendingTabConsumed?: () => void; - /** Diff 视图当前查看的单 commit 范围变化上报(→ App → ChatPane 隐式范围);全部变更 / root commit 为 null。 */ + /** Reports changes to the single-commit scope currently viewed in the Diff view (→ App → ChatPane implicit scope); null for all changes / root commit. */ onViewCommitScopeChange?: (scope: ReviewRunCommitScope | null) => void; } /** - * PR 评审工作区:头部(标题 / 动作)+ tab 栏 + tab 内容(diff / 评论 / 草稿 / 提交 / 信息)+ - * 发布评论弹窗。承载 PR 详情相关的全部状态(当前 tab / diff 视图选项 / 评论 + 提交计数 / - * 草稿池 / 发布弹窗),由 layout/MainPane 在选中 PR 时挂载。 + * PR review workspace: header (title / actions) + tab bar + tab content (diff / comments / drafts / commits / info) + + * publish-comments modal. Holds all PR-detail-related state (current tab / diff view options / comment + commit counts / + * draft pool / publish modal), mounted by layout/MainPane when a PR is selected. */ export function PrPanel({ pr, @@ -75,9 +75,9 @@ export function PrPanel({ }: PrPanelProps) { const { t } = useTranslation(); const [tab, setTab] = useState<PrTab>('diff'); - // 活动标签页「新建评论」编辑框开关(由标签栏「评论」按钮触发,编辑框出现在时间线顶部) + // Activity tab "new comment" editor toggle (triggered by the tab bar's "comment" button; the editor appears at the top of the timeline) const [composingComment, setComposingComment] = useState(false); - // 「查看特定 commit」请求:提交 / 活动标签页点击某 commit → 切到 Diff tab 本地渲染该 commit 变更 + // "View a specific commit" request: clicking a commit in the commits / activity tab → switch to Diff tab and locally render that commit's changes const [pendingCommitView, setPendingCommitView] = useState<PendingCommitView | null>(null); const viewCommit = (commit: PrCommit): void => { setPendingCommitView({ @@ -88,11 +88,11 @@ export function PrPanel({ }); setTab('diff'); }; - // 收到跳转请求 → 强制切到 Diff tab,DiffView 自己负责消费 anchor + // On receiving a jump request → force-switch to the Diff tab; DiffView consumes the anchor itself useEffect(() => { if (pendingDiffNav) setTab('diff'); }, [pendingDiffNav]); - // 外部请求切标签(通知点击 summary 评论 → 活动标签):切换后即清空请求。 + // External tab-switch request (clicking a summary comment notification → activity tab): clear the request right after switching. useEffect(() => { if (!pendingTab) return; setTab(pendingTab); @@ -102,17 +102,17 @@ export function PrPanel({ const v = localStorage.getItem('meebox.diffMode'); return v === null ? true : v === 'side-by-side'; }); - // Blame 默认关:每次启动都得手动开(blame fetch 可能慢/失败,不希望用户进来就被错误 banner 干扰) + // Blame off by default: must be turned on manually each launch (blame fetch may be slow/fail; we don't want users greeted by an error banner on entry) const [showBlame, setShowBlame] = useState<boolean>(false); - // 空白字符可视化:默认关(大多数 review 不关心空格 / tab;强调时再开) + // Whitespace visualization: off by default (most reviews don't care about spaces / tabs; turn on when it matters) const [showWhitespace, setShowWhitespace] = useState<boolean>( () => localStorage.getItem('meebox.showWhitespace') === '1', ); useEffect(() => { localStorage.setItem('meebox.showWhitespace', showWhitespace ? '1' : '0'); }, [showWhitespace]); - // 评论 / commits 数 chip:PR 切换时各拉一次,cancelled token 防 race。deps 含 pr.updatedAt: - // 远端变更后 poller 拉到 → store 更新 → 这里重跑刷新计数,app 一直开着也能跟上远端变动。 + // Comment / commit count chips: each fetched once on PR switch, cancelled token guards against races. deps include pr.updatedAt: + // after a remote change the poller pulls it → store updates → this reruns to refresh counts, so counts keep up with remote changes even with the app left open. const [commentCount, setCommentCount] = useState<number | null>(null); const [commitCount, setCommitCount] = useState<number | null>(null); const prLocalId = pr.localId; @@ -124,7 +124,7 @@ export function PrPanel({ void (async () => { try { const [cm, cc] = await Promise.all([ - // force:true 跳过 cache stale 比对 — 本地 PR.updatedAt 可能滞后于远端(poller 周期性拉)。 + // force:true skips the cache stale comparison — the local PR.updatedAt may lag the remote (poller pulls periodically). invoke('diff:listComments', { localId: prLocalId, force: true }), invoke('diff:commitCount', { localId: prLocalId }), ]); @@ -132,7 +132,7 @@ export function PrPanel({ setCommentCount(cm.length); setCommitCount(cc?.count ?? null); } catch { - // 静默:角标不显示数字,不该挡用户视线 + // Silent: the badge shows no number, shouldn't block the user's view } })(); return () => { @@ -142,15 +142,15 @@ export function PrPanel({ useEffect(() => { localStorage.setItem('meebox.diffMode', renderSideBySide ? 'side-by-side' : 'unified'); }, [renderSideBySide]); - // 清掉历史遗留的 showBlame 持久化值;新逻辑不再读写它 + // Clear the legacy persisted showBlame value; the new logic no longer reads/writes it useEffect(() => { if (localStorage.getItem('meebox.showBlame') !== null) { localStorage.removeItem('meebox.showBlame'); } }, []); - // M4 草稿池 → "提交评论 (N)" 按钮的 N。pending + edited 才算 publishable; - // rejected(用户决断不发)/ posted(远端已发)都排除 + // M4 draft pool → the N in the "publish comments (N)" button. Only pending + edited count as publishable; + // rejected (user decided not to send) / posted (already sent remotely) are both excluded const drafts = useDraftsForPr(prLocalId); const publishableCount = useMemo( () => @@ -160,10 +160,10 @@ export function PrPanel({ ), [drafts], ); - // 草稿 tab 显示条件用总数(任何 status 都算);只有从来没创建过草稿的 PR 才完全隐藏 tab + // The drafts tab's visibility uses the total count (any status counts); only PRs that never created a draft hide the tab entirely const totalDraftCount = (drafts ?? []).length; const [publishModalOpen, setPublishModalOpen] = useState(false); - // 兜底:停在 'drafts' tab 但草稿全清空 → 切回 'diff' 避免显示孤儿空白内容区 + // Fallback: sitting on the 'drafts' tab but all drafts cleared → switch back to 'diff' to avoid showing an orphan blank content area useEffect(() => { if (tab === 'drafts' && totalDraftCount === 0) setTab('diff'); }, [tab, totalDraftCount]); @@ -200,8 +200,8 @@ export function PrPanel({ onSetRenderSideBySide={setRenderSideBySide} /> <div className="pr-tab-content"> - {/* keep-alive:各 tab 首访才挂载、之后保活仅 CSS 显隐(见 KeepAliveTab)。 - 切走再切回瞬时、无重拉、内嵌 Monaco / 滚动位置 / 展开态全部保留,消除切换抖动。 */} + {/* keep-alive: each tab mounts only on first visit, then stays alive with only CSS show/hide (see KeepAliveTab). + Switching away and back is instant, no refetch, embedded Monaco / scroll position / expanded state all preserved, eliminating switch jitter. */} <KeepAliveTab active={tab === 'diff'}> <Suspense fallback={<PaneLoading label={t('mainPane.loadingEditor')} />}> <DiffView @@ -267,8 +267,8 @@ export function PrPanel({ drafts={drafts ?? []} onClose={() => setPublishModalOpen(false)} onJumpToAnchor={(draftId) => { - // 点 anchor → 关 modal + 转 pendingDiffNav 上抛给 App。runId/findingId 不带 → - // DiffView 仅 navigate 不进 edit(用户想看代码上下文,不一定是要改草稿)。 + // Click anchor → close modal + turn into pendingDiffNav bubbled up to App. runId/findingId omitted → + // DiffView only navigates, doesn't enter edit (the user wants to see the code context, not necessarily edit the draft). const d = (drafts ?? []).find((x) => x.id === draftId); if (!d) return; setPublishModalOpen(false); @@ -287,13 +287,13 @@ export function PrPanel({ } /** - * tab 内容保活容器:首次 active 才挂载(保留 DiffView 等的懒加载优势),此后**不卸载**, - * 仅靠 CSS `display` 显隐。切走再切回瞬时、无重拉、内嵌 Monaco / 滚动位置 / 展开态全保留 → - * 消除切换抖动。隐藏期 Monaco 容器尺寸为 0,再显示需重排——由编辑器侧 `automaticLayout` - * 自动处理(见 DiffView / InlineCodeContext)。 + * Tab content keep-alive container: mounts only on first active (preserving DiffView's etc. lazy-load benefit), thereafter **never unmounts**, + * relying only on CSS `display` to show/hide. Switching away and back is instant, no refetch, embedded Monaco / scroll position / expanded state all preserved → + * eliminates switch jitter. While hidden the Monaco container size is 0 and reshowing needs a reflow — handled automatically by the editor-side `automaticLayout` + * (see DiffView / InlineCodeContext). */ function KeepAliveTab({ active, children }: { active: boolean; children: ReactNode }) { - // 「一旦 active 过就保活」latch:ref 在 render 期写入是幂等闩锁,与本仓 stablePr 同模式。 + // "Stay alive once active" latch: writing the ref during render is an idempotent latch, same pattern as this repo's stablePr. const mounted = useRef(false); if (active) mounted.current = true; if (!mounted.current) return null; diff --git a/apps/desktop/src/renderer/src/components/features/pr/ReviewerStack.tsx b/apps/desktop/src/renderer/src/components/features/pr/ReviewerStack.tsx index 173dbbd3..da3d29cd 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/ReviewerStack.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/ReviewerStack.tsx @@ -7,7 +7,7 @@ import { REVIEWER_STATUS_META, ReviewerBadgeGlyph } from './reviewer-status'; const STACK_AVATAR_SIZE = 32; -/** 单个头像 + 右上角决断角标(approved 绿勾 / needsWork 琥珀叹号;待评审无角标)。栈内项与「我」项共用。 */ +/** A single avatar + top-right decision badge (approved green check / needsWork amber exclamation; no badge when pending review). Shared by stack items and the "me" item. */ function StackAvatar({ r, connectionId }: { r: Reviewer; connectionId: string }) { return ( <> @@ -26,19 +26,19 @@ function StackAvatar({ r, connectionId }: { r: Reviewer; connectionId: string }) </> ); } -// 总数 ≤ MAX_VISIBLE 全显;超出则显示 (MAX_VISIBLE-1) 个头像 + 一个「+n」溢出项 +// Total ≤ MAX_VISIBLE shows all; beyond that shows (MAX_VISIBLE-1) avatars + one "+n" overflow item const MAX_VISIBLE = 4; -// 排序优先级:needsWork(待处理,最该被看到)> approved > 待评审 +// Sort priority: needsWork (pending, most in need of attention) > approved > pending review const STATUS_RANK: Record<ReviewerStatus, number> = { needsWork: 0, approved: 1, unapproved: 2 }; /** - * PR 头部右上角的 reviewer 头像栈(Bitbucket 风格,略重叠): - * - 过滤掉当前用户自己;needsWork > approved > 待评审 优先排序,同级按 displayName 稳定排序 - * - approved 右上角绿勾、needsWork 右上角琥珀叹号角标(待评审无角标) - * - 至多展示 4 个;超出显示 3 个 + 「+n」,点击「+n」下拉展示其余 reviewer(头像 + 名 + 决断 chip) - * - 直接展示的头像 hover 出名字(走 Avatar 自带 title) - * - `self`(当前用户的「我的评审」)非空时,在栈右侧分隔展示其头像 + 当前评审角标 - * 栈内无他人且无 self 则不渲染。 + * Reviewer avatar stack at the top-right of the PR header (Bitbucket style, slightly overlapping): + * - Filters out the current user; sorted by priority needsWork > approved > pending review, same-rank by stable displayName sort + * - approved gets a top-right green check, needsWork a top-right amber exclamation badge (no badge for pending review) + * - Shows at most 4; beyond that shows 3 + "+n", clicking "+n" drops down the remaining reviewers (avatar + name + decision chip) + * - Directly shown avatars reveal the name on hover (via Avatar's built-in title) + * - When `self` (the current user's "my review") is non-null, shows its avatar + current review badge separated on the stack's right + * Not rendered when the stack has no others and no self. */ export function ReviewerStack({ reviewers, @@ -49,7 +49,7 @@ export function ReviewerStack({ reviewers: Reviewer[]; connectionId: string; currentUserName?: string | null; - /** 当前用户的「我的评审」:头像 + 当前评审角标,分隔展示在他人头像栈右侧。 */ + /** The current user's "my review": avatar + current review badge, shown separated to the right of the others' avatar stack. */ self?: Reviewer | null; }) { const { t } = useTranslation(); @@ -58,7 +58,7 @@ export function ReviewerStack({ const menuRef = useRef<HTMLDivElement>(null); const [pos, setPos] = useState<{ top: number; right: number } | null>(null); - // 下拉定位 + 外点关闭 + 窗口变化重算(与 DiffScopeSelect 同套,fixed + portal 避免被裁切) + // Dropdown positioning + outside-click close + recompute on window changes (same setup as DiffScopeSelect, fixed + portal to avoid clipping) useEffect(() => { if (!open) return; const compute = (): void => { @@ -96,7 +96,7 @@ export function ReviewerStack({ const overflow = sorted.length > MAX_VISIBLE; const visible = overflow ? sorted.slice(0, MAX_VISIBLE - 1) : sorted; const hidden = overflow ? sorted.slice(MAX_VISIBLE - 1) : []; - // 重叠下让左侧头像压住右侧(z 递减),使各自的右上角角标不被相邻头像遮挡 + // Under overlap, let the left avatar sit over the right (decreasing z), so each one's top-right badge isn't occluded by the adjacent avatar const topZ = sorted.length + 1; return ( @@ -125,7 +125,7 @@ export function ReviewerStack({ +{hidden.length} </button> )} - {/* 「我的评审」:与他人头像栈分隔(左侧细分隔线 + 间距),展示当前用户头像 + 当前评审角标。 */} + {/* "My review": separated from the others' avatar stack (thin divider line + spacing on the left), shows the current user's avatar + current review badge. */} {self && ( <span className="reviewer-stack-item reviewer-stack-self" diff --git a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts index d8a158b8..1914c086 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts @@ -5,16 +5,17 @@ import { invoke } from '../../../../api'; import { formatBackendError } from '../../../../errors'; /** - * PR 列表生命周期与详情动作(领域内聚):列表 state + 选中态、读缓存 reload / 拉远端 refresh、 - * 审批状态决断、合并。不感知 boot/连接(选中连接的反查由 App 持 boot 派生;启动 / 焦点刷新由 - * useBootstrap 经 reloadPrs 驱动),仅依赖 notifyError 弹操作级错误。 + * PR list lifecycle and detail actions (domain-cohesive): list state + selection, cached reload / + * remote refresh, approval status decisions, merge. Unaware of boot/connection (the selected + * connection lookup is derived by App from boot; startup / focus refresh is driven by + * useBootstrap via reloadPrs), only depends on notifyError to surface operation-level errors. */ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => void }) { const { t } = useTranslation(); const [prs, setPrs] = useState<StoredPullRequest[]>([]); const [selectedId, setSelectedId] = useState<string | null>(null); const [refreshing, setRefreshing] = useState(false); - // 合并进行中:GitHub 合并可能较慢(异步算 mergeable),按钮置等待态并防重复点击。 + // Merge in progress: GitHub merge can be slow (mergeable is computed asynchronously); set the button to a waiting state and prevent repeated clicks. const [merging, setMerging] = useState(false); const reloadPrs = useCallback(async (): Promise<void> => { @@ -35,12 +36,12 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => } }, [refreshing, reloadPrs]); - // 标记 PR 已读:用户打开 PR 时调用。先乐观清掉本地未读标志(即时反馈),再持久化已读水位—— - // 下一轮 poll 不会因旧事件把它标回。每次选中都发 IPC:打开 PR 非高频,且推进已读水位本就是对的; - // 不靠 setState 更新器的副作用判断「是否未读」——更新器在渲染阶段才跑,同步读其副作用拿不到结果。 - // 未读标志有两处:`unread` 圆点与 `unreadMentionCount`「@我/回复我」计数 chip(二者在标题槽位互斥、 - // 计数优先)。两者都要乐观清零 —— 否则计数 chip 会残留到下一轮 poll 重算 lastReadAt 才消失, - // 表现为「点开 PR 后未读计数不消除」(与后端 markPrRead 落盘的 unread:false / unreadMentionCount:0 对齐)。 + // Mark PR as read: called when the user opens a PR. First optimistically clear the local unread flags (instant feedback), then persist the read watermark — + // the next poll round won't re-mark it due to stale events. Send IPC on every selection: opening a PR is not high-frequency, and advancing the read watermark is inherently correct; + // don't rely on the side effect of the setState updater to decide "is it unread" — the updater only runs during the render phase, so synchronously reading its side effect gets no result. + // There are two unread flags: the `unread` dot and the `unreadMentionCount` "@me/replies to me" count chip (the two are mutually exclusive in the title slot, count takes priority). + // Both must be optimistically zeroed — otherwise the count chip lingers until the next poll round recomputes lastReadAt before disappearing, + // manifesting as "unread count doesn't clear after opening the PR" (aligned with the backend markPrRead persisting unread:false / unreadMentionCount:0). const markRead = useCallback(async (localId: string): Promise<void> => { setPrs((prev) => prev.map((p) => @@ -67,8 +68,8 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => setPrs((prev) => prev.map((p) => (p.localId === updated.localId ? updated : p))); } } catch (e) { - // 远端拒绝(如 PR 已关闭 / 合并 / 权限不足)→ 本地状态不变,弹 toast 提示。 - // 顺手刷新一次:PR 若已关闭,下一轮 poll 会把它软删,列表自洽 + // Remote rejection (e.g. PR already closed / merged / insufficient permissions) → local state unchanged, show a toast. + // Refresh once as well: if the PR is already closed, the next poll round soft-deletes it and the list stays consistent const msg = e instanceof Error ? e.message : String(e); notifyError(t('app.approveActionFailed', { msg })); void triggerRefresh(); @@ -84,15 +85,15 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) => try { await invoke('prs:merge', { localId: mergedId }); } catch (e) { - // 合并失败(PR 已合并 / 冲突 / veto / 权限)→ 弹 toast,本地不变。先经 formatBackendError 解码 - // AppError 错误码做 i18n(如 EPR0003「已被合并」给友好提示),非编码错误回退原始 message。 + // Merge failed (PR already merged / conflict / veto / permissions) → show a toast, local unchanged. First decode via formatBackendError + // to i18n the AppError error code (e.g. EPR0003 "already merged" gives a friendly message), non-coded errors fall back to the original message. notifyError(t('app.mergeFailed', { msg: formatBackendError(e).title })); void triggerRefresh(); return; } finally { setMerging(false); } - // 合并成功:PR 已转 MERGED,会从 pending 列表退场。取消选中 + 刷新让其消失 + // Merge succeeded: the PR has transitioned to MERGED and will leave the pending list. Deselect + refresh to make it disappear if (selectedId === mergedId) setSelectedId(null); await triggerRefresh(); }, [selected, selectedId, triggerRefresh, notifyError, merging, t]); diff --git a/apps/desktop/src/renderer/src/components/features/pr/index.ts b/apps/desktop/src/renderer/src/components/features/pr/index.ts index 5dd4500d..1680d455 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/index.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/index.ts @@ -1,5 +1,5 @@ -// features/pr 对外公共 API。内部模块(PrHeader / PrTabs / tabs/* 等)相互引用走相对路径, -// 不经此 barrel,避免循环依赖。状态栏 chip 走 features/pr/statusbar/* 子路径,不并入此处。 +// Public API of features/pr. Internal modules (PrHeader / PrTabs / tabs/* etc.) reference each other via relative paths, +// not through this barrel, to avoid circular dependencies. Status bar chips go through the features/pr/statusbar/* subpath and are not merged in here. export { PrPanel } from './PrPanel'; export { PrEmpty } from './PrEmpty'; export { PrItem } from './PrItem'; diff --git a/apps/desktop/src/renderer/src/components/features/pr/reviewer-status.tsx b/apps/desktop/src/renderer/src/components/features/pr/reviewer-status.tsx index c49a0b26..01c814e7 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/reviewer-status.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/reviewer-status.tsx @@ -2,8 +2,8 @@ import type { ReviewerStatus } from '@meebox/shared'; import { AlertGlyphIcon, ApproveIcon, CheckGlyphIcon, NeedsWorkIcon } from '../../common'; /** - * reviewer 状态的展示元信息与图标,供详情页 reviewer 列表(PrInfoView)、PR 头部头像栈 - * (ReviewerStack)等共用:状态 → 决断 chip 类型(复用活动时间线 chip 配色)+ 文案 key(复用 prStatus)。 + * Display metadata and icons for reviewer status, shared by the detail page reviewer list (PrInfoView), + * the PR header avatar stack (ReviewerStack), etc.: status → decision chip kind (reuses activity timeline chip colors) + label key (reuses prStatus). */ export const REVIEWER_STATUS_META: Record<ReviewerStatus, { chipKind: string; labelKey: string }> = { @@ -12,7 +12,7 @@ export const REVIEWER_STATUS_META: Record<ReviewerStatus, { chipKind: string; la unapproved: { chipKind: 'unapproved', labelKey: 'prStatus.pending' }, }; -/** reviewer 状态图标:approve 绿勾 / needs-work 琥珀叹号 / 待评审 中性空心点。 */ +/** Reviewer status icon: approve green check / needs-work amber exclamation / pending review neutral hollow dot. */ export function ReviewerStatusIcon({ status, size = 16, @@ -26,8 +26,8 @@ export function ReviewerStatusIcon({ } /** - * 头像栈角标用的纯符号字形(无外圆环):搭配实心彩底反色展示,只留内部勾 / 叹号。 - * approved / needsWork 才有;其它返回 null(待评审无角标)。 + * Pure symbol glyph for the avatar stack badge (no outer ring): displayed reversed on a solid colored background, keeping only the inner check / exclamation. + * Only approved / needsWork have one; others return null (pending review has no badge). */ export function ReviewerBadgeGlyph({ status, size = 16 }: { status: ReviewerStatus; size?: number }) { if (status === 'approved') return <CheckGlyphIcon size={size} />; diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx index 77cbd418..ce1baa72 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/LastSyncChip.tsx @@ -4,8 +4,8 @@ import { SyncIcon, StatusChip } from '../../../common'; import { formatRelative } from '../../../../utils/time'; /** - * 刷新按钮 + 同步状态合并:一个可点击 chip,显示最近同步相对时间 + 同步图标 - * (刷新中旋转),点击触发一次轮询。 + * Refresh button + sync status combined: a clickable chip showing the last sync relative time + sync icon + * (spinning while refreshing); clicking triggers one poll. */ export function LastSyncChip({ at, @@ -17,7 +17,7 @@ export function LastSyncChip({ onRefresh: () => void; }) { const { t } = useTranslation(); - // 每 30s 重渲染一次,让 "刚刚 / N 分钟前" 文案随时间向前推进 + // Re-render every 30s so the "just now / N minutes ago" text advances with time const [, tick] = useState(0); useEffect(() => { const id = setInterval(() => tick((n) => n + 1), 30_000); diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx index cf68d2ff..6763afd1 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { PullRequestIcon, StatusChip } from '../../../common'; -/** 待处理 PR 计数 chip。 */ +/** Pending PR count chip. */ export function PrsCountChip({ count }: { count: number }) { const { t } = useTranslation(); return ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx index 930d37e2..745c00e3 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/RepoSyncChip.tsx @@ -3,19 +3,19 @@ import { useRepoSyncStore } from '../../../../stores/repo-sync-store'; import { StatusChip } from '../../../common'; /** - * Repo sync 活动 chip:显示当前正在 clone/fetch 的 repo + 阶段 + 百分比。 - * 队列里只有一条在跑 (RepoMirrorManager 全局单队列);store 收着多条时只展示首条。 - * idle 不渲染,避免占状态栏宽度。 + * Repo sync activity chip: shows the repo currently being cloned/fetched + stage + percentage. + * Only one runs in the queue at a time (RepoMirrorManager global single queue); when the store holds multiple, only the first is shown. + * Not rendered when idle, to avoid taking up status bar width. */ export function RepoSyncChip() { const { t } = useTranslation(); const { active } = useRepoSyncStore(); if (active.size === 0) return null; - // Map 没保证迭代序,但 sync 同时只跑一个,多于一个时按 startedAt 升序选最早的 + // Map doesn't guarantee iteration order, but only one sync runs at a time; when there's more than one, pick the earliest by startedAt ascending const snapshots = Array.from(active.values()).sort((a, b) => a.startedAt - b.startedAt); const cur = snapshots[0]!; const more = snapshots.length - 1; - // repo = "host/projectKey/repoSlug",UI 紧凑只展示最后一段 + // repo = "host/projectKey/repoSlug"; for a compact UI show only the last segment const shortRepo = cur.repo.split('/').slice(-1)[0] ?? cur.repo; const stageLabel = cur.stage ? `${cur.stage}` : t('statusBar.syncing'); const pct = typeof cur.percent === 'number' ? ` ${String(Math.round(cur.percent))}%` : ''; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx index 9f2b5832..fa3f6ff3 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/CommitsPanel.tsx @@ -9,17 +9,17 @@ import { formatExactTime } from './comments/CommentItem'; interface CommitsPanelProps { pr: StoredPullRequest; - /** 点击某 commit → 在 Diff 标签页本地渲染该 commit 的变更(不再跳浏览器) */ + /** Click a commit → render that commit's changes locally in the Diff tab (no longer opens browser) */ onViewCommit?: (commit: PrCommit) => void; } /** - * PR commits 列表,表格布局。来源 `diff:listCommits` (无缓存,进入面板时拉一次)。 + * PR commits list, table layout. Source `diff:listCommits` (no cache, fetched once on entering the panel). * - * 列:短 SHA / 提交主题 (commit message 首行) / 作者 / 时间。merge commit 用 - * 标记 chip 区分。点击行 → 在 Diff 标签页本地渲染该 commit 的变更。 + * Columns: short SHA / commit subject (first line of commit message) / author / time. Merge commits are + * distinguished with a marker chip. Click a row → render that commit's changes locally in the Diff tab. * - * 列表默认按平台返回顺序 (newest first),跟 git log 习惯一致。 + * List defaults to the platform's return order (newest first), matching git log convention. */ export function CommitsPanel({ pr, onViewCommit }: CommitsPanelProps) { const { t } = useTranslation(); @@ -105,7 +105,7 @@ function CommitRow({ <tr className={`pr-commits-row ${onView ? 'pr-commits-row-clickable' : ''}`} onClick={() => onView?.(commit)} - title={commit.message /* 完整 commit body hover 可见 */} + title={commit.message /* full commit body visible on hover */} > <td className="pr-commits-col-sha"> <code>{commit.abbreviatedSha}</code> @@ -150,7 +150,7 @@ function formatCommitTime(iso: string, t: TFunction): string { if (diffSec < 3600) return t('commitsPanel.minutesAgo', { count: Math.round(diffSec / 60) }); if (diffSec < 86400) return t('commitsPanel.hoursAgo', { count: Math.round(diffSec / 3600) }); if (diffSec < 86400 * 7) return t('commitsPanel.daysAgo', { count: Math.round(diffSec / 86400) }); - // 一周以上展示 yyyy-mm-dd,避免"X 周前"模糊 + // Older than a week shows yyyy-mm-dd, avoiding the vagueness of "X weeks ago" const d = new Date(parsed); const pad = (n: number): string => String(n).padStart(2, '0'); return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx index 1a1c2d2c..4153a75f 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrInfoView.tsx @@ -18,15 +18,15 @@ interface PrInfoViewProps { export function PrInfoView({ pr }: PrInfoViewProps) { const { t } = useTranslation(); - // 描述 body 内嵌图片走 IPC 代理 (Bitbucket 私有资源需 PAT 鉴权),与评论/diff 一致 + // Images embedded in the description body go through the IPC proxy (Bitbucket private resources need PAT auth), consistent with comments/diff const mdComponents = useMemo( () => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }), [pr.localId, pr.url], ); - // 各平台 adapter 产出的 reviewers 顺序不稳定(GitHub 按 Map 插入序,随评审推进 - // requested_reviewers 会被移除/补到末尾),每次 poll 列表抖动。展示层按 displayName - // 字典序固定排序,name 兜底兜稳,与平台无关。 + // The reviewers order produced by each platform's adapter is unstable (GitHub uses Map insertion order, + // and as review progresses requested_reviewers get removed/appended to the end), so the list jitters every poll. + // The display layer sorts stably by displayName in lexicographic order, with name as a stable fallback, platform-agnostic. const reviewers = useMemo( () => [...pr.reviewers].sort( @@ -38,14 +38,14 @@ export function PrInfoView({ pr }: PrInfoViewProps) { return ( <div className="pr-info-view"> <div className="pr-info-content pr-info-layout"> - {/* 左:描述(主内容);右:时间线 + 评审者(元信息侧栏,参考 PR overview 布局) */} + {/* Left: description (main content); right: timeline + reviewers (metadata sidebar, modeled on PR overview layout) */} <div className="pr-info-main"> {pr.description ? ( <section className="pr-detail-section"> <h3>{t('prInfo.description')}</h3> <div className="pr-detail-description markdown"> - {/* Bitbucket 远端用 \r\n 行尾,remark 解析时 CR 跟 LF 各算一次换行 → 单换行 - 被当成段落分隔,每个 list item 之间多一段空白。归一化成 \n */} + {/* Bitbucket remote uses \r\n line endings; when remark parses, CR and LF each count as a line break → a single + newline gets treated as a paragraph separator, adding blank space between each list item. Normalize to \n */} <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={REMOTE_REHYPE_PLUGINS} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx index e4b4d1bb..e101e6b3 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/PrTabs.tsx @@ -4,8 +4,8 @@ import { ChatIcon, PersonIcon, WhitespaceIcon } from '../../../common'; export type PrTab = 'diff' | 'activity' | 'drafts' | 'commits' | 'info'; /** - * PR 详情 tab 栏:diff / 评论 / 草稿 / 提交 / 信息(带计数徽标),diff tab 时右侧附带 - * 空白可视 / blame / 并排-内联 切换工具条。 + * PR detail tab bar: diff / comments / drafts / commits / info (with count badges); when on the diff tab, the right side + * carries a whitespace-visibility / blame / side-by-side-inline toggle toolbar. */ export function PrTabs({ tab, @@ -30,11 +30,11 @@ export function PrTabs({ commitCount: number | null; totalDraftCount: number; publishableCount: number; - /** 该平台是否提供活动时间线(见 capabilities.activityTimeline);否则该 tab 标题退化为「评论」 */ + /** Whether the platform provides an activity timeline (see capabilities.activityTimeline); otherwise this tab's title degrades to "Comments" */ activityTimeline: boolean; - /** 内容只读(decline / 不可参与归档 PR):隐藏「新建评论」入口。 */ + /** Content is read-only (declined / non-participable archived PR): hides the "new comment" entry point. */ readOnly?: boolean; - /** 活动标签页右侧「评论」按钮:新建一条不锚到文件的评论 */ + /** The "Comment" button on the right of the activity tab: creates a comment not anchored to a file */ onNewComment: () => void; showWhitespace: boolean; onToggleWhitespace: () => void; @@ -55,8 +55,9 @@ export function PrTabs({ > {t('mainPane.tabDiff')} </button> - {/* 活动时间线(评论 + 提交 + 评审决断)在 commits 前:评审决断时讨论权重大于纯 commit 列表。 - 角标仍取评论数——讨论量最具行动指引,提交另有独立 tab 计数。 */} + {/* The activity timeline (comments + commits + review decisions) comes before commits: during review, discussion + weighs more than a plain commit list. The badge still uses the comment count — discussion volume is the most + actionable indicator, and commits have their own separate tab count. */} <button type="button" className={`pr-tab ${tab === 'activity' ? 'active' : ''}`} @@ -70,8 +71,8 @@ export function PrTabs({ ariaLabel={(n) => t('mainPane.commentCountAria', { count: n })} /> </button> - {/* 草稿 tab:显示条件用总数 — 全发完仍能进 tab 看 posted/rejected 历史; - 从未创建草稿的 PR 才完全隐藏 tab,避免冗余入口 */} + {/* Drafts tab: visibility condition uses the total count — even after all are posted you can still enter the tab + to view posted/rejected history; only PRs that never created a draft hide the tab entirely, avoiding a redundant entry point */} {totalDraftCount > 0 && ( <button type="button" @@ -168,10 +169,10 @@ export function PrTabs({ } /** - * tab 计数角标。计数异步加载(评论 / 提交): - * - `null`(加载中):渲染等宽占位 chip,预留角标宽度,消除计数到达时 tab 的横向弹簧抖动; - * - `> 0`:真实数字角标; - * - `0`:不渲染(无角标)。 + * Tab count badge. Counts load asynchronously (comments / commits): + * - `null` (loading): renders a fixed-width placeholder chip, reserving badge width to eliminate the tab's horizontal spring-jitter when the count arrives; + * - `> 0`: the real numeric badge; + * - `0`: not rendered (no badge). */ function TabCountBadge({ count, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx index 762ec3a6..89e7f6ed 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/activity/ActivityPanel.tsx @@ -31,25 +31,25 @@ import { interface ActivityPanelProps { pr: StoredPullRequest; - /** 顶层评论数(不含 replies)拉取成功后回调,供父组件 tab 角标用 */ + /** Callback after the top-level comment count (excluding replies) is fetched successfully, for the parent's tab badge */ onCommentsLoaded?: (count: number) => void; - /** 活动连接能力位;此处用 commentHardBreaks 决定评论是否启用 remark-breaks。 */ + /** Active connection capability flags; here commentHardBreaks decides whether comments enable remark-breaks. */ capabilities?: PlatformCapabilities; - /** 内容只读(decline / 不可参与归档 PR):隐藏评论回复 / 编辑 / 删除及新建编辑框。 */ + /** Content is read-only (declined / non-participable archived PR): hides comment reply / edit / delete and the new composer. */ readOnly?: boolean; - /** 是否展开「新建评论」编辑框(由标签栏「评论」按钮控制,出现在时间线顶部) */ + /** Whether the "new comment" composer is expanded (controlled by the tab bar's "Comment" button, appears at the top of the timeline) */ composing?: boolean; - /** 新建评论编辑框收起(取消 / 发布成功)回调 */ + /** Callback when the new comment composer collapses (cancel / posted successfully) */ onComposeClose?: () => void; - /** 当前 PAT 用户名(新建评论编辑框头像用) */ + /** Current PAT username (used for the new comment composer's avatar) */ currentUserName?: string | null; - /** 点击时间线上的 commit 事件 → 在 Diff 标签页本地渲染该 commit 的变更(不再跳浏览器) */ + /** Click a commit event on the timeline → render that commit's changes locally in the Diff tab (no longer opens browser) */ onViewCommit?: (commit: PrCommit) => void; - /** 点击 inline 评论锚点 chip → 跳到 Diff 对应文件/行 */ + /** Click an inline comment anchor chip → jump to the corresponding file/line in the Diff */ onJumpToAnchor?: (anchor: PrCommentAnchor) => void; } -/** 三路数据 + 其配对 PR 一起冻结,跨 poll 稳定引用,给评论树(含内联 Monaco)稳定身份避免重渲染。 */ +/** The three data streams + their paired PR are frozen together, a stable reference across polls, giving the comment tree (including inline Monaco) a stable identity to avoid re-renders. */ interface ActivityView { pr: StoredPullRequest; comments: PrComment[]; @@ -57,7 +57,7 @@ interface ActivityView { activity: PrActivityEvent[]; } -/** 按 id 列表逐项比对(顺序敏感)。commits 用 sha、activity 用 remoteId,相等则跳过 setState 让 React bail。 */ +/** Compares lists item by item by id (order-sensitive). commits use sha, activity uses remoteId; if equal, skip setState to let React bail. */ function sameIds<T>(a: readonly T[], b: readonly T[], id: (x: T) => string): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { @@ -67,17 +67,18 @@ function sameIds<T>(a: readonly T[], b: readonly T[], id: (x: T) => string): boo } /** - * PR 活动时间线(原「评论」标签页演进而来)。把三路数据按时间归并成一条时间线: - * 1. 评论(summary + inline,含 replies / 编辑 / 删除 / 内联代码,沿用 {@link CommentItem}) - * 2. 提交更新({@link PrCommit}) - * 3. reviewer 评审决断事件(approve / needs-work / unapprove / dismiss,{@link PrActivityEvent}) + * PR activity timeline (evolved from the former "Comments" tab). Merges the three data streams by time into one timeline: + * 1. Comments (summary + inline, including replies / edit / delete / inline code, reusing {@link CommentItem}) + * 2. Commit updates ({@link PrCommit}) + * 3. Reviewer review-decision events (approve / needs-work / unapprove / dismiss, {@link PrActivityEvent}) * - * 排序沿用评论页规则:**按时间倒序(newest first)**,最新动态在顶部。评论数据源与 DiffView 的 inline - * 评论同一份(`diff:listComments`,main 端有 pr_updated_at 缓存);提交 / 决断各走自己的 IPC,二者 - * 为增益信息,单独失败不影响评论时间线(catch 降级为空)。 + * Ordering follows the comments page rule: **reverse chronological (newest first)**, latest activity on top. The comment + * data source is the same as DiffView's inline comments (`diff:listComments`, the main side has a pr_updated_at cache); + * commits / decisions each go through their own IPC, both being additive info, and a standalone failure doesn't affect the + * comment timeline (catch degrades to empty). * - * 切 PR 时不立刻清空(stale-while-loading):旧时间线继续渲染、上盖 loading 遮罩,新数据 ready 后 - * 整体替换,消除「先闪加载中再渲新」的空窗。 + * Switching PRs doesn't clear immediately (stale-while-loading): the old timeline keeps rendering with a loading overlay + * on top, and once new data is ready it's replaced wholesale, eliminating the "flash loading then render new" gap. */ export function ActivityPanel({ pr, @@ -90,16 +91,16 @@ export function ActivityPanel({ onViewCommit, onJumpToAnchor, }: ActivityPanelProps) { - // 评论换行:GitHub/Bitbucket hard-break;GitLab CommonMark 软换行。缺省回退 true。 + // Comment line breaks: GitHub/Bitbucket hard-break; GitLab CommonMark soft break. Defaults to true. const hardBreaks = capabilities?.commentHardBreaks ?? true; - // 评论 emoji 反应模式:'fixed'(GitHub 8 种)/ 'free'(GitLab/Bitbucket 精选集+搜索);false/缺省 = 关闭。 + // Comment emoji reaction mode: 'fixed' (GitHub's 8) / 'free' (GitLab/Bitbucket curated set + search); false/default = off. const reactionsMode = capabilities?.commentReactions || undefined; - // 图片附件上传:平台支持时启用评论粘贴上传。缺省保守关闭(GitHub 无上传 API 亦为 false)。 + // Image attachment upload: enables comment paste-to-upload when the platform supports it. Conservatively off by default (GitHub has no upload API, also false). const attachmentsEnabled = capabilities?.commentAttachments ?? false; - // 差异化:GitHub/Bitbucket 渲染评论+提交+决断的活动时间线;GitLab(activityTimeline=false)退化为 - // 纯评论视图(不拉提交/决断、沿用「评论」文案)。缺省(capabilities 未到)保守按纯评论。 + // Differentiation: GitHub/Bitbucket render the activity timeline of comments+commits+decisions; GitLab (activityTimeline=false) degrades to + // a pure comment view (doesn't fetch commits/decisions, keeps the "Comments" wording). Default (capabilities not yet arrived) conservatively treats as pure comments. const showTimeline = capabilities?.activityTimeline ?? false; - // 文案命名空间:时间线模式用 activityPanel.*,纯评论模式沿用 commentsPanel.*(保持 GitLab 原体验)。 + // Text namespace: timeline mode uses activityPanel.*, pure comment mode keeps commentsPanel.* (preserving GitLab's original experience). const ns = showTimeline ? 'activityPanel' : 'commentsPanel'; const { t } = useTranslation(); const [view, setView] = useState<ActivityView | null>(null); @@ -112,8 +113,8 @@ export function ActivityPanel({ setError(null); const fetchAll = async (): Promise<void> => { try { - // 评论是核心(失败=整块错误);提交 / 决断是增益,单独失败 catch 成空,时间线照常展示评论。 - // 纯评论模式(GitLab)跳过提交 / 决断拉取。 + // Comments are core (failure = whole-block error); commits / decisions are additive, standalone failures catch to empty, and the timeline still shows comments. + // Pure comment mode (GitLab) skips fetching commits / decisions. const [comments, commits, activity] = await Promise.all([ invoke('diff:listComments', { localId: pr.localId, force: true }), showTimeline @@ -126,7 +127,7 @@ export function ActivityPanel({ : Promise.resolve([] as PrActivityEvent[]), ]); if (cancelled) return; - // 三路都与上次相等:保留旧 view 引用让 React bail(poll 无实质变化时不重渲时间线)。 + // All three streams equal last time: keep the old view reference to let React bail (don't re-render the timeline when a poll has no substantive change). setView((prev) => prev && prev.pr.localId === pr.localId && @@ -146,7 +147,7 @@ export function ActivityPanel({ } }; void fetchAll(); - // 用户回复 / 编辑 / 删除 / 发布草稿后 main 广播 comments:changed → 重拉 + // After the user replies / edits / deletes / posts a draft, main broadcasts comments:changed → refetch const unsub = subscribe('comments:changed', (e) => { if (e.localId === pr.localId) void fetchAll(); }); @@ -154,13 +155,13 @@ export function ActivityPanel({ cancelled = true; unsub(); }; - // onCommentsLoaded 故意不放依赖:父组件每次 render 都会重传新 ref,会触发误重拉 + // onCommentsLoaded deliberately left out of deps: the parent passes a new ref on every render, which would trigger a spurious refetch // eslint-disable-next-line react-hooks/exhaustive-deps }, [pr.localId, showTimeline]); const viewPr = view?.pr; - // inline 评论自动挂 Monaco 上限:按 createdAt 倒序后的前 N 条 inline 直接挂;超额走 click-to-expand。 - // 跟评论页一致取 10——一屏内大概看完,再多需主动展开。 + // Cap on auto-mounting Monaco for inline comments: the first N inline comments (after sorting by createdAt descending) mount directly; the rest use click-to-expand. + // Take 10 to match the comments page — roughly viewable within one screen, beyond that requires active expansion. const autoExpandSet = useMemo(() => { const out = new Set<string>(); const inlineByNewest = (view?.comments ?? []) @@ -170,15 +171,15 @@ export function ActivityPanel({ return out; }, [view]); - // @提及候选:从已加载的评论作者(含 replies)+ 提交作者派生——有界、零额外取数、安全(仅本 PR 参与者, - // 不向远端枚举全员)。仅平台支持反应/提及类增强时无关,纯增益;用户仍可自由手打任意 @name。 + // @mention candidates: derived from loaded comment authors (including replies) + commit authors — bounded, zero extra fetches, safe (only this PR's participants, + // not enumerating everyone from the remote). Independent of whether the platform supports reaction/mention enhancements, purely additive; the user can still freely type any @name. const mentionCandidates = useMemo<PlatformUser[]>( () => collectMentionCandidates(view?.comments ?? [], view?.commits ?? []), [view], ); - // 归并三路为时间线条目并按时间倒序。deps 全是稳定引用(view 经上面三路相等比对跳过后不变、 - // autoExpandSet 随 view)→ poll 无变化时整条时间线(含内联 Monaco)元素身份不变,React 跳过重渲。 + // Merge the three streams into timeline entries sorted in reverse chronological order. deps are all stable references (view stays unchanged after the three-stream equality comparison above skips it, + // autoExpandSet follows view) → when a poll has no change, the whole timeline (including inline Monaco) keeps element identity and React skips re-rendering. const timeline = useMemo<ReactElement[]>(() => { if (!viewPr || !view) return []; type Row = { key: string; at: number; node: ReactElement }; @@ -221,7 +222,7 @@ export function ActivityPanel({ node: <ReviewEvent key={`review:${ev.remoteId}`} event={ev} pr={viewPr} />, }); } - // newest first;稳定排序下同刻条目按 评论→提交→决断 入队序排列 + // newest first; under a stable sort, same-instant entries are ordered by enqueue order: comment→commit→decision rows.sort((a, b) => b.at - a.at); return rows.map((r) => r.node); }, [ @@ -238,7 +239,7 @@ export function ActivityPanel({ onJumpToAnchor, ]); - // 首载失败 / 切 PR 失败(无可信展示内容,或现有 view 属于旧 PR):整块错误,不拿旧 PR 内容冒充新的。 + // First-load failure / PR-switch failure (no trustworthy content to show, or the existing view belongs to an old PR): whole-block error, don't pass off old PR content as the new one. if (error && (!view || view.pr.localId !== pr.localId)) { return ( <div className="pr-comments-panel"> @@ -257,7 +258,7 @@ export function ActivityPanel({ <div className="pr-comments-scroll"> {(composing || (view && timeline.length > 0)) && ( <ul className="pr-comments-list pr-activity-list"> - {/* 新建评论编辑框作为时间线首个节点:与其它条目同款图标节点 + 头像,编辑框缩进挂在轨上。 */} + {/* The new comment composer as the first node of the timeline: same icon node + avatar as other entries, the composer indented and mounted on the rail. */} {composing && !readOnly && ( <li className="pr-comment pr-comment-timeline pr-comment-depth-0"> <div className="pr-activity-item pr-activity-comment-head"> @@ -289,16 +290,16 @@ export function ActivityPanel({ <p className="muted">{t(`${ns}.empty`)}</p> )} </div> - {/* 加载遮罩盖住旧内容(或首载空面板),ready 后整体替换。PaneLoading 默认 delayMs=150: - 命中缓存的快切换遮罩根本不出现、旧内容直接换新(零闪);只有慢加载才显 spinner。 */} + {/* The loading overlay covers the old content (or the empty panel on first load), replaced wholesale once ready. PaneLoading defaults to delayMs=150: + for cache-hit fast switches the overlay never appears and the old content swaps directly to new (zero flash); only slow loads show a spinner. */} {loading && <PaneLoading overlay label={t(`${ns}.loading`)} />} </div> ); } /** - * 从已加载的评论(含 replies 递归)+ 提交派生 @提及候选用户:按 name 去重、保序。候选源刻意只取 - * 本 PR 已出现的参与者(有界、零额外取数、不向远端枚举全员),是安全的 @ 自动补全数据来源。 + * Derives @mention candidate users from loaded comments (recursing into replies) + commits: dedup by name, order-preserving. The candidate source deliberately takes only + * participants who already appear in this PR (bounded, zero extra fetches, doesn't enumerate everyone from the remote), a safe data source for @ autocomplete. */ function collectMentionCandidates(comments: PrComment[], commits: PrCommit[]): PlatformUser[] { const seen = new Set<string>(); @@ -322,7 +323,7 @@ function collectMentionCandidates(comments: PrComment[], commits: PrCommit[]): P return out; } -/** 时间线上的提交事件:commit 图标 + 短 SHA + 主题 + 作者 + 时间;可点击跳远端 commit 页。 */ +/** A commit event on the timeline: commit icon + short SHA + subject + author + time; clickable to jump to the remote commit page. */ function CommitEvent({ commit, pr, @@ -344,7 +345,7 @@ function CommitEvent({ <span className="pr-activity-icon pr-activity-icon-commit" aria-hidden="true"> <CommitIcon size={18} /> </span> - {/* 作者展示与评论主体人对齐:同尺寸头像 + 加粗名,不做差异化 */} + {/* Author display aligns with the comment's main person: same-size avatar + bold name, no differentiation */} <Avatar connectionId={pr.connectionId} slug={commit.author.slug ?? commit.author.name} @@ -376,7 +377,7 @@ function CommitEvent({ ); } -/** kind → 图标 + 语义色 class。approved 绿、needsWork 琥珀、unapproved/dismissed 中性。 */ +/** kind → icon + semantic color class. approved green, needsWork amber, unapproved/dismissed neutral. */ const REVIEW_ICON: Record<PrActivityKind, ReactElement> = { approved: <ApproveIcon size={18} />, needsWork: <NeedsWorkIcon size={18} />, @@ -384,7 +385,7 @@ const REVIEW_ICON: Record<PrActivityKind, ReactElement> = { dismissed: <CloseIcon size={18} />, }; -/** 时间线上的评审决断事件:actor + 判定动词 + 时间。 */ +/** A review-decision event on the timeline: actor + decision verb + time. */ function ReviewEvent({ event, pr }: { event: PrActivityEvent; pr: StoredPullRequest }) { const { t } = useTranslation(); return ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx index 38a9d991..b7ab3869 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentComposer.tsx @@ -7,18 +7,18 @@ import { uploadCommentImage } from '../shared/uploadCommentImage'; interface CommentComposerProps { prLocalId: string; - /** `@提及` 自动补全候选(PR 参与者 + 评论作者,由父组件从已加载数据派生)。 */ + /** `@mention` autocomplete candidates (PR participants + comment authors, derived by the parent from loaded data). */ mentionCandidates?: PlatformUser[]; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);为真才启用粘贴上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); paste-to-upload is enabled only when true. */ attachmentsEnabled?: boolean; onCancel: () => void; - /** 发布成功后调用(收起编辑框;时间线通过 comments:changed 事件自动刷新,新评论出现在顶部) */ + /** Called after posting succeeds (collapses the composer; the timeline auto-refreshes via the comments:changed event, the new comment appears at the top) */ onPosted: () => void; } /** - * 新建 summary(不锚到文件)评论的编辑框:textarea + 发送/取消。出现在活动时间线最上方。 - * Cmd/Ctrl+Enter 发送,Esc 取消;空 body disabled 发送。版式复用回复编辑框样式。 + * Composer for a new summary (not anchored to a file) comment: textarea + send/cancel. Appears at the top of the activity timeline. + * Cmd/Ctrl+Enter sends, Esc cancels; send is disabled on an empty body. Layout reuses the reply composer's styles. */ export function CommentComposer({ prLocalId, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx index 8f764cc7..13efb7e5 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentEditEditor.tsx @@ -5,25 +5,25 @@ import { invoke } from '../../../../../api'; interface CommentEditEditorProps { prLocalId: string; commentId: string; - /** 当前评论 version (乐观锁,Bitbucket PUT 必带);版本不一致 Bitbucket 回 409 */ + /** Current comment version (optimistic lock, required for Bitbucket PUT); Bitbucket returns 409 on version mismatch */ version: number; - /** 初始 body — 进编辑态时 textarea 预填,避免用户从空白重写 */ + /** Initial body — prefills the textarea when entering edit mode, so the user does not rewrite from blank */ initialBody: string; onCancel: () => void; - /** 保存成功后调用:UI 收起编辑器;评论树通过 comments:changed 事件自动刷新 */ + /** Called after a successful save: UI collapses the editor; the comment tree auto-refreshes via the comments:changed event */ onSaved: () => void; } /** - * 已有评论的编辑器:textarea + 保存/取消,跟 CommentReplyEditor 同套视觉与 - * 快捷键 (Cmd/Ctrl+Enter 保存,Esc 取消)。 + * Editor for an existing comment: textarea + save/cancel, sharing the same visuals and + * shortcuts as CommentReplyEditor (Cmd/Ctrl+Enter to save, Esc to cancel). * - * 跟 reply 区别: - * - 初始 body = 现有评论文本,进编辑后用户改的是已存内容而非新建 - * - 走 comments:edit IPC (PUT),必须带 version - * - body 没变时禁用保存按钮 (no-op 不调远端) - * - 失败常见情形:Bitbucket 409 (用户在别处先改过 → version 错位) — 错误原文直接 - * 显示在编辑器底部,用户看到提示后可以关闭编辑器、等评论树刷新后再次编辑 + * Differences from reply: + * - Initial body = existing comment text; after entering edit mode the user modifies stored content rather than creating new + * - Goes through the comments:edit IPC (PUT), must carry version + * - Save button is disabled when body is unchanged (no-op, does not call remote) + * - Common failure: Bitbucket 409 (user edited elsewhere first → version mismatch) — the raw error is shown + * directly at the bottom of the editor; after seeing it the user can close the editor and re-edit once the comment tree refreshes */ export function CommentEditEditor({ prLocalId, @@ -39,7 +39,7 @@ export function CommentEditEditor({ const [error, setError] = useState<string | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null); - // mount 自动 focus + 光标到末尾,方便接着原文继续打字 + // On mount, auto-focus + move cursor to the end, so typing continues from the existing text useEffect(() => { const el = textareaRef.current; if (el) { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx index dc329f5d..99423a43 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx @@ -14,15 +14,15 @@ import { CommentReplyEditor } from './CommentReplyEditor'; import { CommentMarkdown } from '../shared/CommentMarkdown'; import { ReactionAddButton, ReactionChips, useReactions } from '../shared/ReactionBar'; import { useCommentThread } from '../shared/useCommentThread'; -// 行内代码上下文用 Monaco,懒加载随 DiffView 同一套 Monaco chunk 按需拉取,不进入口包。 +// Inline code context uses Monaco; lazy-loaded and pulled on demand with the same Monaco chunk as DiffView, not in the entry bundle. const InlineCodeContext = lazy(() => import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), ); /** - * 评论树结构相等比较(按 remoteId + 正文 + version + 编辑/删除权限 + 递归 replies)。poll 多数返回 - * 内容不变的评论:相等就跳过 setState、保留旧引用,让 React bail-out,避免整棵评论树(含内联 - * Monaco)无谓重渲染(刷新抖动)。 + * Structural equality comparison of the comment tree (by remoteId + body + version + edit/delete permissions + recursive replies). poll mostly returns + * comments with unchanged content: on equality, skip setState and keep the old reference so React bails out, avoiding pointless re-render of the whole + * comment tree (including inline Monaco) (refresh flicker). */ export function sameCommentList(a: readonly PrComment[], b: readonly PrComment[]): boolean { if (a.length !== b.length) return false; @@ -44,7 +44,7 @@ export function sameCommentList(a: readonly PrComment[], b: readonly PrComment[] return true; } -/** 反应数组相等比较(emoji + count + mine 三元组逐项一致):让 toggle 后的反应变化能触发重渲染。 */ +/** Equality comparison of the reactions array (emoji + count + mine triple matching item by item): lets reaction changes after a toggle trigger a re-render. */ function sameReactions(a: PrComment['reactions'], b: PrComment['reactions']): boolean { const x = a ?? []; const y = b ?? []; @@ -58,20 +58,20 @@ function sameReactions(a: PrComment['reactions'], b: PrComment['reactions']): bo } /** - * 嵌套回复的最大缩进层级:满此层级后继续递归但**不再加缩进**(拉平展示),避免深嵌套把内容挤到 - * 右侧极窄。depth 0 为顶层评论;depth 1..MAX 逐级缩进,超过 MAX 的更深回复一律平铺在 MAX 层缩进上, - * 仍按作者归属可读。Bitbucket 实际只一层 reply,GitHub / GitLab 可深嵌套,故设上限。 + * Maximum indent level for nested replies: past this level recursion continues but **no further indent is added** (flattened display), avoiding + * deep nesting squeezing content into a very narrow right side. depth 0 is the top-level comment; depth 1..MAX indent progressively, and deeper replies + * beyond MAX are all laid flat at the MAX-level indent, still readable by author attribution. Bitbucket actually has only one reply level, while GitHub / GitLab can nest deeply, hence the cap. */ const MAX_REPLY_DEPTH = 5; /** - * 单条评论 + 嵌套 replies。inline 评论顶部显示 `path:line side` chip 区分锚点位置; - * summary 评论不挂 chip。replies 走递归,depth 控制左侧缩进;满 MAX_REPLY_DEPTH 层后拉平 - * (不再加缩进,见该常量)。 + * A single comment + nested replies. inline comments show a `path:line side` chip at the top to distinguish the anchor location; + * summary comments carry no chip. replies recurse, with depth controlling the left indent; past MAX_REPLY_DEPTH levels they flatten + * (no further indent, see that constant). * - * `timeline` 模式(活动时间线,GitHub/Bitbucket)下的**顶层评论**改走时间线行版式:与其它事件统一 - * 「评论图标 + 头像 + 加粗作者名 + 『评论』动词 + 时间」标题,正文整体缩进成挂在时间线轨上的卡片。 - * 非 timeline(GitLab 纯评论视图)或回复(depth>0)维持原卡片版式。 + * In `timeline` mode (activity timeline, GitHub/Bitbucket), the **top-level comment** switches to the timeline-row layout: a header unified with other events + * ("comment icon + avatar + bold author name + 'commented' verb + time"), with the body indented as a whole into a card hung on the timeline rail. + * Non-timeline (GitLab pure comment view) or replies (depth>0) keep the original card layout. */ export function CommentItem({ comment, @@ -89,29 +89,29 @@ export function CommentItem({ comment: PrComment; pr: StoredPullRequest; depth: number; - /** 顶层 (depth=0) 由父组件按 CAP 决定 true/false;replies 总是 false (不渲染 code) */ + /** Top-level (depth=0) is decided true/false by the parent per CAP; replies are always false (do not render code) */ autoExpandCode?: boolean; hardBreaks: boolean; - /** 评论 emoji 反应模式(capabilities.commentReactions):'fixed'/'free' 才渲染;缺省 = 不支持。 */ + /** Comment emoji reaction mode (capabilities.commentReactions): renders only for 'fixed'/'free'; absent = unsupported. */ reactionsMode?: 'fixed' | 'free'; - /** `@提及` 自动补全候选(PR 参与者 + 评论作者);透传给回复编辑框。 */ + /** `@mention` autocomplete candidates (PR participants + comment authors); passed through to the reply editor. */ mentionCandidates?: PlatformUser[]; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);透传给回复编辑框启用粘贴上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the reply editor to enable paste upload. */ attachmentsEnabled?: boolean; - /** 是否处于活动时间线模式(仅影响顶层评论版式,见上方说明) */ + /** Whether in activity timeline mode (only affects top-level comment layout, see the note above) */ timeline?: boolean; - /** 内容只读(decline / 不可参与归档 PR):隐藏回复 / 编辑 / 删除操作,仅供浏览。 */ + /** Content read-only (decline / non-participable archived PR): hides reply / edit / delete actions, browse only. */ readOnly?: boolean; - /** inline 评论锚点 chip 点击 → 跳到 Diff 对应文件/行。提供时 chip 变可点击。 */ + /** Clicking the inline comment anchor chip → jump to the corresponding file/line in the Diff. When provided, the chip becomes clickable. */ onJumpToAnchor?: (anchor: PrCommentAnchor) => void; }) { const { t } = useTranslation(); - // 评论 body 内嵌图片走 IPC 代理 (Bitbucket 私有资源需 PAT 鉴权) + // Images embedded in the comment body go through the IPC proxy (Bitbucket private resources need PAT auth) const mdComponents = useMemo( () => ({ ...mermaidComponents, img: makeBitbucketImageFor(pr.localId, pr.url) }), [pr.localId, pr.url], ); - // 回复 / 编辑 / 删除 交互状态机(与 diff 行内评论 zone 共用,见 shared/useCommentThread) + // Reply / edit / delete interaction state machine (shared with the diff inline comment zone, see shared/useCommentThread) const { replyOpen, setReplyOpen, @@ -127,15 +127,15 @@ export function CommentItem({ handleDelete, } = useCommentThread(pr.localId, comment); - // 反应状态 + 切换(hook 无条件调用;输出仅在 reactionsMode 存在时渲染)。 + // Reaction state + toggle (hook called unconditionally; output rendered only when reactionsMode exists). const { reactions, busy: reactionBusy, toggle: toggleReaction } = useReactions( pr.localId, comment, readOnly, ); - // inline 评论锚点 chip:path:line + 侧别 (old=base / new=head),让用户在评论里定位到代码位置。 - // 提供 onJumpToAnchor 时(活动视图)chip 变可点击 → 跳到 Diff 对应文件/行。 + // inline comment anchor chip: path:line + side (old=base / new=head), letting the user locate the code position from the comment. + // When onJumpToAnchor is provided (activity view) the chip becomes clickable → jump to the corresponding file/line in the Diff. const anchor = comment.anchor; const anchorChip = anchor ? ( onJumpToAnchor ? ( @@ -160,7 +160,7 @@ export function CommentItem({ ) ) : null; - // inline 评论:在正文上方嵌一段代码上下文 (Monaco read-only)。replies (depth > 0) 不重复展示。 + // inline comment: embed a code context (Monaco read-only) above the body. replies (depth > 0) do not repeat it. const inlineCode = comment.anchor && depth === 0 ? ( <Suspense @@ -170,7 +170,7 @@ export function CommentItem({ </Suspense> ) : null; - // 编辑态:textarea 占位替换 markdown 正文;非编辑态:渲染 markdown + // Edit mode: textarea replaces the markdown body in place; non-edit mode: render markdown const bodyOrEdit = editOpen && typeof comment.version === 'number' ? ( <CommentEditEditor @@ -190,7 +190,7 @@ export function CommentItem({ /> ); - // 操作行:编辑态隐藏所有按钮(避免跟编辑器底部按钮组重复);只读(decline / 不可参与)整行隐藏。 + // Action row: edit mode hides all buttons (avoiding duplication with the editor's bottom button group); read-only (decline / non-participable) hides the whole row. const foot = !editOpen && !readOnly ? ( <div className="pr-comment-foot"> {!replyOpen && ( @@ -208,7 +208,7 @@ export function CommentItem({ {t('common.edit')} </button> )} - {/* 删除按钮在最后,跟其它按钮风格对齐 — disable 期间文案变"删除中…" */} + {/* Delete button last, aligned in style with the other buttons — while disabled the label becomes "deleting…" */} {canDelete && !replyOpen && ( <button type="button" @@ -220,7 +220,7 @@ export function CommentItem({ {deleting ? t('commentsPanel.deleting') : t('common.delete')} </button> )} - {/* 「加反应」按钮放在操作按钮之后;回复编辑态下隐藏避免拥挤 */} + {/* The "add reaction" button goes after the action buttons; hidden in reply edit mode to avoid crowding */} {reactionsMode && !replyOpen && ( <ReactionAddButton reactions={reactions} @@ -247,7 +247,7 @@ export function CommentItem({ </div> ) : null; - // 已有反应:单独成行,渲染在操作按钮行下方(编辑态隐藏)。readOnly 下只展示、不可切换。 + // Existing reactions: on their own row, rendered below the action button row (hidden in edit mode). Under readOnly they only display, not toggle. const reactionChipsEl = reactionsMode && !editOpen ? ( <ReactionChips @@ -261,7 +261,7 @@ export function CommentItem({ const replyEditor = replyOpen ? ( <CommentReplyEditor prLocalId={pr.localId} - // 回复目标抽象(threadId):GitLab=discussion id(reply 必需);Bitbucket 空 / GitHub=remoteId → 回退 remoteId。 + // Reply target abstraction (threadId): GitLab=discussion id (required for reply); Bitbucket empty / GitHub=remoteId → fall back to remoteId. parentCommentId={comment.threadId ?? comment.remoteId} mentionCandidates={mentionCandidates} attachmentsEnabled={attachmentsEnabled} @@ -272,8 +272,8 @@ export function CommentItem({ const repliesEl = comment.replies.length > 0 ? ( - // 满 MAX_REPLY_DEPTH 层后用 pr-comments-flat 取代 pr-comments-replies(不再缩进 / 左边框)→ - // 更深回复拉平在该层缩进上;pr-comments-flat 据此给同层级相邻评论加横向分割线区分。 + // Past MAX_REPLY_DEPTH levels, use pr-comments-flat instead of pr-comments-replies (no more indent / left border) → + // deeper replies flatten at that indent level; pr-comments-flat accordingly adds a horizontal divider between adjacent same-level comments to distinguish them. <ul className={`pr-comments-list ${depth < MAX_REPLY_DEPTH ? 'pr-comments-replies' : 'pr-comments-flat'}`} > @@ -306,7 +306,7 @@ export function CommentItem({ /> ) : null; - // 时间线模式的顶层评论:与其它事件统一的标题行(图标 + 头像 + 作者 + 『评论』+ 时间),正文挂成缩进卡片。 + // Top-level comment in timeline mode: a header row unified with other events (icon + avatar + author + 'commented' + time), with the body hung as an indented card. if (timeline && depth === 0) { return ( <li className="pr-comment pr-comment-timeline pr-comment-depth-0"> @@ -381,7 +381,7 @@ export function CommentItem({ } /** - * 精确到秒的本地时间文案(`YYYY-MM-DD HH:mm:ss`),供时间标签 hover tooltip 展示实际时间点。 + * Local time text to the second (`YYYY-MM-DD HH:mm:ss`), for the time label's hover tooltip to show the actual time point. */ export function formatExactTime(iso: string): string { const ms = Date.parse(iso); @@ -392,7 +392,7 @@ export function formatExactTime(iso: string): string { } /** - * 相对时间文案(刚刚 / N 分钟前 / …),一周以上回退本地日期。供评论与活动事件共用。 + * Relative time text (just now / N minutes ago / …), falling back to the local date beyond a week. Shared by comments and activity events. */ export function formatRelativeTime(iso: string): string { const t = Date.parse(iso); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx index 4e05c342..48ff98e4 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentReplyEditor.tsx @@ -8,18 +8,18 @@ import { uploadCommentImage } from '../shared/uploadCommentImage'; interface CommentReplyEditorProps { prLocalId: string; parentCommentId: string; - /** `@提及` 自动补全候选(PR 参与者 + 评论作者)。 */ + /** `@mention` autocomplete candidates (PR participants + comment authors). */ mentionCandidates?: PlatformUser[]; - /** 平台是否支持图片附件上传;为真才启用粘贴上传。 */ + /** Whether the platform supports image attachment upload; paste upload is enabled only when true. */ attachmentsEnabled?: boolean; onCancel: () => void; - /** reply 创建成功后调用 (UI 收起编辑框;评论列表通过 comments:changed 事件自动刷新) */ + /** Called after a reply is created successfully (UI collapses the editor; the comment list auto-refreshes via the comments:changed event) */ onPosted: () => void; } /** - * 已有评论的人工回复编辑框:textarea + 保存/取消。展开在被回复评论的下方。 - * Cmd/Ctrl+Enter 保存,Esc 取消;空 body disabled 保存 + * Manual reply editor for an existing comment: textarea + save/cancel. Expands below the comment being replied to. + * Cmd/Ctrl+Enter to save, Esc to cancel; empty body disables save */ export function CommentReplyEditor({ prLocalId, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx index b291f2df..c8beaf97 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/InlineCodeContext.tsx @@ -1,5 +1,5 @@ -// 必须在用到 @monaco-editor/react 之前执行(见 DiffView 同款说明)。本文件经 -// React.lazy 动态加载 → Monaco 随本 chunk 按需拉取,不进入口包。 +// Must run before @monaco-editor/react is used (see the same note in DiffView). This file is +// dynamically loaded via React.lazy → Monaco is pulled on demand with this chunk, not in the entry bundle. import '../../../../../lib/monaco-setup'; import { Editor, type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; @@ -17,25 +17,25 @@ import { languageFor } from '../../../../../utils/language'; interface InlineCodeContextProps { pr: StoredPullRequest; anchor: PrCommentAnchor; - /** 锚定行前后展示的上下文行数;默认 5 */ + /** Number of context lines shown around the anchored line; defaults to 5 */ contextLines?: number; /** - * 进入页面时是否自动挂 Monaco 编辑器。CommentsPanel 默认对最新前 N 条 inline - * 评论 (AUTO_EXPAND_CAP) 传 true,超额条目传 false → 渲染"展开代码"按钮, - * 用户点击才挂 editor (懒加载),避免 PR 评论很多时一次性把页面拖慢 + * Whether to auto-mount the Monaco editor on entering the page. CommentsPanel passes true by default + * for the latest N inline comments (AUTO_EXPAND_CAP), and false for the rest → renders an "expand code" button; + * the editor mounts only when the user clicks (lazy load), avoiding slowing the page down all at once when a PR has many comments */ autoExpand?: boolean; } /** - * 评论里 inline 引用的代码上下文:Monaco read-only 编辑器,展示锚定行前后若干行, - * 锚定行用整行底色高亮 (跟 Bitbucket 内嵌评论的视觉惯例一致)。 + * Inline code context referenced in a comment: a Monaco read-only editor showing a few lines around the anchored line, + * with the anchored line highlighted by a full-line background (matching Bitbucket's inline-comment visual convention). * - * 取数走 `diff:getFileContent` —— 跟 DiffView 同一份本地 git blob,无远端往返; - * mirror 还没拉齐 base/head sha 时 (rare,poll 已经先 sync 过) 走 syncMirror 兜底。 + * Data comes from `diff:getFileContent` — the same local git blob as DiffView, no remote round trip; + * when the mirror has not yet fetched the base/head sha (rare, poll already synced first) it falls back to syncMirror. * - * 性能:每个 inline 评论都会挂一个 Monaco 实例 (读 + tokenize)。CommentsPanel 控 - * 默认只 auto-expand 前 N 条 (按时间线),超额走 click-to-expand 懒加载。 + * Performance: each inline comment mounts a Monaco instance (read + tokenize). CommentsPanel controls this: + * by default it only auto-expands the first N (by timeline); the rest use click-to-expand lazy loading. */ function InlineCodeContextImpl({ pr, @@ -53,7 +53,7 @@ function InlineCodeContextImpl({ const [error, setError] = useState<string | null>(null); useEffect(() => { - // 未展开时不拉文件 — 给用户主动控制懒加载的语义 + // Do not fetch the file while collapsed — gives the user active control over lazy-load semantics if (!expanded) return; let cancelled = false; setSnippet(null); @@ -62,7 +62,7 @@ function InlineCodeContextImpl({ try { const c = await invoke('diff:getFileContent', { localId: pr.localId, - // anchor.side 'old' 锚到 base 侧,'new' 锚到 head 侧 + // anchor.side 'old' anchors to the base side, 'new' anchors to the head side side: anchor.side === 'old' ? 'base' : 'head', path: anchor.path, }); @@ -85,9 +85,9 @@ function InlineCodeContextImpl({ return () => { cancelled = true; }; - // 故意不依赖 t:useTranslation 的 t 会在 i18n languageChanged 时换新引用(poll 刷新也可能触发), - // 把它放进依赖会让本 effect 无谓重跑 → setSnippet(null) → 内嵌 Monaco 卸载重建(刷新抖动)。 - // t 仅用于错误文案,重抓时机只该由 expanded / pr / anchor 决定。 + // Intentionally not depending on t: useTranslation's t swaps to a new reference on i18n languageChanged (poll refresh may also trigger it), + // putting it in deps would make this effect re-run pointlessly → setSnippet(null) → the embedded Monaco unmounts and rebuilds (refresh flicker). + // t is only used for error text; the refetch timing should be decided solely by expanded / pr / anchor. // eslint-disable-next-line react-hooks/exhaustive-deps }, [expanded, pr.localId, anchor.path, anchor.side, anchor.line, contextLines]); @@ -115,13 +115,13 @@ function InlineCodeContextImpl({ return <CodeSnippet snippet={snippet} language={languageFor(anchor.path)} />; } -/** 行内片段行高 / 字号比(源自 fs=12 时行高 19);按配置字号等比缩放行高。 */ +/** Inline snippet line-height / font-size ratio (derived from line-height 19 at fs=12); scales line height proportionally to the configured font size. */ const SNIPPET_LINE_HEIGHT_RATIO = 19 / 12; const READONLY_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true, - // keep-alive:评论 tab 切走时本编辑器被 display:none(尺寸归 0),切回需重排。 - // automaticLayout 让 Monaco 自带 ResizeObserver 在显隐时自动 layout,避免切回空白/错位。 + // keep-alive: when the comment tab is switched away, this editor is display:none (size collapses to 0), and needs reflow on switch-back. + // automaticLayout lets Monaco's built-in ResizeObserver auto-layout on show/hide, avoiding blank/misaligned rendering on switch-back. automaticLayout: true, }; @@ -132,11 +132,11 @@ interface Snippet { } /** - * 只读代码片段编辑器。**独立 memo 组件**:props 只有稳定的 snippet(值不变就同一引用)+ language, - * 与父级 CommentItem / CommentsPanel 的任何重渲染(poll / 焦点刷新触发的 pr 换引用等)彻底隔离。 - * 父级重渲染时本组件按 props 浅比较 bail → 不重建 <Editor> 元素 → @monaco-editor/react 的 value / - * options effect 都不触发(避免只读编辑器被无条件 setValue 重置 → 重新 tokenize 的刷新抖动)。 - * onMount / options 也用稳定引用,杜绝即便重渲染时的 updateOptions 抖动。 + * Read-only code snippet editor. **Standalone memo component**: props are only the stable snippet (same reference when value unchanged) + language, + * fully isolated from any re-render of parent CommentItem / CommentsPanel (pr reference swaps triggered by poll / focus refresh, etc.). + * On parent re-render this component bails via shallow props comparison → does not rebuild the <Editor> element → @monaco-editor/react's value / + * options effects do not fire (avoiding the read-only editor being unconditionally reset by setValue → re-tokenize refresh flicker). + * onMount / options also use stable references, eliminating updateOptions flicker even on re-render. */ const CodeSnippet = memo(function CodeSnippet({ snippet, @@ -145,10 +145,10 @@ const CodeSnippet = memo(function CodeSnippet({ snippet: Snippet; language: string; }) { - // Monaco 内置主题不走 CSS 自定义属性,须显式切换:按编辑器主题偏好('auto' 跟随 GUI 深浅)解析。 + // Monaco's built-in themes do not use CSS custom properties, so they must be switched explicitly: resolved by the editor theme preference ('auto' follows GUI light/dark). const monacoTheme = useMonacoEditorTheme(); - // 等宽字体 + 字号随配置切换。行内片段比主编辑器小 2px(保留历史观感)、随配置字号联动,下限受 MIN 约束; - // 行高按字号等比缩放。字号 / 行高 / 字体一并进 options(@monaco-editor/react 按引用比对,useMemo 稳定)。 + // Monospace font + font size switch with config. Inline snippets are 2px smaller than the main editor (preserving the historical look), track the configured font size, with a MIN lower bound; + // line height scales proportionally to the font size. Font size / line height / font all go into options (@monaco-editor/react compares by reference, kept stable via useMemo). const appearance = useEditorAppearance(); const fontFamily = resolveEditorFontFamily(appearance.fontFamily); const snippetFontSize = Math.max(EDITOR_FONT_SIZE_MIN, appearance.fontSize - 2); @@ -167,8 +167,8 @@ const CodeSnippet = memo(function CodeSnippet({ const handleMount = useCallback( (ed: editor.IStandaloneCodeEditor, monaco: Monaco): void => { - // 真实文件行号 = snippet 内部行号 + startLine - 1。Monaco lineNumbers 函数式 - // 完全可控,把内部 1..N 映射回去 + // Real file line number = snippet-internal line number + startLine - 1. Monaco's functional lineNumbers + // is fully controllable, mapping the internal 1..N back ed.updateOptions({ readOnly: true, domReadOnly: true, @@ -182,12 +182,12 @@ const CodeSnippet = memo(function CodeSnippet({ contextmenu: false, folding: false, glyphMargin: false, - // 字号 / 行高 / 字体由 options 统一驱动(随配置实时更新),不在此固定。 + // Font size / line height / font are driven uniformly by options (updated live with config), not fixed here. padding: { top: 6, bottom: 6 }, - // 行宽自适应,长行用 word wrap 而不是横向滚动条 (滚动条已禁) + // Line width adapts; long lines use word wrap instead of a horizontal scrollbar (scrollbars are disabled) wordWrap: 'on', }); - // 锚定行整行底色:用 Monaco decorations。线条 className 走 CSS 决定颜色 + // Full-line background for the anchored line: via Monaco decorations. The line className lets CSS decide the color ed.createDecorationsCollection([ { range: new monaco.Range(snippet.anchorInSnippet, 1, snippet.anchorInSnippet, 1), @@ -217,9 +217,10 @@ const CodeSnippet = memo(function CodeSnippet({ }); /** - * 按**锚点值**(path / line / side)+ pr.localId + 展示选项比较的 memo:父级(CommentsPanel)在 poll - * 重渲染时会传新的 anchor / pr **对象引用**(值未变),默认浅比较会误判变化 → 内嵌 Monaco 重渲染重排 - * (刷新抖动)。这里按值比较,定位信息没变就跳过整个组件,Monaco 不动。 + * A memo comparing by **anchor value** (path / line / side) + pr.localId + display options: on poll re-render the parent + * (CommentsPanel) passes new anchor / pr **object references** (unchanged values), and the default shallow comparison would + * mistake this for a change → embedded Monaco re-renders and reflows (refresh flicker). Here it compares by value, skipping the + * whole component when the location info is unchanged, leaving Monaco untouched. */ export const InlineCodeContext = memo( InlineCodeContextImpl, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx index bd456b5e..0d5c984b 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffPane.tsx @@ -30,52 +30,52 @@ export function DiffPane({ onMount: (editor: MonacoEditor.IStandaloneDiffEditor) => void; }) { const { t } = useTranslation(); - // Monaco 内置主题不走 CSS 自定义属性,须显式切换:按编辑器主题偏好('auto' 跟随 GUI 深浅)解析。 + // Monaco's built-in themes do not use CSS custom properties, so they must be switched explicitly: resolved by the editor theme preference ('auto' follows GUI light/dark). const monacoTheme = useMonacoEditorTheme(); - // 编辑器等宽字体 + 字号:随配置切换(字体空 = Monaco 默认;字号按平台再做微调)。 + // Editor monospace font + font size: switch with config (empty font = Monaco default; font size gets a per-platform tweak). const editorAppearance = useEditorAppearance(); const fontFamily = resolveEditorFontFamily(editorAppearance.fontFamily); - // Monaco 挂载后 diff 还要异步计算 + hideUnchangedRegions 折叠才稳定(见上文 reveal 逻辑), - // 期间编辑器是「空 → 跳一下」的重排。在它之上盖一层 overlay loading,首次 onDidUpdateDiff - // (或挂载即已算完)后卸载,遮住这段抖动一次性 reveal。DiffPane 按 file path keyed → - // 切文件自然 remount,diffReady 随之复位。 + // After Monaco mounts, the diff still needs async computation + hideUnchangedRegions collapsing before it stabilizes (see the reveal logic below), + // during which the editor reflows from "empty → jump". Lay an overlay loading on top of it, unmount after the first onDidUpdateDiff + // (or if already computed on mount), covering this flicker for a one-shot reveal. DiffPane is keyed by file path → + // switching files naturally remounts, and diffReady resets with it. const [diffReady, setDiffReady] = useState(false); - // options 必须 useMemo 稳定引用:@monaco-editor/react 对 options **按引用**比对,引用一变就 - // editor.updateOptions()。父级 DiffView 随 poll(pr 换新对象引用)重渲染 → DiffPane 重渲染, - // 若每次新建 options 字面量,每次 poll 都触发 updateOptions → hideUnchangedRegions 折叠布局重算 → - // 编辑器渲染抖动。只在真正影响项(并排/空白/字号)变化时重建。 + // options must use a stable useMemo reference: @monaco-editor/react compares options **by reference**, and any reference change triggers + // editor.updateOptions(). The parent DiffView re-renders with poll (pr swaps to a new object reference) → DiffPane re-renders, + // and if the options literal is rebuilt each time, every poll triggers updateOptions → hideUnchangedRegions collapse layout recompute → + // editor render flicker. Rebuild only when items that truly matter (side-by-side / whitespace / font size) change. const fontSize = editorFontSize(editorAppearance.fontSize); const editorOptions = useMemo<MonacoEditor.IDiffEditorConstructionOptions>( () => ({ readOnly: true, renderSideBySide, - // keep-alive:tab 切走时本编辑器被 display:none(尺寸归 0),切回需重排。automaticLayout - // 让 Monaco 自带 ResizeObserver 在显隐/尺寸变化时自动 layout,避免切回空白/错位。 + // keep-alive: when the tab is switched away, this editor is display:none (size collapses to 0), and needs reflow on switch-back. automaticLayout + // lets Monaco's built-in ResizeObserver auto-layout on show/hide/size change, avoiding blank/misaligned rendering on switch-back. automaticLayout: true, minimap: { enabled: false }, fontSize, fontFamily, scrollBeyondLastLine: false, - // 关掉 diff 专属的合并总览列(renderOverviewRuler=true 会在两侧滚动条之外再加一条宽列, - // 跟 VS Code 编辑模式「滚动条内打标」不一致)。改走编辑模式效果:内层 modified 编辑器自带的 - // overview ruler(默认渲染、独立于 minimap)+ 行内评论装饰的 overviewRuler 投影(见 useCommentZones)。 + // Turn off the diff-specific merged overview column (renderOverviewRuler=true adds an extra wide column outside both scrollbars, + // inconsistent with VS Code edit mode's "marks inside the scrollbar"). Go with the edit-mode effect instead: the inner modified editor's own + // overview ruler (rendered by default, independent of the minimap) + inline comment decorations' overviewRuler projection (see useCommentZones). renderOverviewRuler: false, - // 显式 3 道:让 overview ruler 按 1/3 分道(diff 占左道、评论占右道,各 1/3 宽), - // 避免被按 2 道算成各占一半,色条更细。 + // Explicit 3 lanes: split the overview ruler into thirds (diff takes the left lane, comments the right, each 1/3 wide), + // avoiding being computed as 2 lanes each taking half, for thinner color bars. overviewRulerLanes: 3, - // 显式开 glyph margin,给行内评论标记留位置 + // Explicitly enable glyph margin, leaving room for inline comment markers glyphMargin: true, - // 空白字符可视化:toolbar 按钮控制;'all' 时空格显示 · / Tab 显示 → + // Whitespace visualization: controlled by a toolbar button; when 'all', spaces show as · / Tab shows as → renderWhitespace: showWhitespace ? 'all' : 'none', - // GitHub 风格折叠:未变更段缩成可展开占位行 + // GitHub-style folding: unchanged sections collapse into expandable placeholder rows hideUnchangedRegions: { enabled: true, contextLineCount: 10, minimumLineCount: 5, revealLineCount: 20, }, - // 关掉依赖 ts.worker 的高级特性(diff review 不需要),同时消掉 - // `Missing requestHandler` 噪音。hover 保留给 blame / 评论装饰用。 + // Turn off advanced features that depend on ts.worker (diff review does not need them), which also silences + // the `Missing requestHandler` noise. hover is kept for blame / comment decorations. inlayHints: { enabled: 'off' }, quickSuggestions: false, suggestOnTriggerCharacters: false, @@ -89,9 +89,9 @@ export function DiffPane({ const handleMount = useCallback( (editor: MonacoEditor.IStandaloneDiffEditor) => { onMount(editor); - // diff 算完触发 onDidUpdateDiff,但 hideUnchangedRegions 折叠的布局还要再 paint - // 一两帧才稳定 → 不在事件里立即揭开(否则露出折叠那一跳),略等 80ms 让折叠 paint - // 完成、overlay 一直盖着,再一次性 reveal。 + // The diff computation fires onDidUpdateDiff, but the hideUnchangedRegions collapse layout still needs another + // frame or two of painting to stabilize → do not reveal immediately in the event (else the collapse jump shows); wait ~80ms to let the collapse + // paint finish while the overlay stays on, then reveal in one shot. const reveal = (): void => { window.setTimeout(() => setDiffReady(true), 80); }; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx index 4ad87b7d..6f3e0cba 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffScopeSelect.tsx @@ -7,9 +7,9 @@ import { formatRelativeTime } from '../comments/CommentItem'; import type { DiffScope } from './diff-types'; /** - * 变更范围选择器:文件树头部「<n> 个文件 · 全部变更 / <commit>」,点击展开下拉切换查看范围。 - * commit 列表懒加载(首次展开才拉)。选「全部变更」= PR 全量 diff;选某 commit = 该 commit - * 的 parent..sha 只读 diff。 + * Diff scope selector: file-tree header "<n> files · All changes / <commit>", click to expand a dropdown to switch the view scope. + * Commit list is lazy-loaded (fetched only on first expand). Picking "All changes" = full PR diff; picking a commit = that + * commit's parent..sha read-only diff. */ export function DiffScopeSelect({ fileCount, @@ -31,13 +31,13 @@ export function DiffScopeSelect({ const ref = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const menuRef = useRef<HTMLUListElement>(null); - // 下拉用 fixed 定位 + portal 挂到 body:否则会被 .diff-file-list 的 overflow 裁切、被右侧 Monaco 盖住。 + // Dropdown uses fixed positioning + portal mounted to body: otherwise it gets clipped by .diff-file-list overflow and covered by the Monaco editor on the right. const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null); const computePos = useCallback(() => { const el = triggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); - // 菜单整体拉宽:不小于触发器宽度,且给 commit 主题留足空间 + // Widen the whole menu: no smaller than the trigger width, and leave enough room for the commit subject setMenuPos({ top: r.bottom + 2, left: r.left, width: Math.max(r.width, 440) }); }, []); useEffect(() => { @@ -48,7 +48,7 @@ export function DiffScopeSelect({ if (ref.current?.contains(target) || menuRef.current?.contains(target)) return; setOpen(false); }; - // 触发器在文件树头部(不随文件列表滚动),但窗口缩放 / 外层滚动时重算位置 + // Trigger sits in the file-tree header (doesn't scroll with the file list), but recompute position on window resize / outer scroll const onReflow = (): void => computePos(); document.addEventListener('mousedown', onDoc); window.addEventListener('resize', onReflow); @@ -92,7 +92,7 @@ export function DiffScopeSelect({ role="listbox" style={{ top: menuPos.top, left: menuPos.left, width: menuPos.width }} > - {/* 「全部变更」:标题 + 提交数副行(参考 Bitbucket 两行布局) */} + {/* "All changes": title + commit-count subrow (mirrors the Bitbucket two-line layout) */} <li> <button type="button" @@ -115,7 +115,7 @@ export function DiffScopeSelect({ </span> </button> </li> - {/* 每个 commit:标题(首行 message)+ 作者 / 短 SHA / 时间副行 */} + {/* Each commit: title (first line of message) + author / short SHA / time subrow */} {(commits ?? []).map((c) => { const subject = c.message.split('\n', 1)[0]!; const active = scope.kind === 'commit' && scope.sha === c.sha; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx index e4204e8b..10af64b2 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffSearchPanel.tsx @@ -5,19 +5,19 @@ import { PER_FILE_MATCH_CAP, basename, dirname } from './search/diff-search'; import { useDiffSearch } from './search/useDiffSearch'; /** - * 搜索 PR diff 全部变更文件内容。仿 Bitbucket "Search code" 入口的行为: + * Search the content of all changed files in the PR diff. Mimics the behavior of Bitbucket's "Search code" entry: * - * - query 在 head + base 两端 line-by-line 找匹配 - * - 每个匹配行带 side 标记: - * 'added' — 仅出现在 head 端 (前缀 `+`) - * 'removed' — 仅出现在 base 端 (前缀 `-`) - * 'context' — 两端都有相同内容 (无前缀) - * - 按文件 group,文件名右侧 badge 显示该文件匹配数 - * - 文件级 expand/collapse,默认全展开 - * - 点击某条结果 → 调 onJumpToMatch 切换文件 + scroll 到对应行 + * - query matches line-by-line on both the head + base sides + * - each matched line carries a side marker: + * 'added' — appears only on the head side (prefix `+`) + * 'removed' — appears only on the base side (prefix `-`) + * 'context' — same content on both sides (no prefix) + * - grouped by file, a badge to the right of the file name shows that file's match count + * - file-level expand/collapse, all expanded by default + * - clicking a result → calls onJumpToMatch to switch file + scroll to the corresponding line * - * 搜索算法见 [search/diff-search](./search/diff-search.ts),状态机见 - * [search/useDiffSearch](./search/useDiffSearch.ts);本组件只负责渲染。 + * Search algorithm in [search/diff-search](./search/diff-search.ts), state machine in + * [search/useDiffSearch](./search/useDiffSearch.ts); this component only handles rendering. */ interface DiffSearchPanelProps { @@ -25,8 +25,8 @@ interface DiffSearchPanelProps { prLocalId: string; onJumpToMatch: (file: DiffChangedFile, line: number, side: 'old' | 'new') => void; /** - * 用户按 Esc 时调 — 父端通常把 sidebarMode 切回 'tree'。无论焦点在 input 还是 - * 结果列表都生效 (走 window 层 keydown) + * Called when the user presses Esc — the parent usually switches sidebarMode back to 'tree'. Works whether focus is + * on the input or the result list (via a window-level keydown) */ onExit?: () => void; } @@ -47,9 +47,9 @@ export function DiffSearchPanel({ files, prLocalId, onJumpToMatch, onExit }: Dif inputRef, } = useDiffSearch(files, prLocalId); - // Esc 退出搜索:用 window capture-stage listener 让焦点在 input / 结果按钮 / - // 任何子元素都生效。子元素的 input 自带 Esc 清空行为浏览器不一定有 (type=text) - // 不会跟它冲突 + // Esc to exit search: a window capture-stage listener makes it work whether focus is on the input / result button / + // any child element. Browsers don't necessarily give a child input a built-in Esc-to-clear behavior (type=text) + // so there's no conflict with it useEffect(() => { if (!onExit) return; const onKey = (e: KeyboardEvent): void => { @@ -143,9 +143,9 @@ export function DiffSearchPanel({ files, prLocalId, onJumpToMatch, onExit }: Dif {m.diffRole === 'added' ? '+' : m.diffRole === 'removed' ? '-' : ' '} </span> <span className="diff-search-match-line">{m.line}</span> - {/* colorize 完成后用 dangerouslySetInnerHTML 渲染带语法 - 着色的 HTML;未完成 / plaintext 文件走 fallback 走纯 - 文本 + 关键词 <mark> 高亮 */} + {/* Once colorize finishes, render the syntax-colored HTML via + dangerouslySetInnerHTML; not-yet-done / plaintext files fall + back to plain text + keyword <mark> highlight */} {m.colorizedHtml ? ( <span className="diff-search-match-content" @@ -181,8 +181,8 @@ export function DiffSearchPanel({ files, prLocalId, onJumpToMatch, onExit }: Dif } /** - * 关键词高亮:把 content 按 [matchStart, matchEnd) 拆三段,中间一段套 <mark>。 - * 只高亮第一处命中,避免渲染额外计算 + * Keyword highlight: split content into three parts by [matchStart, matchEnd), wrapping the middle part in <mark>. + * Only highlights the first hit, avoiding extra render computation */ function renderHighlight(content: string, start: number, end: number): React.ReactNode { if (start < 0 || end <= start) return content; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx index 4e5ebab2..33b2b849 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffStatus.tsx @@ -22,7 +22,7 @@ export function SyncProgress({ progress }: { progress: SyncProgressEvent | null </span> ); } - // sync 完成后 IPC handler 还在跑 git diff 算变更文件列表,显示对应阶段提示 + // After sync completes the IPC handler is still running git diff to compute the changed files list; show the matching phase hint if (progress.phase === 'done') { return ( <span className="muted"> @@ -54,7 +54,7 @@ export function SyncProgress({ progress }: { progress: SyncProgressEvent | null ); } -/** 整块替代 diff 区的硬错误展示(如变更文件列表本身拉不下来) */ +/** Hard-error display that replaces the whole diff area (e.g. the changed files list itself can't be fetched) */ export function BackendErrorView({ err, scope, @@ -81,7 +81,7 @@ export function BackendErrorView({ ); } -/** 顶部细 banner,部分功能拉不下来但 diff 主体仍可用时显示 */ +/** Thin top banner, shown when some features can't be fetched but the diff body is still usable */ export function BackendErrorBanner({ err, scope, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx index 76d12d84..df539a90 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx @@ -1,5 +1,5 @@ -// 必须在用到 @monaco-editor/react 之前执行(loader.config 指向本地 monaco)。 -// 本文件经 React.lazy 动态加载,故 Monaco 随本 chunk 按需拉取,不进入口包。 +// Must run before @monaco-editor/react is used (loader.config points to the local monaco). +// This file is dynamically loaded via React.lazy, so Monaco is fetched on demand with this chunk and stays out of the entry bundle. import '../../../../../lib/monaco-setup'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,7 +36,7 @@ import { useSyncProgress, } from './hooks'; -// commit「查看特定 commit」请求载荷类型(PrPanel 引用);定义在 diff-types,此处 re-export 保持入口稳定。 +// Payload type for the "view specific commit" request (referenced by PrPanel); defined in diff-types, re-exported here to keep the entry stable. export type { PendingCommitView } from './diff-types'; interface DiffViewProps { @@ -44,16 +44,16 @@ interface DiffViewProps { renderSideBySide: boolean; showBlame: boolean; showWhitespace: boolean; - /** 活动连接能力位;此处用 commentHardBreaks 决定评论是否启用 remark-breaks。 */ + /** Active connection capability bits; here commentHardBreaks decides whether comments enable remark-breaks. */ capabilities?: PlatformCapabilities; - /** 内容只读(decline / 不可参与归档 PR):不挂行内「+」新建评论、隐藏行内评论的回复 / 编辑 / 删除。 */ + /** Content read-only (declined / non-participatable archived PR): don't mount the inline "+" new-comment affordance, hide reply / edit / delete on inline comments. */ readOnly?: boolean; /** - * 跳转目标:来自 ChatPane finding card → App pendingDiffNav。 - * 非 null 时 DiffView 切到该文件 + 滚到 anchor 行 + 短暂高亮 + (带 runId/findingId - * 时) 打开 inline 草稿编辑 zone (草稿已由 ChatPane 端懒创建)。 - * runId/findingId 缺省 (PublishReviewModal anchor 点击) → 仅 navigate 不 enter edit。 - * 消费完调 onNavConsumed 清空 token + * Navigation target: from a ChatPane finding card → App pendingDiffNav. + * When non-null, DiffView switches to that file + scrolls to the anchor line + briefly highlights + (when runId/findingId + * are present) opens the inline draft edit zone (the draft is already lazily created on the ChatPane side). + * When runId/findingId are absent (PublishReviewModal anchor click) → navigate only, don't enter edit. + * Once consumed, call onNavConsumed to clear the token */ pendingNav?: { runId?: string; @@ -62,22 +62,22 @@ interface DiffViewProps { } | null; onNavConsumed?: () => void; /** - * 外部请求切到「查看特定 commit」视图(来自 提交 / 活动 标签页点击某 commit)。 - * 非 null 时 DiffView 把变更范围切到该 commit 的 `parent..sha`;消费完调 onCommitViewConsumed。 + * External request to switch to the "view specific commit" view (from clicking a commit on the Commits / Activity tab). + * When non-null, DiffView switches the scope to that commit's `parent..sha`; once consumed, calls onCommitViewConsumed. */ pendingCommitView?: PendingCommitView | null; onCommitViewConsumed?: () => void; /** - * 当前变更范围切到 / 离开单 commit 时上报(commit 有父 → 传该 commit 范围,全部变更 / root commit → null)。 - * 上层(App)据此把「正在查看的 commit」作为聊天区命令的隐式范围(见 ChatPane viewCommitScope)。 + * Reported when the current scope switches to / leaves a single commit (commit has a parent → pass that commit's scope, all changes / root commit → null). + * The parent (App) uses this to treat the "currently viewed commit" as the implicit scope for chat-pane commands (see ChatPane viewCommitScope). */ onViewCommitScopeChange?: (scope: ReviewRunCommitScope | null) => void; } /** - * PR diff 视图(组合根):左侧文件树 / 范围选择 / 跨文件搜索,右侧 Monaco DiffEditor + blame 列 + - * 行内评论 / 草稿 view zone。数据流(变更文件 / 内容 / 评论 / blame / 范围 / 跳转)拆到 ./hooks/*; - * 行内 view-zone 挂载走 ./zones/mountInlineZones;行内评论渲染见 ./inline-comments/。 + * PR diff view (composition root): file tree / scope select / cross-file search on the left, Monaco DiffEditor + blame column + + * inline comment / draft view zones on the right. Data flow (changed files / content / comments / blame / scope / navigation) is split into ./hooks/*; + * inline view-zone mounting goes through ./zones/mountInlineZones; inline comment rendering is in ./inline-comments/. */ export function DiffView({ pr, @@ -92,16 +92,16 @@ export function DiffView({ onCommitViewConsumed, onViewCommitScopeChange, }: DiffViewProps) { - // 评论换行策略:GitHub/Bitbucket hard-break(单 \n → <br>);GitLab CommonMark 软换行。 - // 能力位缺省(旧数据/无连接)回退 true,保持既有行为。 + // Comment line-break policy: GitHub/Bitbucket hard-break (single \n → <br>); GitLab CommonMark soft-wrap. + // When the capability bit is absent (old data / no connection), fall back to true to preserve existing behavior. const commentHardBreaks = capabilities?.commentHardBreaks ?? true; - // 行内评论 emoji 反应 / 图片附件能力(与评论 / 活动 tab 同源能力位):缺省 = 不支持(不渲染加反应按钮 / 不启用粘贴上传)。 + // Inline comment emoji reaction / image attachment capabilities (same capability bits as the comments / activity tab): absent = unsupported (don't render the add-reaction button / don't enable paste upload). const reactionsMode = capabilities?.commentReactions || undefined; const attachmentsEnabled = capabilities?.commentAttachments ?? false; const { t } = useTranslation(); - // 草稿池:跨 ChatPane / DiffView 共享 store;本组件需要它来渲染 inline zones + // Draft pool: store shared across ChatPane / DiffView; this component needs it to render inline zones const drafts = useDraftsForPr(pr.localId); - // 用 state 而非 ref:onMount 异步触发,必须靠 state 变更触发后续 useEffect 重新运行装饰逻辑。 + // Use state, not a ref: onMount fires asynchronously, so a state change is required to re-run the subsequent useEffect decoration logic. const [diffEditor, setDiffEditor] = useState<MonacoEditor.IStandaloneDiffEditor | null>(null); const progress = useSyncProgress(pr); @@ -111,8 +111,8 @@ export function DiffView({ pendingCommitView, onCommitViewConsumed, ); - // 把「正在查看的单 commit」上报给上层,作为聊天区命令的隐式范围。commit 有父才可 parent..sha 定界; - // 全部变更 / root commit(无父)上报 null。 + // Report the "currently viewed single commit" to the parent as the implicit scope for chat-pane commands. Only a commit with a parent can be bounded as parent..sha; + // all changes / root commit (no parent) reports null. useEffect(() => { if (!onViewCommitScopeChange) return; onViewCommitScopeChange( @@ -128,7 +128,7 @@ export function DiffView({ }, [scope, onViewCommitScopeChange]); const { files, filesError, retryFiles, selectedKey, setSelectedKey, selected, loadedKey } = useChangedFiles(pr, range, viewKey); - // 合并会冲突的文件路径集合(仅 pr.hasConflict 时实拉),文件树据此标三角警示。 + // Set of file paths that would conflict on merge (only fetched when pr.hasConflict), the file tree marks a triangle warning based on this. const conflictPaths = useConflictFiles(pr); const { content, contentLoading, contentError, setContentError } = useFileContent( pr, @@ -165,18 +165,18 @@ export function DiffView({ onNavConsumed, triggerAutoEdit, }); - // 捕获 Diff 选区 → selectionStore,供 ChatPane 把选中代码作为隐式上下文带进 agent/ask 提问。 + // Capture the Diff selection → selectionStore, so ChatPane can carry the selected code as implicit context into agent/ask questions. useSelectionCapture({ diffEditor, selected, prLocalId: pr.localId, renderSideBySide }); - // sidebar 模式:'tree' (文件树) / 'search' (跨文件搜索),默认进文件树。PR 切换时回到 'tree'。 + // sidebar mode: 'tree' (file tree) / 'search' (cross-file search), defaults to the file tree. Returns to 'tree' on PR switch. const [sidebarMode, setSidebarMode] = useState<'tree' | 'search'>('tree'); useEffect(() => { setSidebarMode('tree'); }, [pr.localId]); - // Bitbucket 评论附件 markdown 形如 `![alt](attachment:HASH)`;CommentNode 里把 - // `attachment:` 协议改写成此基址 + `/HASH`,让 <a> 能打开(点击走 Electron - // setWindowOpenHandler 转 shell.openExternal,用户在系统浏览器看附件)。 + // Bitbucket comment attachment markdown looks like `![alt](attachment:HASH)`; CommentNode rewrites + // the `attachment:` protocol to this base + `/HASH` so the <a> can open (clicking goes through Electron's + // setWindowOpenHandler to shell.openExternal, and the user views the attachment in the system browser). const attachmentBase = useMemo(() => { try { const u = new URL(pr.url); @@ -186,7 +186,7 @@ export function DiffView({ } }, [pr.url, pr.repo.projectKey, pr.repo.repoSlug]); - // 给文件树用:path → 锚到该文件的评论数(含双 path 别名 + renamed 的 oldPath) + // For the file tree: path → number of comments anchored to that file (including dual-path aliases + renamed oldPath) const commentCountByPath = useMemo(() => { const m = new Map<string, number>(); if (!files) return m; @@ -199,8 +199,8 @@ export function DiffView({ return m; }, [files, comments]); - // 给文件树用:path → 该文件下的待发布草稿数 (pending + edited)。 - // rejected (用户决断不发) / posted (已发,已在 comments chip 算了) 都排除。 + // For the file tree: path → number of unpublished drafts under that file (pending + edited). + // Both rejected (user decided not to post) and posted (already posted, already counted in the comments chip) are excluded. const draftCountByPath = useMemo(() => { const m = new Map<string, number>(); if (!files || !drafts) return m; @@ -214,9 +214,9 @@ export function DiffView({ return m; }, [files, drafts]); - // diff 增/删/改投影到滚动条总览标尺左道(编辑模式风格;评论锚点走右道,见下) + // diff add/delete/modify projected onto the left lane of the scrollbar overview ruler (edit-mode style; comment anchors take the right lane, see below) useDiffOverviewMarks({ diffEditor, content, selected, renderSideBySide }); - // 行内评论标记 + view zone + // Inline comment marks + view zone useCommentZones({ diffEditor, comments, @@ -232,7 +232,7 @@ export function DiffView({ attachmentsEnabled, readOnly, }); - // 内联草稿 view zone(commit 只读视图不渲染) + // Inline draft view zone (not rendered in the commit read-only view) useDraftZones({ diffEditor, drafts, @@ -245,7 +245,7 @@ export function DiffView({ attachmentsEnabled, scopeKind: scope.kind, }); - // 行 hover '+' 新建草稿(commit 只读视图不挂) + // Line hover '+' to create a new draft (not mounted in the commit read-only view) useLineCommentAdder({ diffEditor, content, @@ -260,9 +260,9 @@ export function DiffView({ t, }); - // 切 PR 进行中:仍渲染旧 PR 的树/内容(stale),由下方遮罩盖住,待新 files 到位再整体替换。 + // PR switch in progress: still render the old PR's tree/content (stale), covered by the overlay below, replaced wholesale once the new files arrive. const switching = files !== null && loadedKey !== viewKey; - // 错误仅在「无可信内容可展示」时整块呈现:首载失败(无 files)或切 PR 失败(现有 files 属于旧 PR)。 + // The error is shown as a full block only when "there's no trustworthy content to display": initial load failed (no files) or PR switch failed (existing files belong to the old PR). if (filesError && (!files || loadedKey !== viewKey)) { return ( <BackendErrorView @@ -285,12 +285,12 @@ export function DiffView({ return ( <div className="diff-view"> - {/* 切 PR 加载遮罩:盖住旧树/内容,新数据 ready(loadedKey 推进)后自动消失整体换新。 - PaneLoading 默认 delayMs=150:命中缓存的快切换遮罩不出现、直接换新(零闪)。 */} + {/* PR-switch loading overlay: covers the old tree/content, disappears automatically and swaps in the new one once the new data is ready (loadedKey advances). + PaneLoading defaults to delayMs=150: cache-hit fast switches don't show the overlay and swap directly (zero flash). */} {switching && <PaneLoading overlay label={t('mainPane.loadingEditor')} />} <aside className="diff-file-list" style={{ width: `${String(fileListWidth)}px` }}> - {/* header 一直显示。tree 模式右侧是"搜索"图标 (进搜索);search 模式 - 换"文件树"图标 (明示这是回到文件树的入口) */} + {/* header is always shown. In tree mode the right side is the "search" icon (enter search); in search mode + it becomes the "file tree" icon (making clear this is the entry back to the file tree) */} <div className="diff-file-list-header"> {sidebarMode === 'search' ? ( <span>{t('diffView.searchChanges')}</span> @@ -336,7 +336,7 @@ export function DiffView({ prLocalId={pr.localId} onJumpToMatch={(f, line, side) => { setSelectedKey(fileKey(f)); - // 复用现有 pendingScroll 机制定位行 — 不带 draftId 仅 navigate + // Reuse the existing pendingScroll mechanism to locate the line — no draftId, navigate only setPendingScroll({ line, side }); }} onExit={() => setSidebarMode('tree')} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx index 8ee7989a..fc480c97 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx @@ -5,10 +5,10 @@ import { formatBackendError } from '../../../../../errors'; import { DraftZone } from '../drafts/DraftZone'; /** - * 同行多条草稿的容器;每条独立 DraftZone (read/edit 各自维护),组件间用 hr 分隔。 - * onSave / onDelete 在这里调 IPC drafts:update / drafts:delete;写盘后 main 端 - * 广播 drafts:changed 事件 → drafts-store 重拉 → DiffView 顶层 useEffect 重建 - * zones (此组件随之 unmount/remount)。 + * Container for multiple drafts on the same line; each is an independent DraftZone (maintaining its own read/edit), separated by hr. + * onSave / onDelete call IPC drafts:update / drafts:delete here; after writing to disk the main side + * broadcasts a drafts:changed event → drafts-store refetches → DiffView's top-level useEffect rebuilds the + * zones (this component unmounts/remounts along with it). */ export function DraftZoneList({ drafts, @@ -21,7 +21,7 @@ export function DraftZoneList({ prLocalId: string; registerEditTrigger: (draftId: string, fn: (() => void) | null) => void; hardBreaks: boolean; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);透传给草稿编辑框启用粘贴 / 选取上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the draft editor to enable paste / pick upload. */ attachmentsEnabled?: boolean; }) { const { t } = useTranslation(); @@ -35,10 +35,10 @@ export function DraftZoneList({ const onDelete = async (draftId: string): Promise<void> => { await invoke('drafts:delete', { localId: prLocalId, draftId }); }; - // 单条发布:复用 drafts:publishBatch handler,传 [draftId] 单元素。这样跟 - // PublishReviewModal 的批量路径共用同一份 main 端逻辑 (anchor 映射 / posted - // 回写 / force-refresh 评论 / 失败收集都一致),行为可预测,未来改任一处不会 - // 让两条路径分叉 + // Single publish: reuse the drafts:publishBatch handler, passing a single-element [draftId]. This shares the same + // main-side logic with PublishReviewModal's batch path (anchor mapping / posted + // write-back / force-refresh comments / failure collection are all consistent), keeping behavior predictable so a future + // change to either doesn't fork the two paths const onPublish = async (draftId: string): Promise<{ ok: boolean; error?: string }> => { const resp = await invoke('drafts:publishBatch', { localId: prLocalId, @@ -46,7 +46,7 @@ export function DraftZoneList({ }); const r = resp.results[0]; if (!r) return { ok: false, error: t('diffView.noResultFromMain') }; - // r.error 是 AppError 编码串(草稿域 EPR* / 发布异常),在此解码为本地化文案再上交展示。 + // r.error is an AppError encoded string (draft-domain EPR* / publish exception), decoded here into localized text before being handed up for display. return { ok: r.ok, error: r.error ? formatBackendError(r.error).title : undefined }; }; return ( diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx index 6872cedd..6d7ccbcc 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/FileTree.tsx @@ -8,11 +8,11 @@ import { ChevronIcon, ConflictIcon } from '../../../../common'; interface FileTreeProps { files: DiffChangedFile[]; selectedKey: string | null; - /** path → 远端已发布的 inline 评论数 (含 renamed 文件的 oldPath 兼容) */ + /** path → number of published remote inline comments (with renamed-file oldPath compatibility) */ commentCountByPath: Map<string, number>; - /** path → 本地待发布草稿数 (pending + edited)。跟 PR header "提交评审 (N)" 同口径 */ + /** path → number of local unpublished drafts (pending + edited). Same measure as the PR header "Submit review (N)" */ draftCountByPath: Map<string, number>; - /** 合并会冲突的文件路径集合:命中的文件行在状态点左侧标三角警示图标。 */ + /** Set of file paths that would conflict on merge: matched file rows show a triangle warning icon to the left of the status dot. */ conflictPaths: Set<string>; onSelect: (file: DiffChangedFile) => void; } @@ -31,7 +31,7 @@ interface TreeFolder { name: string; path: string; children: TreeNode[]; - /** 聚合自所有后代文件的状态,用于给 folder name 着色 */ + /** Status aggregated from all descendant files, used to color the folder name */ aggregateStatus: FolderAggregateStatus; } @@ -51,7 +51,7 @@ export function FileTree({ }: FileTreeProps) { const { t } = useTranslation(); const tree = useMemo(() => buildTree(files), [files]); - // 默认全部展开。collapsed 记的是被折叠的 path 集合(默认空 = 全展开) + // All expanded by default. collapsed records the set of collapsed paths (empty by default = all expanded) const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); const toggle = (path: string): void => { @@ -65,9 +65,9 @@ export function FileTree({ return ( <div className="diff-file-tree" role="tree"> - {/* 内层 inline-block: 宽度 = max(max-content, 100%),让所有 row 撑到同一宽度, - 否则不同长度的 row 各自 100%-vs-max-content 会让 sticky dots 落在各自行尾, - 滚动时位置参差不齐 */} + {/* Inner inline-block: width = max(max-content, 100%), stretching all rows to the same width, + otherwise rows of different lengths each go 100%-vs-max-content, leaving sticky dots at their own row ends, + misaligned when scrolling */} <div className="diff-file-tree-inner"> {renderChildren(tree.children, 0, { selectedKey, @@ -160,8 +160,8 @@ function renderChildren(nodes: TreeNode[], depth: number, ctx: RenderCtx): React </span> <span className="tree-name">{n.name}</span> <span className="tree-row-right" aria-hidden="false"> - {/* draft chip 在前 / comment chip 在后:阅读顺序 "未发的 → 已发的", - 跟 PR header "提交评审 → 通过/需修改" 的左右顺序对齐 */} + {/* draft chip first / comment chip after: reading order "unpublished → published", + aligned with the left-to-right order of the PR header "Submit review → Approve/Needs work" */} {draftCount > 0 && ( <span className="tree-draft-count" @@ -250,9 +250,9 @@ function sortTree(node: TreeFolder): void { } /** - * 递归算每个 folder 的聚合状态。 - * 优先级:modified/typechange > 混合 added+deleted > added/renamed/copied > deleted - * (与 VS Code 文件资源管理器观感对齐:folder 含修改即橙黄,纯新增才绿) + * Recursively compute each folder's aggregate status. + * Priority: modified/typechange > mixed added+deleted > added/renamed/copied > deleted + * (aligned with the VS Code file explorer look: a folder with any modification is amber, only pure additions are green) */ function computeAggregateStatus(node: TreeFolder): FolderAggregateStatus { let hasModified = false; @@ -283,7 +283,7 @@ function fileIconFor(filePath: string): string { const base = filePath.split('/').pop()?.toLowerCase() ?? ''; const withPrefix = (name: string): string => `material-icon-theme:${name}`; - // 特殊文件名先匹配 + // Match special file names first const byBasename: Record<string, string> = { dockerfile: 'docker', 'docker-compose.yml': 'docker', diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx index ff14ee2d..b264b912 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/BlameColumn.tsx @@ -13,13 +13,14 @@ import { } from './blame-utils'; /** - * Bitbucket 风格 blame 列。独立于 Monaco DOM 之外,作为 diff-pane-wrapper 的左侧 - * flex 子项;内部用 absolute 子项画各 commit 区块,按 Monaco scrollTop 平移。 + * Bitbucket-style blame column. Lives outside the Monaco DOM, as the left flex + * child of diff-pane-wrapper; internally draws each commit block with absolute + * children, shifted by Monaco scrollTop. * - * 设计权衡: - * - 不走 Monaco InjectedText (DiffEditor 里实测不渲染,详见 commit 提交记录) - * - 不走 Monaco overlay widget (没有"绝对行号"定位选项,只能贴角) - * - 独立 DOM 列:可控、稳定、跟 React 生命周期一致;唯一成本是要同步 scrollTop + * Design trade-offs: + * - Not using Monaco InjectedText (verified not rendered inside DiffEditor, see commit history) + * - Not using Monaco overlay widget (no "absolute line number" positioning option, can only pin to corners) + * - Independent DOM column: controllable, stable, in sync with the React lifecycle; the only cost is syncing scrollTop */ export function BlameColumn({ blame, @@ -34,20 +35,21 @@ export function BlameColumn({ }) { const { t } = useTranslation(); const blocks = useMemo(() => groupBlameByCommit(blame.lines), [blame.lines]); - // 把 changedLines 合并成连续区段,渲染色带(减少 DOM 数量) + // Merge changedLines into contiguous ranges to render color bands (reduces DOM count) const changedRanges = useMemo( () => mergeContiguousLines(blame.changedLines), [blame.changedLines], ); const modifiedEditor = diffEditor.getModifiedEditor(); - // layout 只是触发器:scrollTop / viewportHeight 任一变就重渲,重渲时再走 Monaco - // 实时坐标 API,避免行数学手算和 Monaco 实际渲染的偏差(padding / view zones / - // hideUnchangedRegions 占位 / sticky scroll 全靠 Monaco 自己算) - // 注意:layout 也被上面 style 的 --blame-lh 引用 + // layout is only a trigger: any change to scrollTop / viewportHeight re-renders, and on re-render + // we go through Monaco's live coordinate API, avoiding manual line math and its divergence from + // Monaco's actual rendering (padding / view zones / hideUnchangedRegions placeholders / sticky + // scroll are all computed by Monaco itself) + // Note: layout is also referenced by --blame-lh in the style above - // 只渲染 Monaco 当前可见的行:hideUnchangedRegions 折叠掉的行返回的 range - // 里不会出现,自然不画 blame;评论 view zone 撑出的额外高度也由 Monaco 的 - // getTopForLineNumber 反映 + // Only render lines currently visible in Monaco: lines folded away by hideUnchangedRegions + // won't appear in the returned range, so no blame is drawn for them; the extra height pushed + // out by comment view zones is also reflected by Monaco's getTopForLineNumber const visibleRanges = modifiedEditor.getVisibleRanges(); const scrollTop = modifiedEditor.getScrollTop(); @@ -68,7 +70,7 @@ export function BlameColumn({ type Item = BlameItem | ChangeItem | FoldItem; const items: Item[] = []; - // 1) Blame 区块:跟 visible range 求交集 + // 1) Blame blocks: intersect with visible range for (const range of visibleRanges) { for (const block of blocks) { const from = Math.max(block.lineFrom, range.startLineNumber); @@ -86,8 +88,8 @@ export function BlameColumn({ } } - // 2) PR 改动行色带:在可见 range 内的部分画绿色竖条占位(不带文字,跟 Monaco - // diff 的"added"装饰呼应) + // 2) PR changed-line color band: draw a green vertical bar placeholder for the part within the + // visible range (no text, echoing Monaco diff's "added" decoration) for (const range of visibleRanges) { for (const [from0, to0] of changedRanges) { const from = Math.max(from0, range.startLineNumber); @@ -104,14 +106,14 @@ export function BlameColumn({ } } - // 3) 折叠占位行("X hidden lines"):相邻两个 visibleRange 之间一行的位置, - // 用斜纹/灰底标识"无效行"——这一行不对应 head 文件里任何 line,blame - // 自然没有。 + // 3) Fold placeholder line ("X hidden lines"): the position of the one line between two adjacent + // visibleRanges, marked with hatching/gray background as an "invalid line"—this line does not + // correspond to any line in the head file, so naturally has no blame. for (let i = 0; i < visibleRanges.length - 1; i++) { const cur = visibleRanges[i]!; const next = visibleRanges[i + 1]!; if (next.startLineNumber - cur.endLineNumber <= 1) continue; - // 占位行在 cur 的最后一行底部与 next 第一行顶部之间 + // The placeholder line sits between the bottom of cur's last line and the top of next's first line const yTop = modifiedEditor.getTopForLineNumber(cur.endLineNumber + 1) - scrollTop; const yBottom = modifiedEditor.getTopForLineNumber(next.startLineNumber) - scrollTop; if (yBottom <= yTop) continue; @@ -126,8 +128,8 @@ export function BlameColumn({ return ( <aside className="blame-column" - // --blame-lh = Monaco 的实际行高,让 blame-row 的 grid 行轨道 / line-height - // 都用同一个值,垂直跟 Monaco 第一行代码同高、同 baseline + // --blame-lh = Monaco's actual line height, so blame-row's grid row track / line-height + // both use the same value, vertically matching Monaco's first code line in height and baseline style={ { width: BLAME_COLUMN_WIDTH, @@ -186,8 +188,8 @@ function BlameRow({ height: number; connectionId: string; }) { - // 用 ISO 风格 YYYY-MM-DD:locale 无关、固定 10 字符,在 70px 列宽稳定显示。 - // toLocaleDateString 的中文输出 "2023年3月29日" 太宽会被截断。 + // Use ISO-style YYYY-MM-DD: locale-independent, fixed 10 characters, displays stably in the 70px column width. + // toLocaleDateString's Chinese output "2023年3月29日" is too wide and would be truncated. const dateStr = block.authorDate ? formatIsoDate(new Date(block.authorDate)) : ''; const title = `${block.author}\n${block.commit.slice(0, 12)}\n${block.summary}\n${ block.authorDate ? new Date(block.authorDate).toLocaleString() : '' diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/blame-utils.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/blame-utils.ts index 16567212..dabad0fa 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/blame-utils.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/blame/blame-utils.ts @@ -1,14 +1,14 @@ import type { DiffBlameLine } from '@meebox/ipc'; -/** Bitbucket 风格 blame 列宽:头像(20) + name(80) + sha(75) + date(45) + padding */ +/** Bitbucket-style blame column width: avatar(20) + name(80) + sha(75) + date(45) + padding */ export const BLAME_COLUMN_WIDTH = 240; export interface BlameLayout { - /** Monaco modified editor 可视高度 (px) */ + /** Monaco modified editor visible height (px) */ viewportHeight: number; - /** Monaco 当前行高 (px) */ + /** Monaco current line height (px) */ lineHeight: number; - /** Monaco 当前垂直滚动 (px) */ + /** Monaco current vertical scroll (px) */ scrollTop: number; } @@ -27,7 +27,7 @@ export function formatIsoDate(d: Date): string { return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } -/** 把行号列表合并为连续区段 [from, to],便于画色带(减少 DOM 节点) */ +/** Merge a list of line numbers into contiguous ranges [from, to] to ease drawing color bands (reduces DOM nodes) */ export function mergeContiguousLines(lines: number[]): Array<[number, number]> { if (lines.length === 0) return []; const sorted = [...lines].sort((a, b) => a - b); @@ -48,7 +48,7 @@ export function mergeContiguousLines(lines: number[]): Array<[number, number]> { return out; } -/** 合并连续同 commit 的 blame 行为区块(Bitbucket 风格:一个 commit 一格) */ +/** Merge contiguous blame lines of the same commit into blocks (Bitbucket-style: one cell per commit) */ export function groupBlameByCommit(blame: DiffBlameLine[]): BlameBlock[] { const sorted = [...blame].sort((a, b) => a.line - b.line); const blocks: BlameBlock[] = []; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/diff-types.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/diff-types.ts index a5157088..5f8639ce 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/diff-types.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/diff-types.ts @@ -1,8 +1,8 @@ import type { DiffChangedFile, DiffFileContent } from '@meebox/ipc'; /** - * diff 变更范围:'all' = PR 全部变更(merge-base..head);'commit' = 单个 commit 的 parent..sha。 - * commit 视图为只读 diff(行内评论 / 草稿锚定在 PR 全量 diff 行号上,不套用于单 commit 版本)。 + * diff change scope: 'all' = all PR changes (merge-base..head); 'commit' = a single commit's parent..sha. + * commit view is a read-only diff (inline comments / drafts are anchored to the full-PR diff line numbers, not applied to the single-commit version). */ export type DiffScope = | { kind: 'all' } @@ -19,7 +19,7 @@ export interface LoadedContent { head: DiffFileContent; } -/** 「查看特定 commit」请求载荷(parent 来自 PrCommit.parents[0],root commit 无 parent 为 null)。 */ +/** "View specific commit" request payload (parent comes from PrCommit.parents[0]; a root commit has no parent, so null). */ export interface PendingCommitView { sha: string; parent: string | null; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts index e47c1236..b5074e68 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts @@ -1,5 +1,5 @@ -// diff 业务 hooks 的 barrel:数据流(变更文件 / 内容 / 评论 / blame / 范围 / 跳转)与 -// 行内 view-zone 装配(评论 / 草稿 / hover「+」新建草稿)各自成 hook,由 DiffView 组合根聚合。 +// Barrel for diff business hooks: data flow (changed files / content / comments / blame / scope / navigation) and +// inline view-zone assembly (comments / drafts / hover "+" to create a draft) are each their own hook, aggregated by the DiffView composition root. export { useFileListWidth } from './useFileListWidth'; export { useSyncProgress } from './useSyncProgress'; export { useDiffScope, type DiffScopeState } from './useDiffScope'; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useBlame.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useBlame.ts index 01d47954..021b428e 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useBlame.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useBlame.ts @@ -15,9 +15,9 @@ export interface BlameState { } /** - * blame 数据 + Monaco 视图坐标同步。仅在开关开 + 文件有 head 内容时拉;deleted / 二进制不拉。 - * blameLayout 从 Monaco modified editor 同步 lineHeight / scrollTop / viewportHeight,供独立 - * React blame 列定位(见 BlameColumn)。 + * blame data + Monaco view coordinate sync. Only fetched when the toggle is on + the file has head content; deleted / binary are not fetched. + * blameLayout syncs lineHeight / scrollTop / viewportHeight from the Monaco modified editor, for positioning the independent + * React blame column (see BlameColumn). */ export function useBlame( pr: StoredPullRequest, @@ -34,13 +34,13 @@ export function useBlame( changedLines: number[]; } | null>(null); const [blameError, setBlameError] = useState<FormattedError | null>(null); - // Monaco modified editor 的视图坐标 (用于 React overlay 渲染 blame 列); - // null = blame 关 / blame 数据没好 / editor 未挂载 + // View coordinates of the Monaco modified editor (used by the React overlay to render the blame column); + // null = blame off / blame data not ready / editor not mounted const [blameLayout, setBlameLayout] = useState<BlameLayout | null>(null); - // 拉 blame:仅在开关开 + 文件有 head 内容时跑。deleted 文件 / 二进制不跑。 + // Fetch blame: only runs when the toggle is on + the file has head content. deleted files / binary do not run. useEffect(() => { - // 门控:切视图期间不拉 blame(同 content:避免新视图 + 旧文件错拉),保留旧 blame。 + // Gate: don't fetch blame while switching views (same as content: avoids mis-fetching new view + old file), keep old blame. if (loadedKey !== viewKey) return; if (!showBlame || !selected || !content || content.head.binary) { setBlame(null); @@ -70,9 +70,9 @@ export function useBlame( }; }, [showBlame, selected, content, pr.localId, loadedKey, viewKey, range]); - // Blame 走独立 React 列(Bitbucket 风格),不在 Monaco DOM 里。只需要从 Monaco - // 同步 lineHeight / scrollTop / viewportHeight,BlameColumn 自己用 absolute - // 子项画 row 并按 scrollTop 平移。 + // Blame uses an independent React column (Bitbucket-style), not inside the Monaco DOM. It only needs to + // sync lineHeight / scrollTop / viewportHeight from Monaco; BlameColumn draws rows with its own absolute + // children and shifts them by scrollTop. useEffect(() => { if (!diffEditor || !showBlame || !blame || blame.lines.length === 0) { setBlameLayout(null); @@ -91,7 +91,7 @@ export function useBlame( }); }; update(); - // 初次 mount 时 layout 可能还在计算,下一 tick 再算一次 + // On initial mount layout may still be computing, so recompute once on the next tick const t = setTimeout(update, 0); const subs = [ modifiedEditor.onDidScrollChange(update), diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useChangedFiles.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useChangedFiles.ts index 1be38305..05d78418 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useChangedFiles.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useChangedFiles.ts @@ -12,13 +12,13 @@ export interface ChangedFilesState { selectedKey: string | null; setSelectedKey: React.Dispatch<React.SetStateAction<string | null>>; selected: DiffChangedFile | null; - /** 已渲染视图标识:新 files 到位才推进到当前 viewKey,在此之前各 effect 门控、旧视图保活。 */ + /** Rendered view identifier: only advances to the current viewKey once new files land; before that each effect is gated and the old view is kept alive. */ loadedKey: string | null; } /** - * 拉变更文件列表 + 选中文件管理。切 PR / 切范围期间保留旧 files 渲染(stale-while-loading), - * 新 files 到位才把 loadedKey 推进到 viewKey、整体替换。 + * Fetch changed files list + selected file management. Keeps rendering old files while switching PR / scope (stale-while-loading), + * only advancing loadedKey to viewKey and replacing wholesale once new files land. */ export function useChangedFiles( pr: StoredPullRequest, @@ -31,7 +31,7 @@ export function useChangedFiles( const [selectedKey, setSelectedKey] = useState<string | null>(null); const [filesRetry, setFilesRetry] = useState(0); - // 拉变更文件列表 (fatal 失败 → 整个 diff 区域 fallback) + // Fetch changed files list (fatal failure → fallback for the entire diff area) useEffect(() => { let cancelled = false; setFilesError(null); @@ -39,9 +39,9 @@ export function useChangedFiles( .then((f) => { if (cancelled) return; setFiles(f); - // 新 files 到位才把「已渲染视图」推进到当前 viewKey —— 在此之前各 effect 门控、旧视图保活。 + // Only advance the "rendered view" to the current viewKey once new files land — before that each effect is gated and the old view is kept alive. setLoadedKey(viewKey); - // 选中项:仍存在则保留(同视图重试 / 切范围后同名文件仍在则不丢选中),否则回落首个。 + // Selection: keep it if still present (same-view retry / same-named file still there after scope switch keeps selection), otherwise fall back to the first. setSelectedKey((prev) => prev && f.some((x) => fileKey(x) === prev) ? prev : f.length > 0 ? fileKey(f[0]!) : null, ); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx index 8c7e1a35..53355f5e 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx @@ -11,8 +11,9 @@ import { mountInlineZones } from '../zones/mountInlineZones'; import type { LoadedContent } from '../diff-types'; /** - * 行内评论标记:评论锚定行 glyph margin 蓝点(hover 出 markdown 摘要)+ 行下方插 view zone 渲染 - * 评论内容。zone 挂载 / 清理走通用 mountInlineZones;glyph decorations 由本 hook 自管。 + * Inline comment markers: a blue dot in the glyph margin on the comment's anchored line (hover shows a + * markdown summary) + a view zone inserted below the line rendering the comment content. Zone mount / + * cleanup goes through the shared mountInlineZones; glyph decorations are managed by this hook itself. */ export function useCommentZones(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -25,11 +26,11 @@ export function useCommentZones(opts: { prWebUrl: string; renderSideBySide: boolean; commentHardBreaks: boolean; - /** 评论 emoji 反应模式(capabilities.commentReactions):'fixed'/'free' 才渲染加反应按钮;缺省 = 不支持。 */ + /** Comment emoji reaction mode (capabilities.commentReactions): only 'fixed'/'free' render the add-reaction button; absent = unsupported. */ reactionsMode?: 'fixed' | 'free'; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);透传给行内回复编辑框启用粘贴上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the inline reply editor to enable paste upload. */ attachmentsEnabled?: boolean; - /** 内容只读(decline / 不可参与归档 PR):行内评论 zone 隐藏回复 / 编辑 / 删除。 */ + /** Content read-only (declined / non-participatable archived PR): inline comment zones hide reply / edit / delete. */ readOnly?: boolean; }): void { const { @@ -76,8 +77,8 @@ export function useCommentZones(opts: { glyphMarginClassName: 'monaco-comment-glyph', glyphMarginHoverMessage: { value: renderHoverMd(cs) }, linesDecorationsClassName: 'monaco-comment-line-deco', - // 评论锚点行在滚动条总览标尺投一个蓝色刻度(与评论 glyph 同色系), - // 用户拖滚动条一眼可见「哪里有评论」;minimap 仍关闭。 + // The comment anchor line projects a blue tick on the scrollbar overview ruler (same color family as + // the comment glyph), so users see "where the comments are" at a glance when dragging; minimap stays off. overviewRuler: { color: '#3794ff', position: MonacoEditorNs.OverviewRulerLane.Right, @@ -101,8 +102,8 @@ export function useCommentZones(opts: { newByLine, zoneClassName: 'monaco-comment-zone', innerClassName: 'monaco-comment-zone-inner', - // 不拦 wheel —— 评论区 auto-size 无内部滚动,滚轮要冒泡给 Monaco 滚编辑器, - // 否则鼠标停在评论上时整个 diff 无法滚动(stopPropagation 会吃掉滚动)。 + // Don't intercept wheel — comment zones auto-size with no inner scroll, so the wheel must bubble to Monaco to + // scroll the editor, otherwise the whole diff can't scroll while hovering a comment (stopPropagation would eat the scroll). stopEvents: ['mousedown', 'mouseup', 'click', 'dblclick'], initialHeight: (cs, lineHeight) => Math.max(estimateZoneHeight(cs) * lineHeight, lineHeight * 3), @@ -126,7 +127,7 @@ export function useCommentZones(opts: { originalDecorations.clear(); modifiedDecorations.clear(); } catch { - // editor 已 dispose + // editor already disposed } cleanupZones(); }; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useConflictFiles.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useConflictFiles.ts index a01ee8e2..905319f6 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useConflictFiles.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useConflictFiles.ts @@ -3,8 +3,9 @@ import type { StoredPullRequest } from '@meebox/shared'; import { invoke } from '../../../../../../api'; /** - * 拉「合并会冲突的文件」路径集合,供文件树标三角警示。仅当远端判定 PR 有冲突(pr.hasConflict)时才打 - * 后端(后端再跑本地 merge-tree 试合并);无冲突直接给空集。失败保守返回空集(不标记,不报错)。 + * Fetch the set of paths for "files that will conflict on merge", for the file tree to mark with a warning + * triangle. Only hits the backend when the remote deems the PR conflicted (pr.hasConflict) (the backend then + * runs a local merge-tree trial merge); no conflict returns an empty set directly. On failure conservatively returns an empty set (no marking, no error). */ export function useConflictFiles(pr: StoredPullRequest): Set<string> { const [paths, setPaths] = useState<Set<string>>(() => new Set()); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts index c68d8829..46487f48 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts @@ -11,8 +11,8 @@ export interface DiffCommentsState { } /** - * 拉 PR 评论(inline + summary)。打开 / 范围切换后强制远端拉一次;订阅 comments:changed 重拉。 - * commit 只读视图不展示行内评论(锚定在 PR 全量 diff 行号上,套到单 commit 会错位)。 + * Fetch PR comments (inline + summary). Force a remote fetch once after open / scope switch; subscribe to comments:changed to refetch. + * The commit read-only view doesn't show inline comments (they anchor to the PR full-diff line numbers, so applying them to a single commit misaligns). */ export function useDiffComments( pr: StoredPullRequest, @@ -25,9 +25,9 @@ export function useDiffComments( const [commentsRetry, setCommentsRetry] = useState(0); useEffect(() => { - // 门控:切 PR 期间(新 files 未到)不拉新评论,保留旧评论与旧内容一致渲染,避免 view zone 错位。 + // Gate: during PR switch (new files not yet arrived) don't fetch new comments, keep old comments rendering consistent with old content, avoid view zone misalignment. if (loadedKey !== viewKey) return; - // commit 只读视图:行内评论锚定在 PR 全量 diff 行号上,套到单 commit 版本会错位,故不展示。 + // commit read-only view: inline comments anchor to the PR full-diff line numbers, applying them to a single commit version misaligns, so don't show. if (scopeKind !== 'all') { setComments([]); return; @@ -48,8 +48,8 @@ export function useDiffComments( }); }; fetchList(true); - // 评论 reply / 状态变更后 main 端 broadcast comments:changed,inline view zone - // 需要重拉刷新评论树 (含新 reply 嵌到父评论 .replies) + // After a comment reply / status change the main process broadcasts comments:changed, the inline view zone + // needs to refetch to refresh the comment tree (including new replies nested into the parent comment's .replies) const unsub = subscribe('comments:changed', (e) => { if (e.localId === pr.localId) fetchList(true); }); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts index b8abe604..11d8214d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts @@ -17,9 +17,9 @@ export interface PendingNav { } /** - * 跳转消费:来自 ChatPane → App.pendingDiffNav。设 selectedKey 切到目标文件 + 找关联草稿, - * 再由 reveal 副作用等 selected / diffEditor / drafts 就绪后 revealLine + 短暂高亮 + autoEdit。 - * 同时把 pendingScroll 暴露给跨文件搜索跳转(DiffSearchPanel)复用。 + * Navigation consumption: from ChatPane → App.pendingDiffNav. Set selectedKey to switch to the target file + find the associated draft, + * then the reveal side effect waits for selected / diffEditor / drafts to be ready before revealLine + brief highlight + autoEdit. + * Also exposes pendingScroll for cross-file search navigation (DiffSearchPanel) to reuse. */ export function useDiffNav(opts: { files: DiffChangedFile[] | null; @@ -56,9 +56,9 @@ export function useDiffNav(opts: { if (target) { setSelectedKey(fileKey(target)); } - // 查现有草稿;ChatPane 端已经懒创建过了,正常情况能找到。 - // 没传 runId/findingId (PublishReviewModal anchor 点击场景) → 直接跳过查找, - // draftId 留 undefined → 下游 effect 不会触发 autoEdit,纯 navigate + // Look up the existing draft; ChatPane has already lazily created it, so normally it's found. + // No runId/findingId passed (PublishReviewModal anchor click scenario) → skip the lookup directly, + // draftId stays undefined → downstream effect won't trigger autoEdit, pure navigate const matchingDraft = pendingNav.runId && pendingNav.findingId ? (drafts ?? []).find( @@ -69,19 +69,19 @@ export function useDiffNav(opts: { ) : undefined; setPendingScroll({ - // 取 endLine 跟草稿 zone / 发布锚点对齐(见 zone 行号注释),高亮行与草稿区同位 + // Take endLine to align with the draft zone / publish anchor (see zone line number comment), so the highlight line matches the draft zone line: pendingNav.anchor.endLine, side: 'new', draftId: matchingDraft?.id, }); onNavConsumed?.(); - // drafts 不放 dep —— nav 进来时已 ack;后续 drafts 变化不该重复触发本逻辑 + // drafts not in deps — already acked when nav arrives; later drafts changes shouldn't retrigger this logic // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingNav, files, onNavConsumed]); - // nav 完成消费:scroll + highlight + autoEdit 关联草稿。等 selected 文件 - // 切换 + content 加载 + diffEditor 就绪 + drafts hydrated 完,再 revealLine。 - // pendingScroll 来自 nav effect (setSelectedKey 同时设的);reveal 后清空 + // nav consumption complete: scroll + highlight + autoEdit the associated draft. Wait for the selected file + // switch + content load + diffEditor ready + drafts hydrated, then revealLine. + // pendingScroll comes from the nav effect (set alongside setSelectedKey); cleared after reveal useEffect(() => { if (!pendingScroll || !diffEditor || !content || !selected) return; const editor = @@ -92,12 +92,12 @@ export function useDiffNav(opts: { let highlightTimer: ReturnType<typeof setTimeout> | undefined; let revealed = false; const reveal = () => { - // onDidUpdateDiff 可能多次触发,只跳一次 + // onDidUpdateDiff may fire multiple times, only jump once if (revealed) return; revealed = true; - // 居中滚到目标行 + // Center-scroll to the target line editor.revealLineInCenter(pendingScroll.line); - // 短暂高亮:300ms 黄底脉冲 + // Brief highlight: 300ms yellow-background pulse const collection = editor.createDecorationsCollection([ { range: { @@ -119,17 +119,17 @@ export function useDiffNav(opts: { /* editor disposed */ } }, 800); - // 同时触发关联草稿的 autoEdit (DraftZone 自动 enter edit mode) + // Also trigger autoEdit for the associated draft (DraftZone auto-enters edit mode) if (pendingScroll.draftId) { triggerAutoEdit(pendingScroll.draftId); } setPendingScroll(null); }; - // Monaco diff 是异步算的:models 挂上(onMount)后还要等 diff 计算 + - // hideUnchangedRegions 折叠布局完成,行号到视口位置的映射才稳定。此时 - // 直接 revealLine 会定位到旧布局/错误位置。getLineChanges() 在算完前 - // 返回 null、算完返回数组 → 已就绪直接跳,否则等 onDidUpdateDiff 首次触发。 + // Monaco diff is computed asynchronously: after models mount (onMount) it still waits for diff computation + + // hideUnchangedRegions collapse layout to complete before the line-number-to-viewport-position mapping is stable. At this point + // revealLine directly would locate to the old layout / wrong position. getLineChanges() returns null before + // computation finishes, returns an array after → already ready jump directly, otherwise wait for onDidUpdateDiff to first fire. if (diffEditor.getLineChanges() != null) { reveal(); return () => { @@ -141,7 +141,7 @@ export function useDiffNav(opts: { disposable.dispose(); if (highlightTimer) clearTimeout(highlightTimer); }; - // triggerAutoEdit 不入 deps —— 它每次 render 换新引用,列进去会让 reveal 每帧重跑、反复定位高亮 + // triggerAutoEdit not in deps — it changes reference every render, including it would make reveal rerun every frame, repeatedly locating/highlighting // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingScroll, diffEditor, content, selected]); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts index 40f5e6bc..7c7f842f 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts @@ -3,26 +3,26 @@ import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-ed import type { DiffChangedFile } from '@meebox/ipc'; import type { LoadedContent } from '../diff-types'; -// 增/改绿、删红(与 GitHub diff 配色同系)。diff 标记走 overview ruler 的 Left 道, -// 跟评论锚点(Right 道,见 useCommentZones)分列,互不遮挡。 +// Add/change green, delete red (same color family as GitHub diff). diff marks go on the overview ruler's Left lane, +// separate from comment anchors (Right lane, see useCommentZones), so they don't overlap. const ADDED_COLOR = '#3fb950'; const REMOVED_COLOR = '#f85149'; /** - * 把 diff 增/删/改投影到内层编辑器自带的 overview ruler(编辑模式风格,单条滚动条标尺), - * 替代 diff 专属的 renderOverviewRuler 宽列(那条在并排视图会多出独立列,见 DiffPane)。 + * Project diff add/delete/change onto the inner editor's built-in overview ruler (edit-mode style, single scrollbar ruler), + * replacing the diff-specific renderOverviewRuler wide column (that one adds a separate column in side-by-side view, see DiffPane). * - * - modified 编辑器:增 / 改行绿色;纯删在删除点打一条红 tick - * - 并排视图下 original 编辑器:删 / 改行红色 + * - modified editor: add / change lines green; pure delete draws a red tick at the delete point + * - original editor under side-by-side view: delete / change lines red * - * 注意:`renderSideBySide` 是用户在工具栏选的「并排 / 统一」**意向**,而 Monaco 在宽度不足时会 - * 自动把并排降级成 inline/unified 布局(useInlineViewWhenSpaceIsLimited 默认开),此时意向仍为 - * 并排但实际渲染是统一。若按意向把删除红标画到 original 编辑器,降级后 original 不可见 → 红标全丢、 - * 滚动条只剩绿色。故按 **实际渲染模式** 决定红标去向:Monaco 把实际模式反映在 `.monaco-diff-editor` - * 根节点的 `side-by-side` class 上(降级 inline 时去掉),据此判定,而非用户意向 prop。 + * Note: `renderSideBySide` is the user's toolbar-selected "side-by-side / unified" **intent**, while Monaco, when width is insufficient, will + * automatically downgrade side-by-side to inline/unified layout (useInlineViewWhenSpaceIsLimited on by default), where the intent is still + * side-by-side but the actual rendering is unified. If we draw the delete red mark to the original editor by intent, after downgrade original is invisible → red marks all lost, + * scrollbar left with green only. So decide red-mark placement by the **actual render mode**: Monaco reflects the actual mode in the `.monaco-diff-editor` + * root node's `side-by-side` class (removed when downgrading to inline), judge by that rather than the user intent prop. * - * diff 异步算完后 getLineChanges() 才有值;首帧已算完直接刷,否则等 onDidUpdateDiff。布局在断点处 - * 切换并排 ↔ 统一时(onDidLayoutChange)按新模式重画红标(rAF 合并、待 class 切换稳定后再读)。 + * getLineChanges() only has a value after the async diff finishes; if the first frame already finished refresh directly, otherwise wait for onDidUpdateDiff. When the layout + * switches side-by-side ↔ unified at a breakpoint (onDidLayoutChange) redraw red marks by the new mode (rAF-coalesced, read after the class switch stabilizes). */ export function useDiffOverviewMarks(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -38,8 +38,8 @@ export function useDiffOverviewMarks(opts: { const modCol = modifiedEditor.createDecorationsCollection([]); const origCol = originalEditor.createDecorationsCollection([]); - // 实际是否并排:读 Monaco 反映实际渲染模式的 `.monaco-diff-editor.side-by-side` class - // (宽度不足自动降级 inline 时去掉该 class);取不到时回退用户意向 prop。 + // Whether actually side-by-side: read Monaco's `.monaco-diff-editor.side-by-side` class that reflects the actual render mode + // (the class is removed when auto-downgrading to inline on insufficient width); fall back to the user intent prop when unavailable. const isSideBySide = (): boolean => { if (!renderSideBySide) return false; const el = diffEditor.getContainerDomNode().querySelector('.monaco-diff-editor'); @@ -63,25 +63,25 @@ export function useDiffOverviewMarks(opts: { const modDecos: MonacoEditor.IModelDeltaDecoration[] = []; const origDecos: MonacoEditor.IModelDeltaDecoration[] = []; for (const c of changes) { - const isInsert = c.originalEndLineNumber === 0; // 纯增(原始侧无行) - const isDelete = c.modifiedEndLineNumber === 0; // 纯删(修改侧无行) - // 增 / 改:modified 侧 modifiedStart..End 标绿(左道) + const isInsert = c.originalEndLineNumber === 0; // pure add (no line on original side) + const isDelete = c.modifiedEndLineNumber === 0; // pure delete (no line on modified side) + // add / change: modified side modifiedStart..End marked green (Left lane) if (!isDelete) { modDecos.push( deco(c.modifiedStartLineNumber, c.modifiedEndLineNumber, ADDED_COLOR, Lane.Left), ); } - // 删 / 改的「移除部分」标红: + // mark the "removed part" of delete / change red: if (!isInsert) { if (sideBySide) { - // 并排:红画在左侧 original 编辑器自带 ruler(左道),与右侧绿互不干扰 + // side-by-side: red drawn on the left original editor's built-in ruler (Left lane), doesn't interfere with the right-side green origDecos.push( deco(c.originalStartLineNumber, c.originalEndLineNumber, REMOVED_COLOR, Lane.Left), ); } else { - // 统一视图(含并排降级而来):original 编辑器不可见,红 tick 与绿同画在 modified 左道。 - // 被删行在 unified 下是 view zone(无 model 行号),只能标在删除点 modifiedStartLineNumber; - // 改块同一行既绿又红时绿覆盖红 → 改块呈绿、纯删那行(无绿)呈红。 + // unified view (including downgraded from side-by-side): original editor invisible, red tick and green both drawn on modified Left lane. + // Deleted lines under unified are view zones (no model line number), can only be marked at the delete point modifiedStartLineNumber; + // when a change block's same line is both green and red, green covers red → change block shows green, pure-delete line (no green) shows red. const line = Math.max(1, c.modifiedStartLineNumber); modDecos.push(deco(line, line, REMOVED_COLOR, Lane.Left)); } @@ -93,8 +93,8 @@ export function useDiffOverviewMarks(opts: { if (diffEditor.getLineChanges() != null) refresh(); const disp = diffEditor.onDidUpdateDiff(refresh); - // 宽度跨断点导致并排 ↔ 统一切换时,红标去向随之改变 → 重画。layout 事件可能早于 Monaco 切 - // `side-by-side` class,故用 rAF 推迟到本帧末再读 class;rAF 同时合并 resize 期间的高频事件。 + // When width crosses a breakpoint causing side-by-side ↔ unified switch, red-mark placement changes accordingly → redraw. The layout event may precede Monaco switching + // the `side-by-side` class, so use rAF to defer reading the class to the end of this frame; rAF also coalesces high-frequency events during resize. let raf = 0; const scheduleRefresh = (): void => { if (raf) return; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffScope.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffScope.ts index 2a6db0ad..fc4fba38 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffScope.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffScope.ts @@ -6,27 +6,27 @@ import type { DiffScope, PendingCommitView } from '../diff-types'; export interface DiffScopeState { scope: DiffScope; setScope: (scope: DiffScope) => void; - /** 范围下拉用的 commit 列表(懒加载:首次展开下拉才拉) */ + /** Commit list for the scope dropdown (lazy-loaded: fetched only on first dropdown expand) */ scopeCommits: PrCommit[] | null; loadScopeCommits: () => void; - /** 当前视图标识 = PR + 范围。切 PR 或切范围都视为内容换新,驱动 stale-while-loading。 */ + /** Current view identifier = PR + scope. Switching PR or scope is both treated as content refresh, driving stale-while-loading. */ viewKey: string; - /** commit 视图的 diff 范围(parent..sha);'all' 或 root commit(无 parent)为 null → 走 PR 默认范围。 */ + /** commit view's diff range (parent..sha); 'all' or root commit (no parent) is null → uses the PR default range. */ range: { base: string; head: string } | null; } /** - * diff 变更范围状态:全部变更 / 单个 commit。含切 PR 复位、消费外部「查看特定 commit」请求、 - * 懒加载范围下拉的 commit 列表。 + * diff change scope state: all changes / a single commit. Includes PR-switch reset, consuming external "view a specific commit" requests, + * and lazy-loading the commit list for the scope dropdown. */ export function useDiffScope( pr: StoredPullRequest, pendingCommitView: PendingCommitView | null | undefined, onCommitViewConsumed: (() => void) | undefined, ): DiffScopeState { - // 变更范围:全部变更 / 单个 commit。commit 视图为只读 diff(见 DiffScope)。 + // Change scope: all changes / a single commit. commit view is a read-only diff (see DiffScope). const [scope, setScope] = useState<DiffScope>({ kind: 'all' }); - // 范围下拉用的 commit 列表(懒加载:首次展开下拉才拉)。 + // Commit list for the scope dropdown (lazy-loaded: fetched only on first dropdown expand). const [scopeCommits, setScopeCommits] = useState<PrCommit[] | null>(null); const viewKey = useMemo( () => (scope.kind === 'all' ? `${pr.localId}|all` : `${pr.localId}|c:${scope.sha}`), @@ -47,13 +47,13 @@ export function useDiffScope( }); }, [pr.localId]); - // 切 PR 回到「全部变更」范围,并丢弃旧 PR 的范围下拉 commit 列表 + // On PR switch return to the "all changes" scope, and discard the old PR's scope-dropdown commit list useEffect(() => { setScope({ kind: 'all' }); setScopeCommits(null); }, [pr.localId]); - // 消费外部「查看特定 commit」请求(提交 / 活动标签页点击 commit)→ 切到该 commit 范围。 + // Consume external "view a specific commit" requests (commit / activity tab clicking a commit) → switch to that commit's scope. useEffect(() => { if (!pendingCommitView) return; setScope({ diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftAutoEdit.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftAutoEdit.ts index f6b5a8fa..88a47655 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftAutoEdit.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftAutoEdit.ts @@ -7,19 +7,19 @@ export interface DraftAutoEdit { } /** - * autoEdit 触发器表:draft.id → "进入编辑模式" fn。供两个来源用: - * 1. ChatPane → App.pendingDiffNav 跳转完后,目标 draft 自动 enter edit - * 2. 行 hover '+' 创建 manual draft 后立即 enter edit (新草稿空 body 必须能输入) + * autoEdit trigger table: draft.id → "enter edit mode" fn. Used by two sources: + * 1. ChatPane → after App.pendingDiffNav navigation completes, the target draft auto-enters edit + * 2. after a line hover '+' creates a manual draft, immediately enter edit (a new draft with empty body must be typeable) * - * 用 ref-based fn 而不是 state token。token 方案曾导致 bug:用户取消 → auto save → drafts store - * 变 → DiffView re-render → DraftZone unmount/mount → 新 instance 看到 props token 仍非 undefined - * 又 setIsEditing(true) → 用户看似"取消没生效"。ref-fn 调用纯副作用,不引发 re-render。 + * Uses a ref-based fn rather than a state token. The token approach once caused a bug: user cancels → auto save → drafts store + * changes → DiffView re-renders → DraftZone unmount/mount → the new instance sees the props token still non-undefined + * and calls setIsEditing(true) again → user perceives "cancel didn't take effect". A ref-fn call is a pure side effect, triggering no re-render. */ export function useDraftAutoEdit(pr: StoredPullRequest): DraftAutoEdit { const editTriggerFnsRef = useRef<Map<string, () => void>>(new Map()); - // pending trigger 兜底:triggerAutoEdit 调用时 DraftZone 还没 mount + register - // (典型场景:hover '+' 创建后立即 trigger,drafts store 异步更新)。fn 不在 map - // 时把 id 加 pending;registerEditTrigger 时如果发现自己 pending 立即 fire + // pending trigger fallback: when triggerAutoEdit is called, the DraftZone has not yet mounted + registered + // (typical scenario: trigger immediately after a hover '+' creation, while the drafts store updates asynchronously). When fn is not in the map, + // add the id to pending; on registerEditTrigger, if it finds itself pending, fire immediately const pendingTriggersRef = useRef<Set<string>>(new Set()); const registerEditTrigger = useCallback((draftId: string, fn: (() => void) | null): void => { if (fn) { @@ -41,7 +41,7 @@ export function useDraftAutoEdit(pr: StoredPullRequest): DraftAutoEdit { } }; - // PR 切换清掉所有 trigger fn 引用 + pending (新 PR 的 DraftZone 会重新注册) + // PR switch clears all trigger fn references + pending (the new PR's DraftZone will re-register) useEffect(() => { editTriggerFnsRef.current.clear(); pendingTriggersRef.current.clear(); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx index 8eda7a5f..78454682 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx @@ -7,11 +7,11 @@ import { mountInlineZones } from '../zones/mountInlineZones'; import type { LoadedContent } from '../diff-types'; /** - * 内联草稿 view zones(蓝底、editable)。跟评论 zone 同套 mountInlineZones 机制,按 anchor.side - * 分桶、用 endLine 作 zone 行号(跟发布锚点对齐,WYSIWYG)。 + * Inline draft view zones (blue background, editable). Uses the same mountInlineZones mechanism as comment zones, bucketed by anchor.side, + * with endLine as the zone line number (aligned with the publish anchor, WYSIWYG). * - * 不渲染 rejected(用户决断不发)/ posted(远端评论已由 CommentZone 接管,再渲染视觉重复)。 - * commit 只读视图(scopeKind !== 'all')不渲染草稿(锚定在 PR 全量 diff 行号上,不套用于单 commit)。 + * Does not render rejected (user decided not to send) / posted (the remote comment is already taken over by CommentZone; re-rendering would be visually duplicate). + * The commit read-only view (scopeKind !== 'all') does not render drafts (anchored on the PR full-diff line numbers, not applicable to a single commit). */ export function useDraftZones(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -22,7 +22,7 @@ export function useDraftZones(opts: { registerEditTrigger: (draftId: string, fn: (() => void) | null) => void; renderSideBySide: boolean; commentHardBreaks: boolean; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);透传给草稿编辑框启用粘贴 / 选取上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the draft editor to enable paste / picker upload. */ attachmentsEnabled?: boolean; scopeKind: 'all' | 'commit'; }): void { @@ -41,7 +41,7 @@ export function useDraftZones(opts: { useEffect(() => { if (!diffEditor || !content || !selected) return; - // commit 只读视图:不渲染本地草稿 zone(草稿锚定在 PR 全量 diff 行号上,不套用于单 commit)。 + // commit read-only view: does not render local draft zones (drafts are anchored on the PR full-diff line numbers, not applicable to a single commit). if (scopeKind !== 'all') return; const fileDrafts = (drafts ?? []).filter((d) => { if (d.status === 'rejected' || d.status === 'posted') return false; @@ -53,9 +53,9 @@ export function useDraftZones(opts: { const newByLine = new Map<number, ReviewDraft[]>(); for (const d of fileDrafts) { const target = d.anchor.side === 'old' ? oldByLine : newByLine; - // 用 endLine 作为 zone 行号,跟发布锚点对齐 —— publishInlineComment 用 anchor.endLine 发到 - // 远端,草稿区也落 endLine 即「预览位置 = 最终发布位置」(WYSIWYG)。nav reveal 高亮行同样取 - // endLine,二者视觉一致,跨多行 finding (startLine=403, endLine=425) 也不会起止错位。 + // Use endLine as the zone line number, aligned with the publish anchor — publishInlineComment sends to + // the remote using anchor.endLine, and the draft zone also lands on endLine, so "preview position = final publish position" (WYSIWYG). nav reveal highlights the same + // endLine, so the two are visually consistent, and a multi-line finding (startLine=403, endLine=425) won't have its start/end misplaced. const arr = target.get(d.anchor.endLine) ?? []; arr.push(d); target.set(d.anchor.endLine, arr); @@ -89,8 +89,8 @@ export function useDraftZones(opts: { /> ), }); - // 不依赖 autoEditTokens / registerEditTrigger 引发的 zone 重建(registerEditTrigger 是稳定的 - // useCallback)——避免 trigger 引发 DraftZone unmount/mount,根除取消后重入 edit 模式的 race。 + // Does not depend on zone rebuilds triggered by autoEditTokens / registerEditTrigger (registerEditTrigger is a stable + // useCallback) — avoids trigger-induced DraftZone unmount/mount, eliminating the race of re-entering edit mode after cancel. }, [ diffEditor, drafts, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileContent.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileContent.ts index 11ae5c2f..ecd88c78 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileContent.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileContent.ts @@ -12,7 +12,7 @@ export interface FileContentState { setContentError: (v: FormattedError | null) => void; } -/** 读取选中文件 base / head 两侧内容。切视图期间门控(loadedKey !== viewKey)保留旧内容。 */ +/** Reads the base / head content of the selected file. During a view switch it is gated (loadedKey !== viewKey), keeping the old content. */ export function useFileContent( pr: StoredPullRequest, selected: DiffChangedFile | null, @@ -25,8 +25,8 @@ export function useFileContent( const [contentError, setContentError] = useState<FormattedError | null>(null); useEffect(() => { - // 门控:切视图期间(新 files 未到、selected 仍指向旧文件)不拉内容,保留旧内容渲染, - // 避免用「新视图 + 旧文件路径」错拉。新 files ready 后 selected 切到新文件再拉。 + // Gate: during a view switch (new files not yet arrived, selected still points to the old file), do not fetch content, keep rendering the old content, + // avoiding a wrong fetch with "new view + old file path". After the new files are ready and selected switches to the new file, fetch then. if (loadedKey !== viewKey) return; if (!selected) { setContent(null); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileListWidth.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileListWidth.ts index 74d0ed1d..99b534b0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileListWidth.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useFileListWidth.ts @@ -4,7 +4,7 @@ const DIFF_FILE_LIST_MIN = 180; const DIFF_FILE_LIST_MAX = 560; const DIFF_FILE_LIST_DEFAULT = 280; -/** 左侧文件列表宽度:localStorage 持久化 + 拖拽柄 resize(夹在 MIN/MAX 之间)。 */ +/** Left file list width: localStorage-persisted + drag handle resize (clamped between MIN/MAX). */ export function useFileListWidth(): { fileListWidth: number; startFileListResize: (e: ReactMouseEvent) => void; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useLineCommentAdder.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useLineCommentAdder.ts index ae2bbc9c..27dd0073 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useLineCommentAdder.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useLineCommentAdder.ts @@ -8,14 +8,14 @@ import { invoke } from '../../../../../../api'; import type { LoadedContent } from '../diff-types'; /** - * 行 hover '+' 新建 manual 草稿:modifiedEditor (head 侧) + 并排视图下 originalEditor (base 侧) - * 上加 mousemove + mousedown 监听。**已有评论的行仍可继续追加**(hover 照常出 +,新草稿 zone 挂在评论 - * zone 之下、按时间序在已有评论下方);仅「已有未发布草稿」的行不重复出 +(避免同行两个编辑器)。点击 → - * drafts:create + autoEdit 立即进入编辑。 + * Line hover '+' to create a manual draft: attaches mousemove + mousedown listeners on modifiedEditor (head side) + * and, in side-by-side view, originalEditor (base side). **Lines that already have comments can still be appended to** (hover still shows +, the new draft zone mounts below the comment + * zone, in time order beneath existing comments); only lines that "already have an unpublished draft" do not show + again (to avoid two editors on the same line). Click → + * drafts:create + autoEdit immediately enters edit. * - * Platform policy 过滤:Bitbucket 只允许 hunk 内的行加 inline comment;GitHub/GitLab 宽松。从 - * diffEditor.getLineChanges() 拿 hunks,不允许的行不画 glyph、点击也不创建草稿。commit 只读视图 - * (scopeKind !== 'all')不挂。 + * Platform policy filter: Bitbucket only allows lines inside a hunk to have an inline comment; GitHub/GitLab are lenient. Gets + * hunks from diffEditor.getLineChanges(); disallowed lines get no glyph, and clicking creates no draft. The commit read-only view + * (scopeKind !== 'all') is not wired. */ export function useLineCommentAdder(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -26,7 +26,7 @@ export function useLineCommentAdder(opts: { platform: PlatformKind; scopeKind: 'all' | 'commit'; renderSideBySide: boolean; - /** 内容只读(decline / 不可参与归档 PR):不挂行 hover '+' 新建评论草稿。 */ + /** Content read-only (declined / non-participable archived PR): does not wire line hover '+' to create comment drafts. */ readOnly?: boolean; triggerAutoEdit: (draftId: string) => void; t: TFunction; @@ -47,29 +47,29 @@ export function useLineCommentAdder(opts: { useEffect(() => { if (!diffEditor || !content || !selected) return; - // commit 只读视图:不挂行 hover '+' 新建草稿(草稿锚点属于 PR 全量 diff,不在单 commit 上创建)。 - // 内容只读(decline / 不可参与归档 PR):同样不挂 '+'。 + // commit read-only view: does not wire line hover '+' to create drafts (draft anchors belong to the PR full diff, not created on a single commit). + // Content read-only (declined / non-participable archived PR): likewise does not wire '+'. if (scopeKind !== 'all' || readOnly) return; const modifiedEditor = diffEditor.getModifiedEditor(); const originalEditor = diffEditor.getOriginalEditor(); - // 仅「已有未发布草稿」的行算占用、不重复出 +(避免同行两个编辑器);已有远端评论的行不算占用—— - // 允许继续追加新评论(新草稿 zone 会挂在评论 zone 之下,按时间序展示在已有评论下方)。 + // Only lines that "already have an unpublished draft" count as occupied and do not show + again (to avoid two editors on the same line); lines with existing remote comments do not count as occupied — + // allowing new comments to be appended (the new draft zone mounts below the comment zone, shown in time order beneath existing comments). const occupiedNew = new Set<number>(); const occupiedOld = new Set<number>(); for (const d of drafts ?? []) { if (d.status === 'rejected') continue; - // 跟 zone 创建时一致用 startLine — 之前用 endLine 会让 hover '+' 把行 403 - // (finding 起始) 当未占用错画 +;finding 跨多行场景下两个 + 同时出现 + // Use startLine consistently with zone creation — previously using endLine would make hover '+' treat line 403 + // (finding start) as unoccupied and wrongly draw +; in a multi-line finding scenario two + would appear at once (d.anchor.side === 'old' ? occupiedOld : occupiedNew).add(d.anchor.startLine); } - // 把 monaco ILineChange[] 翻成 DiffHunkRange[]。LineChange 的 EndLineNumber=0 - // 表示该侧无对应(纯增/纯删),翻成 null range。 + // Translate monaco ILineChange[] into DiffHunkRange[]. A LineChange EndLineNumber=0 + // means that side has no counterpart (pure add / pure delete), translated into a null range. // - // **关键**:useEffect 首次执行时 monaco diff 还在异步计算,getLineChanges() 可能 - // 返回 null/[] → 用 Bitbucket policy 严格判会让"所有行都不允许" → 用户看不到任何 +。 - // 监听 onDidUpdateDiff 在 diff 算完后刷新 hunks (mutable let,闭包引用最新值)。 - // 同时:hunks 为空时**兜底允许**(视为 policy 暂不可用),等 update 事件来再收紧 + // **Key**: on the first useEffect run, the monaco diff is still computing asynchronously, and getLineChanges() may + // return null/[] → a strict Bitbucket policy check would make "all lines disallowed" → the user sees no + at all. + // Listen to onDidUpdateDiff to refresh hunks after the diff finishes (mutable let, closure references the latest value). + // Meanwhile: when hunks is empty, **fall back to allowing** (treated as policy temporarily unavailable), tightening once the update event arrives const policy = policyForPlatform(platform); const computeHunks = (): DiffHunkRange[] => { const lineChanges = diffEditor.getLineChanges() ?? []; @@ -91,15 +91,15 @@ export function useLineCommentAdder(opts: { const disposers: Array<() => void> = []; - // 在指定编辑器 + 侧别上挂「hover 出 + / 点击建草稿」。modified=new(新增/上下文行), - // original=old(删除/上下文行,仅并排视图可点 —— 统一视图下原始编辑器隐藏、删除行是 view zone 无行号可 hover)。 + // Wire "hover shows + / click creates draft" on a given editor + side. modified=new (added/context lines), + // original=old (deleted/context lines, clickable only in side-by-side view — in unified view the original editor is hidden, and deleted lines are view zones with no line number to hover). const wireAdder = ( editorInst: MonacoEditor.ICodeEditor, side: 'old' | 'new', occupied: Set<number>, ): void => { - /** 兜底允许:hunks 还没算完(空数组)就一律允许,避免初始"什么都点不出来"。 - * 正常加载完 hunks 非空后才走 policy 严格判 */ + /** Fallback allow: while hunks are not yet computed (empty array), allow everything, avoiding the initial "nothing is clickable". + * Only after normal loading, when hunks is non-empty, does it run the strict policy check */ const isAllowed = (line: number): boolean => hunks.length === 0 || policy.isLineAllowed(hunks, side, line); @@ -121,8 +121,8 @@ export function useLineCommentAdder(opts: { }, options: { isWholeLine: false, - // 用 glyphMarginClassName 跟 commentZone (远端评论) 一致 —— 渲染在 - // editor 最左 glyph margin 列 (跟 GitHub 评论 "+" 位置惯例一致)。 + // Use glyphMarginClassName consistent with commentZone (remote comments) — rendered in + // the editor's leftmost glyph margin column (matching the GitHub comment "+" position convention). glyphMarginClassName: 'monaco-draft-add-glyph', glyphMarginHoverMessage: { value: t('diffView.addCommentHint') }, }, @@ -169,10 +169,10 @@ export function useLineCommentAdder(opts: { status: 'pending', }, }); - // 新建后立即触发 auto edit,让用户能马上输入 + // Trigger auto edit immediately after creation, so the user can type right away triggerAutoEdit(created.id); } catch { - // 静默;UI 上没出 zone 就视为没创建成功 + // Silent; if no zone appears in the UI, treat it as a failed creation } })(); } @@ -190,8 +190,8 @@ export function useLineCommentAdder(opts: { }); }; - // 新增 / 上下文行(head 侧)始终可点;删除 / 上下文行(base 侧)仅并排视图可点 - // (统一视图原始编辑器隐藏,删除行以 view zone 呈现、无可 hover 的行号)。 + // Added / context lines (head side) are always clickable; deleted / context lines (base side) are clickable only in side-by-side view + // (in unified view the original editor is hidden, and deleted lines are presented as view zones with no hoverable line number). wireAdder(modifiedEditor, 'new', occupiedNew); if (renderSideBySide) { wireAdder(originalEditor, 'old', occupiedOld); @@ -201,7 +201,7 @@ export function useLineCommentAdder(opts: { diffUpdateDisp.dispose(); for (const dispose of disposers) dispose(); }; - // triggerAutoEdit 不入 deps —— 它每次 render 换新引用,列进去会让本 effect 每帧重挂监听 + // triggerAutoEdit is not in deps — it changes reference every render, so listing it would make this effect re-attach listeners every frame // eslint-disable-next-line react-hooks/exhaustive-deps }, [ diffEditor, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSelectionCapture.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSelectionCapture.ts index c2102d58..67a57620 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSelectionCapture.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSelectionCapture.ts @@ -6,20 +6,20 @@ import { selectionStore } from '../../../../../../stores/selection-store'; const DEBOUNCE_MS = 120; /** - * 捕获 Diff 里的代码选区 → 写入 selectionStore,供 ChatPane 把选中代码作为隐式上下文带进提问。 + * Captures the code selection in the Diff → writes it to selectionStore, so ChatPane can carry the selected code as implicit context into a question. * - * 监听内层两个子编辑器的 onDidChangeCursorSelection(modified=新/head、original=基线/old);选区空 - * (塌缩成光标、无文本)→ clear,否则去抖 ~120ms 后写入。Monaco diff 子编辑器的行号即该侧显示文件 - * 行号,无需 hunk 映射。文件 / PR 切换或卸载时(依赖变更触发 cleanup)一并清空,避免陈旧选区残留。 + * Listens to onDidChangeCursorSelection on the two inner sub-editors (modified=new/head, original=base/old); an empty selection + * (collapsed to a cursor, no text) → clear, otherwise write after a ~120ms debounce. A Monaco diff sub-editor's line number is that side's displayed file + * line number, so no hunk mapping is needed. On file / PR switch or unmount (dependency change triggers cleanup), it clears too, avoiding stale selection residue. * - * 刻意**不**接「可评论区域」守卫(useLineCommentAdder 的 isAllowed):行内评论受平台 anchor 约束、 - * 只能挂在 diff 命中行;而选区引用只作模型上下文、不回写远端,任意可选代码(含未改动上下文行)皆可 - * 引用——恰恰这些上下文对提问最有用,不应被评论约束挡掉。 + * Deliberately does **not** apply the "commentable region" guard (useLineCommentAdder's isAllowed): inline comments are constrained by the platform anchor + * and can only attach to diff-hit lines; whereas a selection reference is only model context and is not written back to the remote, so any selectable code (including unchanged context lines) can be + * referenced — precisely that context is most useful for a question and should not be blocked by the comment constraint. * - * 统一(inline)视图下,Monaco 经典 inline diff 把删除行渲染为 modified 编辑器内的 view-zone(非任何 - * model 的文本行),original 编辑器宽度归 0 → 删除行无法被光标选中。为让删除内容也能「像添加行一样」被 - * 引用:head 选区跨到删除/改动 hunk 时,据 getLineChanges() 把对应基线行从 original model 取出,作为 - * removed 一并写入选区(见 spannedDeletions)。并排视图删除行可直接在 original 编辑器选中,故不做此增补。 + * In unified (inline) view, Monaco's classic inline diff renders deleted lines as view-zones inside the modified editor (not the text line of any + * model), and the original editor width collapses to 0 → deleted lines cannot be cursor-selected. To let deleted content also be referenced "like added lines": + * when the head selection spans a deletion/change hunk, based on getLineChanges() the corresponding base lines are taken from the original model and written into the selection as + * removed (see spannedDeletions). In side-by-side view, deleted lines can be selected directly in the original editor, so this augmentation is not done. */ export function useSelectionCapture(opts: { diffEditor: MonacoEditor.IStandaloneDiffEditor | null; @@ -34,7 +34,7 @@ export function useSelectionCapture(opts: { const original = diffEditor.getOriginalEditor(); let timer: ReturnType<typeof setTimeout> | null = null; - // 统一视图:求 head 选区 [s,e] 跨到的删除/改动 hunk 的基线侧原始行(含真实代码)。 + // Unified view: find the base-side original lines (with real code) of the deletion/change hunks that the head selection [s,e] spans. const spannedDeletions = ( s: number, e: number, @@ -43,11 +43,11 @@ export function useSelectionCapture(opts: { if (!origModel) return undefined; const segs: Array<{ oStart: number; oEnd: number }> = []; for (const c of diffEditor.getLineChanges() ?? []) { - // 有删除/改动的基线行(纯新增 originalEndLineNumber===0 → 跳过)。 + // Base lines with deletion/change (pure addition originalEndLineNumber===0 → skip). if (c.originalStartLineNumber <= 0 || c.originalEndLineNumber <= 0) continue; const modStart = c.modifiedStartLineNumber; const modEnd = c.modifiedEndLineNumber > 0 ? c.modifiedEndLineNumber : c.modifiedStartLineNumber; - // 该 hunk 在 modified 侧的落点与选区 [s,e] 有交叠 → 视为被选区跨到。 + // The hunk's landing on the modified side overlaps the selection [s,e] → treated as spanned by the selection. if (modEnd < s || modStart > e) continue; segs.push({ oStart: c.originalStartLineNumber, oEnd: c.originalEndLineNumber }); } @@ -70,18 +70,18 @@ export function useSelectionCapture(opts: { const capture = (ed: MonacoEditor.ICodeEditor, side: 'old' | 'new'): void => { const sel = ed.getSelection(); const model = ed.getModel(); - // 空选区(光标塌缩、无选中文本)→ 清空。 + // Empty selection (cursor collapsed, no selected text) → clear. if (!sel || !model || (sel.startLineNumber === sel.endLineNumber && sel.startColumn === sel.endColumn)) { selectionStore.clear(); return; } - // 选到下一行行首(endColumn===1)时该行无实际文本,不计入行数。 + // When the selection reaches the next line's start (endColumn===1), that line has no actual text and is not counted in the line count. const endLine = sel.endColumn === 1 && sel.endLineNumber > sel.startLineNumber ? sel.endLineNumber - 1 : sel.endLineNumber; const startLine = sel.startLineNumber; - // 统一视图 + head 选区:增补跨到的基线删除行(并排视图删除行可直接选中,不增补)。 + // Unified view + head selection: augment with the spanned base deleted lines (in side-by-side view deleted lines can be selected directly, no augmentation). const removed = side === 'new' && !renderSideBySide ? spannedDeletions(startLine, endLine) : undefined; selectionStore.set({ diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSyncProgress.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSyncProgress.ts index 087fc359..44f7dd5d 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSyncProgress.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useSyncProgress.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import type { StoredPullRequest, SyncProgressEvent } from '@meebox/shared'; -/** 订阅 sync:progress 并按当前 PR 所属 repo 过滤;切 PR 清旧进度。 */ +/** Subscribes to sync:progress and filters by the repo the current PR belongs to; clears old progress on PR switch. */ export function useSyncProgress(pr: StoredPullRequest): SyncProgressEvent | null { const [progress, setProgress] = useState<SyncProgressEvent | null>(null); const repoKeySuffix = `/${pr.repo.projectKey}/${pr.repo.repoSlug}`; @@ -11,7 +11,7 @@ export function useSyncProgress(pr: StoredPullRequest): SyncProgressEvent | null }); return unsubscribe; }, [repoKeySuffix]); - // 切 PR 只清瞬态进度(不清 files/content 等,保留 stale-while-loading 旧视图) + // PR switch only clears transient progress (does not clear files/content etc., keeping the stale-while-loading old view) useEffect(() => { setProgress(null); }, [pr.localId]); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx index b435d8a7..2b71e359 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/inline-comments/InlineCommentZone.tsx @@ -10,13 +10,13 @@ import { ReactionAddButton, ReactionChips, useReactions } from '../../shared/Rea import { useCommentThread } from '../../shared/useCommentThread'; /** - * 估算 view zone 高度(行数)。每段评论 = header(avatar+name+date, 1.3 行) + body - * 字数 / 80 行向上取整。回复递归计算,每条 reply 多 0.3 行 (margin/border)。 - * 同行多评论叠加,最后顶天 32 行避免独吞屏幕。 + * Estimate view zone height (in lines). Each comment = header(avatar+name+date, 1.3 lines) + body + * length / 80 rounded up. Replies computed recursively, each reply adds 0.3 line (margin/border). + * Multiple comments on the same line stack, capped at 32 lines to avoid hogging the screen. */ export function estimateZoneHeight(comments: PrComment[]): number { - let h = 1; // 上下 padding - for (const c of comments) h += commentHeight(c) + 0.3; // item 间分隔 + let h = 1; // top/bottom padding + for (const c of comments) h += commentHeight(c) + 0.3; // separator between items return Math.min(Math.ceil(h), 32); } @@ -43,11 +43,11 @@ export function CommentZone({ prLocalId: string; prWebUrl: string; hardBreaks: boolean; - /** 评论 emoji 反应模式(capabilities.commentReactions):'fixed'/'free' 才渲染加反应按钮;缺省 = 不支持。 */ + /** Comment emoji reaction mode (capabilities.commentReactions): only 'fixed'/'free' render the add-reaction button; absent = unsupported. */ reactionsMode?: 'fixed' | 'free'; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);透传给回复编辑框启用粘贴上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); passed through to the reply editor to enable paste upload. */ attachmentsEnabled?: boolean; - /** 内容只读(decline / 不可参与归档 PR):隐藏行内评论的回复 / 编辑 / 删除操作。 */ + /** Content read-only (decline / archived PR that can't be participated in): hide the inline comment reply / edit / delete actions. */ readOnly?: boolean; }) { return ( @@ -76,8 +76,8 @@ export function CommentZone({ } /** - * 把 Bitbucket 评论 markdown 里 `attachment:HASH` 形态的 URL 改写为可点击的 Bitbucket 链接。 - * 返回 null = 不是附件 URL,调用方按原样处理。 + * Rewrite `attachment:HASH` style URLs in Bitbucket comment markdown into clickable Bitbucket links. + * Returns null = not an attachment URL, caller handles it as-is. */ function resolveAttachmentUrl(href: string, base: string | null): string | null { if (!base || !href.startsWith('attachment:')) return null; @@ -87,10 +87,10 @@ function resolveAttachmentUrl(href: string, base: string | null): string | null } /** - * react-markdown components 覆盖:a/img 检测 attachment: 协议,改写到 Bitbucket URL。 - * 图片附件因为 Bitbucket 需要会话鉴权,渲染器 fetch 不到,统一退化为可点击链接 - * (📎 alt 文本),点击走 setWindowOpenHandler → shell.openExternal 在系统 - * 浏览器打开,用户的 Bitbucket 登录 session 能正常加载。 + * react-markdown components override: a/img detect the attachment: protocol and rewrite to a Bitbucket URL. + * Image attachments require Bitbucket session auth so the renderer can't fetch them; they fall back to a + * clickable link (📎 alt text). Clicking goes through setWindowOpenHandler → shell.openExternal to open in + * the system browser, where the user's Bitbucket login session can load them normally. */ function makeCommentMarkdownComponents( attachmentBase: string | null, @@ -111,20 +111,21 @@ function makeCommentMarkdownComponents( }, img: ({ src, alt }) => { if (typeof src !== 'string' || !src) return null; - // 把 src 原样传 IPC — main 端 adapter 懂 Bitbucket `attachment:HASH` 协议 + 绝对/ - // 相对 URL,renderer 不需要前置 resolve。外部公网 URL 在 main 端会被认为 - // 跨 host 返回 null,BitbucketImage 内部 fallback 到原生 <img> + // Pass src through to IPC as-is — the main-side adapter understands the Bitbucket `attachment:HASH` + // protocol + absolute/relative URLs, so the renderer needn't resolve up front. An external public URL + // is treated as cross-host on the main side and returns null; BitbucketImage falls back internally to a native <img> return <BitbucketImage src={src} alt={alt} />; }, }; } /** - * 递归渲染单条评论 + 它的回复子树。comment.replies 是任意层级的;这里递归到底。每往下一层 - * 步进缩进 + 一道左竖线(缩进量 / 边框色与评论 tab 的 .pr-comments-replies 对齐,见 comment-zone.scss)。 + * Recursively render a single comment + its reply subtree. comment.replies is arbitrarily deep; recurse all + * the way down. Each level down adds a step of indent + a left vertical line (indent amount / border color + * aligned with the comments tab's .pr-comments-replies, see comment-zone.scss). */ -/** 嵌套缩进最大 5 层;超过此层级的更深回复**拉平**(comment-zone-reply-flat:去步进 / 边框 / 左 padding), - * 平铺在第 5 层缩进上、上下排列,避免无限嵌套一直右滑。 */ +/** Nested indent maxes out at 5 levels; deeper replies beyond this level are **flattened** (comment-zone-reply-flat: + * drop step / border / left padding), laid out on the level-5 indent stacked vertically, to avoid infinite nesting sliding ever rightward. */ const MAX_REPLY_INDENT_DEPTH = 5; function CommentNode({ @@ -155,14 +156,14 @@ function CommentNode({ () => makeCommentMarkdownComponents(attachmentBase, prLocalId, prWebUrl), [attachmentBase, prLocalId, prWebUrl], ); - // 反应状态 + 切换(与评论 / 活动 tab 的 CommentItem 共用 useReactions;hook 无条件调用, - // 输出仅在 reactionsMode 存在时渲染)。kind 由 comment.anchor 推断为 'inline'。 + // Reaction state + toggle (shares useReactions with the comments / activity tab's CommentItem; the hook is + // called unconditionally, output only rendered when reactionsMode is present). kind inferred from comment.anchor as 'inline'. const { reactions, busy: reactionBusy, toggle: toggleReaction } = useReactions( prLocalId, comment, readOnly, ); - // 回复 / 编辑 / 删除 交互状态机(与评论/活动 tab 的 CommentItem 共用,见 shared/useCommentThread) + // Reply / edit / delete interaction state machine (shared with the comments/activity tab's CommentItem, see shared/useCommentThread) const { replyOpen, setReplyOpen, @@ -178,8 +179,8 @@ function CommentNode({ handleDelete, } = useCommentThread(prLocalId, comment); - // body 只包 author + 正文 + 回复按钮 / 编辑器;replies 作为 sibling 放外面 — - // 不让 hover 内层 replies 冒泡触发外层 :hover 导致所有祖先 reply 按钮一齐显示 + // body wraps only author + content + reply button / editor; replies live outside as siblings — + // so hovering inner replies doesn't bubble up to trigger the outer :hover and reveal all ancestor reply buttons at once const inner = ( <> <div className="comment-zone-item-body"> @@ -207,8 +208,8 @@ function CommentNode({ className="comment-zone-body markdown" /> )} - {/* 回复 / 编辑 / 删除按钮:默认 hidden,hover comment-zone-item-body 显示 (CSS)。 - 编辑态隐藏全部按钮 (避免跟编辑器底部按钮组重复);只读(decline / 不可参与)整组隐藏。 */} + {/* Reply / edit / delete buttons: hidden by default, shown on hover of comment-zone-item-body (CSS). + Edit mode hides all buttons (to avoid duplicating the editor's bottom button group); read-only (decline / can't participate) hides the whole group. */} {!readOnly && !replyOpen && !editOpen && ( <div className="comment-zone-foot"> <button @@ -239,7 +240,7 @@ function CommentNode({ {deleting ? t('commentsPanel.deleting') : t('common.delete')} </button> )} - {/* 「加反应」按钮放在操作按钮之后(与评论 tab 一致);回复 / 编辑态下整组 foot 已隐藏。 */} + {/* The "add reaction" button goes after the action buttons (consistent with the comments tab); the whole foot group is already hidden in reply / edit mode. */} {reactionsMode && ( <ReactionAddButton reactions={reactions} @@ -253,14 +254,14 @@ function CommentNode({ {replyOpen && ( <CommentReplyEditor prLocalId={prLocalId} - // 回复目标抽象(threadId):GitLab=discussion id(reply 必需);Bitbucket 空 / GitHub=remoteId → 回退 remoteId。 + // Reply target abstraction (threadId): GitLab=discussion id (required for reply); Bitbucket empty / GitHub=remoteId → fall back to remoteId. parentCommentId={comment.threadId ?? comment.remoteId} attachmentsEnabled={attachmentsEnabled} onCancel={() => setReplyOpen(false)} onPosted={() => setReplyOpen(false)} /> )} - {/* 已有反应:单独成行,渲染在操作按钮下方(编辑态隐藏)。readOnly 下只展示、不可切换。 */} + {/* Existing reactions: on their own line, rendered below the action buttons (hidden in edit mode). Under readOnly, display only, not toggleable. */} {reactionsMode && !editOpen && ( <ReactionChips reactions={reactions} @@ -313,9 +314,10 @@ function CommentNode({ </> ); if (depth === 0) return inner; - // 满 MAX_REPLY_INDENT_DEPTH 层后**拉平**:去掉步进缩进与左竖线,更深回复平铺在上限层级上 - // (与评论 tab 设计一致)。关键:必须同时去掉 padding-left 与 border —— 仅去步进、保留每层的 - // padding/border 会逐级累加仍右移(之前"还是有缩进"的根因)。 + // Past MAX_REPLY_INDENT_DEPTH levels, **flatten**: drop the step indent and left vertical line, laying deeper + // replies out on the max level (consistent with the comments tab design). Key: must drop both padding-left AND + // border — dropping only the step while keeping each level's padding/border still accumulates and shifts right + // (the root cause of the earlier "still indented" bug). const flat = depth > MAX_REPLY_INDENT_DEPTH; return ( <div className={`comment-zone-reply${flat ? ' comment-zone-reply-flat' : ''}`}>{inner}</div> @@ -350,7 +352,7 @@ function CommentAuthorRow({ ); } -/** 把多条同行评论合成 markdown hover 文本(含回复嵌套) */ +/** Combine multiple same-line comments into markdown hover text (including nested replies) */ export function renderHoverMd(comments: PrComment[]): string { return comments .map((c) => { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts index 5c412988..b94acca1 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/colorize.ts @@ -4,14 +4,14 @@ import { languageFor } from '../../../../../../utils/language'; import type { FileResults } from './diff-search'; /** - * 异步着色:对每个 file 的每条 match.content 用 Monaco colorize 加语法高亮。 + * Async colorize: apply syntax highlighting via Monaco colorize to each file's every match.content. * - * Monaco colorize 返回带 inline style 的 HTML — 不依赖 monaco theme CSS, - * dangerouslySetInnerHTML 即可用。串行 file 但并发 line 平衡 throughput vs - * 启动开销 (一个文件的 language 加载只一次)。 + * Monaco colorize returns HTML with inline styles — doesn't depend on monaco theme CSS, + * usable directly via dangerouslySetInnerHTML. Serial per file but concurrent per line balances throughput vs + * startup cost (a file's language loads only once). * - * session token 检查:search session 已经被新 query 取代时立即放弃,避免 - * setState 到过期结果上 + * session token check: bail immediately when the search session has been superseded by a new query, to avoid + * setState onto a stale result */ export async function colorizeAll( results: FileResults[], @@ -22,7 +22,7 @@ export async function colorizeAll( for (const fr of results) { if (token !== sessionRef.current) return results; const langId = languageFor(fr.file.path); - // plaintext 文件没意义着色 — 直接复用原 matches + // plaintext files aren't worth colorizing — just reuse the original matches if (langId === 'plaintext') { out.push(fr); continue; @@ -31,7 +31,7 @@ export async function colorizeAll( fr.matches.map(async (m) => { try { const html = await MonacoEditorNs.colorize(m.content, langId, { tabSize: 2 }); - // colorize 输出末尾会加 `<br/>`;裁掉避免行高跳一档 + // colorize output appends a trailing `<br/>`; trim it to avoid a line-height jump return { ...m, colorizedHtml: html.replace(/<br\/?>$/i, '') }; } catch { return m; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts index d6e3ee49..69962153 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/diff-search.ts @@ -4,34 +4,34 @@ import { invoke } from '../../../../../../api'; export const CASE_SENSITIVE_LS_KEY = 'meebox.diffSearch.caseSensitive'; -/** 大文件保护:单文件超过 N 条命中只展示前 N + "更多" */ +/** Large-file guard: when a single file exceeds N matches, show only the first N + "more" */ export const PER_FILE_MATCH_CAP = 200; export const SEARCH_DEBOUNCE_MS = 220; /** - * 搜索范围限制:仅扫"变更行 + 上下 N 行 context",跟 Monaco DiffEditor 视觉上 - * 用户能看到的"diff 周围若干行 context"对齐。N=10 比 git diff 默认 unified=3 - * 更宽松,让搜索能命中变更附近稍远的引用 (常见场景:改了某个函数实现,调用方 - * 在其上下 5-10 行能被搜到) + * Search scope limit: scan only "changed lines + N lines of context above/below", aligned with the + * "few lines of context around the diff" the user visually sees in the Monaco DiffEditor. N=10 is more + * generous than git diff's default unified=3, letting search match references slightly farther from a + * change (common case: changed a function's implementation, callers 5-10 lines above/below get found) */ const CONTEXT_LINES = 10; export interface LineMatch { - /** 1-based 行号 (在 srcSide 端的行号) */ + /** 1-based line number (line number on the srcSide side) */ line: number; content: string; - /** 该行在 diff 中的角色 */ + /** The line's role in the diff */ diffRole: 'added' | 'removed' | 'context'; - /** 该行属于 head 还是 base 文件 — 决定 onJumpToMatch 的 side 参数 */ + /** Whether the line belongs to the head or base file — decides onJumpToMatch's side param */ srcSide: 'old' | 'new'; - /** 匹配子串在 content 中的起止位置 (用于高亮渲染) */ + /** Start/end position of the matched substring within content (used for highlight rendering) */ matchStart: number; matchEnd: number; /** - * Monaco colorize 后的 HTML (含 inline style 语法着色)。第一波结果回来时为 - * undefined,后台 colorize 完成后异步填上,UI 再 update 一次。 - * 未着色时 fallback 到 renderHighlight 走纯文本 + 关键词 `<mark>` + * HTML after Monaco colorize (with inline-style syntax coloring). Undefined when the first wave of results + * returns; filled in asynchronously after background colorize completes, then the UI updates once more. + * When uncolorized, falls back to renderHighlight using plain text + keyword `<mark>` */ colorizedHtml?: string; } @@ -51,19 +51,19 @@ export function dirname(p: string): string { } /** - * 跨文件搜索主逻辑: + * Cross-file search main logic: * - * 对每个 file: - * 1. 拉 head + base 内容 (有缓存命中即跳过 IPC) - * 2. 用 multiset consume 算"变更行"集合,扩展 ±CONTEXT_LINES 得到"可见范围" - * (跟用户在 Diff 视图能看到的 hunk 周围 context 对齐) - * 3. 仅在可见范围内扫 line-by-line 找匹配 - * 4. head 命中:base 端也有 → context;否则 added - * 5. base 命中且 head 端无:removed (排除已被 context 命中的) - * 6. 同文件结果按 line 升序整理 + * For each file: + * 1. Fetch head + base content (skip IPC on cache hit) + * 2. Compute the "changed lines" set via multiset consume, expand ±CONTEXT_LINES to get the "visible range" + * (aligned with the hunk-surrounding context the user sees in the Diff view) + * 3. Scan line-by-line for matches only within the visible range + * 4. head match: base side also has it → context; otherwise added + * 5. base match with none on the head side: removed (excluding those already matched as context) + * 6. Sort a file's results in ascending line order * - * 单个文件失败 (binary / 拉取错误) 静默跳过,最后返回 partialError 提示有几个 - * 文件没搜成 + * A single file failing (binary / fetch error) is silently skipped; finally returns partialError noting how many + * files failed to search */ export async function runSearch( token: number, @@ -87,30 +87,30 @@ export async function runSearch( loadContent(cache, prLocalId, 'head', headPath), loadContent(cache, prLocalId, 'base', basePath), ]); - // 中途用户切到别的 query — 当前 promise 还没 return 就被取消,直接放弃 + // User switched to another query midway — this promise was cancelled before returning, so bail if (token === -1) return; const headLines = headText === null ? [] : headText.split('\n'); const baseLines = baseText === null ? [] : baseText.split('\n'); - // 用 Multiset 而非 Set — 同内容行可能出现多次 (空行 / 大括号 / 同名变量 - // 声明)。Set 会让"基础侧某次 instance 仍存在"误判为 context 跳过 added + // Use a Multiset rather than a Set — a same-content line may appear multiple times (blank line / brace / same-named + // variable declaration). A Set would misjudge "some instance still exists on the base side" as context and skip added const baseLineCount = countLines(baseLines); const headLineCount = countLines(headLines); - // 变更行集合 + ±CONTEXT_LINES context = 用户在 Diff 视图看得到的范围 + // Changed-line set + ±CONTEXT_LINES context = the range the user sees in the Diff view const changedHead = findChangedIndices(headLines, baseLineCount); const changedBase = findChangedIndices(baseLines, headLineCount); const visibleHead = expandToContext(changedHead, headLines.length); const visibleBase = expandToContext(changedBase, baseLines.length); const matches: LineMatch[] = []; - // head 端扫 + // scan the head side for (let i = 0; i < headLines.length; i++) { if (!visibleHead.has(i)) continue; const line = headLines[i]!; const span = findMatchSpan(line, rawQuery, caseSensitive, probe); if (span === null) continue; - // base 端有同样内容 → context;否则 added + // base side has the same content → context; otherwise added const stillInBase = (baseLineCount.get(line) ?? 0) > 0; const stripped = stripLeadingIndent(line, span); matches.push({ @@ -122,7 +122,7 @@ export async function runSearch( matchEnd: stripped.matchEnd, }); } - // base 端扫,仅收集"仅在 base 出现"的 removed 行 (避免跟 context 重复) + // scan the base side, collecting only removed lines that "appear only in base" (to avoid duplicating context) for (let i = 0; i < baseLines.length; i++) { if (!visibleBase.has(i)) continue; const line = baseLines[i]!; @@ -140,7 +140,7 @@ export async function runSearch( }); } if (matches.length === 0) return; - // 排序:先按 srcSide (head 优先) 再按 line,让结果列表有可预测顺序 + // Sort: first by srcSide (head first) then by line, giving the result list a predictable order matches.sort((a, b) => { if (a.srcSide !== b.srcSide) return a.srcSide === 'new' ? -1 : 1; return a.line - b.line; @@ -152,7 +152,7 @@ export async function runSearch( }), ); - // 文件级排序按 path 字典序,跟文件树视觉对齐 + // File-level sort by path lexicographic order, aligned with the file tree visually out.sort((a, b) => a.file.path.localeCompare(b.file.path)); return { results: out, @@ -162,11 +162,11 @@ export async function runSearch( } /** - * 用 multiset consume 算"变更行"行号集合:other 端有同内容行就配对消费 (从 - * count 表里扣 1),配不上的就是"仅在本侧出现" — 即变更行。 + * Compute the "changed lines" line-number set via multiset consume: if the other side has a same-content line, pair + * and consume it (decrement 1 from the count table); those that can't be paired "appear only on this side" — i.e. changed lines. * - * 比简单 set 含 / 不含判定更准 — 同内容行 (空行 / `}` / 同名 import) 在 PR 一 - * 般是匹配存在,不是变更 + * More accurate than a simple set contains / doesn't-contain check — same-content lines (blank line / `}` / same-named + * import) in a PR are generally matches that exist, not changes */ export function findChangedIndices( ownLines: string[], @@ -183,7 +183,7 @@ export function findChangedIndices( return changed; } -/** 把变更行索引集合按 ±CONTEXT_LINES 扩展,并合并相邻区间 */ +/** Expand the changed-line index set by ±CONTEXT_LINES and merge adjacent ranges */ export function expandToContext(changed: Set<number>, total: number): Set<number> { const out = new Set<number>(); for (const idx of changed) { @@ -208,8 +208,8 @@ export async function loadContent( side, path, }); - // DiffFileContent 联合:{binary:false, content:string} 或 {binary:true}。 - // binary 文件跳过搜索 (没有可比对的文本);non-binary 取 content 字段 + // DiffFileContent union: {binary:false, content:string} or {binary:true}. + // binary files skip search (no comparable text); non-binary takes the content field const text = c.binary === false ? c.content : null; cache.set(k, text); return text; @@ -220,9 +220,10 @@ export async function loadContent( } /** - * 剥行首缩进 (tab / 空格) — 搜索面板宽度有限,对齐到代码原缩进会浪费横向空间 - * 也让 match 看起来不显眼。剥掉后视觉上左对齐,关键词高亮起止点也按剥后内容 - * 重算。命中**在**缩进里 (query 包含 leading 空白) → 不剥,保持高亮位置正确 + * Strip leading indent (tab / space) — the search panel has limited width; keeping the code's original indent + * wastes horizontal space and makes the match look inconspicuous. After stripping it's visually left-aligned, and + * the keyword highlight start/end are recomputed against the stripped content. If the match is **inside** the + * indent (query contains leading whitespace) → don't strip, to keep the highlight position correct */ export function stripLeadingIndent( line: string, @@ -232,7 +233,7 @@ export function stripLeadingIndent( if (!m) return { content: line, matchStart: span[0], matchEnd: span[1] }; const offset = m[0].length; if (span[0] < offset) { - // 命中在缩进区,剥了就高亮就错位 — 保留原内容 + // Match is in the indent region; stripping would misalign the highlight — keep the original content return { content: line, matchStart: span[0], matchEnd: span[1] }; } return { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts index ede70692..4bf2b4b4 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/search/useDiffSearch.ts @@ -10,15 +10,15 @@ import { } from './diff-search'; /** - * 跨文件搜索状态机:query / 大小写敏感(localStorage 持久化) / 结果 / loading / error / - * 文件折叠态 + 去抖搜索 + 异步着色 + 内容缓存(PR 切换清)。mount 自动聚焦输入框。 - * 纯算法见 ./diff-search;着色见 ./colorize。 + * Cross-file search state machine: query / case sensitivity (localStorage-persisted) / results / loading / error / + * file collapse state + debounced search + async colorize + content cache (cleared on PR switch). Auto-focuses the input on mount. + * Pure algorithm see ./diff-search; colorize see ./colorize. */ export function useDiffSearch(files: DiffChangedFile[], prLocalId: string) { const { t } = useTranslation(); const [query, setQuery] = useState(''); - // 大小写敏感跨 session 持久化 — 用户习惯一旦定下来 (一般是关或开),每次 - // 进搜索面板都得重新切一次很烦。localStorage 写一次就记住 + // Case sensitivity persisted across sessions — once the user's habit settles (usually off or on), having to + // toggle it again every time they open the search panel is annoying. Write to localStorage once and remember it const [caseSensitive, setCaseSensitive] = useState<boolean>(() => { try { return localStorage.getItem(CASE_SENSITIVE_LS_KEY) === '1'; @@ -30,26 +30,26 @@ export function useDiffSearch(files: DiffChangedFile[], prLocalId: string) { try { localStorage.setItem(CASE_SENSITIVE_LS_KEY, caseSensitive ? '1' : '0'); } catch { - // 隐私模式 / 配额满 等失败静默,不影响搜索功能 + // Silently ignore failures like private mode / quota full, doesn't affect search functionality } }, [caseSensitive]); const [results, setResults] = useState<FileResults[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); - // 默认全部展开 — 用户已经主动搜索,不需要再点一次才看结果 + // Expanded by default — the user already actively searched, no need for another click to see results const [collapsedFiles, setCollapsedFiles] = useState<ReadonlySet<string>>(new Set()); - // 当前 search session 的 token,让旧的异步任务发现自己被取消 + // Token for the current search session, letting old async tasks discover they've been cancelled const sessionRef = useRef(0); - // 内容缓存:同一个 PR 搜不同关键字时 invoke diff:getFileContent 拿过的不重复拉 + // Content cache: when searching different keywords in the same PR, don't re-fetch what invoke diff:getFileContent already got // key: `${side}:${path}` → text content const contentCacheRef = useRef<Map<string, string | null>>(new Map()); - // PR 切换时清缓存 + // Clear the cache on PR switch useEffect(() => { contentCacheRef.current = new Map(); }, [prLocalId]); - // mount 时自动聚焦输入框,省一次点击 + // Auto-focus the input on mount, saving a click const inputRef = useRef<HTMLInputElement | null>(null); useEffect(() => { inputRef.current?.focus(); @@ -70,11 +70,11 @@ export function useDiffSearch(files: DiffChangedFile[], prLocalId: string) { void runSearch(token, q, caseSensitive, files, prLocalId, contentCacheRef.current, t) .then(({ results: r, partialError }) => { if (token !== sessionRef.current) return; - // 先显示带 <mark> 关键词高亮的纯文本结果 — 用户立刻能看到命中 + // First show plain-text results with <mark> keyword highlight — the user sees matches immediately setResults(r); setError(partialError); - // 异步着色:Monaco colorize 按文件 language 串行执行;token 跟 session - // 关联,过期 session 不再 update state + // Async colorize: Monaco colorize runs serially per file language; token is tied to the session, + // a stale session no longer updates state void colorizeAll(r, token, sessionRef).then((colorized) => { if (token === sessionRef.current) setResults(colorized); }); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/line-mapping.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/line-mapping.ts index 56447765..ee22fa8f 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/line-mapping.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/line-mapping.ts @@ -1,13 +1,14 @@ import { type editor as MonacoEditor } from 'monaco-editor'; /** - * 统一(inline)视图下原始编辑器隐藏、删除行由 modified 编辑器以 view zone 呈现,故 old 侧评论/草稿 - * 不能再挂原始编辑器(会出现「大段空白、无内容」),须改挂 modified 编辑器。这里按 diff 行变更把 - * 「原始行号」映射成 modified 的 afterLineNumber: - * - 纯删除:删除块落在 modifiedStartLineNumber 之后 → 评论挂该行后; - * - 修改:对齐到 modified 块内对应行; - * - 上下文行(不在任何变更内):按之前各变更的累计行数差平移。 - * diff 尚未计算(getLineChanges 为空)时退化为原行号 + 累计平移。 + * In the unified (inline) view the original editor is hidden and deleted lines are presented by the modified editor + * as a view zone, so old-side comments/drafts can no longer mount on the original editor (would show "a large blank + * with no content") and must mount on the modified editor. This maps the "original line number" to the modified + * afterLineNumber per diff line change: + * - pure deletion: the deletion block falls after modifiedStartLineNumber → comment mounts after that line; + * - modification: align to the corresponding line within the modified block; + * - context lines (not inside any change): shift by the cumulative line-count difference of prior changes. + * When the diff isn't computed yet (getLineChanges empty), falls back to the original line number + cumulative shift. */ export function mapOriginalLineToModified( changes: readonly MonacoEditor.ILineChange[], @@ -20,28 +21,28 @@ export function mapOriginalLineToModified( const mS = ch.modifiedStartLineNumber; const mE = ch.modifiedEndLineNumber; if (oE === 0) { - // 纯插入(原始侧无行):仅当插入点在 origLine 之前才计入偏移 + // Pure insertion (no line on the original side): count into the offset only when the insertion point is before origLine if (oS < origLine) delta += mE - mS + 1; continue; } if (oE < origLine) { - // 变更整体在 origLine 之前:累计 modified 与 original 的行数差 + // The change is entirely before origLine: accumulate the line-count difference between modified and original const oCount = oE - oS + 1; const mCount = mE === 0 ? 0 : mE - mS + 1; delta += mCount - oCount; continue; } if (oS <= origLine && origLine <= oE) { - // origLine 落在本变更内 - if (mE === 0) return mS; // 纯删除:删除块在 modified 行 mS 之后 - return Math.min(mS + (origLine - oS), mE); // 修改:对齐 modified 块 + // origLine falls within this change + if (mE === 0) return mS; // pure deletion: the deletion block is after modified line mS + return Math.min(mS + (origLine - oS), mE); // modification: align to the modified block } - break; // changes 有序,后续都在 origLine 之后 + break; // changes is ordered, the rest are all after origLine } return origLine + delta; } -/** 把 old 侧分桶按 diff 重映射到 modified 行号(统一视图用)。 */ +/** Remap old-side buckets to modified line numbers per diff (used in the unified view). */ export function remapOldByLineToModified<T>( changes: readonly MonacoEditor.ILineChange[], oldByLine: Map<number, T[]>, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts index 2e3cc49d..7ad632aa 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts @@ -4,30 +4,31 @@ import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-ed import { remapOldByLineToModified } from './line-mapping'; /** - * 通用 Monaco 行内 view-zone 挂载机制:行内评论 zone 与草稿 zone 共用同一套管线—— - * 双层 `dom`/`inner` 结构、`stopPropagation` 事件接管、宽度 `applyInnerLayout`、横向滚动 - * `translateX` 同步、高度 `ResizeObserver` 回写、按视图把 old 侧映射到对应编辑器、`removeZone` / - * `unmount` 清理。差异点(拦截哪些事件、初始高度估算、渲染什么组件)由 options 注入。 + * Generic Monaco inline view-zone mount mechanism: the inline comment zone and draft zone share one pipeline — + * two-layer `dom`/`inner` structure, `stopPropagation` event takeover, width `applyInnerLayout`, horizontal-scroll + * `translateX` sync, height write-back via `ResizeObserver`, mapping the old side to the corresponding editor per view, + * `removeZone` / `unmount` cleanup. The differences (which events to intercept, initial height estimation, what component + * to render) are injected via options. * - * 返回 cleanup 函数(在 effect 的 teardown 调)。评论 zone 额外的 glyph decorations 不在此处 - * 管理(由调用方 useCommentZones 自行 create / clear)。 + * Returns a cleanup function (called in the effect's teardown). The comment zone's extra glyph decorations are not managed + * here (the caller useCommentZones creates / clears them itself). */ export interface MountInlineZonesOptions<T> { diffEditor: MonacoEditor.IStandaloneDiffEditor; renderSideBySide: boolean; - /** old 侧(删除 / base 侧上下文行)分桶:key = 行号 */ + /** Old-side (deleted / base-side context lines) buckets: key = line number */ oldByLine: Map<number, T[]>; - /** new 侧(新增 / head 侧上下文行)分桶:key = 行号 */ + /** New-side (added / head-side context lines) buckets: key = line number */ newByLine: Map<number, T[]>; - /** monaco wrapper dom 的 class('monaco-comment-zone' / 'monaco-draft-zone') */ + /** Class of the monaco wrapper dom ('monaco-comment-zone' / 'monaco-draft-zone') */ zoneClassName: string; - /** 真实视觉容器 inner 的 class('monaco-comment-zone-inner' / 'monaco-draft-zone-inner') */ + /** Class of the real visual container inner ('monaco-comment-zone-inner' / 'monaco-draft-zone-inner') */ innerClassName: string; - /** dom + inner 上 stopPropagation 接管的事件集合(草稿含 keydown/wheel 等,评论仅鼠标点击类) */ + /** Set of events stopPropagation takes over on dom + inner (drafts include keydown/wheel etc., comments only mouse-click kinds) */ stopEvents: readonly string[]; - /** 初始 zone 高度(px)估算;lineHeight 为 monaco 当前行高 */ + /** Initial zone height (px) estimation; lineHeight is monaco's current line height */ initialHeight: (items: T[], lineHeight: number) => number; - /** 渲染 zone 内容(React 节点) */ + /** Render the zone content (React node) */ render: (items: T[]) => ReactNode; } @@ -59,17 +60,17 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi const lineHeight = editorInst.getOption(MonacoEditorNs.EditorOption.lineHeight); editorInst.changeViewZones((accessor) => { for (const [line, items] of byLine) { - // 双层结构:dom 是 monaco wrapper(monaco 直接把 height inline 写到它上面), - // inner 是不受 monaco 控制的真实视觉容器 → inner.offsetHeight 才是真实内容高度。 + // Two-layer structure: dom is the monaco wrapper (monaco writes height inline directly onto it), + // inner is the real visual container not controlled by monaco → inner.offsetHeight is the true content height. const dom = document.createElement('div'); dom.className = zoneClassName; - // 经典 Monaco view zone 坑:editor 自带 mousedown listener 把整个 zone 区域当作 - // "editor mouse target" 吞掉冒泡到 DOM 的事件 → zone 内 textarea 收不到 focus、button - // 点不响应。在 dom 容器上 stopPropagation 一组关键事件,让 monaco 不再接管 zone 内的 - // user input。**必须是 bubble 阶段**(第三参数省略 / false):capture 阶段拦截会在事件 - // 到达 button/textarea 之前就阻断,React onClick / onKeyDown 根本不触发。bubble 阶段让 - // target 上的 React handler 先 fire,再阻止冒泡到 editor。 + // Classic Monaco view zone pitfall: the editor's built-in mousedown listener treats the whole zone area as + // an "editor mouse target" and swallows events bubbling to the DOM → the zone's textarea gets no focus, buttons + // don't respond to clicks. stopPropagation a set of key events on the dom container so monaco no longer takes over + // user input within the zone. **Must be the bubble phase** (third arg omitted / false): intercepting in the capture + // phase would block before the event reaches button/textarea, so React onClick / onKeyDown never fire. The bubble + // phase lets the target's React handler fire first, then stops the bubble to the editor. const stopAll = (e: Event): void => e.stopPropagation(); for (const evt of stopEvents) { dom.addEventListener(evt, stopAll); @@ -90,10 +91,10 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi }; const zoneId = accessor.addZone(zoneObj); - // 高度同步:直接 mutate zoneObj.heightInPx + layoutZone(id)。removeZone+addZone 在 textarea - // 拖拽 resize 时每帧调用会引起 zone 重建抖动。layoutZone 是轻量操作,先 mutate - // heightInPx 再 layoutZone 即可让 monaco 重新计算 viewModel whitespace。 - // 用 inner.offsetHeight 测(dom 被 monaco 写死 height,offsetHeight 会自循环)。 + // Height sync: directly mutate zoneObj.heightInPx + layoutZone(id). removeZone+addZone called every frame + // during textarea drag-resize causes zone-rebuild jitter. layoutZone is a lightweight operation; mutate + // heightInPx first then layoutZone to let monaco recompute the viewModel whitespace. + // Measure with inner.offsetHeight (dom's height is hard-set by monaco, so offsetHeight would self-loop). const syncHeight = (): void => { const next = inner.offsetHeight; if (next <= 0) return; @@ -107,26 +108,27 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi /* editor disposed */ } }; - // ResizeObserver 跟踪 inner 高度变化(read↔edit 切换、textarea resize、内嵌图片异步加载、 - // 嵌套评论展开)。requestAnimationFrame 避开"回调里同步 layout 又触发 RO"循环。 + // ResizeObserver tracks inner height changes (read↔edit switch, textarea resize, async loading of embedded images, + // nested comment expansion). requestAnimationFrame avoids the "sync layout in the callback re-triggers RO" loop. const ro = new ResizeObserver(() => { requestAnimationFrame(syncHeight); }); ro.observe(inner); - // 多个时间点 sync 兜底覆盖布局抖动 / React 多阶段 render + // Sync at multiple points as a fallback covering layout jitter / React multi-phase render requestAnimationFrame(syncHeight); setTimeout(syncHeight, 50); setTimeout(syncHeight, 200); - // 宽度 + 位置策略(跟 Bitbucket / GitHub inline 评论对齐):用 BoundingClientRect 拿浏览器 - // 实际渲染坐标(clientWidth / layoutInfo.width 在 monaco inline 视图下偶发超出 editor 视觉 - // 边界,实测评论框跨到 ChatPane 区域)。inner 从 dom 起点开始,最远延伸到 editor 视觉右边界 - // - verticalScrollbar。dom 还没挂到 DOM 树时 rect.width=0,用 editor 左边界兜底。 + // Width + position strategy (aligned with Bitbucket / GitHub inline comments): use BoundingClientRect to get the + // browser's actual render coordinates (clientWidth / layoutInfo.width occasionally exceed the editor's visual + // boundary in monaco inline view, observed the comment box spilling into the ChatPane area). inner starts at the + // dom origin and extends at most to the editor's visual right boundary - verticalScrollbar. When dom isn't yet + // attached to the DOM tree rect.width=0, fall back to the editor's left boundary. const editorDomNode = editorInst.getDomNode(); const applyInnerLayout = (): void => { if (!editorDomNode) return; const editorRect = editorDomNode.getBoundingClientRect(); - if (editorRect.width <= 0) return; // editor 还没 layout,等下次 trigger + if (editorRect.width <= 0) return; // editor not laid out yet, wait for the next trigger const domRect = dom.getBoundingClientRect(); const sbW = editorInst.getLayoutInfo().verticalScrollbarWidth ?? 0; const innerLeft = domRect.width > 0 ? domRect.left : editorRect.left; @@ -139,14 +141,14 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi } }; applyInnerLayout(); - // 多个时间点兜底:切换文件 + autoEdit 跳转时 monaco 在算 diff / 文件 mount 还没完, - // getBoundingClientRect 给的不是稳定 layout(实测跳转新文件框宽度撑爆,resize 后恢复)。 + // Multi-point fallback: on file switch + autoEdit jump, monaco is still computing the diff / the file mount isn't + // done, and getBoundingClientRect gives a non-stable layout (observed the box width blowing out when jumping to a new file, recovering after resize). requestAnimationFrame(applyInnerLayout); setTimeout(applyInnerLayout, 50); setTimeout(applyInnerLayout, 200); setTimeout(applyInnerLayout, 500); - // 双触发:onDidLayoutChange(几何变化)+ ResizeObserver 观察 editor DOM(窗口 / 分隔条 - // resize),覆盖不重叠;onDidUpdateDiff(切文件后算 diff 时 layout 仍在变,算完才稳定)。 + // Dual trigger: onDidLayoutChange (geometry change) + ResizeObserver watching the editor DOM (window / splitter + // resize), non-overlapping coverage; onDidUpdateDiff (after a file switch the layout still changes while the diff is computed, stabilizing only once done). const layoutDisp = editorInst.onDidLayoutChange(applyInnerLayout); const diffDisp = diffEditor.onDidUpdateDiff(() => requestAnimationFrame(applyInnerLayout)); const editorRO = editorDomNode @@ -154,17 +156,17 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi : null; if (editorDomNode && editorRO) editorRO.observe(editorDomNode); - // 横向滚动同步:monaco view zone dom 在 .lines-content 内会跟 scrollLeft 一起左移 - // (横滚后框被裁出 viewport)。给 inner 加 transform translateX(scrollLeft) 反向抵消, - // 框就 stick 在 viewport 内的相对位置不动(跟 Bitbucket / GitHub inline 评论一致)。 + // Horizontal-scroll sync: the monaco view zone dom inside .lines-content shifts left along with scrollLeft + // (after horizontal scroll the box gets clipped out of the viewport). Adding transform translateX(scrollLeft) + // to inner cancels it out, so the box sticks at its relative position within the viewport (consistent with Bitbucket / GitHub inline comments). const applyScroll = (): void => { inner.style.transform = `translateX(${editorInst.getScrollLeft()}px)`; }; applyScroll(); const scrollDisp = editorInst.onDidScrollChange(applyScroll); - // inner 上也 stopPropagation 一份(双层防御)。**必须晚于 createRoot** —— 否则 React 18 在 - // inner 上的 event delegation 初始化顺序受影响,导致 onClick 不 fire(取消按钮点了没反应)。 + // Also stopPropagation on inner (two-layer defense). **Must be after createRoot** — otherwise React 18's event + // delegation initialization order on inner is affected, causing onClick not to fire (clicking the cancel button does nothing). for (const evt of stopEvents) { inner.addEventListener(evt, stopAll); } @@ -173,8 +175,8 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi editor: editorInst, zoneId, root, - // 先 disconnect ResizeObserver + dispose listener,再 unmount root,避免 unmount - // 引起的 DOM 高度回落触发观察回调 + layoutZone(disposed editor) 报错。 + // Disconnect ResizeObserver + dispose listeners first, then unmount root, to avoid the DOM height collapse + // caused by unmount triggering the observer callback + a layoutZone(disposed editor) error. disposers: [ () => ro.disconnect(), () => layoutDisp.dispose(), @@ -187,8 +189,8 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi }); }; - // 并排视图:old 侧挂原始编辑器;统一视图:原始编辑器隐藏,old 侧改挂 modified 编辑器对应行 - // (删除行在统一视图是 modified 的 view zone,按 diff 行变更把原始行号映射到 modified afterLineNumber)。 + // Side-by-side view: old side mounts on the original editor; unified view: the original editor is hidden, old side + // mounts on the modified editor's corresponding line (deleted lines are modified's view zone in the unified view, mapping the original line number to modified afterLineNumber per diff line change). if (renderSideBySide) { addZonesFor(originalEditor, oldByLine); } else if (oldByLine.size > 0) { @@ -223,7 +225,7 @@ export function mountInlineZones<T>(opts: MountInlineZonesOptions<T>): () => voi } } } - // React 18+: unmount 不能在 render 阶段同步调,放微任务里 + // React 18+: unmount can't be called synchronously in the render phase, defer it to a microtask queueMicrotask(() => { for (const z of zoneRefs) { try { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx index 936259f7..7aa225b4 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftZone.tsx @@ -10,37 +10,37 @@ import { useDraftZone } from './useDraftZone'; interface DraftZoneProps { draft: ReviewDraft; - /** 所属 PR 的 localId;草稿编辑框图片上传(attachmentsEnabled 时)需用它定位 PR 附件存储。 */ + /** localId of the owning PR; the draft editor's image upload (when attachmentsEnabled) uses it to locate the PR attachment store. */ prLocalId: string; - /** 平台是否支持图片附件上传(capabilities.commentAttachments);为真才在草稿编辑框启用粘贴 / 选取上传。 */ + /** Whether the platform supports image attachment upload (capabilities.commentAttachments); only when true does the draft editor enable paste / pick upload. */ attachmentsEnabled?: boolean; - /** 评论换行策略(活动平台 commentHardBreaks):决定预览是否启用 remark-breaks,使草稿预览 WYSIWYG。 */ + /** Comment line-break policy (active platform commentHardBreaks): decides whether the preview enables remark-breaks, making the draft preview WYSIWYG. */ hardBreaks: boolean; /** - * 注册 "进入编辑模式" 触发函数到外部 ref map。DiffView 调用注册的 fn 时本组件 - * setIsEditing(true)。用 ref-based fn 而不是 props token,避免 trigger token 变化引发的 - * unmount/mount 循环误触(详见 useDraftZone)。 + * Register the "enter edit mode" trigger fn into an external ref map. When DiffView calls the + * registered fn, this component setIsEditing(true). Uses a ref-based fn instead of a props token to + * avoid unmount/mount cycle mis-triggers caused by trigger token changes (see useDraftZone). */ registerEditTrigger?: (draftId: string, fn: (() => void) | null) => void; - /** 保存编辑后的 body。调用方走 IPC drafts:update。 */ + /** Save the edited body. Caller goes through IPC drafts:update. */ onSave: (newBody: string) => void | Promise<void>; - /** 删除本草稿。调用方走 IPC drafts:delete */ + /** Delete this draft. Caller goes through IPC drafts:delete */ onDelete: () => void | Promise<void>; /** - * 单条直接发布到远端。调用方走 drafts:publishBatch 传单元素 draftIds。 - * 返回 ok=false 时 error 填人读错因,本组件渲染 inline 错误但不卸载 zone。 - * 不传 = read 模式不渲染"发布"按钮。 + * Publish a single draft directly to remote. Caller goes through drafts:publishBatch with a single-element draftIds. + * On ok=false, error carries a human-readable cause; this component renders an inline error but does not unmount the zone. + * Absent = read mode, does not render the "publish" button. */ onPublish?: () => Promise<{ ok: boolean; error?: string }>; } /** - * Diff 视图内联草稿编辑 zone。挂在 Monaco editor 的 view zone 里,由 - * `createRoot.render(<DraftZone ... />)` 渲染。read/edit/publish 状态机见 [useDraftZone](./useDraftZone.ts); - * 本组件只负责渲染。 + * Inline draft editing zone inside the Diff view. Mounted in Monaco editor's view zone, rendered via + * `createRoot.render(<DraftZone ... />)`. read/edit/publish state machine see [useDraftZone](./useDraftZone.ts); + * this component only handles rendering. * - * 视觉跟 CommentZone (远端评论 read-only) 区分:CommentZone 黄底 → 这里**蓝底 + DRAFT chip**; - * posted 切绿底跟远端评论对齐;rejected 默认 css 隐藏(DiffView 端 .monaco-draft-zone-rejected)。 + * Visually distinguished from CommentZone (remote comment, read-only): CommentZone yellow bg → here **blue bg + DRAFT chip**; + * posted switches to green bg to align with remote comments; rejected is hidden by default via css (DiffView side .monaco-draft-zone-rejected). */ export function DraftZone({ draft, @@ -98,10 +98,10 @@ export function DraftZone({ </span> {!isEditing && canEdit && ( <div className="draft-zone-actions"> - {/* 单条"发布":仅 pending/edited 显示 (posted 已发完不再渲染按钮; - rejected 状态在 DiffView 端 CSS 隐藏整个 zone 不会走到这里)。 - publishing 中按钮禁用,文案改"发布中…";其它按钮也 disable 避免 - 同条草稿同时 save / delete / publish 多路并发 */} + {/* Single "publish": shown only for pending/edited (posted is already published so no button; + rejected state has its whole zone hidden by CSS on DiffView side, never reaches here). + While publishing the button is disabled, label changes to "publishing…"; other buttons also disable to avoid + concurrent save / delete / publish on the same draft */} {onPublish && ( <button type="button" @@ -160,8 +160,8 @@ export function DraftZone({ textareaRef={textareaRef} /> <div className="draft-zone-edit-actions"> - {/* 主按钮:有 onPublish 时是"发布" (先 auto-save 再 POST,跟取消的 - auto-save 行为对齐避免双按钮冗余);缺 onPublish 时退回"保存" */} + {/* Primary button: "publish" when onPublish is present (auto-save first, then POST, aligned with cancel's + auto-save behavior to avoid redundant dual buttons); falls back to "save" when onPublish is absent */} {onPublish ? ( <button type="button" diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx index c8eb1f5c..b8a809ca 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/DraftsPanel.tsx @@ -9,39 +9,39 @@ import { formatBackendError } from '../../../../../errors'; import { useDraftsForPr } from '../../../../../stores/drafts-store'; import { ConfirmModal } from '../../../../common'; -// posted 已不存在 (发布成功即删本地),筛选项只保留 publishable / all / rejected +// posted no longer exists (successful publish deletes the local draft), filters keep only publishable / all / rejected type Filter = 'all' | 'publishable' | 'rejected'; interface DraftsPanelProps { pr: StoredPullRequest; - /** 点 anchor 跳 Diff 视图。父端 wire 到 pendingDiffNav (走 App 顶层) */ + /** Click anchor to jump to Diff view. Parent wires to pendingDiffNav (goes through App top level) */ onJumpToAnchor?: (draftId: string) => void; - /** 活动连接能力位;此处用 commentHardBreaks 决定草稿预览是否启用 remark-breaks。 */ + /** Active connection capability bits; here commentHardBreaks decides whether the draft preview enables remark-breaks. */ capabilities?: PlatformCapabilities; - /** 内容只读(decline / 不可参与归档 PR):隐藏草稿的发布 / 删除操作,仅供浏览。 */ + /** Content read-only (decline / archived PR that can't be participated in): hides draft publish / delete actions, browse only. */ readOnly?: boolean; } /** - * 草稿管理面板 (M4)。跟 CommentsPanel 同一个 tab 层级、视觉权重对齐 —— 一个看 - * 远端已发评论,一个看本地未发草稿,互补。 + * Draft management panel (M4). Same tab level as CommentsPanel, aligned visual weight — one views + * remote published comments, the other views local unpublished drafts, complementary. * - * 跟 DiffView 内嵌 DraftZone / PublishReviewModal 的关系: - * - DraftZone:行内就地编辑,"看到代码 + 改"的主路径 - * - PublishReviewModal:一次性"批量发布"入口,全选默认 + 发布动作流 - * - DraftsPanel:常驻"草稿总览",跨文件 + 跨 status 浏览 / 单条 actions + * Relationship with DiffView's embedded DraftZone / PublishReviewModal: + * - DraftZone: inline in-place editing, the main path of "see the code + edit" + * - PublishReviewModal: one-shot "batch publish" entry, select-all default + publish action flow + * - DraftsPanel: persistent "draft overview", browse across files + across status / single-item actions * - * status 筛选默认落在"待发布" — 用户最关心还没发的那批;筛选切到"已发布"可 - * 检视本 PR 自己发出去的评论历史,"已拒绝"可恢复 (M4 暂未做 unreject UI) + * status filter defaults to "to-publish" — users care most about the not-yet-published batch; switching the filter to "published" + * lets you inspect this PR's own published comment history, "rejected" can be restored (M4 has no unreject UI yet) */ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false }: DraftsPanelProps) { - // 草稿预览换行:GitHub/Bitbucket hard-break;GitLab CommonMark 软换行。缺省回退 true。 + // Draft preview line breaks: GitHub/Bitbucket hard-break; GitLab CommonMark soft break. Fallback true by default. const hardBreaks = capabilities?.commentHardBreaks ?? true; const { t } = useTranslation(); const drafts = useDraftsForPr(pr.localId); const [filter, setFilter] = useState<Filter>('publishable'); const [confirmDelete, setConfirmDelete] = useState<string | null>(null); - // 多条草稿可能同时在发,用 Set 跟踪并发的 draftId 各自 disable / 文案 + // Multiple drafts may be publishing at once; use a Set to track concurrent draftIds for per-item disable / label const [publishingIds, setPublishingIds] = useState<ReadonlySet<string>>(new Set()); const [errors, setErrors] = useState<ReadonlyMap<string, string>>(new Map()); @@ -57,8 +57,8 @@ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false const filtered = useMemo<ReviewDraft[]>(() => { const list = drafts ?? []; - // 排序:同文件按 startLine 升序 (跟代码自上而下阅读顺序一致);不同文件按 - // path 字典序 (跟文件树顺序对齐,扫起来不跳跃) + // Sort: within the same file by startLine ascending (matching top-to-bottom code reading order); across files by + // path lexicographic order (aligned with file-tree order, so scanning doesn't jump around) const sorted = list .slice() .sort((a, b) => @@ -105,9 +105,9 @@ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false r?.error ? formatBackendError(r.error).title : t('draftsPanel.publishFailed'), ); } - // 成功 → main 端直接删本地草稿 (不留 posted 历史),broadcastDraftsChanged - // 让本面板重拉,被删的条目从列表里消失。远端评论由 force-refresh comments - // 拉回,在 CommentsPanel / DiffView CommentZone 看 + // Success → main side deletes the local draft directly (no posted history kept), broadcastDraftsChanged + // makes this panel re-fetch, and the deleted item disappears from the list. The remote comment is pulled + // back by force-refresh comments, viewable in CommentsPanel / DiffView CommentZone } catch (e) { setError(draftId, e instanceof Error ? e.message : String(e)); } finally { @@ -120,8 +120,8 @@ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false setConfirmDelete(null); }; - // 草稿池正在 hydrate (首次进 PR) → 占位;fetched 后空数组才显示"无草稿"。 - // 空态也包在 .drafts-panel 里让 flex:1 撑满横向,不变成"随内容缩"的小盒子 + // Draft pool is hydrating (first entering the PR) → placeholder; only an empty array after fetch shows "no drafts". + // The empty state is also wrapped in .drafts-panel so flex:1 fills horizontally, rather than becoming a "shrink-to-content" small box if (drafts === null) { return ( <div className="drafts-panel"> @@ -274,7 +274,7 @@ export function DraftsPanel({ pr, onJumpToAnchor, capabilities, readOnly = false ); } -// 状态/筛选项映射 i18n key(实际文案在组件内用 t() 解析,保持模块级表稳定) +// Status/filter → i18n key mapping (actual text is resolved with t() inside the component, keeping the module-level table stable) const FILTER_LABEL_KEY: Record<Filter, string> = { publishable: 'draftsPanel.filterPublishable', all: 'draftsPanel.filterAll', diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx index 9e4d7595..2fab76ba 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/PublishReviewModal.tsx @@ -5,17 +5,17 @@ import { invoke } from '../../../../../api'; import { formatBackendError } from '../../../../../errors'; /** - * 批量发布草稿到 Bitbucket 的确认 modal。M4 发布闭环最后一公里。 + * Confirmation modal for batch-publishing drafts to Bitbucket. The last mile of the M4 publish loop. * - * 流程: - * 1. confirm: 列出本 PR 所有可发布草稿 (pending + edited),用户可勾选 / 取消 - * 2. publishing: 调 drafts:publishBatch,main 端串行 POST 到 Bitbucket - * 3. done: 显示成功 N 条 / 失败 M 条 + 每条失败明细 + * Flow: + * 1. confirm: list all publishable drafts of this PR (pending + edited), user can check / uncheck + * 2. publishing: call drafts:publishBatch, main side serially POSTs to Bitbucket + * 3. done: show N succeeded / M failed + per-failure details * - * - rejected 草稿不在列表里 (用户决断不发) - * - posted 草稿不在列表里 (远端已经有,避免重复) - * - 默认全部勾选 — 用户进 modal 已经表达"想发评论"的意图,全选符合多数场景; - * 不想发的单条可取消 + * - rejected drafts are not in the list (user decided not to publish) + * - posted drafts are not in the list (already on remote, avoid duplicates) + * - all checked by default — entering the modal already expresses the intent "want to publish comments", select-all fits most cases; + * uncheck individual ones you don't want to publish */ type Phase = 'confirm' | 'publishing' | 'done'; @@ -33,17 +33,17 @@ export function PublishReviewModal({ onJumpToAnchor, }: { localId: string; - /** 本 PR 全部草稿;modal 自己过滤出 publishable (pending + edited) */ + /** All drafts of this PR; the modal itself filters out publishable ones (pending + edited) */ drafts: ReadonlyArray<ReviewDraft>; onClose: () => void; /** - * 用户点 anchor (path:line) 时调用。父端通常实现为:关闭 modal + 触发 Diff - * 跳转到该草稿位置 (复用 pendingDiffNav 链路)。不传则 anchor 不可点 + * Called when the user clicks an anchor (path:line). Parent typically implements: close the modal + trigger Diff + * jump to that draft's position (reusing the pendingDiffNav link). Absent means the anchor is not clickable */ onJumpToAnchor?: (draftId: string) => void; }) { const { t } = useTranslation(); - // 列表用快照:进入 modal 时定下,避免 drafts 变动 (其它窗口编辑) 把当前选择洗掉 + // List snapshot: fixed on entering the modal, to avoid drafts changes (edits from other windows) washing out the current selection const candidates = useMemo<ReviewDraft[]>( () => drafts.filter((d) => d.status === 'pending' || d.status === 'edited'), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -54,7 +54,7 @@ export function PublishReviewModal({ const [results, setResults] = useState<PublishResult[]>([]); const [error, setError] = useState<string | null>(null); - // Esc 关闭 (publishing 阶段禁用避免误中断;done 阶段允许) + // Esc to close (disabled during publishing phase to avoid accidental interruption; allowed in done phase) useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape' && phase !== 'publishing') onClose(); @@ -97,8 +97,8 @@ export function PublishReviewModal({ const okCount = results.filter((r) => r.ok).length; const failCount = results.length - okCount; - // 进入时本 PR 没有 publishable 草稿 → 直接显示空态 (理论上 header 按钮 disabled - // 时不会触发开 modal,但作 fallback) + // On entry this PR has no publishable drafts → show the empty state directly (in theory the header button is disabled + // so it won't trigger opening the modal, but kept as a fallback) if (candidates.length === 0) { return ( <div className="modal-backdrop" onClick={onClose}> @@ -170,8 +170,8 @@ export function PublishReviewModal({ onChange={() => toggle(d.id)} /> <div className="publish-review-item-meta"> - {/* anchor 可点 → 关 modal + 跳 Diff;checkbox label 是 outer, - 这里 stopPropagation 防 click 触发勾选切换 */} + {/* anchor is clickable → close modal + jump to Diff; the checkbox label is outer, + stopPropagation here prevents the click from triggering a check toggle */} {onJumpToAnchor ? ( <button type="button" diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts index 9290913f..e1c8775a 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/drafts/useDraftZone.ts @@ -12,11 +12,11 @@ export interface UseDraftZoneParams { } /** - * Diff 内联草稿 zone 的 read/edit/publish 状态机。把全部 state / ref / effect / handler 收敛于此, - * DraftZone 组件只消费返回值做渲染。 + * read/edit/publish state machine for the Diff inline draft zone. Converges all state / ref / effect / handler here, + * the DraftZone component only consumes the return value to render. * - * 取消四档逻辑(runCancelLogic)被取消按钮 / unmount cleanup / Esc 三处共用,全靠 ref 读最新值, - * 保持「三处行为完全一致」的现有约定 —— 整体留在 hook 内,调用点都指向同一函数。 + * The four-branch cancel logic (runCancelLogic) is shared by the cancel button / unmount cleanup / Esc, all reading the latest + * value via refs, keeping the existing convention that "all three behave identically" — kept entirely inside the hook, with all call sites pointing to the same function. */ export function useDraftZone({ draft, @@ -34,7 +34,7 @@ export function useDraftZone({ const [confirmDelete, setConfirmDelete] = useState(false); const textareaRef = useRef<HTMLTextAreaElement | null>(null); - // Ref 跟踪最新 state / props,供 unmount cleanup 闭包同步读 + // Refs track the latest state / props, for synchronous reads inside the unmount cleanup closure const editingBodyRef = useRef(editingBody); const isEditingRef = useRef(isEditing); const draftBodyRef = useRef(draft.body); @@ -48,19 +48,19 @@ export function useDraftZone({ draftBodyRef.current = draft.body; }, [draft.body]); - // 显式 mutate 标记 — 区分 "用户主动操作触发 unmount" vs "切换文件触发 unmount"。 - // 设计:mutate 入口设 true (handleSave/Cancel/Delete 各种路径);进 edit 时重置 - // false (new editing session 没显式意图)。**不在 finally 清** — 因为 mutate IPC - // 完成跟 drafts:changed 引发的 unmount 时序不可控,清得太早会让 cleanup 误判。 - // 锁的生命周期跟随 component instance:mutate 后保留 true 直到 unmount 或下次进 - // edit 时被重置 + // Explicit mutate flag — distinguishes "user-initiated action triggers unmount" vs "file switch triggers unmount". + // Design: set true at mutate entries (handleSave/Cancel/Delete various paths); reset to false when entering edit + // (a new editing session has no explicit intent). **Not cleared in finally** — because the timing between mutate IPC + // completion and the unmount caused by drafts:changed is uncontrollable, clearing too early would make cleanup misjudge. + // The lock's lifecycle follows the component instance: after mutate it stays true until unmount or the next entry + // into edit resets it const isMutatingRef = useRef(false); useEffect(() => { if (isEditing) isMutatingRef.current = false; }, [isEditing]); - // 统一的 cancel 四档逻辑 — handleCancel / unmount cleanup / Esc 共用 - // (避免三个地方分别实现引起行为漂移)。读 ref 拿最新值,支持 fire-and-forget + // Unified four-branch cancel logic — shared by handleCancel / unmount cleanup / Esc + // (avoids behavior drift from implementing it separately in three places). Reads refs for the latest value, supports fire-and-forget const runCancelLogic = (): void => { const editing = editingBodyRef.current.trim(); const persisted = draftBodyRef.current.trim(); @@ -69,7 +69,7 @@ export function useDraftZone({ return; } if (!editing) { - // 空 + 有 → revert (unmount 时 no-op,state 已销毁;handleCancel 时设 state) + // empty + has persisted → revert (no-op on unmount, state already destroyed; sets state on handleCancel) setEditingBody(draftBodyRef.current); setIsEditing(false); return; @@ -83,23 +83,23 @@ export function useDraftZone({ setIsEditing(false); }; - // unmount cleanup — 切换文件 / PR / tab 触发。跟取消按钮共用 runCancelLogic 让 - // 行为完全一致。isMutating=true 时跳过 (用户已经显式 mutate 接管,不需要 cleanup - // 兜底,避免跟 IPC 形成 race) + // unmount cleanup — triggered by switching file / PR / tab. Shares runCancelLogic with the cancel button so + // behavior is fully identical. Skipped when isMutating=true (the user has explicitly taken over via mutate, no cleanup + // fallback needed, avoiding a race with IPC) useEffect(() => { return () => { if (isMutatingRef.current) return; if (!isEditingRef.current) return; runCancelLogic(); }; - // 空 deps:mount/unmount 跑;runCancelLogic 内部全 ref 读,无 closure 失效问题 + // empty deps: runs on mount/unmount; runCancelLogic reads all refs internally, no closure staleness issue // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // 把 "进入编辑模式" 触发器注册到 DiffView 的 ref map。register 是稳定函数引用 - // (DiffView 用 useCallback 包),draft.id 不变 → effect 只在 mount/unmount 跑。 - // 调用 fn 不引发 React state change in DiffView side → 没有 re-render → 没有 - // unmount/mount 循环,进 edit 一次只一次 + // Register the "enter edit mode" trigger into DiffView's ref map. register is a stable function reference + // (DiffView wraps it with useCallback), draft.id doesn't change → effect only runs on mount/unmount. + // Calling fn does not cause a React state change on the DiffView side → no re-render → no + // unmount/mount cycle, entering edit happens exactly once useEffect(() => { registerEditTrigger?.(draft.id, () => { setIsEditing(true); @@ -108,12 +108,12 @@ export function useDraftZone({ return () => registerEditTrigger?.(draft.id, null); }, [draft.id, registerEditTrigger]); - // draft.body 外部变化 (e.g., 队列写回) + 当前非 editing → 同步 editingBody + // draft.body changed externally (e.g., queue write-back) + currently not editing → sync editingBody useEffect(() => { if (!isEditing) setEditingBody(draft.body); }, [draft.body, isEditing]); - // 进入 edit 时 focus + 光标到末尾 + // On entering edit, focus + move cursor to end useEffect(() => { if (isEditing && textareaRef.current) { const el = textareaRef.current; @@ -122,11 +122,11 @@ export function useDraftZone({ } }, [isEditing]); - // textarea 的 React onKeyDown 在 React 18 root delegation 下走 bubble 阶段, - // monaco 在 editor container 上的 capture-stage keydown listener 可能在事件 - // 冒泡前就 stopPropagation 吞掉 Esc,导致 textarea 的 onKeyDown 永远不触发。 - // 兜底:window 顶层 capture listener,textarea focus 时按 Esc 走 runCancelLogic - // (跟取消按钮 / unmount cleanup 共用) + 设 isMutatingRef=true 让 cleanup 跳过 + // textarea's React onKeyDown runs in the bubble phase under React 18 root delegation, + // and monaco's capture-stage keydown listener on the editor container may stopPropagation and swallow Esc + // before the event bubbles, so textarea's onKeyDown never fires. + // Fallback: a window-level capture listener, when textarea is focused pressing Esc runs runCancelLogic + // (shared with the cancel button / unmount cleanup) + sets isMutatingRef=true so cleanup skips useEffect(() => { if (!isEditing) return; const onKey = (e: KeyboardEvent): void => { @@ -139,23 +139,23 @@ export function useDraftZone({ }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - // runCancelLogic 是局部 fn,不放 deps;isEditing 切回时移除 listener + // runCancelLogic is a local fn, not in deps; the listener is removed when isEditing switches back // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditing]); const status = draft.status; - // posted 不应该再被编辑;UI 上隐藏编辑/删除按钮,整体灰显 + // posted should no longer be editable; UI hides edit/delete buttons and greys the whole thing out const canEdit = status !== 'posted'; const trimmedEditing = editingBody.trim(); - // 评论不能为空:trim 后无字符不允许保存。提交按钮 disabled + 按钮 title 解释 + // Comment cannot be empty: no characters after trim disallows saving. Submit button disabled + button title explains const canSave = trimmedEditing.length > 0; const handleSave = async (): Promise<void> => { if (saving) return; - if (!canSave) return; // 防御:除按钮 disabled 外双保险 + if (!canSave) return; // defensive: double safeguard beyond the button being disabled if (trimmedEditing === draft.body.trim()) { - // 没改 → 退回 read 不调 IPC + // unchanged → fall back to read without calling IPC setIsEditing(false); return; } @@ -163,16 +163,16 @@ export function useDraftZone({ setSaving(true); try { await onSave(editingBody); - // 等 store 事件回来后 draft.body 更新,但乐观先退出 edit;体感更顺 + // draft.body updates after the store event comes back, but optimistically exit edit first; feels smoother setIsEditing(false); } finally { setSaving(false); } }; - // 单条直接发布。仅 pending/edited 可发;body 空也不发 (Bitbucket 拒绝空评论)。 - // 不弹 confirm — 单条评论的"发布"心智跟"批量评审"不同,是即时操作;要二次 - // 确认放在批量入口的 PublishReviewModal 那一层做就够了 + // Publish a single draft directly. Only pending/edited are publishable; empty body is not published either (Bitbucket rejects empty comments). + // No confirm popup — the "publish" mental model for a single comment differs from "batch review", it's an instant action; the + // second confirmation is enough done at the PublishReviewModal layer, the batch entry const handlePublish = async (): Promise<void> => { if (!onPublish || publishing) return; if (status === 'posted' || status === 'rejected') return; @@ -187,8 +187,8 @@ export function useDraftZone({ res.error ? formatBackendError(res.error).title : t('draftZone.publishFailed'), ); } - // 成功 → drafts-store 广播让 status 切到 'posted',组件自动 re-render 显示 - // posted chip + 远端 id。无需手动 setIsEditing 之类,draft prop 流推回 + // Success → drafts-store broadcast switches status to 'posted', the component auto re-renders to show + // the posted chip + remote id. No manual setIsEditing etc. needed, the draft prop flow pushes it back } catch (e) { setPublishError(e instanceof Error ? e.message : String(e)); } finally { @@ -196,19 +196,19 @@ export function useDraftZone({ } }; - // edit 模式的"发布" — 先 save 当前 textarea 内容到本地,再调 publish。 - // 设计动机:取消按钮已经有 dirty auto-save 语义,独立的"保存"按钮反而冗余; - // 用户在 edit 改完最自然的下一步是发布,合并一次点击。 - // - 保存失败 (本地写盘几乎不会) → 仍尝试发布;publish 端读盘拿到的是 main 上 - // 一次成功的 body,对用户没有静默错误风险 - // - 发布失败 (Bitbucket 4xx) → 留在 edit 让用户改 body 重试,inline error 提示 + // "publish" in edit mode — save the current textarea content locally first, then call publish. + // Design motivation: the cancel button already has dirty auto-save semantics, a standalone "save" button is redundant; + // after editing, the most natural next step is to publish, so merge into one click. + // - Save failure (local disk write almost never fails) → still attempt publish; what the publish side reads from disk is the + // last successfully saved body on main, no silent-error risk to the user + // - Publish failure (Bitbucket 4xx) → stay in edit so the user can edit body and retry, with an inline error hint const handlePublishFromEdit = async (): Promise<void> => { if (!onPublish || publishing) return; if (!canSave) return; isMutatingRef.current = true; setPublishError(null); - // 先持久化本次 editing 内容 — onPublish 通过 main store 读盘拿 body,必须 - // 保证盘上是用户刚改的最新版本 + // Persist this editing content first — onPublish reads the body from disk via the main store, so we must + // ensure disk holds the latest version the user just edited if (editingBody !== draft.body) { setSaving(true); try { @@ -235,10 +235,10 @@ export function useDraftZone({ }; const handleCancel = async (): Promise<void> => { - // 取消复用 runCancelLogic (跟 unmount cleanup 共用)。区别:handleCancel 主动 - // 设 isMutatingRef=true (用户显式操作),让 cleanup 知道意图已被接管 + // Cancel reuses runCancelLogic (shared with unmount cleanup). Difference: handleCancel actively + // sets isMutatingRef=true (explicit user action), letting cleanup know the intent has been taken over isMutatingRef.current = true; - // 内部 dirty 分支需要 await save 才退 edit,单独走一遍含 await 的版本: + // The internal dirty branch needs to await save before exiting edit, so run a separate awaited version: const editingTrim = editingBody.trim(); const persistedTrim = draft.body.trim(); if (!editingTrim && !persistedTrim) { @@ -264,8 +264,8 @@ export function useDraftZone({ }; const handleDelete = (): void => { - // body 非空(含编辑中的草稿)→ 弹 ConfirmModal 二次确认;空草稿直接删。 - // edit 模式下用 editingBody 判(用户可能在 textarea 输入很多还没保存就点删除) + // body non-empty (including a draft being edited) → pop ConfirmModal for a second confirmation; empty draft deletes directly. + // In edit mode judge by editingBody (the user may have typed a lot in the textarea unsaved before clicking delete) const currentBody = (isEditing ? editingBody : draft.body).trim(); if (currentBody) { setConfirmDelete(true); @@ -284,7 +284,7 @@ export function useDraftZone({ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { if (e.nativeEvent.isComposing) return; if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - // Cmd/Ctrl+Enter 优先走"发布" (跟新的主按钮一致);onPublish 缺失场景退回保存 + // Cmd/Ctrl+Enter prefers "publish" (consistent with the new primary button); falls back to save when onPublish is absent e.preventDefault(); if (onPublish) { void handlePublishFromEdit(); @@ -297,12 +297,12 @@ export function useDraftZone({ } }; - // 取消按钮动态文案:textarea 有内容 → "暂存",明示点击不丢内容;textarea - // 完全空 → "取消",对应"退出 edit 不留草稿"的语义 (runCancelLogic 此时会删 - // 真空草稿 / revert)。 - // 不再用"dirty (editing != persisted)"作判据 — 用户进 edit 看到已存的旧内容 - // 没改也"有文字",按用户心智应该是暂存 (即使内部走 no-op 直接退出 edit) - // textarea 有内容 → 暂存语义;空 → 取消语义。stash 标志驱动按钮文案与 title + // Cancel button dynamic label: textarea has content → "stash", signaling the click won't lose content; textarea + // completely empty → "cancel", corresponding to the "exit edit without leaving a draft" semantics (runCancelLogic then deletes + // the truly empty draft / reverts). + // No longer uses "dirty (editing != persisted)" as the criterion — a user entering edit and seeing existing saved content + // without changing it still "has text", and per the user's mental model this should be stash (even if internally it's a no-op exit of edit) + // textarea has content → stash semantics; empty → cancel semantics. The stash flag drives the button label and title const isStash = trimmedEditing.length > 0; const cancelLabel = isStash ? t('draftZone.stash') : t('common.cancel'); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/CommentMarkdown.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/CommentMarkdown.tsx index 03917539..2d6d279b 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/CommentMarkdown.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/CommentMarkdown.tsx @@ -6,12 +6,12 @@ import { remarkEmojiShortcodes } from '../../../../../lib/remark-emoji'; import { transformBitbucketUrl } from '../../../../common'; /** - * 评论正文 markdown 渲染样板:评论/活动 tab 与 diff 行内评论 zone 共用同一套 - * remark / rehype / urlTransform 配置,仅 `components`(各自的 img / a / mermaid 覆盖)与外层 - * `className`(`pr-comment-body` / `comment-zone-body`)由调用方传入,互不收敛。 + * Comment body markdown rendering boilerplate: the comments/activity tab and the diff inline comment zone share the same + * remark / rehype / urlTransform config, only `components` (each's img / a / mermaid overrides) and the outer + * `className` (`pr-comment-body` / `comment-zone-body`) are passed in by the caller, kept independent. * - * hardBreaks(Bitbucket / GitHub)挂 remarkBreaks 让单 `\n` → `<br>`;GitLab 走标准 CommonMark - * (单 `\n` = 空格)不挂,与各自 web 渲染对齐。 + * hardBreaks (Bitbucket / GitHub) attaches remarkBreaks to turn a single `\n` → `<br>`; GitLab uses standard CommonMark + * (single `\n` = space) and doesn't attach it, aligned with each's web rendering. */ export function CommentMarkdown({ body, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx index 2c3bc636..f9afe221 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/MentionTextarea.tsx @@ -3,23 +3,23 @@ import { useTranslation } from 'react-i18next'; import type { PlatformUser } from '@meebox/shared'; import { ImageIcon } from '../../../../common'; -/** 弹出候选最多展示条数(候选源本就是 PR 参与者的有界集合,再截断以免列表过长)。 */ +/** Max number of suggestions shown in the popup (the source is already a bounded set of PR participants; truncate further to keep the list from growing too long). */ const MAX_SUGGESTIONS = 8; interface MentionMenu { - /** `@` 之后已输入的查询串(不含 `@`)。 */ + /** Query string typed after `@` (excluding `@`). */ query: string; - /** `@` 在 value 中的下标(替换插入时的起点)。 */ + /** Index of `@` in value (start point for replacement insertion). */ at: number; - /** 过滤后的候选。 */ + /** Filtered suggestions. */ items: PlatformUser[]; - /** 当前高亮项下标。 */ + /** Index of the currently highlighted item. */ index: number; } /** - * 解析光标前文本里正在输入的 `@提及` token:返回 `@` 位置与查询串,否则 null。 - * 触发条件:`@` 紧跟在行首 / 空白 / 左括号之后,其后是「非空白非 @」串(用户名允许 . - _)。 + * Parse the `@mention` token being typed in the text before the cursor: return the `@` position and query string, otherwise null. + * Trigger condition: `@` immediately follows the line start / whitespace / an opening paren, followed by a "non-whitespace non-@" string (usernames allow . - _). */ function parseMention(value: string, caret: number): { at: number; query: string } | null { const before = value.slice(0, caret); @@ -30,12 +30,12 @@ function parseMention(value: string, caret: number): { at: number; query: string } /** - * 评论编辑用 textarea,叠加 `@提及` 自动补全。候选由调用方传入(PR 参与者 + 评论作者等**已加载**的 - * 有界集合,不向远端枚举全员,见 docs/arch/01-platform/01-adapter);输入 `@` 后按查询串就地过滤、↑↓ 选择、Enter/Tab 确认、 - * Esc 关闭。补全仅为便利——用户仍可自由手打任意 `@name`,平台据文本自行解析通知。 + * Comment-editing textarea overlaid with `@mention` autocomplete. Suggestions are passed in by the caller (a bounded set of **already loaded** + * PR participants + comment authors etc., not an enumeration of all remote members, see docs/arch/01-platform/01-adapter); after typing `@` filter in place by the query string, ↑↓ to select, Enter/Tab to confirm, + * Esc to close. Autocomplete is only a convenience — users can still freely type any `@name` by hand, and the platform parses notifications from the text itself. * - * 弹层打开时拦截 ↑↓/Enter/Tab/Esc 用于候选导航,其余按键(含 Cmd/Ctrl+Enter 发送、Esc 取消)冒泡给 - * 调用方的 onKeyDown;弹层关闭时所有按键都交给 onKeyDown。 + * While the popup is open, ↑↓/Enter/Tab/Esc are intercepted for suggestion navigation; other keys (including Cmd/Ctrl+Enter to send, Esc to cancel) bubble up to + * the caller's onKeyDown; while the popup is closed all keys go to onKeyDown. */ export function MentionTextarea({ value, @@ -56,8 +56,8 @@ export function MentionTextarea({ candidates: PlatformUser[]; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; /** - * 粘贴图片时的上传回调:上传成功返回可插入的 markdown(否则 null)。提供时启用图片粘贴上传 - * (平台 commentAttachments 能力为真才由调用方传入)。上传期间禁用输入,避免 value 漂移。 + * Upload callback for pasted images: returns insertable markdown on success (otherwise null). When provided, enables image paste upload + * (the caller only passes it when the platform's commentAttachments capability is true). Input is disabled during upload to avoid value drift. */ onUpload?: (file: File) => Promise<string | null>; placeholder?: string; @@ -67,8 +67,8 @@ export function MentionTextarea({ autoFocus?: boolean; ariaLabel?: string; /** - * 可选:把内部 textarea 元素回传给调用方 ref(与组件内部 ref 并存)。供调用方做外部 focus / - * 焦点判定(如 DraftZone 进入编辑态聚焦、Esc 命中判定)。不传则仅组件内部使用。 + * Optional: pass the internal textarea element back to the caller's ref (coexists with the component's internal ref). Lets the caller do external focus / + * focus checks (e.g. DraftZone focusing on entering edit mode, Esc hit testing). If omitted, used only internally by the component. */ textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>; }) { @@ -79,7 +79,7 @@ export function MentionTextarea({ const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState<string | null>(null); - // 候选去重(按 name),保序:调用方可能混入重复参与者 / 评论作者。 + // Dedupe suggestions (by name), preserving order: the caller may mix in duplicate participants / comment authors. const pool = useMemo(() => { const seen = new Set<string>(); const out: PlatformUser[] = []; @@ -120,7 +120,7 @@ export function MentionTextarea({ const next = value.slice(0, menu.at) + insert + value.slice(end); onChange(next); setMenu(null); - // 插入后把光标放到补全文本之后 + // After insertion, place the cursor after the completed text const caret = menu.at + insert.length; requestAnimationFrame(() => { const el = ref.current; @@ -158,7 +158,7 @@ export function MentionTextarea({ onKeyDown?.(e); }; - // 上传一张图片并把返回的 markdown 插入当前光标处(无焦点时插到末尾)。粘贴 / 点附件按钮共用。 + // Upload one image and insert the returned markdown at the current cursor (append to end when unfocused). Shared by paste / clicking the attach button. const uploadAndInsert = (file: File): void => { if (!onUpload || uploading) return; const at = ref.current?.selectionStart ?? value.length; @@ -179,7 +179,7 @@ export function MentionTextarea({ }); }) .catch((e: unknown) => { - // 上传失败(平台拒绝 / 网络等):就地提示,不让 rejection 逃逸成未处理异常。 + // Upload failed (platform rejection / network etc.): show inline, don't let the rejection escape as an unhandled exception. setUploadError(e instanceof Error ? e.message : String(e)); }) .finally(() => setUploading(false)); @@ -199,7 +199,7 @@ export function MentionTextarea({ const pickFile = (e: React.ChangeEvent<HTMLInputElement>): void => { const file = e.target.files?.[0]; if (file) uploadAndInsert(file); - e.target.value = ''; // 复位:同一文件可再次选取触发 change + e.target.value = ''; // Reset: allow the same file to be selected again to trigger change }; return ( @@ -271,7 +271,7 @@ export function MentionTextarea({ role="option" aria-selected={i === menu.index} className={`mention-option${i === menu.index ? ' mention-option-active' : ''}`} - // mousedown 而非 click:抢在 textarea blur(关闭弹层)之前选中 + // mousedown rather than click: select before the textarea blur (which closes the popup) onMouseDown={(e) => { e.preventDefault(); select(u); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx index 9eb13218..02253388 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/ReactionBar.tsx @@ -10,13 +10,13 @@ import { import { SmilePlusIcon } from '../../../../common'; import { invoke } from '../../../../../api'; -/** 弹层估算尺寸(用于自适应翻转 / 视口夹取的占位计算;实际尺寸由内容决定)。 */ +/** Estimated popup size (used for adaptive flip / viewport-clamp placeholder calculations; actual size is determined by content). */ const MENU_SIZE = { fixed: { w: 264, h: 48 }, free: { w: 244, h: 232 }, } as const; -/** 据触发按钮位置 + 视口空间算弹层 fixed 坐标:下方空间不足且上方更宽裕则上翻;水平按视口夹取。 */ +/** Compute the popup's fixed coordinates from the trigger button position + viewport space: flip up when there's insufficient space below and more room above; clamp horizontally to the viewport. */ function computeMenuPos(rect: DOMRect, mode: 'fixed' | 'free'): { top: number; left: number } { const margin = 6; const { w, h } = MENU_SIZE[mode]; @@ -28,8 +28,8 @@ function computeMenuPos(rect: DOMRect, mode: 'fixed' | 'free'): { top: number; l } /** - * 评论 emoji 反应的共享状态 + 切换逻辑。切换经 `comments:toggleReaction` 写远端,成功后 main 广播 - * comments:changed → 评论列表重拉刷新(不维护本地乐观态,与编辑/删除一致)。busy 期间禁用避免重复点击。 + * Shared state + toggle logic for comment emoji reactions. Toggling writes remotely via `comments:toggleReaction`; on success main broadcasts + * comments:changed → the comment list refetches and refreshes (no local optimistic state, consistent with edit/delete). Disabled during busy to avoid duplicate clicks. */ export function useReactions( prLocalId: string, @@ -38,7 +38,7 @@ export function useReactions( ): { reactions: PrReaction[]; busy: boolean; toggle: (emoji: string, add: boolean) => void } { const [busy, setBusy] = useState(false); const reactions = comment.reactions ?? []; - // GitHub 据 kind 选 issue / review 反应端点;其余平台忽略。anchor 兜底(旧数据无 kind)。 + // GitHub picks the issue / review reaction endpoint by kind; other platforms ignore it. anchor is the fallback (old data has no kind). const kind: 'summary' | 'inline' = comment.kind ?? (comment.anchor ? 'inline' : 'summary'); const toggle = useCallback( (emoji: string, add: boolean): void => { @@ -52,7 +52,7 @@ export function useReactions( add, }) .catch(() => { - // 失败静默:列表不会因 comments:changed 刷新出新反应,状态保持原样 + // Silent on failure: the list won't refresh a new reaction via comments:changed, state stays as-is }) .finally(() => setBusy(false)); }, @@ -61,7 +61,7 @@ export function useReactions( return { reactions, busy, toggle }; } -/** 已有反应的展示条:emoji + 计数,本人反应高亮,点击切换。无反应则不渲染。 */ +/** Display bar for existing reactions: emoji + count, own reactions highlighted, click to toggle. Not rendered when there are no reactions. */ export function ReactionChips({ reactions, busy, @@ -95,8 +95,8 @@ export function ReactionChips({ } /** - * 「加反应」按钮 + 弹出选择器。放在评论操作按钮行内(Reply/Edit 之后)。点击切换弹层,点击弹层外部 / - * Esc 收起(非模态)。fixed 模式(GitHub)列固定 8 种;free 模式(GitLab/Bitbucket)列精选集 + 搜索。 + * "Add reaction" button + popup picker. Placed inline in the comment action button row (after Reply/Edit). Click to toggle the popup, click outside the popup / + * Esc to dismiss (non-modal). fixed mode (GitHub) lists a fixed 8; free mode (GitLab/Bitbucket) lists a curated set + search. */ export function ReactionAddButton({ reactions, @@ -115,7 +115,7 @@ export function ReactionAddButton({ const triggerRef = useRef<HTMLButtonElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null); - // 据触发按钮位置定位弹层;开启时及滚动 / 缩放时重算(弹层走 portal + fixed,故跟随而不被裁切)。 + // Position the popup by the trigger button; recompute on open and on scroll / resize (the popup uses portal + fixed, so it follows without being clipped). useLayoutEffect(() => { if (!open) return; const place = (): void => { @@ -131,7 +131,7 @@ export function ReactionAddButton({ }; }, [open, mode]); - // 点击弹层 / 触发按钮以外 / Esc 收起(非模态:不加遮罩、不拦其它交互)。 + // Click outside the popup / trigger button, or Esc, dismisses (non-modal: no overlay, doesn't block other interactions). useEffect(() => { if (!open) return; const onDown = (e: MouseEvent): void => { @@ -201,7 +201,7 @@ export function ReactionAddButton({ ); } -/** free 模式选择器:搜索框 + 精选 emoji 网格(按关键词 / shortcode 过滤)。 */ +/** free mode picker: search box + curated emoji grid (filter by keyword / shortcode). */ function FreeReactionPicker({ busy, mineOf, @@ -213,7 +213,7 @@ function FreeReactionPicker({ }) { const { t } = useTranslation(); const [q, setQ] = useState(''); - // 空查询回精选默认集;否则在全量 gemoji 词表搜索(截断 60)。 + // Empty query returns the curated default set; otherwise search the full gemoji vocabulary (truncated to 60). const items = searchReactionEmojis(q); return ( <div className="pr-reaction-picker pr-reaction-picker-free" role="menu"> diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/uploadCommentImage.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/uploadCommentImage.ts index 8975adab..ddbdac68 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/uploadCommentImage.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/uploadCommentImage.ts @@ -1,8 +1,8 @@ import { invoke } from '../../../../../api'; /** - * 把粘贴 / 选取的图片 File 经 IPC 上传到当前 PR 的附件存储,返回可插入正文的 markdown - * (上传失败 / 平台不支持回 null)。评论回复 / 新建评论 / 草稿编辑共用,避免各处重复实现。 + * Upload a pasted / selected image File to the current PR's attachment storage via IPC, returning body-insertable markdown + * (returns null on upload failure / platform not supported). Shared by comment reply / new comment / draft editing to avoid reimplementing everywhere. */ export async function uploadCommentImage(prLocalId: string, file: File): Promise<string | null> { const bytes = await file.arrayBuffer(); diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/useCommentThread.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/useCommentThread.ts index 121702ab..ed9984b0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/useCommentThread.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/useCommentThread.ts @@ -3,13 +3,13 @@ import type { PrComment } from '@meebox/shared'; import { invoke } from '../../../../../api'; /** - * 单条评论的「回复 / 编辑 / 删除」交互状态机。评论/活动 tab 的 CommentItem 与 diff 行内 - * 评论 zone 的 CommentNode 共用同一份逻辑(IPC 调用、状态转移、权限读取完全一致),仅外壳 - * (CSS 类 / i18n 文案 / 版式)各异。 + * Interaction state machine for a single comment's "reply / edit / delete". The comment/activity tab's CommentItem and the diff inline + * comment zone's CommentNode share the same logic (IPC calls, state transitions, permission reads are identical), differing only in the shell + * (CSS classes / i18n text / layout). * - * canEdit / canDelete 由 main 端预判(annotateOwnership),renderer 直读 flag,不再自己比对 - * author / version / replies。删除成功后 main 端清 cache + 广播 comments:changed,上层面板/zone - * 重拉评论,这条自然从列表消失,无需本地维护。 + * canEdit / canDelete are predetermined by main (annotateOwnership); the renderer reads the flag directly, no longer comparing + * author / version / replies itself. After a successful delete, main clears the cache + broadcasts comments:changed, the upper panel/zone + * refetches comments, this one naturally disappears from the list, no local maintenance needed. */ export interface CommentThread { replyOpen: boolean; @@ -47,7 +47,7 @@ export function useCommentThread(prLocalId: string, comment: PrComment): Comment commentId: comment.remoteId, version: comment.version, }); - // 成功 → main 端清 cache + 广播 comments:changed → 上层重拉,这条评论自然消失 + // Success → main clears the cache + broadcasts comments:changed → upper layer refetches, this comment naturally disappears } catch (e) { setDeleteError(e instanceof Error ? e.message : String(e)); setDeleting(false); diff --git a/apps/desktop/src/renderer/src/components/features/settings/ConnectionForm.tsx b/apps/desktop/src/renderer/src/components/features/settings/ConnectionForm.tsx index 782bcfab..a5566af3 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/ConnectionForm.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/ConnectionForm.tsx @@ -6,10 +6,10 @@ import { invoke } from '../../../api'; import { formatBackendError } from '../../../errors'; import { EyeIcon, EyeOffIcon } from '../../common'; -// 连接编辑用的扁平草稿(Connection 是嵌套的 auth/clone,拍平后表单好写),存盘前还原。 -// 设置页 ConnectionEditorModal 与首启向导 PlatformStep 共用同一份草稿形状 + 表单。 +// Flat draft for connection editing (Connection is nested auth/clone; flattening makes the form easier), restored before saving. +// The settings-page ConnectionEditorModal and the first-launch wizard PlatformStep share the same draft shape + form. export type ConnEntry = Config['connections'][number]; -/** 当前支持配置的平台 kind */ +/** Platform kinds currently supported for configuration */ export type ConnKind = 'github' | 'bitbucket-server' | 'gitlab'; export type ConnDraft = { id: string; @@ -32,7 +32,7 @@ export function toConnDraft(c: ConnEntry): ConnDraft { } export function fromConnDraft(d: ConnDraft): ConnEntry { - // GitHub / GitLab 的 Base URL 可留空 → 默认官方 endpoint(GHE / 自建实例才需手填)。 + // Base URL for GitHub / GitLab may be left empty → default official endpoint (only GHE / self-hosted instances need manual entry). const trimmed = d.base_url.trim(); const base_url = trimmed === '' && d.kind === 'github' @@ -54,7 +54,7 @@ export function fromConnDraft(d: ConnDraft): ConnEntry { : { ...common, kind: 'bitbucket-server' as const }; } -/** 各平台的字段文案(名称 / Base URL / 令牌 占位) */ +/** Field copy for each platform (name / Base URL / token placeholders) */ function kindHints( t: TFunction, ): Record<ConnKind, { name: string; baseUrl: string; token: string }> { @@ -77,22 +77,22 @@ function kindHints( }; } -/** Base URL 形如 http(s)://… 才算合法;GitHub / GitLab 允许留空(默认官方 endpoint)。 */ +/** Base URL is valid only when shaped like http(s)://…; GitHub / GitLab may be left empty (default official endpoint). */ export function connUrlValid(d: ConnDraft): boolean { const u = d.base_url.trim(); if ((d.kind === 'github' || d.kind === 'gitlab') && u === '') return true; return /^https?:\/\/.+/i.test(u); } -/** 名称 + 合法 URL + token 三者齐全才允许保存 */ +/** Saving is allowed only when name + valid URL + token are all present */ export function connDraftCanSave(d: ConnDraft): boolean { return d.display_name.trim() !== '' && connUrlValid(d) && d.token.trim() !== ''; } /** - * 连接配置受控表单(名称 / Base URL / PAT / Clone 协议 + 「测试连接」)。 - * 只负责字段渲染与即时连通性测试;保存 / 取消等动作由外层(模态框 / 向导)提供。 + * Controlled connection-config form (name / Base URL / PAT / Clone protocol + "test connection"). + * Only handles field rendering and live connectivity testing; save / cancel actions are provided by the outer layer (modal / wizard). * - * autoFocus 默认开(模态打开聚焦名称);嵌在向导里时按需关闭避免抢焦点。 + * autoFocus defaults on (focuses the name field when the modal opens); turn it off as needed when embedded in the wizard to avoid stealing focus. */ export function ConnectionForm({ draft, @@ -110,7 +110,7 @@ export function ConnectionForm({ const update = <K extends keyof ConnDraft>(field: K, value: ConnDraft[K]): void => { onChange({ ...draft, [field]: value }); - setTestResult(null); // 改字段清掉旧测试结果,避免误导 + setTestResult(null); // clear the old test result on field change to avoid misleading }; const urlValid = connUrlValid(draft); @@ -216,7 +216,7 @@ export function ConnectionForm({ </select> </div> </div> - {/* 测试连接:独立一行,左按钮右结果。保存 / 取消 由外层决定布局 */} + {/* Test connection: own row, button on the left, result on the right. Save / cancel layout is decided by the outer layer */} <div className="settings-actions" style={{ marginTop: 12, alignItems: 'center' }}> <button type="button" diff --git a/apps/desktop/src/renderer/src/components/features/settings/LlmProfileForm.tsx b/apps/desktop/src/renderer/src/components/features/settings/LlmProfileForm.tsx index 16f0ba12..99fdb6d9 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/LlmProfileForm.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/LlmProfileForm.tsx @@ -8,25 +8,25 @@ export interface ProviderMeta { value: LlmProvider; label: string; hint: string; - /** Model 字段示例值 / placeholder */ + /** Example value / placeholder for the Model field */ modelExample: string; - /** Base URL 字段的默认 endpoint:作占位提示;用户填了即透传给 pr-agent,留空则由下游回落到等同此处的默认 endpoint */ + /** Default endpoint for the Base URL field: used as a placeholder hint; if the user fills it in it's passed through to pr-agent, if left empty the downstream falls back to a default endpoint equivalent to this one */ defaultBaseUrl: string; - /** API Key 字段是否必填 */ + /** Whether the API Key field is required */ needsKey: boolean; } -// 顺序:海外通用 (OpenAI / OpenAI 兼容 / Anthropic) → 国内三家 (DeepSeek / 阿里 -// 百炼 / 火山方舟) → 兜底 (OpenAI 兼容) → 本地 CLI,方便用户按主流程扫读 +// Order: overseas general (OpenAI / OpenAI-compatible / Anthropic) → three domestic providers (DeepSeek / Alibaba +// DashScope / Volcengine Ark) → fallback (OpenAI-compatible) → local CLI, so users can scan along the main flow // -// label / hint 用 getter 经 i18n.t 惰性取值:保持数组形状不变(消费方直接读 .label 等), -// 同时让文案随当前语言解析(品牌名等无对应 key 时退回字面量)。modelExample 是纯模型名示例 -// (各语言相同、不翻译),作静态字面量、不进 i18n。 +// label / hint use getters that resolve lazily via i18n.t: keeps the array shape unchanged (consumers read .label etc. directly), +// while letting the copy resolve with the current language (fall back to the literal when a brand name etc. has no matching key). modelExample is a plain model-name example +// (same across languages, not translated), a static literal, not in i18n. function provider( - // modelExample 是纯模型名示例(各语言相同、不翻译),作静态字面量传入、**不进 i18n**; - // label / hint 才走 i18n(label 为品牌名时给字面量、省略 key)。 + // modelExample is a plain model-name example (same across languages, not translated), passed in as a static literal, **not in i18n**; + // only label / hint go through i18n (give a literal for label when it's a brand name, omitting the key). meta: Omit<ProviderMeta, 'label' | 'hint'> & { - /** label 无 i18n key(品牌名)时给字面量;有 key 时省略 */ + /** Give a literal for label when there's no i18n key (brand name); omit when a key exists */ label?: string; }, ): ProviderMeta { @@ -76,16 +76,16 @@ export const LLM_PROVIDERS: ReadonlyArray<ProviderMeta> = [ needsKey: true, modelExample: 'ep-20240xxxxxx-xxxxx / doubao-pro-32k / doubao-1-5-pro-256k', }), - // 「OpenAI 兼容」:兜底通用项,主流程让用户先扫读具名 provider;本地 Ollama 也走它 - // (Base URL 填 http://localhost:11434/v1,密钥留空)。 + // "OpenAI-compatible": the fallback general option; the main flow lets users scan the named providers first; local Ollama also uses it + // (fill Base URL with http://localhost:11434/v1, leave the key empty). provider({ value: 'openai-compatible', defaultBaseUrl: '', needsKey: true, modelExample: 'gpt-4o-mini / qwen2.5-72b-instruct', }), - // 「本地 CLI」放最后:进阶项,转交本机命令行工具代调模型,不直连 API。 - // modelExample 不展示(model 输入框对 cli 用 cliCommandPlaceholder),置空。 + // "Local CLI" goes last: an advanced option that delegates model calls to a local command-line tool instead of connecting to the API directly. + // modelExample is not shown (the model input uses cliCommandPlaceholder for cli), left empty. provider({ value: 'cli', defaultBaseUrl: '', @@ -110,19 +110,19 @@ export interface ProfileErrors { } /** - * 名称 slug 规则:1-32 字符,首位字母数字,其余可含 `-` / `_`。 - * 避免空格 / 中文符号 / 大写形态差异 / 路径分隔符造成日志或文件命中歧义。 + * Name slug rule: 1-32 chars, first char alphanumeric, the rest may contain `-` / `_`. + * Avoids ambiguity in logs or file matching from spaces / CJK symbols / case-form differences / path separators. */ const LABEL_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/; /** - * 按 provider 元信息判定 profile 哪些字段必填: - * - label 必填,且必须符合 slug 规则 - * - model 永远必填(pr-agent 必须知道用哪个模型) - * - api_key:needsKey=true 的 provider 必填(本地 CLI / 本地服务不需要) - * - base_url:没有默认值的 provider 必填(仅 openai-compatible) + * Determine which profile fields are required based on provider metadata: + * - label required, and must conform to the slug rule + * - model always required (pr-agent must know which model to use) + * - api_key: required for providers with needsKey=true (local CLI / local services don't need it) + * - base_url: required for providers without a default value (only openai-compatible) * - * existing 传入用于唯一性校验(编辑时排除自身 id)。 + * existing is passed in for uniqueness validation (excludes its own id when editing). */ export function validateProfile(p: LlmProfile, existing: LlmProfile[]): ProfileErrors { const errors: ProfileErrors = {}; @@ -138,9 +138,9 @@ export function validateProfile(p: LlmProfile, existing: LlmProfile[]): ProfileE if (dup) errors.label = i18n.t('llmProfileForm.errorLabelDuplicate'); } - // cli:model 字段填的是本机命令名,无 base_url / api_key 概念。隐藏校验——只放行已适配的 - // 命令(claude / codex,与 sitecustomize 的 _CLI_SPECS 同步),其余命令运行时无对应规格、跑不通。 - // 提示不点名受支持的命令(保持隐晦),仅给通用的「不受支持」反馈。 + // cli: the model field holds a local command name, with no base_url / api_key concept. Hidden validation—only allows + // adapted commands (claude / codex, kept in sync with sitecustomize's _CLI_SPECS); other commands have no matching spec at runtime and won't run. + // The hint doesn't name the supported commands (keeps it opaque), only gives generic "unsupported" feedback. if (p.provider === 'cli') { const cmd = p.model.trim().toLowerCase(); if (!cmd) errors.model = i18n.t('llmProfileForm.errorRequired'); @@ -165,11 +165,11 @@ export function newProfileId(): string { } /** - * LLM 预设受控表单(名称 / Provider / Model / Base URL / API Key + provider 提示)。 - * 字段级 touched 校验:只对碰过(失焦)的字段亮错,避免刚打开一片红。 + * Controlled LLM profile form (name / Provider / Model / Base URL / API Key + provider hint). + * Field-level touched validation: only shows errors on fields that have been touched (blurred), avoiding an all-red state right after opening. * - * 外层(设置页子模态 / 向导 LLM 步)通过 `forceShowErrors` 在点保存时一次性暴露 - * 所有必填项;通过 onValidityChange 上报当前是否全部合法。 + * The outer layer (settings-page sub-modal / wizard LLM step) uses `forceShowErrors` to expose + * all required fields at once when Save is clicked; reports whether everything is currently valid via onValidityChange. */ export function LlmProfileForm({ draft, @@ -184,7 +184,7 @@ export function LlmProfileForm({ onChange: (draft: LlmProfile) => void; forceShowErrors?: boolean; onValidityChange?: (valid: boolean) => void; - /** 由外部(如向导左侧列表)控制 provider 时,隐藏表单内的 Provider 下拉 */ + /** When provider is controlled externally (e.g. the wizard's left-side list), hide the in-form Provider dropdown */ hideProvider?: boolean; }) { const { t } = useTranslation(); @@ -208,7 +208,7 @@ export function LlmProfileForm({ onChange(next); onValidityChange?.(Object.keys(validateProfile(next, existing)).length === 0); }; - // Base URL placeholder:有默认 endpoint 时直接展示该 URL;没有默认值时给示例 + 必填提示 + // Base URL placeholder: show the URL directly when there's a default endpoint; give an example + required hint when there's no default const baseUrlPlaceholder = providerMeta.defaultBaseUrl || 'https://your-endpoint.example.com/v1'; return ( @@ -265,7 +265,7 @@ export function LlmProfileForm({ } /> </div> - {/* cli 模式不直连 API:没有 Base URL / API Key 概念,整组隐藏 */} + {/* cli mode doesn't connect to the API directly: no Base URL / API Key concept, hide the whole group */} {!isCli && ( <> <div className="modal-kv-key"> diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index e52a22a9..62a21955 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -17,6 +17,7 @@ import { useAppearanceDraft } from './hooks/useAppearanceDraft'; import { ConnectionEditorModal } from './editors/ConnectionEditorModal'; import { LlmEditorModal } from './editors/LlmEditorModal'; import { ProxyEditorModal } from './editors/ProxyEditorModal'; +import { TemplateEditorModal } from './editors/TemplateEditorModal'; import { LanguageSection } from './sections/LanguageSection'; import { ThemeSection } from './sections/ThemeSection'; import { EditorSection } from './sections/EditorSection'; @@ -44,8 +45,8 @@ export type SettingsCategory = | 'about'; /** - * 配置分区导航元数据(左侧栏)。新增配置分区在此登记一项,并在右侧面板的 switch - * 中渲染对应 section —— 分区结构为后续扩展(主题 / 编辑器 / 上下文窗口等)预留。 + * Settings-section nav metadata (left sidebar). Register a new settings section here and render + * the corresponding section in the right panel's switch — the section structure is reserved for future extensions (theme / editor / context window etc.). */ const SETTINGS_CATEGORIES: ReadonlyArray<{ id: SettingsCategory; @@ -65,33 +66,33 @@ interface SettingsModalProps { info: AppInfo; paths: AppPaths; config: Config; - /** LLM 配置改动后通知父级同步状态(StatusBar chip 等) */ + /** Notify the parent to sync state (StatusBar chip etc.) after LLM config changes */ onLlmChange?: (llm: Config['llm']) => void; onProxyChange?: (proxy: Config['proxy']) => void; - /** UI 语言即时切换后通知父级同步 boot.config.language(与写盘/实时切换解耦的状态同步) */ + /** Notify the parent to sync boot.config.language after an instant UI-language switch (state sync decoupled from write-to-disk / live switching) */ onLanguageChange?: (language: SupportedLanguage) => void; - /** 外观(全局主题 + 等宽字体 + 字号)即时改动后通知父级同步 boot.config.appearance */ + /** Notify the parent to sync boot.config.appearance after an instant appearance change (global theme + monospace font + font size) */ onEditorAppearanceChange?: (appearance: { editor_theme: EditorTheme; editor_font_family: string; editor_font_size: number; }) => void; /** - * 连接改动(含切换活动连接)保存成功后通知父级。父级需重拉 config + 连接摘要 + PR 列表: - * 活动连接变化后,main 端 app:connections 只返回新活动连接的摘要、prs:list 只返回其 PR, - * 不刷新的话 App 的 boot.connections / 列表会过期(丢 capabilities/user、PR 对不上)。 + * Notify the parent after a connection change (including switching the active connection) is saved successfully. The parent needs to re-fetch config + connection summary + PR list: + * after the active connection changes, the main side's app:connections only returns the new active connection's summary and prs:list only returns its PRs, + * without refreshing, the App's boot.connections / list goes stale (loses capabilities/user, PRs don't match). */ onConnectionsChange?: () => void | Promise<void>; - /** 整体保存成功后回传写盘后的权威 config,父级据此同步 boot.config(再次打开设置页显示最新值)。 */ + /** After a full save succeeds, pass back the authoritative config after write-to-disk; the parent syncs boot.config from it (so reopening settings shows the latest values). */ onConfigPersisted?: (config: Config) => void; - /** 打开时的初始分区(命令面板「打开关于」等深链用);缺省 'general'。 */ + /** Initial section on open (used by deep links like the command palette's "open About"); defaults to 'general'. */ initialCategory?: SettingsCategory; onClose: () => void; } /** - * 设置面板(容器):布局编排 + 装配各分区。草稿 / 保存状态机归 useSettingsDraft, - * 各设置分区拆到 sections/,连接 / 代理 / LLM 编辑器拆到 editors/,通用模态壳用 common/Modal。 + * Settings panel (container): layout orchestration + assembling the sections. The draft / save state machine belongs to useSettingsDraft, + * each settings section is split into sections/, the connection / proxy / LLM editors into editors/, and the generic modal shell uses common/Modal. */ export function SettingsModal({ info, @@ -108,7 +109,7 @@ export function SettingsModal({ }: SettingsModalProps) { const { t } = useTranslation(); const [category, setCategory] = useState<SettingsCategory>(initialCategory ?? 'general'); - // 已挂载时再次以新初始分区打开(命令面板深链):切到目标分区;用户在面板内手动导航仍走 setCategory。 + // Reopening with a new initial section while already mounted (command-palette deep link): switch to the target section; manual navigation within the panel still goes through setCategory. useEffect(() => { if (initialCategory) setCategory(initialCategory); }, [initialCategory]); @@ -121,7 +122,7 @@ export function SettingsModal({ onConfigPersisted, onClose, }); - // 外观类即时生效设置(语言 / 主题 / 编辑器外观):与整体保存事务正交,独立 hook 管理。 + // Appearance-type instantly-applied settings (language / theme / editor appearance): orthogonal to the full-save transaction, managed by a separate hook. const a = useAppearanceDraft({ config, onLanguageChange, @@ -133,7 +134,7 @@ export function SettingsModal({ <Modal size="lg" onClose={onClose} - // 有未保存草稿时点背景误关会丢配置:设置页禁用背景点击关闭,仅右上角关闭键(或保存成功)退出。 + // Accidentally closing via a backdrop click with an unsaved draft loses config: the settings page disables backdrop-click close, exit only via the top-right close button (or a successful save). closeOnBackdrop={false} title={t('settings.title')} headerClose="icon" @@ -217,7 +218,7 @@ export function SettingsModal({ /> </> )} - {/* 模型:仅 LLM 连接 + 上下文长度。 */} + {/* Model: only LLM connections + context length. */} {category === 'model' && ( <> <LlmSection @@ -233,7 +234,7 @@ export function SettingsModal({ /> </> )} - {/* 智能体:记忆目录 + 策略 + 评审并发。 */} + {/* Agent: memory directory + strategy + review concurrency. */} {category === 'agent' && ( <> <AgentDirSection @@ -248,6 +249,14 @@ export function SettingsModal({ onMaxFollowupAsksChange={s.setMaxFollowupAsks} maxCodeSuggestions={s.maxCodeSuggestions} onMaxCodeSuggestionsChange={s.setMaxCodeSuggestions} + codeSuggestionSpec={s.codeSuggestionSpec} + onEditCodeSuggestionSpec={() => + s.setTemplateEditor({ field: 'spec', draft: s.codeSuggestionSpec }) + } + codeSuggestionLayout={s.codeSuggestionLayout} + onEditCodeSuggestionLayout={() => + s.setTemplateEditor({ field: 'layout', draft: s.codeSuggestionLayout }) + } /> <ConcurrencySection value={s.maxConcurrencyInput} @@ -300,6 +309,15 @@ export function SettingsModal({ onCancel={() => s.setProxyEditor(null)} /> )} + {s.templateEditor && ( + <TemplateEditorModal + field={s.templateEditor.field} + draft={s.templateEditor.draft} + onChange={(next) => s.setTemplateEditor({ ...s.templateEditor!, draft: next })} + onSave={s.saveTemplateEditor} + onCancel={() => s.setTemplateEditor(null)} + /> + )} {s.connDeleteId && ( <ConfirmModal title={t('settings.deleteConnectionConfirmTitle')} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx index 46e1ea2b..49f9b8bd 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ConnectionEditorModal.tsx @@ -18,11 +18,11 @@ export function ConnectionEditorModal({ const { t } = useTranslation(); const { mode, draft } = state; const canSave = connDraftCanSave(draft); - // 退出校验:记下打开时的草稿快照,与当前比对判断是否有未提交改动 + // Exit validation: snapshot the draft on open, compare against current to detect uncommitted changes const initialDraft = useRef(JSON.stringify(draft)); const dirty = JSON.stringify(draft) !== initialDraft.current; const [confirmDiscard, setConfirmDiscard] = useState(false); - // 关闭(背景点击 / 关闭键 / 取消按钮)统一走这里:有改动则先拦截确认 + // Closing (backdrop click / close key / cancel button) all routes through here: intercept with a confirm if dirty const requestClose = (): void => { if (dirty) setConfirmDiscard(true); else onCancel(); @@ -37,8 +37,8 @@ export function ConnectionEditorModal({ mode === 'add' ? t('settings.addConnectionTitle') : t('settings.editConnectionTitle') } > - {/* 左右两栏:左选集成平台(复用向导布局),右填连接表单。 - 平台选择仅新增时可改;编辑既有连接只读(base_url / token 语义随平台而异)。 */} + {/* Two columns: left picks the integration platform (reuses wizard layout), right fills the connection form. + Platform selection is editable only when adding; editing an existing connection is read-only (base_url / token semantics vary by platform). */} <div className="config-pick-grid"> <PlatformPicker value={draft.kind} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx index 35e5560d..4bb24b87 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/LlmEditorModal.tsx @@ -20,7 +20,7 @@ export function LlmEditorModal({ }) { const { t } = useTranslation(); const { mode, draft } = state; - // 点保存才把所有必填项暴露出来(LlmProfileForm 内部按 touched 渐进显示) + // Only reveal all required fields when Save is clicked (LlmProfileForm shows them progressively by touched) const [forceShowErrors, setForceShowErrors] = useState(false); const isValid = Object.keys(validateProfile(draft, existing)).length === 0; const trySave = (): void => { @@ -30,11 +30,11 @@ export function LlmEditorModal({ } onSave(); }; - // 退出校验:记下打开时的草稿快照,与当前比对判断是否有未提交改动 + // Exit validation: snapshot the draft on open, compare against current to detect uncommitted changes const initialDraft = useRef(JSON.stringify(draft)); const dirty = JSON.stringify(draft) !== initialDraft.current; const [confirmDiscard, setConfirmDiscard] = useState(false); - // 关闭(背景点击 / 关闭键 / 取消按钮)统一走这里:有改动则先拦截确认 + // Closing (backdrop click / close key / cancel button) all routes through here: intercept with a confirm if dirty const requestClose = (): void => { if (dirty) setConfirmDiscard(true); else onCancel(); @@ -47,8 +47,8 @@ export function LlmEditorModal({ onClose={requestClose} title={mode === 'add' ? t('settings.addLlmTitle') : t('settings.editLlmTitle')} > - {/* 左右两栏:左选 provider(复用向导布局),右填该 provider 的配置(隐藏表单内冗余的 provider 下拉)。 - 固定高度:两栏各自在其内滚动,切换 provider 时模态高度恒定、不抖动。 */} + {/* Two columns: left picks the provider (reuses wizard layout), right fills that provider's config (hides the form's redundant provider dropdown). + Fixed height: each column scrolls within itself, so the modal height stays constant and doesn't jitter when switching provider. */} <div className="config-pick-grid config-pick-grid-fixed"> <LlmProviderPicker value={draft.provider} diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx index 7c602603..d32a5b04 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx @@ -22,7 +22,7 @@ export function ProxyEditorModal({ result: { ok: boolean; reason?: string } | null; }>({ testing: false, result: null }); const [pwVisible, setPwVisible] = useState(false); - // 改任意字段都清掉上次测试结果(避免误导) + // Changing any field clears the previous test result (to avoid misleading) const patch = (p: Partial<Config['proxy']>): void => { onChange({ ...draft, ...p }); setTest({ testing: false, result: null }); @@ -49,7 +49,7 @@ export function ProxyEditorModal({ </label> {draft.enabled && ( <> - {/* 字段名放输入框前(modal-kv 网格);用户名 / 密码分上下两行,均可选 */} + {/* Field names precede the inputs (modal-kv grid); username / password on separate rows, both optional */} <div className="modal-kv" style={{ marginTop: 10, alignItems: 'center' }}> <div className="modal-kv-key">{t('settings.proxyHost')}</div> <div className="modal-kv-val"> diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/TemplateEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/TemplateEditorModal.tsx new file mode 100644 index 00000000..382ff357 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/TemplateEditorModal.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; +import { DEFAULT_CODE_SUGGESTION_LAYOUT } from '@meebox/shared'; +import { Modal } from '../../../common'; + +/** + * Nested modal for editing a code-suggestion template (settings → Agent strategy). Reused for both fields: + * - `spec`: a free-text spec injected into the LLM (extra_instructions for /improve /review /ask) — soft constraint. + * - `layout`: a deterministic markdown layout for the whole draft comment when a finding becomes a draft, with + * placeholders `<TITLE>` / `<SUGGESTIONS>` / `<HOME>` / `<PR>` / `<MODEL>`. + * + * Editing writes to a draft held by the parent (useSettingsDraft.templateEditor); Cancel discards it, Confirm commits + * to the field. The committed value is only persisted by the settings footer "Save" (same as the other draft editors). + */ +export function TemplateEditorModal({ + field, + draft, + onChange, + onSave, + onCancel, +}: { + field: 'spec' | 'layout'; + draft: string; + onChange: (next: string) => void; + onSave: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const isLayout = field === 'layout'; + return ( + <Modal + nested + size="md" + onClose={onCancel} + title={t(isLayout ? 'settings.codeSuggestionLayoutTitle' : 'settings.codeSuggestionSpecTitle')} + > + <p className="muted" style={{ margin: '0 0 10px' }}> + {t( + isLayout + ? 'settings.codeSuggestionLayoutModalHint' + : 'settings.codeSuggestionSpecModalHint', + )} + </p> + {isLayout && ( + <p className="muted" style={{ margin: '0 0 10px', fontSize: '0.85em' }}> + {t('settings.codeSuggestionLayoutVars')} + </p> + )} + <textarea + className="settings-input" + value={draft} + onChange={(e) => onChange(e.target.value)} + placeholder={ + isLayout + ? DEFAULT_CODE_SUGGESTION_LAYOUT + : t('settings.codeSuggestionSpecPlaceholder') + } + spellCheck={false} + rows={10} + style={{ + width: '100%', + minHeight: 200, + resize: 'vertical', + fontFamily: 'var(--font-mono, ui-monospace, monospace)', + lineHeight: 1.5, + }} + aria-label={t( + isLayout ? 'settings.codeSuggestionLayoutTitle' : 'settings.codeSuggestionSpecTitle', + )} + /> + <div + className="settings-actions" + style={{ marginTop: 12, justifyContent: 'flex-end', alignItems: 'center' }} + > + <button type="button" className="btn" onClick={onCancel}> + {t('common.cancel')} + </button> + <button type="button" className="btn btn-primary" onClick={onSave}> + {t('common.confirm')} + </button> + </div> + </Modal> + ); +} diff --git a/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx index ededb839..159874b4 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/elements/UpdateCheckButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { UpdateCheckResult } from '@meebox/shared'; import { invoke } from '../../../../api'; -/** 「检查更新」按钮(运行环境段):手动查 GitHub 最新版,自管 loading + 结果展示。 */ +/** "Check for updates" button (runtime environment section): manually queries the latest GitHub release, self-manages loading + result display. */ export function UpdateCheckButton({ enabled }: { enabled: boolean }) { const { t } = useTranslation(); const [checking, setChecking] = useState(false); diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useAppearanceDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useAppearanceDraft.ts index 3881dc2c..2f7807ac 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/hooks/useAppearanceDraft.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useAppearanceDraft.ts @@ -17,36 +17,36 @@ interface UseAppearanceDraftParams { } /** - * 外观类「即时生效」设置:UI 语言 / 全局主题 + 编辑器字体(Monaco 主题即全局主题,另含等宽字体 + 字号)。 - * 与 useSettingsDraft 的「草稿 → 整体保存」事务相互正交 —— 这里每项改即生效:实时应用到运行时 - * (store / data-theme / chrome / CSS 变量)+ 持久化(localStorage + 写盘)+ 同步父级,不进 base/saveAll。 - * 写盘失败不回滚 UI(已切),仅经 error 提示;下次启动按 localStorage 兜底。主题切换的 data-theme / - * chrome 派生由 App 的 useGlobalTheme 订阅 store 变化驱动(见 hooks/useTheme)。 + * Appearance "instant-effect" settings: UI language / global theme + editor font (Monaco theme is the global theme, plus monospace font + size). + * Orthogonal to useSettingsDraft's "draft → save-all" transaction —— here each change takes effect immediately: applied to the runtime in real time + * (store / data-theme / chrome / CSS variables) + persisted (localStorage + written to disk) + synced to parent, not part of base/saveAll. + * Write-to-disk failure does not roll back the UI (already switched), only surfaces via error; next startup falls back to localStorage. The data-theme / + * chrome derivation of theme switching is driven by App's useGlobalTheme subscribing to store changes (see hooks/useTheme). */ export function useAppearanceDraft({ config, onLanguageChange, onEditorAppearanceChange, }: UseAppearanceDraftParams) { - // 即时生效项写盘失败的错误(与整体保存的 saveError 分开,由 SettingsModal 合并展示) + // Error from write-to-disk failure of instant-effect items (kept separate from save-all's saveError, merged for display by SettingsModal) const [error, setError] = useState<string | null>(null); - // UI 语言:即时生效项(不走全局保存) + // UI language: instant-effect item (does not go through save-all) const [language, setLanguage] = useState<SupportedLanguage>(() => resolveUiLanguage(config.language)); const handleLanguageChange = (next: SupportedLanguage): void => { if (next === language) return; setLanguage(next); - void i18n.changeLanguage(next); // 渲染层实时切换 - persistLanguage(next); // localStorage 缓存,下次启动同步命中 - onLanguageChange?.(next); // 同步父级 boot.config.language + void i18n.changeLanguage(next); // switch the renderer layer in real time + persistLanguage(next); // localStorage cache, hit synchronously on next startup + onLanguageChange?.(next); // sync parent boot.config.language invoke('config:setLanguage', { language: next }).catch((e: unknown) => { - // 写盘 / 主进程切换失败不回滚 UI(已切),仅提示;下次启动按 localStorage 兜底 + // Write-to-disk / main-process switch failure does not roll back the UI (already switched), only surfaces; next startup falls back to localStorage setError(e instanceof Error ? e.message : String(e)); }); }; - // 全局主题(Monaco 主题 + 等宽字体):即时生效项。主题为离散选择 → 改即写盘;字体为文本输入 → - // onChange 仅实时预览(写 store + CSS + 同步父级),onBlur 才写盘,避免逐字符落盘。 + // Global theme (Monaco theme + monospace font): instant-effect items. Theme is a discrete choice → written to disk on change; font is text input → + // onChange only previews in real time (writes store + CSS + syncs parent), onBlur writes to disk, avoiding per-character disk writes. const [editorTheme, setEditorTheme] = useState<EditorTheme>(config.appearance.editor_theme); const [editorFontFamily, setEditorFontFamily] = useState<string>( config.appearance.editor_font_family, @@ -54,7 +54,7 @@ export function useAppearanceDraft({ const [editorFontSize, setEditorFontSizeState] = useState<number>( config.appearance.editor_font_size, ); - // 实时应用到运行时:写共享 store(Monaco 组件读)+ 字体 CSS 变量(全应用 $font-mono)+ 同步父级。 + // Apply to runtime in real time: write shared store (read by Monaco component) + font CSS variable (app-wide $font-mono) + sync parent. const applyEditorAppearance = (nextTheme: EditorTheme, nextFont: string, nextSize: number): void => { setEditorAppearance({ editorTheme: nextTheme, fontFamily: nextFont, fontSize: nextSize }); applyEditorFontFamily(nextFont); @@ -79,12 +79,12 @@ export function useAppearanceDraft({ }; const handleEditorFontChange = (next: string): void => { setEditorFontFamily(next); - applyEditorAppearance(editorTheme, next, editorFontSize); // 实时预览,不写盘 + applyEditorAppearance(editorTheme, next, editorFontSize); // real-time preview, not written to disk }; const commitEditorFont = (): void => { - persistEditorAppearance(editorTheme, editorFontFamily, editorFontSize); // 失焦才写盘 + persistEditorAppearance(editorTheme, editorFontFamily, editorFontSize); // written to disk only on blur }; - // 字号为离散下拉 → 改即生效并写盘;clamp 防越界(异常 / config 手改超范围)。 + // Font size is a discrete dropdown → takes effect and written to disk on change; clamp guards against out-of-range (anomaly / manually edited config out of range). const handleEditorFontSizeChange = (next: number): void => { const clamped = Math.min(EDITOR_FONT_SIZE_MAX, Math.max(EDITOR_FONT_SIZE_MIN, Math.round(next))); if (clamped === editorFontSize) return; @@ -94,10 +94,10 @@ export function useAppearanceDraft({ }; return { - // 语言 + // language language, handleLanguageChange, - // 全局主题 + 编辑器字体 + // global theme + editor font editorTheme, editorFontFamily, editorFontSize, @@ -105,7 +105,7 @@ export function useAppearanceDraft({ handleEditorFontChange, commitEditorFont, handleEditorFontSizeChange, - // 即时生效项写盘错误 + // write-to-disk error of instant-effect items error, }; } diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts index 32d8e1f4..f7ec4742 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -11,16 +11,16 @@ interface UseSettingsDraftParams { onLlmChange?: (llm: Config['llm']) => void; onProxyChange?: (proxy: Config['proxy']) => void; onConnectionsChange?: () => void | Promise<void>; - /** 整体保存成功后回传写盘后的权威 config,供父级同步 boot.config(再次打开设置页显示最新值)。 */ + /** After a successful save-all, passes back the authoritative written config so the parent can sync boot.config (reopening the settings page shows the latest values). */ onConfigPersisted?: (config: Config) => void; onClose: () => void; } /** - * SettingsModal 的「草稿 → 整体保存」状态机:所有编辑只改本地 state,点底栏「保存」才整体写盘 + - * 生效;连接 / LLM 改动额外自动写入 config.yaml(防丢失)但不应用到运行时。对外暴露各分区所需的 - * 语义化 state 与 setter(编辑即标脏),以及编辑器弹窗状态与 saveAll。即时生效的外观类设置 - * (语言 / 主题 / 编辑器外观)与本事务正交,拆到 useAppearanceDraft。 + * SettingsModal's "draft → save-all" state machine: all edits only change local state; clicking the footer "Save" writes to disk + + * takes effect as a whole; connection / LLM changes are additionally auto-written to config.yaml (to prevent loss) but not applied to the runtime. Exposes the + * semantic state and setters each section needs (editing marks dirty), plus editor popup state and saveAll. Instant-effect appearance settings + * (language / theme / editor appearance) are orthogonal to this transaction and split out into useAppearanceDraft. */ export function useSettingsDraft({ config, @@ -35,13 +35,13 @@ export function useSettingsDraft({ const [opening, setOpening] = useState(false); const [openError, setOpenError] = useState<string | null>(null); - // 草稿 → 整体保存:所有编辑只改本地 state,点底栏"保存"才整体写盘 + 生效 + // draft → save-all: all edits only change local state; clicking the footer "Save" writes to disk + takes effect as a whole const [reposDirInput, setReposDirInput] = useState(config.workspace.repos_dir); - // Agent 其余字段(max_steps / summary_max_chars / autopilot)在 UI 不编辑,仅持有以便保存时 - // 原样回传、不被覆盖成默认值;目录经 agentDirInput、策略开关经 autoFollowup 可编辑。 + // Agent's other fields (max_steps / summary_max_chars / autopilot) are not edited in the UI, only held so that on save they are + // passed back as-is and not overwritten with defaults; dir is editable via agentDirInput, strategy toggles via autoFollowup. const [agent] = useState<Config['agent']>(config.agent); const [agentDirInput, setAgentDirInput] = useState(config.agent.dir); - // Agent 策略(自动追问开关 + 追问数量上限 + 代码建议数量上限)。随 config:setAgent 一并保存。 + // Agent strategy (auto-followup toggle + followup count cap + code-suggestion count cap). Saved together with config:setAgent. const [autoFollowup, setAutoFollowupState] = useState(config.agent.strategy.auto_followup); const [maxFollowupAsks, setMaxFollowupAsksState] = useState( config.agent.strategy.max_followup_asks, @@ -49,24 +49,37 @@ export function useSettingsDraft({ const [maxCodeSuggestions, setMaxCodeSuggestionsState] = useState( config.agent.strategy.max_code_suggestions, ); + // Code-suggestion spec (LLM soft constraint, injected as extra_instructions for /improve /review /ask) + layout template + // (deterministic, applied when a finding becomes a draft body). Both free markdown, edited in a nested modal, saved with config:setAgent. + const [codeSuggestionSpec, setCodeSuggestionSpecState] = useState( + config.agent.strategy.code_suggestion_spec, + ); + const [codeSuggestionLayout, setCodeSuggestionLayoutState] = useState( + config.agent.strategy.code_suggestion_layout, + ); + // Code-suggestion template editor (shared nested modal): null=closed; field selects which value the draft edits, saved back on confirm. + const [templateEditor, setTemplateEditor] = useState<{ + field: 'spec' | 'layout'; + draft: string; + } | null>(null); const [pollerInput, setPollerInput] = useState(String(config.poller.interval_seconds)); const [maxConcurrencyInput, setMaxConcurrencyInput] = useState(config.pr_agent.max_concurrency); const [llm, setLlm] = useState<Config['llm']>(config.llm); const [llmEditor, setLlmEditor] = useState<{ mode: 'add' | 'edit'; draft: LlmProfile } | null>( null, ); - // 消息通知(总开关 + 分类型系统通知 + dock 角标)。随 config:setNotifications 保存。 + // Message notifications (master toggle + per-type system notifications + dock badge). Saved with config:setNotifications. const [notifications, setNotificationsState] = useState<Config['notifications']>( config.notifications, ); const [proxy, setProxy] = useState<Config['proxy']>(config.proxy); - // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 + // Proxy is edited in a separate modal: null=closed, non-null=draft being edited; saved back to proxy, written to disk only on footer "Save". const [proxyEditor, setProxyEditor] = useState<Config['proxy'] | null>(null); - // 本地 API 服务监听(开关 / host / port 随整体保存;token 经 generateServiceToken 立即写盘)。 + // Local API service listener (toggle / host / port follow save-all; token written to disk immediately via generateServiceToken). const [service, setServiceState] = useState<Config['service']>(config.service); - // 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 + // Connections: multiple configurable + single-select enabled; editing only changes local state, save-all writes to disk + hot-rebuilds const [connections, setConnections] = useState<Config['connections']>(config.connections); const [activeConnId, setActiveConnId] = useState<string>(config.active_connection_id); const [connEditor, setConnEditor] = useState<{ mode: 'add' | 'edit'; draft: ConnDraft } | null>( @@ -74,13 +87,15 @@ export function useSettingsDraft({ ); const [connDeleteId, setConnDeleteId] = useState<string | null>(null); - // 保存基线:保存成功后更新,用于 changed 判定(禁用保存按钮) + // Save baseline: updated after a successful save, used for changed detection (to disable the save button) const [base, setBase] = useState(() => ({ reposDir: config.workspace.repos_dir, agentDir: config.agent.dir, autoFollowup: config.agent.strategy.auto_followup, maxFollowupAsks: config.agent.strategy.max_followup_asks, maxCodeSuggestions: config.agent.strategy.max_code_suggestions, + codeSuggestionSpec: config.agent.strategy.code_suggestion_spec, + codeSuggestionLayout: config.agent.strategy.code_suggestion_layout, poller: config.poller.interval_seconds, concurrency: config.pr_agent.max_concurrency, llm: config.llm, @@ -113,7 +128,7 @@ export function useSettingsDraft({ } }; - // 连接 / LLM 编辑:改本地 state + 自动写入 config.yaml(防丢失),但不应用到运行时 + // Connection / LLM editing: change local state + auto-write to config.yaml (to prevent loss), but not applied to the runtime const autosaveDraft = ( nextConnections: Config['connections'], activeId: string, @@ -124,11 +139,11 @@ export function useSettingsDraft({ active_connection_id: activeId, llm: nextLlm, }).catch(() => { - /* 自动保存失败不打断编辑;点底栏保存时会再写一次 */ + /* Autosave failure does not interrupt editing; it will be written again on footer save */ }); }; - // ── LLM 配置 ── + // ── LLM config ── const persistLlm = (next: Config['llm']): void => { setLlm(next); setSaved(false); @@ -174,13 +189,13 @@ export function useSettingsDraft({ if (llm.active_id === id) return; persistLlm({ ...llm, active_id: id }); }; - // 上下文长度归属 llm(随 config:setLlm 一并保存);编辑即走 persistLlm(标脏 + 草稿写盘)。 + // Context length belongs to llm (saved together with config:setLlm); editing goes through persistLlm (mark dirty + draft write-to-disk). const setLlmContextTokens = (tokens: number): void => { if (llm.context_tokens === tokens) return; persistLlm({ ...llm, context_tokens: tokens }); }; - // ── 连接 ── + // ── Connections ── const persistConnections = (next: Config['connections'], activeId: string): void => { setConnections(next); setActiveConnId(activeId); @@ -210,14 +225,14 @@ export function useSettingsDraft({ const conn = fromConnDraft(draft); const next = mode === 'add' ? [...connections, conn] : connections.map((c) => (c.id === conn.id ? conn : c)); - // 新增首条自动设为启用 + // The first added one is automatically set as enabled const activeId = mode === 'add' && !activeConnId ? conn.id : activeConnId; persistConnections(next, activeId); setConnEditor(null); }; const deleteConn = (id: string): void => { const next = connections.filter((c) => c.id !== id); - // 删的是当前启用 → 启用回退到剩下第一条(无则空串,不轮询任何连接) + // Deleting the currently enabled one → enabled falls back to the first remaining (empty string if none, polls no connection) const activeId = activeConnId === id ? (next[0]?.id ?? '') : activeConnId; persistConnections(next, activeId); }; @@ -226,7 +241,7 @@ export function useSettingsDraft({ persistConnections(connections, id); }; - // ── 代理 ── + // ── Proxy ── const saveProxyEditor = (): void => { if (!proxyEditor) return; setProxy(proxyEditor); @@ -234,7 +249,7 @@ export function useSettingsDraft({ setSaved(false); }; - // ── 目录 / 轮询的语义化 setter(编辑即标脏)── + // ── Semantic setters for directory / polling (editing marks dirty) ── const setPoller = (seconds: number): void => { setPollerInput(String(seconds)); setSaved(false); @@ -251,8 +266,8 @@ export function useSettingsDraft({ setServiceState(next); setSaved(false); }; - // token 重新生成只更新草稿并标脏(不落盘、不同步基线):与 host / port 一致走草稿制,随底栏「保存」 - // 经 config:setService 生效;不保存则丢弃、保留原 token。functional update 避开与开关切换的竞态。 + // Token regeneration only updates the draft and marks dirty (not written to disk, does not sync baseline): like host / port, follows the draft model, takes effect + // via config:setService on footer "Save"; if not saved it is discarded, keeping the original token. functional update avoids a race with toggle switching. const regenerateServiceToken = async (): Promise<void> => { try { const { token } = await invoke('config:generateServiceToken', undefined); @@ -278,6 +293,21 @@ export function useSettingsDraft({ setMaxCodeSuggestionsState(n); setSaved(false); }; + const setCodeSuggestionSpec = (v: string): void => { + setCodeSuggestionSpecState(v); + setSaved(false); + }; + const setCodeSuggestionLayout = (v: string): void => { + setCodeSuggestionLayoutState(v); + setSaved(false); + }; + // Confirm the template editor: write the draft back to the field it edits, then close. + const saveTemplateEditor = (): void => { + if (!templateEditor) return; + if (templateEditor.field === 'spec') setCodeSuggestionSpec(templateEditor.draft); + else setCodeSuggestionLayout(templateEditor.draft); + setTemplateEditor(null); + }; const setReposDir = (v: string): void => { setReposDirInput(v); setSaved(false); @@ -297,13 +327,15 @@ export function useSettingsDraft({ if (r.path) setReposDir(r.path); }; - // ── 变更检测(对比基线)+ 整体保存 ── + // ── Change detection (compare against baseline) + save-all ── const reposDirChanged = reposDirInput.trim() !== base.reposDir; const agentChanged = agentDirInput.trim() !== base.agentDir || autoFollowup !== base.autoFollowup || maxFollowupAsks !== base.maxFollowupAsks || - maxCodeSuggestions !== base.maxCodeSuggestions; + maxCodeSuggestions !== base.maxCodeSuggestions || + codeSuggestionSpec !== base.codeSuggestionSpec || + codeSuggestionLayout !== base.codeSuggestionLayout; const pollerChanged = pollerInput.trim() !== String(base.poller); const concurrencyChanged = maxConcurrencyInput !== base.concurrency; const llmChanged = JSON.stringify(llm) !== JSON.stringify(base.llm); @@ -340,7 +372,7 @@ export function useSettingsDraft({ await invoke('config:setMaxConcurrency', { max_concurrency: maxConcurrencyInput }); } if (agentChanged) { - // UI 编辑 dir + 策略开关;其余字段从已加载的 config 原样保留,避免被覆盖成默认值。 + // UI edits dir + strategy toggles; other fields are kept as-is from the loaded config to avoid being overwritten with defaults. await invoke('config:setAgent', { agent: { ...agent, @@ -350,6 +382,8 @@ export function useSettingsDraft({ auto_followup: autoFollowup, max_followup_asks: maxFollowupAsks, max_code_suggestions: maxCodeSuggestions, + code_suggestion_spec: codeSuggestionSpec, + code_suggestion_layout: codeSuggestionLayout, }, }, }); @@ -367,7 +401,7 @@ export function useSettingsDraft({ } if (serviceChanged) { const host = service.host.trim(); - // 简单合法性校验:非空、无空白 / 协议 / 斜杠(端口单列);放过 IPv4 / 主机名 / 0.0.0.0 / ::1。 + // Simple validation: non-empty, no whitespace / protocol / slash (port is separate); allows IPv4 / hostname / 0.0.0.0 / ::1. if (!host || !/^[A-Za-z0-9.:-]+$/.test(host)) { throw new Error(t('settings.serviceHostInvalidError')); } @@ -383,8 +417,8 @@ export function useSettingsDraft({ if (reposDirChanged && reposDirInput.trim()) { await invoke('config:setReposDir', { reposDir: reposDirInput.trim() }); } - // 回读写盘后的权威配置同步父级 boot.config:否则 agent / poller / 并发 等无即时回调的项, - // 再次打开设置页仍读旧的 boot.config(行为已生效但 UI 显示陈旧)。含 main 端 clamp 后的值。 + // Read back the authoritative written config to sync the parent's boot.config: otherwise items without an immediate callback like agent / poller / concurrency + // would still read the stale boot.config when reopening the settings page (behavior has taken effect but the UI shows stale values). Includes main-side clamped values. onConfigPersisted?.(await invoke('config:read', undefined)); setBase({ reposDir: reposDirInput.trim(), @@ -392,6 +426,8 @@ export function useSettingsDraft({ autoFollowup, maxFollowupAsks, maxCodeSuggestions, + codeSuggestionSpec, + codeSuggestionLayout, poller: Number.parseInt(pollerInput, 10), concurrency: maxConcurrencyInput, llm, @@ -402,7 +438,7 @@ export function useSettingsDraft({ activeConnId, }); setSaved(true); - // 保存成功后自动关闭设置面板(失败则保持打开并展示 saveError) + // Automatically close the settings panel after a successful save (on failure, keep it open and show saveError) onClose(); } catch (e) { setSaveError(e instanceof Error ? e.message : String(e)); @@ -412,7 +448,7 @@ export function useSettingsDraft({ }; return { - // 连接 + // connections connections, activeConnId, connEditor, @@ -435,19 +471,19 @@ export function useSettingsDraft({ deleteProfile, setActiveLlm, setLlmContextTokens, - // 代理 + // proxy proxy, proxyEditor, setProxyEditor, saveProxyEditor, - // 通知 + // notifications notifications, setNotifications, - // 本地 API 服务监听 + // local API service listener service, setService, regenerateServiceToken, - // 轮询 / 并发 / 目录 + // polling / concurrency / directories pollerInput, setPoller, maxConcurrencyInput, @@ -461,11 +497,16 @@ export function useSettingsDraft({ setMaxFollowupAsks, maxCodeSuggestions, setMaxCodeSuggestions, + codeSuggestionSpec, + codeSuggestionLayout, + templateEditor, + setTemplateEditor, + saveTemplateEditor, reposDirInput, setReposDir, pickReposDir, totalBytes, - // 保存 / 配置文件 + // save / config file opening, openError, openConfigFile, diff --git a/apps/desktop/src/renderer/src/components/features/settings/index.ts b/apps/desktop/src/renderer/src/components/features/settings/index.ts index 3b196d7f..d5340af4 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/index.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/index.ts @@ -1,5 +1,5 @@ -// features/settings 对外公共 API:设置面板 + 连接 / LLM 表单(onboarding 复用)。 -// 内部模块(sections / editors / hooks 等)相互引用走相对路径。状态栏 chip 走 statusbar/* 子路径。 +// features/settings public API: settings panel + connection / LLM forms (reused by onboarding). +// Internal modules (sections / editors / hooks etc.) reference each other via relative paths. Status-bar chips go through the statusbar/* subpath. export { SettingsModal, type SettingsCategory } from './SettingsModal'; export { ConnectionForm, diff --git a/apps/desktop/src/renderer/src/components/features/settings/pickers/LlmProviderPicker.tsx b/apps/desktop/src/renderer/src/components/features/settings/pickers/LlmProviderPicker.tsx index 8f720894..2c1afc1a 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/pickers/LlmProviderPicker.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/pickers/LlmProviderPicker.tsx @@ -4,8 +4,8 @@ import { LlmProviderIcon } from '../../../common'; import { LLM_PROVIDERS } from '../LlmProfileForm'; /** - * LLM provider 选择列表(左侧栏):品牌图标 + 名称,单选高亮。 - * 首启向导 LLM 步与设置面板「LLM」子模态共用同一视觉(配置选择器左右布局,见 config-picker.scss)。 + * LLM provider picker list (left column): brand icon + name, single-select highlight. + * The first-run wizard's LLM step and the settings panel's "LLM" sub-modal share the same visuals (config picker left/right layout, see config-picker.scss). */ export function LlmProviderPicker({ value, diff --git a/apps/desktop/src/renderer/src/components/features/settings/pickers/PlatformPicker.tsx b/apps/desktop/src/renderer/src/components/features/settings/pickers/PlatformPicker.tsx index 49aa4c1d..ae65425d 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/pickers/PlatformPicker.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/pickers/PlatformPicker.tsx @@ -3,11 +3,11 @@ import { PLATFORM_META } from '../../../common'; import type { ConnKind } from '../ConnectionForm'; /** - * 集成平台选择列表(左侧栏):图标 + 名称 + 副标题,单选高亮。 - * 首启向导平台步与设置面板「连接」子模态共用同一视觉(配置选择器左右布局,见 config-picker.scss)。 + * Integration platform picker list (left column): icon + name + subtitle, single-select highlight. + * The first-run wizard's platform step and the settings panel's "connections" sub-modal share the same visuals (config picker left/right layout, see config-picker.scss). * - * readOnly:编辑既有连接时不允许切平台(base_url / token 语义随平台而异)——当前项保持高亮、 - * 其余置灰,整列禁用交互。 + * readOnly: switching platform is not allowed when editing an existing connection (base_url / token semantics vary by platform) —— the current item stays highlighted, + * the rest are dimmed, and the whole list disables interaction. */ export function PlatformPicker({ value, @@ -25,7 +25,7 @@ export function PlatformPicker({ <div className="config-pick-list" role="radiogroup" aria-label={ariaLabel}> {PLATFORM_META.map((p) => { const selected = p.kind === value; - // 可点选:平台已实现且非只读。置灰:未实现平台,或只读态下的非当前项。 + // Clickable: platform is implemented and not read-only. Dimmed: unimplemented platform, or non-current item in read-only state. const interactive = p.available && !readOnly; const dim = !p.available || (readOnly && !selected); return ( diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx index 982c13e1..ef0a87bd 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentDirSection.tsx @@ -14,11 +14,12 @@ export function AgentDirSection({ const { t } = useTranslation(); return ( <section className="modal-section"> - {/* 标题行:左侧标题 + 右侧蓝色「打开当前目录」按钮(在系统文件管理器打开生效的 Agent 目录, - 便于直接查看 / 编辑文件)。放在标题行而非配置行,避免与下方的目录选择按钮混淆。 */} + {/* Header row: title on the left + blue "open current directory" button on the right (opens the + active Agent directory in the system file manager for direct viewing / editing). Placed in the + header row rather than the config row to avoid confusion with the directory picker button below. */} <div className="modal-section-head"> <h4>{t('settings.agentDirTitle')}</h4> - {/* 文案按钮(非图标):与下方的目录「选择」图标按钮区分开,避免混淆。 */} + {/* Text button (not an icon): distinguished from the directory "pick" icon button below to avoid confusion. */} <button type="button" className="btn btn-primary btn-sm" diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentStrategySection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentStrategySection.tsx index 41b2f009..5b1c1e49 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/AgentStrategySection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/AgentStrategySection.tsx @@ -1,15 +1,17 @@ import { useTranslation } from 'react-i18next'; import { Switch } from '../../../common'; -// 自动追问数量上限可选档位(1~5)。开关已独立控制启停,故不给 0(0 在 schema 等同关闭,仅手改 config 可达)。 +// Selectable tiers for the max follow-up asks (1~5). The switch controls enable/disable independently, so 0 is not offered (0 equals off in the schema, reachable only by hand-editing config). const MAX_FOLLOWUP_ASKS_OPTIONS = [1, 2, 3, 4, 5]; -// 代码建议数量上限可选档位(2~8)。 +// Selectable tiers for the max code suggestions (2~8). const MAX_CODE_SUGGESTIONS_OPTIONS = [2, 3, 4, 5, 6, 7, 8]; /** - * Agent 策略:扩展 Agent 行为控制的分区,子项以缩进的「功能列表」逐行展示(行首圆点 + 标题 + 说明, - * 右侧控件)。右侧控件不限于开关——自动追问用 Switch,追问数量 / 代码建议数量用下拉(同一通用 - * .settings-sublist-*)。追问数量仅在自动追问开启时可调(关闭时下拉禁用)。后续策略项在此追加一行即可。 + * Agent strategy: a section extending Agent behavior controls, with sub-items shown row by row as an indented + * "feature list" (leading dot + title + description, control on the right). Right-side controls aren't limited to + * switches—auto follow-up ask uses a Switch, follow-up ask count / code suggestion count use dropdowns (the same + * generic .settings-sublist-*). The follow-up ask count is only adjustable when auto follow-up ask is on (the dropdown + * is disabled when off). Append later strategy items as one more row here. */ export function AgentStrategySection({ autoFollowup, @@ -18,6 +20,10 @@ export function AgentStrategySection({ onMaxFollowupAsksChange, maxCodeSuggestions, onMaxCodeSuggestionsChange, + codeSuggestionSpec, + onEditCodeSuggestionSpec, + codeSuggestionLayout, + onEditCodeSuggestionLayout, }: { autoFollowup: boolean; onAutoFollowupChange: (next: boolean) => void; @@ -25,6 +31,10 @@ export function AgentStrategySection({ onMaxFollowupAsksChange: (next: number) => void; maxCodeSuggestions: number; onMaxCodeSuggestionsChange: (next: number) => void; + codeSuggestionSpec: string; + onEditCodeSuggestionSpec: () => void; + codeSuggestionLayout: string; + onEditCodeSuggestionLayout: () => void; }) { const { t } = useTranslation(); return ( @@ -52,7 +62,7 @@ export function AgentStrategySection({ {t('settings.maxFollowupAsksHint')} </span> </div> - {/* 追问数量仅在自动追问开启时生效 → 关闭时禁用,避免「开关关、数量却可调」的歧义。 */} + {/* The follow-up ask count only takes effect when auto follow-up ask is on → disabled when off, avoiding the ambiguity of "switch off, yet count adjustable". */} <select className="settings-input settings-sublist-select" value={maxFollowupAsks} @@ -87,6 +97,50 @@ export function AgentStrategySection({ ))} </select> </li> + {/* Code-suggestion spec: a soft LLM directive (extra_instructions for /improve /review /ask) shaping how each suggestion is structured. Edited as free markdown in a nested modal. */} + <li className="settings-sublist-row"> + <div className="settings-sublist-text"> + <span className="settings-sublist-label">{t('settings.codeSuggestionSpecLabel')}</span> + <span className="muted settings-sublist-desc"> + {t('settings.codeSuggestionSpecHint')} + </span> + </div> + <div className="settings-sublist-edit"> + <span + className={`settings-status-chip ${codeSuggestionSpec.trim() ? 'is-on' : 'is-off'}`} + > + {codeSuggestionSpec.trim() + ? t('settings.codeSuggestionSet') + : t('settings.codeSuggestionDefault')} + </span> + <button type="button" className="btn btn-sm" onClick={onEditCodeSuggestionSpec}> + {t('common.edit')} + </button> + </div> + </li> + {/* Code-suggestion layout: a deterministic markdown template for the whole draft comment when a finding becomes a draft (placeholders <TITLE>/<SUGGESTIONS>/<HOME>/<PR>/<MODEL>). */} + <li className="settings-sublist-row"> + <div className="settings-sublist-text"> + <span className="settings-sublist-label"> + {t('settings.codeSuggestionLayoutLabel')} + </span> + <span className="muted settings-sublist-desc"> + {t('settings.codeSuggestionLayoutHint')} + </span> + </div> + <div className="settings-sublist-edit"> + <span + className={`settings-status-chip ${codeSuggestionLayout.trim() ? 'is-on' : 'is-off'}`} + > + {codeSuggestionLayout.trim() + ? t('settings.codeSuggestionSet') + : t('settings.codeSuggestionDefault')} + </span> + <button type="button" className="btn btn-sm" onClick={onEditCodeSuggestionLayout}> + {t('common.edit')} + </button> + </div> + </li> </ul> </section> ); diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ConcurrencySection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ConcurrencySection.tsx index d1570674..50caf655 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/ConcurrencySection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ConcurrencySection.tsx @@ -3,16 +3,16 @@ import { CONCURRENCY_TIERS } from '../utils'; import { TierSlider } from './TierSlider'; /** - * 评审任务并发(pr_agent.max_concurrency,1~8):同时执行的评审 run 数。 - * 复用轮询配置的数值拖拽组件(TierSlider)。 + * Review task concurrency (pr_agent.max_concurrency, 1~8): the number of review runs executed simultaneously. + * Reuses the numeric drag component (TierSlider) from the polling config. */ export function ConcurrencySection({ value, onChange, }: { - /** 当前并发数 */ + /** Current concurrency count */ value: number; - /** 选定档位 → 回传该档并发数 */ + /** Selected tier → pass back that tier's concurrency count */ onChange: (max: number) => void; }) { const { t } = useTranslation(); @@ -22,7 +22,7 @@ export function ConcurrencySection({ <p className="muted" style={{ margin: '0 0 8px' }}> {t('settings.concurrencyHint')} </p> - {/* 并发数为纯数字、无单位,且刻度已标 1~8 并高亮当前档 → 省去右侧重复读数。 */} + {/* The concurrency count is a plain number with no unit, and the scale already marks 1~8 and highlights the current tier → omit the redundant readout on the right. */} <TierSlider tiers={CONCURRENCY_TIERS} value={value} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/EditorSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/EditorSection.tsx index 93c8e39b..57fd9f82 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/EditorSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/EditorSection.tsx @@ -2,13 +2,14 @@ import { useTranslation } from 'react-i18next'; import { EDITOR_FONT_SIZE_PRESETS } from '@meebox/shared'; /** - * 编辑器字体分区:代码编辑器(Monaco)等宽字体 + 字号配置。两项均即时生效(字体输入失焦才写盘, - * onChange 实时预览),由 useAppearanceDraft 编排。本分区在「常规」内以分隔线与语言 / 主题分组。 - * 编辑器配色主题已升格为全局「主题」分区(见 ThemeSection),不在此处。 + * Editor font section: code editor (Monaco) monospace font + font size config. Both take effect immediately (the font + * input only writes to disk on blur, onChange live-previews), orchestrated by useAppearanceDraft. This section is grouped + * under "General" with a divider alongside language / theme. + * The editor color theme has been promoted to the global "theme" section (see ThemeSection), not here. * - * 字体仿 VS Code `editor.fontFamily`:自由输入、可逗号分隔多个候选(按序优先),整体作为 font-family - * 前缀拼到内置 mono 字体栈之前(拼接见 theme/resolveEditorFontFamily)。不做本机字体枚举(枚举会阻塞 UI - * 1~2s)。 + * The font mimics VS Code `editor.fontFamily`: free input, comma-separated multiple candidates (prioritized in order), the + * whole thing prepended as a font-family prefix before the built-in mono font stack (see theme/resolveEditorFontFamily for + * the concatenation). No local font enumeration (enumeration would block the UI for 1~2s). */ export function EditorSection({ fontFamily, @@ -24,14 +25,14 @@ export function EditorSection({ onFontSizeChange: (next: number) => void; }) { const { t } = useTranslation(); - // 当前字号若不在预设档位(config 手改),并入下拉、按数值排序,保证选中态可见。 + // If the current font size isn't among the preset tiers (hand-edited config), merge it into the dropdown and sort by value, ensuring the selected state is visible. const sizeOptions = [...new Set<number>([...EDITOR_FONT_SIZE_PRESETS, fontSize])].sort( (a, b) => a - b, ); return ( <section className="modal-section modal-section-divider"> - {/* 字体为自由输入(可能较长的逗号分隔列表)→ 单独成行、输入框铺满,不与标题挤在一行。 */} + {/* The font is free input (possibly a long comma-separated list) → on its own row with the input filling the width, not crammed onto the title row. */} <div className="settings-field-stacked"> <h4>{t('settings.editorFontTitle')}</h4> <input diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/LlmContextSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmContextSection.tsx index 10a50c1b..9e240b4f 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/LlmContextSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/LlmContextSection.tsx @@ -3,16 +3,16 @@ import { LLM_CONTEXT_TIERS, formatTokens } from '../utils'; import { TierSlider } from './TierSlider'; /** - * LLM 上下文长度:裁剪输入内容的上下文长度上限(token),32k~1M 间的习惯档位。 - * 复用轮询配置的数值拖拽组件(TierSlider)。本地 CLI 模式不生效(CLI 工具自管上下文)。 + * LLM context length: the upper limit of the context length (tokens) for trimming input content, with common tiers between 32k~1M. + * Reuses the numeric drag component (TierSlider) from the polling config. Does not take effect in local CLI mode (the CLI tool manages context itself). */ export function LlmContextSection({ value, onChange, }: { - /** 当前上下文长度(token) */ + /** Current context length (tokens) */ value: number; - /** 选定档位 → 回传该档 token 数 */ + /** Selected tier → pass back that tier's token count */ onChange: (tokens: number) => void; }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx index 02cd1c9e..f594ccaf 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx @@ -3,14 +3,17 @@ import type { Config } from '@meebox/shared'; import { Switch } from '../../../common'; import { invoke } from '../../../../api'; -// macOS 在系统层管控通知授权(应用无法代为开启)→ 仅 macOS 展示「打开系统通知设置」引导按钮。 +// macOS gates notification authorization at the system level (the app can't enable it on the user's behalf) → only macOS shows the "Open system notification settings" guidance button. const IS_MAC = navigator.platform.toLowerCase().includes('mac'); /** - * 通知分区:总开关 + 分类型系统通知——面向评审的(新 PR / 评论回复 / 评论 @)与面向「我创建的」PR 的 - * (新评论 / 被标记需修改 / 出现冲突)。总开关关闭时下属各项禁用(灰显但保留各自值)。系统通知受 OS 权限 - * 约束——用户在系统设置关闭后应用静默降级,此处仅控制应用侧意图。macOS dock「待回应」计数角标随总开关默认 - * 启用、无独立开关,故此处不列。 + * Notification section: master switch + per-type system notifications — review-facing ones (new PR / + * comment reply / comment @) and "my authored" PR ones (new comment / marked needs-work / conflict + * appeared). When the master switch is off the sub-items are disabled (grayed out but keep their own + * values). System notifications are subject to OS permission — once the user disables them in system + * settings the app silently degrades; this only controls the app-side intent. The macOS dock + * "awaiting response" count badge is enabled by default with the master switch, has no independent + * switch, and so is not listed here. */ export function NotificationSection({ value, @@ -122,7 +125,7 @@ export function NotificationSection({ </li> </ul> {IS_MAC && ( - // macOS 授权引导:系统层未授权时通知会被静默丢弃,应用无法代为开启 → 提供按钮跳转系统设置由用户开启。 + // macOS authorization guidance: when unauthorized at the system level notifications are silently dropped and the app can't enable it on the user's behalf → provide a button to jump to system settings for the user to enable. <div className="settings-edit-row" style={{ marginTop: 8 }}> <span className="muted settings-sublist-desc" style={{ flex: 1 }}> {t('settings.notifyMacPermissionHint')} diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx index 737ae57d..c95e198c 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/PollerSection.tsx @@ -6,9 +6,9 @@ export function PollerSection({ value, onChange, }: { - /** 当前轮询秒数(字符串形态,与底层 pollerInput 一致) */ + /** Current polling seconds (string form, consistent with the underlying pollerInput) */ value: string; - /** 选定档位 → 回传该档秒数 */ + /** Selected tier → return that tier's seconds */ onChange: (seconds: number) => void; }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx index 48ebc4ae..734ab1be 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ProxySection.tsx @@ -15,8 +15,8 @@ export function ProxySection({ <div className="modal-section-head"> <div className="modal-section-head-title"> <h4>{t('settings.proxyTitle')}</h4> - {/* 启用状态用 chip 表达(绿=已启用/灰=未启用),与应用其它状态视觉一致; - 地址不在此展示,详情见「配置」弹窗。 */} + {/* Enabled state shown as a chip (green=enabled / gray=disabled), visually consistent with + other app states; the address isn't shown here, see the "Configure" dialog for details. */} <span className={`settings-status-chip ${on ? 'is-on' : 'is-off'}`}> {on ? t('settings.proxyEnabledStatus') : t('settings.proxyDisabledStatus')} </span> diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx index 2620a002..ea5cd9ef 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx @@ -1,23 +1,30 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { AppInfo, PrAgentStatus } from '@meebox/shared'; -import { CheckGlyphIcon, CopyIcon, GitHubMarkIcon, IssueIcon, TagIcon } from '../../../common'; +import { PRODUCT_HOME_URL, type AppInfo, type PrAgentStatus } from '@meebox/shared'; +import { + CheckGlyphIcon, + CopyIcon, + GitHubMarkIcon, + GlobeIcon, + IssueIcon, + TagIcon, +} from '../../../common'; import { invoke } from '../../../../api'; import { UpdateCheckButton } from '../elements/UpdateCheckButton'; export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateEnabled: boolean }) { const { t } = useTranslation(); - // 复制后短暂切到「打勾 + 已复制」绿色态作反馈(无 toast 体系,按钮内联反馈)。 + // After copying, briefly switch to a green "check + copied" state as feedback (no toast system, inline button feedback). const [copied, setCopied] = useState(false); - // pr-agent 运行时状态:从状态栏弱化下沉到此处按需展示,打开关于页时拉取。 + // pr-agent runtime status: de-emphasized from the status bar down to here for on-demand display, fetched when the about page opens. const [prAgent, setPrAgent] = useState<PrAgentStatus | null>(null); useEffect(() => { void invoke('app:prAgentStatus', undefined) .then(setPrAgent) .catch(() => setPrAgent(null)); }, []); - // 仅展示版本号(不显示 embedded/local-cli 运行策略——对用户无意义):embedded 的 version 形如 - // `pr-agent 0.36.0` → 取 `0.36.0`;local-cli 为 help 首行 → 截到首个空白前。 + // Only show the version number (not the embedded/local-cli run strategy — meaningless to the user): embedded version looks like + // `pr-agent 0.36.0` → take `0.36.0`; local-cli is the first help line → truncate before the first whitespace. const prAgentVer = prAgent?.available ? prAgent.strategy === 'embedded' ? prAgent.version.replace(/^pr-agent\s+/, '') @@ -25,10 +32,10 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE : null; const prAgentText = prAgent ? (prAgentVer ?? t('statusBar.prAgentUnavailable')) : '…'; - // 操作系统:平台代号 + 系统版本合并展示(如「darwin 15.5」)。 + // Operating system: platform code + system version shown combined (e.g. "darwin 15.5"). const osText = `${info.platform} ${info.osVersion}`.trim(); - // 整体运行环境信息的纯文本快照(每行「键: 值」),供一键复制粘贴到 issue / 反馈。 + // Plain-text snapshot of the overall runtime environment info (each line "key: value"), for one-click copy-paste into issues / feedback. const infoText = [ `${t('settings.appVersion')}: ${info.appVersion}`, `Electron: ${info.electronVersion}`, @@ -87,36 +94,47 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE {t('settings.openDevTools')} </button> </div> - {/* 关于 & 反馈:低频社区链接。http(s) 外链由 App 顶层点击拦截走 openExternal 在系统浏览器打开。 */} + {/* About & feedback: label on its own line, then a wrapping row of low-frequency community links. http(s) external links are intercepted by the App top-level click and routed through openExternal to open in the system browser. */} <div className="settings-about-links"> <span className="muted settings-about-label">{t('settings.aboutFeedback')}</span> - <a - className="settings-about-link" - href="https://github.com/huhamhire/code-meeseeks" - target="_blank" - rel="noreferrer" - > - <GitHubMarkIcon size={14} /> - {t('settings.starOnGithub')} - </a> - <a - className="settings-about-link" - href="https://github.com/huhamhire/code-meeseeks/issues/new" - target="_blank" - rel="noreferrer" - > - <IssueIcon size={14} /> - {t('settings.reportIssue')} - </a> - <a - className="settings-about-link" - href="https://github.com/huhamhire/code-meeseeks/releases" - target="_blank" - rel="noreferrer" - > - <TagIcon size={14} /> - {t('settings.releases')} - </a> + <div className="settings-about-linkrow"> + <a + className="settings-about-link" + href={PRODUCT_HOME_URL} + target="_blank" + rel="noreferrer" + > + <GlobeIcon size={14} /> + {t('settings.website')} + </a> + <a + className="settings-about-link" + href="https://github.com/huhamhire/code-meeseeks" + target="_blank" + rel="noreferrer" + > + <GitHubMarkIcon size={14} /> + {t('settings.starOnGithub')} + </a> + <a + className="settings-about-link" + href="https://github.com/huhamhire/code-meeseeks/issues/new" + target="_blank" + rel="noreferrer" + > + <IssueIcon size={14} /> + {t('settings.reportIssue')} + </a> + <a + className="settings-about-link" + href="https://github.com/huhamhire/code-meeseeks/releases" + target="_blank" + rel="noreferrer" + > + <TagIcon size={14} /> + {t('settings.releases')} + </a> + </div> </div> </section> ); diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx index 057f8f8a..ba76eb39 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx @@ -4,10 +4,13 @@ import type { Config } from '@meebox/shared'; import { CopyIcon, EyeIcon, EyeOffIcon, Switch, SyncIcon } from '../../../common'; /** - * 本地 API 服务监听分区:总开关(分区头)+ 缩进「功能列表」逐行展示监听地址 / 访问令牌(行首圆点 + - * 标签 + 说明,右侧控件),与「策略」「通知」等分区风格统一。地址为 http://<host>:<port> 组合,token - * 可显示 / 复制 / 重新生成;任何开关状态下均可编辑。启用且无 token 时自动生成;非 loopback 绑定给安全 - * 警示。监听地址 / 端口 / token 均为**草稿制**——随底栏「保存」经 config:setService 生效,不保存则丢弃。 + * Local API service listen section: master switch (section head) + an indented "feature list" showing + * the listen address / access token line by line (leading dot + label + description, controls on the + * right), stylistically consistent with the "Strategy" / "Notification" sections. The address is an + * http://<host>:<port> combination; the token can be revealed / copied / regenerated, and is editable + * under any switch state. When enabled with no token one is auto-generated; a non-loopback binding + * shows a security warning. Listen address / port / token are all **draft-based** — they take effect + * via config:setService with the bottom-bar "Save", and are discarded if not saved. */ export function ServiceSection({ value, @@ -16,24 +19,24 @@ export function ServiceSection({ }: { value: Config['service']; onChange: (next: Config['service']) => void; - /** 立即重新生成 token(写盘 + 即时生效);启用且无 token 时也由此自动补一枚。 */ + /** Regenerate the token immediately (write to disk + take effect instantly); also used to auto-add one when enabled with no token. */ onRegenerateToken: () => void; }) { const { t } = useTranslation(); const [revealed, setRevealed] = useState(false); const [copied, setCopied] = useState(false); const on = value.enabled; - // 暴露判定:非 loopback 绑定(0.0.0.0 / 局域网 IP 等)即视为可被同网段访问,给安全警示。 + // Exposure check: a non-loopback binding (0.0.0.0 / LAN IP, etc.) is treated as accessible from the same subnet, so show a security warning. const host = value.host.trim(); const exposed = host !== '' && !['127.0.0.1', 'localhost', '::1'].includes(host); const set = (patch: Partial<Config['service']>): void => onChange({ ...value, ...patch }); - // 监听地址组与令牌组共用同一固定宽度,使两行右侧控件左右边缘对齐;组内输入框自适应填充剩余。 + // The listen address group and the token group share the same fixed width so the right-side controls of both rows align at their left and right edges; the input inside each group flexibly fills the remainder. const CONTROL_W = 320; const handleEnabled = (v: boolean): void => { - if (v && !value.token) onRegenerateToken(); // 启用且无 token → 自动生成一枚 + if (v && !value.token) onRegenerateToken(); // enabled with no token → auto-generate one set({ enabled: v }); }; @@ -44,7 +47,7 @@ export function ServiceSection({ setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { - /* 复制失败静默 */ + /* copy failure silenced */ } }; diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ThemeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ThemeSection.tsx index d6b2c142..80916cc4 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/ThemeSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ThemeSection.tsx @@ -2,8 +2,8 @@ import { useTranslation } from 'react-i18next'; import { EDITOR_THEME_OPTIONS, type EditorTheme } from '@meebox/shared'; /** - * 全局主题分区:选择应用主题(Monaco 配色主题,亦驱动整个 GUI chrome 与浅 / 深语义色板)。 - * 'auto'(自动适应系统)走 i18n、其余主题用专名(GitHub Dark / Monokai…,各 UI 语言一致不翻译)。 + * Global theme section: pick the app theme (Monaco color theme, also drives the whole GUI chrome and light / dark semantic palette). + * 'auto' (auto-adapt to system) goes through i18n; other themes use proper names (GitHub Dark / Monokai…, consistent across UI languages, not translated). */ export function ThemeSection({ theme, diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/TierSlider.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/TierSlider.tsx index 44f22dad..9956fb8c 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/TierSlider.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/TierSlider.tsx @@ -2,9 +2,9 @@ import type { CSSProperties } from 'react'; import { nearestTierIdx } from '../utils'; /** - * 离散档位滑块(数值拖拽组件):拖的是档位索引而非数值,从而实现非线性步长 + 刻度吸附。 - * 轮询间隔 / 评审并发 / LLM 上下文长度等数值配置共用此组件(见 PollerSection 等)。 - * 仅负责滑块 + 刻度 + 右侧读数的呈现;具体档位 / 文案由调用方传入。 + * Discrete tier slider (numeric drag component): drags the tier index rather than the value, achieving non-linear step size + tick snapping. + * Numeric configs such as poll interval / review concurrency / LLM context length share this component (see PollerSection etc.). + * Only responsible for rendering the slider + ticks + right-side readout; specific tiers / text are passed in by the caller. */ export function TierSlider({ tiers, @@ -14,16 +14,16 @@ export function TierSlider({ formatTick = String, formatValue, }: { - /** 档位数值集合(升序);滑块在其索引上移动。 */ + /** Tier value set (ascending); the slider moves over its indices. */ tiers: readonly number[]; - /** 当前数值(不在档位上时就近吸附到最接近档位)。 */ + /** Current value (snaps to the nearest tier when not on a tier). */ value: number; - /** 选定档位 → 回传该档数值。 */ + /** Selected tier → passes back that tier's value. */ onChange: (value: number) => void; ariaLabel: string; - /** 刻度文案(默认直接显示数值)。 */ + /** Tick text (defaults to showing the value directly). */ formatTick?: (tier: number) => string; - /** 右侧读数文案;省略则不渲染右侧读数(纯数字无单位、与刻度重复时可隐藏)。 */ + /** Right-side readout text; omit to not render the right-side readout (can hide when it's a plain unitless number that duplicates the ticks). */ formatValue?: (value: number) => string; }) { const idx = nearestTierIdx(tiers, value); @@ -42,8 +42,8 @@ export function TierSlider({ onChange={(e) => onChange(tiers[Number.parseInt(e.target.value, 10)]!)} aria-label={ariaLabel} /> - {/* 档位刻度:按 thumb 实际停靠位置绝对定位(thumb 宽 12px,两端内缩 6px), - translateX(-50%) 居中对齐;当前档位高亮。 */} + {/* Tier ticks: absolutely positioned by the thumb's actual resting position (thumb is 12px wide, inset 6px at each end), + translateX(-50%) for center alignment; current tier highlighted. */} <div className="settings-range-ticks"> {tiers.map((tier, i) => { const frac = i / (tiers.length - 1); diff --git a/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx b/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx index 598de969..409e1f83 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/statusbar/LlmChip.tsx @@ -4,8 +4,8 @@ import type { Config } from '@meebox/shared'; import { StarIcon, StatusChip } from '../../../common'; /** - * 当前 active LLM profile 概要。点击展开下拉,列出所有 profile 直接切换。 - * 未配置时显示 "LLM: 未配置",点击直接打开设置。 + * Summary of the currently active LLM profile. Click to expand a dropdown listing all profiles for direct switching. + * When unconfigured, shows "LLM: not configured"; clicking opens settings directly. */ export function LlmChip({ llm, @@ -18,7 +18,7 @@ export function LlmChip({ }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - // 点外面关菜单 + // Click outside to close the menu useEffect(() => { if (!open) return; const onDown = (e: MouseEvent): void => { diff --git a/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx b/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx index 5fb3a6ac..a31b55ea 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/statusbar/UserChip.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import type { ConnectionSummary } from '@meebox/ipc'; import { PersonIcon } from '../../../common'; -/** 当前连接的登录用户概要(多连接时带连接名前缀)。无可识别用户时不渲染。 */ +/** Summary of the logged-in user for the current connection (prefixed with the connection name when there are multiple connections). Not rendered when there is no identifiable user. */ export function UserChip({ connections }: { connections: ConnectionSummary[] }) { const { t } = useTranslation(); const labels = connections diff --git a/apps/desktop/src/renderer/src/components/features/settings/utils.ts b/apps/desktop/src/renderer/src/components/features/settings/utils.ts index 71fa51a9..a087205b 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/utils.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/utils.ts @@ -1,14 +1,14 @@ -// 轮询间隔档位(秒):低值细(30s 一档)、高值粗(分钟级),梯度放大。滑块拖的是 -// 档位索引而非秒数,从而实现非线性步长 + 离散刻度(见 TierSlider)。 +// Poll-interval tiers (seconds): fine at low values (30s per step), coarse at high values (minute-level), with a widening gradient. The slider drags +// the tier index rather than the seconds, achieving a nonlinear step size + discrete ticks (see TierSlider). export const POLLER_TIERS = [60, 90, 120, 180, 300, 600, 900]; -// 评审任务并发档位(pr_agent.max_concurrency,1~8 整数)。 +// Review-task concurrency tiers (pr_agent.max_concurrency, integer 1~8). export const CONCURRENCY_TIERS = [1, 2, 3, 4, 5, 6, 7, 8]; -// LLM 上下文长度档位(token):32k~1M 间的主要习惯配置。与 schema 默认 128000 对齐。 +// LLM context-length tiers (tokens): the main conventional configs between 32k~1M. Aligned with the schema default 128000. export const LLM_CONTEXT_TIERS = [32000, 64000, 128000, 256000, 512000, 1000000]; -/** 取最接近给定值的档位索引(配置值不在档位上时就近吸附)。 */ +/** Get the tier index closest to the given value (snap to the nearest when the config value isn't on a tier). */ export function nearestTierIdx(tiers: readonly number[], value: number): number { let best = 0; for (let i = 1; i < tiers.length; i++) { @@ -17,13 +17,13 @@ export function nearestTierIdx(tiers: readonly number[], value: number): number return best; } -/** token 数 → 习惯简写(32000 → 32k,1000000 → 1M)。 */ +/** Token count → conventional shorthand (32000 → 32k, 1000000 → 1M). */ export function formatTokens(n: number): string { if (n >= 1_000_000) return `${n / 1_000_000}M`; return `${Math.round(n / 1000)}k`; } -/** 字节数 → 人类可读(B / KB / MB / GB)。 */ +/** Byte count → human-readable (B / KB / MB / GB). */ export function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; diff --git a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx index 22e94f70..6f659da3 100644 --- a/apps/desktop/src/renderer/src/components/layout/MainPane.tsx +++ b/apps/desktop/src/renderer/src/components/layout/MainPane.tsx @@ -1,8 +1,10 @@ import type { ReactNode } from 'react'; /** - * 主内容区(layout 薄壳):仅提供语义化 `<main>` 槽位,内容由上层(App)按当前业务决定。 - * 不感知 PR 等具体领域,便于后续扩展非 PR 的主区业务——往这个槽里塞别的面板即可。 + * Main content area (thin layout shell): provides only the semantic `<main>` slot; content is + * decided by the upper layer (App) per the current business. + * Domain-agnostic (unaware of PRs etc.), easing future non-PR main-area business—just drop another + * pane into this slot. */ export function MainPane({ children }: { children: ReactNode }) { return <main className="main">{children}</main>; diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index 0b5e9071..f67f9f3d 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -14,48 +14,49 @@ import { useChatRunStore } from '../../stores/chat-run-store'; import { HistoryIcon, PaneLoading } from '../common'; import { PrItem } from '../features/pr'; -// 二级筛选键复用 @meebox/shared 的 PrSecondaryFilter(与本地 API 同源): -// 'conflict' / 'mergeable' 按远端 merge 状态跨 localStatus 横切;'all' 不限定。 +// Secondary filter key reuses @meebox/shared's PrSecondaryFilter (same source as the local API): +// 'conflict' / 'mergeable' cut across localStatus by remote merge status; 'all' is unrestricted. export type FilterKey = PrSecondaryFilter; -/** PR 列表范围:进行中(活跃,按发现分类 + 状态细分)/ 已关闭(归档冷存储,扁平只读浏览)。 */ +/** PR list scope: active (in-progress, subdivided by discovery category + status) / archived (cold-storage archive, flat read-only browsing). */ export type SidebarScope = 'active' | 'archived'; interface SidebarProps { prs: StoredPullRequest[]; /** - * 活跃范围 PR(始终传入,与当前 scope 无关):供一级发现分类标签的未读圆点计算——即便处在 - * 「已关闭」视图,标签仍反映活跃分类的未读。缺省回退到 prs。 + * Active-scope PRs (always passed, independent of the current scope): feeds the unread-dot + * computation for primary discovery-category tabs—even in the "archived" view, the tabs still + * reflect unread of active categories. Falls back to prs when omitted. */ activePrs?: StoredPullRequest[]; selectedId: string | null; onSelect: (pr: StoredPullRequest) => void; width: number; onResize: (next: number) => void; - /** 活动连接支持的发现分类(来自 capabilities);为空 / undefined 时不渲染分类标签行。 */ + /** Discovery categories supported by the active connection (from capabilities); when empty / undefined, the category tab row is not rendered. */ availableFilters?: readonly PrDiscoveryFilter[]; - /** 当前选中的发现分类。 */ + /** Currently selected discovery category. */ discoveryFilter?: PrDiscoveryFilter; onDiscoveryFilterChange?: (filter: PrDiscoveryFilter) => void; - /** 状态筛选(待处理 / 全部 / 冲突 / 可合并等),由 App 持有以便命令面板亦可驱动。 */ + /** Status filter (pending / all / conflict / mergeable etc.), held by App so the command palette can drive it too. */ statusFilter: FilterKey; onStatusFilterChange: (filter: FilterKey) => void; - /** 当前范围:进行中 / 已关闭。 */ + /** Current scope: in-progress / archived. */ scope: SidebarScope; - /** 切回「进行中」(无发现分类的平台用单一锚点;有发现分类则点 tab 经 onDiscoveryFilterChange 切回)。 */ + /** Switch back to "in-progress" (platforms without discovery categories use a single anchor; with categories, clicking a tab goes back via onDiscoveryFilterChange). */ onViewActive: () => void; - /** 切到「已关闭」(归档)范围。 */ + /** Switch to the "archived" scope. */ onViewArchived: () => void; - /** 列表数据加载中(如归档冷存储懒加载):列表区显示 loading 占位,替代「无 PR」空态。 */ + /** List data loading (e.g. archive cold-storage lazy load): the list area shows a loading placeholder in place of the "no PR" empty state. */ loading?: boolean; - /** 活动连接是否支持 needs_work(「需修改」)评审态:决定非「待我评审」分类下是否保留「待处理」状态筛选。 */ + /** Whether the active connection supports the needs_work ("needs work") review state: decides whether the "pending" status filter is kept under categories other than "review requested". */ supportsNeedsWork?: boolean; } export const SIDEBAR_MIN_WIDTH = 240; export const SIDEBAR_MAX_WIDTH = 720; -/** 发现分类标签 i18n key;实际展示哪几类由活动连接的 capabilities.discoveryFilters 决定。 */ +/** Discovery-category tab i18n keys; which categories actually show is decided by the active connection's capabilities.discoveryFilters. */ const DISCOVERY_LABEL_KEYS: Record<PrDiscoveryFilter, string> = { 'review-requested': 'sidebar.discoveryReviewRequested', created: 'sidebar.discoveryCreated', @@ -72,8 +73,9 @@ export const FILTERS: ReadonlyArray<{ value: FilterKey; labelKey: string }> = [ { value: 'mergeable', labelKey: 'sidebar.filterMergeable' }, ]; -// reviewer 决断类(通过/需修改):有发现分类标签时只对「待我评审」有意义,其余标签下恒空, -// 故隐藏;无发现分类的场景仍展示全部六项状态筛选。 +// Reviewer-decision filters (approved/needs work): with discovery-category tabs they only make sense +// under "review requested", being always empty under other tabs, so they are hidden; without +// discovery categories, all six status filters are still shown. export const DECISION_STATUS_FILTERS: ReadonlySet<FilterKey> = new Set(['approved', 'needs_work']); interface PrGroup { @@ -100,7 +102,7 @@ export function Sidebar({ supportsNeedsWork = false, }: SidebarProps) { const { t } = useTranslation(); - // 已关闭范围:扁平浏览(不分发现分类、不分状态、强制「全部」);进行中范围维持原细分行为。 + // Archived scope: flat browsing (no discovery categories, no status split, forced "all"); the in-progress scope keeps the original subdivided behavior. const isArchived = scope === 'archived'; const startResize = (e: React.MouseEvent): void => { e.preventDefault(); @@ -124,34 +126,35 @@ export function Sidebar({ }; const [query, setQuery] = useState(''); - // 切换 PR 类型(发现分类标签 / 进行中⇄已关闭范围)后清空搜索框,避免上一类型遗留的过滤条件连带到新类型。 + // Clear the search box after switching PR type (discovery-category tab / in-progress⇄archived scope), to avoid the previous type's filter carrying over into the new type. useEffect(() => { setQuery(''); }, [discoveryFilter, scope]); - // 状态筛选改由 App 持有(受控):命令面板的「分类筛选」亦可驱动;折叠侧栏也不丢选择。 + // Status filter is now held by App (controlled): the command palette's "category filter" can drive it too; collapsing the sidebar doesn't lose the selection. const filter = statusFilter; const setFilter = onStatusFilterChange; - // 哪些组当前折叠了。默认空集合 = 全部展开。 + // Which groups are currently collapsed. Default empty set = all expanded. const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); - // 评审建议台账 recommendation(per localId,手动 / AutoPilot 一视同仁),PR 列表 ★ 徽标用; - // prs 变化时批量重取。 + // Review recommendation ledger (per localId, manual / AutoPilot treated alike), used for the ★ badge in the PR list; + // batch-refetched when prs changes. const [reviewVerdicts, setReviewVerdicts] = useState< Record<string, AgentRecommendationVerdict> >({}); - // 「执行中」指示数据源:运行队列里有在跑 / 排队 run 的 PR(active + waiting),**并上**有编排 Agent - // 运行中的 PR(agentPrs,含纯思考阶段、无活跃工具 run 时)——补齐 agent 思考态下列表项缺执行中标记的空档。 + // Data source for the "executing" indicator: PRs with a running / queued run in the run queue (active + waiting), + // **unioned with** PRs that have an orchestrating Agent running (agentPrs, including the pure-thinking phase with no active tool run)—filling the gap where list items lack an executing marker during the agent's thinking state. const { active, waiting, agentPrs } = useChatRunStore(); const executingPrIds = useMemo( () => new Set([...active.map((r) => r.prLocalId), ...waiting.map((r) => r.prLocalId), ...agentPrs]), [active, waiting, agentPrs], ); - // 状态筛选可见性随发现分类细化(localStatus = 本人的 reviewer 决断): - // - 无发现分类(单一「待我评审」平台):六项全展示。 - // - 有发现分类:reviewer 决断类(通过 / 需修改)恒隐藏。「待处理」在「待我评审」恒有意义;其余分类 - // (我创建的 / 指派给我 / 提及我)下仅当平台支持 needs_work(GitHub / Bitbucket,可表达「需修改」语义) - // 时保留,GitLab(二元审批、无 needs_work)下「待处理」无意义、隐藏,只留 全部 / 冲突 / 可合并。 + // Status-filter visibility refines with discovery category (localStatus = the user's own reviewer decision): + // - No discovery categories (single "review requested" platform): all six shown. + // - With discovery categories: reviewer-decision filters (approved / needs work) always hidden. "pending" is always + // meaningful under "review requested"; under the other categories (created / assigned / mentioned) it is kept only + // when the platform supports needs_work (GitHub / Bitbucket, which can express the "needs work" semantics), + // while on GitLab (binary approval, no needs_work) "pending" is meaningless and hidden, leaving only all / conflict / mergeable. const hasDiscoveryTabs = Boolean(availableFilters && availableFilters.length > 0); const visibleFilters = useMemo(() => { if (!hasDiscoveryTabs) return FILTERS; @@ -162,14 +165,14 @@ export function Sidebar({ return true; }); }, [hasDiscoveryTabs, discoveryFilter, supportsNeedsWork]); - // 当前选中的状态筛选在本分类下不可见时,回落到首个可见项(待我评审 → 待处理;其余 → 全部),避免按不可见筛选过滤。 + // When the currently selected status filter is not visible under this category, fall back to the first visible item (review requested → pending; others → all), to avoid filtering by an invisible filter. useEffect(() => { if (!visibleFilters.some((f) => f.value === filter)) { setFilter(visibleFilters[0]?.value ?? 'all'); } }, [visibleFilters, filter, setFilter]); - // AutoPilot 徽标:批量取当前 PR 的台账建议(prs 变化时刷新;ledger 在下次 poll 更新 prs 后体现)。 + // AutoPilot badge: batch-fetch the ledger recommendations for the current PRs (refresh when prs changes; the ledger is reflected after the next poll updates prs). useEffect(() => { const localIds = prs.map((p) => p.localId); if (localIds.length === 0) { @@ -185,7 +188,7 @@ export function Sidebar({ }; }, [prs]); - // 清空某 PR 执行历史会一并清掉其 AutoPilot 台账 → 即时清掉该 PR 的评审建议 ★(不必等下个 poll 重取)。 + // Clearing a PR's execution history also clears its AutoPilot ledger → immediately clear that PR's review-recommendation ★ (no need to wait for the next poll to refetch). useEffect(() => { const unsub = subscribe('agent:reviewStatusCleared', (ev) => { setReviewVerdicts((prev) => { @@ -198,8 +201,8 @@ export function Sidebar({ return unsub; }, []); - // 评审完成(手动 / AutoPilot 都经 recordReviewSummaryMessage 写台账 + 广播 agent:conversationChanged)→ - // 即时重取该 PR 的评审建议,让 ★ 立刻出现在 PR 列表,不必等下个 poll 刷新 prs 才体现。 + // Review completion (both manual / AutoPilot write the ledger via recordReviewSummaryMessage + broadcast agent:conversationChanged) → + // immediately refetch that PR's review recommendation, so the ★ appears in the PR list at once, without waiting for the next poll to refresh prs. useEffect(() => { const unsub = subscribe('agent:conversationChanged', (ev) => { void invoke('agent:autopilotLedgers', { localIds: [ev.prLocalId] }).then((v) => { @@ -213,16 +216,16 @@ export function Sidebar({ return unsub; }, []); - // GitHub 发现分类:按 PR 上的 discoveryFilters 标记本地过滤(poller 已把四类都抓回来缓存), - // 切标签纯本地、瞬时、零远端请求。非 GitHub(discoveryFilter 未设)时用全量。 + // GitHub discovery categories: filter locally by the discoveryFilters marked on each PR (the poller has already fetched and cached all four categories), + // so switching tabs is purely local, instantaneous, zero remote requests. For non-GitHub (discoveryFilter unset), use the full set. const scopedPrs = useMemo( () => prs.filter((p) => matchesDiscoveryFilter(p, !isArchived ? discoveryFilter : undefined)), [prs, discoveryFilter, isArchived], ); - // 未读圆点始终基于**活跃** PR(缺省回退 prs):即便当前在「已关闭」视图,一级标签仍反映活跃分类的未读。 + // Unread dots are always based on **active** PRs (falls back to prs): even in the "archived" view, primary tabs still reflect unread of active categories. const unreadSourcePrs = activePrs ?? prs; - // 各一级发现分类下是否有未读 PR → 在标签文字后加未读圆点,提示该分类有新的待处理。 + // Whether each primary discovery category has any unread PR → add an unread dot after the tab text, hinting the category has new items to handle. const unreadFilters = useMemo(() => { const s = new Set<PrDiscoveryFilter>(); for (const p of unreadSourcePrs) { @@ -231,7 +234,7 @@ export function Sidebar({ } return s; }, [unreadSourcePrs]); - // 无发现分类平台的单一「进行中」锚点:任一活动 PR 未读即标圆点。 + // Single "in-progress" anchor for platforms without discovery categories: mark the dot if any active PR is unread. const anyUnread = useMemo(() => unreadSourcePrs.some((p) => p.unread), [unreadSourcePrs]); const counts = useMemo(() => { @@ -248,7 +251,7 @@ export function Sidebar({ if (p.hasConflict) out.conflict += 1; if (p.mergeStatus?.canMerge) out.mergeable += 1; } - // 「我创建的」下「待处理」并入冲突 PR(作者需跟进),复用同源谓词重算、避免与冲突计数重复叠加。 + // Under "created", "pending" folds in conflicting PRs (the author needs to follow up); recompute via the same-source predicate to avoid double-counting with the conflict count. if (!isArchived && discoveryFilter === 'created') { out.pending = scopedPrs.filter((p) => matchesSecondaryFilter(p, 'pending', 'created')).length; } @@ -256,8 +259,8 @@ export function Sidebar({ }, [scopedPrs, isArchived, discoveryFilter]); const filtered = useMemo(() => { - // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。过滤 / 检索语义复用 - // @meebox/shared 纯谓词(与本地 API 同源),并传入一级发现分类以启用分类相关的语义细化。 + // Archived scope forces "all" (no status filter applied); in-progress scope filters by the current status. Filter / search + // semantics reuse @meebox/shared's pure predicates (same source as the local API), passing in the primary discovery category to enable category-specific semantic refinement. const effFilter: FilterKey = isArchived ? 'all' : filter; const effPrimary = !isArchived ? discoveryFilter : undefined; return scopedPrs.filter( @@ -273,7 +276,7 @@ export function Sidebar({ if (list) list.push(pr); else m.set(key, [pr]); } - // 组按 repo 路径字母序;组内 PR 按远端 updatedAt 倒序(最新修改在上) + // Groups sorted alphabetically by repo path; PRs within a group sorted by remote updatedAt descending (most recently modified on top) return Array.from(m.entries()) .map(([key, items]) => ({ key, @@ -282,7 +285,7 @@ export function Sidebar({ .sort((a, b) => a.key.localeCompare(b.key)); }, [filtered]); - // 搜索时强制展开(否则用户在折叠组里看不到匹配的 PR) + // Force expand while searching (otherwise the user won't see matching PRs inside collapsed groups) const searching = query.trim().length > 0; const toggleGroup = (key: string): void => { @@ -302,8 +305,8 @@ export function Sidebar({ title={t('sidebar.resizeTitle')} aria-label="resize sidebar" /> - {/* 范围行(常驻):左组 = 进行中(发现分类细分,或无分类平台的单一锚点)、右组 = 已关闭辅助切换。 - 左组始终有锚点,使「已关闭」恒为旁侧的次要项、不致被误读为唯一分类。 */} + {/* Scope row (always present): left group = in-progress (subdivided by discovery category, or a single anchor for platforms without categories), right group = archived auxiliary toggle. + The left group always has an anchor, keeping "archived" a secondary item to the side and not misread as the only category. */} <div className="sidebar-toolbar sidebar-scope" role="tablist" aria-label={t('sidebar.discoveryTablistAria')}> <div className="sidebar-scope-primary"> {hasDiscoveryTabs && availableFilters && onDiscoveryFilterChange ? ( @@ -335,7 +338,7 @@ export function Sidebar({ </button> )} </div> - {/* 非 tab:独立图标按钮(切换到已关闭范围),toggle 语义用 aria-pressed */} + {/* Not a tab: standalone icon button (switch to archived scope), toggle semantics via aria-pressed */} <button type="button" aria-pressed={isArchived} diff --git a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx index db8ade7a..6e01c530 100644 --- a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx @@ -19,29 +19,30 @@ interface StatusBarProps { refreshing: boolean; sidebarCollapsed: boolean; chatCollapsed: boolean; - /** Poller 最近一次完成时间(ISO 字符串);null 表示从未同步 */ + /** Poller's most recent completion time (ISO string); null means never synced */ lastSyncAt: string | null; onToggleSidebar: () => void; onToggleChat: () => void; onRefresh: () => void; onOpenSettings: () => void; - /** 切换 active LLM profile,由父组件做实际持久化 */ + /** Switch the active LLM profile; the parent component does the actual persistence */ onSwitchActiveLlm: (profileId: string) => void; /** - * 跳到指定 PR (供"pr-agent 运行中"chip 点击使用)。可空 —— 不传时 chip 仍展示但 - * 不可点击。父组件传 `setSelectedId` 即可 + * Jump to a given PR (used by the "pr-agent running" chip click). Nullable — when omitted the chip still + * shows but is not clickable. The parent component can just pass `setSelectedId` */ onJumpToPr?: (localId: string) => void; - /** 启动检测到的新版本;非空且 hasUpdate 时展示「新版本」chip,点击跳转下载页。 */ + /** New version detected at startup; when non-null and hasUpdate, show the "new version" chip, clicking jumps to the download page. */ updateInfo?: UpdateCheckResult | null; - /** AutoPilot 是否启用(agent.autopilot.enabled);点击切换,由父组件持久化。 */ + /** Whether AutoPilot is enabled (agent.autopilot.enabled); click to toggle, persisted by the parent component. */ autopilotEnabled: boolean; onToggleAutopilot: () => void; } /** - * pr-agent 运行时 chip:仅在**不可用**(错误态)时显示红色「不可用」告警(可操作信息)。可用时的版本 - * 号已从状态栏弱化下沉到「关于」页展示(见 RuntimeSection),此处不再渲染,减少常态噪声。 + * pr-agent runtime chip: shows a red "unavailable" warning (actionable info) only when **unavailable** (error state). + * The version number for the available case has been de-emphasized off the status bar and moved down to the "About" + * page (see RuntimeSection), and is no longer rendered here, reducing steady-state noise. */ function PrAgentRuntimeChip({ status }: { status: PrAgentStatus }) { const { t } = useTranslation(); @@ -54,9 +55,9 @@ function PrAgentRuntimeChip({ status }: { status: PrAgentStatus }) { } /** - * 应用状态栏(薄壳):左侧折叠按钮 + 同步 / 仓库镜像 / pr-agent 运行时 / PR 计数 / 用户, - * 右侧 pr-agent 活动 / AutoPilot / LLM / 更新,末尾 chat 折叠 + 设置。各业务 chip 由其所属 - * feature 提供(features/<x>/statusbar/),本组件只做组合与布局。 + * App status bar (thin shell): left side collapse button + sync / repo mirror / pr-agent runtime / PR count / user, + * right side pr-agent activity / AutoPilot / LLM / update, and at the end chat collapse + settings. Each business chip + * is provided by its owning feature (features/<x>/statusbar/); this component only does composition and layout. */ export function StatusBar({ prsCount, @@ -93,13 +94,13 @@ export function StatusBar({ <PanelToggleIcon side="left" collapsed={sidebarCollapsed} /> </button> <LastSyncChip at={lastSyncAt} refreshing={refreshing} onRefresh={onRefresh} /> - {/* 当前正在 sync 的 repo (clone/fetch 中)。idle 不渲染 */} + {/* The repo currently being synced (cloning/fetching). Not rendered when idle */} <RepoSyncChip /> {prAgent && <PrAgentRuntimeChip status={prAgent} />} <PrsCountChip count={prsCount} /> <UserChip connections={connections} /> <div className="statusbar-spacer" /> - {/* pr-agent 活动 / 空闲指示。pr-agent 不可用时不显示(上方运行时 chip 已红色提示)。 */} + {/* pr-agent activity / idle indicator. Hidden when pr-agent is unavailable (the runtime chip above already shows a red warning). */} {prAgent?.available && <PrAgentActiveChip onJumpToPr={onJumpToPr} />} <AutopilotChip enabled={autopilotEnabled} onToggle={onToggleAutopilot} /> <LlmChip llm={llm} onSwitch={onSwitchActiveLlm} onOpenSettings={onOpenSettings} /> diff --git a/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx b/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx index d83e456c..4ae5f620 100644 --- a/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx @@ -5,11 +5,11 @@ import type { SettingsCategory } from '../features/settings'; import type { FilterKey } from './Sidebar'; interface TitleBarProps { - /** 运行平台:macOS 需为红绿灯留出左侧占位;Windows/Linux 窗控由系统 overlay 画在右上。 */ + /** Running platform: macOS needs left-side space reserved for the traffic-light buttons; Windows/Linux window controls are drawn top-right by the system overlay. */ platform: Platform; - /** 标题区展示的上下文文案(如当前 PR 标题);非必要、窄屏让位给命令面板,可省略隐藏。 */ + /** Context text shown in the title area (e.g. the current PR title); non-essential, yields to the command palette on narrow screens, can be omitted/hidden. */ title?: string; - /** 命令面板上下文:当前配置 + 选中 PR + 同步父级状态 + 打开设置面板。 */ + /** Command palette context: current config + selected PR + synced parent state + open settings panel. */ config: Config; selectedPrId: string | null; patchConfig: (updater: (c: Config) => Config) => void; @@ -25,14 +25,14 @@ interface TitleBarProps { } /** - * 无边框窗口的自绘标题栏(VS Code 风)。整条 `-webkit-app-region: drag` 可拖拽窗口, - * 其中的交互元素需各自标 `no-drag`(见 styles/titlebar.scss)。 + * Self-drawn title bar for the frameless window (VS Code style). The whole bar is `-webkit-app-region: drag` + * so the window can be dragged; interactive elements within it must each be marked `no-drag` (see styles/titlebar.scss). * - * 平台差异: - * - macOS:`titleBarStyle: hidden` 保留红绿灯,左侧留 72px 占位避免与品牌名重叠; - * 左上空间被红绿灯占据,**不展示**应用图标,仅品牌名。 - * - Windows/Linux:`titleBarOverlay` 让系统在右上画最小化/最大化/关闭,故右侧留白, - * 勿在右上角放可点元素(会被 overlay 覆盖);左侧空闲,开头展示应用图标。 + * Platform differences: + * - macOS: `titleBarStyle: hidden` keeps the traffic-light buttons, leaving 72px on the left to avoid overlapping the brand name; + * the top-left space is occupied by the traffic lights, so the app icon is **not shown**, only the brand name. + * - Windows/Linux: `titleBarOverlay` lets the system draw minimize/maximize/close top-right, so the right side is left blank; + * do not place clickable elements top-right (they would be covered by the overlay); the left side is free, showing the app icon at the start. */ export function TitleBar({ platform, @@ -55,9 +55,9 @@ export function TitleBar({ <header className={`app-titlebar${isMac ? ' app-titlebar--mac' : ''}`}> {!isMac && <img className="app-titlebar-icon" src={brandIcon} alt="" />} <div className="app-titlebar-brand">Code Meeseeks</div> - {/* PR 标题留在左侧原位(避开右上 Windows 窗控);窄屏时右缘渐隐、被居中的命令面板浮层遮盖。 */} + {/* PR title stays in its original left position (avoiding the top-right Windows window controls); on narrow screens its right edge fades out and is covered by the centered command-palette overlay. */} {title && <div className="app-titlebar-title">{title}</div>} - {/* 命令面板:居中绝对浮层(DOM 置后→绘制在标题之上,输入框不透明底覆盖其下标题)。 */} + {/* Command palette: centered absolute overlay (placed later in DOM → drawn above the title, the input's opaque background covers the title beneath it). */} <div className="app-titlebar-center"> <CommandPalette platform={platform} diff --git a/apps/desktop/src/renderer/src/errors.ts b/apps/desktop/src/renderer/src/errors.ts index fdee39a3..2ba9c735 100644 --- a/apps/desktop/src/renderer/src/errors.ts +++ b/apps/desktop/src/renderer/src/errors.ts @@ -2,23 +2,23 @@ import { decodeAppError, errorDomain } from '@meebox/shared'; import i18n from './i18n'; /** - * 把 main 进程 / 适配器 / fetch 抛出的原始异常翻成用户能读懂的文案。 + * Translate raw exceptions thrown by the main process / adapters / fetch into text the user can understand. * - * 设计原则: - * - **不把原始 message 隐藏掉**,detail 字段保留原文便于诊断 - * - 只识别常见模式给个 title 标签,未匹配的直接落到"未知错误"标签 - * - 不在这里 console.error,调用方决定是否记日志 + * Design principles: + * - **Do not hide the raw message**, the detail field keeps the original text for diagnosis + * - Only recognize common patterns to give a title label; unmatched ones fall to the "unknown error" label + * - Do not console.error here, the caller decides whether to log */ export interface FormattedError { - /** 短标签,UI 顶部色字或图标旁文案,如"连接超时" */ + /** Short label, colored text at the top of the UI or text next to an icon, e.g. "Connection timed out" */ title: string; - /** 详情,给用户看的人话或原始 message */ + /** Detail, human-readable text for the user or the raw message */ detail: string; - /** 给可观测性 / 自动重试逻辑用的归类 */ + /** Classification for observability / auto-retry logic */ kind: 'timeout' | 'network' | 'auth' | 'not-found' | 'platform' | 'unknown'; } -// title/hint 存 i18n key,实际文案在 formatBackendError 用 i18n.t() 翻译(纯模块无法用 hook) +// title/hint store i18n keys, the actual text is translated in formatBackendError via i18n.t() (a pure module cannot use hooks) const MATCHERS: Array<{ re: RegExp; titleKey: string; @@ -88,8 +88,8 @@ const MATCHERS: Array<{ export function formatBackendError(err: unknown): FormattedError { const raw = err instanceof Error ? err.message : String(err); - // 先解码统一错误码(AppError 信封)→ 按码精确 i18n(errors.<CODE>,meta 作插值)。未注册码 / 缺 key → - // 通用兜底文案 + 显示原始码(便于报障)。非本信封 → 落到下方正则模式匹配(第三方 / 历史未编码错误兜底)。 + // First decode the unified error code (AppError envelope) → precise i18n by code (errors.<CODE>, meta as interpolation). Unregistered code / missing key → + // generic fallback text + show the raw code (eases reporting). Not this envelope → fall to the regex pattern matching below (fallback for third-party / historical uncoded errors). const decoded = decodeAppError(raw); if (decoded) { const key = `errors.${decoded.code}`; diff --git a/apps/desktop/src/renderer/src/hooks/useAppStores.ts b/apps/desktop/src/renderer/src/hooks/useAppStores.ts index 2dae461c..b963781b 100644 --- a/apps/desktop/src/renderer/src/hooks/useAppStores.ts +++ b/apps/desktop/src/renderer/src/hooks/useAppStores.ts @@ -5,10 +5,10 @@ import { wireFindingClosuresStore } from '../stores/finding-closures-store'; import { wireRepoSyncStore } from '../stores/repo-sync-store'; /** - * 把 IPC 事件流接到各全局 store(挂载到 React 树根,效果等价于「应用级 hook」): - * - chatRunStore:pr-agent 活动 run + 实时 stdout,ChatPane 跨 PR 切换可读回运行态 - * - repoSyncStore:repo 镜像 clone/fetch 进度,StatusBar 任意时刻可读当前同步任务 - * - draftsStore:草稿写盘后 drafts:changed 触发指定 PR 的草稿列表自动刷新 + * Wire the IPC event streams to each global store (mounted at the React tree root, effectively an "app-level hook"): + * - chatRunStore: pr-agent active run + real-time stdout, ChatPane can read back the run state when switching across PRs + * - repoSyncStore: repo mirror clone/fetch progress, StatusBar can read the current sync task at any time + * - draftsStore: after a draft is written to disk, drafts:changed triggers an auto-refresh of the given PR's draft list */ export function useAppStores(): void { useEffect(() => wireChatRunStore(), []); diff --git a/apps/desktop/src/renderer/src/hooks/useBootstrap.ts b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts index 824dc2e8..9dabd248 100644 --- a/apps/desktop/src/renderer/src/hooks/useBootstrap.ts +++ b/apps/desktop/src/renderer/src/hooks/useBootstrap.ts @@ -16,17 +16,18 @@ export interface BootstrapState { } interface UseBootstrapParams { - /** PR 列表 store 的写入口(usePullRequests):启动 / 热刷新时注入最新列表。 */ + /** Write entry point for the PR list store (usePullRequests): injects the latest list on bootstrap / hot refresh. */ setPrs: Dispatch<SetStateAction<StoredPullRequest[]>>; - /** 读缓存重拉 PR 列表(usePullRequests):poll tick / 窗口聚焦时调用。 */ + /** Re-fetch the PR list from cache (usePullRequests): called on poll tick / window focus. */ reloadPrs: () => Promise<void>; } /** - * 应用启动与全局生命周期:首帧前加载 boot(info/paths/config/prAgent + 初始 PR 列表 + 连接摘要 + - * 最近同步)并定档 UI 语言;运行时跟随 config.language 切换;订阅 poll tick(刷新最近同步 + 重拉 - * 列表 + 补连接摘要)与窗口聚焦刷新;并提供首启向导完成 / 连接热生效后的整体重载。派生 needsOnboarding - * 供 App 决定是否进向导。 + * App bootstrap and global lifecycle: before the first frame, loads boot (info/paths/config/prAgent + initial + * PR list + connection summary + last sync) and locks in the UI language; switches at runtime following + * config.language; subscribes to poll tick (refresh last sync + reload list + top up connection summary) and + * window focus refresh; and provides a full reload after onboarding completes / connections take effect hot. + * Derives needsOnboarding for App to decide whether to enter the wizard. */ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { boot: BootstrapState | null; @@ -35,19 +36,20 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { needsOnboarding: boolean; completeOnboarding: (result: OnboardingResult) => Promise<void>; refreshBootAndPrs: () => Promise<void>; - /** 乐观更新 boot.config(IPC 写盘后本地同步,如切 LLM / 切 AutoPilot / 设置页改动)。 */ + /** Optimistically update boot.config (local sync after IPC persists, e.g. switching LLM / AutoPilot / settings page edits). */ patchConfig: (fn: (config: Config) => Config) => void; } { const [boot, setBoot] = useState<BootstrapState | null>(null); const [fatalError, setFatalError] = useState<string | null>(null); const [lastSyncAt, setLastSyncAt] = useState<string | null>(null); - // 仅调试用:localStorage 里 meebox.forceOnboarding='1' 时强制进首启向导,不必动 config.yaml。 + // Debug only: when localStorage meebox.forceOnboarding='1', force entry into the onboarding wizard without touching config.yaml. const [forceOnboarding, setForceOnboarding] = useState( () => localStorage.getItem('meebox.forceOnboarding') === '1', ); - // 连接改动(尤其切换活动连接)后整体刷新 boot:活动连接变化后 main 端 app:connections / - // prs:list 都随之变,必须重拉,否则 boot.connections、PR 列表会过期。 + // Full boot refresh after connection changes (especially switching the active connection): after the active + // connection changes, main's app:connections / prs:list both change, so a re-fetch is mandatory or boot.connections + // and the PR list go stale. const refreshBootAndPrs = useCallback(async (): Promise<void> => { const [config, connections, freshPrs, lastSync] = await Promise.all([ invoke('config:read', undefined), @@ -60,7 +62,7 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { setLastSyncAt(lastSync.at); }, [setPrs]); - // 启动加载:拉齐 boot 数据 + 初始列表,并先把 UI 语言切到目标并等资源加载完,再 setBoot 渲染主界面。 + // Bootstrap load: fetch all boot data + initial list, first switch the UI language to the target and wait for resources to load, then setBoot to render the main UI. useEffect(() => { void (async () => { try { @@ -88,11 +90,12 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { })(); }, [setPrs]); - // 运行时语言切换(如设置页改 config.language):仅依赖 config.language。 - // **不能依赖整个 boot**——poll:tick 会 setBoot 刷新 connections 等字段,boot 频繁换新引用; - // 若依赖 boot,每次 poll 都会 i18n.changeLanguage(同一语言) → react-i18next 发 languageChanged - // → 所有 useTranslation 的 t 换新引用 → 凡是 effect 依赖 t 的组件(如 InlineCodeContext 抓取代码 - // 片段的 effect)都被无谓重跑,内嵌 Monaco 随之 setSnippet(null)→重建 → 刷新抖动。 + // Runtime language switch (e.g. settings page changes config.language): depends only on config.language. + // **Must not depend on the whole boot** — poll:tick calls setBoot to refresh fields like connections, so boot + // frequently gets a new reference; if it depended on boot, every poll would call i18n.changeLanguage(same language) + // → react-i18next emits languageChanged → t for every useTranslation gets a new reference → any component whose + // effect depends on t (e.g. InlineCodeContext's effect that grabs code snippets) reruns needlessly, and the embedded + // Monaco then setSnippet(null)→rebuilds → refresh jitter. const configLanguage = boot?.config.language; useEffect(() => { if (configLanguage === undefined) return; @@ -101,8 +104,9 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { void i18n.changeLanguage(lang); }, [configLanguage]); - // poll tick:刷新「最近同步」+ 重拉列表(后台轮询新增/删除即时反映)+ 补连接摘要 - // (启动 ping 在建窗后才完成,借首轮 tick 把状态栏用户/能力位补上)。 + // poll tick: refresh "last sync" + reload list (background polling reflects additions/deletions immediately) + top up + // connection summary (the startup ping only completes after the window is built, so use the first tick to fill in the + // status bar user/capability bits). useEffect(() => { if (!window.api) return; return subscribe('poll:tick', (info) => { @@ -111,14 +115,14 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { void invoke('app:connections', undefined).then( (connections) => setBoot((b) => (b ? { ...b, connections } : b)), () => { - /* 摘要刷新失败不影响主流程 */ + /* summary refresh failure does not affect the main flow */ }, ); }); }, [reloadPrs]); - // 窗口重新获得焦点时主动 refresh 远端:拉 PR meta,Bitbucket 上加 comment / 改状态后 - // PR.updatedAt 跳变 → PrPanel 的 prUpdatedAt dep 触发 → force listComments 拉新评论。 + // Proactively refresh the remote when the window regains focus: fetch PR meta; on Bitbucket, after adding a comment / + // changing status, PR.updatedAt jumps → PrPanel's prUpdatedAt dep fires → force listComments to fetch new comments. useEffect(() => { if (!boot) return; const onFocus = (): void => { @@ -127,7 +131,7 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { await invoke('prs:refresh', undefined); await reloadPrs(); } catch { - // 静默:focus 触发的刷新失败不该弹错给用户 + // Silent: a focus-triggered refresh failure should not surface an error to the user } })(); }; @@ -135,8 +139,8 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { return () => window.removeEventListener('focus', onFocus); }, [boot, reloadPrs]); - // 首启向导完成:落盘连接(必)+ LLM / 缓存目录(按需),再整体重载 boot;boot.config 拿到有效 - // active 连接后 needsOnboarding 派生为 false,向导自然卸载、切入主界面。 + // Onboarding complete: persist connection (required) + LLM / cache dir (as needed), then fully reload boot; once + // boot.config has a valid active connection, needsOnboarding derives to false, the wizard unmounts, and we cut into the main UI. const completeOnboarding = useCallback( async (result: OnboardingResult): Promise<void> => { await invoke('config:setConnections', { @@ -153,7 +157,7 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { }); } const trimmedRepos = result.reposDir.trim(); - // 注意:与初值不同才写盘 —— 这里用最新一次 read 比对(boot 闭包可能旧),交给 main 幂等即可。 + // Note: only persist when different from the initial value — compare against the latest read here (the boot closure may be stale), leaving idempotency to main. if (trimmedRepos) { const cur = await invoke('config:read', undefined); if (trimmedRepos !== (cur.workspace.repos_dir ?? '')) { @@ -161,7 +165,7 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { } } await refreshBootAndPrs(); - // 走完向导清掉调试 flag,避免强制模式下完成后仍被困在向导 + // Clear the debug flag after finishing the wizard, to avoid staying stuck in the wizard after completing in forced mode if (forceOnboarding) { localStorage.removeItem('meebox.forceOnboarding'); setForceOnboarding(false); @@ -170,8 +174,8 @@ export function useBootstrap({ setPrs, reloadPrs }: UseBootstrapParams): { [forceOnboarding, refreshBootAndPrs], ); - // gate 条件 = 有无「有效的 active 连接」:连接为空 / active 悬空都触发首启向导。 - // 不依赖一次性 firstRun 标记 —— 用户清空连接后下次进入仍会回到向导。 + // Gate condition = whether there is a "valid active connection": empty connections / dangling active both trigger the onboarding wizard. + // Does not rely on a one-time firstRun flag — after the user clears connections, the next entry still returns to the wizard. const needsOnboarding = !!boot && (forceOnboarding || diff --git a/apps/desktop/src/renderer/src/hooks/useDockBadge.ts b/apps/desktop/src/renderer/src/hooks/useDockBadge.ts index 204d20bc..8ea7d58e 100644 --- a/apps/desktop/src/renderer/src/hooks/useDockBadge.ts +++ b/apps/desktop/src/renderer/src/hooks/useDockBadge.ts @@ -3,9 +3,11 @@ import type { Config, StoredPullRequest } from '@meebox/shared'; import { invoke } from '../api'; /** - * macOS dock 角标同步:把活跃 PR「@我 / 回复我」待回应总数推给主进程落到 dock 图标。角标无独立开关——随通知 - * 总开关默认启用,关闭则置 0 清除;非 macOS 直接跳过(主进程也仅在 macOS 落地)。随 PR 列表 / 通知配置变化 - * 重算并推送。各 PR 计数已在 poll 端封顶 10,故总数自带上界。 + * macOS dock badge sync: pushes the total pending-response count of active PRs ("@me / replies to me") to the main + * process to land on the dock icon. The badge has no independent switch — it follows the notification master switch, + * enabled by default, and is set to 0 to clear when disabled; skipped outright on non-macOS (the main process also + * only lands it on macOS). Recomputes and pushes as the PR list / notification config changes. Each PR's count is + * already capped at 10 on the poll side, so the total has a built-in upper bound. */ export function useDockBadge({ prs, @@ -22,7 +24,7 @@ export function useDockBadge({ ? prs.reduce((sum, p) => sum + (p.unreadMentionCount ?? 0), 0) : 0; void invoke('app:setBadgeCount', { count }).catch(() => { - /* 角标失败不影响主流程 */ + /* badge failure does not affect the main flow */ }); }, [prs, platform, notifications]); } diff --git a/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts b/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts index 5ef9ed2d..7903e0bc 100644 --- a/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts +++ b/apps/desktop/src/renderer/src/hooks/useExternalLinkGuard.ts @@ -2,9 +2,9 @@ import { useEffect } from 'react'; import { invoke } from '../api'; /** - * 全局外链跳转防护:所有 UGC 场景(评论 / PR 描述 / finding / chat 等)内的 - * `<a href="http(s)://">` 点击都走系统默认浏览器,不允许 Electron 在 app window 内直接跳转 - * 覆盖整个界面。capture 阶段 listener 先于 React onClick 跑。 + * Global external link navigation guard: clicks on `<a href="http(s)://">` in any UGC scenario (comments / PR + * description / finding / chat, etc.) all go through the system default browser; Electron is not allowed to navigate + * directly within the app window and cover the entire UI. The capture-phase listener runs before React onClick. */ export function useExternalLinkGuard(): void { useEffect(() => { diff --git a/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts index 4988817d..1fa9d499 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts @@ -3,14 +3,15 @@ import { invoke } from '../api'; import { chatRunStore } from '../stores/chat-run-store'; /** - * 窗口级全局快捷键(VS Code 风),统一在此挂一个 `keydown` 监听: - * - **F5**:对当前选中 PR 运行自动评审(与命令面板同逻辑:有选中 PR、可参与、且未在跑才触发——重入保护)。 - * - **DevTools**:mac ⌥⌘I / 其余 Ctrl+Shift+I(带 Shift/Alt,与下面单修饰键的 B/J 区分)。 - * - **查看已关闭**:mac ⌘⇧H(避开系统「隐藏应用」⌘H)/ 其余 Ctrl+H(浏览器历史惯例)。 - * - **布局开关**:Ctrl/Cmd+B 切 PR 列表(左侧栏)、Ctrl/Cmd+J 切对话面板(右侧);单修饰键,排除 Shift/Alt - * (避开 Cmd+Shift+P 命令面板)。 + * Window-level global shortcuts (VS Code style), all attached to a single `keydown` listener here: + * - **F5**: run auto review on the currently selected PR (same logic as the command palette: only triggers when there + * is a selected PR, it's engageable, and it's not already running — reentrancy guard). + * - **DevTools**: mac ⌥⌘I / otherwise Ctrl+Shift+I (with Shift/Alt, distinguished from the single-modifier B/J below). + * - **View closed**: mac ⌘⇧H (avoiding the system "Hide App" ⌘H) / otherwise Ctrl+H (browser history convention). + * - **Layout toggles**: Ctrl/Cmd+B toggles the PR list (left sidebar), Ctrl/Cmd+J toggles the chat panel (right); + * single modifier, excluding Shift/Alt (avoiding the Cmd+Shift+P command palette). * - * `selectedId` / `canEngage` 经 ref 读实时值,使监听只随 platform 与几个稳定回调重建、不随选中 PR 频繁重订阅。 + * `selectedId` / `canEngage` are read live via ref, so the listener rebuilds only with platform and a few stable callbacks, not resubscribing frequently as the selected PR changes. */ export function useGlobalShortcuts({ platform, @@ -27,7 +28,7 @@ export function useGlobalShortcuts({ setSidebarCollapsed: Dispatch<SetStateAction<boolean>>; setChatCollapsed: Dispatch<SetStateAction<boolean>>; }): void { - // 选中 PR / 可参与态的 ref:供稳定监听读实时值,免得每次切 PR 重订阅。 + // Refs for the selected PR / engageable state: let the stable listener read live values, avoiding a resubscribe on every PR switch. const selectedIdRef = useRef(selectedId); selectedIdRef.current = selectedId; const canEngageRef = useRef(canEngage); @@ -37,7 +38,7 @@ export function useGlobalShortcuts({ const isMac = platform === 'darwin'; const onKey = (e: KeyboardEvent): void => { const k = e.key.toLowerCase(); - // F5:对当前选中 PR 运行自动评审(有选中 PR、可参与、且未在跑才触发——重入保护) + // F5: run auto review on the currently selected PR (only triggers when there is a selected PR, it's engageable, and it's not already running — reentrancy guard) if (k === 'f5') { const id = selectedIdRef.current; if (id && canEngageRef.current && !chatRunStore.getSnapshot().agentPrs.includes(id)) { @@ -46,7 +47,7 @@ export function useGlobalShortcuts({ } return; } - // DevTools:mac ⌥⌘I / 其余 Ctrl+Shift+I(带 Shift/Alt,与下面单修饰键的 B/J 区分) + // DevTools: mac ⌥⌘I / otherwise Ctrl+Shift+I (with Shift/Alt, distinguished from the single-modifier B/J below) if (k === 'i') { const devtools = isMac ? e.metaKey && e.altKey && !e.shiftKey && !e.ctrlKey @@ -57,7 +58,7 @@ export function useGlobalShortcuts({ } return; } - // 查看已关闭(history):mac ⌘⇧H(避开系统「隐藏应用」⌘H)/ 其余 Ctrl+H(浏览器历史惯例) + // View closed (history): mac ⌘⇧H (avoiding the system "Hide App" ⌘H) / otherwise Ctrl+H (browser history convention) if (k === 'h') { const wantArchived = isMac ? e.metaKey && e.shiftKey && !e.ctrlKey && !e.altKey @@ -68,7 +69,7 @@ export function useGlobalShortcuts({ } return; } - // 单修饰键布局开关:Ctrl/Cmd+B(PR 列表)、Ctrl/Cmd+J(对话面板) + // Single-modifier layout toggles: Ctrl/Cmd+B (PR list), Ctrl/Cmd+J (chat panel) const mod = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey; if (!mod || e.shiftKey || e.altKey) return; if (k === 'b') { diff --git a/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts b/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts index d7d3fab1..3b74578b 100644 --- a/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts +++ b/apps/desktop/src/renderer/src/hooks/usePanelLayout.ts @@ -8,8 +8,8 @@ function clampWidth(raw: string | null, min: number, max: number): number { } /** - * 左右两栏(PR 列表 / chat)的宽度与折叠态:初值从 localStorage 读(夹到各自 min/max), - * 变化即回写。纯 UI 布局态,不涉及业务。 + * Width and collapsed state of the two side panels (PR list / chat): initial values read from localStorage (clamped + * to each one's min/max), written back on change. Pure UI layout state, no business logic involved. */ export function usePanelLayout(): { sidebarWidth: number; @@ -31,7 +31,7 @@ export function usePanelLayout(): { clampWidth(localStorage.getItem('meebox.chatWidth'), CHAT_MIN_WIDTH, CHAT_MAX_WIDTH), ); const [chatCollapsed, setChatCollapsed] = useState<boolean>( - // 默认收起:chat 早期是空壳,避免空占地方 + // Collapsed by default: chat was an empty shell early on, avoid taking up space for nothing () => (localStorage.getItem('meebox.chatCollapsed') ?? '1') === '1', ); useEffect(() => { diff --git a/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts b/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts index 076e30cf..4c5a457c 100644 --- a/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts +++ b/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts @@ -4,8 +4,8 @@ import { invoke, subscribe } from '../api'; import { formatBackendError } from '../errors'; /** - * 跨组件跳转意图(M4):ChatPane finding card 点「编辑」/ PublishReviewModal anchor 点击 / 通知点击 inline - * 评论 → 这里 set → PrPanel 切到 Diff tab + 透传给 DiffView 做 scroll/highlight/(可选)open edit zone,消费完清空。 + * Cross-component jump intent (M4): ChatPane finding card "edit" click / PublishReviewModal anchor click / notification click on inline + * comment → set here → PrPanel switches to Diff tab + passes through to DiffView for scroll/highlight/(optional) open edit zone, cleared once consumed. */ export interface PendingDiffNav { runId?: string; @@ -14,25 +14,25 @@ export interface PendingDiffNav { } export interface PrNavigation { - /** 列表范围:进行中(活跃)/ 已关闭(归档冷存储,懒加载、只读)。 */ + /** List scope: in-progress (active) / closed (archived cold storage, lazy-loaded, read-only). */ scope: 'active' | 'archived'; - /** GitHub 发现分类(运行时筛选,不持久化);仅活动连接支持时在 PR 列表展示。 */ + /** GitHub discovery category (runtime filter, not persisted); shown in PR list only when the active connection supports it. */ discoveryFilter: PrDiscoveryFilter; - /** 当前展示列表(归档范围用归档列表,其余用活跃列表)。 */ + /** Currently displayed list (archived scope uses the archived list, otherwise the active list). */ displayedPrs: StoredPullRequest[]; - /** 当前展示列表里解析出的选中 PR(解析不到回 null)。 */ + /** Selected PR resolved from the currently displayed list (null when not resolvable). */ selectedPr: StoredPullRequest | null; - /** 归档冷存储拉取中(列表区据此显示 loading)。 */ + /** Archived cold storage fetch in progress (the list area shows loading based on this). */ archivedLoading: boolean; - /** 选发现分类(侧栏 tab / 命令面板)→ 回到「进行中」范围并切分类。 */ + /** Select a discovery category (sidebar tab / command panel) → return to "in-progress" scope and switch category. */ selectDiscovery: (f: PrDiscoveryFilter) => void; - /** 切到「进行中」范围。 */ + /** Switch to "in-progress" scope. */ viewActive: () => void; - /** 切到「已关闭」范围(触发懒加载)。 */ + /** Switch to "closed" scope (triggers lazy load). */ viewArchived: () => void; - /** 按 URL 打开当前平台 PR(命令面板「打开 URL」):定位本地或拉取存档后切到对应范围并选中;失败弹 toast。 */ + /** Open a PR of the current platform by URL (command panel "open URL"): locate locally or fetch the archive, then switch to the matching scope and select it; shows a toast on failure. */ openPrByUrl: (url: string) => Promise<void>; - /** 定位并选中某 PR(活跃命中切活跃 + 必要时切分类 + 标已读;否则视为已归档 → 切归档范围、加载归档列表后选中)。 */ + /** Locate and select a PR (active hit switches to active + switches category if needed + marks read; otherwise treated as archived → switch to archived scope, load the archived list, then select). */ jumpToPr: (localId: string) => Promise<void>; pendingDiffNav: PendingDiffNav | null; setPendingDiffNav: Dispatch<SetStateAction<PendingDiffNav | null>>; @@ -41,11 +41,11 @@ export interface PrNavigation { } /** - * PR 导航 / 范围领域:在 {@link usePullRequests} 的列表 + 选中之上,统管发现分类、活跃 / 归档范围切换、 - * 归档冷存储懒加载、按 URL 打开、定位跳转(活跃 / 归档统一逻辑),并消费系统通知点击的导航意图 - * (`notification:activate` → jumpToPr + inline 评论跳 Diff 行 / summary 评论开「活动」标签)。 + * PR navigation / scope domain: on top of {@link usePullRequests}'s list + selection, governs discovery categories, active / archived scope switching, + * archived cold storage lazy loading, opening by URL, locate-and-jump (unified active / archived logic), and consumes navigation intent from system notification clicks + * (`notification:activate` → jumpToPr + inline comment jumps to Diff line / summary comment opens the "activity" tab). * - * 选中态 / 已读由 usePullRequests 拥有(经入参传入);本 hook 只负责「去哪儿、看哪个范围」。 + * Selection state / read status is owned by usePullRequests (passed in via props); this hook only handles "where to go, which scope to view". */ export function usePrNavigation({ prs, @@ -63,13 +63,13 @@ export function usePrNavigation({ const [discoveryFilter, setDiscoveryFilter] = useState<PrDiscoveryFilter>('review-requested'); const [scope, setScope] = useState<'active' | 'archived'>('active'); const [archivedPrs, setArchivedPrs] = useState<StoredPullRequest[]>([]); - // 归档冷存储拉取中:列表区据此显示 loading(归档规模大、可能慢;PaneLoading 自带 150ms 延迟,快路径不闪)。 + // Archived cold storage fetch in progress: the list area shows loading based on this (archives are large and may be slow; PaneLoading has a built-in 150ms delay, so the fast path doesn't flash). const [archivedLoading, setArchivedLoading] = useState(false); const [pendingDiffNav, setPendingDiffNav] = useState<PendingDiffNav | null>(null); - // 通知点击 summary 评论 → 请求 PrPanel 切到「活动」对话标签(inline 评论走 pendingDiffNav)。 + // Notification click on a summary comment → request PrPanel to switch to the "activity" conversation tab (inline comments go through pendingDiffNav). const [pendingTab, setPendingTab] = useState<'activity' | null>(null); - // 进入「已关闭」范围时懒加载归档冷存储(每次进入重取,纳入此后新归档的 PR);离开不清,便于来回切。 + // Lazy-load archived cold storage on entering "closed" scope (re-fetch on each entry to include newly archived PRs); don't clear on leaving, to make back-and-forth switching easy. useEffect(() => { if (scope !== 'archived') return; let cancelled = false; @@ -86,7 +86,7 @@ export function usePrNavigation({ }; }, [scope]); - // 选发现分类(侧栏 tab / 命令面板)即回到「进行中」范围;「查看已关闭」切到归档范围。 + // Selecting a discovery category (sidebar tab / command panel) returns to "in-progress" scope; "view closed" switches to archived scope. const selectDiscovery = useCallback((f: PrDiscoveryFilter) => { setScope('active'); setDiscoveryFilter(f); @@ -94,7 +94,7 @@ export function usePrNavigation({ const viewActive = useCallback(() => setScope('active'), []); const viewArchived = useCallback(() => setScope('archived'), []); - // 当前发现分类的 ref:供 openPrByUrl / jumpToPr 在稳定回调里读最新值,免得把 discoveryFilter 进依赖、频繁重建。 + // Ref to the current discovery category: lets openPrByUrl / jumpToPr read the latest value in stable callbacks, avoiding putting discoveryFilter in the deps and rebuilding frequently. const discoveryFilterRef = useRef(discoveryFilter); discoveryFilterRef.current = discoveryFilter; @@ -104,10 +104,10 @@ export function usePrNavigation({ const res = await invoke('prs:openByUrl', { url }); setScope(res.location); if (res.location === 'archived') { - // 归档范围(已存在归档 / 新拉取存档)需重载列表纳入目标 PR。 + // Archived scope (already archived / newly fetched archive) needs a list reload to include the target PR. setArchivedPrs(await invoke('prs:listArchived', undefined)); } else if ( - // 活跃 PR:若当前发现分类不含它,落到包含它的分类,确保侧栏能展示并高亮(否则只剩详情显示、列表无选中)。 + // Active PR: if the current discovery category doesn't include it, fall to a category that does, ensuring the sidebar can show and highlight it (otherwise only the detail view shows, with no list selection). res.discoveryFilters.length > 0 && !res.discoveryFilters.includes(discoveryFilterRef.current) ) { @@ -122,12 +122,12 @@ export function usePrNavigation({ [setSelectedId, markRead, notifyError], ); - // 活跃 PR 列表 ref:供通知点击 / 状态栏跳转在稳定回调里读最新值,免得把 prs 进依赖、频繁重建。 + // Ref to the active PR list: lets notification clicks / status bar jumps read the latest value in stable callbacks, avoiding putting prs in the deps and rebuilding frequently. const prsRef = useRef(prs); prsRef.current = prs; - // 状态栏运行指示 / 通知点击 → 定位 PR。任务运行期间该 PR 可能已被 poll 归档(任务不取消、仍在跑),故活跃列表 - // 里找不到时视为已归档:切归档范围 + 重载归档列表(覆盖「本 tick 刚归档、缓存未含」与「已在归档范围、setScope - // 同值不触发懒加载」两种情况)再选中。活跃命中则切活跃范围 + 必要时切到含它的发现分类(确保侧栏展示并高亮)+ 标已读。 + // Status bar run indicator / notification click → locate the PR. During a task run the PR may already have been archived by a poll (the task isn't cancelled, still running), so when + // it's not found in the active list it's treated as archived: switch to archived scope + reload the archived list (covers both "just archived this tick, cache doesn't have it" and "already in + // archived scope, setScope with the same value doesn't trigger lazy load") then select. On an active hit, switch to active scope + switch to a discovery category that includes it if needed (ensuring the sidebar shows and highlights it) + mark read. const jumpToPr = useCallback( async (localId: string) => { const active = prsRef.current.find((p) => p.localId === localId); @@ -150,8 +150,8 @@ export function usePrNavigation({ [markRead, setSelectedId], ); - // 系统通知点击 → 导航:复用 jumpToPr 选中目标,再按类型定位——inline 评论跳 Diff 行,summary 评论 - // (mention / reply)开「活动」标签,new_pr 仅选中。与状态栏跳转走同一套活跃 / 归档定位逻辑。 + // System notification click → navigation: reuse jumpToPr to select the target, then locate by type — inline comment jumps to the Diff line, summary comment + // (mention / reply) opens the "activity" tab, new_pr only selects. Shares the same active / archived locate logic as the status bar jump. useEffect(() => { return subscribe('notification:activate', ({ localId, kind, anchor }) => { void jumpToPr(localId); @@ -163,8 +163,8 @@ export function usePrNavigation({ }); }, [jumpToPr]); - // 列表 / 详情数据源随范围切换:已关闭范围用归档列表,其余用活跃列表。选中 PR 从当前展示列表解析—— - // 切到归档范围时若原选中是活跃 PR 则解析不到、详情区回落空态,选归档项后再展示其详情。 + // The list / detail data source switches with scope: closed scope uses the archived list, otherwise the active list. The selected PR is resolved from the currently displayed list — + // when switching to archived scope, if the previous selection was an active PR it won't resolve and the detail area falls back to an empty state, showing details again after an archived item is selected. const displayedPrs = scope === 'archived' ? archivedPrs : prs; const selectedPr = displayedPrs.find((p) => p.localId === selectedId) ?? null; diff --git a/apps/desktop/src/renderer/src/hooks/useTheme.ts b/apps/desktop/src/renderer/src/hooks/useTheme.ts index fab2ee6b..ac6b00e5 100644 --- a/apps/desktop/src/renderer/src/hooks/useTheme.ts +++ b/apps/desktop/src/renderer/src/hooks/useTheme.ts @@ -11,20 +11,20 @@ import { applyChromeFromEditorTheme } from '../theme/editor-chrome-sync'; import { setEditorAppearance, useEditorAppearance } from '../stores/editor-appearance-store'; import { invoke } from '../api'; -/** 主题应用后把派生的窗控配色(Windows titleBarOverlay)推给主进程;null 回退通用深/浅。失败静默。 */ +/** After a theme is applied, push the derived window-control colors (Windows titleBarOverlay) to the main process; null falls back to generic dark/light. Fails silently. */ function syncWindowControls(colors: { color: string; symbolColor: string } | null): void { void invoke('window:setControlColors', colors).catch(() => { - /* 平台不支持 / 主进程未就绪 → 忽略 */ + /* platform unsupported / main process not ready → ignore */ }); } /** - * 全局主题生效:主题变化时把它反推浅 / 深写到 documentElement.data-theme(驱动语义色板)+ 派生结构性 - * chrome 色覆盖 + 持久化到 localStorage(供下次启动同步命中);'auto' 主题下还监听 OS 深 / 浅色切换、 - * 实时重解析跟随。 + * Apply the global theme: when the theme changes, resolve it to light / dark and write it to documentElement.data-theme (driving the semantic palette) + derive structural + * chrome color overrides + persist to localStorage (for a synchronous hit on next startup); under the 'auto' theme also watch OS dark / light changes and + * re-resolve in real time to follow. * - * 主题源为共享 store(由 useEditorAppearanceSync 从 config.appearance.editor_theme 注入,设置页即时 - * 改动经 setEditorAppearance 同步),故主题切换与语言切换走同一条「config 驱动 + 即时生效」路径。 + * The theme source is a shared store (injected by useEditorAppearanceSync from config.appearance.editor_theme, with instant settings-page + * changes synced via setEditorAppearance), so theme switching and language switching go through the same "config-driven + instant effect" path. */ export function useGlobalTheme(): void { const { editorTheme } = useEditorAppearance(); @@ -32,14 +32,14 @@ export function useGlobalTheme(): void { applyGlobalTheme(editorTheme); persistEditorTheme(editorTheme); syncWindowControls(applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme))); - // 'auto' 主题:OS 深浅切换时重写 data-theme(watch 内部已做)并重派生 chrome + 同步窗控配色 + // 'auto' theme: on OS dark/light switch, rewrite data-theme (watch already does this) and re-derive chrome + sync window-control colors return watchSystemThemeForAuto(editorTheme, () => { syncWindowControls(applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme))); }); }, [editorTheme]); } -/** 订阅 documentElement.data-theme 变化(含 system 偏好下 OS 切换)。 */ +/** Subscribe to documentElement.data-theme changes (including OS switches under the system preference). */ function subscribeResolvedTheme(onChange: () => void): () => void { const obs = new MutationObserver(onChange); obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); @@ -51,16 +51,16 @@ function getResolvedThemeSnapshot(): ResolvedTheme { } /** - * 当前实际生效的视觉主题(解析后的 light / dark),随 data-theme 变化实时更新。供需按主题切换内部 - * 配色的非 CSS 组件用(如 Monaco 编辑器、Mermaid —— 它们的主题不走 CSS 自定义属性,须显式传入)。 + * The visual theme currently in effect (resolved light / dark), updated in real time as data-theme changes. For non-CSS components that need to switch internal + * colors by theme (e.g. the Monaco editor, Mermaid — their themes don't go through CSS custom properties and must be passed in explicitly). */ export function useResolvedTheme(): ResolvedTheme { return useSyncExternalStore(subscribeResolvedTheme, getResolvedThemeSnapshot); } /** - * 把 config.appearance 的编辑器外观同步到运行时:写入共享 store(供 Monaco 组件读)+ 应用等宽字体 - * CSS 变量(供全应用 $font-mono)。源为 config(启动注入、设置页即时改动经 patchConfig 同步)。 + * Sync config.appearance's editor appearance to the runtime: write the shared store (read by Monaco components) + apply the monospace-font + * CSS variable (used app-wide by $font-mono). The source is config (injected at startup, with instant settings-page changes synced via patchConfig). */ export function useEditorAppearanceSync(appearance: Config['appearance']): void { const { editor_theme, editor_font_family, editor_font_size } = appearance; @@ -75,8 +75,8 @@ export function useEditorAppearanceSync(appearance: Config['appearance']): void } /** - * 当前生效的 Monaco 编辑器主题名:编辑器主题偏好为 'auto' 时跟随 GUI 解析主题(浅 'light-2026' / 深 - * 'dark-2026',即默认的 2026 配色),否则用所选主题 id。 + * The Monaco editor theme name currently in effect: when the editor theme preference is 'auto', it follows the GUI resolved theme (light 'light-2026' / dark + * 'dark-2026', i.e. the default 2026 colors), otherwise it uses the selected theme id. */ export function useMonacoEditorTheme(): string { const { editorTheme } = useEditorAppearance(); diff --git a/apps/desktop/src/renderer/src/hooks/useToast.ts b/apps/desktop/src/renderer/src/hooks/useToast.ts index 53758966..85ee783a 100644 --- a/apps/desktop/src/renderer/src/hooks/useToast.ts +++ b/apps/desktop/src/renderer/src/hooks/useToast.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; /** - * 操作级 toast(审批 / 合并等远端动作失败时提示,区别于 fatalError 整屏报错)。 - * key 用随机数:同样文案连续触发也能重置自动消失计时器;6s 后自动消失。 + * Action-level toast (shown when a remote action such as approve / merge fails, as opposed to fatalError's full-screen error). + * The key uses a random number: the same text triggered consecutively can still reset the auto-dismiss timer; auto-dismisses after 6s. */ export function useToast(): { toast: { text: string; key: number } | null; @@ -18,7 +18,7 @@ export function useToast(): { if (!toast) return; const id = setTimeout(() => setToast(null), 6000); return () => clearTimeout(id); - // 仅依赖 key:同一 toast 重渲不重置计时,新 toast (key 变) 才重置 + // Depend only on key: re-rendering the same toast doesn't reset the timer, only a new toast (key changes) resets it // eslint-disable-next-line react-hooks/exhaustive-deps }, [toast?.key]); return { toast, notifyError, dismiss }; diff --git a/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts b/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts index 0e684765..93c8f115 100644 --- a/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts +++ b/apps/desktop/src/renderer/src/hooks/useUpdateNotice.ts @@ -3,9 +3,9 @@ import type { UpdateCheckResult } from '@meebox/shared'; import { invoke, subscribe } from '../api'; /** - * 版本更新提示(main 为单一真相源):挂载时水合已缓存结果(设置页手动检查 / 定时检查到的新版 - * 不因窗口重挂载而丢失),再订阅后续广播。另含 dev 调试钩子——控制台 dispatch - * `meebox:debug-update`(detail 可带 latestVersion / 为 null 清除)模拟「发现新版」验证状态栏 chip。 + * Version update notice (main is the single source of truth): on mount, hydrate the cached result (a new version found via the settings-page manual check / scheduled check + * isn't lost when the window remounts), then subscribe to subsequent broadcasts. Also includes a dev debug hook — the console can dispatch + * `meebox:debug-update` (detail may carry latestVersion / null to clear) to simulate "new version found" and verify the status bar chip. */ export function useUpdateNotice(): UpdateCheckResult | null { const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null); diff --git a/apps/desktop/src/renderer/src/i18n/index.ts b/apps/desktop/src/renderer/src/i18n/index.ts index 630ace09..6c41a3d5 100644 --- a/apps/desktop/src/renderer/src/i18n/index.ts +++ b/apps/desktop/src/renderer/src/i18n/index.ts @@ -10,30 +10,30 @@ import { import enUS from './locales/en-US.json'; /** - * 渲染层国际化(react-i18next)。 + * Renderer internationalization (react-i18next). * - * - key 是**中立标识符**(`chatPane.emptySelectPrTitle` 这种);zh-CN / en-US / ja-JP / - * de-DE 是**对等的译文集**(各 locale 满覆盖、无源/译层级),按「组件命名空间」组织 - * (顶层 object 对应一个组件,common 放跨组件复用文案)。 - * - 实际语言由 config.language 经 `resolveLanguage` 决定(空则按 OS 偏好回落英语); - * App 启动拿到 boot.config 后调 `i18n.changeLanguage` 切换。 + * - Keys are **neutral identifiers** (like `chatPane.emptySelectPrTitle`); zh-CN / en-US / ja-JP / + * de-DE are **equivalent translation sets** (each locale fully covered, no source/translation hierarchy), organized by "component namespace" + * (a top-level object corresponds to a component, common holds cross-component reusable text). + * - The actual language is decided by config.language via `resolveLanguage` (empty falls back to English per OS preference); + * after App startup gets boot.config it calls `i18n.changeLanguage` to switch. * - * 默认 / 兜底语言取 **en-US**(国际化项目标准:英文最通用): - * - en-US **静态打包**进入口,同时作 `fallbackLng`——任何 locale 缺 key 都回退英文而非中文。 - * - zh-CN / ja-JP / de-DE 经 resourcesToBackend + 动态 import **按需懒加载**:Vite 把每份 - * locale 拆成独立 chunk,仅当切到该语言时才拉取,不进入口包(语言越多收益越大)。 - * - `partialBundledLanguages` 让静态 resources 与 backend 懒加载共存;`useSuspense: false` - * 使切换懒加载语言时不走 Suspense——加载完成自动重渲,期间回退当前语言,无需 Suspense 边界。 + * The default / fallback language is **en-US** (i18n project standard: English is most universal): + * - en-US is **statically bundled** into the entry, and also serves as `fallbackLng` — any locale missing a key falls back to English rather than Chinese. + * - zh-CN / ja-JP / de-DE are **lazy-loaded on demand** via resourcesToBackend + dynamic import: Vite splits each + * locale into a separate chunk, pulled only when switching to that language, not entering the entry bundle (the more languages, the greater the benefit). + * - `partialBundledLanguages` lets static resources coexist with backend lazy loading; `useSuspense: false` + * makes switching a lazy-loaded language avoid Suspense — it auto re-renders once loaded, falling back to the current language meanwhile, no Suspense boundary needed. */ export { SUPPORTED_LANGUAGES, matchSupportedLanguage, type SupportedLanguage }; -// 渲染层同步缓存的「上次语言」:config.language 经 IPC 异步到达,启动时拿不到; -// localStorage 可同步读,故用它做初始语言,避免英语/日语/德语用户启动先闪一帧中文。 -// App 拿到 config.language 后会 persistLanguage 回写,供下次启动直接命中。 +// Renderer synchronously cached "last language": config.language arrives asynchronously via IPC and is unavailable at startup; +// localStorage can be read synchronously, so use it as the initial language, avoiding English/Japanese/German users flashing a frame of Chinese on startup. +// Once App gets config.language it persistLanguage-writes it back, for the next startup to hit directly. const LANG_STORAGE_KEY = 'meebox.language'; -/** 浏览器/OS 偏好语言列表(作 config 为空时的 OS 探测来源)。 */ +/** Browser/OS preferred language list (the OS-detection source when config is empty). */ function osLocales(): string[] { try { return [...(navigator.languages ?? [navigator.language])].filter(Boolean); @@ -42,40 +42,40 @@ function osLocales(): string[] { } } -/** 解析有效 UI 语言:config.language 优先,空则按 OS 偏好回落英语。 */ +/** Resolve the effective UI language: config.language takes priority, empty falls back to English per OS preference. */ export function resolveUiLanguage(configLang: string | null | undefined): SupportedLanguage { return resolveLanguage(configLang, osLocales()); } function readInitialLanguage(): SupportedLanguage { try { - // 上次持久化的语言已是解析后的受支持值,直接命中;首启无记录时按 OS 偏好探测。 + // The last persisted language is already a resolved supported value, hit it directly; on first launch with no record, detect per OS preference. return matchSupportedLanguage(localStorage.getItem(LANG_STORAGE_KEY)) ?? resolveUiLanguage(''); } catch { return resolveUiLanguage(''); } } -/** 持久化当前语言到 localStorage,供下次启动同步读取作初始语言。 */ +/** Persist the current language to localStorage, for the next startup to read synchronously as the initial language. */ export function persistLanguage(lang: string): void { try { const matched = matchSupportedLanguage(lang); if (matched) localStorage.setItem(LANG_STORAGE_KEY, matched); } catch { - // localStorage 不可用时忽略:仅影响下次启动的初始语言命中,不影响功能。 + // Ignore when localStorage is unavailable: only affects the next startup's initial language hit, not functionality. } } /** - * 让 `<html lang>` 跟随当前 UI 语言(初始 + 每次切换)。index.html 静态写死 `zh-CN`、切语言时不更新, - * 会误导 CSS `hyphens:auto` 断词(用错语言的断词词典)、屏幕阅读器与字体选择。i18n 的 languageChanged - * 覆盖所有切换来源(启动 / 设置页 / 首启向导 / 命令面板),在此一处同步即可。 + * Make `<html lang>` follow the current UI language (initial + every switch). index.html statically hardcodes `zh-CN` and does not update on language switch, + * which would mislead CSS `hyphens:auto` hyphenation (wrong-language hyphenation dictionary), screen readers, and font selection. i18n's languageChanged + * covers all switch sources (startup / settings page / onboarding wizard / command palette), so syncing in this one place suffices. */ function syncDocumentLang(lng: string): void { try { document.documentElement.lang = lng; } catch { - // document 不可用(非常规宿主)时忽略:仅影响断词 / 可达性提示,不影响功能。 + // Ignore when document is unavailable (non-standard host): only affects hyphenation / accessibility hints, not functionality. } } @@ -85,7 +85,7 @@ i18n.on('languageChanged', syncDocumentLang); void i18n .use( - // zh-CN 已静态打包,backend 只为其余语言按需拉取对应 chunk。 + // zh-CN is already statically bundled, the backend only pulls the corresponding chunk on demand for the remaining languages. resourcesToBackend( (lng: string) => import(`./locales/${lng}.json`) as Promise<{ default: Record<string, unknown> }>, @@ -94,28 +94,28 @@ void i18n .use(initReactI18next) .init({ resources: { - // 仅默认语言 en-US 静态进入口;其余由 backend 懒加载。 + // Only the default language en-US statically enters the entry; the rest are lazy-loaded by the backend. 'en-US': { translation: enUS }, }, partialBundledLanguages: true, - // 初始语言取持久化值 / OS 偏好(默认按 OS 回落英语):直接以用户语言启动。 + // Initial language takes the persisted value / OS preference (default falls back to English per OS): start directly in the user's language. lng: initialLanguage, - // 兜底取 en-US(国际化标准:英文最通用):任何 locale 缺 key 回退英文而非中文。 - // 各 locale 满覆盖,单层兜底足够;en-US 已静态打包,作 fallback 也不必再拉 chunk。 + // Fallback takes en-US (i18n standard: English is most universal): any locale missing a key falls back to English rather than Chinese. + // Each locale is fully covered, single-layer fallback suffices; en-US is already statically bundled, so serving as fallback needs no extra chunk pull. fallbackLng: 'en-US', supportedLngs: [...SUPPORTED_LANGUAGES], - // 注意:**不要**开 nonExplicitSupportedLngs。它会把带地区码的 'zh-CN' 按基码 'zh' - // 规整查找,而资源 bundle 是按 'zh-CN' 注册的 → 命名空间落空、t() 退化成裸 key(整页 - // 不翻译)。语言进 i18n 前已由 resolveLanguage/matchSupportedLanguage 规整成精确受支持码 - // (zh-CN/en-US/ja-JP/de-DE),无需 i18next 再做非精确匹配。 + // Note: **do not** enable nonExplicitSupportedLngs. It would normalize the region-coded 'zh-CN' to the base code 'zh' + // for lookup, whereas the resource bundle is registered under 'zh-CN' → the namespace comes up empty, t() degrades to bare keys (whole page + // untranslated). Before entering i18n the language is already normalized to an exact supported code by resolveLanguage/matchSupportedLanguage + // (zh-CN/en-US/ja-JP/de-DE), so i18next need not do non-exact matching. load: 'currentOnly', interpolation: { - // React 已对插值做转义,关闭 i18next 二次转义避免 & 之类。 + // React already escapes interpolation, disable i18next's double-escaping to avoid things like &. escapeValue: false, }, returnNull: false, react: { - // 切到懒加载语言时不抛 Suspense;加载完成后自动重渲,期间回退当前语言。 + // Do not throw Suspense when switching to a lazy-loaded language; auto re-render once loaded, falling back to the current language meanwhile. useSuspense: false, }, }); diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index dc0b2c82..c441fce7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -43,7 +43,7 @@ "verdictManualReview": "Manuelle Prüfung", "verdictNeedsWork": "Nachbesserung nötig" }, - "aiSuggestionPrefix": "[KI-Vorschlag]", + "aiSuggestionLabel": "KI-Vorschlag", "anchorJumpTitle": "Zur entsprechenden Zeile im Code springen", "askArgQuestion": "Frage", "askNeedsQuestion": "/ask erfordert eine Frage", @@ -729,6 +729,18 @@ "checkUpdateTitle": "GitHub auf die neueste Version prüfen (nur Erkennung, keine automatische Installation)", "checking": "Prüfe…", "cliExperimentalHint": "Lokale CLI-Provider hängen von einem lokalen Kommandozeilen-Tool ab; sein Verhalten kann sich mit dessen Releases ändern — Stabilität und dauerhafte Verfügbarkeit sind nicht garantiert.", + "codeSuggestionDefault": "Standard", + "codeSuggestionLayoutHint": "Deterministisches Markdown-Layout für den gesamten Entwurfskommentar aus einem KI-Vorschlag; Platzhalter werden stets ersetzt.", + "codeSuggestionLayoutLabel": "Layout für Code-Vorschläge", + "codeSuggestionLayoutModalHint": "Layout für den aus einem KI-Vorschlag erstellten Entwurfskommentar. Markiere mit <SUGGESTIONS>, wo der Text steht; füge Zeilenumbrüche, Abschnitte oder weitere Inhalte frei hinzu. Leer lassen, um das im Platzhalter gezeigte Standard-Layout zu verwenden.", + "codeSuggestionLayoutTitle": "Layout für Code-Vorschläge", + "codeSuggestionLayoutVars": "Platzhalter: <TITLE> KI-Vorschlag-Label · <SUGGESTIONS> Vorschlagstext · <HOME> Projektwebsite · <PR> aktueller PR-Link · <MODEL> aktueller Modellname. Mit <SUGGESTIONS> ist die Vorlage der vollständige Kommentartext; andernfalls wird die gesamte Zeichenkette als Präfix vorangestellt.", + "codeSuggestionSet": "Konfiguriert", + "codeSuggestionSpecHint": "Zusätzliche Anweisungen für /improve, /review und /ask, die vorgeben, wie jeder Vorschlag verfasst wird (z. B. Problem / Analyse / Vorschlag). Eine weiche Vorgabe, der das Modell in der Regel folgt.", + "codeSuggestionSpecLabel": "Spezifikation für Code-Vorschläge", + "codeSuggestionSpecModalHint": "Freitext-Vorgabe, die beim Erzeugen von Code-Vorschlägen an die Modellanweisungen angehängt wird. Leer lassen, um die Standardausgabe von pr-agent zu verwenden.", + "codeSuggestionSpecPlaceholder": "Jeden Vorschlag in drei Abschnitte gliedern — Problem, Analyse, Vorschlag — jeweils in einer eigenen Zeile.", + "codeSuggestionSpecTitle": "Spezifikation für Code-Vorschläge", "concurrencyHint": "Anzahl gleichzeitig laufender Review-Aufgaben, 1–8. Höhere Parallelität verbraucht Tokens schneller.", "concurrencySliderAria": "Stufe der Review-Parallelität", "concurrencyTitle": "Review-Parallelität", @@ -859,6 +871,7 @@ "updateAvailableLabel": "↑ Neue Version v{{version}} · Zum Download", "updateDisabledHint": "Die Update-Prüfung ist in der Konfiguration deaktiviert (config.yaml `update.check_enabled`)", "updateDisabledTitle": "Die Update-Prüfung ist in der Konfiguration deaktiviert", + "website": "Website", "workDirTitle": "Arbeitsverzeichnis" }, "sidebar": { diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index e8b378c6..5bb39577 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -43,7 +43,7 @@ "verdictManualReview": "Manual review", "verdictNeedsWork": "Needs work" }, - "aiSuggestionPrefix": "[AI suggestion]", + "aiSuggestionLabel": "AI suggestion", "anchorJumpTitle": "Jump to the corresponding line in code", "askArgQuestion": "question", "askNeedsQuestion": "/ask requires a question", @@ -729,6 +729,18 @@ "checkUpdateTitle": "Check GitHub for the latest version (detection only, no auto-install)", "checking": "Checking…", "cliExperimentalHint": "Local CLI providers depend on a local command-line tool; its behavior may change with the tool's releases — stability and continued availability are not guaranteed.", + "codeSuggestionDefault": "Default", + "codeSuggestionLayoutHint": "Deterministic markdown layout for the whole draft comment created from an AI suggestion; placeholders are always filled.", + "codeSuggestionLayoutLabel": "Code suggestion layout", + "codeSuggestionLayoutModalHint": "Layout for the draft comment created from an AI suggestion. Mark where the body goes with <SUGGESTIONS>; add line breaks, sections or extra content freely. Leave empty to use the default layout shown as placeholder.", + "codeSuggestionLayoutTitle": "Code suggestion layout", + "codeSuggestionLayoutVars": "Placeholders: <TITLE> AI-suggestion label · <SUGGESTIONS> suggestion body · <HOME> project site · <PR> current PR link · <MODEL> current model name. With <SUGGESTIONS> the template is the full comment body; otherwise the whole string is a prefix before the body.", + "codeSuggestionSet": "Configured", + "codeSuggestionSpecHint": "Extra instructions injected into /improve, /review and /ask to shape how each suggestion is written (e.g. Problem / Analysis / Suggestion). A soft guideline the model generally follows.", + "codeSuggestionSpecLabel": "Code suggestion spec", + "codeSuggestionSpecModalHint": "Free-text guidance appended to the model's instructions when generating code suggestions. Leave empty to use pr-agent's default output.", + "codeSuggestionSpecPlaceholder": "Structure every suggestion as three sections — Problem, Analysis, Suggestion — each on its own line.", + "codeSuggestionSpecTitle": "Code suggestion spec", "concurrencyHint": "Number of review tasks running at once, 1–8. Higher concurrency consumes tokens faster.", "concurrencySliderAria": "Review concurrency level", "concurrencyTitle": "Review Concurrency", @@ -859,6 +871,7 @@ "updateAvailableLabel": "↑ New version v{{version}} · Go to download", "updateDisabledHint": "Update checking is disabled in config (config.yaml `update.check_enabled`)", "updateDisabledTitle": "Update checking is disabled in config", + "website": "Website", "workDirTitle": "Working Directory" }, "sidebar": { diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index dcefaaef..8f0dfc96 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -43,7 +43,7 @@ "verdictManualReview": "手動レビュー推奨", "verdictNeedsWork": "要修正" }, - "aiSuggestionPrefix": "[AI 提案]", + "aiSuggestionLabel": "AI 提案", "anchorJumpTitle": "コードの該当行へジャンプ", "askArgQuestion": "質問", "askNeedsQuestion": "/ask には質問の入力が必要です", @@ -712,6 +712,18 @@ "checkUpdateTitle": "GitHub で最新バージョンを確認(検出のみ、自動インストールなし)", "checking": "確認中…", "cliExperimentalHint": "ローカル CLI プロバイダーはローカルのコマンドラインツールに依存し、その挙動はツールのバージョン変更で変わる可能性があります。安定性・継続的な利用は保証されません。", + "codeSuggestionDefault": "デフォルト", + "codeSuggestionLayoutHint": "AI 提案から作成する下書きコメント全体の確定的な Markdown レイアウト。プレースホルダーは常に埋め込まれます。", + "codeSuggestionLayoutLabel": "コード提案レイアウト", + "codeSuggestionLayoutModalHint": "AI 提案から作成する下書きコメント全体のレイアウト。<SUGGESTIONS> で本文の位置を指定し、改行・段落・その他の内容を自由に追加できます。空欄の場合はプレースホルダーに表示された既定のレイアウトを使用します。", + "codeSuggestionLayoutTitle": "コード提案レイアウト", + "codeSuggestionLayoutVars": "プレースホルダー: <TITLE> AI 提案ラベル · <SUGGESTIONS> 提案本文 · <HOME> プロジェクトサイト · <PR> 現在の PR リンク · <MODEL> 現在のモデル名。テンプレートに <SUGGESTIONS> があれば本文全体、なければ全体が本文の前に付くプレフィックスになります。", + "codeSuggestionSet": "設定済み", + "codeSuggestionSpecHint": "/improve・/review・/ask に注入する追加指示で、各コード提案の書き方(例: 問題 / 分析 / 提案)を規定します。ソフトな制約で、モデルは概ね従います。", + "codeSuggestionSpecLabel": "コード提案仕様", + "codeSuggestionSpecModalHint": "コード提案の生成時にモデル指示へ追記する自由記述の仕様。空欄の場合は pr-agent の既定出力を使用します。", + "codeSuggestionSpecPlaceholder": "すべての提案を 問題・分析・提案 の 3 セクションに分け、それぞれ 1 行にする。", + "codeSuggestionSpecTitle": "コード提案仕様", "concurrencyHint": "同時に実行するレビュータスク数、1〜8。同時実行数が多いほどトークン消費が速くなります。", "concurrencySliderAria": "レビュー同時実行数のレベル", "concurrencyTitle": "レビュー同時実行数", @@ -842,6 +854,7 @@ "updateAvailableLabel": "↑ 新しいバージョン v{{version}} · ダウンロードへ", "updateDisabledHint": "アップデートの確認は設定で無効化されています(config.yaml `update.check_enabled`)", "updateDisabledTitle": "アップデートの確認は設定で無効化されています", + "website": "公式サイト", "workDirTitle": "作業ディレクトリ" }, "sidebar": { diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index 2b7e955b..ba017cdc 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -43,7 +43,7 @@ "verdictManualReview": "建议人工复核", "verdictNeedsWork": "建议修改" }, - "aiSuggestionPrefix": "[AI 建议]", + "aiSuggestionLabel": "AI 建议", "anchorJumpTitle": "跳转到代码对应行", "askArgQuestion": "问题", "askNeedsQuestion": "/ask 需要输入问题内容", @@ -712,6 +712,18 @@ "checkUpdateTitle": "查 GitHub 最新版本(仅检测,不自动安装)", "checking": "检查中…", "cliExperimentalHint": "本地 CLI provider 依赖本机命令行工具,其行为可能随工具版本变更,稳定性与持续可用性不作保证。", + "codeSuggestionDefault": "默认", + "codeSuggestionLayoutHint": "AI 建议转为评审草稿评论时整段评论的确定性 Markdown 排版;占位符始终确定性填充。", + "codeSuggestionLayoutLabel": "代码建议排版", + "codeSuggestionLayoutModalHint": "由 AI 建议生成的草稿评论的整体排版。用 <SUGGESTIONS> 标记正文位置,可自由换行、分段或添加其他内容。留空则使用占位提示所示的默认排版。", + "codeSuggestionLayoutTitle": "代码建议排版", + "codeSuggestionLayoutVars": "占位符:<TITLE> AI 建议文案 · <SUGGESTIONS> 建议正文 · <HOME> 项目官网 · <PR> 当前 PR 链接 · <MODEL> 当前模型名。模板含 <SUGGESTIONS> 时作为完整评论正文,否则整段作为前缀拼在正文之前。", + "codeSuggestionSet": "已配置", + "codeSuggestionSpecHint": "注入 /improve、/review、/ask 的额外指令,规范每条代码建议的写法(如 问题 / 分析 / 建议 分段)。软约束,模型通常会遵守。", + "codeSuggestionSpecLabel": "代码建议规范", + "codeSuggestionSpecModalHint": "生成代码建议时附加到模型指令中的自由文本规范。留空则使用 pr-agent 的默认输出。", + "codeSuggestionSpecPlaceholder": "每条建议按三段组织:问题、分析、建议,各占一行。", + "codeSuggestionSpecTitle": "代码建议规范", "concurrencyHint": "同时执行的评审任务数,1~8。并发越高,token 消耗越快。", "concurrencySliderAria": "评审任务并发数档位", "concurrencyTitle": "评审任务并发", @@ -842,6 +854,7 @@ "updateAvailableLabel": "↑ 新版本 v{{version}} · 前往下载", "updateDisabledHint": "更新检测已在配置中关闭(config.yaml `update.check_enabled`)", "updateDisabledTitle": "更新检测已在配置中关闭", + "website": "官网", "workDirTitle": "工作目录" }, "sidebar": { diff --git a/apps/desktop/src/renderer/src/lib/editor-font.ts b/apps/desktop/src/renderer/src/lib/editor-font.ts index b447bcc9..269d54f1 100644 --- a/apps/desktop/src/renderer/src/lib/editor-font.ts +++ b/apps/desktop/src/renderer/src/lib/editor-font.ts @@ -1,8 +1,8 @@ -// macOS 下 Monaco 的字号视觉上比 Windows 偏大(系统字体渲染差异),同样的 px 值在 mac -// 看着更大。统一在 mac 上把编辑器字号缩小一号,让两端体验接近。 +// On macOS Monaco's font size looks visually larger than on Windows (system font rendering differences); the same +// px value looks bigger on mac. Uniformly shrink the editor font size by one on mac to bring both platforms closer. export const IS_MAC = navigator.platform.toLowerCase().includes('mac'); -/** 按平台校正 Monaco 字号:mac 减 1px,其他平台原值。 */ +/** Correct Monaco font size per platform: mac minus 1px, original value on other platforms. */ export function editorFontSize(base: number): number { return IS_MAC ? base - 1 : base; } diff --git a/apps/desktop/src/renderer/src/lib/markdown.ts b/apps/desktop/src/renderer/src/lib/markdown.ts index 9bcb2cc2..db9afb94 100644 --- a/apps/desktop/src/renderer/src/lib/markdown.ts +++ b/apps/desktop/src/renderer/src/lib/markdown.ts @@ -1,9 +1,10 @@ -// 远端 markdown(PR 描述 / 评论)里常含原始 HTML —— 典型如 Qodo / pr-agent 机器人用 -// <details> 折叠、<table>、<picture>/<img>、<sub>/<sup> 等 GitHub 风格 HTML。react-markdown -// 默认会丢弃原始 HTML,导致这类评论显示残缺。这里用 rehype-raw 解析 HTML,再用 rehype-sanitize -// 按白名单过滤(剔除 <script>、on* 事件、javascript: 链接等),既能渲染又不引入 XSS。 +// Remote markdown (PR descriptions / comments) often contains raw HTML — typically GitHub-style HTML like Qodo / +// pr-agent bots' <details> folds, <table>, <picture>/<img>, <sub>/<sup>, etc. react-markdown drops raw HTML by +// default, leaving such comments rendered incompletely. Here rehype-raw parses the HTML, then rehype-sanitize +// filters it by an allowlist (stripping <script>, on* events, javascript: links, etc.), rendering it without +// introducing XSS. // -// 仅对**远端来源**的 markdown 启用(评论、PR 描述);本地 / AI 生成的内容无需放开 HTML。 +// Enabled only for **remote-sourced** markdown (comments, PR descriptions); local / AI-generated content needs no HTML. import type { Options } from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; @@ -12,13 +13,13 @@ function extend<T>(list: readonly T[] | null | undefined, extra: readonly T[]): return Array.from(new Set<T>([...(list ?? []), ...extra])); } -// 在 rehype-sanitize 的 GitHub 默认白名单基础上,补齐 Qodo 等机器人常用但默认未必放开的标签/属性。 +// On top of rehype-sanitize's GitHub default allowlist, add tags/attributes commonly used by bots like Qodo but not necessarily allowed by default. const schema = { ...defaultSchema, - // 放行 Bitbucket 评论的 `attachment:<repoId>/<id>` 内部协议(内嵌图片/附件引用)。 - // 默认 protocols 白名单 src/href 只许 http/https,会在 react-markdown 的 urlTransform - // 之前就把 attachment: 的 src/href 整个剥掉 → img 收不到 src(BitbucketImage 不触发)、 - // a 收不到 href(渲染成裸文字)。两条附件渲染路径都被卡死,故在此显式放行 attachment 协议。 + // Allow Bitbucket comments' `attachment:<repoId>/<id>` internal protocol (inline image/attachment references). + // The default protocols allowlist only permits http/https for src/href, and would strip attachment:'s src/href + // entirely before react-markdown's urlTransform → img gets no src (BitbucketImage doesn't fire), a gets no href + // (rendered as bare text). Both attachment render paths are dead-ended, so explicitly allow the attachment protocol here. protocols: { ...defaultSchema.protocols, src: extend(defaultSchema.protocols?.src, ['attachment']), @@ -47,7 +48,7 @@ const schema = { }, }; -/** 远端 markdown 的 rehype 插件链:解析原始 HTML → 按白名单消毒。 */ +/** rehype plugin chain for remote markdown: parse raw HTML → sanitize by allowlist. */ export const REMOTE_REHYPE_PLUGINS: NonNullable<Options['rehypePlugins']> = [ rehypeRaw, [rehypeSanitize, schema], diff --git a/apps/desktop/src/renderer/src/lib/monaco-setup.ts b/apps/desktop/src/renderer/src/lib/monaco-setup.ts index 7a3de932..811209ff 100644 --- a/apps/desktop/src/renderer/src/lib/monaco-setup.ts +++ b/apps/desktop/src/renderer/src/lib/monaco-setup.ts @@ -1,23 +1,24 @@ -// 让 @monaco-editor/react 使用本地 monaco-editor 包,而不是从 CDN 拉 loader.js。 -// 配合 CSP 严格化(无 cdn.jsdelivr.net 例外)。 +// Make @monaco-editor/react use the local monaco-editor package instead of pulling loader.js from a CDN. +// Pairs with the strict CSP (no cdn.jsdelivr.net exception). // -// 仅注册 base editor worker,markdown / 通用 diff 用不到 JSON/CSS/HTML/TS language worker。 -// M1+ 真正要做语法高亮时再按需 import 对应 language worker。 +// Only register the base editor worker; markdown / generic diff don't need the JSON/CSS/HTML/TS language workers. +// When M1+ actually needs syntax highlighting, import the corresponding language worker on demand. import * as monaco from 'monaco-editor'; import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import { loader } from '@monaco-editor/react'; -// 4 个「带 worker 后端语言服务」的 contribution 子模块(editor.main 已加载,这里再引一次只为 -// 取其具名导出的 *Defaults;ES module 单例不会重复执行)。它们的运行期 JS 具名导出 -// typescriptDefaults / jsonDefaults / cssDefaults … 但 .d.ts 误为 `export {}`(monaco 0.55 ESM -// 打包缺陷),故按 namespace 引入、下方经 unknown 转成已知形状取用,不引入 any。 +// The 4 contribution submodules for "worker-backed language services" (editor.main already loaded them; importing +// again here is only to grab their named-exported *Defaults; ES module singletons won't re-execute). Their runtime +// JS named-exports typescriptDefaults / jsonDefaults / cssDefaults … but the .d.ts is wrongly `export {}` (monaco +// 0.55 ESM bundling defect), so import as namespace and cast via unknown below into the known shapes, without any. import * as tsLang from 'monaco-editor/esm/vs/language/typescript/monaco.contribution.js'; import * as jsonLang from 'monaco-editor/esm/vs/language/json/monaco.contribution.js'; import * as cssLang from 'monaco-editor/esm/vs/language/css/monaco.contribution.js'; import * as htmlLang from 'monaco-editor/esm/vs/language/html/monaco.contribution.js'; -// 第三方编辑器主题(IStandaloneThemeData 形状,vendored 自 monaco-themes,见 editor-themes/NOTICE.md), -// 下方经 defineTheme 注册供选择。id 与 @meebox/shared EDITOR_THEME_OPTIONS 对齐;vs / vs-dark / hc-* -// 为 Monaco 内置、无需注册。就地内置而非 npm 依赖:monaco-themes 的 exports 未暴露 ./themes/* 子路径。 +// Third-party editor themes (IStandaloneThemeData shape, vendored from monaco-themes, see editor-themes/NOTICE.md), +// registered via defineTheme below for selection. ids align with @meebox/shared EDITOR_THEME_OPTIONS; vs / vs-dark / +// hc-* are Monaco built-ins, no registration needed. Vendored in place rather than an npm dependency: monaco-themes' +// exports don't expose the ./themes/* subpath. import githubLight from './editor-themes/github-light.json'; import githubDark from './editor-themes/github-dark.json'; import monokai from './editor-themes/monokai.json'; @@ -30,11 +31,11 @@ import solarizedLight from './editor-themes/solarized-light.json'; import solarizedDark from './editor-themes/solarized-dark.json'; import cobalt2 from './editor-themes/cobalt2.json'; import oceanicNext from './editor-themes/oceanic-next.json'; -// VS Code 内置 2026 默认主题(转换自 microsoft/vscode theme-defaults,已解析 include 链并转为 Monaco 形状)。 +// VS Code built-in 2026 default themes (converted from microsoft/vscode theme-defaults, include chain resolved and converted to Monaco shape). import dark2026 from './editor-themes/dark-2026.json'; import light2026 from './editor-themes/light-2026.json'; -// Vite 的 ?worker import 返回一个可 new 的 Worker 构造类。 +// Vite's ?worker import returns a newable Worker constructor class. self.MonacoEnvironment = { getWorker() { return new editorWorker(); @@ -43,8 +44,8 @@ self.MonacoEnvironment = { loader.config({ monaco }); -// 注册第三方编辑器主题(id → 主题数据)。Monaco 内置 vs / vs-dark / hc-* 不在此列。JSON 的 base -// 字段类型为 string,与 IStandaloneThemeData 的字面量联合不完全兼容,经 unknown 转换取用。 +// Register third-party editor themes (id → theme data). Monaco built-in vs / vs-dark / hc-* aren't in this list. +// The JSON's base field is typed string, not fully compatible with IStandaloneThemeData's literal union, so cast via unknown. const CUSTOM_EDITOR_THEMES: ReadonlyArray<readonly [string, unknown]> = [ ['github-light', githubLight], ['github-dark', githubDark], @@ -66,9 +67,10 @@ for (const [id, data] of CUSTOM_EDITOR_THEMES) { } /** - * 【SPIKE】按主题 id 取其 base + colors,供「GUI chrome 跟随编辑器主题」验证从中提取 background / - * foreground / selection 等 base 色。第三方 monaco-themes 仅带极少数 colors(editor.* 几个键), - * 内置 vs / vs-dark / hc-* 无 JSON、在此补最小已知色。验证完毕若不采纳可整体移除。 + * [SPIKE] Get a theme's base + colors by id, for the "GUI chrome follows editor theme" experiment to extract base + * colors like background / foreground / selection. Third-party monaco-themes carry only a handful of colors (a few + * editor.* keys); built-in vs / vs-dark / hc-* have no JSON, so supply minimal known colors here. Removable + * wholesale if not adopted after the experiment. */ export interface EditorThemeColorData { base: string; @@ -119,24 +121,26 @@ export function getEditorThemeColors(id: string): EditorThemeColorData | null { } /** - * 关掉 4 个「带 worker 后端语言服务」的语言族的全部特性:typescript/javascript(ts.worker)、 - * json(json.worker)、css/scss/less(css.worker)、html/handlebars/razor(html.worker)。 + * Turn off all features of the 4 "worker-backed language service" language families: typescript/javascript + * (ts.worker), json (json.worker), css/scss/less (css.worker), html/handlebars/razor (html.worker). * - * 本应用只用 Monaco 做只读 diff + 语法着色(着色走 monarch tokenizer,与语言服务无关),不需要 - * 补全 / 悬浮 / 符号 / 诊断 / 格式化等;而这些特性会向对应 *.worker 发 RPC(getNavigationTree / - * getSyntacticDiagnostics / doValidation …)。但我们只注册了 base editor.worker(见上 getWorker) - * → 这些方法找不到 handler,抛 `Missing requestHandler or method: …`。 + * This app only uses Monaco for read-only diff + syntax coloring (coloring goes through the monarch tokenizer, + * unrelated to language services), and needs no completion / hover / symbols / diagnostics / formatting, etc.; + * those features send RPCs to the corresponding *.worker (getNavigationTree / getSyntacticDiagnostics / + * doValidation …). But we only registered the base editor.worker (see getWorker above) → these methods find no + * handler and throw `Missing requestHandler or method: …`. * - * 传空 ModeConfiguration(各字段均 optional,缺省即不注册对应 provider)→ 这些 provider 不再注册、 - * 不再发 RPC,从源头消除整族报错(下方 window 兜底退化为纯保险)。其余 80+ 语言是 monarch - * tokenizer 纯着色、无 worker 后端,不受影响也无需处理。 + * Passing an empty ModeConfiguration (each field is optional, absent = don't register that provider) → these + * providers are no longer registered, no longer send RPCs, eliminating the whole family of errors at the source + * (the window fallback below degrades to pure insurance). The other 80+ languages are pure monarch tokenizer + * coloring with no worker backend, unaffected and needing no handling. */ interface LangServiceDefaults { - // 传空 ModeConfiguration(各字段均 optional,缺省即不注册对应 provider)= 关掉全部特性 + // Passing an empty ModeConfiguration (each field is optional, absent = don't register that provider) = turn off all features setModeConfiguration(modeConfiguration: object): void; } -// 运行期具名导出存在(见各 contribution.js 的 export),但其 .d.ts 误为 `export {}`,故经 -// unknown 转成已知 *Defaults 形状取用(不引入 any);名字缺失时 filter 兜底,未来 monaco 改名不崩。 +// The runtime named exports exist (see each contribution.js's export), but their .d.ts is wrongly `export {}`, so +// cast via unknown into the known *Defaults shape (without any); filter as fallback when a name is missing, so a future monaco rename won't crash. const defaultsOf = (mod: unknown, names: readonly string[]): LangServiceDefaults[] => names .map((n) => (mod as Record<string, LangServiceDefaults | undefined>)[n]) @@ -152,18 +156,19 @@ for (const d of [ } /** - * Monaco 在 DiffEditor 快速切换文件时,会出现 model 已 dispose 但 widget 还在 - * 收尾的竞态;以及"内置 ts/js language contribution 主动询问 inlayHints / - * quickInfo / navigationTree / ...,editor.worker 没实现对应方法"的一系列运行时 - * 报错。两类都不影响渲染,但污染 console 让用户以为应用挂了。 + * When Monaco quickly switches files in DiffEditor, a race occurs where the model is already disposed but the + * widget is still finishing up; plus a series of runtime errors from "the built-in ts/js language contribution + * proactively asking for inlayHints / quickInfo / navigationTree / ..., which editor.worker doesn't implement". + * Neither affects rendering, but they pollute the console and make users think the app crashed. * - * 仅按消息前缀黑名单吞掉,不动其他真实业务错误。 + * Only swallow by a message-prefix blacklist, leaving other real business errors untouched. * - * `Missing requestHandler or method:` 整族源于 worker RPC 找不到 handler(已在上方从源头 - * 关闭对应语言服务,这里留作双保险);`TextModel got disposed …` 是 DiffEditor 切换 model - * 的 dispose 竞态。两类都是 Monaco 上游已知问题、不影响渲染、应用侧无法根除 → **默认静默忽略** - * (作为已知问题)。需诊断时在 devtools 执行 `localStorage.setItem('meebox.monacoDebug','1')` - * 再刷新,即可看到被吞的报错明细。 + * The whole `Missing requestHandler or method:` family stems from worker RPCs finding no handler (already turned + * off at the source above by disabling the corresponding language services; kept here as double insurance); + * `TextModel got disposed …` is the DiffEditor model-switch dispose race. Both are Monaco upstream known issues, + * don't affect rendering, and can't be eradicated on the app side → **silently ignored by default** (as known + * issues). To diagnose, run `localStorage.setItem('meebox.monacoDebug','1')` in devtools and refresh to see the + * details of the swallowed errors. */ const MONACO_DEBUG = (() => { try { @@ -187,7 +192,7 @@ function isBenignMonacoError(msg: unknown): boolean { window.addEventListener('error', (e) => { if (isBenignMonacoError(e.message) || isBenignMonacoError(e.error?.message)) { e.preventDefault(); - // 默认静默(已知问题);开 meebox.monacoDebug 才打一行便于诊断 + // Silent by default (known issue); only log a line when meebox.monacoDebug is on, for diagnostics if (MONACO_DEBUG) console.warn('[monaco] suppressed benign error:', e.message); } }); diff --git a/apps/desktop/src/renderer/src/lib/remark-emoji.ts b/apps/desktop/src/renderer/src/lib/remark-emoji.ts index 3f3661cb..9fe3a64f 100644 --- a/apps/desktop/src/renderer/src/lib/remark-emoji.ts +++ b/apps/desktop/src/renderer/src/lib/remark-emoji.ts @@ -1,22 +1,23 @@ import { reactionCodeToEmoji } from '@meebox/shared'; -/** mdast 节点的最小结构(仅取本插件用到的字段,避免引入 mdast 类型依赖)。 */ +/** Minimal shape of an mdast node (only the fields this plugin uses, avoiding an mdast type dependency). */ interface MdNode { type: string; value?: string; children?: MdNode[]; } -// `:shortcode:`:字母 / 数字 / _ / + / -(覆盖 gemoji 风格名,如 tada / +1 / heart_eyes)。 +// `:shortcode:`: letters / digits / _ / + / - (covers gemoji-style names, like tada / +1 / heart_eyes). const SHORTCODE = /:([a-z0-9_+-]{1,40}):/gi; /** - * remark 插件:把评论正文里的 `:shortcode:`(如 `:tada:` → 🎉)替换为对应 emoji 字符,复用内置精选集 - * ({@link reactionCodeToEmoji},含 `+1`/`-1` 别名)。 + * remark plugin: replace `:shortcode:` in comment bodies (like `:tada:` → 🎉) with the corresponding emoji + * character, reusing the built-in curated set ({@link reactionCodeToEmoji}, including `+1`/`-1` aliases). * - * 只改写 mdast `text` 节点——代码(inlineCode / code 块)的内容不在 text 节点里,故天然跳过, - * `def f(x):` / `http://h:8080` 等不会被误伤;内置集外的未知 shortcode 原样保留(部分渲染、与精选集 - * 路线一致)。emoji 是纯文本,直接就地替换字符串、无需拆分节点。 + * Only rewrites mdast `text` nodes — the contents of code (inlineCode / code blocks) aren't in text nodes, so they're + * naturally skipped, and `def f(x):` / `http://h:8080`, etc. won't be hit by mistake; unknown shortcodes outside the + * built-in set are kept as-is (partial rendering, consistent with the curated-set approach). emoji are plain text, + * so replace the string in place directly, no need to split nodes. */ export function remarkEmojiShortcodes() { return (tree: MdNode): void => { diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index f6f5c598..7b00c3ca 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -2,18 +2,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { addCollection } from '@iconify/react'; import materialIconTheme from '@iconify-json/material-icon-theme/icons.json'; -// 注意:Monaco(~10MB)不在入口加载。monaco-setup 已移进 DiffView / InlineCodeContext -// 两个懒模块,仅在首次看 diff / 行内代码上下文时才拉取,避免阻塞窗口首帧。 -// i18n 必须在 App 之前 import:副作用里同步 init i18next,保证首帧渲染前 t() 可用。 +// Note: Monaco (~10MB) is not loaded at the entry. monaco-setup has been moved into the two +// lazy modules DiffView / InlineCodeContext, pulled only on first viewing a diff / inline code context, avoiding blocking the window's first frame. +// i18n must be imported before App: its side effect synchronously inits i18next, ensuring t() is available before the first-frame render. import './i18n'; -// theme 同样在 App 之前 import:副作用里按 localStorage 缓存同步定下首帧主题(写 data-theme), -// 避免浅色用户启动先闪一帧深色。 +// theme is likewise imported before App: its side effect synchronously pins the first-frame theme from the localStorage cache (writing data-theme), +// avoiding a light-mode user flashing a frame of dark on startup. import './theme'; import App from './App'; import './App.scss'; -// 预加载 PKief Material Icon Theme,让 <Icon icon="material-icon-theme:..." /> -// 走 bundle 而非默认的 api.iconify.design CDN(CSP 不允许) +// Preload the PKief Material Icon Theme so that <Icon icon="material-icon-theme:..." /> +// goes through the bundle instead of the default api.iconify.design CDN (disallowed by CSP) addCollection(materialIconTheme); const container = document.getElementById('root'); diff --git a/apps/desktop/src/renderer/src/stores/chat-run-store.ts b/apps/desktop/src/renderer/src/stores/chat-run-store.ts index 34cae14d..8cc24d34 100644 --- a/apps/desktop/src/renderer/src/stores/chat-run-store.ts +++ b/apps/desktop/src/renderer/src/stores/chat-run-store.ts @@ -3,23 +3,27 @@ import type { PragentRunInfo } from '@meebox/ipc'; import { invoke, subscribe } from '../api'; /** - * 跨 ChatPane 实例的 pr-agent run 队列 + 实时 stdout 缓存。放模块级 store 而不是 - * React 状态,原因:用户切 PR 时 ChatPane 会被卸载重建,但 pr-agent 在主进程仍 - * 在跑、还在持续发 `pragent:runProgress` 事件 —— 把 (active, waiting, 已收到的 - * lines) 提到 React 树之上,新挂载的 ChatPane 能立刻读到正确状态。 + * Cross-ChatPane-instance pr-agent run queue + live stdout cache. Kept in a + * module-level store rather than React state because: switching PRs unmounts and + * rebuilds ChatPane, but pr-agent keeps running in the main process and keeps + * emitting `pragent:runProgress` events — lifting (active, waiting, received + * lines) above the React tree lets a freshly mounted ChatPane read the correct + * state immediately. * - * 数据 immutable 替换 (linesByRunId 用全新 Map / 数组,waiting 用新数组),配合 - * useSyncExternalStore 的 identity 检查触发 re-render。 + * Data is replaced immutably (linesByRunId uses a brand-new Map / array, waiting + * a new array), pairing with useSyncExternalStore's identity check to trigger a + * re-render. */ export interface ChatRunStoreState { - /** 当前并发运行中的 run 列表(长度 ≤ max_concurrency)。空数组表示当前无 run 在跑 */ + /** List of runs currently running concurrently (length ≤ max_concurrency). Empty array means no run is currently running */ active: ReadonlyArray<PragentRunInfo>; - /** 等待执行的 run 队列 (FIFO,前面的先跑)。空数组表示无人排队 */ + /** Queue of runs waiting to execute (FIFO, earlier ones run first). Empty array means nobody is queued */ waiting: ReadonlyArray<PragentRunInfo>; - /** 各 run 的实时 stdout 行缓存。键 = runId;run 完成后保留直到 clearLines 调用 */ + /** Live stdout line cache per run. Key = runId; retained after a run completes until clearLines is called */ linesByRunId: ReadonlyMap<string, ReadonlyArray<string>>; - /** 有编排 Agent 运行中(思考或派发工具)的 PR localId 集合——含纯思考阶段(无活跃工具 run), - * 来自主进程 `agent:runningChanged`。PR 列表项「执行中」指示在工具队列之外再并入此集合。 */ + /** Set of PR localIds with an orchestrating Agent running (thinking or dispatching tools) — includes the pure-thinking + * phase (no active tool run), from the main process's `agent:runningChanged`. The PR list item's "running" indicator + * merges in this set on top of the tool queue. */ agentPrs: ReadonlyArray<string>; } @@ -50,11 +54,11 @@ function setQueue( waiting: ReadonlyArray<PragentRunInfo>, ): void { const sameActive = sameRunList(state.active, active); - // 浅相等优化:active / waiting 的 runId(+startedAt) 序列都没变 → 不通知,避免无谓 re-render + // Shallow-equality optimization: if the runId(+startedAt) sequences of active / waiting are unchanged → don't notify, avoiding pointless re-renders if (sameActive && sameRunList(state.waiting, waiting)) return; - // 全局回收 stdout 缓存:离开 active 的 run(成功/失败/取消完成)清掉其 lines。 - // 放在 store 层而非 ChatPane —— 不依赖用户当前打开哪个 PR,避免非当前 PR 上完成的 - // run 的 lines 长期驻留。删除合并进同一次 state 更新,只 notify 一次。 + // Globally reclaim stdout cache: clear the lines of any run that left active (completed as success/failure/cancel). + // Placed at the store layer rather than ChatPane — independent of which PR the user currently has open, avoiding + // long-lived lines for runs completed on a non-current PR. The deletions merge into the same state update, notifying only once. let linesByRunId = state.linesByRunId; if (!sameActive) { const nextActiveIds = new Set(active.map((r) => r.runId)); @@ -70,7 +74,7 @@ function setQueue( notify(); } -/** 集合相等(顺序无关):长度相同且每个 id 都在旧集合里。避免广播顺序差异引发无谓 re-render。 */ +/** Set equality (order-independent): same length and every id is in the old set. Avoids pointless re-renders from broadcast ordering differences. */ function sameIdSet(a: ReadonlyArray<string>, b: ReadonlyArray<string>): boolean { if (a.length !== b.length) return false; const sa = new Set(a); @@ -118,12 +122,12 @@ export function useChatRunStore(): ChatRunStoreState { } /** - * 把 IPC → store 的数据流接起来。在 App 顶层 useEffect 调用一次。 - * - 启动时拉一次队列快照兜底 (window reload / preload 重连后仍能恢复 UI) - * - 订阅 queueChanged 事件,更新 store.active + store.waiting - * - 订阅 runProgress 事件,把 line 追加到对应 runId 的 lines 缓存 + * Wire up the IPC → store data flow. Call once in a top-level App useEffect. + * - Pull a queue snapshot once at startup as a fallback (UI can still recover after window reload / preload reconnect) + * - Subscribe to queueChanged events, updating store.active + store.waiting + * - Subscribe to runProgress events, appending each line to the lines cache of the corresponding runId * - * 返回 cleanup 函数,App unmount 时一并取消订阅。 + * Returns a cleanup function that unsubscribes everything on App unmount. */ export function wireChatRunStore(): () => void { let cancelled = false; @@ -132,7 +136,7 @@ export function wireChatRunStore(): () => void { const snap = await invoke('pragent:queue', undefined); if (!cancelled) chatRunStore.setQueue(snap.active, snap.waiting); } catch { - // 启动阶段拿不到队列也不致命,等事件兜 + // Failing to get the queue at startup is not fatal; events will fill in as a fallback } })(); const unsubQueue = subscribe('pragent:queueChanged', (ev) => { @@ -141,7 +145,7 @@ export function wireChatRunStore(): () => void { const unsubProgress = subscribe('pragent:runProgress', (ev) => { chatRunStore.appendLine(ev.runId, ev.line); }); - // 编排 Agent 运行中(含纯思考阶段)的 PR 集合:手动 run/ask 与 AutoPilot 后台评审一并计入。 + // Set of PRs with an orchestrating Agent running (including the pure-thinking phase): manual run/ask and AutoPilot background review are both counted in. const unsubAgentRunning = subscribe('agent:runningChanged', (ev) => { chatRunStore.setAgentPrs(ev.prLocalIds); }); diff --git a/apps/desktop/src/renderer/src/stores/drafts-store.ts b/apps/desktop/src/renderer/src/stores/drafts-store.ts index 37fc5a57..d32b285b 100644 --- a/apps/desktop/src/renderer/src/stores/drafts-store.ts +++ b/apps/desktop/src/renderer/src/stores/drafts-store.ts @@ -3,20 +3,20 @@ import type { ReviewDraft } from '@meebox/shared'; import { invoke, subscribe } from '../api'; /** - * 跨组件共享的草稿池。每个 PR 的草稿独立存一份 (按 localId 维护),避免 PR 切换时 - * 把别 PR 的草稿洗掉。 + * Cross-component shared draft pool. Each PR's drafts are stored separately (keyed + * by localId), avoiding wiping another PR's drafts when switching PRs. * - * 数据流: - * main IPC `drafts:create/update/delete` → 写盘 + 广播 `drafts:changed` → - * 本 store 收到事件 → 调 `drafts:list` 重拉 → notify subscribers + * Data flow: + * main IPC `drafts:create/update/delete` → write to disk + broadcast `drafts:changed` → + * this store receives the event → calls `drafts:list` to re-pull → notify subscribers * - * ChatPane / DiffView 都通过 `useDraftsForPr(localId)` 读,跟 chatRunStore / - * repoSyncStore 同模。 + * ChatPane / DiffView both read via `useDraftsForPr(localId)`, following the same + * pattern as chatRunStore / repoSyncStore. */ export interface DraftsStoreState { - /** localId → 该 PR 的全部草稿。未拉过的 PR 缺 key (UI 第一次访问会触发 hydrate) */ + /** localId → all drafts of that PR. A PR not yet pulled is missing its key (the UI's first access triggers hydrate) */ byPr: ReadonlyMap<string, ReadonlyArray<ReviewDraft>>; - /** 正在 fetch 的 localId 集合,避免重复触发 */ + /** Set of localIds currently being fetched, to avoid duplicate triggers */ loading: ReadonlySet<string>; } @@ -50,7 +50,7 @@ async function hydrate(localId: string): Promise<void> { const list = await invoke('drafts:list', { localId }); setForPr(localId, list); } catch { - // 失败静默;UI 看到空草稿,重试由下次 hydrate 触发 + // Fail silently; the UI sees empty drafts, and a retry is triggered by the next hydrate setForPr(localId, []); } } @@ -63,9 +63,9 @@ export const draftsStore = { subscribers.delete(cb); }; }, - /** 强制重拉 (drafts:changed 事件触发) */ + /** Force a re-pull (triggered by the drafts:changed event) */ refresh: (localId: string): Promise<void> => hydrate(localId), - /** 首次访问时按需 hydrate;已 fetch 过 / 正在 fetch 不重复 */ + /** Hydrate on demand on first access; skip if already fetched / currently fetching */ ensureLoaded: (localId: string): void => { if (state.byPr.has(localId) || state.loading.has(localId)) return; void hydrate(localId); @@ -73,15 +73,16 @@ export const draftsStore = { }; /** - * 读取指定 PR 的草稿数组。首次访问触发后台 hydrate;hydrate 完成后通过 store - * subscription 自动 re-render。返回 null 表示还在 loading (UI 可显示加载占位)。 + * Read the draft array of a given PR. First access triggers a background hydrate; + * after hydrate completes it auto re-renders via the store subscription. Returns + * null to indicate still loading (the UI can show a loading placeholder). */ export function useDraftsForPr(localId: string | null | undefined): ReadonlyArray<ReviewDraft> | null { const snap = useSyncExternalStore(draftsStore.subscribe, draftsStore.getSnapshot); - // 首次访问按需 hydrate。**必须放 effect、绝不在 render 阶段触发**:ensureLoaded → - // markLoading → notify() 会同步通知所有订阅者,render 期调用即「渲染 A 组件时更新了 - // 同样订阅本 store 的 B 组件」,React 报 "Cannot update a component while rendering a - // different component"。effect 在 commit 后跑,notify 落在渲染之外,告警消除。 + // Hydrate on demand on first access. **Must go in an effect, never trigger during the render phase**: ensureLoaded → + // markLoading → notify() synchronously notifies all subscribers, and calling it during render means "updating component B + // that also subscribes to this store while rendering component A", for which React reports "Cannot update a component while + // rendering a different component". An effect runs after commit, so notify lands outside rendering and the warning is gone. useEffect(() => { if (localId) draftsStore.ensureLoaded(localId); }, [localId]); @@ -91,12 +92,12 @@ export function useDraftsForPr(localId: string | null | undefined): ReadonlyArra } /** - * 在 App 顶层 useEffect 调用一次。订阅 `drafts:changed` 事件,main 写盘后 - * renderer 自动同步。返回 cleanup 函数。 + * Call once in a top-level App useEffect. Subscribes to the `drafts:changed` event; + * after main writes to disk, the renderer auto-syncs. Returns a cleanup function. */ export function wireDraftsStore(): () => void { return subscribe('drafts:changed', (ev) => { - // 只重拉本地已有 (其他 PR 的草稿先不动,省 IPC 往返) + // Only re-pull what's already local (leave other PRs' drafts untouched, saving IPC round-trips) if (state.byPr.has(ev.localId) || state.loading.has(ev.localId)) { void draftsStore.refresh(ev.localId); } diff --git a/apps/desktop/src/renderer/src/stores/editor-appearance-store.ts b/apps/desktop/src/renderer/src/stores/editor-appearance-store.ts index b9600ea7..94ce061f 100644 --- a/apps/desktop/src/renderer/src/stores/editor-appearance-store.ts +++ b/apps/desktop/src/renderer/src/stores/editor-appearance-store.ts @@ -2,18 +2,19 @@ import { useSyncExternalStore } from 'react'; import type { EditorTheme } from '@meebox/shared'; /** - * 编辑器外观(Monaco 配色主题 + 等宽字体)的渲染层共享态。App 从 config.appearance 写入,深层的 Monaco - * 组件(DiffPane / InlineCodeContext)经 useEditorAppearance 读出 —— 避免逐层透传 props。 + * Renderer-layer shared state for editor appearance (Monaco color theme + monospace font). App writes it from + * config.appearance, and deep Monaco components (DiffPane / InlineCodeContext) read it via useEditorAppearance — + * avoiding threading props through every layer. * - * 与 selection-store 同模(模块级状态 + Set<subscriber> + useSyncExternalStore):纯本地、无 IPC、无 - * hydrate。持久化与写盘走 config(IPC config:setEditorAppearance),本 store 只承载「当前生效值」。 + * Same pattern as selection-store (module-level state + Set<subscriber> + useSyncExternalStore): purely local, no IPC, no + * hydrate. Persistence and disk writes go through config (IPC config:setEditorAppearance); this store only carries the "currently effective value". */ export interface EditorAppearanceState { - /** 编辑器配色主题偏好:'auto' 跟随 GUI 深 / 浅色,其余为具体 Monaco 主题名。 */ + /** Editor color theme preference: 'auto' follows the GUI's dark / light mode, otherwise a specific Monaco theme name. */ editorTheme: EditorTheme; - /** 等宽字体族(空 = 用内置 mono 字体栈)。 */ + /** Monospace font family (empty = use the built-in mono font stack). */ fontFamily: string; - /** 字号(px,未做平台微调的基准值)。 */ + /** Font size (px, the baseline value before any platform fine-tuning). */ fontSize: number; } @@ -34,7 +35,7 @@ const store = { }, }; -/** 写入当前编辑器外观(App 在 config 变化时调用)。各字段相等则跳过,避免无谓重渲。 */ +/** Write the current editor appearance (App calls this when config changes). Skips if all fields are equal, avoiding pointless re-renders. */ export function setEditorAppearance(next: EditorAppearanceState): void { if ( next.editorTheme === state.editorTheme && @@ -47,7 +48,7 @@ export function setEditorAppearance(next: EditorAppearanceState): void { notify(); } -/** 读当前编辑器外观(Monaco 组件用)。 */ +/** Read the current editor appearance (for Monaco components). */ export function useEditorAppearance(): EditorAppearanceState { return useSyncExternalStore(store.subscribe, store.getSnapshot); } diff --git a/apps/desktop/src/renderer/src/stores/finding-closures-store.ts b/apps/desktop/src/renderer/src/stores/finding-closures-store.ts index 792f8e09..d25f0fc2 100644 --- a/apps/desktop/src/renderer/src/stores/finding-closures-store.ts +++ b/apps/desktop/src/renderer/src/stores/finding-closures-store.ts @@ -3,10 +3,10 @@ import type { FindingClosure } from '@meebox/shared'; import { invoke, subscribe } from '../api'; /** - * 跨组件共享的 finding 关闭关系池(复评 /ask 取代/撤销原 finding 时建立)。每个 PR 独立一份,与 - * draftsStore 同模。数据流:main `findingClosures:create/delete` → 写盘 + 广播 `findingClosures:changed` - * → 本 store 重拉 → notify。`useFindingClosuresForPr(localId)` 供 FindingCard / RunResultView 读, - * 据 (runId, findingId) 反查某条 finding 是否已被复评关闭。 + * Cross-component shared finding-closure relation pool (established when a re-review /ask supersedes/revokes the original + * finding). One separate copy per PR, same pattern as draftsStore. Data flow: main `findingClosures:create/delete` → write + * to disk + broadcast `findingClosures:changed` → this store re-pulls → notify. `useFindingClosuresForPr(localId)` is read + * by FindingCard / RunResultView to look up, by (runId, findingId), whether a given finding has been closed by re-review. */ export interface FindingClosuresStoreState { byPr: ReadonlyMap<string, ReadonlyArray<FindingClosure>>; @@ -62,7 +62,7 @@ export const findingClosuresStore = { }, }; -/** 读取指定 PR 的关闭关系数组(null = loading)。首次访问触发后台 hydrate。 */ +/** Read the closure-relation array of a given PR (null = loading). First access triggers a background hydrate. */ export function useFindingClosuresForPr( localId: string | null | undefined, ): ReadonlyArray<FindingClosure> | null { @@ -70,7 +70,7 @@ export function useFindingClosuresForPr( findingClosuresStore.subscribe, findingClosuresStore.getSnapshot, ); - // 同 draftsStore:hydrate 放 effect,避免 render 期 notify 触发跨组件更新告警。 + // Same as draftsStore: put hydrate in an effect, avoiding a render-phase notify triggering the cross-component update warning. useEffect(() => { if (localId) findingClosuresStore.ensureLoaded(localId); }, [localId]); @@ -79,7 +79,7 @@ export function useFindingClosuresForPr( return snap.byPr.get(localId)!; } -/** App 顶层调用一次,订阅 `findingClosures:changed` 自动同步。 */ +/** Call once at the top level of App; subscribes to `findingClosures:changed` to auto-sync. */ export function wireFindingClosuresStore(): () => void { return subscribe('findingClosures:changed', (ev) => { if (state.byPr.has(ev.localId) || state.loading.has(ev.localId)) { diff --git a/apps/desktop/src/renderer/src/stores/repo-sync-store.ts b/apps/desktop/src/renderer/src/stores/repo-sync-store.ts index c8d95c58..7bf43802 100644 --- a/apps/desktop/src/renderer/src/stores/repo-sync-store.ts +++ b/apps/desktop/src/renderer/src/stores/repo-sync-store.ts @@ -3,30 +3,30 @@ import type { SyncProgressEvent } from '@meebox/shared'; import { subscribe } from '../api'; /** - * 跨组件共享的当前活动 repo sync 状态。 + * Cross-component shared state of the currently active repo sync. * - * 数据来源:main 的 `sync:progress` 事件流(每个 repo 的 start/progress/done/error)。 - * 一个 repo 一条记录:start → 进 active,progress 更新阶段+百分比,done/error 移出。 + * Data source: main's `sync:progress` event stream (each repo's start/progress/done/error). + * One record per repo: start → enters active, progress updates stage + percent, done/error removes it. * - * UI 用法:StatusBar 取 `getActive()` 第一条展示;没有任何 active 时 chip 隐藏不占位。 - * 同一时刻可能有多个 repo 排队 (RepoMirrorManager 已是全局单队列串行),store 都收着 - * 便于"还有 N 个在排队"扩展,第一版只展示首条。 + * UI usage: StatusBar displays the first entry from `getActive()`; when there is no active entry the chip is hidden and takes no space. + * Multiple repos may be queued at the same time (RepoMirrorManager is already a global single-queue serial), and the store keeps them all + * to allow a "N more queued" extension; the first version only displays the first entry. */ export interface RepoSyncState { - /** repoKey ("host/group/repo") → 当前阶段快照 */ + /** repoKey ("host/group/repo") → current stage snapshot */ active: ReadonlyMap<string, RepoSyncSnapshot>; } export interface RepoSyncSnapshot { - /** 来自 sync:progress.repo,"host/projectKey/repoSlug" */ + /** From sync:progress.repo, "host/projectKey/repoSlug" */ repo: string; - /** simple-git 阶段名(compressing / receiving / resolving / ...);start 时可能空 */ + /** simple-git stage name (compressing / receiving / resolving / ...); may be empty at start */ stage?: string; - /** 0-100;progress 阶段填,done 时一般已到 100 */ + /** 0-100; filled during the progress stage, usually already at 100 on done */ percent?: number; - /** 给 hover tooltip / 排障用 */ + /** For the hover tooltip / troubleshooting */ message?: string; - /** 进入 active 的时间戳,用于 UI 排序 / "停留 X 秒" 显示 */ + /** Timestamp of entering active, used for UI ordering / "held for X seconds" display */ startedAt: number; } @@ -67,7 +67,7 @@ export const repoSyncStore = { subscribers.delete(cb); }; }, - /** 测试 / 调试用:直接灌一条事件 */ + /** For testing / debugging: feed in an event directly */ handleEvent, }; @@ -76,8 +76,8 @@ export function useRepoSyncStore(): RepoSyncState { } /** - * 把 IPC sync:progress 事件接到 store。App 顶层 useEffect 调一次。 - * Date.now() 仅在事件发生时本地取,store 自身保持纯。 + * Wire the IPC sync:progress event to the store. Call once in a top-level App useEffect. + * Date.now() is read locally only when an event occurs, keeping the store itself pure. */ export function wireRepoSyncStore(): () => void { return subscribe('sync:progress', (ev) => { diff --git a/apps/desktop/src/renderer/src/stores/selection-store.ts b/apps/desktop/src/renderer/src/stores/selection-store.ts index bc9ae88e..644845da 100644 --- a/apps/desktop/src/renderer/src/stores/selection-store.ts +++ b/apps/desktop/src/renderer/src/stores/selection-store.ts @@ -1,41 +1,43 @@ import { useSyncExternalStore } from 'react'; /** - * 跨组件共享的「Diff 选区」池(渲染层内部态,不走 IPC)。DiffView 在用户于 Diff 里选中代码时写入, - * ChatPane / ChatInputBar 读出,用于把选中代码作为**隐式上下文**带进 agent/ask 提问。 + * Cross-component shared "Diff selection" pool (renderer-internal state, not via IPC). DiffView writes on + * user code selection in the Diff, ChatPane / ChatInputBar read it, to carry the selected code as **implicit + * context** into agent/ask questions. * - * 数据流: - * DiffView 监听 Monaco onDidChangeCursorSelection → set(选区) / clear() → - * notify → useDiffSelection 重渲染 → 输入栏角标更新 → - * 发送时(未忽略)formatReferencedContext(选区) → 作为 referencedContext 发给 main + * Data flow: + * DiffView listens to Monaco onDidChangeCursorSelection → set(selection) / clear() → + * notify → useDiffSelection re-renders → input bar badge updates → + * on send (not ignored) formatReferencedContext(selection) → sent to main as referencedContext * - * 与 drafts-store 同模(模块级状态 + Set<subscriber> + useSyncExternalStore),但纯本地、无 hydrate。 + * Same pattern as drafts-store (module-level state + Set<subscriber> + useSyncExternalStore), but purely local, no hydrate. */ export interface DiffSelection { - /** 选区归属 PR(防跨 PR 串台:useDiffSelection 不匹配当前 PR 时对外呈 null)。 */ + /** PR the selection belongs to (prevents cross-PR mixups: useDiffSelection presents null when it doesn't match the current PR). */ prLocalId: string; - /** 文件路径(head 侧用新路径,old 侧用基线路径)。 */ + /** File path (head side uses new path, old side uses baseline path). */ path: string; - /** 选区所在 Diff 子编辑器:old=基线(original) / new=变更(modified)。 */ + /** Diff sub-editor the selection is in: old=baseline(original) / new=changed(modified). */ side: 'old' | 'new'; - /** 起止行(含两端,1 基,即该侧显示文件行号)。 */ + /** Start/end lines (both ends inclusive, 1-based, i.e. the displayed file line numbers on that side). */ startLine: number; endLine: number; - /** 行数(endLine - startLine + 1)。指 head/old 主选区的行数,不含下方 removed 附带行。 */ + /** Line count (endLine - startLine + 1). Refers to the head/old primary selection line count, excluding the removed lines carried below. */ lineCount: number; - /** 选中文本快照(选区产生时即取,发送时直接用,无需回查 model)。 */ + /** Selected text snapshot (captured when the selection is produced, used directly on send, no need to re-query the model). */ text: string; /** - * 内联(统一)视图专属:head 选区跨到的删除/改动 hunk 的**基线侧**原始行(含真实代码)。统一视图下 - * 删除行是 Monaco view-zone、无法被光标选中,故据 getLineChanges() 映射、从 original model 取出,与 - * 所选 head 行一并引用——让删除内容也能「像添加行一样」被引用。side==='new' 且非并排视图时可能有。 + * Inline (unified) view only: the **baseline-side** original lines (with real code) of the deleted/changed hunk + * spanned by the head selection. In unified view the deleted lines are Monaco view-zones and can't be cursor-selected, + * so they are mapped via getLineChanges() and taken from the original model, referenced together with the selected + * head lines — letting deleted content also be referenced "like added lines". May be present when side==='new' and not side-by-side view. */ removed?: { startLine: number; endLine: number; text: string }; } interface SelectionStoreState { selection: DiffSelection | null; - /** 忽略态:用户点角标切到「不附带」,本条消息不带选区引用(角标仍显示,置灰 + eye-slash)。 */ + /** Ignored state: user clicked the badge to toggle "don't carry", this message carries no selection reference (badge still shows, greyed out + eye-slash). */ ignored: boolean; } @@ -54,18 +56,18 @@ export const selectionStore = { subscribers.delete(cb); }; }, - /** 写入新选区。每次新选区默认「附带」(ignored 复位为 false)。 */ + /** Write a new selection. Each new selection defaults to "carry" (ignored reset to false). */ set: (selection: DiffSelection): void => { state = { selection, ignored: false }; notify(); }, - /** 清空选区(选区塌缩 / 切 PR / 文件切换)。 */ + /** Clear the selection (selection collapse / PR switch / file switch). */ clear: (): void => { if (state.selection === null && !state.ignored) return; state = { selection: null, ignored: false }; notify(); }, - /** 切换忽略态(角标点击)。 */ + /** Toggle ignored state (badge click). */ toggleIgnored: (): void => { if (!state.selection) return; state = { ...state, ignored: !state.ignored }; @@ -74,8 +76,8 @@ export const selectionStore = { }; /** - * 读取「归属当前 PR」的选区快照。选区 prLocalId 与传入不符(切到别的 PR / 无 PR)→ 返回 null 选区, - * 避免把别 PR 的选区误带进本会话。 + * Read the selection snapshot "belonging to the current PR". If the selection's prLocalId doesn't match the passed + * value (switched to another PR / no PR) → returns a null selection, to avoid carrying another PR's selection into this session. */ export function useDiffSelection(prLocalId: string | null | undefined): { selection: DiffSelection | null; @@ -88,7 +90,7 @@ export function useDiffSelection(prLocalId: string | null | undefined): { return snap; } -/** 行范围标签:单行 `Lx`,多行 `Lx-Ly`。 */ +/** Line range label: single line `Lx`, multiple lines `Lx-Ly`. */ function rangeLabel(startLine: number, endLine: number): string { return startLine === endLine ? `L${String(startLine)}` @@ -96,10 +98,12 @@ function rangeLabel(startLine: number, endLine: number): string { } /** - * 把选区拼成自描述的引用块(路径 + 行范围 + 侧 + 代码围栏),渲染层拼一次作为 referencedContext 发出。 - * 两条注入路径(pragent /ask 与 planner)共用此串。用四个反引号围栏,避免选中代码内含三反引号时破栏。 + * Assemble the selection into a self-describing reference block (path + line range + side + code fence); the renderer + * assembles it once and sends it as referencedContext. Both injection paths (pragent /ask and planner) share this string. + * Uses four-backtick fences to avoid breaking the fence when the selected code contains triple backticks. * - * 内联视图带 removed 时分两块列出:所选 head 行 + 跨到的基线删除行,让删除内容也能被引用。 + * When the inline view carries removed, it lists two blocks: the selected head lines + the spanned baseline deleted lines, + * so the deleted content can also be referenced. */ export function formatReferencedContext(sel: DiffSelection): string { const sideLabel = sel.side === 'old' ? 'base' : 'head'; diff --git a/apps/desktop/src/renderer/src/styles/_mixins.scss b/apps/desktop/src/renderer/src/styles/_mixins.scss index 0635e18f..469e7a05 100644 --- a/apps/desktop/src/renderer/src/styles/_mixins.scss +++ b/apps/desktop/src/renderer/src/styles/_mixins.scss @@ -1,11 +1,13 @@ -// 跨文件复用的样式片段。mixin 可经 @use 跨模块复用(placeholder/@extend 不能跨模块), -// 因此「多文件共用」的片段统一沉到这里。各文件按层级 `@use '../mixins' as *;` 拉取。 +// Style fragments reused across files. A mixin can be reused across modules via @use +// (placeholder/@extend cannot cross modules), so fragments shared by multiple files all +// sink to here. Each file pulls them in by layer with `@use '../mixins' as *;`. @use 'sass:map'; @use 'sass:list'; @use './tokens' as *; -// 浮层菜单基座:绝对定位的暗色浮层(statusbar 队列 / LLM 菜单、chat `/` 命令菜单与补全浮层)。 -// 仅含「浮层外观」共性;定位锚点 / 圆角 / 内边距 / 宽度由各调用点自定。 +// Popover menu base: an absolutely positioned dark popover (statusbar queue / LLM menu, +// chat `/` command menu and completion popover). Contains only the "popover appearance" +// commonality; positioning anchor / radius / padding / width are set per call site. @mixin popover { position: absolute; background: $bg-elev; @@ -14,8 +16,8 @@ z-index: $z-statusbar-menu; } -// Ghost 按钮外框:浅底 + 1px 边框 + 小圆角,hover 主色描边 + 主色文字。 -// chat 的 `/` 命令触发 / 发送 / 重试 / 草稿操作按钮共用;display / padding / disabled 各自补。 +// Ghost button frame: light background + 1px border + small radius, hover accent border + accent text. +// Shared by chat's `/` command trigger / send / retry / draft action buttons; display / padding / disabled added per site. @mixin ghost-btn { background: $bg-surface; border: 1px solid $border-default; @@ -30,15 +32,16 @@ } } -// 单行截断省略号(需配合确定的宽度约束生效)。 +// Single-line truncation with ellipsis (requires a definite width constraint to take effect). @mixin ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -// 「按钮即链接」复位:把 <button> 退成行内链接外观(透明底 / 无边框 / 无内边距)+ 统一 -// focus-visible 焦点环。各调用点自定 color / font / hover(评论锚点 / 草稿锚点 / finding 锚点等)。 +// "Button as link" reset: strip <button> back to an inline link appearance (transparent background / +// no border / no padding) + a uniform focus-visible focus ring. Each call site sets color / font / hover +// (comment anchor / draft anchor / finding anchor, etc.). @mixin anchor-link { background: transparent; border: none; @@ -52,13 +55,14 @@ } } -// ===== chip 共性词汇(statusbar / chat / 各业务域的小圆标签复用)===== -// 各场景 chip 度量(高度 / padding / 字号)差异大,故 common 只共「本质 + 色板词汇」, -// 不强塞统一 class:各场景类 @include chip 后自叠度量,配色统一走 chip-tone。 +// ===== chip common vocabulary (reused by statusbar / chat / small rounded tags across business domains) ===== +// chip metrics (height / padding / font size) differ widely per scenario, so common shares only the +// "essence + palette vocabulary" and does not force a unified class: each scenario class @includes chip +// then layers its own metrics, with coloring uniformly going through chip-tone. // -// chip 本质:行内 flex 居中 + 圆角 + 不换行 + 加粗。 -// 注意:若调用方还需 `font: inherit`(按钮型 chip 重置 UA 字体),须先写 font:inherit -// 再 @include chip,否则简写会把这里的 font-weight 顶回继承值。 +// chip essence: inline flex centering + radius + no wrap + bold. +// Note: if the caller also needs `font: inherit` (button-type chip resetting the UA font), write font:inherit +// first, then @include chip, otherwise the shorthand pushes this font-weight back to the inherited value. @mixin chip { display: inline-flex; align-items: center; @@ -67,8 +71,9 @@ white-space: nowrap; } -// 语义色板:浅底 + 同色相前景。扩展语义色 = 在此 map 加一条,全场景(含 @each 生成的 -// chat-chip-<name>)即时可用 —— 这是 chip 的可扩展轴。 +// Semantic palette: light background + same-hue foreground. Extending a semantic color = add one entry +// to this map, and it is instantly usable in all scenarios (including the @each-generated +// chat-chip-<name>) —— this is chip's extensibility axis. $chip-tones: ( 'accent': ($color-accent-bg-fade, $color-info), 'approved': ($color-approved-fade, $color-approved), @@ -80,12 +85,12 @@ $chip-tones: ( background: list.nth($pair, 1); color: list.nth($pair, 2); } -// 实色填充(强调态,如运行中 / 选中) +// Solid fill (emphasis state, e.g. running / selected) @mixin chip-solid($bg: $color-accent, $fg: $text-on-accent) { background: $bg; color: $fg; } -// 描边(边框随 currentColor,由调用方设 color) +// Outline (border follows currentColor, caller sets color) @mixin chip-outline { border: 1px solid currentColor; } diff --git a/apps/desktop/src/renderer/src/styles/_palette.scss b/apps/desktop/src/renderer/src/styles/_palette.scss index 9dbc7e7e..3bcda65c 100644 --- a/apps/desktop/src/renderer/src/styles/_palette.scss +++ b/apps/desktop/src/renderer/src/styles/_palette.scss @@ -1,105 +1,106 @@ -// 原始调色板(标准色层 / Tier 1):全应用配色的唯一色值真相源。上层语义 token(_theme.scss) -// 只引用这里的原色,绝不再写裸 hex —— 主题切换换的是「语义 → 原色」映射,而非散落各处的色值。 -// 纯变量、无 CSS 输出;经 `@use './palette' as *;` 拉取。 +// Raw palette (standard color layer / Tier 1): the single source of truth for all color values in the app. +// Upper-layer semantic tokens (_theme.scss) reference only the raw colors here and never write bare hex —— +// theme switching swaps the "semantic → raw color" mapping, not color values scattered everywhere. +// Pure variables, no CSS output; pulled in via `@use './palette' as *;`. // -// 按**来源系列**分区命名,让混源显式而非隐性(命名前缀即来源): -// $vscode-* VS Code Dark+/Light+:编辑器 chrome / 中性灰阶 / accent 蓝 / 列表选中 / -// git 装饰 / blame / 错误红族。与内嵌 Monaco 一致,**勿擅自换源**。 -// $tw-* Tailwind:通用语义状态色(approved / warning / danger token / info 等), -// chip · banner · badge 用。是通用 UI 的单一来源刻度。 -// $material-* Material:**目录树文件状态图标**配色(单独保留的一套)+ diff 搜索命中黄。 -// 与 $tw-* 偶有同值(已注明),但语义独立、自成一组,不与之合并。 -// 命名 `$<series>-<family>-<step>`,step 越大越深(family 内排序,非跨 family 感知刻度)。 -// brand / 平台 / 模型厂商 logo 配色不在此(随各 icon 组件,按品牌保真,亦不参与收敛)。 +// Partitioned and named by **source series**, making mixed sources explicit rather than implicit (the name prefix is the source): +// $vscode-* VS Code Dark+/Light+: editor chrome / neutral gray scale / accent blue / list selection / +// git decoration / blame / error red family. Consistent with the embedded Monaco, **do not swap the source arbitrarily**. +// $tw-* Tailwind: generic semantic state colors (approved / warning / danger token / info, etc.), +// used by chip · banner · badge. The single-source scale for generic UI. +// $material-* Material: **directory tree file status icon** colors (a separately kept set) + diff search hit yellow. +// Occasionally shares values with $tw-* (noted), but is semantically independent and forms its own group, not merged with it. +// Named `$<series>-<family>-<step>`, larger step is deeper (ordered within a family, not a cross-family perceptual scale). +// brand / platform / model vendor logo colors are not here (they follow each icon component, kept brand-faithful, and do not participate in convergence). -// ===== 通用黑白(无来源属性)===== +// ===== Generic black and white (no source attribution) ===== $white: #ffffff; $black: #000000; // ============================================================================ -// VS Code 系($vscode-*) +// VS Code series ($vscode-*) // ============================================================================ -// ----- 中性灰阶(暗色):底 / 边 / 字面,深 → 浅。slate 带轻微蓝灰,单列区分纯灰。 -$vscode-gray-900: #181818; // 最深:侧栏分组头 -$vscode-gray-880: #1a1a1b; // 输入区等更深一档 +// ----- Neutral gray scale (dark): background / border / text, deep → light. slate has a slight blue-gray, listed separately from pure gray. +$vscode-gray-900: #181818; // deepest: sidebar group header +$vscode-gray-880: #1a1a1b; // one notch deeper, e.g. input area $vscode-gray-850: #1e1e1e; // app shell / body -$vscode-gray-840: #1f1f20; // 侧栏 / 列表底 -$vscode-gray-820: #252526; // 浮层 / 弹出菜单 -$vscode-gray-780: #2a2d2e; // hover 高亮 / avatar 底 -$vscode-gray-760: #2d2d30; // 输入框 / chip 默认底 / 弱边框 -$vscode-gray-680: #3e3e42; // 默认边框 -$vscode-gray-600: #4d4d4d; // 滚动条滑块 -$vscode-gray-540: #5e5e5e; // 滚动条 hover -$vscode-gray-500: #6e6e6e; // 极弱 placeholder 字 / 滚动条 active(原 #707070 收敛至此) -$vscode-gray-420: #858585; // 次级文字 -$vscode-gray-200: #cccccc; // 主文字 -$vscode-gray-160: #d4d4d4; // body 文字 -$vscode-slate-500: #6e7681; // 更弱文字(blame / footer)/ 折叠斜纹 +$vscode-gray-840: #1f1f20; // sidebar / list background +$vscode-gray-820: #252526; // popover / popup menu +$vscode-gray-780: #2a2d2e; // hover highlight / avatar background +$vscode-gray-760: #2d2d30; // input box / chip default background / weak border +$vscode-gray-680: #3e3e42; // default border +$vscode-gray-600: #4d4d4d; // scrollbar thumb +$vscode-gray-540: #5e5e5e; // scrollbar hover +$vscode-gray-500: #6e6e6e; // very weak placeholder text / scrollbar active (originally #707070, converged here) +$vscode-gray-420: #858585; // secondary text +$vscode-gray-200: #cccccc; // primary text +$vscode-gray-160: #d4d4d4; // body text +$vscode-slate-500: #6e7681; // weaker text (blame / footer) / fold diagonal stripe -// ----- 中性灰阶(浅色「纸面」,亮 → 暗):最亮的纯白直接用 $white。 -$vscode-gray-30: #f8f8f8; // 浅色 app shell -$vscode-gray-60: #f3f3f3; // 浅色侧栏 / 列表底 -$vscode-gray-90: #ececec; // 浅色更深一档(输入区) -$vscode-gray-120: #e8e8e8; // 浅色 hover / 分组头 -$vscode-gray-150: #e0e0e0; // 浅色弱边框 / avatar 底 -$vscode-gray-220: #cfcfcf; // 浅色默认边框 -$vscode-gray-260: #c1c1c1; // 浅色滚动条滑块 -$vscode-gray-340: #a8a8a8; // 浅色滚动条 hover / 极弱文字 -$vscode-gray-380: #909090; // 浅色滚动条 active / 折叠灰 +// ----- Neutral gray scale (light "paper", light → dark): the brightest pure white uses $white directly. +$vscode-gray-30: #f8f8f8; // light app shell +$vscode-gray-60: #f3f3f3; // light sidebar / list background +$vscode-gray-90: #ececec; // light one notch deeper (input area) +$vscode-gray-120: #e8e8e8; // light hover / group header +$vscode-gray-150: #e0e0e0; // light weak border / avatar background +$vscode-gray-220: #cfcfcf; // light default border +$vscode-gray-260: #c1c1c1; // light scrollbar thumb +$vscode-gray-340: #a8a8a8; // light scrollbar hover / very weak text +$vscode-gray-380: #909090; // light scrollbar active / fold gray -// ----- 蓝:accent / 列表选中 / info·链接(VS Code 按钮蓝系) -$vscode-blue-900: #094771; // 暗色列表项选中底(activeList) -$vscode-blue-700: #0e639c; // 暗色主蓝 accent / primary -$vscode-blue-600: #1177bb; // 暗色 primary hover(提亮一档) -$vscode-blue-300: #6cb6ff; // 暗色链接 / info / cache 命中 -$vscode-blue-800: #005fb8; // 浅色 accent / primary -$vscode-blue-750: #0a4f9e; // 浅色 primary hover(压暗一档) -$vscode-blue-650: #0a66c2; // 浅色链接 / info / cache -$vscode-blue-100: #cfe3fb; // 浅色列表选中底(承深色文字) +// ----- Blue: accent / list selection / info·link (VS Code button blue family) +$vscode-blue-900: #094771; // dark list item selected background (activeList) +$vscode-blue-700: #0e639c; // dark primary blue accent / primary +$vscode-blue-600: #1177bb; // dark primary hover (one notch brighter) +$vscode-blue-300: #6cb6ff; // dark link / info / cache hit +$vscode-blue-800: #005fb8; // light accent / primary +$vscode-blue-750: #0a4f9e; // light primary hover (one notch darker) +$vscode-blue-650: #0a66c2; // light link / info / cache +$vscode-blue-100: #cfe3fb; // light list selected background (carries dark text) -// ----- 错误红族(VS Code error 系):danger 实底 / 强调 / 文字 -$vscode-red-700: #c72e0f; // 错误实底 / 删除按钮(高饱和) -$vscode-red-500: #e8512e; // 暗色 ghost 危险按钮描边 / 文字 -$vscode-red-620: #c0392b; // 浅色 ghost 危险按钮描边 / 文字 -$vscode-red-300: #f48771; // 暗色错误文案 / 红边框(salmon) -$vscode-red-650: #cd3131; // 浅色 danger / needs-work 文字(VS Code Light error) +// ----- Error red family (VS Code error series): danger solid background / emphasis / text +$vscode-red-700: #c72e0f; // error solid background / delete button (high saturation) +$vscode-red-500: #e8512e; // dark ghost danger button border / text +$vscode-red-620: #c0392b; // light ghost danger button border / text +$vscode-red-300: #f48771; // dark error copy / red border (salmon) +$vscode-red-650: #cd3131; // light danger / needs-work text (VS Code Light error) -// ----- git 装饰 / blame(与 Monaco 行内 / 装订线一致,**carve-out:不收敛、不换源**) -$vscode-green-300: #73c991; // 暗色 git added 装饰 -$vscode-green-550: #1a7f37; // 浅色 git added 装饰 -$vscode-amber-300: #e2c08d; // 暗色 git modified 装饰(tan) -$vscode-amber-650: #9a6700; // 浅色 git modified 装饰 -$vscode-red-600: #c74e39; // 暗色 git deleted 装饰 -$vscode-red-680: #b31d28; // 浅色 git deleted 装饰 -$vscode-lime-700: #487e02; // blame 改动行绿条(偏橄榄) +// ----- git decoration / blame (consistent with Monaco inline / gutter, **carve-out: no convergence, no source swap**) +$vscode-green-300: #73c991; // dark git added decoration +$vscode-green-550: #1a7f37; // light git added decoration +$vscode-amber-300: #e2c08d; // dark git modified decoration (tan) +$vscode-amber-650: #9a6700; // light git modified decoration +$vscode-red-600: #c74e39; // dark git deleted decoration +$vscode-red-680: #b31d28; // light git deleted decoration +$vscode-lime-700: #487e02; // blame changed-line green bar (olive-ish) // ============================================================================ -// Tailwind 系($tw-*):通用语义状态色(chip / banner / badge) +// Tailwind series ($tw-*): generic semantic state colors (chip / banner / badge) // ============================================================================ -$tw-green-700: #15803d; // approved / success / token↑输入(浅色) -$tw-green-600: #16a34a; // PR approved 绿(暗色) -$tw-green-500: #22c55e; // token ↑输入(暗色) -$tw-amber-700: #b45309; // warning / 纯文字告警 / 冲突·待办浅底(浅色) -$tw-amber-600: #d97706; // warning 饱和琥珀 / 待办 pending 浅底(暗色) -$tw-amber-500: #f59e0b; // 冲突 / needsWork chip 浅底基色 -$tw-amber-400: #fbbf24; // 亮琥珀,纯文字告警(暗色) -$tw-red-600: #dc2626; // token ↓输出(浅色) -$tw-red-500: #ef4444; // token ↓输出(暗色) +$tw-green-700: #15803d; // approved / success / token↑input (light) +$tw-green-600: #16a34a; // PR approved green (dark) +$tw-green-500: #22c55e; // token ↑input (dark) +$tw-amber-700: #b45309; // warning / plain-text warning / conflict·todo light background (light) +$tw-amber-600: #d97706; // warning saturated amber / todo pending light background (dark) +$tw-amber-500: #f59e0b; // conflict / needsWork chip light background base color +$tw-amber-400: #fbbf24; // bright amber, plain-text warning (dark) +$tw-red-600: #dc2626; // token ↓output (light) +$tw-red-500: #ef4444; // token ↓output (dark) // ============================================================================ -// Material 系($material-*):目录树文件状态图标(单独一套,鲜明饱和色)+ diff 搜索命中黄 -// 注:modified #f59e0b / deleted #dc2626 与 $tw-amber-500 / $tw-red-600 同值,但属树图标 -// 独立集合,故单列不合并——后续可整组换色而不波及通用语义。 +// Material series ($material-*): directory tree file status icons (a separate set, vivid saturated colors) + diff search hit yellow +// Note: modified #f59e0b / deleted #dc2626 share values with $tw-amber-500 / $tw-red-600, but belong to the tree icon +// independent set, so listed separately and not merged —— the whole group can later be recolored without affecting generic semantics. // ============================================================================ -$material-green-500: #4caf50; // 文件 added 状态点(暗色,Material green-500) -$material-green-480: #2e9e4f; // 文件 added 状态点(浅色) -$material-amber-500: #f59e0b; // 文件 modified 状态点(暗色) -$material-amber-550: #d18616; // 文件 modified 状态点(浅色) -$material-red-550: #dc2626; // 文件 deleted 状态点(深浅共用) -$material-purple-400: #a78bfa; // 文件 renamed 状态点(暗色) -$material-purple-500: #8b5cf6; // 文件 renamed 状态点(浅色) -$material-blue-400: #60a5fa; // 文件 copied 状态点(暗色) -$material-blue-500: #2563eb; // 文件 copied 状态点(浅色) -$material-yellow-300: #ffd54f; // diff 搜索命中高亮(暗色,Material amber) -$material-yellow-400: #facc15; // diff 搜索命中高亮(浅色) +$material-green-500: #4caf50; // file added status dot (dark, Material green-500) +$material-green-480: #2e9e4f; // file added status dot (light) +$material-amber-500: #f59e0b; // file modified status dot (dark) +$material-amber-550: #d18616; // file modified status dot (light) +$material-red-550: #dc2626; // file deleted status dot (shared by dark and light) +$material-purple-400: #a78bfa; // file renamed status dot (dark) +$material-purple-500: #8b5cf6; // file renamed status dot (light) +$material-blue-400: #60a5fa; // file copied status dot (dark) +$material-blue-500: #2563eb; // file copied status dot (light) +$material-yellow-300: #ffd54f; // diff search hit highlight (dark, Material amber) +$material-yellow-400: #facc15; // diff search hit highlight (light) diff --git a/apps/desktop/src/renderer/src/styles/_theme.scss b/apps/desktop/src/renderer/src/styles/_theme.scss index 9b754571..3bc427dc 100644 --- a/apps/desktop/src/renderer/src/styles/_theme.scss +++ b/apps/desktop/src/renderer/src/styles/_theme.scss @@ -1,18 +1,19 @@ -// 主题层(语义层 / Tier 2):把语义 token 映射到 _palette.scss 的标准色,发射为 CSS 自定义属性 -// (CSS custom properties),使「语义 → 色值」可在运行期整体切换 —— 这是深色 / 浅色主题切换的基座。 +// Theme layer (semantic layer / Tier 2): maps semantic tokens to the standard colors of _palette.scss, +// emitting them as CSS custom properties, so "semantic → color value" can be switched wholesale at runtime +// —— this is the base for dark / light theme switching. // -// - 默认(:root)即当前**暗色**主题。 -// - 浅色主题在 `:root[data-theme='light'] { ... }` 覆盖同名变量即可,无需触碰任何使用处。 -// - 业务样式不直接引用本文件的变量名,而经 _tokens.scss 的 `$token: var(--token)` 桥接(使用处零改动)。 +// - The default (:root) is the current **dark** theme. +// - The light theme just overrides same-named variables in `:root[data-theme='light'] { ... }`, without touching any usage site. +// - Business styles do not reference this file's variable names directly, but bridge through _tokens.scss's `$token: var(--token)` (zero changes at usage sites). // -// 半透明叠加一律**从 palette 原色派生**(`#{rgba($palette-var, a)}` 编译期出固定 rgba;或使用处 -// 按多档 alpha 叠加时用 `rgb(var(--rgb-x) / a)`,三元组见底部)—— 绝不再手抄十进制通道字面量, -// 否则改原色时浅底不跟随、悄悄脱节(单一真相源原则)。 +// Semi-transparent overlays are always **derived from palette raw colors** (`#{rgba($palette-var, a)}` emits a fixed rgba at compile time; or at usage sites +// stacking multiple alpha levels via `rgb(var(--rgb-x) / a)`, triples at the bottom) —— never hand-copy decimal channel literals again, +// otherwise the light background will not follow when the raw color changes, silently drifting out of sync (single source of truth principle). @use 'sass:color'; @use './palette' as *; -// 取色的 RGB 通道三元组(空格分隔,供 `rgb(<triple> / <alpha>)`)。以 palette 原色为唯一来源。 +// RGB channel triple of a color (space-separated, for `rgb(<triple> / <alpha>)`). palette raw color is the sole source. @function rgb-channels($c) { @return #{color.channel($c, 'red', $space: rgb)} #{color.channel($c, 'green', $space: rgb)} @@ -20,9 +21,9 @@ } :root { - // ===== 背景 ===== + // ===== Background ===== --bg-app: #{$vscode-gray-850}; - --bg-white-fade: #{rgba($white, 0.12)}; // icon-btn hover / count-pill 底 + --bg-white-fade: #{rgba($white, 0.12)}; // icon-btn hover / count-pill background --bg-panel: #{$vscode-gray-840}; --bg-panel-alt: #{$vscode-gray-880}; --bg-elev: #{$vscode-gray-820}; @@ -30,45 +31,45 @@ --bg-hover: #{$vscode-gray-780}; --bg-selected: #{$vscode-blue-900}; --bg-group-header: #{$vscode-gray-900}; - // 状态栏「未启用 / idle」中性胶囊底:独立于边框色,暗色沿用旧 border-default 灰(#3e3e42)。 + // Statusbar "not enabled / idle" neutral pill background: independent of border color, dark reuses the old border-default gray (#3e3e42). --chip-neutral: #{$vscode-gray-680}; - // ===== 边框 ===== + // ===== Border ===== --border-default: #{$vscode-gray-680}; - --border-default-fade: #{rgba($vscode-gray-680, 0.5)}; // blame row 内分隔线 + --border-default-fade: #{rgba($vscode-gray-680, 0.5)}; // divider inside a blame row --border-muted: #{$vscode-gray-760}; - // ===== 文字 ===== + // ===== Text ===== --text-primary: #{$vscode-gray-200}; --text-body: #{$vscode-gray-160}; --text-muted: #{$vscode-gray-420}; --text-subtle: #{$vscode-slate-500}; --text-dim: #{$vscode-gray-500}; - --text-on-accent: #{$white}; // 饱和色实底(accent / warning / danger 等)上的文字,深浅皆白 - // 选中高亮底(--bg-selected)上的文字:暗色底深蓝 → 白;浅色底浅蓝 → 深字。与 --text-on-accent - // 分开,避免浅色下选中项沿用白字成「反色」。 + --text-on-accent: #{$white}; // text on saturated solid backgrounds (accent / warning / danger, etc.), white in both dark and light + // Text on the selected highlight background (--bg-selected): dark deep-blue background → white; light light-blue background → dark text. Kept separate from --text-on-accent + // to avoid selected items reusing white text in light and becoming "inverted". --text-on-selected: #{$white}; - // ===== 语义色(动作 / 状态)===== + // ===== Semantic colors (action / state) ===== --color-accent: #{$vscode-blue-700}; --color-accent-hover: #{$vscode-blue-600}; --color-accent-bg-fade: #{rgba($vscode-blue-700, 0.18)}; --color-accent-strong-fade: #{rgba($vscode-blue-700, 0.5)}; // resize handle hover/active --color-info: #{$vscode-blue-300}; --color-success: #{$tw-green-700}; - --color-success-strong: #{$tw-green-500}; // 高饱和成功绿(复制成功打勾等强反馈) + --color-success-strong: #{$tw-green-500}; // high-saturation success green (strong feedback like copy-success checkmark) --color-approved: #{$tw-green-600}; --color-warning: #{$tw-amber-600}; --color-warning-bright: #{$tw-amber-400}; --color-danger: #{$vscode-red-300}; --color-danger-bg: #{$vscode-red-700}; --color-danger-strong: #{$vscode-red-500}; - --color-danger-strong-fade: #{rgba($vscode-red-500, 0.12)}; // ghost 危险按钮 hover 浅红底 + --color-danger-strong-fade: #{rgba($vscode-red-500, 0.12)}; // ghost danger button hover light red background --color-token-in: #{$tw-green-500}; --color-token-out: #{$tw-red-500}; --color-token-cache: #{$vscode-blue-300}; - // 语义色浅底(chip / banner 背景,与对应主色同色相) + // Semantic light backgrounds (chip / banner background, same hue as the corresponding primary color) --color-accent-bg-hover: #{rgba($vscode-blue-700, 0.28)}; --color-approved-fade: #{rgba($tw-green-600, 0.18)}; --color-approved-fade-soft: #{rgba($tw-green-600, 0.1)}; @@ -76,41 +77,41 @@ --color-danger-fade: #{rgba($vscode-red-300, 0.08)}; --color-danger-border: #{rgba($vscode-red-300, 0.35)}; - // 状态浅底(pending / posted 原 off-palette 一次性色,已收敛到对应主色相) - --color-conflict-fade: #{rgba($tw-amber-500, 0.18)}; // 冲突 / needsWork chip 填充 - --color-pending-fade: #{rgba($tw-amber-600, 0.15)}; // 待办 / 草稿 pending 填充 - --color-posted-fade: #{rgba($tw-green-600, 0.15)}; // 草稿已发布填充 - --color-search-hit: #{rgba($material-yellow-300, 0.42)}; // diff 搜索命中高亮 + // State light backgrounds (pending / posted originally off-palette one-off colors, now converged to the corresponding primary hue) + --color-conflict-fade: #{rgba($tw-amber-500, 0.18)}; // conflict / needsWork chip fill + --color-pending-fade: #{rgba($tw-amber-600, 0.15)}; // todo / draft pending fill + --color-posted-fade: #{rgba($tw-green-600, 0.15)}; // draft posted fill + --color-search-hit: #{rgba($material-yellow-300, 0.42)}; // diff search hit highlight - // ===== Git decoration 配色(跟 Monaco / VS Code 一致)===== + // ===== Git decoration colors (consistent with Monaco / VS Code) ===== --git-added: #{$vscode-green-300}; --git-added-bg-fade: #{rgba($vscode-green-300, 0.16)}; --git-modified: #{$vscode-amber-300}; --git-deleted: #{$vscode-red-600}; --git-needs-work-fade: #{rgba($vscode-red-600, 0.18)}; --git-needs-work-fg: #{$vscode-red-300}; - --blame-change: #{$vscode-lime-700}; // PR 改动行绿条 - --blame-fold: #{$vscode-slate-500}; // 折叠占位斜纹灰 + --blame-change: #{$vscode-lime-700}; // PR changed-line green bar + --blame-fold: #{$vscode-slate-500}; // fold placeholder diagonal-stripe gray - // ===== 文件改动状态点(目录树 Material 图标集)===== - // 鲜明的 Material 饱和色:圆点小、需「跳」出来,深浅主题都用亮色更醒目(区别于文件名的 git 装饰色)。 + // ===== File change status dots (directory tree Material icon set) ===== + // Vivid Material saturated colors: the dot is small and needs to "pop"; both dark and light themes use bright colors for more prominence (distinct from the file name's git decoration color). --file-status-added: #{$material-green-500}; --file-status-modified: #{$material-amber-500}; --file-status-deleted: #{$material-red-550}; --file-status-renamed: #{$material-purple-400}; --file-status-copied: #{$material-blue-400}; - // ===== Avatar / 装饰 ===== + // ===== Avatar / decoration ===== --avatar-bg-default: #{$vscode-gray-780}; - // GitHub octocat 单色 logo:暗底用官方反白(invertocat),浅底翻成官方近黑(见浅色块覆盖)。 + // GitHub octocat monochrome logo: dark background uses the official inverted white (invertocat), light background flips to the official near-black (see light block override). --github-logo: #{$white}; - // ===== 滚动条(webkit)===== + // ===== Scrollbar (webkit) ===== --scrollbar-thumb: #{$vscode-gray-600}; --scrollbar-thumb-hover: #{$vscode-gray-540}; --scrollbar-thumb-active: #{$vscode-gray-500}; - // ===== RGB 通道三元组(供使用处 `rgb(var(--rgb-x) / <alpha>)` 半透明叠加)===== + // ===== RGB channel triples (for usage sites' `rgb(var(--rgb-x) / <alpha>)` semi-transparent overlays) ===== --rgb-accent: #{rgb-channels($vscode-blue-700)}; --rgb-danger: #{rgb-channels($vscode-red-300)}; --rgb-warning: #{rgb-channels($tw-amber-600)}; @@ -121,13 +122,13 @@ } // ============================================================================ -// 浅色主题:覆盖同名语义变量。偏好解析为 light 时 renderer 写 documentElement[data-theme='light'] -// 触发本块。白底需更深 / 更饱和的品牌色保对比;半透明叠加(白雾 hover / 分隔线)翻成黑雾,否则白底看不见。 +// Light theme: overrides same-named semantic variables. When the preference resolves to light, the renderer writes documentElement[data-theme='light'] +// to trigger this block. A white background needs deeper / more saturated brand colors to keep contrast; semi-transparent overlays (white-fog hover / divider) flip to black fog, otherwise invisible on white. // ============================================================================ :root[data-theme='light'] { - // ===== 背景 ===== + // ===== Background ===== --bg-app: #{$vscode-gray-30}; - --bg-white-fade: #{rgba($black, 0.06)}; // icon-btn hover:白底改黑雾 + --bg-white-fade: #{rgba($black, 0.06)}; // icon-btn hover: white background switches to black fog --bg-panel: #{$vscode-gray-60}; --bg-panel-alt: #{$vscode-gray-90}; --bg-elev: #{$white}; @@ -135,31 +136,31 @@ --bg-hover: #{$vscode-gray-120}; --bg-selected: #{$vscode-blue-100}; --bg-group-header: #{$vscode-gray-120}; - // 浅色用更浅的灰:边框级灰(#cfcfcf)作胶囊填充在白底状态栏上偏深,改用 #e0e0e0 更轻。 + // Light uses a lighter gray: border-level gray (#cfcfcf) as pill fill looks too deep on the white-background statusbar, switch to #e0e0e0 for lighter. --chip-neutral: #{$vscode-gray-150}; - // ===== 边框 ===== + // ===== Border ===== --border-default: #{$vscode-gray-220}; --border-default-fade: #{rgba($black, 0.08)}; --border-muted: #{$vscode-gray-150}; - // ===== 文字 ===== + // ===== Text ===== --text-primary: #{$vscode-gray-840}; --text-body: #{$vscode-gray-820}; --text-muted: #{$vscode-gray-500}; --text-subtle: #{$vscode-gray-420}; --text-dim: #{$vscode-gray-340}; --text-on-accent: #{$white}; - --text-on-selected: #{$vscode-gray-840}; // 浅色选中底(浅蓝)承深字 + --text-on-selected: #{$vscode-gray-840}; // light selected background (light blue) carries dark text - // ===== 语义色(动作 / 状态)===== + // ===== Semantic colors (action / state) ===== --color-accent: #{$vscode-blue-800}; --color-accent-hover: #{$vscode-blue-750}; --color-accent-bg-fade: #{rgba($vscode-blue-800, 0.1)}; --color-accent-strong-fade: #{rgba($vscode-blue-800, 0.4)}; --color-info: #{$vscode-blue-650}; --color-success: #{$tw-green-700}; - --color-success-strong: #{$tw-green-600}; // 高饱和成功绿(浅色下取 green-600 兼顾可读) + --color-success-strong: #{$tw-green-600}; // high-saturation success green (light takes green-600 to balance readability) --color-approved: #{$tw-green-700}; --color-warning: #{$tw-amber-700}; --color-warning-bright: #{$tw-amber-700}; @@ -193,24 +194,24 @@ --blame-change: #{$vscode-lime-700}; --blame-fold: #{$vscode-gray-380}; - // ===== 文件改动状态点(目录树 Material 图标集)===== + // ===== File change status dots (directory tree Material icon set) ===== --file-status-added: #{$material-green-480}; --file-status-modified: #{$material-amber-550}; --file-status-deleted: #{$material-red-550}; --file-status-renamed: #{$material-purple-500}; --file-status-copied: #{$material-blue-500}; - // ===== Avatar / 装饰 ===== + // ===== Avatar / decoration ===== --avatar-bg-default: #{$vscode-gray-150}; - // GitHub octocat 浅底用官方近黑($vscode-gray-900 ≈ 品牌 #181717),白底反白 logo 不可见。 + // GitHub octocat light background uses the official near-black ($vscode-gray-900 ≈ brand #181717); an inverted-white logo is invisible on white. --github-logo: #{$vscode-gray-900}; - // ===== 滚动条 ===== + // ===== Scrollbar ===== --scrollbar-thumb: #{$vscode-gray-260}; --scrollbar-thumb-hover: #{$vscode-gray-340}; --scrollbar-thumb-active: #{$vscode-gray-380}; - // ===== RGB 通道三元组(随浅色品牌基色重置)===== + // ===== RGB channel triples (reset along with the light brand base colors) ===== --rgb-accent: #{rgb-channels($vscode-blue-800)}; --rgb-danger: #{rgb-channels($vscode-red-650)}; --rgb-warning: #{rgb-channels($tw-amber-700)}; diff --git a/apps/desktop/src/renderer/src/styles/_tokens.scss b/apps/desktop/src/renderer/src/styles/_tokens.scss index a426c7f7..be8bbbf0 100644 --- a/apps/desktop/src/renderer/src/styles/_tokens.scss +++ b/apps/desktop/src/renderer/src/styles/_tokens.scss @@ -1,18 +1,18 @@ -// 设计 token 桥接层:颜色 / 字号 / 圆角 / 间距 / 行高 / z-index 阶梯 -// 各 .scss 文件用 `@use './tokens' as *;` 拉到本地作用域。 +// Design token bridge layer: color / font size / radius / spacing / line height / z-index ladder +// Each .scss file pulls it into local scope with `@use './tokens' as *;`. // -// 颜色三层结构(见 _palette.scss / _theme.scss): -// - _palette.scss 原始标准色(Tier 1,唯一色值真相源) -// - _theme.scss 语义 → 原色,发射为 CSS 自定义属性(Tier 2,运行期可整体换主题) -// - 本文件 把语义 SCSS 变量桥接到对应 CSS 自定义属性(`$token: var(--token)`), -// 使用处保持 `background: $bg-app` 等旧写法不变,编译即输出 `var(--bg-app)`。 +// Three-layer color structure (see _palette.scss / _theme.scss): +// - _palette.scss raw standard colors (Tier 1, the single source of truth for color values) +// - _theme.scss semantic → raw color, emitted as CSS custom properties (Tier 2, whole theme swappable at runtime) +// - this file bridges semantic SCSS variables to the corresponding CSS custom properties (`$token: var(--token)`), +// so usage sites keep old forms like `background: $bg-app` unchanged, compiling to `var(--bg-app)`. // -// 规则: -// - 颜色一律桥接到 var(--*),绝不在此写裸 hex —— 改色去 _palette.scss / _theme.scss。 -// - 需在使用处按 alpha 叠加的少数色,用 `rgb(var(--rgb-*) / <alpha>)`(通道三元组见 _theme.scss)。 -// - 字号 / 间距 / 圆角 / z-index 等非颜色 token 仍为编译期常量(不参与主题切换),留在本文件。 +// Rules: +// - Colors are always bridged to var(--*), never write bare hex here —— change colors in _palette.scss / _theme.scss. +// - For the few colors that need alpha stacking at usage sites, use `rgb(var(--rgb-*) / <alpha>)` (channel triples in _theme.scss). +// - Non-color tokens like font size / spacing / radius / z-index remain compile-time constants (do not participate in theme switching) and stay in this file. -// ===== 背景色 ===== +// ===== Background colors ===== $bg-app: var(--bg-app); $bg-white-fade: var(--bg-white-fade); $bg-panel: var(--bg-panel); @@ -24,12 +24,12 @@ $bg-selected: var(--bg-selected); $bg-group-header: var(--bg-group-header); $chip-neutral: var(--chip-neutral); -// ===== 边框 ===== +// ===== Border ===== $border-default: var(--border-default); $border-default-fade: var(--border-default-fade); $border-muted: var(--border-muted); -// ===== 文字 ===== +// ===== Text ===== $text-primary: var(--text-primary); $text-body: var(--text-body); $text-muted: var(--text-muted); @@ -38,7 +38,7 @@ $text-dim: var(--text-dim); $text-on-accent: var(--text-on-accent); $text-on-selected: var(--text-on-selected); -// ===== 语义色 (动作 / 状态) ===== +// ===== Semantic colors (action / state) ===== $color-accent: var(--color-accent); $color-accent-hover: var(--color-accent-hover); $color-accent-bg-fade: var(--color-accent-bg-fade); @@ -57,7 +57,7 @@ $color-token-in: var(--color-token-in); $color-token-out: var(--color-token-out); $color-token-cache: var(--color-token-cache); -// 语义色浅底 (chip / banner 背景) +// Semantic light backgrounds (chip / banner background) $color-accent-bg-hover: var(--color-accent-bg-hover); $color-approved-fade: var(--color-approved-fade); $color-approved-fade-soft: var(--color-approved-fade-soft); @@ -65,13 +65,13 @@ $color-warning-fade: var(--color-warning-fade); $color-danger-fade: var(--color-danger-fade); $color-danger-border: var(--color-danger-border); -// off-palette 状态浅底 +// off-palette state light backgrounds $color-conflict-fade: var(--color-conflict-fade); $color-pending-fade: var(--color-pending-fade); $color-posted-fade: var(--color-posted-fade); $color-search-hit: var(--color-search-hit); -// ===== Git decoration 配色 ===== +// ===== Git decoration colors ===== $git-added: var(--git-added); $git-added-bg-fade: var(--git-added-bg-fade); $git-modified: var(--git-modified); @@ -81,44 +81,44 @@ $git-needs-work-fg: var(--git-needs-work-fg); $blame-change: var(--blame-change); $blame-fold: var(--blame-fold); -// ===== 文件改动状态点 ===== +// ===== File change status dots ===== $file-status-added: var(--file-status-added); $file-status-modified: var(--file-status-modified); $file-status-deleted: var(--file-status-deleted); $file-status-renamed: var(--file-status-renamed); $file-status-copied: var(--file-status-copied); -// ===== Avatar / Initials 等装饰 ===== +// ===== Avatar / Initials and other decorations ===== $avatar-bg-default: var(--avatar-bg-default); -// ===== 滚动条 (webkit) ===== +// ===== Scrollbar (webkit) ===== $scrollbar-thumb: var(--scrollbar-thumb); $scrollbar-thumb-hover: var(--scrollbar-thumb-hover); $scrollbar-thumb-active: var(--scrollbar-thumb-active); -// ===== 字号 ===== -// 整体设计基线在 14px:body 默认 / Monaco 编辑器都走 $fs-lg=14。 -// $fs-md=13 是次要文本(输入框 / 小按钮 / 评论正文);更小的 12 / 11 -// 仅留给 chip 标签 / footer 注脚等单独信息密度高的场景。 +// ===== Font size ===== +// The overall design baseline is 14px: body default / Monaco editor both use $fs-lg=14. +// $fs-md=13 is secondary text (input box / small button / comment body); the smaller 12 / 11 +// are reserved for scenarios with high information density like chip tags / footer notes. $fs-xs: 11px; $fs-sm: 12px; $fs-md: 13px; -$fs-lg: 14px; // body 默认 / Monaco 编辑器字号 -$fs-xl: 16px; // 段落标题 / 主面板顶栏标题 +$fs-lg: 14px; // body default / Monaco editor font size +$fs-xl: 16px; // paragraph heading / main panel top bar heading -// ===== 行高 ===== +// ===== Line height ===== $lh-tight: 1.35; $lh-normal: 1.4; $lh-loose: 1.5; -// ===== 圆角 ===== -$radius-sm: 3px; // chip / 小按钮 -$radius-md: 4px; // 输入框 / hover 区 -$radius-lg: 5px; // 卡片 / 弹出菜单 -$radius-pill: 9px; // 圆点 count-pill / review-chip -$radius-full: 50%; // 圆形 (avatar 等) +// ===== Radius ===== +$radius-sm: 3px; // chip / small button +$radius-md: 4px; // input box / hover area +$radius-lg: 5px; // card / popup menu +$radius-pill: 9px; // dot count-pill / review-chip +$radius-full: 50%; // circular (avatar, etc.) -// ===== 间距 (4 倍数) ===== +// ===== Spacing (multiples of 4) ===== $space-1: 2px; $space-2: 4px; $space-3: 6px; @@ -127,14 +127,14 @@ $space-5: 10px; $space-6: 12px; $space-8: 16px; -// ===== z-index 阶梯 ===== +// ===== z-index ladder ===== $z-resize-handle: 50; $z-blame-overlay: 5; $z-statusbar-menu: 200; $z-modal: 1000; $z-modal-nested: 1100; -// ===== 字体族 ===== -// 等宽字体优先取用户配置(--editor-font-family,由 renderer 按 config 写入 documentElement), -// 未配置时回落 Monaco 注入的 --vscode-editor-font-family,再回落内置 Consolas / monospace。 +// ===== Font family ===== +// The monospace font prefers the user config (--editor-font-family, written to documentElement by the renderer per config); +// when unconfigured it falls back to the --vscode-editor-font-family injected by Monaco, then to the built-in Consolas / monospace. $font-mono: var(--editor-font-family, var(--vscode-editor-font-family, 'Consolas', monospace)); diff --git a/apps/desktop/src/renderer/src/styles/base.scss b/apps/desktop/src/renderer/src/styles/base.scss index 6b6cf0bf..b2e10443 100644 --- a/apps/desktop/src/renderer/src/styles/base.scss +++ b/apps/desktop/src/renderer/src/styles/base.scss @@ -1,5 +1,5 @@ @use './tokens' as *; -@use './theme'; // 发射 :root 主题变量块(语义 → 标准色);本文件经 App.scss 首位引入,确保只发射一次 +@use './theme'; // emits the :root theme variable block (semantic → standard color); this file is imported first via App.scss to ensure it is emitted only once * { box-sizing: border-box; @@ -24,9 +24,9 @@ body { font-size: $fs-lg; } -/* 全局滚动条:跟 VS Code Dark+ 一致的细暗色样式。Monaco 内部滚动条自己渲染,不受此影响。 - 用 background-color (而非 shorthand background) 保留 background-clip,状态切换时 - 只换颜色,不重复声明 clip。 */ +/* Global scrollbar: a thin dark style consistent with VS Code Dark+. Monaco's internal scrollbar renders itself and is unaffected by this. + Use background-color (rather than the shorthand background) to preserve background-clip, so on state changes + only the color swaps, without redeclaring clip. */ ::-webkit-scrollbar { width: 10px; height: 10px; @@ -111,7 +111,7 @@ textarea { font-size: $fs-sm; } -// 仅图标按钮(编辑/删除/选择目录):收窄水平 padding,svg 居中 +// Icon-only button (edit/delete/choose directory): narrow horizontal padding, svg centered .btn-icon { padding-left: $space-4; padding-right: $space-4; @@ -121,22 +121,22 @@ textarea { } } -// 危险图标按钮(删除连接 / LLM 配置等):hover 变饱和红,与其它危险按钮统一 +// Danger icon button (delete connection / LLM config, etc.): hover turns saturated red, unified with other danger buttons .btn-icon-danger:hover:not(:disabled) { border-color: $color-danger-strong; color: $color-danger-strong; background: $color-danger-strong-fade; } -// 主操作图标按钮(编辑):hover 变蓝 +// Primary action icon button (edit): hover turns blue .btn-icon-primary:hover:not(:disabled) { border-color: $color-accent; color: $color-accent; } .btn-danger { - // 实底用饱和的 $color-danger-bg(#c72e0f),而非偏浅鲑红的 $color-danger - // (后者是为文案 / 红边框设计的浅色,当实底背景会发灰白、警示力不足)。 + // Solid background uses the saturated $color-danger-bg (#c72e0f), rather than the lighter salmon-red $color-danger + // (the latter is a light color designed for copy / red borders; as a solid background it looks grayish-white with insufficient warning force). background: $color-danger-bg; border-color: $color-danger-bg; color: $text-on-accent; @@ -157,8 +157,8 @@ textarea { } } -// 文本型按钮:无底 / 无边框的链接态动作(工具栏里的轻量操作,如「全选 / 取消全选」), -// 强调蓝、hover 加下划线;不占按钮实底,读作行内链接。 +// Text button: a link-state action with no background / no border (lightweight toolbar operations, e.g. "select all / deselect all"), +// accent blue, underline on hover; takes no button solid background, reads as an inline link. .btn-link { background: none; border: none; @@ -186,15 +186,15 @@ textarea { text-align: center; } -// 可合并分类计数 >0 时染绿,提示"有可直接合并的 PR"。绿底白字在过滤按钮的 -// 默认底 / btn-primary 选中蓝底上都够对比 +// When the mergeable category count is >0, tint green to hint "there are directly mergeable PRs". Green background with white text has enough contrast +// on both the filter button's default background and the btn-primary selected blue background .count-pill-mergeable { background: $color-approved; color: $text-on-accent; } -// 操作级 toast:顶部居中浮层,自动消失 / 点击关闭。用于审批 / 合并等远端动作失败提示 -// (放顶部避免在 diff 视图下与底部状态栏 / 横向滚动条叠压)。 +// Action-level toast: top-centered popover, auto-dismiss / click to close. Used for failure hints of remote actions like approve / merge +// (placed at top to avoid overlapping the bottom statusbar / horizontal scrollbar in diff view). .app-toast { position: fixed; left: 50%; @@ -218,7 +218,7 @@ textarea { @keyframes app-toast-in { from { opacity: 0; - transform: translate(-50%, -8px); // 顶部浮层:自上方滑入 + transform: translate(-50%, -8px); // top popover: slides in from above } to { opacity: 1; @@ -226,8 +226,8 @@ textarea { } } -// 活动脉冲点:表示"正在进行"的小圆点。跨域共享(pr-agent 活动 chip + repo sync chip)。 -// idle 变体:静态灰、不脉冲。 +// Activity pulse dot: a small dot indicating "in progress". Shared across domains (pr-agent activity chip + repo sync chip). +// idle variant: static gray, no pulse. .activity-dot { width: 6px; height: 6px; @@ -252,8 +252,8 @@ textarea { } } -// 通用图标按钮:透明底无边框,hover 浅底高亮。app 多处复用(状态栏 / Modal / ChatPane / -// 同步刷新等),故置于 base 而非某一业务域。 +// Generic icon button: transparent background no border, hover light-background highlight. Reused in many places in the app (statusbar / Modal / ChatPane / +// sync refresh, etc.), so placed in base rather than a single business domain. .icon-btn { background: transparent; border: none; @@ -267,7 +267,7 @@ textarea { &:hover:not(:disabled) { background: $bg-white-fade; - // 主文字色(随主题)——勿写死 white:浅色模式下白图标会消失在浅底 hover 上。 + // primary text color (follows theme) —— do not hardcode white: in light mode a white icon disappears on the light hover background. color: $text-primary; } &:disabled { @@ -287,8 +287,8 @@ textarea { } } -// 通用开关(Switch 组件):轨道 + 滑块。off 走中性灰底,on 走 accent 底,滑块右移;白色滑块带轻阴影 -// 在深浅轨道上都可辨。 +// Generic switch (Switch component): track + knob. off uses a neutral gray background, on uses an accent background, knob shifts right; the white knob has a light shadow +// so it is distinguishable on both dark and light tracks. .switch { position: relative; flex-shrink: 0; diff --git a/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss b/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss index bb15498a..c73a511e 100644 --- a/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss +++ b/apps/desktop/src/renderer/src/styles/common/bitbucket-image.scss @@ -1,5 +1,5 @@ -// Bitbucket 评论 attachment 图片(见 components/common/BitbucketImage):经 IPC 代理拉回 -// data URL,缩略展示 + 点击放大 overlay。原图通常很大(截图等),故限尺寸 + zoom-in 提示。 +// Bitbucket comment attachment images (see components/common/BitbucketImage): fetched back via IPC proxy +// as data URLs, shown as thumbnails + click-to-enlarge overlay. Originals are usually large (screenshots etc.), so limit size + zoom-in hint. @use '../tokens' as *; @@ -9,7 +9,7 @@ height: auto; width: auto; object-fit: contain; - // !important 覆盖 monaco view zone 内的 cursor:text 继承 + // !important overrides inherited cursor:text inside the monaco view zone cursor: zoom-in !important; border-radius: $radius-sm; border: 1px solid $border-default; @@ -25,7 +25,7 @@ border-radius: $radius-sm; } -// 大图预览 overlay (portal 到 document.body) — 点击缩略图打开 +// Large image preview overlay (portal to document.body) — opened by clicking the thumbnail .bitbucket-image-zoom-backdrop { position: fixed; inset: 0; diff --git a/apps/desktop/src/renderer/src/styles/common/markdown.scss b/apps/desktop/src/renderer/src/styles/common/markdown.scss index c1775b9e..0a9e705b 100644 --- a/apps/desktop/src/renderer/src/styles/common/markdown.scss +++ b/apps/desktop/src/renderer/src/styles/common/markdown.scss @@ -1,7 +1,7 @@ -// Markdown 渲染:用于评论 view zone (.comment-zone-body.markdown) 和 PR 描述 -// (.pr-detail-description.markdown) 等。react-markdown 输出的 h/p/li/code/pre/ -// blockquote/table 元素的 dark-theme 配色,跟 VS Code Dark+ 视觉对齐。 -// 组件见 components/common/(markdownMermaid 等)。mermaid 图 / Bitbucket 图各自拆出。 +// Markdown rendering: used for the comment view zone (.comment-zone-body.markdown) and PR description +// (.pr-detail-description.markdown) etc. dark-theme colors for the h/p/li/code/pre/ +// blockquote/table elements output by react-markdown, aligned with VS Code Dark+. +// Components see components/common/ (markdownMermaid etc.). mermaid diagrams / Bitbucket images are split out separately. @use '../tokens' as *; @@ -19,9 +19,9 @@ border-radius: $radius-sm; font-family: $font-mono; font-size: 0.92em; - // 长行内代码(如 `analyticsUsage.get(...)` / `tenant.getId().toString()`)超宽时允许断行, - // 避免把有序列表 / 标题那一行(含开头的数字序号)撑出卡片边界。块级 pre 里的 code 不受影响 - // (pre 为 white-space:pre,不参与换行,仍横向滚动)。 + // Long inline code (e.g. `analyticsUsage.get(...)` / `tenant.getId().toString()`) may wrap when too wide, + // to avoid pushing the ordered-list / heading line (including the leading number) past the card boundary. code inside block-level pre is unaffected + // (pre is white-space:pre, does not wrap, still scrolls horizontally). overflow-wrap: anywhere; } @@ -69,9 +69,9 @@ border-collapse: collapse; margin: $space-3 0; font-size: $fs-md; - // pr-agent 的 walkthrough / 路径列经常塞长 monospace 字符串 (file paths), - // 默认布局会让单元格溢出甚至撑爆面板宽度。table-layout:fixed + width:100% 强制 - // 按容器宽度分列,单元格内强制 break-word,长路径在 code 字符间断行 + // pr-agent's walkthrough / path columns often stuff long monospace strings (file paths), + // the default layout lets cells overflow or even blow out the panel width. table-layout:fixed + width:100% forces + // columns to divide by container width, cells force break-word, long paths break between code characters width: 100%; table-layout: fixed; } @@ -87,8 +87,8 @@ background: $bg-surface; font-weight: 600; } - // 单元格里的 inline code:长 path 没空格也得断;overflow-wrap:anywhere 让浏览器 - // 在任意字符位置断行 (比 break-all 更宽容,会优先在 - / _ 等位置断) + // inline code inside cells: long paths must break even without spaces; overflow-wrap:anywhere lets the browser + // break at any character position (more lenient than break-all, prefers breaking at - / _ etc.) td code, th code { word-break: break-all; @@ -96,7 +96,7 @@ white-space: pre-wrap; } - // <details>/<summary> 折叠块:Qodo / pr-agent 机器人评论大量使用。 + // <details>/<summary> collapsible blocks: heavily used by Qodo / pr-agent bot comments. details { margin: $space-3 0; border: 1px solid $border-default; @@ -121,7 +121,7 @@ h4 { margin: $space-4 0 $space-2; font-weight: 600; - // 标题里常含长路径 / 长标识符(含或不含反引号),无 break 机会会撑出容器;允许超宽时断行。 + // Headings often contain long paths / long identifiers (with or without backticks); with no break opportunity they push out the container; allow wrapping when too wide. overflow-wrap: anywhere; word-break: break-word; } diff --git a/apps/desktop/src/renderer/src/styles/common/mermaid.scss b/apps/desktop/src/renderer/src/styles/common/mermaid.scss index d0c7bc1d..639edccc 100644 --- a/apps/desktop/src/renderer/src/styles/common/mermaid.scss +++ b/apps/desktop/src/renderer/src/styles/common/mermaid.scss @@ -1,13 +1,13 @@ -// Mermaid 图(markdown ```mermaid 代码块渲染,见 components/common/MermaidDiagram): -// 内联图 + 点击放大的模态预览(缩放 / 拖拽)。 +// Mermaid diagrams (rendered from markdown ```mermaid code blocks, see components/common/MermaidDiagram): +// inline diagram + click-to-enlarge modal preview (zoom / drag). @use '../tokens' as *; .mermaid-diagram { margin: $space-3 0; - overflow-x: auto; // 宽图横向滚动,不撑破容器 + overflow-x: auto; // wide diagrams scroll horizontally, don't blow out the container text-align: center; - cursor: zoom-in; // 提示可点击放大 + cursor: zoom-in; // hint that it's clickable to enlarge svg { max-width: 100%; @@ -15,7 +15,7 @@ } } -// 点击图表后的模态预览(portal 到 body):固定纯色背景预览区 + 缩放/拖拽视图 +// Modal preview after clicking the diagram (portal to body): fixed solid-color preview stage + zoom/drag view .mermaid-zoom-overlay { position: fixed; inset: 0; @@ -71,26 +71,26 @@ } } } -// 纯色预览区:图在此缩放/拖拽,超出裁剪 +// Solid-color preview stage: the diagram zooms/drags here, overflow is clipped .mermaid-zoom-stage { flex: 1; position: relative; overflow: hidden; - background: $bg-app; // 固定纯色背景 + background: $bg-app; // fixed solid-color background cursor: grab; &:active { cursor: grabbing; } } -// 缩放/平移层:充满预览区,transform 叠加缩放/平移(origin 0 0 与 zoomAt 数学一致) +// Zoom/pan layer: fills the preview stage, transform applies zoom/pan (origin 0 0 matches the zoomAt math) .mermaid-zoom-content { position: absolute; inset: 0; transform-origin: 0 0; - // svg 充满本层,由原生 preserveAspectRatio(默认 xMidYMid meet) 等比缩放并居中实现「适应窗口」。 - // !important 覆盖 mermaid 注入的内联 max-width / width,避免其按自身固有尺寸渲染。 + // svg fills this layer, native preserveAspectRatio (default xMidYMid meet) scales proportionally and centers to achieve "fit to window". + // !important overrides mermaid's injected inline max-width / width, preventing it from rendering at its own intrinsic size. svg { display: block; width: 100% !important; @@ -102,7 +102,7 @@ margin: $space-3 0; font-size: $fs-sm; } -// 渲染失败回退:复用代码块观感,至少可读源码 +// Render-failure fallback: reuse the code block look, at least the source stays readable .mermaid-fallback { margin: $space-3 0; padding: $space-3; diff --git a/apps/desktop/src/renderer/src/styles/common/modal.scss b/apps/desktop/src/renderer/src/styles/common/modal.scss index f9c0d6f1..38bd5d4c 100644 --- a/apps/desktop/src/renderer/src/styles/common/modal.scss +++ b/apps/desktop/src/renderer/src/styles/common/modal.scss @@ -1,6 +1,6 @@ -// 通用模态壳:backdrop / 外框 / header / body / 区块(section) / 键值表(kv) / 底栏(footer/ -// actions) / 嵌套子模态 / 尺寸修饰。各业务模态在此基础上组合,专属内容样式在各自域维护 -// (settings → features/settings/forms,提交评审 → features/pr/publish-review 等)。 +// Generic modal shell: backdrop / outer frame / header / body / section / key-value table (kv) / footer (footer/ +// actions) / nested sub-modal / size modifiers. Each business modal composes on top of this, with dedicated content styles maintained in their own domains +// (settings → features/settings/forms, publish review → features/pr/publish-review etc.). @use '../tokens' as *; @@ -40,16 +40,16 @@ } } -// header 右侧组:自定义动作(headerActions)+ 关闭键,右对齐并排。 -// align-items: stretch 让图标动作按钮拉伸到与关闭键等高(图标内容比文字矮,否则会短一截)。 +// header right-side group: custom actions (headerActions) + close button, right-aligned side by side. +// align-items: stretch makes the icon action buttons stretch to the same height as the close button (icon content is shorter than text, otherwise it would come up short). .modal-header-right { display: flex; align-items: stretch; gap: $space-3; } -// 右上角关闭按钮:方形 X 图标(免国际化),复用 .icon-btn 的透明无边框, -// 这里只调方形 padding + hover 浅底 +// Top-right close button: square X icon (no i18n needed), reuses .icon-btn's transparent borderless look, +// here only tweaks square padding + hover light background .modal-close { padding: 4px; border-radius: $radius-sm; @@ -66,16 +66,16 @@ overflow-y: auto; } -// 宽尺寸:左右分区类模态(如 SettingsModal 分区导航 + 内容)需要更宽横向空间, -// 让左导航与右侧内容各得其所。定高(受 .modal 的 max-height: 84vh 上限约束), -// 使切换分区时模态尺寸恒定、右侧内容区独立滚动而非整体撑高。 +// Wide size: left/right split modals (e.g. SettingsModal's section nav + content) need more horizontal space, +// so the left nav and right content each get their place. Fixed height (bounded by .modal's max-height: 84vh), +// keeping the modal size constant when switching sections, with the right content area scrolling independently rather than stretching the whole thing. .modal-lg { max-width: 760px; height: 600px; } -// ConfirmModal: 跟 SettingsModal 共用 .modal-backdrop/.modal/.modal-header,加几条 -// 小尺寸 + 底部操作行特化 +// ConfirmModal: shares .modal-backdrop/.modal/.modal-header with SettingsModal, adds a few +// small-size + bottom action row specializations .modal-confirm { max-width: 380px; } @@ -107,15 +107,15 @@ } } -// 分区分隔线:在分区上方画一条细线 + 留白,把它与上方分区视觉分组(如设置「常规」里编辑器配置与 -// 语言 / 主题分隔)。 +// Section divider: draws a thin line + whitespace above a section, visually grouping it apart from the section above (e.g. separating editor config from +// language / theme in settings "General"). .modal-section-divider { border-top: 1px solid $border-default; padding-top: $space-8; } -// 区块标题行:标题左、操作按钮(如"+ 添加连接")右,省纵向空间。h4 去掉自身 -// 下边距改由本行统一控制 +// Section head row: title left, action button (e.g. "+ Add connection") right, saving vertical space. h4 drops its own +// bottom margin, controlled uniformly by this row instead .modal-section-head { display: flex; align-items: center; @@ -127,7 +127,7 @@ } } -// 标题 + 状态 chip 成组靠左,让右侧操作按钮保持两端对齐 +// Title + status chip grouped to the left, keeping the right action button in space-between alignment .modal-section-head-title { display: flex; align-items: center; @@ -147,14 +147,14 @@ font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; word-break: break-all; color: $text-body; - min-width: 0; // 让 input width:100% 不被 grid cell 自然撑出去 + min-width: 0; // keep input width:100% from being pushed out by the grid cell's natural size } .modal-footer { margin-top: $space-4; } -// 模态框底栏:左侧工具(编辑 config.yaml / DevTools),右侧全局保存。固定不滚动 +// Modal footer bar: tools on the left (edit config.yaml / DevTools), global save on the right. Fixed, no scroll .modal-footer-bar { flex-shrink: 0; display: flex; @@ -183,15 +183,15 @@ margin-top: $space-4; } -// 嵌套子模态框 + 小模态体 +// Nested sub-modal + small modal body .modal-backdrop-nested { background: rgba(0, 0, 0, 0.4); z-index: $z-modal-nested; } .modal-sm { - // LlmEditorModal 用:URL / 模型 ID / 自部署 endpoint 这类字段很长 - // (dashscope 默认 endpoint 已经 ~50 chars,自定义 base URL 更长), - // 拓到 720px 让输入框横向空间充裕 + // Used by LlmEditorModal: fields like URL / model ID / self-hosted endpoint are very long + // (dashscope's default endpoint is already ~50 chars, custom base URLs are longer), + // widened to 720px to give the inputs ample horizontal space max-width: 720px; width: 100%; } diff --git a/apps/desktop/src/renderer/src/styles/features/chat/_index.scss b/apps/desktop/src/renderer/src/styles/features/chat/_index.scss index 9b841922..af234cfc 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/_index.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/_index.scss @@ -1,5 +1,5 @@ -// chat 面板样式簇入口:@forward 各 partial,App.scss 用 `@use '.../features/chat'` 一行引入。 -// 新增 chat partial 在此登记即可,无需改 App.scss。chip 原子先行(供各 partial 的 markup 组合)。 +// chat pane style cluster entry: @forward each partial; App.scss pulls it in with a single `@use '.../features/chat'`. +// Register new chat partials here — no need to touch App.scss. chip atoms come first (for the markup composition in each partial). @forward 'chip'; @forward 'pane'; @forward 'run'; diff --git a/apps/desktop/src/renderer/src/styles/features/chat/agent.scss b/apps/desktop/src/renderer/src/styles/features/chat/agent.scss index 6da3906f..3666b5db 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/agent.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/agent.scss @@ -1,16 +1,16 @@ -// chat 面板内 Agent(自动评审 / 自然对话)的输出:评审总结卡 + 判定徽标、过程化思考步骤、 -// 自然对话回复气泡、用户输入气泡、空态引导。run 结果与 findings 分别见 chat-run / chat-findings。 +// Agent output inside the chat pane (auto-review / natural conversation): review summary card + verdict badge, procedural thinking steps, +// natural conversation reply bubbles, user input bubbles, empty-state guidance. run results and findings see chat-run / chat-findings respectively. @use '../../tokens' as *; @use '../../mixins' as *; -// === Agent 自动评审:触发条 + 收尾总结卡 === +// === Agent auto-review: trigger bar + closing summary card === .chat-agent-summary { - // 横向不再额外内缩(去掉原 $space-6)——与 finding 卡同宽(二者同处 .chat-pane-body 的 $space-6 内边距内)。 + // No extra horizontal inset (dropped the original $space-6) — same width as finding cards (both sit inside .chat-pane-body's $space-6 padding). margin: $space-4 0; padding: $space-4 $space-5; border: 1px solid $border-muted; - // 左侧主题色条:统一用蓝色,与卡片蓝色淡底($color-accent-bg-fade)成一套(不随判定变色)。 + // Left theme-color bar: always blue, paired with the card's faint blue background ($color-accent-bg-fade) (does not change with the verdict). border-left: 3px solid $color-accent; border-radius: $radius-md; background: $color-accent-bg-fade; @@ -23,7 +23,7 @@ gap: $space-3; margin-bottom: $space-3; - // 标题做成实色 chip(与「代码反馈 / 工作量」等卡片标签同款),右侧判定为描边 chip,成对。 + // Title rendered as a solid chip (same style as card labels like "code feedback / effort"), paired with the outline chip verdict on the right. strong { flex-shrink: 0; padding: 2px $space-3; @@ -34,8 +34,8 @@ font-weight: 600; } } -// 判定徽标:描边色 chip(边框 + 文字同色,随判定变色)。度量/描边走 -// chat-chip + chat-chip-tight + chat-chip-md + chat-chip-outline,这里只留布局 + 判定色。 +// Verdict badge: outline-color chip (border + text same color, changes with the verdict). Metrics/outline go through +// chat-chip + chat-chip-tight + chat-chip-md + chat-chip-outline; this keeps only layout + verdict color. .chat-agent-verdict { flex-shrink: 0; @@ -51,20 +51,20 @@ } .chat-agent-summary-text { margin: 0 0 $space-3; - // 与 finding 卡正文统一字号 / 行高(此前 $fs-md + 默认行高显得比其它卡片更窄)。 + // Unified font size / line height with finding card body text (previously $fs-md + default line height looked narrower than other cards). font-size: $fs-lg; line-height: $lh-normal; color: $text-body; } -// 评审总结 / 对话回复内的 markdown 章节标题(## 摘要 等)压到不超过卡片标题(评审总结)字号, -// 避免章节标题比卡片标题还大、层级倒置。组合类选择器提升特异性以盖过 markdown.scss 的默认 h 字号。 +// Markdown section headings inside review summary / conversation reply (## Summary, etc.) capped to not exceed the card title (review summary) font size, +// avoiding section headings larger than the card title and an inverted hierarchy. The combined-class selector raises specificity to override markdown.scss's default h font size. .markdown.chat-agent-summary-text, .markdown.chat-agent-reply-body { h1, h2, h3, h4 { - // 与正文同字号(靠加粗区分层级),不小于正文、也不放大成 markdown 默认大标题。 + // Same font size as body text (hierarchy distinguished by bold), not smaller than body text nor enlarged to markdown's default big headings. font-size: $fs-lg; margin: $space-3 0 $space-1; } @@ -72,8 +72,8 @@ margin-top: 0; } } -// 评审总结的 ## 小节之间用分割线隔开(首节不加,避免与卡片头部叠线),对应「概述 / 发现 / 建议」三段、 -// 层次清晰。仅评审总结(固定三段骨架)应用,对话回复(自由排版)不加。 +// Divider lines between the review summary's ## sections (not on the first section, to avoid stacking a line onto the card header), matching the "overview / findings / suggestions" three sections, +// clear hierarchy. Applied only to the review summary (fixed three-section skeleton); conversation replies (free layout) get none. .markdown.chat-agent-summary-text { h2 { margin-top: $space-4; @@ -86,18 +86,18 @@ border-top: none; } } -// 建议理由:markdown 渲染但保持次要灰(覆盖 .markdown 默认正文色)。 +// Suggestion rationale: markdown-rendered but kept secondary gray (overrides .markdown's default body color). .chat-agent-summary-reason { font-size: $fs-sm; color: $text-muted; } -// 用户输入气泡:自然语言「对话即委派」的输入回显,右对齐成对话气泡。 -// 注意类名避开既有 .chat-user-msg(/ask 提问行,带左竖条 + 淡蓝底),否则会叠到气泡外形成第二层底色。 +// User input bubble: echo of the natural-language "conversation-as-delegation" input, right-aligned as a conversation bubble. +// Note the class name avoids the existing .chat-user-msg (/ask question row, with left bar + faint blue background), otherwise it would stack outside the bubble forming a second background layer. .chat-user-row { margin: $space-3 $space-6; display: flex; - // 纵向堆叠并右对齐:气泡在上,引用上下文(若有)折叠块在其下方。 + // Stack vertically and right-align: bubble on top, quoted context (if any) collapse block below it. flex-direction: column; align-items: flex-end; gap: $space-2; @@ -113,7 +113,7 @@ white-space: pre-wrap; word-break: break-word; } -// 提问气泡下方的引用上下文(Diff 选区代码):折叠块,默认收起,轻量底色区别于气泡。 +// Quoted context below the question bubble (Diff selection code): collapse block, collapsed by default, light background to distinguish from the bubble. .chat-user-ref { max-width: 85%; padding: $space-2 $space-3; @@ -128,34 +128,34 @@ user-select: none; color: $text-muted; } - // 展开后标题与下方代码围栏留点距离 + // Leave some distance between the title and the code fence below once expanded &[open] > summary { margin-bottom: $space-2; } } -// 过程化跟踪(类 Claude Code):思考步骤按时间穿插进时间线。两行——带 bullet 标记的「已思考 xx s」 -// 首行 + 另起的步骤结果次行。无底色 / 无边框(思考态不加蓝色背景);实时「思考中」首行以 spinner 充当 -// 进行中的 bullet,计时紧贴提示之后。 +// Procedural tracking (Claude Code style): thinking steps interleaved into the timeline by time. Two rows — the "thought xx s" +// first row with a bullet marker + a separate step-result second row. No background / no border (thinking state adds no blue background); the live "thinking" first row uses a spinner as +// the in-progress bullet, with the timer right after the prompt. .chat-agent-step { - margin: $space-2 $space-6 $space-2 $space-1; // 左缩进收到最小,让 bullet 视觉尽量靠前 + margin: $space-2 $space-6 $space-2 $space-1; // Left indent kept to a minimum so the bullet sits visually as far forward as possible font-size: $fs-sm; } -// 首行:bullet / spinner 标记 + 「已思考 / 思考中 xx s」提示,计时紧贴文本(不撑开到行尾)。 -// bullet 占固定列宽、与文案间留 gap 间距;正文按「列宽 + gap」缩进,保证首行文案与正文左对齐。 +// First row: bullet / spinner marker + "thought / thinking xx s" prompt, timer right against the text (not pushed to the line end). +// The bullet takes a fixed column width, with gap spacing to the text; body text indents by "column width + gap", keeping first-row text left-aligned with body text. .chat-agent-step-head { display: flex; align-items: center; - gap: $space-3; // bullet 与文案之间的留白 + gap: $space-3; // Whitespace between the bullet and the text color: $text-muted; } .chat-agent-step-bullet { - flex: 0 0 $space-3; // 固定 bullet 列宽,文本对齐的基准 + flex: 0 0 $space-3; // Fixed bullet column width, the baseline for text alignment color: $color-info; - font-size: $fs-md; // 略大,标记更醒目 + font-size: $fs-md; // Slightly larger, makes the marker more prominent line-height: 1; } -// 步骤行内的本步 token 用量(不累计):等宽数字、顶到行尾,与「已思考 xx s」同一行右侧轻量呈现。 +// Per-step token usage within the step row (not cumulative): tabular numerals, pushed to the line end, lightly presented on the right of the same row as "thought xx s". .chat-agent-step-tokens { margin-left: auto; flex-shrink: 0; @@ -163,7 +163,7 @@ font-variant-numeric: tabular-nums; font-size: $fs-xs; } -// AutoPilot 触发标记:评审首步行内的机器人图标(强调色),表「这次评审由 AutoPilot 触发」。 +// AutoPilot trigger marker: robot icon (accent color) within the review's first step row, indicating "this review was triggered by AutoPilot". .chat-agent-step-autopilot { display: inline-flex; align-items: center; @@ -174,7 +174,7 @@ display: block; } } -// 次行:步骤结果(思考内容 / 判读结论),缩进到首行文案的左缘(= bullet 列宽 + gap)对齐。 +// Second row: step result (thinking content / interpreted conclusion), indented to align with the left edge of the first-row text (= bullet column width + gap). .chat-agent-step-body { margin-top: $space-1; padding-left: $space-3 + $space-3; @@ -186,7 +186,7 @@ } } -// Agent 自然对话回复:左对齐、带 Agent 图标的轻量包装,区别于「评审总结」卡片(无判定 / 无标题)。 +// Agent natural conversation reply: left-aligned, lightweight wrapper with an Agent icon, distinct from the "review summary" card (no verdict / no title). .chat-agent-reply { margin: $space-3 $space-6; display: flex; @@ -208,7 +208,7 @@ min-width: 0; } -// === 空态 === +// === Empty state === .chat-empty { display: flex; flex-direction: column; diff --git a/apps/desktop/src/renderer/src/styles/features/chat/chip.scss b/apps/desktop/src/renderer/src/styles/features/chat/chip.scss index ab4c627b..64ec5322 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/chip.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/chip.scss @@ -1,11 +1,11 @@ -// chat 面板 chip:在 common chip 词汇(_mixins)之上叠 chat 自己的度量(padding / 字号 / -// 字距)。各类 chip 在 markup 组合 `chat-chip` + 形/尺/色修饰类,家族类只留专属差异。 -// 配色修饰由 $chip-tones 经 @each 生成 —— 扩展语义色只需在 map 加一条。 +// chat pane chip: layers chat's own metrics (padding / font size / letter spacing) on top of the common chip vocabulary (_mixins). +// Each chip composes `chat-chip` + shape/size/color modifier classes in markup; family classes keep only their specific differences. +// Color modifiers are generated by @each from $chip-tones — extending a semantic color just needs one more entry in the map. @use '../../tokens' as *; @use '../../mixins' as *; -// 共享本质 + chat 度量 +// Shared essence + chat metrics .chat-chip { @include chip; padding: 1px $space-3; @@ -14,7 +14,7 @@ letter-spacing: 0.02em; } -// 形 / 尺修饰(少数派覆盖) +// Shape / size modifiers (minority overrides) .chat-chip-pill { border-radius: $radius-pill; } // stage-tag @@ -26,19 +26,19 @@ } // finding-cat / phase / verdict .chat-chip-quiet { font-weight: 400; -} // run-chip / phase(非加粗) +} // run-chip / phase (non-bold) -// 配色修饰:由 common 色板词汇生成(accent / approved / warning / neutral) +// Color modifiers: generated from the common palette vocabulary (accent / approved / warning / neutral) @each $name, $pair in $chip-tones { .chat-chip-#{$name} { @include chip-tone($name); } } -// 实色填充(run-status 运行中等强调态) +// Solid fill (emphasis states like run-status running) .chat-chip-solid { @include chip-solid; } -// 描边(判定徽标:边框跟随 currentColor,由家族类设 color) +// Outline (verdict badge: border follows currentColor, family class sets the color) .chat-chip-outline { @include chip-outline; } diff --git a/apps/desktop/src/renderer/src/styles/features/chat/findings.scss b/apps/desktop/src/renderer/src/styles/features/chat/findings.scss index 93021764..0637dfae 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/findings.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/findings.scss @@ -1,5 +1,5 @@ -// chat 面板内 pr-agent 结构化结果卡片 (findings):按 sectionKey 着色的卡片、类别 chip、 -// 概览指标行、草稿状态/操作、评分、代码对比块、锚点跳转、PR Type 胶囊。 +// pr-agent structured result cards (findings) inside the chat pane: cards colored by sectionKey, category chip, +// overview metric row, draft status/actions, score, code comparison block, anchor jump, PR Type pills. @use '../../tokens' as *; @use '../../mixins' as *; @@ -22,12 +22,12 @@ font-size: $fs-lg; line-height: $lh-normal; - // 按 sectionKey 着不同的左条颜色,呈现"标准文档"骨架的视觉分组: - // - 元信息 (建议标题 / 类型) + 架构图 + 工作量 走主蓝 - // - 描述 / 走查 / 总结 + 测试 / 安全(概览结论)走绿 - // - 评分 走中性灰 - // - 代码反馈 / 改进建议 走警示色 (黄/橙) - // general (兜底) 不着色,保留默认灰 + // Color the left bar differently by sectionKey to present the visual grouping of the "standard document" skeleton: + // - meta info (suggested title / type) + architecture diagram + effort use main blue + // - description / walkthrough / summary + tests / security (overview conclusions) use green + // - score uses neutral gray + // - code feedback / improvement suggestions use warning color (yellow/orange) + // general (fallback) stays uncolored, keeping the default gray &.chat-finding-title, &.chat-finding-pr-type, &.chat-finding-diagram, @@ -43,7 +43,7 @@ &.chat-finding-ask-summary { border-left-color: $color-approved; } - // /ask 过程分析:中性灰(默认收起的过程性段,不抢眼) + // /ask process analysis: neutral gray (collapsed-by-default procedural section, unobtrusive) &.chat-finding-score, &.chat-finding-ask-analysis { border-left-color: $text-muted; @@ -56,22 +56,22 @@ &.chat-finding-general { border-left-color: $border-default; } - // 已拒绝:左色条转中性灰(盖过 code-feedback / suggestion 的警示色)、整卡降饱和收起 + // Rejected: left color bar turns neutral gray (overrides the warning color of code-feedback / suggestion), whole card desaturated and collapsed &.chat-finding-rejected { border-left-color: $text-muted; opacity: 0.68; } } -// 点击复评引用徽标定位到原 finding 卡片时的短暂高亮:用「覆盖式高亮环 + inset 淡色填充」而非背景渐隐 -// ——finding 卡有实底 $bg-elev,背景渐隐会被洗掉看不出闪烁;覆盖式与卡片底色无关、任意底色都清晰可见, -// 动画结束自动回落(fill-mode 默认 none)。高亮色 = 卡片左条主题色(--flash-color,按 sectionKey 取), -// 让定位反馈与卡片类别视觉一致(如代码建议=橙黄);已关闭/拒绝卡统一中性灰(盖过类别色)。 +// Brief highlight when clicking a re-review reference badge to locate the original finding card: uses an "overlay highlight ring + inset faint fill" rather than a background fade-out +// —finding cards have a solid $bg-elev background, so a background fade would be washed out and the flash invisible; the overlay is independent of the card background, clearly visible on any background, +// and falls back automatically when the animation ends (fill-mode defaults to none). Highlight color = the card's left bar theme color (--flash-color, taken by sectionKey), +// keeping the location feedback visually consistent with the card category (e.g. code suggestion = orange-yellow); closed/rejected cards use unified neutral gray (overriding the category color). .chat-finding-flash { --flash-color: #{$border-default}; animation: chat-finding-flash-kf 1.5s ease-out; } -// 类别色:与上面 .chat-finding 的 border-left-color 分组逐一对应。 +// Category colors: correspond one-to-one with the border-left-color groups of .chat-finding above. .chat-finding-title.chat-finding-flash, .chat-finding-pr-type.chat-finding-flash, .chat-finding-diagram.chat-finding-flash, @@ -96,7 +96,7 @@ .chat-finding-ask-suggestions.chat-finding-flash { --flash-color: #{$color-warning}; } -// 已关闭/拒绝卡:统一中性灰(语义=非活跃,盖过类别色)。须置于类别规则之后以同特异性覆盖。 +// Closed/rejected cards: unified neutral gray (semantics = inactive, overriding the category color). Must be placed after the category rules to override at the same specificity. .chat-finding-rejected.chat-finding-flash { --flash-color: #{$text-muted}; } @@ -118,17 +118,17 @@ gap: $space-3; margin-bottom: $space-3; } -// 收起态:正文已隐藏,头部的下间距会让卡片上下不对称(下方多出 margin-bottom)→ 归零,使上下内边距对称。 +// Collapsed state: body is hidden, and the header's bottom spacing would make the card vertically asymmetric (extra margin-bottom below) → zero it out so top/bottom padding is symmetric. .chat-finding-collapsed .chat-finding-head { margin-bottom: 0; } -// 可折叠卡:整行标题区即展开/收起热区(扩大可点面积,不止右侧 chevron)。 +// Collapsible card: the whole title row is the expand/collapse hotspot (enlarges the clickable area, not just the right-side chevron). .chat-finding-head-toggle { cursor: pointer; } -// 折叠/展开高度动画:grid-template-rows 0fr↔1fr(可对 auto 高度做过渡)。内容始终挂载,inner -// overflow:hidden + min-height:0 在收起时裁切——故收/展是平滑的高度过渡。仅可折叠卡会切到 -// collapsed → 0fr;其余卡恒 1fr、无动画。 +// Collapse/expand height animation: grid-template-rows 0fr↔1fr (allows transitioning auto height). Content is always mounted, inner +// overflow:hidden + min-height:0 clips when collapsed — so collapse/expand is a smooth height transition. Only collapsible cards switch to +// collapsed → 0fr; other cards stay 1fr with no animation. .chat-finding-collapsible { display: grid; grid-template-rows: 1fr; @@ -141,21 +141,21 @@ min-height: 0; overflow: hidden; } -// 尊重系统「减少动效」:关掉高度过渡与 chevron 旋转过渡。 +// Respect the system "reduce motion": turn off the height transition and the chevron rotation transition. @media (prefers-reduced-motion: reduce) { .chat-finding-collapsible, .chat-finding-collapse-toggle svg { transition: none; } } -// 分析解读(ask-analysis)展开时:chip 行下加一条分割线,与下方富文本内容(表格 / 代码块)衔接更自然, -// 避免头部 chip 直接贴正文显得突兀。仅展开态(无 chat-finding-collapsed)出现。 +// When analysis interpretation (ask-analysis) is expanded: add a divider below the chip row for a more natural transition to the rich-text content below (tables / code blocks), +// avoiding the abruptness of the header chip sitting directly against the body. Appears only in the expanded state (no chat-finding-collapsed). .chat-finding-ask-analysis:not(.chat-finding-collapsed) .chat-finding-head { padding-bottom: $space-3; border-bottom: 1px solid $border-default; } -// 分析解读正文:H2 作为小节标题——字号调小到正文级(靠加粗区分,不过分抢眼),且每个小节上方用分割线 -// 隔开,长解读分段清晰。首个 H2(正文起始即标题)不重复加线,避免与头部分割线叠成双线。 +// Analysis interpretation body: H2 as a subsection heading — font size reduced to body level (distinguished by bold, not overly prominent), and each subsection separated by a divider +// above it, keeping long interpretations clearly segmented. The first H2 (a title at the very start of the body) does not add a duplicate line, avoiding a double line stacking with the header divider. .chat-finding-ask-analysis .chat-finding-body { h2 { font-size: $fs-lg; @@ -169,18 +169,18 @@ border-top: none; } } -// 度量走 chat-chip + chat-chip-md;配色由 markup 的 chat-chip-<tone(key)> 决定 -// (元信息/图/工作量→accent,内容/测试/安全→approved,警示→warning,评分/兜底→neutral)。 +// Metrics go through chat-chip + chat-chip-md; color determined by markup's chat-chip-<tone(key)> +// (meta info/diagram/effort→accent, content/tests/security→approved, warning→warning, score/fallback→neutral). .chat-finding-cat { flex-shrink: 0; } -// 已拒绝卡片:类别 chip 一并置灰,盖过分类配色(与左色条置灰一致) +// Rejected card: the category chip is grayed out too, overriding the category color (consistent with the grayed-out left color bar) .chat-finding-rejected .chat-finding-cat { background: $bg-surface; color: $text-muted; } -// 已拒绝卡片头部的展开 / 收起切换:chevron 图标,推到头部最右,低调中性样式。 -// 图标在右侧,收起态朝左(◂,旋转 180° 指回内容),展开态朝下(▾,旋转 90°)。 +// Expand / collapse toggle in the rejected card header: chevron icon, pushed to the far right of the header, low-key neutral style. +// Icon on the right, points left when collapsed (◂, rotated 180° to point back at the content), points down when expanded (▾, rotated 90°). .chat-finding-collapse-toggle { margin-left: auto; flex-shrink: 0; @@ -193,7 +193,7 @@ color: $text-muted; cursor: pointer; - // ChevronIcon 原始朝右(▸);收起态旋转 180° 朝左 + // ChevronIcon originally points right (▸); collapsed state rotates 180° to point left svg { transition: transform 0.15s ease; transform: rotate(180deg); @@ -207,8 +207,8 @@ background: $bg-surface; } } -// 头部操作图标栏:编辑(评论)/ 拒绝(圆形禁止)/ 引用(转发箭头)成组贴右上角,排在折叠 chevron 之左。 -// margin-left:auto 顶到最右;与 collapse-toggle 并存时由本组吃掉左侧空白,二者贴右成一组。 +// Header action icon bar: edit (comment) / reject (circle ban) / reference (forward arrow) grouped at the top-right corner, placed to the left of the collapse chevron. +// margin-left:auto pushes it to the far right; when coexisting with collapse-toggle, this group absorbs the left whitespace so both sit right as one group. .chat-finding-head-actions { margin-left: auto; flex-shrink: 0; @@ -220,7 +220,7 @@ margin-left: 0; } } -// 头部图标按钮(编辑 / 拒绝):低调中性 → hover 主色;拒绝单独 → hover 高饱和危险红。 +// Header icon buttons (edit / reject): low-key neutral → hover accent color; reject alone → hover high-saturation danger red. .chat-finding-head-btn { display: inline-flex; align-items: center; @@ -240,7 +240,7 @@ color: $color-danger-strong; background: $color-danger-strong-fade; } -// 「引用」按钮:社媒「转发」箭头图标,排在编辑 / 拒绝之右。低调中性 → hover 主色。 +// "Reference" button: social-media "forward" arrow icon, placed to the right of edit / reject. Low-key neutral → hover accent color. .chat-finding-reference-btn { display: inline-flex; align-items: center; @@ -256,7 +256,7 @@ background: $bg-surface; } } -// 概览指标(工作量 / 测试 / 安全 / 评分):值很短,chip 与值排同一行、紧凑展示,不再各占两行。 +// Overview metrics (effort / tests / security / score): values are very short, chip and value on the same row, compact display, no longer two rows each. .chat-finding-effort, .chat-finding-relevant-tests, .chat-finding-security, @@ -272,7 +272,7 @@ .chat-finding-body { margin: 0; - // 去掉 markdown 段落块级外边距,使「2 🔵🔵」之类短值与 chip 同排 + // Remove the markdown paragraph block-level margin so short values like "2 🔵🔵" line up with the chip p { margin: 0; } @@ -287,18 +287,18 @@ min-width: 0; word-break: break-word; } -// 草稿状态 chip + 操作按钮:anchor 行右侧,跟 score chip 共用 margin-left:auto 推到末尾 +// Draft status chip + action buttons: right side of the anchor row, sharing margin-left:auto with the score chip to push to the end .chat-finding-draft-actions { margin-left: auto; display: inline-flex; align-items: center; gap: $space-2; - // 不可压缩 — anchor 行 path 太长时整段 chip+按钮要么完整显示要么换行, - // 不允许被压扁让按钮文字 wrap (用户实测"编辑"被换成两行) + // Non-shrinkable — when the anchor row path is too long, the whole chip+button segment either shows fully or wraps, + // not allowed to be squashed and let the button text wrap (user testing showed "edit" wrapped onto two lines) flex-shrink: 0; } -// 度量走 chat-chip + chat-chip-tight;配色由 markup 的 chat-chip-<tone(status)> 决定 -// (pending/edited→accent,posted→approved,rejected→neutral)。rejected 额外加删除线。 +// Metrics go through chat-chip + chat-chip-tight; color determined by markup's chat-chip-<tone(status)> +// (pending/edited→accent, posted→approved, rejected→neutral). rejected additionally adds a strikethrough. .chat-finding-draft-chip-rejected { text-decoration: line-through; } @@ -311,7 +311,7 @@ white-space: nowrap; } -// /improve 的重要度评分 chip:右浮在 anchor 行末尾,高分 (>=8) 加 warning 调色 +// /improve importance score chip: floated right at the end of the anchor row, high scores (>=8) get warning coloring .chat-finding-score { margin-left: auto; padding: 1px $space-2; @@ -328,7 +328,7 @@ } } -// /improve 建议的 existing → improved 代码片段对比块。两个独立 <pre>,红绿背景模拟 diff +// existing → improved code snippet comparison block for /improve suggestions. Two separate <pre>, red/green backgrounds simulating a diff .chat-finding-code-change { display: flex; flex-direction: column; @@ -362,8 +362,8 @@ display: flex; gap: $space-2; align-items: baseline; - // path 长 + actions 不可缩 (flex-shrink:0) 时,整段会被推到容器外。 - // 允许 wrap:path code 太长时 actions 换到下一行,永远在可见区内 + // When the path is long + actions non-shrinkable (flex-shrink:0), the whole segment gets pushed outside the container. + // Allow wrap: when the path code is too long, actions wrap to the next line, always within the visible area flex-wrap: wrap; code { @@ -372,15 +372,15 @@ border-radius: $radius-sm; font-family: $font-mono; color: $text-body; - // 长路径优先按分隔符 / 连接符折断(BreakablePath 在 / . _ - 后插了 <wbr> 软断点): - // word-break:normal 让浏览器只在这些软断点(及空格 / CJK)处折行,保证可读性;overflow-wrap:anywhere - // 仅作兜底——单个分段仍超宽时才允许任意断开,且让 code 作为 flex item 能正常收缩不撑爆容器。 + // Long paths break at separators / connectors first (BreakablePath inserts <wbr> soft break points after / . _ -): + // word-break:normal makes the browser wrap only at these soft break points (and spaces / CJK), ensuring readability; overflow-wrap:anywhere + // is just a fallback — only allows arbitrary breaking when a single segment is still too wide, and lets code as a flex item shrink normally without bursting the container. word-break: normal; overflow-wrap: anywhere; min-width: 0; } } -// 可点击锚点:path + 行号整体作为「跳转到代码对应行」的链接按钮 +// Clickable anchor: path + line number as a whole acts as a "jump to the corresponding code line" link button .chat-finding-anchor-link { @include anchor-link; display: inline-flex; @@ -403,19 +403,19 @@ font-size: $fs-lg; } -// PR Type 段:拆成胶囊横排。背景色 / 描边色 / 文字色 inline 由 pillStyle() 计算 -// (按标签内容 hash 出色相),这里只管布局 + 共通排版 +// PR Type section: split into a horizontal row of pills. Background / border / text colors are computed inline by pillStyle() +// (hues hashed from the label content); this only handles layout + common typography .chat-finding-pills { display: flex; flex-wrap: wrap; gap: $space-2; } -// PR Type 值胶囊并入头部行时:推到「类型」标签右侧、右对齐(多个仍可换行),单行省纵向空间。 +// When PR Type value pills are merged into the header row: pushed to the right of the "type" label, right-aligned (multiple can still wrap), single row saving vertical space. .chat-finding-pills-inline { margin-left: auto; justify-content: flex-end; } -// 值胶囊已并入头部行,头部即整卡内容,去掉下外边距避免多余留白。 +// Value pills are merged into the header row, so the header is the whole card content; remove the bottom margin to avoid extra whitespace. .chat-finding-pr-type .chat-finding-head { margin-bottom: 0; } @@ -427,8 +427,8 @@ font-weight: 500; letter-spacing: 0.02em; line-height: $lh-normal; - // 按标签 hue 自动上色(--pill-hue 由 pillStyle 注入;缺省回退中性蓝)。暗色主题:深底 + 亮字; - // 浅色主题在下方覆写为浅底 + 深字,避免沿用暗色深胶囊在浅背景上过重。 + // Auto-colored by the label hue (--pill-hue injected by pillStyle; falls back to neutral blue by default). Dark theme: dark background + bright text; + // light theme is overridden below to light background + dark text, avoiding the dark pill being too heavy on a light background. border: 1px solid hsl(var(--pill-hue, 210) 50% 32%); background: hsl(var(--pill-hue, 210) 55% 22%); color: hsl(var(--pill-hue, 210) 85% 78%); diff --git a/apps/desktop/src/renderer/src/styles/features/chat/pane.scss b/apps/desktop/src/renderer/src/styles/features/chat/pane.scss index 248e6d50..10b5f670 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/pane.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/pane.scss @@ -1,5 +1,5 @@ -// 右侧 pr-agent chat 面板:外壳 + 标题栏 + 滚动体容器 + 底部输入区。 -// run 卡片 / findings / agent 输出分别拆到 chat-run / chat-findings / chat-agent。 +// Right-side pr-agent chat pane: shell + title bar + scroll body container + bottom input area. +// run cards / findings / agent output are split into chat-run / chat-findings / chat-agent respectively. @use '../../tokens' as *; @use '../../mixins' as *; @@ -14,10 +14,10 @@ position: relative; } -// 折叠态:组件仍挂载 (保住 run 计时器 + runProgress 订阅) 但视觉上消失。 -// width:0 + overflow:hidden 替代 display:none,避免 width inline style 反复变化 -// 触发 layout thrash;inline style 上的 width 仍按用户上次设的值保留,下次展开 -// 直接生效 +// Collapsed state: the component stays mounted (keeping the run timer + runProgress subscription) but visually disappears. +// width:0 + overflow:hidden replaces display:none, avoiding repeated changes to the width inline style +// triggering layout thrash; the width on the inline style is kept at the user's last set value, taking effect +// directly on the next expand .chat-pane-collapsed { width: 0 !important; overflow: hidden; @@ -25,7 +25,7 @@ pointer-events: none; } -// 拖拽柄基样式:透明热区 + hover/active 主色高亮(左缘 col-resize 与输入区顶边 row-resize 共用) +// Drag handle base style: transparent hotspot + hover/active accent highlight (shared by the left-edge col-resize and the input area top-edge row-resize) %chat-resize-handle { position: absolute; z-index: $z-resize-handle; @@ -38,7 +38,7 @@ } } -// 拖拽柄:左缘,跟 sidebar 镜像 +// Drag handle: left edge, mirrors the sidebar .chat-pane-resize-handle { @extend %chat-resize-handle; top: 0; @@ -67,7 +67,7 @@ color: $text-muted; font-size: $fs-sm; } -// 清空历史按钮:顶到标题栏最右。hover 用统一的高饱和危险红(与删除单条记录 / .btn-icon-danger 一致)。 +// Clear history button: pushed to the far right of the title bar. hover uses the unified high-saturation danger red (consistent with deleting a single record / .btn-icon-danger). .chat-pane-clear { margin-left: auto; @@ -82,19 +82,19 @@ padding: $space-5 $space-6; display: flex; flex-direction: column; - // 顶层每个 chunk (run 卡片 / 运行中视图 / busy / error) 之间留较大间距 —— - // 比 .chat-run-result 内部 gap ($space-4) 更宽,让"运行边界"在视觉上清晰可辨, - // 避免上一条 finding 列表跟下一条 meta 行黏成一块 + // Larger spacing between each top-level chunk (run card / running view / busy / error) — + // wider than the internal gap of .chat-run-result ($space-4), making the "run boundary" visually distinguishable, + // avoiding the previous finding list sticking together with the next meta row gap: $space-8; - // flex column 子项默认 min-width:auto,宽内容(代码块 / 长串)会把子块撑过面板宽度 → 整条会话 - // 横向溢出。允许子块收缩到面板宽度,宽内容在块内横向滚动 / 换行,而非把整个会话撑出横向滚动条。 + // flex column children default to min-width:auto, and wide content (code blocks / long strings) would stretch a child past the pane width → the whole conversation + // overflows horizontally. Allow children to shrink to the pane width, with wide content scrolling / wrapping within the block, rather than stretching the whole conversation out with a horizontal scrollbar. > * { min-width: 0; } } -// "向上滚动加载更早" 提示行:顶端显眼但不抢眼,居中虚化 +// "Scroll up to load earlier" hint row: prominent at the top but not attention-grabbing, centered and faded .chat-run-more-hint { text-align: center; font-size: $fs-xs; @@ -102,7 +102,7 @@ border-bottom: 1px dashed $border-muted; } -// 当前 PR 命中的规则 chip:actions bar 下方一行,点击弹出正文预览 +// Rule chip matched by the current PR: a row below the actions bar, clicking pops up a body preview .chat-rule-chip { display: flex; align-items: center; @@ -133,7 +133,7 @@ flex: 1; } -// === 规划 Agent 计划面板 (PlanPanel):header 下、滚动区上,始终可见的复选清单 === +// === Planning Agent plan panel (PlanPanel): below the header, above the scroll area, an always-visible checklist === .chat-plan-panel { flex-shrink: 0; margin: 0 $space-6 $space-3; @@ -197,7 +197,7 @@ word-break: break-word; } -// === 底部交互输入区 (ChatInputBar) === +// === Bottom interactive input area (ChatInputBar) === .chat-pane-input { border-top: 1px solid $border-muted; padding: $space-4 $space-6 $space-5; @@ -206,15 +206,15 @@ gap: $space-3; flex-shrink: 0; background: $bg-panel-alt; - position: relative; // 给 .chat-cmd-suggest 浮层做定位基 + position: relative; // Positioning base for the .chat-cmd-suggest floating layer } -// textarea + 顶边拖动 handle 的容器 +// Container for the textarea + top-edge drag handle .chat-pane-textarea-wrap { position: relative; } -// 顶边自绘 handle:向上拖让 textarea 长高 (input 区在底部, 视觉是向上扩展, -// 跟操作方向一致)。CSS native `resize: vertical` 的右下角 handle 跟视觉方向反, -// 不用。 +// Top-edge custom-drawn handle: dragging up makes the textarea taller (the input area is at the bottom, visually expanding upward, +// consistent with the drag direction). The bottom-right handle of CSS native `resize: vertical` runs counter to the visual direction, +// so it is not used. .chat-pane-textarea-resize-handle { @extend %chat-resize-handle; top: -3px; @@ -224,7 +224,7 @@ cursor: row-resize; } .chat-pane-textarea { - resize: none; // 用自绘 handle 代替 CSS resize + resize: none; // Custom-drawn handle replaces CSS resize width: 100%; display: block; background: $bg-surface; @@ -235,8 +235,8 @@ line-height: $lh-normal; padding: $space-3 $space-4; border-radius: $radius-md; - overflow-y: auto; // 内容超过当前高度时内部滚动 - // 默认 2 行;JS 端 handle 拖动可调到 8 行;继续输入超过当前高度走内部滚动 + overflow-y: auto; // Scroll internally when content exceeds the current height + // Default 2 rows; the JS-side handle drag can adjust up to 8 rows; further input beyond the current height scrolls internally min-height: calc(#{$fs-lg} * #{$lh-normal} * 2 + #{$space-3} * 2); max-height: calc(#{$fs-lg} * #{$lh-normal} * 8 + #{$space-3} * 2); box-sizing: border-box; @@ -256,8 +256,8 @@ justify-content: space-between; gap: $space-4; } -// stop + send 包在一个 group 里,让 space-between 只对"/ 按钮 vs 这一组"生效, -// stop 跟 send 之间用 group 自己的 gap 控制贴近距离。发送区不参与压缩:窄宽度时挤压的是左侧 chip 而非发送键。 +// stop + send wrapped in one group so space-between applies only to "/ button vs this group", +// with the closeness between stop and send controlled by the group's own gap. The send area does not participate in compression: at narrow widths it is the left chip that gets squeezed, not the send key. .chat-pane-send-group { display: inline-flex; align-items: center; @@ -265,8 +265,8 @@ flex-shrink: 0; } -// 命令触发器组:`/` 命令按钮 + 自动评审图标按钮,左对齐成一组(两者间距略放宽)。 -// min-width:0 允许本组在窄宽度下收窄(由内部可伸缩的范围 / 选区 chip 让位、省略号截断),不推挤右侧发送区。 +// Command trigger group: `/` command button + auto-review icon button, left-aligned into one group (spacing between them slightly loosened). +// min-width:0 lets this group narrow at narrow widths (by the internally shrinkable range / selection chip yielding and truncating with an ellipsis), not squeezing the right-side send area. .chat-cmd-group { display: inline-flex; align-items: center; @@ -274,15 +274,15 @@ min-width: 0; } -// 命令触发器:单 `/` 按钮 + 弹出菜单 (替代之前 3 个并列按钮) +// Command trigger: single `/` button + popup menu (replacing the previous 3 side-by-side buttons) .chat-cmd-bar { position: relative; display: inline-flex; } .chat-cmd-trigger { - // 等宽按钮:`/` 命令触发器与自动评审图标按钮宽度一致、左对齐成一组;高度随内容(padding - // 决定),不固定行高外的额外高度,避免显得过高。共用 ghost 按钮外框。 + // Equal-width buttons: the `/` command trigger and the auto-review icon button have the same width, left-aligned into one group; height follows the content (determined by + // padding), no extra height beyond the line height, avoiding looking too tall. Shares the ghost button frame. @include ghost-btn; box-sizing: border-box; min-width: 30px; @@ -306,7 +306,7 @@ } } -// 自动评审图标按钮:复用 .chat-cmd-trigger 基样式,强调色区分。 +// Auto-review icon button: reuses the .chat-cmd-trigger base style, distinguished by accent color. .chat-agent-review-trigger { color: $color-info; @@ -317,8 +317,8 @@ } } -// AutoReview 与「选区角标」之间的竖线分隔:居中、定高(略低于按钮,不顶满); -// 额外左间距,与左侧按钮拉开(在组 gap 基础上再加)。 +// Vertical line divider between AutoReview and the "selection corner badge": centered, fixed height (slightly lower than the button, not filling fully); +// extra left spacing to pull away from the left button (added on top of the group gap). .chat-cmd-divider { width: 1px; height: 18px; @@ -328,13 +328,13 @@ background: $border-default; } -// Diff 选区角标:图标 + 「N 行已选中」。点击切忽略态——忽略时置灰 + 删除线 + eye-slash 图标。 +// Diff selection corner badge: icon + "N lines selected". Clicking toggles the ignore state — when ignored, grayed out + strikethrough + eye-slash icon. .chat-selection-chip { display: inline-flex; align-items: center; gap: $space-2; max-width: 180px; - // 窄宽度时自身收窄(span 省略号截断),把空间让给右侧发送区,而非撑破布局。 + // Narrows itself at narrow widths (span truncated with an ellipsis), yielding space to the right-side send area rather than bursting the layout. min-width: 0; padding: $space-2 $space-2; background: transparent; @@ -366,7 +366,7 @@ } } -// 复评引用 chip:复用选区 chip 视觉,着 info 色以区分;末尾带 ✕ 清除按钮(chip 本身不可点)。 +// Re-review reference chip: reuses the selection chip visuals, colored info to distinguish; ends with a ✕ clear button (the chip itself is not clickable). .chat-reference-chip { max-width: 220px; color: $color-info; @@ -392,12 +392,12 @@ } } -// `/` 按钮弹出的菜单:在按钮正上方展开 +// Menu popped up by the `/` button: expands directly above the button .chat-cmd-menu { @include popover; bottom: calc(100% + #{$space-2}); left: 0; - // 宽到能让最长一条 (label + desc) 单行展示,避免说明被挤换行 + // Wide enough to show the longest entry (label + desc) on a single line, avoiding the description being squeezed and wrapping min-width: 240px; width: max-content; max-width: 360px; @@ -407,17 +407,17 @@ border-radius: $radius-md; } -// 分组分隔:pragent 工具 (/review /describe /ask) ↔ review 决断 (/approve /needswork) -// 之间画一条横线,让两类操作在视觉上分开 +// Group separator: draw a horizontal line between pragent tools (/review /describe /ask) ↔ review decisions (/approve /needswork) +// to visually separate the two kinds of actions .chat-cmd-menu-group { border-top: 1px solid $border-muted; margin-top: $space-2; padding-top: $space-2; } -// `/` 自动补全浮层:positioned 在 textarea 正上方 (input 容器是 relative)。 -// 宽度跟 .chat-cmd-menu 对齐 —— 紧凑自适应,避免 space-between 把 label 和 -// desc 推到 textarea 两端 (700px+) 导致说明文字离指令太远不易读 +// `/` autocomplete floating layer: positioned directly above the textarea (the input container is relative). +// Width aligned with .chat-cmd-menu — compact and adaptive, avoiding space-between pushing label and +// desc to the two ends of the textarea (700px+), which would leave the description too far from the command and hard to read .chat-cmd-suggest { @include popover; bottom: 100%; @@ -435,7 +435,7 @@ .chat-cmd-suggest-item { display: flex; align-items: baseline; - // label 左 / desc 右,两端对齐 — 横向空间不够时不让 desc 换到下一行 + // label left / desc right, aligned to both ends — when horizontal space is insufficient, don't let desc wrap to the next line justify-content: space-between; gap: $space-6; width: 100%; @@ -454,27 +454,27 @@ white-space: nowrap; flex-shrink: 0; } - // desc 走 muted;不要换行,避免 "标记 PR 为需修改" 被拆成两行 + // desc goes muted; no wrapping, avoiding "mark PR as needs work" being split into two lines .muted { white-space: nowrap; } - // hover + 键盘选中态共用 .active;hover 通过 onMouseEnter 同步 selectedIdx 触发 + // hover + keyboard-selected state share .active; hover triggers by syncing selectedIdx via onMouseEnter &.active, &:hover { background: $bg-hover; } } -// 解析错误 (未知命令 / ask 缺 question) +// Parse error (unknown command / ask missing question) .chat-input-error { color: $color-danger; font-size: $fs-xs; margin-top: -$space-2; } -// 输入栏右侧的发送 / 停止按钮。跟左侧 `/` 命令按钮 (.chat-cmd-trigger) 共用 ghost 外框: -// 浅底 + 1px 边框 + radius-sm,hover 时主色描边 + 主色文字。 -// 同槽位形态切换:idle 纸飞机 / running stop 方块 +// Send / stop button on the right of the input bar. Shares the ghost frame with the left `/` command button (.chat-cmd-trigger): +// light background + 1px border + radius-sm, accent border + accent text on hover. +// Same-slot form switching: idle paper plane / running stop square .chat-pane-send { @include ghost-btn; display: inline-flex; @@ -487,14 +487,14 @@ color 0.12s, opacity 0.12s; flex-shrink: 0; - line-height: 1; // 让 svg 在中线对齐 / 命令按钮高度一致 + line-height: 1; // Aligns the svg on the midline / same height as the command button &:disabled { opacity: 0.5; cursor: not-allowed; } } -// Stop 形态:保留同样的边框结构但用饱和 danger 着色,hover 仍变深,明确表达"危险动作" +// Stop form: keeps the same border structure but colored with saturated danger, still darkening on hover, clearly conveying a "dangerous action" .chat-pane-send-stop { color: $color-danger-strong; &:hover:not(:disabled) { diff --git a/apps/desktop/src/renderer/src/styles/features/chat/run.scss b/apps/desktop/src/renderer/src/styles/features/chat/run.scss index 19fad80b..020a84a3 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/run.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/run.scss @@ -1,10 +1,10 @@ -// chat 面板内 pr-agent run 的状态输出:失败 / 占用 banner、运行中视图、run 结果头 -// (RunMeta 行 + 各类胶囊 + 状态徽标)、/ask 提问行、排队卡片。findings 见 chat-findings。 +// Status output of a pr-agent run in the chat panel: failure / busy banner, running view, run result header +// (RunMeta row + various pills + status badge), /ask question row, queued card. For findings see chat-findings. @use '../../tokens' as *; @use '../../mixins' as *; -// === 失败 banner === +// === failure banner === .chat-error { background: $color-danger-fade; border: 1px solid $color-danger-border; @@ -20,7 +20,7 @@ } } -// 重试按钮:ghost 图标按钮(跟 .chat-pane-send 共用外框),浮到 banner 顶行右侧 +// Retry button: ghost icon button (shares outer frame with .chat-pane-send), floated to the right of the banner top row .chat-run-retry { @include ghost-btn; float: right; @@ -41,7 +41,7 @@ color: $text-muted; } -// 失败 run 内嵌的 stderr / stdout 折叠区:在 chat-error 红色 banner 里多一层缩进 +// stderr / stdout collapsible area embedded in a failed run: one extra level of indentation inside the chat-error red banner .chat-error-stderr, .chat-error-stdout { margin-top: $space-3; @@ -53,7 +53,7 @@ padding: $space-1 0; user-select: none; } - // 内嵌的 stdout panel 用更小的字号 + 限高,避免遮住下方 finding 列表 + // The embedded stdout panel uses a smaller font size + height cap, to avoid covering the finding list below .chat-run-stdout { max-height: 280px; font-size: $fs-xs; @@ -61,17 +61,17 @@ } } -// === 运行中:spinner + 实时 stdout === +// === running: spinner + live stdout === .chat-run-running { display: flex; flex-direction: column; gap: $space-3; } -// 阶段推断 chip:从 stdout 命中关键字得到的当前阶段文案 (启动 / 解析 diff / -// 组装 prompt / 等待 LLM 响应)。放在 RunMeta 行下方独立一行,跟 stdout 之间 -// 用 gap 隔开 — 不挤进 chip 行避免横向溢出,单独一行也让"当前在做什么"更醒目 -// 度量/配色走 chat-chip + chat-chip-md + chat-chip-quiet + chat-chip-accent,这里只留布局 +// Phase-inference chip: current phase text derived from keyword hits in stdout (startup / parsing diff / +// assembling prompt / awaiting LLM response). Placed on its own row below the RunMeta row, separated from +// stdout by a gap — not crammed into the chip row to avoid horizontal overflow; a dedicated row also makes "what's happening now" more prominent. +// Metrics/coloring go through chat-chip + chat-chip-md + chat-chip-quiet + chat-chip-accent; only layout is kept here .chat-run-phase { align-self: flex-start; } @@ -91,15 +91,15 @@ overflow-y: auto; } -// === Run 结果 === +// === Run result === .chat-run-result { display: flex; flex-direction: column; gap: $space-4; } -// /ask 用户提问行:贴在 RunMeta 下方,左侧 `?` 图标 + 提问内容, -// 视觉上让人立即识别"这是用户输入而不是答案" +// /ask user question row: sits below RunMeta, `?` icon on the left + question content, +// visually letting the reader immediately recognize "this is user input, not the answer" .chat-user-msg { display: flex; gap: $space-3; @@ -115,7 +115,7 @@ svg { flex-shrink: 0; color: $color-info; - margin-top: 2px; // 跟首行文字基线对齐 + margin-top: 2px; // align with the first line's text baseline } } .chat-user-msg-body { @@ -125,14 +125,14 @@ } .chat-run-meta { display: flex; - flex-wrap: wrap; // 空间不够时整块 chip 换行,而非互相挤压重叠 + flex-wrap: wrap; // when space runs out, wrap whole chips instead of squeezing/overlapping each other align-items: center; gap: $space-3; font-size: $fs-sm; padding-bottom: $space-3; border-bottom: 1px solid $border-muted; - // chip 不收缩:保持各自完整宽度,宁可换行也不压缩文字造成重叠 + // chips don't shrink: keep each at full width, wrapping rather than compressing text into overlap > * { flex-shrink: 0; } @@ -140,43 +140,43 @@ .chat-run-tool { font-family: $font-mono; font-weight: 600; - font-size: $fs-xs; // 跟 chip 一档;align-items:center 下基线就齐了 + font-size: $fs-xs; // same tier as the chip; under align-items:center the baseline aligns color: $color-info; } -// 中性胶囊:模型 / token 用量 / 耗时共用 chat-chip + chat-chip-quiet + chat-chip-neutral; -// 各自只补字体差异(下方 mono / 截断)。 +// Neutral pills: model / token usage / duration share chat-chip + chat-chip-quiet + chat-chip-neutral; +// each only adds font differences (mono / truncation below). .chat-run-tokens, .chat-run-turns, .chat-run-duration { font-family: $font-mono; font-variant-numeric: tabular-nums; } -// 轮次胶囊:循环箭头图标 + 次数,图标与数字对齐 +// Turns pill: loop-arrow icon + count, icon aligned with the number .chat-run-turns { display: inline-flex; align-items: center; gap: 3px; } -// 单 commit 范围徽标:提交图标 + 短 SHA,图标与 SHA 对齐(等宽字体) +// Single-commit scope badge: commit icon + short SHA, icon aligned with the SHA (monospace font) .chat-run-scope { display: inline-flex; align-items: center; gap: 3px; font-family: $font-mono; } -// 输入 / 输出各自一组(箭头 + 数字[+ 缓存]):独立 hover 提示的悬浮目标,内部行内对齐 +// Input / output each form a group (arrow + number[+ cache]): a hover target for an independent hover tooltip, aligned inline internally .chat-token-grp { display: inline-flex; align-items: center; } -// token 用量箭头:↑输入(绿) / ↓输出(红),run 卡片与思考步骤共用 +// Token usage arrows: ↑input (green) / ↓output (red), shared by the run card and thinking steps .chat-token-in { color: $color-token-in; } .chat-token-out { color: $color-token-out; } -// cache_read 拆分(↑总量 后的缓存命中量):与输入数字拉开间隔,数字同色、柱体图标浅蓝并与数字对齐 +// cache_read split (cache-hit amount after ↑total): spaced apart from the input number, same-colored number, light-blue bar icon aligned with the number .chat-token-cache { display: inline-flex; align-items: center; @@ -187,14 +187,14 @@ color: $color-token-cache; } } -// 模型名可能很长:单行截断 + 省略号,避免一个 chip 撑爆整行 +// Model name can be very long: single-line truncation + ellipsis, so one chip doesn't blow out the whole row .chat-run-model { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } -// 开始时间:纯文本不带胶囊背景;margin-left:auto 顶到 chip 队列最右,把视觉 -// 权重让给左侧主信息 (tool/状态/strategy/时长),自己只作为 "什么时候跑的" 标注 +// Start time: plain text without a pill background; margin-left:auto pushes it to the far right of the chip queue, ceding visual +// weight to the main info on the left (tool/status/strategy/duration), serving only as a "when it ran" annotation .chat-run-time { margin-left: auto; font-family: $font-mono; @@ -203,7 +203,7 @@ color: $text-muted; white-space: nowrap; } -// 状态行最右的删除按钮:默认低调隐于行内,hover 变红强调「删除本条记录」。 +// Delete button at the far right of the status row: subdued and blended into the row by default, turns red on hover to emphasize "delete this record". .chat-run-delete { display: inline-flex; align-items: center; @@ -216,14 +216,14 @@ border-radius: $radius-sm; opacity: 0.6; - // hover 用统一的高饱和危险红($color-danger-strong),与 .btn-icon-danger / 拒绝按钮一致 + // hover uses the unified high-saturation danger red ($color-danger-strong), consistent with .btn-icon-danger / the reject button &:hover { background: $color-danger-strong-fade; color: $color-danger-strong; opacity: 1; } } -// 度量走 chat-chip;这里只留 status 专属:大写 + spinner gap + 实色状态变体 +// Metrics go through chat-chip; only status-specific bits are kept here: uppercase + spinner gap + solid-color status variants .chat-run-status { gap: $space-2; text-transform: uppercase; @@ -232,9 +232,9 @@ background: $color-accent; color: $text-on-accent; - // status 内嵌 Spinner 跟"运行中"文字 inline 排。.spinner 默认 12×12, - // border-top-color: $color-accent 在蓝底上看不见 — 这里 override 用半透明 - // 白 + 实白顶部,跟 chip 文字色对比明显 + // The Spinner embedded in status lays out inline with the "running" text. .spinner defaults to 12×12, + // and border-top-color: $color-accent is invisible on a blue background — here we override with translucent + // white + solid white top, giving clear contrast against the chip text color .spinner { width: 10px; height: 10px; @@ -257,19 +257,19 @@ background: $border-default; color: $text-primary; } - // 排队中:弱化的中性胶囊,区别于运行中的高亮蓝 + // queued: a muted neutral pill, distinct from the highlighted blue of running &.chat-run-status-queued { background: $bg-surface; color: $text-muted; } } -// 排队中卡片:比运行中更轻,留点上下间距跟历史/运行中区隔 +// Queued card: lighter than running, with some top/bottom spacing to separate it from history/running .chat-run-queued { padding: $space-3 0; opacity: 0.85; } -// 取消排队按钮:跟在 meta 行最右,纯图标 +// Cancel-queue button: trails at the far right of the meta row, icon-only .chat-run-queued-cancel { margin-left: auto; display: inline-flex; @@ -302,13 +302,13 @@ } } -// 复评「复评自 <file:line>」徽标:可点击回链被引用的原 finding 所在 run。 +// Re-review "re-reviewed from <file:line>" badge: a clickable back-link to the run containing the referenced original finding. .chat-run-ref-badge { align-self: flex-start; max-width: 100%; display: inline-flex; - // 图标对齐定位文本首行(不随多行路径垂直居中而单独成行);图标与定位文本不在徽标层换行—— - // 换行交给定位文本内部,否则定位文本整体掉到图标下一行、图标被孤立成单独一行。 + // Icon aligns with the first line of the location text (not centered onto its own line with a multi-line path); icon and location text don't wrap at the badge level — + // wrapping is delegated to inside the location text, otherwise the whole location text drops to the line below the icon and the icon is isolated on its own line. align-items: flex-start; flex-wrap: nowrap; gap: $space-1 $space-2; @@ -323,19 +323,19 @@ svg { flex-shrink: 0; - // 12px 图标在首行内与文字光学对齐(align-items:flex-start 顶对齐后略微下压)。 + // The 12px icon is optically aligned with the text within the first line (nudged down slightly after align-items:flex-start top-aligns it). margin-top: 2px; } .chat-run-ref-loc { - // 行内文本流(非 flex):路径与行号作为连续行内内容换行——行号紧跟路径末行,而非作为独立 flex - // 项掉到新行。作为徽标 flex 子项 display 被 blockify 为块、占余宽并在内部换行(首行仍与图标同排)。 + // Inline text flow (not flex): path and line number wrap as continuous inline content — the line number follows the path's last line, rather than dropping to a new line as an independent flex + // item. As a badge flex child its display is blockified to block, taking the remaining width and wrapping internally (the first line still sits alongside the icon). display: block; min-width: 0; flex: 1 1 auto; } code { font-family: $font-mono; - // 换行规则同代码建议定位(.chat-finding-anchor code):优先 BreakablePath 的软断点,超宽兜底任意断。 + // Wrapping rules match the code-suggestion location (.chat-finding-anchor code): prefer BreakablePath's soft break points, falling back to arbitrary breaks when overly wide. word-break: normal; overflow-wrap: anywhere; min-width: 0; @@ -345,7 +345,7 @@ } } -// 复评卡 ↔ 原 finding 卡互链滚动定位时的短暂高亮。 +// Brief highlight when the re-review card ↔ original finding card cross-link scrolls into position. .chat-run-flash { animation: chat-run-flash-kf 1.5s ease-out; } diff --git a/apps/desktop/src/renderer/src/styles/features/chat/statusbar.scss b/apps/desktop/src/renderer/src/styles/features/chat/statusbar.scss index 25345184..3510cfee 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/statusbar.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/statusbar.scss @@ -1,11 +1,11 @@ -// chat 域在底部状态栏的 chip:AutoPilot 开关 + pr-agent 活动指示 / 队列 popover。 -// 组件见 components/features/chat/statusbar/。基础壳 (.app-statusbar / .statusbar-chip) -// 在 layout/statusbar;活动脉冲点 .activity-dot 在 base(与 repo sync 跨域共用)。 +// chat-domain chips in the bottom status bar: AutoPilot toggle + pr-agent activity indicator / queue popover. +// For components see components/features/chat/statusbar/. The base shell (.app-statusbar / .statusbar-chip) +// is in layout/statusbar; the activity pulse dot .activity-dot is in base (shared cross-domain with repo sync). @use '../../tokens' as *; @use '../../mixins' as *; -// AutoPilot 开关 chip:机器人图标(开)/ 斜杠机器人(关)+ 文案;默认中性,启用时蓝底强调 +// AutoPilot toggle chip: robot icon (on) / slashed robot (off) + label; neutral by default, blue-background emphasis when enabled .statusbar-chip-autopilot { cursor: pointer; @@ -15,13 +15,13 @@ } } -// 队列弹出框定位基:active chip 外面套一层 relative 让 popover 绝对定位 +// Queue popover positioning base: wrap the active chip in a relative layer so the popover can position absolutely .statusbar-pragent-chip-wrap { position: relative; display: inline-flex; } -// pr-agent 运行中指示。蓝色调跟 chat-run-phase 一致;button 形态时给 hover 边框反馈 +// pr-agent running indicator. Blue tone consistent with chat-run-phase; provides a hover border feedback in button form .statusbar-pragent-chip { @include chip-tone(accent); font-variant-numeric: tabular-nums; @@ -34,17 +34,17 @@ button.statusbar-pragent-chip { border-color: $color-accent; } } -// Idle 形态:去掉主蓝色调,换成中性灰底白字,区分"在跑"和"空闲"两种语义 +// Idle form: drop the primary blue tone, switch to a neutral gray background with white text, distinguishing the "running" and "idle" semantics .statusbar-pragent-chip-idle { background: $chip-neutral; color: $text-muted; } .statusbar-pragent-elapsed { - // 不用 mono 字体 — sans 和 mono 的 x-height 差异让 flex center 后视觉基线不齐。 - // 数字等宽靠父级 .statusbar-pragent-chip 的 font-variant-numeric: tabular-nums + // Don't use a mono font — the x-height difference between sans and mono misaligns the visual baseline after flex center. + // Number monospacing relies on the parent .statusbar-pragent-chip's font-variant-numeric: tabular-nums opacity: 0.75; } -// 队列数尾缀 (+N):跟 elapsed 视觉一致但显得是 "另外排队" 不是 "时间" +// Queue-count suffix (+N): visually consistent with elapsed but reads as "additionally queued" rather than "time" .statusbar-pragent-queue-count { font-family: $font-mono; font-weight: 600; @@ -53,8 +53,8 @@ button.statusbar-pragent-chip { background: rgba(255, 255, 255, 0.1); } -// === 队列 popover === -// chip 上方弹出。最多 6 行高 + 内部滚动,避免遮住其他状态栏元素 +// === queue popover === +// Pops up above the chip. At most 6 rows tall + internal scrolling, to avoid covering other status bar elements .statusbar-queue-popover { @include popover; bottom: calc(100% + #{$space-2}); @@ -79,7 +79,7 @@ button.statusbar-pragent-chip { list-style: none; margin: 0; padding: 0; - // 最多 6 条 (active 1 + 排队 5);超出滚动。每行约 32px (padding+content) + // At most 6 entries (1 active + 5 queued); scrolls when exceeded. Each row is about 32px (padding+content) max-height: calc(32px * 6); overflow-y: auto; } diff --git a/apps/desktop/src/renderer/src/styles/features/config-picker.scss b/apps/desktop/src/renderer/src/styles/features/config-picker.scss index d3ce69a0..5c52bdd8 100644 --- a/apps/desktop/src/renderer/src/styles/features/config-picker.scss +++ b/apps/desktop/src/renderer/src/styles/features/config-picker.scss @@ -1,18 +1,18 @@ @use '../tokens' as *; -// 配置选择器:左右两栏布局 —— 左侧集成平台 / LLM provider 选择列表,右侧受控表单。 -// 由 settings 域的 PlatformPicker / LlmProviderPicker 复用,首启向导与设置面板子模态共用同一视觉。 -// (从原 .onboarding-platform-* / .onboarding-provider-* 收敛为中性类,统一在 settings 维护。) +// Config picker: two-column layout —— integration platform / LLM provider selection list on the left, controlled form on the right. +// Reused by the settings domain's PlatformPicker / LlmProviderPicker, the onboarding wizard and settings panel sub-modal share the same visuals. +// (Converged from the original .onboarding-platform-* / .onboarding-provider-* into neutral classes, maintained uniformly in settings.) .config-pick-grid { display: grid; - // 左列固定 220px:容下最长副标题(如「gitlab.com / Self-Managed」)单行不折行 + // Left column fixed at 220px: fits the longest subtitle (e.g. "gitlab.com / Self-Managed") on a single line without wrapping grid-template-columns: 220px 1fr; gap: $space-8; align-items: start; } .config-pick-form { - min-width: 0; // 防止右栏内容把 grid 撑出 + min-width: 0; // prevent right-column content from blowing out the grid } .config-pick-list { @@ -21,19 +21,19 @@ gap: $space-3; } -// 设置子模态内的左右两栏:固定高度,避免切换 provider 时右侧表单高矮变化导致模态抖动。 -// 左侧列表与右侧表单各自在该固定高度内独立滚动(自适应填满,内容不足时下方留白、内容超出则滚动), -// 整体高度恒定。向导里在大卡片中铺开、不加此类,保持自适应。 +// Two columns inside the settings sub-modal: fixed height, avoiding modal jitter when the right form's height changes on provider switch. +// The left list and right form each scroll independently within that fixed height (adaptively filling, whitespace below when content is short, scroll when content overflows), +// with overall height constant. In the wizard it spreads out in a large card without this class, staying adaptive. .config-pick-grid-fixed { height: 190px; align-items: stretch; > .config-pick-list, > .config-pick-form { - min-height: 0; // 允许在 grid 行内收缩,从而触发自身滚动 + min-height: 0; // allow shrinking within the grid row, thereby triggering its own scroll overflow-y: auto; } - // 右侧表单顶部留半行余白,第一项不贴顶、与左侧列表卡片视觉更协调 + // Half a line of whitespace at the top of the right form, so the first item isn't flush to the top and reads more in harmony with the left list cards > .config-pick-form { padding-top: $space-5; } @@ -61,8 +61,8 @@ box-shadow: inset 3px 0 0 $color-accent; color: $text-primary; } - // 置灰:未实现平台 / 只读态的非当前项。仅 .disabled 控制视觉淡化, - // 交互失效单独用 HTML disabled 属性 —— 这样只读态下「当前选中项」可保持高亮不被淡化。 + // Grayed out: unimplemented platforms / non-current items in read-only state. Only .disabled controls the visual dimming, + // interaction disabling uses the HTML disabled attribute separately —— this way the "currently selected item" in read-only state stays highlighted without being dimmed. &.disabled { opacity: 0.5; cursor: not-allowed; @@ -73,7 +73,7 @@ display: inline-flex; justify-content: center; flex-shrink: 0; - width: 24px; // 固定宽度,保证各项图标占位一致、文字起点不漂移 + width: 24px; // fixed width, ensuring each item's icon takes consistent space and text start doesn't drift &.muted-icon { color: $text-muted; @@ -81,7 +81,7 @@ } } -// 平台项:图标右侧名称 + 副标题居中 +// Platform item: name to the right of the icon + centered subtitle .config-pick-text { flex: 1; display: flex; @@ -99,9 +99,9 @@ .config-pick-meta { color: $text-muted; font-size: $fs-sm; - white-space: nowrap; // 副标题始终单行,避免被拆词换行 + white-space: nowrap; // subtitle always on a single line, avoiding word-break wrapping } -// provider 项:名称占满图标右侧、过长省略号(无副标题,与平台项区分) +// provider item: name fills the space right of the icon, ellipsis when too long (no subtitle, distinguishing it from platform items) .config-pick-name-fill { flex: 1; min-width: 0; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/_index.scss b/apps/desktop/src/renderer/src/styles/features/diff/_index.scss index a44c40c7..df35d07b 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/_index.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/_index.scss @@ -1,5 +1,5 @@ -// DiffView 样式簇入口:@forward 各 partial,App.scss 用 `@use '.../features/diff'` 一行引入。 -// 新增 diff partial 在此登记即可,无需改 App.scss。 +// DiffView style cluster entry: @forward each partial, App.scss imports in one line via `@use '.../features/diff'`. +// Register a new diff partial here; no need to touch App.scss. @forward 'view'; @forward 'search'; @forward 'file-tree'; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/blame.scss b/apps/desktop/src/renderer/src/styles/features/diff/blame.scss index 26e4264f..e5e4e80f 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/blame.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/blame.scss @@ -1,9 +1,9 @@ -// DiffView 的 Bitbucket 风格 blame 列:toolbar 开关 + 左侧 blame 栏(头像/名/sha/日期 4 列 grid)。 -// 见 components/features/pr/tabs/diff/blame/BlameColumn。toolbar 内的尺寸钉死在 layout/main-pane 的 .pr-tabs-right。 +// DiffView's Bitbucket-style blame column: toolbar toggle + left blame bar (avatar/name/sha/date 4-column grid). +// See components/features/pr/tabs/diff/blame/BlameColumn. Sizing inside the toolbar is pinned in layout/main-pane's .pr-tabs-right. @use '../../tokens' as *; -// blame 开关:跟 .diff-mode-toggle button 同尺寸(高度由 .pr-tabs-right 内统一 height 钉死) +// blame toggle: same size as .diff-mode-toggle button (height pinned by the unified height inside .pr-tabs-right) .blame-toggle { display: inline-flex; align-items: center; @@ -27,8 +27,8 @@ } } -// blame 列:独立 React 组件,做 diff-pane-wrapper 的左侧 flex 子项。 -// 内部 row 用 absolute 子项画,按 Monaco scrollTop 实时同步位置。 +// blame column: standalone React component, acts as the left flex child of diff-pane-wrapper. +// Inner rows are drawn as absolute children, syncing position in real time to Monaco scrollTop. .blame-column { flex-shrink: 0; background: $bg-panel-alt; @@ -44,10 +44,10 @@ height: 100%; } -// 4 列 grid 固定布局:头像 | 名 | sha | 日期。 -// - 区块跨多行时,内容贴顶部(align-content: start);下方留空给该 commit 的后续行 -// - 顶部一行的 track 高度 = --blame-lh(Monaco 实际行高),跟第一行代码同 Y、同高 -// - 行内三个文本 + 头像 align-items: center 共享同一垂直中线,避免错位 +// Fixed 4-column grid layout: avatar | name | sha | date. +// - when a block spans multiple lines, content sticks to the top (align-content: start); space below is left for that commit's following lines +// - top row's track height = --blame-lh (Monaco's actual line height), same Y and height as the first code line +// - the three texts + avatar in a row share the same vertical centerline via align-items: center, avoiding misalignment .blame-row { position: absolute; left: 0; @@ -71,15 +71,15 @@ background: $bg-hover; } - // 头像在 row 中也以 lineHeight 为基准居中:grid align-items: center 已经处理, - // 这里只统一尺寸 + 不收缩 + // avatar is also centered in the row on the lineHeight baseline: grid align-items: center already handles it, + // here we only unify size + prevent shrinking .avatar { width: 18px; height: 18px; } } -// 三个文本元素共享 line-height + font-size,baseline 一致 +// the three text elements share line-height + font-size, keeping a consistent baseline .blame-row-name, .blame-row-sha, .blame-row-date { @@ -105,7 +105,7 @@ text-align: right; } -// PR 改动行色带:左侧 3px 绿条,对齐 Monaco diff 的 "added" 装饰色 +// PR change-line color band: left 3px green bar, aligned with Monaco diff's "added" decoration color .blame-row-change { position: absolute; left: 0; @@ -117,7 +117,7 @@ box-sizing: border-box; } -// 折叠占位行("X hidden lines"):斜纹背景 + 灰底,表示"非真实行 / 无 blame" +// collapsed placeholder row ("X hidden lines"): diagonal-stripe background + gray base, indicating "not a real line / no blame" .blame-row-fold { position: absolute; left: 0; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss b/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss index da5adaa5..d5de40e1 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/comment-zone.scss @@ -1,37 +1,37 @@ -// Monaco 行内评论 view zone + glyph margin 标记。 -// 通过 DiffView 内 viewZone 注入 DOM;样式跟 markdown.scss 配合, -// .comment-zone-body 也会加 .markdown class 走 markdown 视觉规则。 +// Monaco inline comment view zone + glyph margin marker. +// DOM is injected via viewZone inside DiffView; styles work with markdown.scss, +// .comment-zone-body also gets the .markdown class to follow markdown visual rules. @use '../../tokens' as *; -// 结构 (跟 DraftZone 同): -// .monaco-comment-zone (= dom,monaco 控制 style.height inline,不能放视觉样式) -// .monaco-comment-zone-inner (= 内容容器,背景 / padding / 边框全在这) -// .comment-zone-inner (= CommentZone React 根,flex column 列表) +// Structure (same as DraftZone): +// .monaco-comment-zone (= dom, monaco controls style.height inline, no visual styles here) +// .monaco-comment-zone-inner (= content container, background / padding / border all here) +// .comment-zone-inner (= CommentZone React root, flex column list) // -// dom 自身设了视觉样式时蓝框尺寸 = monaco wrapper 高度 ≠ 内容高度,图片异步 -// 加载 / 嵌套评论展开后会出现内容溢出。挪到 inner + RO 监听 inner.offsetHeight -// 同步 zoneObj.heightInPx 让 zone 自动撑高 +// When the dom itself sets visual styles, the blue box size = monaco wrapper height ≠ content height, so +// content overflows after async image loading / nested comment expansion. Moved to inner + RO watching inner.offsetHeight +// to sync zoneObj.heightInPx so the zone grows automatically .monaco-comment-zone { overflow: visible; - // monaco view zone 内部某层 z-index 较高会遮挡 view zone DOM 的鼠标命中 - // (跟 DraftZone z-index:50 同套防御),不然 click 完全到不了 inner 内 img/a + // some layer inside the monaco view zone with a higher z-index would block mouse hits on the view zone DOM + // (same defense as DraftZone z-index:50), otherwise clicks can't reach img/a inside inner at all z-index: 50; } .monaco-comment-zone-inner { position: relative; z-index: 51; } -// monaco editor 全局会给 view zone 内 element 加 user-select: none / cursor:text, -// 让评论变得既不能选文字也不能点链接。这里 reset 让评论变成"普通可交互内容": -// - user-select: text 让浏览器允许 selection -// - cursor: text 让 hover 评论体看起来"像可选文本" -// - a / .bitbucket-image 覆盖各自语义 cursor +// The monaco editor globally adds user-select: none / cursor:text to elements inside the view zone, +// making comments neither text-selectable nor link-clickable. This reset turns comments into "ordinary interactive content": +// - user-select: text lets the browser allow selection +// - cursor: text makes the hovered comment body look "like selectable text" +// - a / .bitbucket-image override their respective semantic cursors .monaco-comment-zone, .monaco-comment-zone-inner { - // monaco view zone 默认可能给子元素设 pointer-events: none 让代码命中区干净 - // (DraftZone 工作是因为 button/textarea 浏览器特殊处理)。显式 auto 让 Bitbucket - // 评论里 img / a 也能接收 click + // the monaco view zone may by default set pointer-events: none on children to keep the code hit area clean + // (DraftZone works because browsers handle button/textarea specially). Explicit auto lets img / a inside Bitbucket + // comments receive clicks too pointer-events: auto !important; } .monaco-comment-zone-inner { @@ -45,10 +45,10 @@ cursor: zoom-in !important; } -// 回复 / 删除按钮:默认 hidden,hover 父评论时显示,避免常驻占视觉 +// reply / delete buttons: hidden by default, shown on hovering the parent comment, to avoid always-on visual clutter .comment-zone-foot { margin-top: $space-2; - min-height: 22px; // 占位防 hover 抖动 + min-height: 22px; // placeholder to prevent hover jitter display: flex; gap: $space-3; align-items: center; @@ -70,13 +70,13 @@ border-color: $color-accent; color: $color-info; } -// 编辑按钮:中性操作走主色描边,跟"回复"风格相似但有微差让人区分 +// edit button: neutral action uses the accent-color outline, similar to "reply" style but with a slight difference to distinguish .comment-zone-edit-btn:hover { border-color: $color-info; color: $color-info; background: rgb(var(--rgb-accent) / 0.08); } -// 删除按钮主色用 danger,跟其它按钮拉开语义;disabled 时半透明 +// delete button uses danger as its main color to differentiate its semantics from other buttons; semi-transparent when disabled .comment-zone-delete-btn:hover:not(:disabled) { border-color: $color-danger-strong; color: $color-danger-strong; @@ -86,12 +86,12 @@ opacity: 0.4; cursor: not-allowed !important; } -// 「加反应」按钮跟 foot 其它按钮一样默认隐藏、hover / focus 才显(已有反应 chips 常驻可见、不在此列)。 +// The "add reaction" button, like other foot buttons, is hidden by default and shown only on hover / focus (existing reaction chips stay always visible, not in this group). .comment-zone-foot .pr-reaction-add-wrap { opacity: 0; transition: opacity 0.12s; } -// hover comment body 区时显示 foot 里的所有按钮(含加反应);focus-within 覆盖键盘 / 反应弹层打开态 +// on hovering the comment body area, show all buttons in foot (including add reaction); focus-within covers keyboard / reaction popover open states .comment-zone-item-body:hover > .comment-zone-foot .comment-zone-reply-btn, .comment-zone-item-body:hover > .comment-zone-foot .comment-zone-edit-btn, .comment-zone-item-body:hover > .comment-zone-foot .comment-zone-delete-btn, @@ -106,7 +106,7 @@ opacity: 1; } -// 删除失败 inline 红条 (跟 .pr-comment-delete-error 同模) +// inline red bar for delete failure (same pattern as .pr-comment-delete-error) .comment-zone-delete-error { display: flex; align-items: flex-start; @@ -138,7 +138,7 @@ background: $bg-panel-alt; border-left: 3px solid $color-accent; padding: $space-3 $space-6 $space-4 $space-8; - // 跟代码字号同等 (14px) — 评论不该比代码小 + // same font size as code (14px) — comments should not be smaller than code font-size: $fs-lg; color: $text-primary; font-family: @@ -179,30 +179,30 @@ } } -// 评论正文 / reply 与头像列对齐:左侧缩进 = 头像宽(18) + head gap(8) = 26 +// comment body / reply aligned with the avatar column: left indent = avatar width(18) + head gap(8) = 26 .comment-zone-body { word-wrap: break-word; margin-top: $space-2; padding-left: 26px; } -// margin-left 由 DiffView 内 CommentNode 按 depth 计算后写到 style 里 -// (前 5 层每层 18px,第 6+ 层 0 跟第 5 层平齐) +// margin-left is computed by depth in CommentNode inside DiffView and written into style +// (first 5 levels 18px each, level 6+ is 0, flush with level 5) .comment-zone-reply { margin-top: $space-4; - // 每层步进 12px + 左竖线 —— 缩进量与边框色对齐评论 tab 的 .pr-comments-replies($space-6 / $border-muted)。 + // 12px step per level + left vertical line —— indent amount and border color align with the comments tab's .pr-comments-replies ($space-6 / $border-muted). margin-left: $space-6; padding: $space-2 0 $space-2 $space-5; border-left: 2px solid $border-muted; - // 字号继承父级 (.monaco-comment-zone-inner 14px),不再缩减 — 嵌套 reply 跟 - // 父评论同等可读性,视觉层级靠左侧 border + indent 区分就够 + // font size inherits from parent (.monaco-comment-zone-inner 14px), no further shrinking — nested reply has + // the same readability as the parent comment; the left border + indent are enough to distinguish visual hierarchy & + & { margin-top: $space-3; } - // 满 MAX_REPLY_INDENT_DEPTH 层后拉平:去步进 / 去左竖线 / 去左 padding,更深回复平铺在上限层级上。 - // 必须三者全去 —— 仅去 margin 步进、保留 padding-left + border 会逐级累加仍右移("还是有缩进"的根因)。 + // flatten after reaching MAX_REPLY_INDENT_DEPTH levels: drop the step / left vertical line / left padding, deeper replies lay flat at the cap level. + // All three must be dropped —— dropping only the margin step while keeping padding-left + border accumulates per level and still shifts right (the root cause of "still indented"). &.comment-zone-reply-flat { margin-left: 0; padding-left: 0; @@ -210,13 +210,13 @@ } .comment-zone-body { - // reply 头像也是 18px,从左对齐头像列 + // reply avatar is also 18px, left-aligned to the avatar column padding-left: 26px; margin-top: 3px; } } -// Monaco 行内评论标记 (glyph margin 蓝点 + 行号左侧细条) +// Monaco inline comment marker (glyph margin blue dot + thin bar left of the line number) .monaco-comment-glyph { position: relative; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/draft-zone.scss b/apps/desktop/src/renderer/src/styles/features/diff/draft-zone.scss index 5f595c6c..1cdd4230 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/draft-zone.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/draft-zone.scss @@ -1,27 +1,27 @@ -// Monaco 行内草稿 view zone (设计见 ADR-0007):跟 comment-zone 视觉区分 —— -// remote 评论是黄底,草稿用蓝底 + "草稿" 标识;posted 状态切回绿底跟远端评论形态一致 +// Monaco inline draft view zone (design see ADR-0007): visually distinct from comment-zone —— +// remote comments have a yellow base, drafts use a blue base + "draft" marker; posted state switches back to green to match remote comment form @use '../../tokens' as *; -// **结构**: -// .monaco-draft-zone (= dom,monaco 控制 style.height, 不能放视觉样式) -// .monaco-draft-zone-inner (= 内容容器,蓝色背景 / padding / border-left 全在这) +// **Structure**: +// .monaco-draft-zone (= dom, monaco controls style.height, no visual styles here) +// .monaco-draft-zone-inner (= content container, blue background / padding / border-left all here) // -// monaco 直接给 dom 写 inline style.height = <heightInPx>px,覆盖任何 CSS。如果把 -// 视觉样式(背景、border)放 dom 上,蓝框尺寸 = monaco wrapper 高度 ≠ 内容高度 -// → 出现"蓝框停在 textarea,按钮溢出到下方代码上"。所以 dom 只是 monaco 占位容器, -// 视觉容器是 inner,用 ResizeObserver 测 inner.offsetHeight 同步回 zoneObj.heightInPx +// monaco writes inline style.height = <heightInPx>px directly onto the dom, overriding any CSS. If +// visual styles (background, border) are put on the dom, the blue box size = monaco wrapper height ≠ content height +// → resulting in "blue box stopping at the textarea, buttons overflowing onto the code below". So the dom is only a monaco placeholder container, +// the visual container is inner, measured via ResizeObserver on inner.offsetHeight and synced back to zoneObj.heightInPx .monaco-draft-zone { overflow: visible; box-sizing: border-box; - // 防御性 z-index:万一 syncHeight 没把 monaco wrapper 撑到内容高度、按钮区溢出 dom - // 边界,溢出部分(按钮)在层叠上压过 monaco .view-lines 仍可点击。 - // monaco 自己给 dom 设 position:absolute,z-index 在 absolute 上直接生效 + // defensive z-index: in case syncHeight fails to grow the monaco wrapper to content height and the button area overflows the dom + // boundary, the overflowing part (buttons) stacks above monaco .view-lines and stays clickable. + // monaco sets position:absolute on the dom itself, so z-index takes effect directly on the absolute element z-index: 50; } .monaco-draft-zone-inner { - // 同样防御 z-index:inner 内部按钮永远在最顶层 + // same defensive z-index: buttons inside inner are always on the topmost layer position: relative; z-index: 51; background: rgb(var(--rgb-accent) / 0.12); // $color-accent fade @@ -39,10 +39,10 @@ line-height: $lh-loose; box-sizing: border-box; - // CSS 兜底:inline style.width 由 DiffView applyInnerLayout 监听 editor 的 - // onDidLayoutChange 动态设 (= contentLeft + contentWidth,对应代码区右边界)。 - // CSS max-width:100% 防止 width 没设 / 设错值时仍不超 dom 视口宽。 - // 注:DiffEditor 关闭了 minimap,所以不留 minimap buffer + // CSS fallback: inline style.width is set dynamically by DiffView applyInnerLayout watching the editor's + // onDidLayoutChange (= contentLeft + contentWidth, corresponding to the code area's right edge). + // CSS max-width:100% prevents exceeding the dom viewport width when width is unset / set wrong. + // Note: DiffEditor disables the minimap, so no minimap buffer is reserved max-width: 100%; } @@ -61,7 +61,7 @@ flex-direction: column; gap: $space-2; - // posted 状态:整体绿色调,跟远端评论视觉一致 + // posted state: overall green tone, visually consistent with remote comments &.draft-zone-status-posted { .draft-zone-tag { background: $color-approved; @@ -76,7 +76,7 @@ flex-wrap: wrap; } -// "草稿" 标识:常驻 chip,让用户一眼区分本地草稿 vs 远端评论 +// "draft" marker: an always-on chip, letting users tell local drafts vs remote comments at a glance .draft-zone-tag { padding: 1px $space-3; border-radius: $radius-sm; @@ -159,7 +159,7 @@ background: $color-danger-strong-fade; } -// 图标按钮 (垃圾桶等):方形紧凑,跟文字按钮高度对齐 +// icon buttons (trash can, etc.): compact square, height-aligned with text buttons .draft-zone-btn-icon { padding: 0; width: 22px; @@ -173,7 +173,7 @@ } } -// edit 模式下的删除按钮:靠右远离保存/取消,视觉上"独立操作" +// delete button in edit mode: right-aligned away from save/cancel, visually an "independent action" .draft-zone-edit-delete { margin-left: auto; } @@ -206,7 +206,7 @@ } .draft-zone-edit-actions { - display: flex; // 必须 block-level flex 才能让 margin-left:auto 推 [🗑] 到右端 + display: flex; // must be block-level flex so margin-left:auto pushes [🗑] to the right end align-items: center; gap: $space-3; } @@ -221,9 +221,9 @@ margin-top: $space-2; } -// 单条发布失败的 inline 错误条。视觉权重比"已发布" foot 高一档 — danger 色 -// 描边 + 浅红底,user 一眼能看到;右侧 ✕ 让用户主动 dismiss (不带超时自动消失 -// 避免错过)。比弹 modal 干扰更小 — zone 仍在原位,可改 body 再次"发布"重试 +// inline error bar for a single publish failure. Visual weight one notch above the "published" foot — danger-color +// outline + light red base, so the user sees it at a glance; the ✕ on the right lets the user dismiss it actively (no timeout auto-dismiss, +// to avoid missing it). Less intrusive than a modal — the zone stays in place, and the body can be edited to retry "publish" .draft-zone-publish-error { display: flex; align-items: flex-start; @@ -252,10 +252,10 @@ } } -// 行 hover 出现的 '+' 草稿创建按钮 — Monaco glyph margin 区 (editor 最左、 -// line number 左侧外列;DiffEditor options 启用了 glyphMargin:true 让那一列宽到 -// ~24px 装下 +)。decoration 元素本身 width/height 由 monaco 控制 (glyphMarginWidth -// × lineHeight),子 ::before 用 flex 占满 parent + 居中 '+' 字符 +// the '+' draft-creation button appearing on line hover — Monaco glyph margin area (editor's far left, +// the outer column left of the line number; DiffEditor options enable glyphMargin:true to widen that column to +// ~24px to fit +). The decoration element's own width/height is controlled by monaco (glyphMarginWidth +// × lineHeight), the child ::before uses flex to fill the parent + center the '+' character .monaco-draft-add-glyph { cursor: pointer; @@ -280,7 +280,7 @@ } } -// nav 跳转 reveal 后短暂高亮 (0.8s 脉冲),help 用户看清落到哪一行 +// brief highlight after nav jump reveal (0.8s pulse), helping the user see which line it landed on .monaco-draft-highlight-flash { background: rgb(var(--rgb-warning) / 0.25); animation: draft-nav-flash 0.8s ease-out; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss index ead28746..7704ac02 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/file-tree.scss @@ -1,19 +1,19 @@ -// 左侧改动文件树:.diff-file-list 容器 + 拖拽柄 + .diff-file-tree (FileTree 组件) + -// 行/文件夹/文件 + 状态点 + 文件名 git decoration 着色。 -// 颜色跟 VS Code Git decoration 对齐:filename 走 $git-*;状态点用更亮的 Material 系 -// 以在小尺寸点上保持识别度。 +// left changed-file tree: .diff-file-list container + drag handle + .diff-file-tree (FileTree component) + +// row/folder/file + status dot + filename git decoration coloring. +// colors align with VS Code Git decoration: filename uses $git-*; status dots use a brighter Material palette +// to stay identifiable on small dots. @use '../../tokens' as *; .diff-file-list { - // width 通过 inline style 由 DiffView 控制;CSS 端兜底最小值,避免 width 丢失时塌缩 + // width is controlled by DiffView via inline style; CSS provides a fallback minimum to avoid collapse when width is lost min-width: 180px; flex-shrink: 0; border-right: 1px solid $border-default; background: $bg-panel; - // 垂直自滚;水平显式 hidden 避免被规范自动提成 auto,否则 - // 和内层 .diff-file-tree 各出一条横向滚动条 (CSS Overflow 规范:当一个轴是 auto - // 另一个是 visible 时,visible 会被强制提升为 auto) + // vertical auto-scroll; horizontal explicitly hidden to avoid being auto-promoted to auto by the spec, otherwise + // this and the inner .diff-file-tree each get a horizontal scrollbar (CSS Overflow spec: when one axis is auto + // and the other is visible, visible is forced up to auto) overflow-x: hidden; overflow-y: auto; display: flex; @@ -52,8 +52,8 @@ gap: $space-4; } -// header 右侧搜索按钮 — 默认看着轻,hover/active 切到主色让用户知道当前模式。 -// 单按钮,跟"X 个文件" / "搜索变更内容"标签合占同一行 toolbar +// search button on the right of the header — looks light by default, hover/active switches to accent color to signal the current mode. +// single button, sharing the same toolbar row as the "X files" / "search changed content" label .diff-file-list-search-btn { display: inline-flex; align-items: center; @@ -83,7 +83,7 @@ } } -// 变更范围选择器(文件树头部「<n> 个文件 · 全部变更 / <commit>」可点击下拉) +// change scope selector (clickable dropdown in the file tree header: "<n> files · all changes / <commit>") .diff-scope-select { position: relative; flex: 1 1 auto; @@ -100,7 +100,7 @@ border-radius: $radius-sm; color: $text-muted; cursor: pointer; - // header 整体 uppercase + letterspacing;trigger 内含 commit 主题,需还原为普通可读文本 + // the header is overall uppercase + letterspacing; the trigger contains the commit subject, which needs restoring to ordinary readable text text-transform: none; letter-spacing: normal; @@ -123,8 +123,8 @@ transform: rotate(180deg); } } -// 下拉用 fixed 定位(top/left/width 由 JS 按触发器位置计算)+ portal 挂 body, -// 避免被 .diff-file-list 的 overflow 裁切 / 被右侧 Monaco 盖住。 +// the dropdown uses fixed positioning (top/left/width computed by JS from the trigger position) + portal mounted on body, +// to avoid being clipped by .diff-file-list's overflow / covered by the right-side Monaco. .diff-scope-menu { position: fixed; z-index: 100; @@ -139,7 +139,7 @@ border-radius: $radius-md; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); } -// 每个选项:左侧横跨两行高度的图标 + 右侧两行正文(标题行 + 元信息副行,参考 Bitbucket) +// each option: an icon spanning two rows' height on the left + two body rows on the right (title row + meta subrow, referencing Bitbucket) .diff-scope-option { display: flex; flex-direction: row; @@ -218,17 +218,17 @@ margin-left: auto; } -// 文件树容器 +// file tree container .diff-file-tree { flex: 1; - overflow: auto; // x + y 都可滚,文件名/层级超出时横向滚动 + overflow: auto; // both x + y scrollable, horizontal scroll when filename/nesting overflows padding: $space-2 0; } -// 内层容器:用 inline-block 让自身宽度 = 内容 max-content,再用 min-width:100% -// 兜底到容器视口宽。结果是:当某行很长时,整个 inner 都被撑宽 → 所有 row 都跟着 -// 撑到同一宽度(因为 row 是 block 元素 width:auto),sticky dots 因此都贴在统一的 -// 滚动右端,scroll 时同步漂移、视口内永远固定在最右。 +// inner container: use inline-block so its own width = content max-content, then min-width:100% +// falls back to the container viewport width. The result: when a row is very long, the whole inner is widened → all rows +// stretch to the same width (because rows are block elements with width:auto), so sticky dots all cling to a unified +// scroll right end, drifting in sync while scrolling and staying pinned to the far right within the viewport. .diff-file-tree-inner { display: inline-block; min-width: 100%; @@ -260,7 +260,7 @@ } } -// 固定 16px 占位槽:始终占位,iconify 的 svg 晚一帧进 DOM 也不塌缩 → 文件名不再左右抖动。 +// fixed 16px placeholder slot: always occupies space, so even when iconify's svg enters the DOM a frame late it won't collapse → the filename no longer jitters horizontally. .tree-icon { flex-shrink: 0; width: 16px; @@ -271,9 +271,9 @@ } .tree-name { - // 不截断;长名直接撑出触发横向滚动 + // no truncation; long names simply extend and trigger horizontal scroll white-space: nowrap; - // flex:1 让 name 占满剩余空间,把右侧状态点固定推到右边缘 + // flex:1 lets name fill the remaining space, pushing the right status dot fixed to the right edge flex: 1 1 auto; min-width: 0; } @@ -288,9 +288,9 @@ flex-shrink: 0; } -// 待发布草稿 chip:跟 comment chip 同尺寸 / radius,仅配色区分。用 warning -// 色 (amber) 强调"待办"语义 —— 跟 PR header "提交评审 (N)" 按钮的视觉权重 -// 呼应,让用户在文件树扫一眼就能定位到还有未发草稿的文件 +// pending-draft chip: same size / radius as the comment chip, differing only in color. Uses warning +// color (amber) to emphasize the "to-do" semantics —— echoing the visual weight of the PR header "submit review (N)" button, +// so users can locate files with unpublished drafts at a glance in the file tree .tree-draft-count { background: $color-warning; color: $text-on-accent; @@ -305,7 +305,7 @@ flex-shrink: 0; } -// 合并冲突警示:紧贴状态点左侧的三角叹号,用 warning 色提示该文件合并会冲突。 +// merge conflict warning: a triangle exclamation right next to the left of the status dot, using warning color to indicate this file will conflict on merge. .tree-conflict { display: inline-flex; align-items: center; @@ -313,14 +313,14 @@ color: $color-warning; } -// 评论 chip + 状态点的右侧容器;横向滚动时固定在视口右边缘,左缘渐变淡出, -// 遮盖背后滚出来的长文件名。背景跟随 .tree-row 状态 (默认/hover/selected)。 +// right container for the comment chip + status dot; pinned to the viewport's right edge during horizontal scroll, its left edge fades out via gradient, +// masking the long filename scrolling out behind it. Background follows the .tree-row state (default/hover/selected). // -// 渐变起点用「同色零透明」(rgb(from … / 0)) 而非 transparent:transparent 等价 rgba(0,0,0,0) -// (透明黑),渐变中段会掺入半透明灰 → 在选中蓝底上呈现一道发灰的「阴影」带。同色零透明则纯按 -// 该底色淡入、无杂色。 -// align-self: stretch 让背景撑满整行高度(行用 align-items:center,默认子项不拉伸 → 背景盒只有 -// 圆点那么高、未撑满整行);内部仍 align-items:center 居中圆点/chip。 +// the gradient start uses "same-color zero-alpha" (rgb(from … / 0)) rather than transparent: transparent equals rgba(0,0,0,0) +// (transparent black), so the gradient midsection blends in semi-transparent gray → producing a grayish "shadow" band on the selected blue base. Same-color zero-alpha +// fades in purely by that base color, with no stray color. +// align-self: stretch makes the background fill the full row height (the row uses align-items:center, so by default children don't stretch → the background box is only +// as tall as the dot, not filling the row); inside it still uses align-items:center to center the dot/chip. .tree-row-right { position: sticky; right: 0; @@ -330,8 +330,8 @@ gap: $space-3; padding: 0 $space-4 0 14px; flex-shrink: 0; - // 强制顶到行右端:即使 row 是 max-content 导致 flex:1 无可分配空间, - // auto margin 仍能保证间距 + // force it to the row's right end: even when row is max-content leaving flex:1 no space to distribute, + // auto margin still guarantees the spacing margin-left: auto; z-index: 2; background: linear-gradient(to right, rgb(from #{$bg-panel} r g b / 0) 0, $bg-panel 14px); @@ -339,8 +339,8 @@ .tree-row:hover & { background: linear-gradient(to right, rgb(from #{$bg-hover} r g b / 0) 0, $bg-hover 14px); } - // 选中态行底已是实色高亮,不再铺遮罩渐变 —— 圆点直接落在选中底色上更干净(避免任何叠色 / 边界感)。 - // 代价:文件名长到触发横向滚动时会从圆点下方穿过、不再被遮住(窄面板 / 深层级才会遇到)。 + // the selected row base is already a solid highlight, so no mask gradient is laid down —— the dot sitting directly on the selected base is cleaner (avoiding any color blend / edge artifact). + // trade-off: when a filename is long enough to trigger horizontal scroll, it passes under the dot and is no longer masked (only hit in narrow panels / deep nesting). .tree-row.selected & { background: none; } @@ -351,7 +351,7 @@ color: $text-body; } -// folder name 按聚合状态着色(跟 VS Code Git decoration 的折叠规则一致) +// folder name colored by aggregated status (consistent with VS Code Git decoration's collapse rules) .folder-status-added .tree-name { color: $git-added; } @@ -363,7 +363,7 @@ color: $git-deleted; } -// VS Code Git decoration 配色:filename 整体按 status 着色 +// VS Code Git decoration coloring: filename colored entirely by status .tree-file-added .tree-name, .tree-file-renamed .tree-name, .tree-file-copied .tree-name { @@ -378,7 +378,7 @@ text-decoration: line-through; } -// 选中行:保留高亮蓝底,文字用选中态前景(暗色底白、浅色底深字),覆盖 git 状态色以保证对比 +// selected row: keep the highlighted blue base, text uses the selected-state foreground (white on dark base, dark text on light base), overriding the git status color to ensure contrast .tree-file.selected .tree-name { color: $text-on-selected; } @@ -396,22 +396,22 @@ transform: rotate(90deg); } -// 文件行没 chevron,用相同宽度的不可见占位维持层级对齐 +// file rows have no chevron, using an invisible placeholder of the same width to maintain hierarchy alignment .tree-chevron-spacer { flex-shrink: 0; width: 10px; margin-right: -2px; } -// 文件改动状态点 (right column)。 -// Material 系亮色:在 8px 小圆点上比 $git-* 装饰色更易辨认 +// file change status dot (right column). +// Material bright colors: more distinguishable than $git-* decoration colors on an 8px small dot .diff-file-status { width: 8px; height: 8px; border-radius: $radius-full; flex-shrink: 0; - // 状态色叠加(markup:`diff-file-status file-<status>`),色值统一走 token + // status color overlay (markup: `diff-file-status file-<status>`), color values uniformly go through tokens &.file-added { background: $file-status-added; } diff --git a/apps/desktop/src/renderer/src/styles/features/diff/search.scss b/apps/desktop/src/renderer/src/styles/features/diff/search.scss index c9d8c3e6..b664df9d 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/search.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/search.scss @@ -1,9 +1,9 @@ @use '../../tokens' as *; -// DiffSearchPanel:替换文件树位置的搜索面板。 -// 结构:input row → stats line → 结果列表 (按文件 group)。 -// 视觉跟 Bitbucket "Search code" 风格对齐:紧凑列表 + 文件名 chip + 行号 + -// 关键词黄背景高亮 + +/- diff role marker +// DiffSearchPanel: search panel that replaces the file tree. +// Structure: input row → stats line → results list (grouped by file). +// Visually aligned with Bitbucket "Search code" style: compact list + file name chip + line number + +// keyword yellow-background highlight + +/- diff role marker .diff-search-panel { flex: 1; @@ -22,9 +22,9 @@ flex-shrink: 0; } -// input 跟 case toggle 共用基础尺寸 — 同 box-sizing + 同 padding 上下 + 同 -// font-size,才能在 flex row 里基线和外框双对齐。toggle 一般比 input 文字略小 -// 但因为字符宽度 ("Aa"),按钮还要够宽不被挤 +// input and case toggle share a base size — same box-sizing + same top/bottom padding + same +// font-size, so baseline and outer frame both align in the flex row. The toggle text is usually +// slightly smaller than the input, but because of character width ("Aa") the button must also be wide enough not to get squeezed %diff-search-input-base { box-sizing: border-box; padding: 5px $space-4; @@ -98,7 +98,7 @@ text-align: center; } -// 文件级行:头 (折叠按钮 + 文件名 + 路径 + 命中数 chip),下方是 matches 列表 +// File-level row: head (collapse button + file name + path + match-count chip), matches list below .diff-search-file { border-bottom: 1px solid $border-muted; } @@ -167,7 +167,7 @@ margin: 0; } -// 单条匹配:[marker] [line#] [content with <mark>] +// Single match: [marker] [line#] [content with <mark>] .diff-search-match { width: 100%; display: flex; diff --git a/apps/desktop/src/renderer/src/styles/features/diff/view.scss b/apps/desktop/src/renderer/src/styles/features/diff/view.scss index 53752a8d..a79dd917 100644 --- a/apps/desktop/src/renderer/src/styles/features/diff/view.scss +++ b/apps/desktop/src/renderer/src/styles/features/diff/view.scss @@ -5,14 +5,14 @@ width: 100%; height: 100%; min-height: 0; - // 定位锚:切 PR 时加载遮罩 .pane-loading-overlay 铺满整个 diff 区(树 + 编辑器)。 + // Positioning anchor: when switching PRs the loading mask .pane-loading-overlay covers the whole diff area (tree + editor). position: relative; } -// 显示换行符 ↵:monaco renderWhitespace 只渲染 space/tab,不显示 EOL; -// 给每行最后一个 token span 加 ::after 标记。 -// 注意:.view-line 容器宽度 = viewport 全宽,所以 ::after 加在 .view-line 上 -// 会跑到 viewport 最右;必须挂到 .view-line > span:last-child 紧贴代码字符末尾 +// Show EOL character ↵: monaco renderWhitespace only renders space/tab, not EOL; +// add an ::after marker to each line's last token span. +// Note: the .view-line container width = full viewport width, so putting ::after on .view-line +// would push it to the far right of the viewport; it must attach to .view-line > span:last-child right at the end of the code characters .diff-editor-show-eol { .view-line > span:last-child::after { content: '↵'; @@ -23,7 +23,7 @@ } } -// Split / Unified 切换钮组:紧贴的两个按钮,第二个去掉左 border 避免重叠 +// Split / Unified toggle button group: two adjacent buttons, the second drops its left border to avoid overlap .diff-mode-toggle { display: inline-flex; gap: 0; @@ -67,7 +67,7 @@ flex-direction: column; } -// 主区 wrapper:左侧 blame-column 是固定宽度;ErrorBoundary 或 DiffPane 走 flex:1 +// Main area wrapper: the left blame-column is fixed width; ErrorBoundary or DiffPane take flex:1 .diff-pane-wrapper { flex: 1; min-height: 0; @@ -81,7 +81,7 @@ } } -// Monaco 编辑器外壳:position:relative 作为 diff 就绪前 overlay loading 的定位锚点。 +// Monaco editor shell: position:relative serves as the positioning anchor for the overlay loading shown before the diff is ready. .diff-pane-editor { position: relative; height: 100%; @@ -114,7 +114,7 @@ } } -// 单 pane 内部出错:ErrorBoundary 兜底 +// Error inside a single pane: ErrorBoundary fallback .error-boundary-fallback { padding: 24px; display: flex; @@ -143,7 +143,7 @@ word-break: break-word; } -// 整块 fallback:拉变更文件本身失败时占满 diff 区 +// Whole-block fallback: fills the diff area when fetching the changed file itself fails .backend-error-view { display: flex; flex-direction: column; @@ -171,8 +171,8 @@ color: $text-primary; } -// 顶部 banner:评论 / 单文件内容这类非阻塞失败时,让 diff 继续显示。 -// kind=auth / not-found 走更柔和的黄;其余 (network/unknown) 走红 +// Top banner: for non-blocking failures like comments / single-file content, keep the diff showing. +// kind=auth / not-found use a softer yellow; the rest (network/unknown) use red .backend-error-banner { display: flex; align-items: center; @@ -225,7 +225,7 @@ white-space: pre-wrap; } -// Mirror sync 进度条 (clone / fetch 阶段占满 diff 区) +// Mirror sync progress bar (fills the diff area during clone / fetch phases) .sync-progress { display: flex; flex-direction: column; @@ -270,7 +270,7 @@ } } -// 二进制文件 (image/binary) 占位 +// Binary file (image/binary) placeholder .diff-binary { display: flex; align-items: center; diff --git a/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss index fa2e3b4a..6368f9a1 100644 --- a/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss +++ b/apps/desktop/src/renderer/src/styles/features/drafts-panel.scss @@ -1,19 +1,19 @@ @use '../tokens' as *; @use '../mixins' as *; -// 草稿管理面板。布局跟 CommentsPanel 一致:上半个状态筛选条、下半个滚动 -// 列表。每条卡片含 anchor + status chip + actions + body markdown + (可选) 错误 +// Drafts management panel. Layout matches CommentsPanel: a status filter bar in the upper half, a scrolling +// list in the lower half. Each card contains anchor + status chip + actions + body markdown + (optional) error .drafts-panel { - // 父 .pr-tab-content 是 display:flex row,子元素默认按 content 大小,要 flex:1 - // 才能撑满 main pane 横向 (跟 .pr-comments-panel 同模);min-width:0 防止内嵌 - // 长 markdown / code 撑出父容器 + // The parent .pr-tab-content is display:flex row, children default to content size, needs flex:1 + // to fill the main pane horizontally (same pattern as .pr-comments-panel); min-width:0 prevents embedded + // long markdown / code from bursting out of the parent container flex: 1; min-width: 0; display: flex; flex-direction: column; background: $bg-panel; - overflow: hidden; // 内部 .drafts-panel-list 自己滚 + overflow: hidden; // the inner .drafts-panel-list scrolls on its own } .drafts-panel-filter { @@ -79,8 +79,8 @@ list-style: none; padding: $space-6; margin: 0; - // 在 .drafts-panel (column flex) 里占满剩余高度 + 自身滚动;min-height:0 防止 - // 内嵌长 markdown 把外层撑出去导致 .drafts-panel 整体滚 + // Fills the remaining height inside .drafts-panel (column flex) + scrolls itself; min-height:0 prevents + // embedded long markdown from stretching the outer layer and making the whole .drafts-panel scroll flex: 1; min-height: 0; overflow-y: auto; @@ -89,7 +89,7 @@ gap: $space-5; } -// 单条草稿卡片:border-left 着色按 status 区分 — 一眼区分待办 / 已发 / 已拒 +// Single draft card: border-left color differentiated by status — distinguishes pending / posted / rejected at a glance .drafts-panel-item { background: $bg-surface; border: 1px solid $border-muted; @@ -185,7 +185,7 @@ font-size: $fs-md; color: $text-body; line-height: $lh-loose; - // 长 body 不撑爆面板;纵向滚动 + max-height + // Long body doesn't blow out the panel; vertical scrolling + max-height max-height: 220px; overflow-y: auto; word-wrap: break-word; diff --git a/apps/desktop/src/renderer/src/styles/features/onboarding.scss b/apps/desktop/src/renderer/src/styles/features/onboarding.scss index b9ab207e..0afa4b49 100644 --- a/apps/desktop/src/renderer/src/styles/features/onboarding.scss +++ b/apps/desktop/src/renderer/src/styles/features/onboarding.scss @@ -1,6 +1,6 @@ @use '../tokens' as *; -// 首启配置向导:整屏覆盖,居中卡片 + 步骤圆点 + 轮播 slide +// Onboarding config wizard: full-screen overlay, centered card + step dots + carousel slides .onboarding { position: absolute; inset: 0; @@ -24,7 +24,7 @@ overflow: hidden; } -// ===== 顶部步骤圆点 ===== +// ===== top step dots ===== .onboarding-dots { display: flex; align-items: center; @@ -42,7 +42,7 @@ color: $text-muted; font-size: $fs-md; - // 圆点之间的连接线 + // connecting line between dots &:not(:last-child)::after { content: ''; width: 24px; @@ -84,7 +84,7 @@ } } -// ===== slide 主体 ===== +// ===== slide body ===== .onboarding-slide { padding: $space-8; overflow-y: auto; @@ -113,7 +113,7 @@ line-height: $lh-loose; } -// ===== 欢迎页 ===== +// ===== welcome page ===== .onboarding-welcome { text-align: center; padding: $space-6 $space-4; @@ -137,8 +137,8 @@ .onboarding-points { list-style: none; padding: 0; - // width:fit-content + margin auto:整组按最宽一条收窄后水平居中, - // 条目内部仍左对齐(圆点对齐),视觉上跟标题/介绍一条中轴 + // width:fit-content + margin auto: the whole group narrows to its widest entry then centers horizontally, + // items inside stay left-aligned (dot-aligned), sharing one central axis with the title/intro visually width: fit-content; max-width: 480px; margin: 0 auto $space-8; @@ -165,14 +165,14 @@ } } } -// 欢迎页底部 nav 内的语言选择:标签 + 定宽下拉,标签与下拉之间留白。位于 nav-left / -// nav-right(step 0 均为空)之间,靠父级 justify-content:space-between 居中。 +// Language selection in the welcome page's bottom nav: label + fixed-width dropdown, with spacing between label and dropdown. Sits between nav-left / +// nav-right (both empty at step 0), centered by the parent's justify-content:space-between. .onboarding-nav-language { display: flex; align-items: center; gap: $space-4; } -// 语言下拉:收窄固定宽度、左对齐;不被 .settings-input 的 flex:1 撑满(复合选择器提权) +// Language dropdown: narrow fixed width, left-aligned; not stretched by .settings-input's flex:1 (compound selector for higher specificity) .settings-input.onboarding-language-select { flex: 0 0 auto; width: 160px; @@ -182,11 +182,11 @@ justify-content: center; } -// ===== 平台步:左右布局 ===== -// 平台选择列表 + 表单的左右两栏布局收敛到 config-picker.scss(.config-pick-grid / -list / -item …), -// 由 PlatformStep 与设置面板「连接」子模态共用,本文件不再单独维护。 +// ===== platform step: left-right layout ===== +// The two-column left-right layout of platform selection list + form is consolidated into config-picker.scss (.config-pick-grid / -list / -item …), +// shared by PlatformStep and the settings panel's "Connection" sub-modal; no longer maintained separately in this file. -// ===== 折叠的「缓存目录」高级入口 ===== +// ===== collapsed "cache directory" advanced entry ===== .onboarding-advanced { margin-top: $space-6; border-top: 1px solid $border-muted; @@ -220,8 +220,8 @@ margin-top: $space-4; } -// ===== LLM 步:provider 选择 + 配置 ===== -// provider 品牌图标容器(SVG glyph) +// ===== LLM step: provider selection + config ===== +// provider brand icon container (SVG glyph) .llm-provider-glyph { display: inline-flex; align-items: center; @@ -233,22 +233,22 @@ } } -// provider 选择列表(两阶段共用:图标 + 名称 + 箭头/选中)收敛到 config-picker.scss -// (.config-pick-list / -item / -name / -arrow …),由 LlmStep 与设置面板「LLM」子模态共用。 +// The provider selection list (shared across two stages: icon + name + arrow/selected) is consolidated into config-picker.scss +// (.config-pick-list / -item / -name / -arrow …), shared by LlmStep and the settings panel's "LLM" sub-modal. -// 阶段一:居中的选择列表 +// Stage one: centered selection list .onboarding-provider-pick { max-width: 360px; margin: 0 auto; } -// 阶段一选择列表:与阶段二网格同高(190px),两步切换不跳动;provider 增多时在内滚动 +// Stage-one selection list: same height as the stage-two grid (190px), so switching between the two steps doesn't jump; scrolls internally as providers grow .onboarding-provider-pick .config-pick-list { height: 190px; overflow-y: auto; } -// 阶段二:左列表 + 右配置。固定高度 + 两栏各自滚动, -// 切换 provider 时模态高度恒定、不抖动,列表后续扩展也不撑高(同设置子模态)。 +// Stage two: list on the left + config on the right. Fixed height + each column scrolls on its own, +// so the modal height stays constant and doesn't jitter when switching provider, and later list expansion doesn't stretch it either (same as the settings sub-modal). .onboarding-llm-grid { display: grid; grid-template-columns: 220px 1fr; @@ -256,14 +256,14 @@ align-items: stretch; height: 190px; - // 左侧列表:从右侧滑入到位(视觉上「收到左边」),并在固定高度内滚动 + // Left list: slides in from the right into place (visually "collected to the left"), and scrolls within a fixed height .config-pick-list { min-height: 0; overflow-y: auto; animation: onboarding-slide-left 0.28s ease both; } } -// 右侧配置:略微延迟后渐隐进入;固定高度内滚动,顶部留半行余白与左侧列表协调 +// Right config: fades in after a slight delay; scrolls within a fixed height, leaving half a row of top whitespace to harmonize with the left list .onboarding-llm-form { min-width: 0; min-height: 0; @@ -293,7 +293,7 @@ } } -// ===== 完成页:成功图案 + 友好文案 ===== +// ===== done page: success graphic + friendly copy ===== .onboarding-done { text-align: center; padding: $space-6 $space-4; @@ -303,7 +303,7 @@ margin-bottom: $space-5; animation: onboarding-pop 0.36s ease both; } -// 对勾描边按路径长度做「书写」动画 +// The checkmark stroke does a "writing" animation based on path length .onboarding-check-path { stroke-dasharray: 34; stroke-dashoffset: 34; @@ -328,7 +328,7 @@ } } -// ===== 底部导航 ===== +// ===== bottom navigation ===== .onboarding-nav { display: flex; align-items: center; @@ -344,7 +344,7 @@ } @media (max-width: 640px) { - // .config-pick-grid 的窄屏单列回退在 config-picker.scss;此处仅 LLM 步的 grid + // The narrow-screen single-column fallback for .config-pick-grid is in config-picker.scss; here only the LLM step's grid .onboarding-llm-grid { grid-template-columns: 1fr; } diff --git a/apps/desktop/src/renderer/src/styles/features/pr-info.scss b/apps/desktop/src/renderer/src/styles/features/pr-info.scss index 130d388b..9f3334cd 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr-info.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr-info.scss @@ -1,21 +1,21 @@ @use '../tokens' as *; .pr-info-view { - // 滚动容器铺满,滚动条贴在外层容器右缘 + // Scroll container fills the space, scrollbar hugs the right edge of the outer container flex: 1; overflow-y: auto; padding: 20px 24px; - // 作为容器查询基准:左右布局按本面板宽度响应(而非窗口宽度),窄到阈值时侧栏落到描述下方 + // Serves as the container-query basis: the left-right layout responds to this panel's width (rather than the window width); at the narrow threshold the sidebar drops below the description container-type: inline-size; } .pr-info-content { - // 内容行宽限制 + 居中,避免宽屏下正文铺满过散 + // Content line-width cap + centering, to avoid the body text spreading too sparsely on wide screens max-width: 960px; margin: 0 auto; } -// 左右布局:左侧描述(主内容)+ 右侧元信息侧栏(时间线 / 评审者) +// Left-right layout: description on the left (main content) + meta-info sidebar on the right (timeline / reviewers) .pr-info-layout { display: flex; align-items: flex-start; @@ -27,17 +27,17 @@ } .pr-info-side { flex: 0 0 260px; - // 宽度上限:堆叠(占满整宽)时也保持紧凑,避免时间 / 评审 chip 被拉到两端过散 + // Width cap: stays compact even when stacked (full width), avoiding time / review chips being pulled too sparsely to both ends max-width: 400px; display: flex; flex-direction: column; } -// 两列首个 section 顶部不留大间距,与对侧标题齐平 +// The first section in each of the two columns leaves no large top spacing, staying level with the opposite side's heading .pr-info-main > .pr-detail-section:first-child, .pr-info-side > .pr-detail-section:first-child { margin-top: 0; } -// 响应式:面板窄到阈值时改为纵向堆叠,侧栏(时间线 / 评审者)落到描述下方、占满整宽 +// Responsive: at the narrow threshold the panel switches to vertical stacking, the sidebar (timeline / reviewers) drops below the description and fills the full width @container (max-width: 680px) { .pr-info-layout { flex-direction: column; @@ -57,7 +57,7 @@ margin-left: $space-1; text-transform: uppercase; } -// pending 用 VS Code 主蓝;approved 走成功绿;needs_work 走 git-decoration 红 +// pending uses VS Code primary blue; approved goes with success green; needs_work goes with git-decoration red .status-pending { background: $color-accent; color: $text-on-accent; @@ -71,7 +71,7 @@ color: $text-on-accent; } -// 冲突标记:chip 效果(琥珀色 tint 底 + 警示色文字),比纯文字更突出 +// Conflict marker: chip effect (amber tint background + warning-color text), more prominent than plain text .conflict-tag { display: inline-flex; align-items: center; @@ -103,7 +103,7 @@ } .reviewer-list { - // 行式展示(与活动时间线一致):前置状态图标 + 头像 + 名 + 决断 chip,不再用 disc bullet + // Row-style display (consistent with the activity timeline): leading status icon + avatar + name + verdict chip, no longer using disc bullets list-style: none; padding-left: 0; margin: 0; @@ -128,7 +128,7 @@ .reviewer-icon-needsWork { color: $color-warning-bright; } -// 待评审(unapproved):中性空心圆点 +// Pending review (unapproved): a neutral hollow dot .reviewer-pending-dot { width: 9px; height: 9px; @@ -137,7 +137,7 @@ opacity: 0.6; } .reviewer-name { - // 侧栏较窄:名字过长时省略,给后置 chip 让位 + // Sidebar is fairly narrow: truncate an overly long name to make way for the trailing chip flex: 1 1 auto; min-width: 0; overflow: hidden; @@ -154,15 +154,15 @@ .pr-detail-kv { display: grid; - // key 列按内容自适应(不再固定 160px),把更多宽度留给时间值;窄侧栏下避免日期时间换行 + // The key column sizes to content (no longer fixed at 160px), leaving more width for the time value; avoids date-time wrapping in a narrow sidebar grid-template-columns: auto 1fr; gap: $space-2 $space-4; - // 侧栏较窄:时间用小字号,单行展示不换行 + // Sidebar is fairly narrow: time uses a small font size, shown on a single line without wrapping font-size: $fs-sm; .modal-kv-val { white-space: nowrap; - // 时间值右对齐到侧栏右缘,与左侧标签分列两端 + // The time value right-aligns to the sidebar's right edge, split to opposite ends from the left-side label text-align: right; } } diff --git a/apps/desktop/src/renderer/src/styles/features/pr/_index.scss b/apps/desktop/src/renderer/src/styles/features/pr/_index.scss index c344cc43..5b56ef77 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/_index.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/_index.scss @@ -1,6 +1,6 @@ -// PR 域样式簇入口:@forward 各 partial,App.scss 用 `@use '.../features/pr'` 一行引入。 -// 新增 pr partial 在此登记即可,无需改 App.scss。publish-review 组合通用 .modal, -// 故 App.scss 中本簇须在 common/modal 之后引入。 +// PR domain style cluster entry: @forward each partial; App.scss imports with a single `@use '.../features/pr'`. +// Register a new pr partial here — no need to touch App.scss. publish-review composes the shared .modal, +// so this cluster must be imported after common/modal in App.scss. @forward 'statusbar'; @forward 'reviewer-stack'; @forward 'header-actions'; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/activity.scss b/apps/desktop/src/renderer/src/styles/features/pr/activity.scss index a250e394..311d3595 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/activity.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/activity.scss @@ -1,14 +1,14 @@ -// PR 活动时间线(提交 / 评审决断事件行 + 评论行):竖向虚线轨 + 图标节点 + 决断 chip。 -// 见 components/features/pr/tabs/activity。评论卡片样式在 features/pr/comments。 +// PR activity timeline (commit / review decision event rows + comment rows): vertical dashed rail + icon nodes + decision chip. +// See components/features/pr/tabs/activity. Comment card styles live in features/pr/comments. @use '../../tokens' as *; -// 所有条目(评论 / 提交 / 决断)首列都是同一位置的图标节点,正文在右;一条竖向虚线轨贯穿图标列、 -// 连接相邻条目,图标用面板底色遮住轨线形成「节点」效果。 +// Every item (comment / commit / decision) has an icon node in the same first-column position, body on the right; a vertical dashed rail runs through the icon column, +// connecting adjacent items, with icons masking the rail using the panel background to form the "node" effect. .pr-activity-list { position: relative; - // 竖向虚线轨:居中于图标列(行左 padding $space-3 + 图标半宽 11px)。首尾各内缩一点,不超出首/末节点。 + // Vertical dashed rail: centered on the icon column (row left padding $space-3 + icon half-width 11px). Inset a bit at both ends so it doesn't extend past the first/last node. &::before { content: ''; position: absolute; @@ -24,7 +24,7 @@ align-items: center; gap: $space-3; padding: $space-2 $space-3; - // 与评论主体人文本同字号(body 默认 14px),作者展示不做差异化 + // Same font size as the comment body text (body default 14px); author display is not differentiated font-size: $fs-lg; color: $text-body; } @@ -44,12 +44,12 @@ width: 22px; height: 22px; color: $text-muted; - // 浮于虚线轨之上并用面板底色遮挡轨线 → 图标成为时间线节点 + // Floats above the dashed rail and masks it with the panel background → the icon becomes a timeline node position: relative; z-index: 1; background: $bg-app; } -// 决断语义色:approve 绿、needs-work 琥珀;unapproved / dismissed 维持中性 muted +// Decision semantic colors: approve green, needs-work amber; unapproved / dismissed stay neutral muted .pr-activity-icon-approved { color: $color-approved; } @@ -96,13 +96,13 @@ color: $text-muted; } -// 评论事件标题行:长文件路径锚点 chip 吸收宽度压力——路径过长时截断、保留尾部(文件名)与 `:行号`, -// 避免把『评论』动词(及头像左的固定项)挤压换行。 +// Comment event title row: the long file-path anchor chip absorbs width pressure — truncate when the path is too long, keeping the tail (file name) and `:line-number`, +// to avoid squeezing the 'comment' verb (and the fixed items left of the avatar) onto a new line. .pr-activity-comment-head .pr-comment-anchor { min-width: 0; overflow: hidden; - // 路径段截断保留文件名:direction: rtl 让省略号落在左侧(首部),:行号在 code 外不受影响。 + // Path segment truncation keeps the file name: direction: rtl puts the ellipsis on the left (head), :line-number sits outside code and is unaffected. code { min-width: 0; overflow: hidden; @@ -112,14 +112,14 @@ text-align: left; } } -// reviewer 决断动词用带色 chip 突出:approve 绿、needs-work 琥珀、unapproved/dismissed 中性 +// The reviewer decision verb is highlighted with a colored chip: approve green, needs-work amber, unapproved/dismissed neutral .pr-activity-chip { flex-shrink: 0; display: inline-flex; align-items: center; padding: 1px $space-3; border-radius: $radius-pill; - // 与行内文本同字号(14px),比默认 chip 更突出 + // Same font size as inline text (14px), more prominent than the default chip font-size: $fs-lg; font-weight: 600; } @@ -142,8 +142,8 @@ font-size: $fs-xs; } -// 时间标签自定义 tooltip:原生 title 延迟长、且部分场景不弹(review 行),改用 data-tip + ::after, -// 短延迟(120ms 显示、移开即隐),各时间标签行为一致、精确到秒。 +// Custom tooltip for time labels: native title has a long delay and doesn't show in some cases (review rows), so use data-tip + ::after instead, +// with a short delay (show at 120ms, hide on leave), giving all time labels consistent behavior, precise to the second. .time-tip { position: relative; } @@ -170,12 +170,12 @@ transition-delay: 0.12s; } -// 新建评论编辑框:缩进对齐到标题行头像左缘,挂在时间线轨右侧(与评论卡片同一缩进) +// New comment editor box: indented to align with the title row's avatar left edge, hung on the right of the timeline rail (same indent as comment cards) .pr-activity-compose-card { margin-left: calc(22px + #{$space-3} * 2); } -// 活动标签页右侧「评论」按钮:复用 diff 工具条按钮的轻量样式 +// Activity tab's right-side "comment" button: reuses the lightweight style of the diff toolbar buttons .pr-tab-action-btn { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/comments.scss b/apps/desktop/src/renderer/src/styles/features/pr/comments.scss index a250f519..23db8ee7 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/comments.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/comments.scss @@ -1,14 +1,14 @@ -// PR 评论标签页:全量评论(summary + inline)卡片排版 + 嵌套回复 + 锚点 chip + 回复/编辑/删除 -// 操作 + 代码上下文。见 components/features/pr/tabs/comments。活动时间线版式在 features/pr/activity。 +// PR comments tab: full comment (summary + inline) card layout + nested replies + anchor chip + reply/edit/delete +// actions + code context. See components/features/pr/tabs/comments. The activity timeline layout is in features/pr/activity. @use '../../tokens' as *; -// 外层为定位锚(position:relative)+ 不滚动:让加载遮罩 .pane-loading-overlay 铺满视口、 -// 居中 spinner,而非随内部滚动跑到滚动区顶端。滚动交给内层 .pr-comments-scroll。 +// The outer layer is a positioning anchor (position:relative) + no scroll: lets the loading overlay .pane-loading-overlay fill the viewport +// and center the spinner, rather than tracking inner scroll to the top of the scroll area. Scrolling is handled by the inner .pr-comments-scroll. .pr-comments-panel { flex: 1; min-height: 0; - // 同 .pr-tab-pane:可收缩到小于内容固有宽度,横向滚动交给内层 .pr-comments-scroll + // Same as .pr-tab-pane: can shrink below the content's intrinsic width, horizontal scroll handled by the inner .pr-comments-scroll min-width: 0; display: flex; flex-direction: column; @@ -18,7 +18,7 @@ } .pr-comments-scroll { flex: 1; - // 纵向常驻滚动;横向在内容低于最小宽度时出现滚动条(见下方 min-width) + // Vertical scroll always present; horizontal scrollbar appears when content drops below the min width (see min-width below) overflow: auto; padding: $space-6 20px; } @@ -30,9 +30,9 @@ flex-direction: column; gap: $space-5; } -// 顶层时间线 / 评论列表:宽度在 [480, 960] 内自适应缩放并居中;窄于 480 不再压缩, -// 转为外层 .pr-comments-scroll 的横向滚动(避免内嵌代码 / 评论被挤到不可读)。 -// 仅约束直接子列表,嵌套 replies 的 .pr-comments-list 不受影响。 +// Top-level timeline / comment list: width scales responsively within [480, 960] and is centered; below 480 it no longer compresses, +// switching to horizontal scroll on the outer .pr-comments-scroll (to avoid embedded code / comments being squeezed to unreadable). +// Only constrains the direct child list; the nested replies' .pr-comments-list is unaffected. .pr-comments-scroll > .pr-comments-list { max-width: 960px; min-width: 480px; @@ -45,29 +45,29 @@ } .pr-comment { padding: $space-4 $space-5; - // 卡片视觉(底色 / 边框 / 圆角)只给**顶层评论**;嵌套回复改走「左竖线缩进」的扁平样式(与行内 - // 评论 DiffView 一致)——回复的 <ul> 渲染在父 <li.pr-comment> 内部,若每层都是卡片就会层层「盒中盒」。 + // Card visuals (background / border / radius) apply only to **top-level comments**; nested replies use the flat "left vertical line indent" style (consistent with the inline + // comment DiffView) — a reply's <ul> renders inside the parent <li.pr-comment>, so making every level a card would nest "boxes within boxes". &.pr-comment-depth-0 { background: $bg-elev; border: 1px solid $border-default; border-radius: $radius-md; } - // 嵌套回复:无卡片盒子,仅纵向留白;横向缩进与左竖线由 .pr-comments-replies 提供,满 5 层后拉平。 + // Nested replies: no card box, only vertical spacing; horizontal indent and left vertical line come from .pr-comments-replies, flattened after 5 levels. &:not(.pr-comment-depth-0) { padding: $space-3 0; } } -// 拉平层(满 MAX_REPLY_DEPTH 层后)的回复同层级、无卡片盒子区隔 → 每条加一道顶部横向分割线 + 上下 -// 留白(margin 在线上方、padding 在线下方),清晰区分上下相邻评论(首条同时与上一缩进层级隔开)。 -// 置于 .pr-comment 之后以盖过其 padding-top。 +// Flattened-level replies (after MAX_REPLY_DEPTH levels) are at the same level with no card box to separate them → each gets a top horizontal divider + vertical +// spacing (margin above the line, padding below it), clearly separating adjacent comments (the first is also separated from the previous indent level). +// Placed after .pr-comment to override its padding-top. .pr-comments-flat > .pr-comment { margin-top: $space-4; padding-top: $space-4; border-top: 1px solid $border-muted; } -// 时间线模式的顶层评论:li 自身退掉卡片样式(卡片下移到 .pr-comment-card),标题行走 .pr-activity-item -// 版式与其它事件统一;正文卡片整体缩进、挂在时间线轨右侧。 +// Top-level comments in timeline mode: the li itself drops the card style (the card moves down to .pr-comment-card), the title row uses .pr-activity-item +// layout unified with other events; the body card is indented as a whole, hung on the right of the timeline rail. .pr-comment-timeline.pr-comment-depth-0 { padding: 0; background: none; @@ -75,7 +75,7 @@ border-radius: 0; } .pr-comment-card { - // 缩进对齐到标题行的头像左缘(行左 padding + 图标 22 + gap) + // Indented to align with the title row's avatar left edge (row left padding + icon 22 + gap) margin-left: calc(22px + #{$space-3} * 2); margin-top: $space-2; padding: $space-4 $space-5; @@ -105,7 +105,7 @@ color: $color-info; font-size: $fs-xs; - // base 侧 (old) 用红调,head 侧 (new) 用绿调,跟 diff 视觉一致 + // base side (old) uses a red tone, head side (new) uses a green tone, consistent with the diff visuals &.pr-comment-anchor-old { background: rgb(var(--rgb-danger) / 0.15); color: $color-danger; @@ -121,8 +121,8 @@ padding: 0; } } -// 可点击锚点 chip(活动视图):button 形态需重置原生样式,并给可点反馈。 -// 注意:它是「可点击的 chip」(保留 chip 底色),不同于纯文本链接的 anchor-link mixin。 +// Clickable anchor chip (activity view): the button form needs native styles reset, plus clickable feedback. +// Note: it is a "clickable chip" (retaining the chip background), different from the plain-text-link anchor-link mixin. .pr-comment-anchor-link { border: none; font-family: inherit; @@ -141,7 +141,7 @@ font-size: $fs-md; line-height: $lh-normal; } -// 评论 emoji 反应条(见 tabs/shared/ReactionBar):已有反应 pill + 「加反应」按钮 + 就地候选行。 +// Comment emoji reaction bar (see tabs/shared/ReactionBar): existing reaction pills + "add reaction" button + in-place candidate row. .pr-reactions { margin-top: $space-3; display: flex; @@ -170,7 +170,7 @@ opacity: 0.75; } } -// 自己反应过:主色描边 + 淡主色底 + 计数主色,呼应「已选中」语义。 +// Reacted by self: accent border + faint accent background + accent count, echoing the "selected" semantic. .pr-reaction-mine { border-color: $color-accent; background: $color-accent-bg-fade; @@ -200,22 +200,22 @@ border-radius: $radius-pill; color: $text-muted; cursor: pointer; - // SVG 图标基线规整,line-height:0 去掉行盒带来的额外高度,确保与相邻文字按钮垂直居中。 + // SVG icons have a clean baseline; line-height:0 removes the extra height from the line box, ensuring vertical centering with adjacent text buttons. line-height: 0; - // hover 配色对齐相邻的评论操作按钮(编辑/回复):用 info 色系,避免与 accent 色系混用。 + // hover colors align with adjacent comment action buttons (edit/reply): use the info color family, avoiding mixing with the accent family. &:hover:not(:disabled) { border-color: $color-info; color: $color-info; background: $bg-hover; } } -// 候选浮层经 portal 渲染到 body、fixed 定位(坐标由 JS 按触发按钮位置 + 视口空间自适应算出, -// 上下翻转、视口夹取),从而不被评论滚动容器裁切、也不与其它层级 z-index 干涉。 +// The candidate popover is rendered to body via portal with fixed positioning (coordinates computed by JS from the trigger button position + adaptive viewport space, +// with vertical flip and viewport clamping), so it isn't clipped by the comment scroll container nor interferes with other z-index layers. .pr-reaction-portal { position: fixed; z-index: 1000; } -// 候选面板外观(定位交给 .pr-reaction-portal)。fixed 模式为单行 8 键;free 模式为搜索框 + 网格。 +// Candidate panel appearance (positioning delegated to .pr-reaction-portal). fixed mode is a single row of 8 keys; free mode is a search box + grid. .pr-reaction-picker { display: flex; gap: $space-1; @@ -292,7 +292,7 @@ background: $bg-hover; } } -// 编辑按钮:跟回复按钮同尺寸,hover 主色描边 — 是中性操作,不需要 danger 配色 +// Edit button: same size as the reply button, hover accent border — it's a neutral action, no need for danger colors .pr-comment-edit-btn { padding: 2px $space-4; font-size: $fs-sm; @@ -307,8 +307,8 @@ background: rgb(var(--rgb-accent) / 0.08); } } -// 删除按钮:跟回复按钮同尺寸,主色用 danger 让用户一眼识别为破坏性操作。 -// 二次确认走 ConfirmModal,按钮上不再加 hover-only 隐藏 (避免误以为没这功能) +// Delete button: same size as the reply button, uses danger as the main color so users recognize it at a glance as a destructive action. +// Secondary confirmation goes through ConfirmModal; no more hover-only hiding on the button (to avoid making users think the feature is missing) .pr-comment-delete-btn { padding: 2px $space-4; font-size: $fs-sm; @@ -327,7 +327,7 @@ cursor: not-allowed; } } -// 删除失败 inline 红条 +// Delete failure inline red bar .pr-comment-delete-error { display: flex; align-items: flex-start; @@ -379,7 +379,7 @@ border-color: $color-accent; } } -// @提及自动补全(见 tabs/shared/MentionTextarea):textarea 包一层定位锚,候选浮层覆盖在其下方。 +// @mention autocomplete (see tabs/shared/MentionTextarea): the textarea is wrapped in a positioning anchor, with the candidate popover overlaid below it. .mention-textarea-wrap { position: relative; display: block; @@ -447,7 +447,7 @@ opacity: 1; } } -// 上传图片按钮:悬浮在 textarea 右上角(避开右下角的原生拖拽缩放手柄)。 +// Upload image button: floats at the top-right corner of the textarea (avoiding the native drag-resize handle at the bottom-right). .mention-attach { position: absolute; top: 6px; @@ -526,7 +526,7 @@ } } -// inline 评论上方嵌的代码上下文:Monaco read-only,锚定行整行底色 +// Code context embedded above inline comments: Monaco read-only, full-row background on the anchored line .comment-code-context { margin: $space-3 0; border: 1px solid $border-default; @@ -542,7 +542,7 @@ border-radius: $radius-md; font-size: $fs-sm; } -// 懒加载占位按钮:CAP 之外的 inline 评论用,点击才挂 Monaco +// Lazy-load placeholder button: used for inline comments beyond the CAP, mounts Monaco only on click .comment-code-context-toggle { margin: $space-3 0; padding: $space-3 $space-4; @@ -566,8 +566,8 @@ color: $color-info; } } -// Monaco decoration:锚定行整行底色 + 左侧色带 (margin gutter),跟 inline 锚点 -// chip 颜色保持一组语义 (warning amber 区分于 added 绿 / removed 红) +// Monaco decoration: full-row background on the anchored line + left color band (margin gutter), keeping one semantic group +// with the inline anchor chip color (warning amber, distinct from added green / removed red) .comment-code-context-anchor-line { background: rgb(var(--rgb-warning) / 0.18); } diff --git a/apps/desktop/src/renderer/src/styles/features/pr/commits.scss b/apps/desktop/src/renderer/src/styles/features/pr/commits.scss index 34de2536..29939ca5 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/commits.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/commits.scss @@ -1,5 +1,5 @@ -// PR Commits 标签页:限宽居中表格(sha / 主题 / 作者 / 时间)+ 合并标记。 -// 见 components/features/pr/tabs/CommitsPanel。 +// PR Commits tab: width-limited centered table (sha / subject / author / time) + merge tag. +// See components/features/pr/tabs/CommitsPanel. @use '../../tokens' as *; @@ -10,7 +10,7 @@ background: $bg-app; } .pr-commits-table { - // 表格限宽 960 居中,与详情 / 活动 / 评论页一致;滚动条留在外层 .pr-commits-panel 右缘 + // Table width-limited to 960 and centered, consistent with the detail / activity / comments pages; scrollbar stays on the outer .pr-commits-panel right edge width: 100%; max-width: 960px; margin: 0 auto; @@ -25,17 +25,17 @@ } th { text-align: left; - // 表头与行同步加高,保持视觉一致 + // Header raised in sync with rows, keeping visual consistency padding: $space-5 $space-4; font-weight: 600; color: $text-muted; - // 表头字号不小于正文(与表格 $fs-md 一致),靠加粗 + muted 色区分而非缩小 + // Header font size not smaller than body (consistent with the table's $fs-md), distinguished by bold + muted color rather than shrinking font-size: $fs-md; border-bottom: 1px solid $border-default; white-space: nowrap; } td { - // 行高适当加高(纵向 padding 6px → 10px),提交列表更松弛易读 + // Row height raised moderately (vertical padding 6px → 10px), making the commit list more relaxed and readable padding: $space-5 $space-4; border-bottom: 1px solid $border-muted; vertical-align: middle; @@ -69,8 +69,8 @@ font-weight: 600; } .pr-commits-col-subject { - // 主题列允许折行;但 cap 高度避免极长 message 把表格撑炸 - max-width: 0; // trick: 配合 width:100% 让 table-layout 算可用宽,剩余给主题列 + // Subject column allows wrapping; but cap the height to keep an extremely long message from blowing up the table + max-width: 0; // trick: paired with width:100% to let table-layout compute available width, giving the rest to the subject column width: 100%; word-break: break-word; } diff --git a/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss b/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss index 451c4559..e29367c0 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss @@ -1,5 +1,5 @@ -// PR 头部动作区:浏览器打开 / 合并 / 提交评审 / approve·needs-work 决断按钮。 -// 见 components/features/pr/PrHeader。头部带布局 (.pr-header / -top / -title / -meta) 在 layout/main-pane。 +// PR header actions: open in browser / merge / publish review / approve·needs-work decision buttons. +// See components/features/pr/PrHeader. The header band layout (.pr-header / -top / -title / -meta) is in layout/main-pane. @use '../../tokens' as *; @@ -9,16 +9,16 @@ flex-wrap: wrap; align-items: center; } -// "浏览器打开" 按钮:图标 + 文案 横排 +// "Open in browser" button: icon + label in a row .pr-header-open-browser { display: inline-flex; align-items: center; gap: $space-2; } -// "合并" 按钮:分支合并图标 + 文案。基础态与 approve 的 inactive 一致(默认描边 + 正文色,仅图标用 -// 绿色语义色),不再「常绿填充」——常绿易被误判为「已点击 / 已合并」态。改以 1s 周期 blink 突出 -// 可点击;点击后(merging → disabled)走 .btn:disabled 灰显并停闪。 +// "Merge" button: branch-merge icon + label. Base state matches approve's inactive (default border + body color, only the icon uses +// the green semantic color), no longer "always-green filled" — always-green is easily misread as a "clicked / merged" state. Instead, a 1s-cycle blink highlights +// clickability; after clicking (merging → disabled) it goes through .btn:disabled to gray out and stop blinking. .pr-header-merge { display: inline-flex; align-items: center; @@ -31,7 +31,7 @@ } &:hover:not(:disabled) { - // hover 给确定反馈:停闪 + 实心绿描边 + 浅绿填充 + // hover gives definite feedback: stop blinking + solid green border + light green fill animation: none; background: rgb(var(--rgb-approved) / 0.14); border-color: $color-approved; @@ -50,7 +50,7 @@ background: rgb(var(--rgb-approved) / 0.14); } } -// 降低动态偏好:不闪烁,保留绿描边以示可合并 +// Reduced-motion preference: no blinking, keep the green border to indicate mergeable @media (prefers-reduced-motion: reduce) { .pr-header-merge { animation: none; @@ -58,32 +58,32 @@ } } -// review 决断按钮组:用 auto margin 顶到右边;wrap 时整组下沉但仍保持右对齐 +// review decision button group: pushed to the right with auto margin; on wrap the whole group drops down but stays right-aligned .pr-header-actions-right { margin-left: auto; display: inline-flex; gap: $space-3; } -// "提交评审 (N)" 入口:N > 0 时主色描边 + 主色文字,跟"通过 / 需修改" inactive 态视觉权重一致 -// (它俩也是默认描边、靠 icon 颜色做语义);不做整块填充避免在 header 里盖过"浏览器打开"主色按钮。 -// hover 时浅填一层主色提示可点。0 条时走默认 :disabled 灰显 +// "Publish review (N)" entry: when N > 0, accent border + accent text, matching the visual weight of the "approve / needs-work" inactive state +// (those also use a default border, with icon color carrying the semantic); no full fill, to avoid overpowering the "open in browser" accent button in the header. +// On hover, a light accent fill hints clickability. When 0, it uses the default :disabled gray-out .pr-header-publish { &:not(:disabled) { border-color: $color-info; color: $color-info; &:hover { - // 用极浅的主色填充 (~10% 透明);border 同时加深一档强化可点感 + // Very light accent fill (~10% transparent); border also deepens one step to reinforce clickability background: rgb(var(--rgb-accent) / 0.12); } } } -// PR review 操作按钮 (approve / needs work) —— 图标 + 文案。 -// 图标用动作语义色:approve = 绿,needs work = 黄 (amber)。 -// 文案在 inactive 状态仍是 $text-body,避免整行文字被绿/黄"染色"破坏对比; -// active 状态整体填充语义色,icon 与文字一起翻到对比色 +// PR review action buttons (approve / needs work) —— icon + label. +// Icons use action semantic colors: approve = green, needs work = yellow (amber). +// The label stays $text-body in the inactive state, to avoid the whole line of text being "tinted" green/yellow and breaking contrast; +// in the active state the whole thing fills with the semantic color, icon and text flip together to the contrast color .review-action { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss b/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss index 4053f2cd..316a5768 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/publish-review.scss @@ -1,11 +1,11 @@ -// 提交评审 modal(草稿批量发布,见 components/features/pr/tabs/drafts/PublishReviewModal)。 -// 通用模态壳复用 common/modal:backdrop → .modal-backdrop,底栏 → .modal-actions, -// 外框 → .modal(本文件只叠更宽的 max-width)。这里只放发布列表 / 进度 / 结果等专属内容。 +// Publish review modal (batch-publish drafts; see components/features/pr/tabs/drafts/PublishReviewModal). +// Reuses the common/modal shell: backdrop → .modal-backdrop, footer bar → .modal-actions, +// outer frame → .modal (this file only layers a wider max-width). Here we only put publish-list / progress / result content. @use '../../tokens' as *; @use '../../mixins' as *; -// 比默认 .modal(640) 宽:要展示 N 条草稿的 anchor + body 摘要,太窄一条占两屏不利核对 +// Wider than the default .modal(640): shows anchor + body summary of N drafts; too narrow and one item spans two screens, hard to review .publish-review-modal { max-width: 720px; } @@ -73,7 +73,7 @@ color: $text-primary; word-break: break-all; } -// anchor 可点版本:button 退成链接外观(@include anchor-link)+ hover 主色 +// Clickable anchor variant: button demoted to link appearance (@include anchor-link) + hover primary color .publish-review-item-anchor-link { @include anchor-link; text-align: left; @@ -105,8 +105,8 @@ color: $text-body; background: $bg-panel-alt; border-radius: $radius-sm; - // body 通常 2~10 行,过长折叠:max-height + 内部 scroll,避免一条长 finding - // 把整个 modal 撑爆 + // body is usually 2~10 lines; collapse when too long: max-height + inner scroll, to keep a single long finding + // from blowing out the whole modal max-height: 180px; overflow-y: auto; white-space: pre-wrap; @@ -119,15 +119,15 @@ flex-direction: column; align-items: center; gap: $space-4; - // 上下留多一些纵向空间让 spinner 视觉居中;tokens 阶梯到 $space-8 (16px) 就 - // 封顶了,再加一倍直接写字面值,跟设计意图比照算 + // Extra vertical space above/below so the spinner reads visually centered; the tokens ladder tops out at $space-8 (16px), + // so doubling it is written as a literal, gauged against the design intent padding: 32px $space-8; p { margin: 0; } } -// 复用 base 的 spin keyframe +// Reuses base's spin keyframe .publish-review-spinner { width: 32px; height: 32px; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/reviewer-stack.scss b/apps/desktop/src/renderer/src/styles/features/pr/reviewer-stack.scss index b371818e..ead2c1ee 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/reviewer-stack.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/reviewer-stack.scss @@ -1,5 +1,5 @@ -// PR 头部右上角的 reviewer 头像栈(Bitbucket 风格略重叠 + 决断角标 + 「+n」下拉)。 -// 见 components/features/pr/ReviewerStack。 +// Reviewer avatar stack at the top-right of the PR header (Bitbucket-style slight overlap + verdict badge + "+n" dropdown). +// See components/features/pr/ReviewerStack. @use '../../tokens' as *; @@ -8,7 +8,7 @@ align-items: center; flex-shrink: 0; - // 重叠:除首个外左移;各项带灰色描边环分隔相邻头像 + // Overlap: shift left except the first; each item has a gray outline ring to separate adjacent avatars > * + * { margin-left: -6px; } @@ -20,14 +20,14 @@ position: relative; display: inline-flex; } -// 「我的评审」:与他人头像栈拉开间距并加细分隔线,不重叠(覆盖 `> * + *` 的 -6px 重叠,复合选择器提权)。 +// "My review": spaced apart from the others' avatar stack with a thin divider, no overlap (overrides the -6px overlap of `> * + *`, compound selector raises specificity). .reviewer-stack > .reviewer-stack-self { margin-left: $space-4; padding-left: $space-4; border-left: 1px solid $border-muted; } -// 角标:钉在头像右上角(待评审无角标)。反色处理——实心彩色圆底 + 白色勾/叹号符号(此前是白底彩符, -// 白色面积过大不够醒目)。无描边环(纯彩底,靠颜色与头像区分)。 +// Badge: pinned to the avatar's top-right corner (no badge when pending review). Inverted — solid colored round base + white check/exclamation symbol (was previously a colored symbol on white base, +// too much white area, not prominent enough). No outline ring (pure colored base, distinguished from the avatar by color). .reviewer-stack-badge { position: absolute; top: -1px; @@ -38,7 +38,7 @@ align-items: center; justify-content: center; - // 实心彩色圆底(按状态着色,撑满角标)。 + // Solid colored round base (colored by status, fills the badge). &::before { content: ''; position: absolute; @@ -48,8 +48,8 @@ } svg { display: block; - position: relative; // 压在彩底之上 - color: #fff; // 白色符号 + position: relative; // sits on top of the colored base + color: #fff; // white symbol } } .reviewer-stack-badge-approved { @@ -58,12 +58,12 @@ .reviewer-stack-badge-needsWork { --reviewer-badge-color: #{$color-warning}; } -// 「+n」溢出项:灰圆,点击展开下拉 +// "+n" overflow item: gray circle, click to expand the dropdown .reviewer-stack-more { width: 32px; height: 32px; padding: 0; - border: none; // 复位浏览器默认 button 边框,使其与头像一致只剩 box-shadow 外框 + border: none; // reset the browser default button border so it matches avatars with only the box-shadow frame left border-radius: $radius-full; background: $bg-surface; box-shadow: 0 0 0 1px $border-default; @@ -81,7 +81,7 @@ color: $text-primary; } } -// 「+n」下拉(fixed + portal,视觉同 diff-scope-menu):其余 reviewer 头像 + 名 + 决断 chip +// "+n" dropdown (fixed + portal, visually same as diff-scope-menu): remaining reviewer avatar + name + verdict chip .reviewer-stack-menu { z-index: $z-statusbar-menu; min-width: 200px; diff --git a/apps/desktop/src/renderer/src/styles/features/pr/statusbar.scss b/apps/desktop/src/renderer/src/styles/features/pr/statusbar.scss index 11ea1700..825377e5 100644 --- a/apps/desktop/src/renderer/src/styles/features/pr/statusbar.scss +++ b/apps/desktop/src/renderer/src/styles/features/pr/statusbar.scss @@ -1,23 +1,23 @@ -// pr 域在底部状态栏的 chip:PR 数 / 最近同步时间 + 刷新按钮 / 当前 repo clone-fetch 进度。 -// 组件见 components/features/pr/statusbar/。基础壳在 layout/statusbar;活动脉冲点 -// .activity-dot 在 base(repo sync 与 pr-agent 跨域共用)。 +// pr-domain chips in the bottom status bar: PR count / last sync time + refresh button / current repo clone-fetch progress. +// Components in components/features/pr/statusbar/. Base shell in layout/statusbar; the activity pulse dot +// .activity-dot is in base (shared across domains by repo sync and pr-agent). @use '../../tokens' as *; @use '../../mixins' as *; -// PR 数 chip:图标 + 数字横排(gap / svg 基线由 .statusbar-chip 统一)。用与 pr-agent 活动 / -// repo sync 同一蓝调(chip-tone accent),与主题强调色一致。 +// PR count chip: icon + number in a row (gap / svg baseline unified by .statusbar-chip). Uses the same blue tone as pr-agent activity / +// repo sync (chip-tone accent), consistent with the theme accent color. .statusbar-chip-prs { @include chip-tone(accent); font-variant-numeric: tabular-nums; } -// 同步时间 chip:时钟图标 + 相对时间文本 -// 合并刷新按钮 + 同步状态:chip 外观的可点击按钮(重置 button 默认样式) +// Sync time chip: clock icon + relative time text +// Merges refresh button + sync status: a clickable button with chip appearance (resets button default styles) .statusbar-sync-btn { border: none; color: inherit; - // 只继承字体族;字号沿用 .statusbar-chip,但同步状态文案不加粗 + // Inherit only the font family; font size follows .statusbar-chip, but sync-status text is not bold font-family: inherit; font-weight: 400; cursor: pointer; @@ -33,8 +33,8 @@ font-variant-numeric: tabular-nums; } -// 当前 repo sync (clone/fetch) chip:跟 pr-agent 活动 chip 同蓝调(chip-tone accent), -// dot 用 .activity-dot 的脉冲语义即"在跑"。idle 时整条不渲染 +// Current repo sync (clone/fetch) chip: same blue tone as the pr-agent activity chip (chip-tone accent), +// dot uses .activity-dot's pulse semantic meaning "running". Not rendered at all when idle .statusbar-repo-sync-chip { @include chip-tone(accent); font-variant-numeric: tabular-nums; diff --git a/apps/desktop/src/renderer/src/styles/features/settings/forms.scss b/apps/desktop/src/renderer/src/styles/features/settings/forms.scss index fce8ecbe..6ccf0d9c 100644 --- a/apps/desktop/src/renderer/src/styles/features/settings/forms.scss +++ b/apps/desktop/src/renderer/src/styles/features/settings/forms.scss @@ -1,11 +1,11 @@ -// settings 域的表单 / 列表 / 设置项控件样式:SettingsModal 各 section + 首启向导复用。 -// 通用模态壳 (.modal / .modal-section / .modal-kv 等) 在 common/modal;这里只放 settings 专属。 +// Form / list / settings-item control styles for the settings domain: reused by SettingsModal sections + first-run wizard. +// Generic modal shell (.modal / .modal-section / .modal-kv etc.) lives in common/modal; only settings-specific styles go here. @use '../../tokens' as *; @use '../../mixins' as *; -// 配置面板左右分区:模态 body 去内边距、由本布局接管;左侧分区导航固定不滚, -// 右侧分区内容独立滚动。分区结构为后续扩展(主题 / 编辑器 / 上下文窗口等)预留。 +// Settings panel left/right split: the modal body drops its padding, this layout takes over; the left section nav stays fixed and doesn't scroll, +// the right section content scrolls independently. The section structure is reserved for future expansion (theme / editor / context window etc.). .modal-body.settings-modal-body { padding: 0; overflow: hidden; @@ -17,7 +17,7 @@ display: flex; flex: 1; min-width: 0; - // 撑满定高模态(.modal-lg):左导航固定、右面板独立滚动,不随分区内容增减高度 + // Fill the fixed-height modal (.modal-lg): left nav fixed, right panel scrolls independently, height doesn't change with section content min-height: 0; } .settings-nav { @@ -26,14 +26,14 @@ flex-direction: column; gap: $space-1; padding: $space-5 $space-4; - // 与模态主体同色($bg-elev),仅以右侧分隔线区分导航区,避免色块割裂 + // Same color as the modal body ($bg-elev), the nav area is set apart only by the right divider line to avoid color-block fragmentation border-right: 1px solid $border-default; overflow-y: auto; } .settings-nav-item { display: flex; align-items: center; - // 图标与文案间距放宽:原 $space-3 视觉上偏挤、偏左,拉到 $space-5 让二者更舒展 + // Widen the icon-to-text spacing: the original $space-3 looked cramped and left-leaning, pulling it to $space-5 gives both more breathing room gap: $space-5; padding: $space-3 $space-5; border: none; @@ -49,8 +49,8 @@ color: $text-muted; } - // 标签允许换行 + 断词:德语等长复合词(如 Benachrichtigungen)在固定宽导航里一行放不下, - // 靠 hyphens 正确断词(依赖 <html lang>,见 i18n),overflow-wrap 兜底——绝不裁切。 + // Labels allow wrapping + word breaking: long compound words in German etc. (e.g. Benachrichtigungen) don't fit one line in the fixed-width nav, + // hyphens break them correctly (depends on <html lang>, see i18n), overflow-wrap as fallback — never clip. span { min-width: 0; overflow-wrap: break-word; @@ -78,33 +78,33 @@ overflow-y: auto; padding: $space-8; - // 设置页配置项上下间距放宽一档(比通用 modal 的 $space-8 更舒展),避免堆叠拥挤 + // Widen the top/bottom spacing of settings items one notch (more relaxed than the generic modal's $space-8), avoiding a cramped stack .modal-section { margin-bottom: 20px; } - // 分区内末个 section 去掉多余底部留白 + // Drop the extra bottom whitespace on the last section within the group .modal-section:last-child { margin-bottom: 0; } } -// 语言下拉:固定宽度、靠右(块定位),文本左对齐;不被 .settings-input 的 flex:1 撑满(复合选择器提权) +// Language dropdown: fixed width, right-aligned (block positioning), text left-aligned; not stretched by .settings-input's flex:1 (compound selector raises specificity) .settings-input.settings-language-select { flex: 0 0 auto; width: 180px; } -// 整行铺满的输入(编辑器字体:自由输入、可能较长的逗号分隔列表,单独成行铺满、不靠右压缩) +// Full-row input (editor font: free input, possibly long comma-separated list, on its own row filling full width, not compressed to the right) .settings-input.settings-input-block { flex: none; width: 100%; } -// 单独成行的字段:标题在上、控件在下;收紧标题与控件间距 +// Field on its own row: title on top, control below; tighten the title-to-control spacing .settings-field-stacked h4 { margin-bottom: $space-3; } -// 设置项启用状态 chip:复用 common chip 词汇 —— on=approved(绿)、off=neutral(灰) +// Settings-item enabled-state chip: reuses common chip vocabulary — on=approved(green), off=neutral(gray) .settings-status-chip { @include chip; padding: 1px $space-2; @@ -118,7 +118,7 @@ } } -// settings-input 在 modal-kv 值列里撑满(覆盖默认 input 宽度) +// settings-input fills the modal-kv value column (overrides the default input width) .modal-kv-val .settings-input { width: 100%; box-sizing: border-box; @@ -145,13 +145,13 @@ margin-top: $space-5; } -// 拖拽滑块(轮询间隔等):自定义细 track(3px)+ 蓝色填充(--range-fill 控制)+ 圆点 thumb +// Drag slider (polling interval etc.): custom thin track (3px) + blue fill (controlled by --range-fill) + round-dot thumb .settings-range { align-self: center; cursor: pointer; -webkit-appearance: none; appearance: none; - height: 12px; // 给 thumb 垂直居中留空间 + height: 12px; // leave room to vertically center the thumb background: transparent; &::-webkit-slider-runnable-track { @@ -169,14 +169,14 @@ appearance: none; width: 12px; height: 12px; - margin-top: -4.5px; // (track 3 - thumb 12)/2,使圆点垂直居中于细线 + margin-top: -4.5px; // (track 3 - thumb 12)/2, vertically centers the dot on the thin line border-radius: 50%; background: $color-accent; } } -// 档位刻度参考行:每个刻度按 thumb 停靠位置绝对定位(见组件内 left 计算), -// translateX(-50%) 居中对齐;当前档位高亮 +// Step tick reference row: each tick is absolutely positioned at the thumb's stop position (see the left calculation in the component), +// translateX(-50%) centers it; the current step is highlighted .settings-range-ticks { position: relative; height: 16px; @@ -202,25 +202,29 @@ gap: $space-4; flex-wrap: wrap; } -// 行内结果文案(如「测试连接」结果):去掉块级 margin-top,与同行按钮垂直居中对齐 +// Inline result text (e.g. "test connection" result): drops the block-level margin-top, vertically centered with the buttons on the same row .settings-actions .error-text { margin-top: 0; } -// 关于 & 反馈:低频社区外链行(Star / Issue / Releases)。带 1px 分隔线 + 留白与上方运行时 -// 控件清晰分区;各链接带专属图标、加大间距,彼此可辨。克制的文字链接,不抢运行时控件。 +// About & feedback: a label on its own line, then a wrapping row of low-frequency community external links (Website / Star / Issue / Releases). +// A 1px divider + whitespace cleanly separates it from the runtime controls above; each link has its own icon and wider spacing, distinguishable +// from one another. Restrained text links that don't upstage the runtime controls. .settings-about-links { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: $space-6; margin-top: $space-6; padding-top: $space-5; border-top: 1px solid $border-default-fade; font-size: $fs-sm; } .settings-about-label { - margin-right: $space-2; + display: block; + margin-bottom: $space-3; +} +.settings-about-linkrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $space-6; } .settings-about-link { display: inline-flex; @@ -253,12 +257,12 @@ } } -// 关于页「复制运行环境信息」按钮:紧跟运行时标题的图标按钮;复制成功后短暂切到打勾的高饱和 -// 绿色态(is-copied)作反馈。两态均为同尺寸图标、不改内边距,避免点击后高度抖动。 +// About page "copy runtime environment info" button: an icon button right after the runtime title; on successful copy it briefly switches to a checkmark high-saturation +// green state (is-copied) as feedback. Both states are same-size icons with unchanged padding, avoiding height jitter after clicking. .settings-copy-info { display: inline-flex; align-items: center; - margin-left: $space-3; // 与标题拉开间距 + margin-left: $space-3; // add spacing from the title &.is-copied { border-color: $color-success-strong; @@ -266,8 +270,8 @@ } } -// 原生 <select> 默认比文本输入高:去掉原生外观、补自绘下拉箭头,盒模型与 -// .settings-input 完全一致从而等高。 +// Native <select> is taller than text inputs by default: strip the native appearance, add a self-drawn dropdown arrow, box model +// exactly matches .settings-input so heights are equal. select.settings-input { appearance: none; -webkit-appearance: none; @@ -288,13 +292,13 @@ select.settings-input { letter-spacing: 0.04em; } - // 显隐(眼睛)按钮跟随输入框高度,不因图标小而矮一截,行视觉对齐其它单输入行 + // The show/hide (eye) button follows the input height, not shorter just because the icon is small, visually aligning the row with other single-input rows .btn { align-self: stretch; } } -// 必填项标识 + 校验错误样式 +// Required-item marker + validation error styles .settings-required { color: $color-danger; margin-left: $space-1; @@ -318,12 +322,12 @@ select.settings-input { margin: $space-2 0 0; } -// LLM 模型主面板紧凑列表:active radio + 名称/概要 + 操作按钮 +// LLM model main panel compact list: active radio + name/summary + action buttons .llm-profile-list { display: flex; flex-direction: column; gap: $space-3; - // 最多展示约 4.5 行:露出半行提示下方还有内容、可滚动,避免漏看(4 行整高会被误以为到底) + // Show at most about 4.5 rows: exposing a half row hints there's more content below and it scrolls, avoiding missed items (a full 4-row height would be mistaken for the end) max-height: 266px; overflow-y: auto; } @@ -346,8 +350,8 @@ select.settings-input { align-items: center; cursor: pointer; } -// 卡片前缀类型图标:连接=平台品牌图标(彩色),LLM=模型/终端图标(随文字色)。固定占位 -// 让标题起点对齐,一眼区分类型、避免误判。 +// Card prefix type icon: connection=platform brand icon (colored), LLM=model/terminal icon (follows text color). Fixed placeholder +// aligns the title start point, distinguishing types at a glance and avoiding misjudgment. .llm-profile-icon { flex-shrink: 0; width: 22px; @@ -376,7 +380,7 @@ select.settings-input { @include ellipsis; min-width: 0; } -// 实验性标记:本地 CLI 类 provider 受上游 CLI 行为变更影响,标注以示风险。 +// Experimental marker: local CLI-type providers are affected by upstream CLI behavior changes, marked to signal risk. .badge-experimental { flex-shrink: 0; font-size: $fs-xs; @@ -393,12 +397,12 @@ select.settings-input { font-size: $fs-sm; } -// 分区下的「下一级功能列表」(如「策略」的子项):左缩进 + 行首圆点体现从属关系;每行左侧 -// 标题 + 说明,右侧放控件——控件不限于开关,后续可为下拉 / 滑块等,故命名与结构保持通用。 +// "Next-level feature list" under a section (e.g. sub-items of "strategy"): left indent + leading dot conveys the subordinate relationship; each row has title + +// description on the left, control on the right — the control isn't limited to a switch and could later be a dropdown / slider etc., so naming and structure stay generic. .settings-sublist { list-style: none; margin: $space-1 0 0; - padding-left: $space-3; // 整体内缩,与分区标题拉开层级 + padding-left: $space-3; // indent as a whole, setting the level apart from the section title } .settings-sublist-row { display: flex; @@ -406,7 +410,7 @@ select.settings-input { gap: $space-4; padding: $space-3 0; - // 行首圆点:列表标记,体现子项从属 + // Leading dot: list marker, conveys sub-item subordination &::before { content: '•'; flex-shrink: 0; @@ -421,7 +425,7 @@ select.settings-input { display: flex; flex-direction: column; gap: 2px; - flex: 1; // 撑开,把右侧控件顶到行末 + flex: 1; // expand, pushing the right control to the end of the row min-width: 0; } .settings-sublist-label { @@ -431,10 +435,17 @@ select.settings-input { font-size: $fs-sm; line-height: $lh-normal; } -// 子项右侧下拉:与其他主配置下拉(settings-language-select)同宽 180px,不被 .settings-input -// 的 flex:1 撑满(复合选择器提权覆盖)。纯数值选项右对齐,避免在宽框内孤零零靠左。 +// Sub-item right dropdown: same 180px width as other main config dropdowns (settings-language-select), not stretched by .settings-input's +// flex:1 (compound selector raises specificity to override). Pure numeric options are right-aligned, avoiding them sitting lonely on the left in a wide box. .settings-input.settings-sublist-select { flex: 0 0 auto; width: 180px; text-align: right; } +// Sub-item right edit control: a status chip (configured / default) + an "Edit" button opening the template modal. +.settings-sublist-edit { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: $space-2; +} diff --git a/apps/desktop/src/renderer/src/styles/features/settings/statusbar.scss b/apps/desktop/src/renderer/src/styles/features/settings/statusbar.scss index 031ccd5a..1675fa25 100644 --- a/apps/desktop/src/renderer/src/styles/features/settings/statusbar.scss +++ b/apps/desktop/src/renderer/src/styles/features/settings/statusbar.scss @@ -1,5 +1,5 @@ -// settings 域在底部状态栏的 chip:当前连接用户 + LLM 切换 chip / 下拉菜单。 -// 组件见 components/features/settings/statusbar/。基础壳在 layout/statusbar。 +// Chips for the settings domain in the bottom status bar: current connection user + LLM switch chip / dropdown menu. +// Components in components/features/settings/statusbar/. Base shell in layout/statusbar. @use '../../tokens' as *; @use '../../mixins' as *; @@ -18,13 +18,13 @@ } } -// === LLM 切换 chip + 下拉菜单:自洽组件,统一 llm-chip-* 命名(原 statusbar-llm-* 并入)=== +// === LLM switch chip + dropdown menu: self-contained component, unified llm-chip-* naming (former statusbar-llm-* merged in) === .llm-chip-wrap { position: relative; display: inline-flex; } -// chip 本体(与 .statusbar-chip 基类组合);active 态用 VS Code 蓝标签色, -// 跟 pending status-tag / blame-toggle.active 同色 +// The chip itself (combined with the .statusbar-chip base class); active state uses the VS Code blue tag color, +// same color as pending status-tag / blame-toggle.active .llm-chip { cursor: pointer; @@ -33,7 +33,7 @@ color: $text-on-accent; } } -// 切换菜单:chip 正上方弹出 +// Switch menu: pops up directly above the chip .llm-chip-menu { @include popover; bottom: calc(100% + #{$space-2}); diff --git a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss index 7cb83fe6..8d4fc451 100644 --- a/apps/desktop/src/renderer/src/styles/layout/main-pane.scss +++ b/apps/desktop/src/renderer/src/styles/layout/main-pane.scss @@ -1,6 +1,6 @@ -// 主面板布局壳:主区容器 + 加载占位 + PR 头部带(标题/元信息)+ 标签栏(tab / 角标 / 右侧操作组) -// + tab 内容容器。各业务组件样式拆到 features/pr/(reviewer-stack / header-actions / comments / -// activity / commits)与 features/diff/blame。 +// Main pane layout shell: main-area container + loading placeholder + PR header band (title/meta) + tab bar (tab / badge / right action group) +// + tab content container. Individual component styles are split into features/pr/ (reviewer-stack / header-actions / comments / +// activity / commits) and features/diff/blame. @use '../tokens' as *; @@ -23,8 +23,8 @@ padding: 24px; } -// 重型组件 async init 期间的延迟 loading 占位(PaneLoading;Monaco Suspense fallback、 -// ChatPane 会话加载、diff 编辑器 ready 前的 overlay 共用) +// Deferred loading placeholder during heavy components' async init (PaneLoading; shared by Monaco Suspense fallback, +// ChatPane session loading, and the overlay before the diff editor is ready) .pane-loading { display: flex; align-items: center; @@ -35,18 +35,18 @@ padding: 24px; font-size: 13px; - // .spinner 默认带右 margin(inline-文字场景用),居中布局里由 gap 接管,清掉避免偏移 + // .spinner has a right margin by default (for inline-text scenarios); in centered layout gap takes over, clear it to avoid offset .spinner { margin: 0; } } -// overlay 变体:绝对定位铺满父容器(父需 position:relative),盖在 Monaco 编辑器之上, -// 遮住「空编辑器 → diff 计算 / 折叠跳一下」那段重排,diff 就绪后由调用方卸载。 +// overlay variant: absolutely positioned filling the parent (parent needs position:relative), covers the Monaco editor, +// hides the reflow during "empty editor → diff computation / folding jump"; unmounted by the caller once diff is ready. .pane-loading-overlay { position: absolute; inset: 0; - // 盖在 Monaco 之上:编辑器内部有自带 stacking context,z-index 取高值确保 overlay 在最上层 + // Covers Monaco: the editor has its own stacking context internally, use a high z-index to ensure the overlay stays on top z-index: 20; height: auto; background: $bg-app; @@ -58,7 +58,7 @@ flex-shrink: 0; } -// 标题 + 元信息 band 与 reviewer 头像栈同行:头像栈靠右、相对该 band 垂直居中(位于动作按钮行之上) +// Title + meta band on the same row as the reviewer avatar stack: stack aligned right, vertically centered relative to the band (above the action button row) .pr-header-top { display: flex; align-items: center; @@ -91,18 +91,18 @@ flex-shrink: 0; padding: 0 $space-6; gap: 0; - // 容器宽度不够时整组横向滑动,不让单个 tab 被挤压触发文字换行 (会让 tab 栏 - // 高度从 1 行变 2 行,破坏 main-pane 上半区固定高度的预期)。 - // overflow-y: hidden 防止纵向出现 scrollbar 影响视觉 + // When container width is insufficient the whole group scrolls horizontally, so no single tab gets squeezed into text wrapping (which would make the tab bar + // height go from 1 line to 2 lines, breaking the expected fixed height of the main-pane's upper area). + // overflow-y: hidden prevents a vertical scrollbar from affecting visuals overflow-x: auto; overflow-y: hidden; - // 隐藏横向滚动条但保留滚动能力 — 用户拖也能滑,但视觉上跟原 tab 栏一致 + // Hide the horizontal scrollbar but keep scroll ability — users can still drag to scroll, but visually consistent with the original tab bar scrollbar-width: none; // Firefox &::-webkit-scrollbar { display: none; } - // tab 内嵌的数字角标 (评论数 / 待办数等)。圆角胶囊紧贴文字右侧 + // Numeric badge embedded in the tab (comment count / todo count, etc.). Rounded pill hugging the right side of the text .pr-tab-badge { display: inline-flex; align-items: center; @@ -119,15 +119,15 @@ line-height: 1; font-variant-numeric: tabular-nums; - // warning 变体:草稿待发布 chip 用 amber,跟文件树 .tree-draft-count 同色 - // 让用户对"待办"语义在不同位置的识别保持一致 + // warning variant: draft-pending-publish chip uses amber, same color as the file tree .tree-draft-count + // keeping users' recognition of the "todo" semantic consistent across locations &.pr-tab-badge-warning { background: $color-pending-fade; color: $color-warning; } - // 加载占位:等宽空 chip 预留角标宽度,消除计数异步到达时 tab 的横向弹簧抖动。 - // 底色更淡 + 轻微脉冲,提示"加载中"而非真实计数。 + // Loading placeholder: equal-width empty chip reserves badge width, eliminating the tab's horizontal spring jitter when the count arrives asynchronously. + // Lighter background + slight pulse, hinting "loading" rather than a real count. &.pr-tab-badge-loading { background: $color-accent-bg-fade; opacity: 0.45; @@ -150,24 +150,24 @@ } } -/* 右侧操作组:blame 开关 + 并列/合并 切换。整组顶到右边 */ +/* Right action group: blame toggle + side-by-side/unified switch. The whole group pushed to the right */ .pr-tabs-right { display: inline-flex; align-items: center; gap: $space-4; margin-left: auto; padding-right: $space-4; - // 整组不被 flex 压缩 — diff 区窄时靠 .pr-tabs overflow-x 横向滑动, - // 而不是把按钮挤窄触发文字 wrap + // The whole group isn't compressed by flex — when the diff area is narrow, rely on .pr-tabs overflow-x for horizontal scroll, + // rather than squeezing the buttons narrow and triggering text wrap flex-shrink: 0; .diff-mode-toggle { - margin-left: 0; /* 在 right 组内不需要再 margin-left:auto */ + margin-left: 0; /* inside the right group no need for margin-left:auto */ } - /* 操作按钮高度钉死:带图标的"空白/追溯"行盒比纯文字略高,不固定就会顶出 - tab 栏、跟"并排/统一"参差。统一 height + box-sizing 让四个按钮严丝合缝; - nowrap + 不收缩 防止窄宽度下中文竖排换行撑破固定高度。 */ + /* Action button height pinned: the icon-bearing "blame/trace" line box is slightly taller than plain text; without pinning it would push out + the tab bar and misalign with "side-by-side/unified". Unified height + box-sizing makes the four buttons fit tightly; + nowrap + no-shrink prevents CJK text from wrapping at narrow widths and blowing out the fixed height. */ .blame-toggle, .diff-mode-toggle button { height: 22px; @@ -182,9 +182,9 @@ } .pr-tab { - // inline-flex + 垂直居中:tab 高度由始终存在的文字行盒决定,计数角标(inline-flex 16px) - // 居中放置、不超过文字行高 → 角标加载占位 / 出现 / 消失都不改变 tab 高度,消除 tab 栏 - // 加载计数时的 1~2px 竖向抖动(默认 inline 布局下角标会按基线顶高行盒)。 + // inline-flex + vertical centering: tab height is determined by the always-present text line box, the count badge (inline-flex 16px) + // is centered and doesn't exceed the text line height → badge loading placeholder / appearance / disappearance never changes tab height, eliminating the tab bar's + // 1~2px vertical jitter when loading counts (in default inline layout the badge would push up the line box along the baseline). display: inline-flex; align-items: center; padding: $space-4 $space-8; @@ -196,8 +196,8 @@ line-height: $lh-normal; border-bottom: 2px solid transparent; margin-bottom: -1px; - // 文字单行 + 不被 flex 容器挤压 — 窄宽度下保持单 tab 完整尺寸,整组靠 - // .pr-tabs overflow-x 横向滑动而不是文字 wrap (避免 tab 栏高度变化) + // Single-line text + not squeezed by the flex container — keeps a single tab at full size at narrow widths, the whole group relies on + // .pr-tabs overflow-x for horizontal scroll rather than text wrap (avoiding tab bar height changes) white-space: nowrap; flex-shrink: 0; @@ -216,12 +216,12 @@ min-height: 0; display: flex; } -// keep-alive tab 保活容器:active 时铺满 .pr-tab-content(内部面板各自 flex:1), -// inactive 由内联 display:none 隐藏但保留挂载(见 PrPanel KeepAliveTab)。 +// keep-alive tab container: fills .pr-tab-content when active (inner panels each flex:1), +// inactive is hidden by inline display:none but kept mounted (see PrPanel KeepAliveTab). .pr-tab-pane { flex: 1; min-height: 0; - // 允许在窄宽度下收缩到小于内容固有宽度,让内部滚动容器接管横向滚动(否则会撑破、被 ChatPane 盖住) + // Allows shrinking below the content's intrinsic width at narrow widths, letting the inner scroll container take over horizontal scroll (otherwise it overflows and gets covered by ChatPane) min-width: 0; display: flex; } diff --git a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss index 0776a879..8433c19f 100644 --- a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss @@ -7,7 +7,7 @@ } .sidebar { - // width 通过 inline style 由 App 控制,min/max 由 React 端 clamp,CSS 端兜底 + // width is controlled by App via inline style, min/max clamped on the React side, CSS side as fallback flex-shrink: 0; border-right: 1px solid $border-default; background: $bg-panel; @@ -46,9 +46,9 @@ .sidebar-filters { flex-wrap: wrap; - // 胶囊宽度自适应:basis 取内容宽(auto)→ 一行挤得下就用一行;容纳不下时按内容宽换行(窄侧栏下 - // 自然落为每行两个)。flex-grow 让每行按项数均分撑满整行,消除定宽按钮换行后右侧留白参差。 - // 内容居中,读作对称胶囊。 + // Pill width auto-adapts: basis takes content width (auto) → one row if it fits; wraps by content width when it doesn't (in a narrow sidebar + // naturally falls to two per row). flex-grow makes each row split evenly to fill the whole row, eliminating uneven right-side whitespace after fixed-width buttons wrap. + // Content centered, reads as symmetric pills. .btn { flex: 1 1 auto; justify-content: center; @@ -69,18 +69,18 @@ } } -// 范围行(常驻):左组发现分类 / 进行中锚点 + 右组「已关闭」辅助切换。两组间距分隔,右组靠右。 -// **行本身不设上下 padding**(垂直留白下放到 tab 内):这样行内容盒 = 可见区,靠右图标按钮 align-self -// center 才真正落在可见区垂直中心(上下留白一致);同时 tab 的下划线指示仍贴住行底(紧邻 1px 分隔线)。 +// Scope row (always present): left group discovery categories / in-progress anchor + right group "closed" auxiliary toggle. The two groups separated by spacing, right group aligned right. +// **The row itself has no top/bottom padding** (vertical whitespace pushed down into the tabs): this way the row content box = visible area, so the right-aligned icon button's align-self +// center actually lands at the visible area's vertical center (symmetric top/bottom whitespace); meanwhile the tab's underline indicator still hugs the row bottom (adjacent to the 1px divider). .sidebar-scope { - // 组间仅留极小间隔($space-2):把多余留白让给左侧 tab 组,使 tabs 尽量铺满、延伸到贴近图标按钮 + // Only a minimal gap between groups ($space-2): give the excess whitespace to the left tab group, so the tabs fill as much as possible and extend close to the icon button gap: $space-2; - // 左缘对齐其余 toolbar($space-6);右缘收紧($space-4)让「已关闭」图标按钮更靠右、贴近面板边 + // Left edge aligns with the rest of the toolbar ($space-6); right edge tightened ($space-4) to push the "closed" icon button further right, close to the panel edge padding: 0 $space-4 0 $space-6; align-items: stretch; } -// 左组:发现分类 tabs 等分(或无分类平台的单一「进行中」锚点);flex-grow 吃掉行内多余空间 +// Left group: discovery category tabs split evenly (or a single "in-progress" anchor for platforms without categories); flex-grow eats up the extra space in the row .sidebar-scope-primary { flex: 1 1 auto; display: flex; @@ -88,8 +88,8 @@ min-width: 0; } -// 右组:「已关闭」切换 —— **非 tab**,做成靠右的独立图标按钮(类似工具栏「⋯」),不随 tabs 拉伸、 -// 不带下划线指示;选中态用填充底 + 高亮色,区别于左组 tab 的下划线指示。 +// Right group: "closed" toggle — **not a tab**, made a standalone right-aligned icon button (like the toolbar "⋯"), doesn't stretch with the tabs, +// no underline indicator; selected state uses filled background + highlight color, distinct from the left group tabs' underline indicator. .sidebar-scope-history { flex: 0 0 auto; align-self: center; @@ -113,7 +113,7 @@ color: $text-body; } - // 选中(已关闭范围激活):填充底 + 高亮色(图标按钮式按下态,非 tab 下划线) + // Selected (closed scope active): filled background + highlight color (icon-button-style pressed state, not tab underline) &.is-active { background: $bg-selected; color: $color-info; @@ -122,16 +122,16 @@ .sidebar-discovery-tab { flex: 1; - // 文字在 tab 高度内垂直 + 水平居中 + // Text vertically + horizontally centered within the tab height display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-bottom: 2px solid transparent; - // 非活动 tab 用可读的 muted,而非几乎不可见的 dim,提升整组可视性 + // Inactive tab uses a readable muted rather than the near-invisible dim, improving the whole group's visibility color: $text-muted; - // 垂直留白下放到 tab:上 $space-4、下 $space-3 + 2px 下划线 ≈ 上下对称(行更高些),文字居中;下划线贴住行底 + // Vertical whitespace pushed down into the tab: top $space-4, bottom $space-3 + 2px underline ≈ symmetric top/bottom (row a bit taller), text centered; underline hugs the row bottom padding: $space-4 0 $space-3; font-size: 12px; cursor: pointer; @@ -143,14 +143,14 @@ color: $text-body; } - // 活动 tab 用亮蓝 $color-info(对齐 ChatPane 的明亮配色),比深蓝 $color-accent 更醒目 + // Active tab uses bright blue $color-info (aligned with ChatPane's bright palette), more prominent than the dark blue $color-accent &.is-active { color: $color-info; border-bottom-color: $color-info; } } -// 一级发现分类标签的未读圆点:该分类下有未读 PR 时,跟在标签文字后(复用 PR 项未读点的亮蓝)。 +// Unread dot for top-level discovery category labels: when there are unread PRs under the category, follows the label text (reuses the bright blue of the PR item unread dot). .sidebar-discovery-tab-dot { display: inline-block; width: 6px; @@ -220,7 +220,7 @@ user-select: none; display: flex; gap: $space-5; - // 头像 40px 视觉锚定整行:标题 + meta 大约 36-40px 高,center 对齐看起来最稳 + // The 40px avatar visually anchors the whole row: title + meta are roughly 36-40px tall, center alignment looks the most stable align-items: center; &:hover { @@ -240,7 +240,7 @@ min-width: 0; } -// 圆形头像:img 与 initials 共用尺寸 / 圆角;initials 走 hash 色 + 白字 +// Circular avatar: img and initials share size / radius; initials use a hash color + white text .avatar { display: inline-flex; align-items: center; @@ -279,7 +279,7 @@ margin-bottom: $space-2; line-height: $lh-tight; word-break: break-word; - // 超长 PR 标题截到 2 行 + 省略号,避免单条 PR 撑爆整列高度 + // Very long PR titles truncated to 2 lines + ellipsis, to keep a single PR from blowing out the whole column height display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -294,8 +294,8 @@ gap: $space-1; } -// meta 两行:row 1 = author + 状态 chip;row 2 = branch (源→目标) -// flex-direction:row + align-items:center 让 chip 跟 author 基线居中 +// meta two rows: row 1 = author + status chip; row 2 = branch (source→target) +// flex-direction:row + align-items:center centers the chip with the author baseline .pr-item-meta-row { display: flex; align-items: center; @@ -307,19 +307,19 @@ .pr-item-meta-branch { flex: 1 1 auto; min-width: 0; - // flex + align-items:center 真正垂直居中前缀图标与文字(取代脆弱的 vertical-align 基线微调); - // 文字的省略号截断交给内层 .pr-item-meta-text。 + // flex + align-items:center truly vertically centers the prefix icon and text (replacing the fragile vertical-align baseline tweak); + // text ellipsis truncation is delegated to the inner .pr-item-meta-text. display: flex; align-items: center; - // 图标前缀:固定列宽对齐 author/branch 两行,不随文字省略号缩没 + // Icon prefix: fixed column width aligns the author/branch two rows, doesn't shrink away with text ellipsis svg { flex: 0 0 auto; margin-right: $space-2; opacity: 0.75; } } -// 前缀图标后的文字:占满剩余宽度并在过长时省略号截断(min-width:0 让其可收缩)。 +// Text after the prefix icon: fills the remaining width and truncates with ellipsis when too long (min-width:0 lets it shrink). .pr-item-meta-text { min-width: 0; overflow: hidden; @@ -327,14 +327,14 @@ white-space: nowrap; } -// chip 推右贴边 + 不可缩 — 长 author 时 chip 仍可见 +// chip pushed to the right edge + non-shrinkable — chip still visible with a long author .pr-item-review-chips { flex: 0 0 auto; margin-left: auto; } -// reviewer 统计胶囊:靠右贴边,把 author/branch meta 挤左。固定带高 + 居中:让不同行无论含哪种 -// chip(文字计数 / 图标星标 / 执行中 spinner)都占同一高度,避免跨行 chip 垂直位置漂移。 +// Reviewer stats pill: aligned to the right edge, pushing the author/branch meta left. Fixed band height + centered: so different rows, whichever kind of +// chip they contain (text count / icon star / running spinner), all occupy the same height, avoiding vertical drift of chips across rows. .pr-item-review-chips { display: inline-flex; align-items: center; @@ -343,8 +343,8 @@ margin-left: auto; } -// 所有 chip 统一带高(含图标类 verdict / mergeable 与文字类 approved / needs-work):图标 chip 原本 -// 只有 ~13px、比文字 chip(~17px)矮,导致「星标 only」行与「星标+计数」行高度不一。固定 18px + 居中对齐。 +// All chips have a unified band height (including icon-type verdict / mergeable and text-type approved / needs-work): icon chips were originally +// only ~13px, shorter than text chips (~17px), causing "star only" rows and "star + count" rows to differ in height. Fixed 18px + center-aligned. .review-chip { display: inline-flex; align-items: center; @@ -357,8 +357,8 @@ font-weight: 600; line-height: $lh-normal; - // 饱和背景 + 白字 — 跟 ChatPane .chat-run-status 风格对齐,hover sidebar - // 时一眼能扫到 reviewer 状态 (之前 git-decoration-fade 淡色背景视觉太弱) + // Saturated background + white text — aligned with ChatPane .chat-run-status style, so hovering the sidebar + // lets you scan the reviewer status at a glance (the previous git-decoration-fade light background was visually too weak) &.review-chip-approved { background: $color-approved; color: $text-on-accent; @@ -368,8 +368,8 @@ color: $text-on-accent; } - // 可合并 chip:纯分支合并图标无数值。绿底跟 approved 同色系,表"正向放行"; - // icon-only 收紧水平 padding 让胶囊不至于过宽 + // Mergeable chip: pure branch-merge icon with no number. Green base in the same color family as approved, meaning "positive go-ahead"; + // icon-only tightens horizontal padding so the pill isn't too wide &.review-chip-mergeable { background: $color-approved; color: $text-on-accent; @@ -380,8 +380,8 @@ } } - // 评审建议徽标(★,手动 / AutoPilot 一视同仁):approve 绿 / needs_work 琥珀 / manual_review 蓝。 - // flex 居中让 SVG 星形垂直对齐(此前 ★ 字形基线偏移、不居中)。 + // Review suggestion badge (★, manual / AutoPilot treated the same): approve green / needs_work amber / manual_review blue. + // flex centering vertically aligns the SVG star (the ★ glyph baseline was previously offset, not centered). &.verdict-chip { display: inline-flex; align-items: center; @@ -408,8 +408,8 @@ } -// 执行中指示:复用运行卡片同款 .spinner(中心对称的蓝色环旋转),裸图标无 chip 外框; -// 清掉 .spinner 默认的 inline-文字外边距,在徽标行里居中对齐。 +// Running indicator: reuses the run card's .spinner (a center-symmetric blue ring rotating), bare icon with no chip frame; +// clears .spinner's default inline-text margin, center-aligned in the badge row. .pr-item-spinner { margin: 0; vertical-align: middle; @@ -420,25 +420,25 @@ font-size: $fs-md; } -// 行级状态前缀:approved 绿勾、needs_work 红叹号;pending 不加。 -// 字符 + bold + 大号 + accent 描边底色让符号在标题文本里"跳出来", -// 不用 emoji 是因为不同 OS 字体渲染差异大、不可控 +// Row-level status prefix: approved green check, needs_work red exclamation; pending adds none. +// character + bold + larger size + accent outline background make the symbol "pop out" in the title text, +// emoji isn't used because font rendering varies greatly and is uncontrollable across OSes .pr-item-status-approved .pr-item-title::before, .pr-item-status-needs_work .pr-item-title::before { display: inline-block; margin-right: $space-2; - // 固定方形尺寸 + 内容居中 — 否则 ✓ 跟 ! 字符天然宽度不同 chip 看起来一宽一扁 + // Fixed square size + centered content — otherwise the ✓ and ! characters have naturally different widths, making the chip look wide vs. flat width: 18px; height: 18px; text-align: center; border-radius: $radius-sm; font-weight: 800; - // 字符比标题文字小一号,留出 chip 内边距视觉更精致;标题 $fs-lg ≈ 13px → 11px + // Character one size smaller than the title text, leaving chip padding for a more refined look; title $fs-lg ≈ 13px → 11px font-size: 0.85em; line-height: 18px; vertical-align: 0.05em; } -// 暗色 fade 背景 + 高饱和度字:底色淡而不突兀,符号本身仍醒目 +// Dark fade background + high-saturation text: base color light and unobtrusive, symbol itself still prominent .pr-item-status-approved .pr-item-title::before { content: '✓'; background: $git-added-bg-fade; @@ -455,8 +455,8 @@ font-size: $fs-md; } -// 未读圆点:自上次查看后该 PR 有「与我相关」的新事件(新到达 / 新 commit 推送 / @我 / 回复我)。 -// 标题前一个亮蓝实心小圆点(沿用未读消息惯例,取较亮的 info 蓝以在列表里醒目),并把标题字重提到 600 更跳。 +// Unread dot: since the last view this PR has new "relevant to me" events (new arrival / new commit push / @me / reply to me). +// A bright blue solid small dot before the title (following the unread-message convention, using the brighter info blue to stand out in the list), and bumps the title font weight to 600 to pop more. .pr-item-unread-dot { display: inline-block; width: 7px; @@ -470,9 +470,9 @@ .pr-item-unread .pr-item-title { font-weight: 600; } -// 未读点名计数:有「@我 / 回复我」未读时,圆点升级为标题前的中性数字胶囊(封顶 10 → 「10+」)。 -// 中性色($chip-neutral 灰底 + $text-body 文字),不抢评审状态 chip(绿/红/蓝)的语义;无图标,纯数字省空间。 -// 固定带高 + 居中、min-width 容双字符,单/双位数不致左右跳动。 +// Unread mention count: when there are "@me / reply to me" unreads, the dot upgrades to a neutral numeric pill before the title (capped at 10 → "10+"). +// Neutral color ($chip-neutral gray base + $text-body text), doesn't steal the semantics of the review status chips (green/red/blue); no icon, pure number to save space. +// Fixed band height + centered, min-width fits two characters, so single/double digits don't jump left/right. .pr-item-unread-count { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/src/styles/layout/statusbar.scss b/apps/desktop/src/renderer/src/styles/layout/statusbar.scss index b622a2a7..c9e34946 100644 --- a/apps/desktop/src/renderer/src/styles/layout/statusbar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/statusbar.scss @@ -1,6 +1,6 @@ -// StatusBar 基础壳:底部条容器 + chip 基类 + 通用语义色调(ok/err)+ 应用更新 chip + spacer。 -// 各业务域的 chip(pr-agent / AutoPilot → chat,PR 数 / 同步 / repo → pr,LLM / 用户 → settings) -// 已迁回各自域的 statusbar.scss 维护,本文件只留与具体业务无关的基础组件。 +// StatusBar base shell: bottom bar container + chip base class + common semantic tones (ok/err) + app update chip + spacer. +// Each business domain's chips (pr-agent / AutoPilot → chat, PR count / sync / repo → pr, LLM / user → settings) +// have been moved back to their own domain's statusbar.scss; this file keeps only base components unrelated to specific business. @use '../tokens' as *; @use '../mixins' as *; @@ -17,10 +17,10 @@ min-height: 28px; } -// chip 基类:所有状态栏 chip 的共同壳。复用 common 的 `chip` 本质(flex 居中 / 圆角 / -// 加粗 / 不换行),叠状态栏特有的固定高度 + 字号 + 图标基线。 -// 注意:`font: inherit` 须在 @include chip 之前 —— 简写会重置 font-weight,需让 chip 的 -// 600 落在其后生效(按钮型 chip 默认不继承字体,靠这条统一字体 + 等高)。 +// chip base class: the common shell for all status bar chips. Reuses common's `chip` essence (flex centering / radius / +// bold / no wrap), layering the status bar's specific fixed height + font size + icon baseline. +// Note: `font: inherit` must come before @include chip — the shorthand resets font-weight, so chip's +// 600 must land after it to take effect (button-type chips don't inherit fonts by default, this line unifies font + equal height). .statusbar-chip { background: $chip-neutral; color: $text-primary; @@ -33,16 +33,16 @@ box-sizing: border-box; padding: 0 $space-3; - // 图标统一行为:`display:block` 干掉 inline svg 自带的基线缝隙(否则图标相对 flex - // 居中的文字会偏下、视觉不齐线),`flex-shrink:0` 防窄状态被压缩。 + // Unified icon behavior: `display:block` kills the baseline gap inherent to inline svg (otherwise the icon sits below the flex- + // centered text, visually misaligned), `flex-shrink:0` prevents compression in narrow states. svg { display: block; flex-shrink: 0; } - // 语义色调 / 变体:与基类组合(StatusChip 渲染时恒带 .statusbar-chip),用 &- 嵌套 - // 表达「base + modifier」组合关系,产物类名不变(.statusbar-chip-ok 等)。 - // VS Code 远程指示器 remoteBackground:实色深绿 + 白字 + // Semantic tones / variants: combined with the base class (StatusChip always carries .statusbar-chip when rendered), using &- nesting + // to express the "base + modifier" combination, the resulting class name unchanged (.statusbar-chip-ok, etc.). + // VS Code remote indicator remoteBackground: solid dark green + white text &-ok { background: $color-success; color: $text-on-accent; @@ -53,7 +53,7 @@ color: $text-on-accent; cursor: help; } - // 新版本提示:强调蓝底,点击跳转下载页(渲染在状态栏壳内,非某业务域;border 由基类给) + // New version prompt: accent blue background, click jumps to the download page (rendered inside the status bar shell, not a business domain; border given by the base class) &-update { background: $color-accent; color: $text-on-accent; @@ -65,7 +65,7 @@ } } -// 左右分区填充:把右侧 chip 组顶到末尾(scoped 类名,避免裸 .spacer 全局撞名) +// Left-right partition filler: pushes the right chip group to the end (scoped class name, to avoid the bare .spacer clashing globally) .statusbar-spacer { flex: 1; } diff --git a/apps/desktop/src/renderer/src/styles/layout/titlebar.scss b/apps/desktop/src/renderer/src/styles/layout/titlebar.scss index 0114148e..bf00a51d 100644 --- a/apps/desktop/src/renderer/src/styles/layout/titlebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/titlebar.scss @@ -1,10 +1,10 @@ @use '../tokens' as *; -// 无边框窗口的自绘标题栏。高度必须与 main 进程 titleBarOverlay.height 一致(36px)。 +// Self-drawn title bar for the frameless window. Height must match the main process titleBarOverlay.height (36px). .app-titlebar { - position: relative; // 命令面板浮层(app-titlebar-center)以此为定位上下文居中 - // 整条抬到「菜单/弹层」层级:否则命令面板下拉会被 app-body 的 diff 内容遮盖——app-titlebar-center - // 的 transform 自建层叠上下文,把面板 z-index 困在标题栏内部,需在标题栏这一层抬到内容之上(低于模态 1000)。 + position: relative; // command palette overlay (app-titlebar-center) uses this as positioning context to center + // Whole bar raised to the "menu/popup" layer: otherwise the command palette dropdown would be covered by app-body's diff content — app-titlebar-center's + // transform creates its own stacking context, trapping the panel z-index inside the title bar, so it must be raised above content at the title bar layer (below modal 1000). z-index: $z-statusbar-menu; flex: 0 0 36px; height: 36px; @@ -15,11 +15,11 @@ background: $bg-app; border-bottom: 1px solid $border-muted; user-select: none; - // 整条作为拖拽区;内部交互元素需各自 no-drag + // Whole bar as a drag region; inner interactive elements each need no-drag -webkit-app-region: drag; - // macOS:为左上角红绿灯留位(trafficLightPosition x=12 起,3 颗 + 间距约到 x≈64)。再留一段 - // 舒适间隔,避免品牌名紧贴红绿灯显得局促。 + // macOS: leave room for the top-left traffic lights (trafficLightPosition starts at x=12, 3 dots + spacing reaching about x≈64). Leave a further + // comfortable gap so the brand name isn't cramped against the traffic lights. &--mac { padding-left: 92px; } @@ -30,7 +30,7 @@ height: 18px; border-radius: $radius-md; flex: 0 0 auto; - // 拖拽区内的图片默认可被拖出,禁掉以免误触发原生图片拖拽 + // Images in the drag region can be dragged out by default; disable it to avoid accidentally triggering native image drag -webkit-user-drag: none; } @@ -48,19 +48,19 @@ white-space: nowrap; overflow: hidden; min-width: 0; - // 留在左侧原位;上限不越过中线、避免漫到右侧撞 Windows 窗控(浮层 DOM 置后绘制在上、输入框不透明底遮盖)。 - // 渐隐由命令面板左缘的渐变罩负责(见 .app-titlebar-center::before),锚定输入框位置,故此处不自带 mask。 + // Stays in place on the left; upper limit doesn't cross the midline, to avoid spilling right and hitting the Windows window controls (overlay DOM placed later draws on top, input's opaque base covers it). + // Fade-out is handled by the gradient mask at the command palette's left edge (see .app-titlebar-center::before), anchored to the input position, so this doesn't carry its own mask. flex: 1 1 auto; max-width: 42%; } -// 标题栏内的可点元素需关闭拖拽,否则点击会被当成拖窗 +// Clickable elements in the title bar must disable drag, otherwise clicks would be treated as window dragging .app-titlebar :is(button, a, input, select) { -webkit-app-region: no-drag; } -// 命令面板(VS Code 风):标题栏居中的输入框 + 下拉结果。绝对浮层居中,不占流、不挤压标题, -// 绘制在标题之上以遮盖(输入框不透明底)。 +// Command palette (VS Code style): centered input in the title bar + dropdown results. Absolute overlay centered, out of flow, doesn't squeeze the title, +// drawn on top of the title to cover it (input's opaque base). .app-titlebar-center { position: absolute; left: 50%; @@ -70,8 +70,8 @@ display: flex; align-items: center; - // 输入框左缘的渐变罩:透明 → 标题栏底色,锚定在输入框左侧。标题漫到这里时隐入底色, - // 形成「逼近命令框即渐隐」的过渡(不论标题多长、窗口多宽都与输入框对齐)。不拦截点击。 + // Gradient mask at the input's left edge: transparent → title bar base color, anchored to the input's left side. When the title spills here it fades into the base color, + // forming a "fade out on approaching the command box" transition (aligned with the input regardless of title length or window width). Doesn't intercept clicks. &::before { content: ''; position: absolute; @@ -89,11 +89,11 @@ width: 38vw; min-width: 220px; max-width: 420px; - // 整块关闭拖拽:下拉面板是 div(非 button/input),不关会被当拖窗区 + // Whole block disables drag: the dropdown panel is a div (not button/input), without disabling it would be treated as a drag region -webkit-app-region: no-drag; } -// 输入框外框:承载边框 / 底色,内含可选的「当前子层」前缀标签 + 实际 input。 +// Input outer frame: carries the border / base color, contains an optional "current sublevel" prefix label + the actual input. .cmdk-field { display: flex; align-items: center; @@ -110,8 +110,8 @@ } } -// 二级层前缀提示符(如「URL ›」/「切换主题 ›」):提示符样式(非标签框)+ 尾部 chevron, -// 读作「进入此层输入」,呼应 shell / VS Code 子模式提示。chevron 由样式统一加,prefixLabel 数据保持简洁。 +// Sublevel prefix prompt (e.g. "URL ›" / "Switch theme ›"): prompt style (not a label box) + trailing chevron, +// reads as "enter this level to input", echoing shell / VS Code submode prompts. The chevron is added uniformly by style, keeping the prefixLabel data clean. .cmdk-field-prefix { flex: 0 0 auto; max-width: 50%; @@ -149,7 +149,7 @@ left: 0; right: 0; z-index: 1000; - // 最高高度:取 60vh 与 460px 较小值——高屏不喧宾夺主(约 10 行两行项 / 16 行单行项),超出滚动 + // Max height: takes the smaller of 60vh and 460px — on tall screens it doesn't overwhelm (about 10 two-line items / 16 single-line items), scrolls beyond that max-height: min(60vh, 460px); overflow-y: auto; padding: $space-2; @@ -177,7 +177,7 @@ } } -// 命令项主体(占满除右侧勾选外的宽度):可叠主行 + 英文次行 +// Command item body (fills the width except the right checkmark): can stack a main line + English sub line .cmdk-item-main { flex: 1 1 auto; min-width: 0; @@ -185,14 +185,14 @@ flex-direction: column; } -// 单行(领域前缀 + 命令名) +// Single line (domain prefix + command name) .cmdk-item-line { display: flex; align-items: baseline; min-width: 0; } -// 英文次行:弱化(更小、更淡),仅非英语界面展示 +// English sub line: de-emphasized (smaller, dimmer), shown only in non-English UI .cmdk-item-sub { font-size: $fs-sm; color: $text-muted; @@ -206,8 +206,8 @@ white-space: nowrap; } -// 领域前缀(如「设置」),置于命令名前,便于检索归类。与命令名间距收紧。 -// 主行前缀用亮中性色(选中行深蓝底上仍可读,accent 蓝字会撞底故不用);英文次行前缀跟随次行弱化色。 +// Domain prefix (e.g. "Settings"), placed before the command name for easier searching/categorization. Spacing to the command name tightened. +// Main-line prefix uses a bright neutral color (still readable on the selected row's dark blue base, accent blue text would clash with the base so it's not used); English sub-line prefix follows the sub-line's de-emphasized color. .cmdk-item-cat { flex: 0 0 auto; margin-right: $space-2; @@ -228,7 +228,7 @@ color: $color-accent; } -// 快捷键提示(命令项右侧):一键一框(VS Code 风),每个 token 一个淡键帽 +// Shortcut hint (right of the command item): one key one box (VS Code style), a light keycap per token .cmdk-item-kbd { flex: 0 0 auto; margin-left: $space-4; @@ -256,7 +256,7 @@ font-size: $fs-lg; } -// 搜索命中高亮(覆盖 <mark> 默认黄底黑字):用统一的搜索命中色,文字色继承不变 +// Search hit highlight (overrides <mark>'s default yellow base black text): uses the unified search hit color, text color inherited unchanged .cmdk-hl { background: $color-search-hit; color: inherit; diff --git a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts index e47007f0..8cc35272 100644 --- a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts +++ b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts @@ -1,19 +1,23 @@ -// GUI chrome 跟随全局主题:从当前 Monaco 主题派生「结构性中性 token」(背景 / 前景 / 边框 / 选区)覆盖 -// 到 documentElement,使整个 chrome 与编辑器同主题。 +// GUI chrome follows the global theme: derive "structural neutral tokens" (background / foreground / border / +// selection) from the current Monaco theme and override them on documentElement, so the whole chrome shares the +// editor's theme. // -// 混合方案(非「完全消除浅深色板」):只派生结构性中性色;语义色(accent / approved / warning / danger / -// chip / 文件状态点等)仍由 _theme.scss 的语义层按 data-theme 浅 / 深自管 —— 编辑器主题里没有这些产品 -// 语义色、且需对比度保证。 +// Hybrid approach (not "fully eliminate the light/dark palette"): only derive structural neutral colors; semantic +// colors (accent / approved / warning / danger / chip / file status dots, etc.) are still self-managed by +// _theme.scss's semantic layer per data-theme light / dark — the editor theme has no such product semantic colors +// and they need contrast guarantees. // -// 数据现实:第三方 monaco-themes 仅带 editor.background / foreground / selectionBackground 等极少数键 -// (见 monaco-setup getEditorThemeColors)。故 muted 文字 / 各级背景 / 边框全部从 fg↔bg 混合派生, -// 并对次级文字加对比度地板(fadeWithFloor),防低对比主题(如 Solarized)击穿可读性。 +// Data reality: third-party monaco-themes carry only a handful of keys such as editor.background / foreground / +// selectionBackground (see monaco-setup getEditorThemeColors). So muted text / each background level / borders are +// all derived from fg↔bg mixing, with a contrast floor added to secondary text (fadeWithFloor) to keep low-contrast +// themes (like Solarized) from breaking readability. // -// 取不到色 / 缺 bg·fg 时清空覆盖,回退到纯语义色板(仍随 data-theme 浅 / 深正常显示)。 +// When no color is available / bg·fg is missing, clear the overrides and fall back to the pure semantic palette +// (still displays normally per data-theme light / dark). import { getEditorThemeColors } from '../lib/monaco-setup'; -/** 本模块覆盖的全部 CSS 自定义属性(清理时逐个 remove,回退到 _theme.scss 的语义色板)。 */ +/** All CSS custom properties this module overrides (removed one by one on cleanup, falling back to _theme.scss's semantic palette). */ const OVERRIDDEN_VARS = [ '--bg-app', '--bg-panel', @@ -40,7 +44,7 @@ interface Rgb { a: number; } -/** 解析 #rgb / #rgba / #rrggbb / #rrggbbaa;失败返回 null。 */ +/** Parse #rgb / #rgba / #rrggbb / #rrggbbaa; return null on failure. */ function parseHex(hex: string): Rgb | null { const h = hex.trim().replace(/^#/, ''); const expand = (s: string): string => @@ -60,7 +64,7 @@ function parseHex(hex: string): Rgb | null { return { r, g, b, a }; } -/** 在 c1 → c2 间按 t(0..1)线性插值(忽略 alpha,结果不透明)。 */ +/** Linearly interpolate between c1 → c2 by t (0..1) (alpha ignored, result opaque). */ function mix(c1: Rgb, c2: Rgb, t: number): Rgb { return { r: Math.round(c1.r + (c2.r - c1.r) * t), @@ -74,13 +78,13 @@ function toRgbString({ r, g, b, a }: Rgb): string { return a >= 1 ? `rgb(${r}, ${g}, ${b})` : `rgb(${r} ${g} ${b} / ${a.toFixed(3)})`; } -/** 转 #rrggbb(忽略 alpha);供 Windows titleBarOverlay 用(其 color 取 hex)。 */ +/** Convert to #rrggbb (alpha ignored); for Windows titleBarOverlay (its color takes hex). */ function toHex({ r, g, b }: Rgb): string { const h = (v: number): string => v.toString(16).padStart(2, '0'); return `#${h(r)}${h(g)}${h(b)}`; } -/** 相对亮度(WCAG)。 */ +/** Relative luminance (WCAG). */ function luminance({ r, g, b }: Rgb): number { const ch = (v: number): number => { const s = v / 255; @@ -89,7 +93,7 @@ function luminance({ r, g, b }: Rgb): number { return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b); } -/** 对比度比值(WCAG,1..21)。 */ +/** Contrast ratio (WCAG, 1..21). */ function contrastRatio(c1: Rgb, c2: Rgb): number { const l1 = luminance(c1); const l2 = luminance(c2); @@ -98,9 +102,10 @@ function contrastRatio(c1: Rgb, c2: Rgb): number { } /** - * 带对比度地板的「文字衰减」:本想把 fg 向 bg 混 desiredT(越大越淡),但从 desiredT 往 0 回收, - * 直到结果对 bg 的对比度 ≥ floor 才停 —— 保证次级文字在低对比主题(如 Solarized)下不被衰减到不可读。 - * 代价是低对比主题里 muted 会塌回 ≈fg(与主文字同权重),是可读性优先的诚实取舍。 + * "Text fade" with a contrast floor: intends to mix fg toward bg by desiredT (larger = fainter), but walks + * desiredT back toward 0 until the result's contrast against bg is ≥ floor — ensuring secondary text isn't faded + * to unreadable under low-contrast themes (like Solarized). The cost is that in low-contrast themes muted collapses + * back to ≈fg (same weight as primary text), an honest readability-first tradeoff. */ function fadeWithFloor(fg: Rgb, bg: Rgb, desiredT: number, floor: number): Rgb { const steps = 24; @@ -121,14 +126,15 @@ function clearChromeOverrides(): void { } /** - * 把当前全局主题的 base 色派生为 GUI chrome 的结构性 token,写到 documentElement(覆盖 _theme.scss)。 - * 取不到色 / 缺 bg·fg 时清空覆盖、回退语义色板(仍随 data-theme 浅 / 深正常显示)。 + * Derive the current global theme's base colors into structural tokens for the GUI chrome and write them to + * documentElement (overriding _theme.scss). When no color is available / bg·fg is missing, clear the overrides and + * fall back to the semantic palette (still displays normally per data-theme light / dark). */ export function applyChromeFromEditorTheme( editorThemeId: string, resolvedGuiTheme: 'light' | 'dark', ): { color: string; symbolColor: string } | null { - // 'auto' 跟随解析主题 → 取默认 2026 主题(dark-2026 / light-2026)的 base 色 + // 'auto' follows the resolved theme → take the default 2026 theme's (dark-2026 / light-2026) base color const effectiveId = editorThemeId === 'auto' ? (resolvedGuiTheme === 'dark' ? 'dark-2026' : 'light-2026') : editorThemeId; const data = getEditorThemeColors(effectiveId); @@ -140,24 +146,24 @@ export function applyChromeFromEditorTheme( return null; } const isDark = luminance(bg) < 0.5; - const edge = isDark ? WHITE : BLACK; // 提升层(背景越「浮」越靠该边)/ 边框混合方向 + const edge = isDark ? WHITE : BLACK; // elevation layer (the more a background "floats" the closer to this edge) / border mix direction const sel = parseHex(data?.colors['editor.selectionBackground'] ?? '') ?? mix(bg, edge, 0.16); - // 各级背景:editor.background 为基准,按 elevation 轻微向 edge 提 + // Each background level: editor.background as baseline, lifted slightly toward edge by elevation const bgPanel = mix(bg, edge, 0.03); const bgPanelAlt = mix(bg, edge, 0.06); const bgElev = mix(bg, edge, 0.05); const bgSurface = mix(bg, edge, 0.08); const bgHover = mix(bg, edge, 0.1); - // 各级文字:editor.foreground 向 bg 衰减出 muted / subtle / dim,各带对比度地板防低对比主题击穿 + // Each text level: fade editor.foreground toward bg into muted / subtle / dim, each with a contrast floor to keep low-contrast themes from breaking through const textMuted = fadeWithFloor(fg, bg, 0.45, 4.5); const textSubtle = fadeWithFloor(fg, bg, 0.52, 4.0); const textDim = fadeWithFloor(fg, bg, 0.6, 3.0); - // 边框:fg 大幅向 bg 衰减,留极淡轮廓 + // Border: fade fg heavily toward bg, leaving a very faint outline const borderDefault = mix(fg, bg, 0.8); const borderMuted = mix(fg, bg, 0.88); const borderFade = { ...borderDefault, a: 0.5 }; - // 分组头:向黑轻微下沉(比 bg-app 略暗的「凹陷」标题带,hover 走 --bg-panel 上浮),随主题派生。 + // Group header: sink slightly toward black (a "recessed" title band slightly darker than bg-app, hover lifts up via --bg-panel), derived per theme. const bgGroupHeader = mix(bg, BLACK, 0.12); const set = (name: string, c: Rgb): void => document.documentElement.style.setProperty(name, toRgbString(c)); @@ -178,12 +184,12 @@ export function applyChromeFromEditorTheme( set('--bg-selected', sel); set('--bg-group-header', bgGroupHeader); - // 可读性体检:muted 文字 / 弱边框对背景的对比度(AA 正文≥4.5、次要文本/非文本≥3) + // Readability check: contrast of muted text / weak border against background (AA body text ≥4.5, secondary/non-text ≥3) const mutedCr = contrastRatio(textMuted, bg); const borderCr = contrastRatio(borderDefault, bg); console.info( `[chrome-sync] "${effectiveId}" (${isDark ? 'dark' : 'light'}) → muted/bg contrast ${mutedCr.toFixed(2)} (AA≥4.5), border/bg ${borderCr.toFixed(2)} (≥3)`, ); - // 窗控按钮同色:把主题 base 背景 / 前景(hex)交回主进程更新 Windows titleBarOverlay(见 useGlobalTheme)。 + // Match window control buttons: hand the theme base background / foreground (hex) back to the main process to update Windows titleBarOverlay (see useGlobalTheme). return { color: toHex(bg), symbolColor: toHex(fg) }; } diff --git a/apps/desktop/src/renderer/src/theme/index.ts b/apps/desktop/src/renderer/src/theme/index.ts index bacdf6b3..aec22396 100644 --- a/apps/desktop/src/renderer/src/theme/index.ts +++ b/apps/desktop/src/renderer/src/theme/index.ts @@ -1,23 +1,23 @@ import { resolveEditorThemeMode, editorThemeMode, type ResolvedTheme } from '@meebox/shared'; /** - * 渲染层主题运行时。 + * Renderer theme runtime. * - * 全局主题(Monaco 编辑器 + 整个 GUI chrome 共用同一主题,见 @meebox/shared EDITOR_THEME_OPTIONS)反推 - * 浅 / 深,写到 `documentElement` 的 `data-theme`;语义配色经 CSS 自定义属性整体切换(默认 :root = 暗色, - * `[data-theme='light']` 覆盖为浅色,见 styles/_theme.scss)。结构性 chrome 色另由主题派生覆盖(见 - * editor-chrome-sync)。本模块只管 data-theme + 字体,不引 Monaco(保持首帧轻量)。 + * The global theme (the Monaco editor + the entire GUI chrome share one theme, see @meebox/shared EDITOR_THEME_OPTIONS) is resolved to + * light / dark and written to `documentElement`'s `data-theme`; semantic colors switch wholesale via CSS custom properties (default :root = dark, + * `[data-theme='light']` overrides to light, see styles/_theme.scss). Structural chrome colors are separately overridden as derived from the theme (see + * editor-chrome-sync). This module only handles data-theme + fonts, and doesn't import Monaco (to keep the first frame lightweight). * - * - 主题经 IPC 异步到达(config.appearance.editor_theme),启动时拿不到;localStorage 可同步读,故用它做 - * 首帧初始主题,避免启动闪错主题。App 拿到 config 后会 persist 回写,下次启动直接命中。 - * - 主题 mode 为 'auto' 时跟随 `prefers-color-scheme`,并由 watchSystemThemeForAuto 在 OS 切换时实时重解析。 - * - 默认主题取 **auto**(自动适应系统):localStorage 无记录 / 不可用时回落 'auto'。 + * - The theme arrives asynchronously via IPC (config.appearance.editor_theme), unavailable at startup; localStorage can be read synchronously, so it's used as + * the first-frame initial theme to avoid a wrong-theme flash on startup. After App gets config it persists it back, for a direct hit on next startup. + * - When the theme mode is 'auto' it follows `prefers-color-scheme`, and watchSystemThemeForAuto re-resolves in real time on OS switches. + * - The default theme is **auto** (auto-adapt to the system): falls back to 'auto' when localStorage has no record / is unavailable. */ const EDITOR_THEME_STORAGE_KEY = 'meebox.editorTheme'; const DEFAULT_EDITOR_THEME = 'auto'; -/** OS 是否偏好深色('auto' 主题据此解析)。matchMedia 不可用时保守按深色。 */ +/** Whether the OS prefers dark ('auto' theme resolves based on this). Conservatively assumes dark when matchMedia is unavailable. */ function systemPrefersDark(): boolean { try { return window.matchMedia('(prefers-color-scheme: dark)').matches; @@ -26,12 +26,12 @@ function systemPrefersDark(): boolean { } } -/** 把全局主题 id 解析为实际视觉主题('auto' 按 OS 深 / 浅落地)。 */ +/** Resolve a global theme id to the actual visual theme ('auto' lands on OS dark / light). */ export function resolveGlobalTheme(editorTheme: string): ResolvedTheme { return resolveEditorThemeMode(editorTheme, systemPrefersDark()); } -/** 读 localStorage 缓存的主题作首帧初始值;无记录 / 不可用时回落默认(auto)。 */ +/** Read the localStorage-cached theme as the first-frame initial value; falls back to the default (auto) when there's no record / it's unavailable. */ export function readInitialEditorTheme(): string { try { return localStorage.getItem(EDITOR_THEME_STORAGE_KEY) ?? DEFAULT_EDITOR_THEME; @@ -40,23 +40,23 @@ export function readInitialEditorTheme(): string { } } -/** 持久化主题到 localStorage,供下次启动同步读取作初始主题。 */ +/** Persist the theme to localStorage, for synchronous reading as the initial theme on next startup. */ export function persistEditorTheme(editorTheme: string): void { try { localStorage.setItem(EDITOR_THEME_STORAGE_KEY, editorTheme); } catch { - // localStorage 不可用时忽略:仅影响下次启动的初始主题命中,不影响功能。 + // Ignore when localStorage is unavailable: only affects the initial theme hit on next startup, not functionality. } } -/** 把全局主题反推浅 / 深后写到 documentElement.data-theme,触发语义色板整体切换。 */ +/** Resolve the global theme to light / dark, then write it to documentElement.data-theme, triggering a wholesale semantic palette switch. */ export function applyGlobalTheme(editorTheme: string): void { document.documentElement.dataset.theme = resolveGlobalTheme(editorTheme); } /** - * 监听 OS 深 / 浅色变化。仅 'auto' 主题需要:OS 切换时实时重解析并重写 data-theme。 - * 返回取消订阅函数;非 'auto' 主题直接返回空 cleanup(无监听)。 + * Watch OS dark / light changes. Needed only for the 'auto' theme: re-resolve in real time on OS switch and rewrite data-theme. + * Returns an unsubscribe function; non-'auto' themes just return an empty cleanup (no watching). */ export function watchSystemThemeForAuto(editorTheme: string, onChange: () => void): () => void { if (editorThemeMode(editorTheme) !== 'auto') return () => {}; @@ -74,19 +74,19 @@ export function watchSystemThemeForAuto(editorTheme: string, onChange: () => voi return () => mq.removeEventListener('change', handler); } -// 内置等宽字体兜底栈:用户自定义字体后置于其后,保证缺字时仍回落到合理 mono 字体。 +// Built-in monospace fallback stack: placed after the user's custom font, ensuring a fall-back to a reasonable mono font when glyphs are missing. const MONO_FALLBACK = "'Cascadia Code', 'Consolas', ui-monospace, monospace"; -/** 把用户配置的字体族解析为完整 font-family 串(追加兜底栈);空配置返回 undefined(用默认)。 */ +/** Resolve the user-configured font family to a full font-family string (appending the fallback stack); empty config returns undefined (uses the default). */ export function resolveEditorFontFamily(font: string): string | undefined { const f = font.trim(); return f ? `${f}, ${MONO_FALLBACK}` : undefined; } /** - * 应用编辑器等宽字体到全应用:写 documentElement 的 `--editor-font-family` 自定义属性($font-mono 经 - * 它取值,覆盖 diff / 评论 / 代码块等所有等宽文本)。空配置时移除该属性,回落内置 mono 字体栈。 - * Monaco 编辑器内容字体另经其 fontFamily option 设置(见 DiffPane / InlineCodeContext)。 + * Apply the editor monospace font app-wide: write documentElement's `--editor-font-family` custom property ($font-mono takes + * its value from it, covering all monospace text such as diff / comments / code blocks). When config is empty, remove the property and fall back to the built-in mono font stack. + * The Monaco editor content font is set separately via its fontFamily option (see DiffPane / InlineCodeContext). */ export function applyEditorFontFamily(font: string): void { const resolved = resolveEditorFontFamily(font); @@ -94,5 +94,5 @@ export function applyEditorFontFamily(font: string): void { else document.documentElement.style.removeProperty('--editor-font-family'); } -// 副作用:模块导入即按 localStorage 缓存定下首帧主题(在 React 渲染前),避免启动闪错主题。 +// Side effect: on module import, set the first-frame theme from the localStorage cache (before React renders), avoiding a wrong-theme flash on startup. applyGlobalTheme(readInitialEditorTheme()); diff --git a/apps/desktop/src/renderer/src/utils/ansi.ts b/apps/desktop/src/renderer/src/utils/ansi.ts index 3b53446e..6c36252a 100644 --- a/apps/desktop/src/renderer/src/utils/ansi.ts +++ b/apps/desktop/src/renderer/src/utils/ansi.ts @@ -1,13 +1,13 @@ /** - * 把含 ANSI SGR 转义的字符串切成可渲染的 segment 列表。 + * Slice a string containing ANSI SGR escapes into a renderable segment list. * - * 底层用 [anser](https://github.com/IonicaBizau/anser)(事实标准 ANSI → JSON 解析器, - * 7KB 体积,Sentry / Storybook / Jest 都在用)。它正确处理 SGR / OSC / CSI 全谱、 - * 16/256/truecolor、bold/italic/underline/dim 装饰,不踩自卷边角案的坑(之前自实现 - * 时撞过 `\x1b.` 贪心吃掉 CSI 起头的 bug)。 + * Backed by [anser](https://github.com/IonicaBizau/anser) (de-facto standard ANSI → JSON parser, + * 7KB, used by Sentry / Storybook / Jest). It correctly handles the full SGR / OSC / CSI spectrum, + * 16/256/truecolor, bold/italic/underline/dim decorations, and avoids the pitfalls of a hand-rolled + * edge case (a prior self-implementation hit a `\x1b.` greedy bug that ate the CSI head). * - * Anser 返回的 fg / bg 是 `"rgb(r, g, b)"` 字符串 (默认配色为标准 ANSI 16 色 + 256 - * 色 cube 算法),直接塞进 React style 即可。 + * Anser returns fg / bg as `"rgb(r, g, b)"` strings (default palette is standard ANSI 16 colors + 256 + * color cube algorithm), which can be dropped straight into a React style. */ import Anser from 'anser'; @@ -40,7 +40,7 @@ export function parseAnsi(input: string): AnsiSegment[] { }); } -/** AnsiSegment → React inline style;给 <span style={...}> 用 */ +/** AnsiSegment → React inline style; for use with <span style={...}> */ export function segmentStyle(seg: AnsiSegment): React.CSSProperties { const style: React.CSSProperties = {}; if (seg.fg) style.color = seg.fg; diff --git a/apps/desktop/src/renderer/src/utils/language.ts b/apps/desktop/src/renderer/src/utils/language.ts index de23fcc7..7f60179d 100644 --- a/apps/desktop/src/renderer/src/utils/language.ts +++ b/apps/desktop/src/renderer/src/utils/language.ts @@ -1,8 +1,8 @@ /** - * 按文件扩展名映射到 Monaco language id。Monaco 自带的 language 列表参见 + * Map a file extension to a Monaco language id. For Monaco's built-in language list see * https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages * - * 未识别 → 返回 'plaintext'。无扩展名文件按 basename 识别(Dockerfile / Makefile)。 + * Unrecognized → returns 'plaintext'. Extensionless files are identified by basename (Dockerfile / Makefile). */ export function languageFor(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; diff --git a/apps/desktop/src/renderer/src/utils/time.ts b/apps/desktop/src/renderer/src/utils/time.ts index a90e7eec..99afe43c 100644 --- a/apps/desktop/src/renderer/src/utils/time.ts +++ b/apps/desktop/src/renderer/src/utils/time.ts @@ -1,10 +1,10 @@ import type { TFunction } from 'i18next'; /** - * 把毫秒时长格式化为 "Ns" / "Mm SSs": + * Format a millisecond duration as "Ns" / "Mm SSs": * < 60s → "42s" - * >= 60s → "1m 30s"(秒两位补零定宽);`compact` 时去掉空格 → "1m30s"(状态栏紧凑场景) - * 用 `m` / `s` 单位字面而非冒号,避免跟时间戳 (HH:MM) 视觉混淆。 + * >= 60s → "1m 30s" (seconds zero-padded to a fixed two-digit width); with `compact` the space is dropped → "1m30s" (status bar compact scenario) + * Uses the `m` / `s` unit literals rather than a colon, to avoid visual confusion with a timestamp (HH:MM). */ export function formatElapsed(ms: number, opts?: { compact?: boolean }): string { const totalSec = Math.max(0, Math.floor(ms / 1000)); @@ -15,8 +15,8 @@ export function formatElapsed(ms: number, opts?: { compact?: boolean }): string } /** - * 把时间点格式化为相对时间:"刚刚 / N 秒前 / N 分钟前 / N 小时前";超过 1 天给绝对时间, - * 避免 "3 天前" 这种模糊。 + * Format a point in time as relative time: "just now / N seconds ago / N minutes ago / N hours ago"; beyond 1 day + * give an absolute time, to avoid vagueness like "3 days ago". */ export function formatRelative(date: Date, t: TFunction): string { const diffSec = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); diff --git a/apps/desktop/src/renderer/src/utils/translate-pr-agent.ts b/apps/desktop/src/renderer/src/utils/translate-pr-agent.ts index d51b2356..ea61df61 100644 --- a/apps/desktop/src/renderer/src/utils/translate-pr-agent.ts +++ b/apps/desktop/src/renderer/src/utils/translate-pr-agent.ts @@ -1,23 +1,24 @@ /** - * pr-agent 输出模板的翻译(独立于 react-i18next 的 UI 文案)。 + * Translation of pr-agent output templates (independent of react-i18next's UI text). * - * 背景:`CONFIG__RESPONSE_LANGUAGE=zh-CN` 只影响 LLM 生成的**内容值**,但 pr-agent - * 在其 Python 源码里**硬编码**了一批结构化模板字符串(section 标题 / fixed labels / - * checkbox 文字),这些 LLM 不动它们,所以中文环境下仍以英文出现。我们在渲染层做一次 - * 替换,把已知模板词翻成目标语言。 + * Background: `CONFIG__RESPONSE_LANGUAGE=zh-CN` only affects the **content values** the LLM generates, but pr-agent + * **hardcodes** a batch of structured template strings in its Python source (section headings / fixed labels / + * checkbox text) that the LLM leaves untouched, so under a Chinese environment they still appear in English. We do one + * pass of replacement in the renderer, translating known template words into the target language. * - * 这不是「按 key 取串」而是「按英文原文匹配、整段 blob 子串替换」,与 react-i18next 的 - * 访问模型不同,故**不进 locale 资源**,各语言的 <英文模板 → 译文> 表独立维护在同目录 - * 的 `pr-agent-labels/<lang>.json`,由本文件的替换引擎加载。 + * This is not "look up a string by key" but "match by English source, substring-replace across the whole blob", which + * differs from react-i18next's access model, so it **does not go into locale resources**; each language's + * <English template → translation> table is maintained separately in `pr-agent-labels/<lang>.json` in the same + * directory, loaded by this file's replacement engine. * - * 字典随 pr-agent 版本维护(按输出模板分组、便于跟上游升级 spot-check;JSON 顺序不影响 - * 正确性——引擎按 key 长度倒序处理,避免"短键先吃掉长键的子串")。pr-agent v0.36 把 - * issue_header "Possible bug" rewrite 成大写 I 的 "Possible Issue" 绕过 LLM 翻译,故字典 - * 里另列了大写版本。 + * The dictionary is maintained per pr-agent version (grouped by output template, to ease spot-checking against upstream + * upgrades; JSON order does not affect correctness — the engine processes keys in descending length order, avoiding + * "a short key eating the substring of a long key"). pr-agent v0.36 rewrote the issue_header "Possible bug" into + * "Possible Issue" with an uppercase I to bypass LLM translation, so the dictionary also lists the uppercase version. * - * 语言感知:仅当 UI 语言 (config.language) 有对应字典(当前 zh-CN)时替换;en-US 等无字典 - * 语言下 pr-agent 输出本就是英文,原样返回 (passthrough)。新增目标语言 = 加一份 - * `pr-agent-labels/<lang>.json` 并在 TRANSLATION_MAPS 注册。 + * Language-aware: replaces only when the UI language (config.language) has a matching dictionary (currently zh-CN); + * under dictionary-less languages like en-US the pr-agent output is already English, returned as-is (passthrough). + * Adding a target language = add a `pr-agent-labels/<lang>.json` and register it in TRANSLATION_MAPS. */ import i18n, { matchSupportedLanguage, type SupportedLanguage } from '../i18n'; @@ -25,14 +26,14 @@ import zhCN from './pr-agent-labels/zh-CN.json'; import jaJP from './pr-agent-labels/ja-JP.json'; import deDE from './pr-agent-labels/de-DE.json'; -// 各语言的 <英文模板 → 译文> 表注册表。未注册的语言(如 en-US)→ passthrough。 +// Registry of each language's <English template → translation> table. Unregistered languages (e.g. en-US) → passthrough. const TRANSLATION_MAPS: Partial<Record<SupportedLanguage, Record<string, string>>> = { 'zh-CN': zhCN, 'ja-JP': jaJP, 'de-DE': deDE, }; -// 按语言缓存「预排序条目」(长 key 在前,避免短 key 先吃掉长 key 的子串) +// Cache "pre-sorted entries" per language (long keys first, to avoid a short key eating a long key's substring) const SORTED_BY_LANG = new Map<SupportedLanguage, Array<[string, string]>>(); function sortedEntriesFor(lang: SupportedLanguage): Array<[string, string]> | null { const map = TRANSLATION_MAPS[lang]; @@ -46,10 +47,10 @@ function sortedEntriesFor(lang: SupportedLanguage): Array<[string, string]> | nu } /** - * 把含 pr-agent 模板英文标签的字符串按当前 UI 语言翻译。 - * - 替换是字面量 (split/join),不走正则,避免特殊字符意外匹配。 - * - 大小写敏感:模板里都是首字母大写,保持原样。 - * - 当前语言无对应字典 (如 en-US) 时原样返回 (pr-agent 输出本就是英文)。 + * Translate a string containing pr-agent template English labels according to the current UI language. + * - Replacement is literal (split/join), not regex, to avoid accidental matches on special characters. + * - Case-sensitive: templates are all title-cased, kept as-is. + * - When the current language has no matching dictionary (e.g. en-US) returns as-is (pr-agent output is already English). */ export function translatePrAgentLabels(text: string): string { if (!text) return text; diff --git a/apps/desktop/typings/env.d.ts b/apps/desktop/typings/env.d.ts index 08adf7f8..2f4420a2 100644 --- a/apps/desktop/typings/env.d.ts +++ b/apps/desktop/typings/env.d.ts @@ -1,9 +1,9 @@ /** - * Renderer 端的 ambient 全局类型声明。放在 typings/ 而非 src/,避免跟业务源码混在 - * 同一目录。tsconfig.json 的 include 已经加上 `typings/**`。 + * Ambient global type declarations for the renderer. Placed in typings/ rather than src/ to avoid + * mixing with business source in the same directory. tsconfig.json's include already adds `typings/**`. * - * vite/client 的全局类型通过 tsconfig.json 的 compilerOptions.types 引入; - * 不再 /// reference,跟 logger 包共享 typings 的做法保持一致。 + * vite/client's global types are brought in via tsconfig.json's compilerOptions.types; + * no more /// reference, consistent with how the logger package shares typings. */ import type { IpcBridge } from '@meebox/ipc'; diff --git a/assets/images/screenshot.dark.png b/assets/images/screenshot.dark.png new file mode 100644 index 00000000..e8da7430 --- /dev/null +++ b/assets/images/screenshot.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03c345ad171073115e77ebc716720d76fd0e9269413dd49baa0271edfa169ac6 +size 556671 diff --git a/assets/images/screenshot.light.png b/assets/images/screenshot.light.png new file mode 100644 index 00000000..2b9a7394 --- /dev/null +++ b/assets/images/screenshot.light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0369489270f1a2a02bf849efdad22c920416ea82550397775dd8388aaec86a8b +size 544745 diff --git a/assets/images/screenshot.zh-CN.png b/assets/images/screenshot.zh-CN.png deleted file mode 100644 index 9d4a2e2f..00000000 --- a/assets/images/screenshot.zh-CN.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ce727b140a09527480f406e538d6018578e8f531b690a819c54f4f09aef7a570 -size 945202 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 31e28935..c1de632e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,95 +1,85 @@ # Code Meeseeks Roadmap -> 最后更新:2026-06-30 +> Last updated: 2026-07-05 -本文件只保留**高层视角**:已交付能力全景、持续演进、风险与下一步。面向用户的**特性详述**见 -**[README](../README.md)**;各模块的**设计与实现细节**见 **[模块设计文档 docs/arch/](arch/README.md)**。 +> For user-facing **feature details**, see **[README](../README.md)**. +> +> For each module's **design and implementation details**, see the **[module design docs docs/arch/](arch/README.md)**. -## 1. 项目定位 +## 1. Delivered capabilities -面向 Reviewer **个人**的本地化、半自动化代码评审 GUI 客户端,基于社区版 -[pr-agent](https://docs.pr-agent.ai/) 构建,核心立场是**决策权在人、规则在本地、数据在本地**。 +#### 🌍 Multi-platform integration -> 完整定位、适用 / 不适用场景见 **[README](../README.md)**,此处不再重复。 +- [x] Unified integration with GitHub / Bitbucket / GitLab (including GitHub Enterprise / GitLab Self-Managed, with adaptive degradation per platform capability) +- [x] Local-first: repo copy / PR metadata / drafts stored locally; embedded pr-agent runtime, no Python / Docker install needed +- [x] Outbound HTTP proxy (local addresses auto-direct) ---- - -## 2. 已交付能力 - -> 按 README「核心特性」的领域划分组织,此处为**交付全景**(高层);面向用户的特性详述见 README。 - -#### 🌍 多平台接入 +#### 📥 PR discovery and browsing -- [x] 统一接入 GitHub / Bitbucket / GitLab(含 GitHub Enterprise / GitLab Self-Managed,按平台能力自适应降级) -- [x] 本地优先:仓库副本 / PR 元数据 / 草稿存本机;内嵌 pr-agent 运行时,免装 Python / Docker -- [x] 出站 HTTP 代理(本地地址自动直连) +- [x] Polling auto-discovery + categories (To review / Created by me / Assigned / Mentioned) + repo grouping + status filter + search +- [x] Unread and mention markers (newly assigned / new commits / @-ed / replied to; mention count tallied separately) +- [x] Archived-history browsing + open any PR by URL (including supplementary comments, re-running reviews) -#### 📥 PR 发现与浏览 +#### 🔍 Local diff reading -- [x] 轮询自动发现 + 分类(待我评审 / 我创建 / 指派 / 提及)+ 仓库分组 + 状态过滤 + 搜索 -- [x] 未读与点名标记(新分配 / 新提交 / 被 @ / 被回复;被点名条数单独计数) -- [x] 历史归档浏览 + 按 URL 打开任意 PR(含补充评论、补跑评审) +- [x] Side-by-side / inline diff, file tree (merge-conflict annotation), by change scope / single commit, overview ruler, blame, cross-file search +- [x] Inline comments (on added and deleted lines alike) + selected code as context reference -#### 🔍 本地 Diff 阅读 +#### 🤖 AI / Agentic review -- [x] 并排 / 内联 diff、文件树(合并冲突标注)、按变更范围 / 单 commit、总览标尺、blame、跨文件搜索 -- [x] 行内评论(新增行与删除行均可)+ 选中代码作上下文引用 +- [x] Command-driven pr-agent (`/describe`·`/review`·`/improve`·`/ask`), with results structured into actionable review findings +- [x] Re-review loop: launch an `/ask` re-review of a finding, auto-handling the original comment per the verdict (supersede / keep / withdraw) +- [x] Agentic autonomous planning + multi-tool orchestration + long-term Memory + observable process, with mid-run input and stop-anytime +- [x] AutoPilot pre-review: auto pre-runs on new to-review·pending PRs, with admission control + per-item authorization + red-line checks (read-only tools by default) +- [x] CLI-mode `/ask` repo-file access: a one-off worktree takes full context + cleans the repo's own agent-instruction files before landing in cwd to prevent injection (see [agent design](arch/02-agent/01-agent.md)) -#### 🤖 AI / Agentic 评审 +#### ✍️ Review loop and collaboration -- [x] 指令驱动 pr-agent(`/describe`·`/review`·`/improve`·`/ask`),结果结构化成可操作的评审发现 -- [x] 复评闭环:对评审发现发起 `/ask` 复评,按裁决(取代 / 保留 / 撤销)自动处理原评论 -- [x] Agentic 自主规划 + 多工具编排 + 长期 Memory + 过程可观测,可中途追加输入、随时停止 -- [x] AutoPilot 预评审:对待我评审·待处理的新 PR 自动预跑,准入控制 + 逐项授权 + 红线校验(默认仅只读工具) -- [x] CLI 模式 `/ask` 仓库文件访问:一次性 worktree 取完整上下文 + 落 cwd 前清洗仓库自带 agent 指令文件防注入(见 [06](arch/06-agent.md)) +- [x] Findings → draft pool → inline editing → single / batch publish; remote mergeable state visualized + one-click merge (also `/merge`) +- [x] Comment interaction: reply / edit / delete + emoji reactions + @-mention completion + image attachments + `:shortcode:` emoji rendering (per platform capability) +- [x] Activity timeline: comments / commit updates / review decisions merged into one (GitHub / Bitbucket) +- [x] Notifications: categorized system notifications for new PR / comment reply / @-mention (pending PRs only) + click-through to PR / code line + macOS dock badge + macOS permission guidance -#### ✍️ 评审闭环与协作 +#### ⚙️ Models and rules -- [x] 评审发现 → 草稿池 → 内联编辑 → 单条 / 批量发布;远端可合并状态可视 + 一键合并(亦可 `/merge`) -- [x] 评论互动:回复 / 编辑 / 删除 + emoji 反应 + @ 提及补全 + 图片附件 + `:shortcode:` 表情渲染(随平台能力) -- [x] 活动时间线:评论 / 提交更新 / 评审决断归并为一条(GitHub / Bitbucket) -- [x] 消息通知:新 PR / 评论回复 / 被 @ 分类系统通知(仅待处理 PR)+ 点击直达 PR / 代码行 + macOS dock 角标 + macOS 授权引导 +- [x] Multiple LLM providers (OpenAI / openai-compatible / DeepSeek / Anthropic / Tongyi Qianwen / Volcano Ark, etc.; local CLI claude·codex) + token-usage collection +- [x] Personalized rules directory (markdown + frontmatter, recursive sub-directories; multiple matches injected by Ruleset section, sorted by `priority`) +- [x] Adjustable runtime parameters: review-task concurrency, input context length, Agent strategy (auto follow-up toggle, code-suggestion count) -#### ⚙️ 模型与规则 +#### 🔌 External integration and CLI -- [x] 多 LLM Provider(OpenAI / openai-compatible / DeepSeek / Anthropic / 通义千问 / 火山方舟等;本地 CLI claude·codex)+ token 用量采集 -- [x] 个性化规则目录(markdown + frontmatter,子目录递归;命中多条按 Ruleset 分段注入、`priority` 排序) -- [x] 运行参数可调:评审任务并发、输入上下文长度、Agent 策略(自动追问开关、代码建议数量) +- [x] Local API (local-only reachable + token auth) exposing PR discovery / browsing / diff / review Agent / review write actions, for external agent · script · CI integration +- [x] Cross-platform CLI `meebox` (Windows / macOS / Linux): browse PRs + drive the review Agent + review write actions (approve / needswork / comment); the archive is itself an agent skill directory -#### 🎨 界面与体验 +#### 🎨 Interface and experience -- [x] 主题与外观:深色 / 浅色 / 跟随系统 + 多款编辑器配色 + 自定义等宽字体字号 -- [x] 命令面板(`Ctrl/Cmd+Shift+P`)归口分散功能 + 全局快捷键 -- [x] 四语界面(简体中文 / English / 日本語 / Deutsch),AI 回复语言随界面语言 -- [x] 无边框自绘标题栏 + 首启配置向导 + 设置页可视化 CRUD +- [x] Themes and appearance: dark / light / follow system + several editor color themes + custom monospace font and size +- [x] Command palette (`Ctrl/Cmd+Shift+P`) centralizing scattered features + global shortcuts +- [x] Four-language UI (Simplified Chinese / English / 日本語 / Deutsch), with the AI reply language following the UI language +- [x] Frameless custom title bar + first-launch config wizard + visual CRUD on the settings page -#### 📦 工程与发布 +#### 📦 Engineering and release -- [x] 单仓多包(npm + Nx)+ Electron + 类型化 IPC + CI(lint / typecheck / test / build) -- [x] 桌面安装包 Windows x64 + macOS arm64;CI 按 `v*` tag 自动出包并发 GitHub Release(暂不出 Linux) -- [x] 开源发布(Apache-2.0 + NOTICE) +- [x] Monorepo multi-package (npm + Nx) + Electron + typed IPC + CI (lint / typecheck / test / build) +- [x] Desktop installers Windows x64 + macOS arm64; CI auto-builds and publishes a GitHub Release on `v*` tags (no Linux for now) +- [x] Brand website (VitePress, English default + Chinese, deployed independently to GitHub Pages, decoupled from the release pipeline) + bilingual external docs (README / user guide: English canonical + Chinese mirror) +- [x] Open-source release (Apache-2.0 + NOTICE) --- -## 3. 持续演进 +## 2. Ongoing evolution -开放的持续阶段,不设单一 Done when。 +Open-ended continuous phases, with no single Done-when. -### 进行中 / 待办 ⏭️ +### In progress / backlog ⏭️ -- [ ] **可观测性扩展**:规则命中率、模型对比(token 用量已做)。 -- [ ] **外部集成扩展与 CLI**:下个版本(0.9.0)方向——主进程内置本地 API + 独立 CLI,使外部 agent / 工具 - 可集成应用能力(设计见 [服务监听与本地 API](arch/04-integration/01-service-api.md) · [CLI 工具](arch/04-integration/02-cli.md))。 +- [ ] **Observability expansion**: rule hit rate, model comparison (token usage already done). --- -## 4. 风险与未决项 +## 3. Risks and open items -| 风险 / 议题 | 应对 | -| ------------------------- | -------------------------------------------------------------------------------------------------- | -| pr-agent 升级破坏输出格式 | 输出解析层独立(parse-output 单测进 CI)+ shim 构建期 / 运行期版本守卫(见 [04](arch/04-pragent-runtime.md))+ 构建期冒烟(import / 补丁 / litellm,release);升级 pin 时人工刷样本重验 | -| 大型 PR 性能 / diff 截断 | Diff 走本地 git(不用平台截断端点)+ Monaco 懒加载 + 大文件跳过(见 [02](arch/02-repo-mirror.md)) | -| 大型仓库挤爆磁盘 | `repos_dir` 可配置 + 设置页显示体积 + 清理 | -| 明文凭据(config.yaml) | 文件权限收紧 + 文档警示 + `SecretStore` 抽象预留(keytar 升级暂无计划,见 [08](arch/08-config-and-secrets.md)) | -| JSON 状态文件膨胀 | 监控单文件大小;评估后维持 JSON(SQLite 为备选,暂不切,见 [03](arch/03-state-storage.md)) | -| LLM 调用成本 | token 用量统计已做;规则层可控 max_tokens / 模型分级 | +| Risk / topic | Response | +| --- | --- | +| Plaintext credentials (config.yaml) | Tighten file permissions + doc warnings + a reserved `SecretStore` abstraction (no keytar-upgrade plan for now, see [config and secrets](arch/99-core/02-config-and-secrets.md)) | +| LLM call cost | Token-usage tracking done; rule layer can control max_tokens / model tiering | diff --git a/docs/arch/00-overview.md b/docs/arch/00-overview.md index 17a41090..98dfd29c 100644 --- a/docs/arch/00-overview.md +++ b/docs/arch/00-overview.md @@ -1,105 +1,105 @@ -# 架构总览 +# Architecture overview -## 职责与边界 +## Responsibilities & boundaries -本篇给出整体架构与各模块的关系,作为其余模块文档的入口。具体设计落在各分篇。 +This doc gives the overall architecture and the relationships between modules, serving as the entry point to the rest of the module docs. Specific designs live in the individual docs. -形态:单用户**本地桌面应用**(Electron),无服务端、无多用户同步。主进程(Node)承载所有 -业务与 IO;渲染层(React)只做展示与交互,经 IPC 调主进程。 +Form factor: a single-user **local desktop application** (Electron), no server, no multi-user sync. The main process (Node) carries all +business logic and IO; the render layer (React) only does display and interaction, calling the main process over IPC. -## 核心设计 +## Core design -### 进程模型 +### Process model -- **Main(Node + TS)**:业务与 IO 的唯一所在——轮询、仓库镜像、跑 pr-agent、状态读写、发布评论。 - 单写者,独占状态目录,无需文件锁。 -- **Renderer(React)**:UI(PR 列表 / Diff / 对话 / 草稿)。`contextIsolation` 开、无 `nodeIntegration`、走 CSP。 -- **Preload**:经 `contextBridge` 只暴露一个泛型 `invoke(channel, req)` 与少量事件订阅,不暴露 Node 能力。 +- **Main (Node + TS)**: the sole home of business logic and IO — polling, repo mirror, running pr-agent, state reads/writes, publishing comments. + Single writer, exclusive owner of the state directory, no file lock needed. +- **Renderer (React)**: the UI (PR list / Diff / conversation / drafts). `contextIsolation` on, no `nodeIntegration`, under CSP. +- **Preload**: exposes, via `contextBridge`, only a single generic `invoke(channel, req)` plus a few event subscriptions, exposing no Node capabilities. ### IPC -Renderer ↔ Main 全部走 `ipcMain.handle(channel, …)` + 渲染侧泛型 `invoke<K>(channel, req)`,由一份 -集中的 `IpcChannels` 类型映射约束请求/响应类型。新增交互=先在该映射加通道类型,再两侧实现。 -(注意:早期设想用 tRPC,实际是这套手写类型映射。) +Renderer ↔ Main all goes through `ipcMain.handle(channel, …)` + the render-side generic `invoke<K>(channel, req)`, with a single +centralized `IpcChannels` type map constraining request/response types. Adding an interaction = first add the channel type to that map, then implement both sides. +(Note: tRPC was considered early on; what shipped is this hand-written type map.) -### 数据流(一次评审的主链路) +### Data flow (the main path of one review) ```mermaid flowchart TB - subgraph discover[发现] - poller[轮询 Poller] -->|按连接拉「待评审 PR」| state[(状态存储<br/>per-PR 目录)] + subgraph discover[Discover] + poller[Poller] -->|pull "PRs to review" per connection| state[(State storage<br/>per-PR directory)] end - subgraph review[评审一条 PR] + subgraph review[Review one PR] direction TB - pick([用户选中 PR]) --> mirror[仓库镜像<br/>同步 bare + 物化 worktree] - cmd(["/review · /describe · /ask"]) --> bridge[pr-agent 桥<br/>worktree 上跑嵌入式 pr-agent] + pick([User selects a PR]) --> mirror[Repo mirror<br/>sync bare + materialize worktree] + cmd(["/review · /describe · /ask"]) --> bridge[pr-agent bridge<br/>run embedded pr-agent on the worktree] mirror --> bridge - bridge --> parse[输出解析 → findings] - parse --> drafts[草稿池] - drafts --> confirm([用户确认 / 编辑]) - confirm --> publish[批量发布 → 平台 Adapter] + bridge --> parse[Output parsing → findings] + parse --> drafts[Draft pool] + drafts --> confirm([User confirms / edits]) + confirm --> publish[Batch publish → platform Adapter] end - state -.选中.-> pick + state -.select.-> pick ``` -### 模块地图(packages / 主进程子系统) - -- **`01-platform/`** —— 平台集成与 PR 操作 - - [代码平台适配](01-platform/01-adapter.md) —— `platform-bitbucket-server` + `PlatformAdapter` 抽象 - - [仓库镜像与 Diff](01-platform/02-repo-mirror.md) —— `repo-mirror` - - [评审→发布闭环](01-platform/03-review-workflow.md) —— `poller`(输出解析) + 主进程草稿 / 发布 - - [评论互动](01-platform/04-comment-interactions.md) —— 渲染层评论 UI + Adapter 反应 / 附件能力 -- **`02-agent/`** —— Agent 与规则 - - [Agent 与上下文](02-agent/01-agent.md) —— Agent 目录 / 上下文注入 / 工具红线 - - [会话 Agent 化](02-agent/02-session.md) —— 自然语言委派 + 规划循环 - - [AutoPilot 与调度](02-agent/03-autopilot.md) —— 自动预评审 + 优先级队列 - - [规则系统](02-agent/04-rules.md) —— `rules` - - [pr-agent 集成与运行时](02-agent/05-pragent-runtime.md) —— `pr-agent-bridge` + 嵌入式运行时 -- **`03-gui/`** —— GUI 与交互 - - [GUI 与交互](03-gui/01-ui-interaction.md) —— 渲染层 React(布局 / 面板 / 跨 PR 保活) - - [命令面板](03-gui/02-command-palette.md) —— 渲染层标题栏入口 + 分域命令注册表 - - [消息通知](03-gui/03-notifications.md) —— `poller` 事件投影 + 主进程系统通知 / dock 角标 - - [国际化](03-gui/04-i18n.md) —— react-i18next + 主 / 渲染双运行时 locale -- **`04-integration/`** —— 外部集成扩展与 CLI - - [服务监听与本地 API](04-integration/01-service-api.md) —— 主进程内置 HTTP API(IPC 之外的第二前端) - - [CLI 工具](04-integration/02-cli.md) —— Go 独立二进制,经本地 API 消费应用能力 -- **`99-core/`** —— 基础设施 - - [状态存储与数据模型](99-core/01-state-storage.md) —— `state-store` + `poller` 的 pr-state - - [配置与凭据](99-core/02-config-and-secrets.md) —— `config` + 设置页 - - [出站网络与代理](99-core/03-networking-proxy.md) —— 主进程 proxy plumbing - - [错误码与传递](99-core/04-error-codes.md) —— `shared` 的 `AppError` + 跨 IPC 编码 - -> 打包 / 构建 / 签名 见开发专题 [`../development/packaging-release.md`](../development/packaging-release.md)(非产品子系统)。 - -`shared` 是跨包共享类型(含 `IpcChannels` 契约、PR/Finding/Run 等领域类型);`logger` 是统一日志。 - -### 工程基线 - -- npm workspaces + Nx 单仓多包;统一 `lint`/`typecheck`/`test`/`build` 任务(详见根 `AGENTS.md`)。 -- 桌面壳 Electron + electron-vite;渲染 React + Monaco(并排/内联 diff)。 - -### 数据与隐私边界 - -- **本地优先**:仓库副本、PR 元数据、评论缓存、草稿、配置全部留在本地工作目录 `~/.code-meeseeks/` - (仓库镜像可改到 `repos_dir`)。无服务端、不做多用户同步。 -- **出站只有两类**(除此不向任何第三方上报数据;两类都可经统一 HTTP 代理管控,见 [网络与代理](99-core/03-networking-proxy.md)): - - 评审者自配的 **LLM API**(经 pr-agent / litellm); - - 所配置的**代码平台**(PR / 评论 REST + git 拉取)。 -- **发给 LLM 的内容**:pr-agent 评审时只把 **PR diff + 命中的规则**(extra_instructions)发给 LLM,不发其它本地数据。 -- **凭据**:平台 token / LLM API key / 代理密码**明文**存 `config.yaml`(文件权限收紧),属已知风险; - 抽象层预留 keytar 升级(见 [配置与凭据](99-core/02-config-and-secrets.md))。 -- **安全基线**:渲染层 `contextIsolation` 开、无 `nodeIntegration`、CSP;preload 仅暴露白名单能力(见 [GUI 交互](03-gui/01-ui-interaction.md))。 - -## 数据 / 接口契约 - -- **IPC 契约**:集中在 `shared` 的 `IpcChannels` 类型映射(`channel → { request, response }`)。 -- **领域类型**:PR(`StoredPullRequest` / `PrIdentity`)、评论(`PrComment`)、评审 run(`ReviewRun`,含 - `findings` / `tokenUsage`)、平台抽象(`PlatformAdapter`)均在 `shared`,被各包共享。 - -## 扩展与注意事项 - -- **加新代码平台**:实现 `PlatformAdapter`,业务层(Poller/发布/镜像)不感知具体平台。见 [平台适配](01-platform/01-adapter.md)。 -- **跨进程能力**一律走 IPC 通道 + 类型映射,别在渲染层直接碰 Node / 文件 / 网络。 -- 各分篇描述「当前实现」,演进时同步更新对应分篇即可。 +### Module map (packages / main-process subsystems) + +- **`01-platform/`** — Platform integration & PR operations + - [Code-platform adaptation](01-platform/01-adapter.md) — `platform-bitbucket-server` + the `PlatformAdapter` abstraction + - [Repo mirror & Diff](01-platform/02-repo-mirror.md) — `repo-mirror` + - [Review→publish loop](01-platform/03-review-workflow.md) — `poller` (output parsing) + main-process drafts / publish + - [Comment interactions](01-platform/04-comment-interactions.md) — render-layer comment UI + Adapter reaction / attachment capabilities +- **`02-agent/`** — Agent & rules + - [Agent & context](02-agent/01-agent.md) — Agent directory / context injection / tool mutation red line + - [Agentic sessions](02-agent/02-session.md) — natural-language delegation + planning loop + - [AutoPilot & scheduling](02-agent/03-autopilot.md) — automatic pre-review + priority queue + - [Rules system](02-agent/04-rules.md) — `rules` + - [pr-agent integration & runtime](02-agent/05-pragent-runtime.md) — `pr-agent-bridge` + embedded runtime +- **`03-gui/`** — GUI & interaction + - [GUI & interaction](03-gui/01-ui-interaction.md) — render-layer React (layout / panels / cross-PR state persistence) + - [Command palette](03-gui/02-command-palette.md) — render-layer title-bar entry + domain-grouped command registry + - [Notifications](03-gui/03-notifications.md) — `poller` event projection + main-process system notifications / dock badge + - [Internationalization](03-gui/04-i18n.md) — react-i18next + dual main / render runtime locale +- **`04-integration/`** — External integration extensions & CLI + - [Local API service & listener](04-integration/01-service-api.md) — main-process built-in HTTP API (a second front-end beyond IPC) + - [CLI tool](04-integration/02-cli.md) — standalone Go binary, consuming app capabilities via the local API +- **`99-core/`** — Infrastructure + - [State storage & data model](99-core/01-state-storage.md) — `state-store` + the `poller`'s pr-state + - [Config & credentials](99-core/02-config-and-secrets.md) — `config` + settings page + - [Outbound network & proxy](99-core/03-networking-proxy.md) — main-process proxy plumbing + - [Error codes & propagation](99-core/04-error-codes.md) — `shared`'s `AppError` + cross-IPC encoding + +> Packaging / build / signing: see the development topic [`../development/packaging-release.md`](../development/packaging-release.md) (not a product subsystem). + +`shared` holds cross-package shared types (including the `IpcChannels` contract and PR/Finding/Run and other domain types); `logger` is the unified logging. + +### Engineering baseline + +- npm workspaces + Nx monorepo; unified `lint`/`typecheck`/`test`/`build` tasks (see the root `AGENTS.md`). +- Desktop shell Electron + electron-vite; rendering React + Monaco (side-by-side/inline diff). + +### Data & privacy boundary + +- **Local-first**: repo copies, PR metadata, comment cache, drafts, and config all stay in the local working directory `~/.code-meeseeks/` + (the repo mirror can be redirected to `repos_dir`). No server, no multi-user sync. +- **Only two kinds of outbound** (nothing else is reported to any third party; both kinds can be governed through a unified HTTP proxy, see [Networking & proxy](99-core/03-networking-proxy.md)): + - the reviewer's self-configured **LLM API** (via pr-agent / litellm); + - the configured **code platform** (PR / comment REST + git fetch). +- **What is sent to the LLM**: when pr-agent reviews, it sends only the **PR diff + matched rules** (extra_instructions) to the LLM, nothing else from local data. +- **Credentials**: the platform token / LLM API key / proxy password are stored in **plaintext** in `config.yaml` (with tightened file permissions), a known risk; + the abstraction layer reserves a keytar upgrade (see [Config & credentials](99-core/02-config-and-secrets.md)). +- **Security baseline**: the render layer has `contextIsolation` on, no `nodeIntegration`, CSP; preload exposes only whitelisted capabilities (see [GUI interaction](03-gui/01-ui-interaction.md)). + +## Data / interface contract + +- **IPC contract**: centralized in `shared`'s `IpcChannels` type map (`channel → { request, response }`). +- **Domain types**: PR (`StoredPullRequest` / `PrIdentity`), comment (`PrComment`), review run (`ReviewRun`, including + `findings` / `tokenUsage`), and the platform abstraction (`PlatformAdapter`) all live in `shared`, shared across packages. + +## Extension & caveats + +- **Adding a new code platform**: implement `PlatformAdapter`; the business layer (Poller/publish/mirror) is agnostic to the specific platform. See [Platform adaptation](01-platform/01-adapter.md). +- **Cross-process capabilities** always go through an IPC channel + type map; don't touch Node / files / network directly in the render layer. +- Each doc describes the "current implementation"; when it evolves, just update the corresponding doc in sync. diff --git a/docs/arch/01-platform/01-adapter.md b/docs/arch/01-platform/01-adapter.md index 1931f8e8..2596e5cf 100644 --- a/docs/arch/01-platform/01-adapter.md +++ b/docs/arch/01-platform/01-adapter.md @@ -1,22 +1,16 @@ -# 代码平台适配 +# Code platform adaptation -把「代码托管平台」的差异收口到一个统一抽象 `PlatformAdapter`,业务层(轮询、镜像、评审发布) -只依赖该抽象、不感知具体平台。`PlatformAdapter` 不是单一巨接口,而是**按业务领域拆分的服务容器** -(连接 / PR 操作 / 评论 / 用户与媒体四个领域),各领域共享一份平台连接。本章是平台适配的**统一设计 -与维护入口**:分层与领域拆分、能力位与降级、评论统一模型,以及各平台(Bitbucket / GitHub / GitLab) -差异化适配逻辑。 +Funnel the differences between "code hosting platforms" into a single unified abstraction, `PlatformAdapter`; the business layers (polling, mirroring, review publishing) depend only on this abstraction and are unaware of the concrete platform. `PlatformAdapter` is not one monolithic interface but a **service container split by business domain** (four domains: connection / PR operations / comments / user & media), all sharing one platform connection. This chapter is the **unified design and maintenance entry point** for platform adaptation: layering and the domain split, capability flags and degradation, the unified comment model, and the differential adaptation logic of each platform (Bitbucket / GitHub / GitLab). -已实现:**Bitbucket Server / Data Center**、**GitHub(github.com + GitHub Enterprise Server)**、 -**GitLab(gitlab.com + Self-Managed,CE/EE,REST API v4)**。不负责:git 本地操作(见 -[仓库镜像](02-repo-mirror.md))、pr-agent 调用(见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))。 +Implemented: **Bitbucket Server / Data Center**, **GitHub (github.com + GitHub Enterprise Server)**, **GitLab (gitlab.com + Self-Managed, CE/EE, REST API v4)**. Out of scope: local git operations (see [Repo mirror](02-repo-mirror.md)), pr-agent invocation (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)). --- -## 1. 核心抽象设计 +## 1. Core abstraction design -### 分层 +### Layering -三层,契约与传输实现解耦: +Three layers, with the contract decoupled from the transport implementation: ```mermaid classDiagram @@ -88,219 +82,160 @@ classDiagram PlatformTransport <|.. BitbucketClient ``` -> **图说**(`<|..` 实现 / `<|--` 继承 / `o--` 持有组合): +> **Legend** (`<|..` implements / `<|--` inherits / `o--` holds by composition): > -> - **契约层** `@meebox/platform-core`:每个领域接口由对应 `Base*` 实现、承载跨平台逻辑;`Base*` 共同继承 `PlatformDomainService`;容器接口 `PlatformAdapter` 持有四个领域接口。 -> - **平台扩展**(**GitHub / GitLab / Bitbucket** 同构):`*Adapter` 实现容器接口 `PlatformAdapter`、`*Client` 实现传输端口 `PlatformTransport`。 -> - 每个 `*Adapter` 组合该平台四个领域服务(分别 `extends` 对应 `Base*`)、各服务经 `ConnectionContext` 共享同一 `*Client`——为免图杂未展开。 - -- **契约层 `@meebox/platform-core`**:只声明业务契约,零 HTTP 实现。含四个**领域接口**与对应**领域基类** - (`BaseConnection` / `BasePullRequestService` / `BaseCommentService` / `BaseMediaService`,承载跨平台业务 - 逻辑)、**传输端口 `PlatformTransport`**(领域服务发请求的唯一接缝)、连接上下文 `ConnectionContext`、 - 组合器 `composePlatformAdapter`,以及可选传输 helper(超时 / URL 拼接 / 错误解析 / Link 分页等自由函数)。 -- **实现层 `@meebox/platform-{github,gitlab,bitbucket-server}`**:每平台一个**统一连接封装实例**(client, - 实现 `PlatformTransport`)+ 四个领域服务(`extends` 对应基类、注入 client)。平台特有的响应类型放 - `types.ts`、跨领域工具放 `utils.ts`、领域专属映射作各服务私有方法。 -- **根 `PlatformAdapter`(领域服务容器)**:`{ kind, connection, prs, comments, media }`,不含业务逻辑, - 只持有并暴露四个领域。业务层按领域取服务(`adapter.comments.list(...)` / `adapter.prs.listPending(...)`)。 - -**领域拆分**(替代旧的单一约 20 方法巨接口): - -| 领域 | 接口 | 职责 | +> - **Contract layer** `@meebox/platform-core`: each domain interface is implemented by its corresponding `Base*`, which carries the cross-platform logic; the `Base*` classes all inherit `PlatformDomainService`; the container interface `PlatformAdapter` holds the four domain interfaces. +> - **Platform extensions** (**GitHub / GitLab / Bitbucket** isomorphic): `*Adapter` implements the container interface `PlatformAdapter`, `*Client` implements the transport port `PlatformTransport`. +> - Each `*Adapter` composes that platform's four domain services (each `extends` the corresponding `Base*`); the services share the same `*Client` via `ConnectionContext` — not expanded in the diagram to avoid clutter. + +- **Contract layer `@meebox/platform-core`**: declares only business contracts, zero HTTP implementation. Contains the four **domain interfaces** and their corresponding **domain base classes** (`BaseConnection` / `BasePullRequestService` / `BaseCommentService` / `BaseMediaService`, carrying the cross-platform business logic), the **transport port `PlatformTransport`** (the sole seam through which domain services issue requests), the connection context `ConnectionContext`, the composer `composePlatformAdapter`, and optional transport helpers (free functions for timeouts / URL joining / error parsing / Link pagination, etc.). +- **Implementation layer `@meebox/platform-{github,gitlab,bitbucket-server}`**: one **unified connection-wrapper instance** per platform (a client implementing `PlatformTransport`) + four domain services (each `extends` the corresponding base class and is injected with the client). Platform-specific response types go in `types.ts`, cross-domain utilities in `utils.ts`, domain-specific mappings as private methods of each service. +- **Root `PlatformAdapter` (domain-service container)**: `{ kind, connection, prs, comments, media }`, no business logic — it only holds and exposes the four domains. Business layers pull services by domain (`adapter.comments.list(...)` / `adapter.prs.listPending(...)`). + +**Domain split** (replacing the old single ~20-method monolithic interface): + +| Domain | Interface | Responsibility | | --- | --- | --- | -| `connection` | `PlatformConnection` | 连接探测 `ping()`、当前用户缓存、能力聚合 `capabilities()`、clone URL | -| `prs` | `PullRequestService` | PR 发现、提交(**newest-first**)、活动决断、审批、合并 | -| `comments` | `CommentService` | 评论读 / 发 / 回复 / 编辑 / 删除 | -| `media` | `MediaService` | 头像、评论内嵌附件代理 | - -- **统一连接封装实例(每平台一个 client)**:实现 `PlatformTransport` 端口,是该平台连接 / 鉴权配置的 - 单一持有者——base URL 归一与 web/git host 推导、PAT、单请求超时、代理解析、clone 协议与 clone URL 构造, - 全收口于此。token 经凭据层读取、**绝不进日志**。四领域经 `ConnectionContext`(持 client + `cachedUser`) - 共享同一连接态,不重复持有 transport 或 token。 -- **传输端口 `PlatformTransport`**:仅声明三平台同构的最小连接能力——`get/getWithHeaders/post/put/del` - + `paginate`(纯 JSON 读写 + 分页)。二进制拉取、`search`/`patch`、clone URL 等平台特有方法是各 client - 的端口外扩展(信任模型迥异,不入通用契约)。领域基类只依赖此端口,不知底层 fetch / 鉴权头 / 翻页风格。 -- **平台中性的 PR 身份 `PrIdentity`**:`platform / group / repo / remoteId / connectionId`(+ 可选 url)。 - 各平台把自己的概念映射进来;这套身份也是状态存储 hash localId 的输入(见 [状态存储](../99-core/01-state-storage.md))。 - - | 中性概念 | Bitbucket | GitHub | GitLab | +| `connection` | `PlatformConnection` | connection probe `ping()`, current-user cache, capability aggregation `capabilities()`, clone URL | +| `prs` | `PullRequestService` | PR discovery, commits (**newest-first**), activity, review decision, merge | +| `comments` | `CommentService` | comment read / post / reply / edit / delete | +| `media` | `MediaService` | avatars, proxying comment-embedded attachments | + +- **Unified connection-wrapper instance (one client per platform)**: implements the `PlatformTransport` port and is the single holder of that platform's connection / auth config — base-URL normalization and web/git host derivation, PAT, per-request timeout, proxy resolution, clone protocol and clone-URL construction, all funneled here. The token is read via the credential layer and **never enters logs**. The four domains share the same connection state via `ConnectionContext` (which holds the client + `cachedUser`), never independently holding the transport or token. +- **Transport port `PlatformTransport`**: declares only the minimal connection capability isomorphic across the three platforms — `get/getWithHeaders/post/put/del` + `paginate` (pure JSON read/write + pagination). Binary fetching, `search`/`patch`, clone URL, etc. are per-client extensions outside the port (their trust models differ sharply and do not belong in the generic contract). The domain base classes depend only on this port and are unaware of the underlying fetch / auth headers / pagination style. +- **Platform-neutral PR identity `PrIdentity`**: `platform / group / repo / remoteId / connectionId` (+ optional url). Each platform maps its own concepts into it; this identity is also the input for hashing the state store's `localId` (see [State storage](../99-core/01-state-storage.md)). + + | Neutral concept | Bitbucket | GitHub | GitLab | | --- | --- | --- | --- | - | group | projectKey | owner(org/user) | namespace | + | group | projectKey | owner (org/user) | namespace | | repo | repoSlug | repo | project path | - | remoteId | PR id | PR number | MR **iid**(项目内编号) | - -- **认证只用 PAT**:`Authorization: Bearer <token>`(GitLab 走 `PRIVATE-TOKEN` 头)。token 经凭据层读取,绝不进日志。 -- **代理统一进连接层**:连接配置 `PlatformConnectionConfig` 携带 `proxy`,client 构造时据 baseUrl host - **一次性**解析有效 fetch(loopback 直连 / 否则挂代理)。为不让 core 依赖 undici,解析经注入的 - `ProxyFetchFactory` 完成(组合根 desktop 提供实现)。分页按平台风格封装成异步迭代器(Bitbucket - `start/limit`;GitHub/GitLab `Link` 头)。 -- **Diff 不走 adapter 抓取**:平台 `/diff` 端点对大 PR 会 `truncated`;Diff 展示一律由本地镜像 `git` 算 - (见 [仓库镜像](02-repo-mirror.md)),与平台解耦。**仅「发布行内评论」时用平台锚点**——本地算 diff 时已知每行 - 新旧行号与 added/removed/context 角色,正是各平台锚点都需要的输入,这是抽象能成立的关键。 -- **clone 协议二选一**:`pat`(默认,URL 里嵌 `<user>:<PAT>`)或 `ssh`(`git@host:...`,走系统 ssh 配置)。 - -### 接口与中性数据模型 - -各领域服务方法: - -- `connection`:`capabilities()`(静态能力描述符,见 §2)、`ping()`(版本 + 用户)、`getCurrentUser()` - (同步读 ping 缓存,判 approved 用)、`getCloneUrl()`。 -- `prs`:`listPendingPullRequests()`(reviewer 待处理,跨仓)、`listPullRequestCommits()`(**newest-first**)、 - `listPullRequestActivity()`、`setPullRequestReviewStatus()`、`mergePullRequest()`。 -- `comments`:`listPullRequestComments()`、`publishSummaryComment()`、`publishInlineComment()`、 - `replyToComment()`、`editComment()`、`deleteComment()`。 -- `media`:`getUserAvatar()`、`getAttachment()`。 - -中性类型要点: - -- `PrComment`:`anchor`(null=summary / 非空=inline)、`replies[]`、可选 `version`(**仅 Bitbucket 乐观锁**)、 - `kind`('summary' | 'inline')、`threadId`(回复目标抽象:Bitbucket=父评论 id / GitHub=review-comment id / GitLab=discussion id)。 -- `PrCommentAnchor`:`(path, line, side('old'|'new'), lineType('added'|'removed'|'context'))`。 -- `PrDiffRefs`:`{ headSha, baseSha, startSha? }`——行内评论发布锚点用(GitHub head sha / GitLab 三 sha;Bitbucket 忽略)。 -- `MergeStatus`:`{ canMerge, conflicted, vetoes[] }`;保真度见 §2 的 `mergeVetoFidelity`。 + | remoteId | PR id | PR number | MR **iid** (per-project number) | + +- **Authentication is PAT-only**: `Authorization: Bearer <token>` (GitLab uses the `PRIVATE-TOKEN` header). The token is read via the credential layer and never enters logs. +- **Proxying is funneled into the connection layer**: the connection config `PlatformConnectionConfig` carries `proxy`, and at client construction the effective fetch is resolved **once** by baseUrl host (loopback = direct connection / otherwise attach proxy). To keep core from depending on undici, the resolution is done via the injected `ProxyFetchFactory` (the composition root, desktop, provides the implementation). Pagination is wrapped into an async iterator per platform style (Bitbucket `start/limit`; GitHub/GitLab `Link` header). +- **Diff is not fetched through the adapter**: a platform's `/diff` endpoint will `truncated` for large PRs; diff display is always computed by the local mirror's `git` (see [Repo mirror](02-repo-mirror.md)), decoupled from the platform. **Platform anchors are used only when "publishing an inline comment"** — the local diff computation already knows each line's old/new line numbers and its added/removed/context role, which is exactly the input every platform's anchor needs; this is the key that makes the abstraction hold. +- **Clone protocol is one of two**: `pat` (default, embedding `<user>:<PAT>` in the URL) or `ssh` (`git@host:...`, via the system ssh config). + +### Interfaces & neutral data model + +Per-domain service methods: + +- `connection`: `capabilities()` (static capability descriptor, see §2), `ping()` (version + user), `getCurrentUser()` (synchronously reads the ping cache, used for the approved check), `getCloneUrl()`. +- `prs`: `listPendingPullRequests()` (reviewer's pending, cross-repo), `listPullRequestCommits()` (**newest-first**), `listPullRequestActivity()`, `setPullRequestReviewStatus()`, `mergePullRequest()`. +- `comments`: `listPullRequestComments()`, `publishSummaryComment()`, `publishInlineComment()`, `replyToComment()`, `editComment()`, `deleteComment()`. +- `media`: `getUserAvatar()`, `getAttachment()`. + +Neutral-type highlights: + +- `PrComment`: `anchor` (null = summary / non-null = inline), `replies[]`, optional `version` (**Bitbucket optimistic lock only**), `kind` ('summary' | 'inline'), `threadId` (the reply-target abstraction: Bitbucket = parent comment id / GitHub = review-comment id / GitLab = discussion id). +- `PrCommentAnchor`: `(path, line, side('old'|'new'), lineType('added'|'removed'|'context'))`. +- `PrDiffRefs`: `{ headSha, baseSha, startSha? }` — used for the inline-comment publish anchor (GitHub head sha / GitLab three shas; Bitbucket ignores it). +- `MergeStatus`: `{ canMerge, conflicted, vetoes[] }`; for fidelity see `mergeVetoFidelity` in §2. --- -## 2. 能力描述符与功能降级 +## 2. Capability descriptor & capability degradation -无法在所有平台等价实现的能力,用 `capabilities()` 返回的 **`PlatformCapabilities`** 显式声明, -UI 据此 显/隐/灰,业务层据此调策略——**绝不在调用处 `try/catch` 猜,也不写 `if (platform === ...)`**。 +Capabilities that cannot be implemented equivalently on all platforms are declared explicitly via the **`PlatformCapabilities`** returned by `capabilities()`; the UI shows / hides / greys accordingly, and the business layer switches strategy accordingly — **never `try/catch` to guess at the call site, and never write `if (platform === ...)`**. -`PlatformCapabilities` 字段:`reviewStatuses`(支持的审批决断)、`inlineComments`、`inlineMultiline`、 -`commentOptimisticLock`、`commentHardBreaks`(单 `\n` 是否按 hard-break 渲染)、`mergeVetoFidelity` -('full' | 'partial')、`discoveryRateLimited`、`discoveryFilters`(PR 发现分类)、`resolvableThreads`、 -`suggestions`、`reviewGrouping`、`activityTimeline`(是否提供决断活动事件流)、`commentCountIncludesReplies` -(`PullRequest.commentCount` 是否含回复——决定 poller 评论跟踪触发策略,见 [通知](../03-gui/03-notifications.md);GitHub/GitLab 为真、Bitbucket 为假)。 +`PlatformCapabilities` fields: `reviewStatuses` (supported review decisions), `inlineComments`, `inlineMultiline`, `commentOptimisticLock`, `commentHardBreaks` (whether a single `\n` renders as a hard break), `mergeVetoFidelity` ('full' | 'partial'), `discoveryRateLimited`, `discoveryFilters` (PR discovery categories), `resolvableThreads`, `suggestions`, `reviewGrouping`, `activityTimeline` (whether a review-decision activity event stream is provided), `commentCountIncludesReplies` (whether `PullRequest.commentCount` includes replies — determines the poller's comment-tracking trigger strategy, see [Notifications](../03-gui/03-notifications.md); true for GitHub/GitLab, false for Bitbucket). -**合并否决原因走中性码**:`MergeVeto` 不在后台拼面向用户的本地化文案。GitHub / GitLab 把派生原因归一到 -`@meebox/platform-core` 的稳定码 `MergeVetoCode`(`conflict` / `branchProtected` / `behind` / `checksFailed` -/ `checking` / `draft` / `discussionsUnresolved` / `notApproved` / `notOpen` / `blockedByDependency` / -`notMergeable`),前端按码 i18n(`mergeVeto.<code>`);Bitbucket 直接透传服务端文案(`summary`,无码)。 -同理连接探测的版本不支持等后台用户态错误以错误码承载(见 [错误码](../99-core/04-error-codes.md)),不在后台拼中文。 +**Merge-veto reasons go through neutral codes**: `MergeVeto` does not assemble user-facing localized text in the backend. GitHub / GitLab normalize their derived reasons to the stable codes `MergeVetoCode` in `@meebox/platform-core` (`conflict` / `branchProtected` / `behind` / `checksFailed` / `checking` / `draft` / `discussionsUnresolved` / `notApproved` / `notOpen` / `blockedByDependency` / `notMergeable`), and the frontend does i18n by code (`mergeVeto.<code>`); Bitbucket passes the server text through directly (`summary`, no code). Likewise, backend user-facing errors such as unsupported version from the connection probe are carried by error codes (see [Error codes](../99-core/04-error-codes.md)), not assembled as Chinese text in the backend. -各平台能力一览: +Per-platform capability overview: -| 能力 | Bitbucket | GitHub | GitLab | +| Capability | Bitbucket | GitHub | GitLab | | --- | --- | --- | --- | -| reviewStatuses | 通过/需修改/撤销 | 通过/需修改/撤销 | Premium:通过/撤销;CE:无 API 审批 | -| commentOptimisticLock | 是(version) | 否 | 否 | -| mergeVetoFidelity | full(/merge vetoes) | partial(拼 mergeable_state) | full(detailed_merge_status) | -| discoveryRateLimited | 否 | 是(search 30/分) | 否 | -| resolvableThreads / suggestions | 否 / 否 | 概念有,当前未实现 | 概念有,当前未实现 | +| reviewStatuses | approve/needs work/revoke | approve/needs work/revoke | Premium: approve/revoke; CE: no review API | +| commentOptimisticLock | yes (version) | no | no | +| mergeVetoFidelity | full (/merge vetoes) | partial (assembled from mergeable_state) | full (detailed_merge_status) | +| discoveryRateLimited | no | yes (search 30/min) | no | +| resolvableThreads / suggestions | no / no | conceptually present, not yet implemented | conceptually present, not yet implemented | -### 功能降级三态与判据 +### The three degradation states & the decision criteria -- **置灰 + 原因 tooltip**:用户预期存在、但因平台版本 / 权限暂不可用 → 保留可发现性并说明原因。 -- **隐藏不渲染**:平台概念上根本没有该能力。 -- **降级替代**:有可用的弱替代动作。 +- **Grey out + reason tooltip**: the user expects it to exist, but it is temporarily unavailable due to platform version / permissions → preserve discoverability and state the reason. +- **Hidden (not rendered)**: the platform conceptually has no such capability at all. +- **Degraded substitute**: a usable weaker substitute action exists. -判据:**「本可有但此实例没有」→ 置灰说明;「平台无此概念」→ 隐藏;「有弱替代」→ 替代 + 提示。** +Criteria: **"could exist but this instance lacks it" → grey out with explanation; "the platform has no such concept" → hidden; "a weak substitute exists" → substitute + hint.** -两层能力来源: +Two layers of capability source: -- **静态**(平台/版本/套餐)← `capabilities()`:经 `ConnectionSummary.capabilities` 下发渲染层,决定 feature 级 显/隐/灰。 -- **动态**(本 PR / 本用户权限 / 异步未就绪)← PR 数据:决定 instance 级 灰 + 原因(如合并按钮仅 `mergeStatus.canMerge` 时出现; - GitHub `mergeable=null` 用「计算中」中性态而非永久置灰;自己作者的 PR 审批按钮灰显)。 +- **Static** (platform / version / plan) ← `capabilities()`: pushed down to the render layer via `ConnectionSummary.capabilities`, deciding feature-level show / hide / grey. +- **Dynamic** (this PR / this user's permissions / async not-yet-ready) ← PR data: decides instance-level grey + reason (e.g. the merge button appears only when `mergeStatus.canMerge`; GitHub's `mergeable=null` uses a neutral "computing" state rather than a permanent grey; the review button is greyed on your own authored PR). --- -## 3. 评论交互:统一模型 + 能力位 +## 3. Comment interactions: unified model + capability flags -**不按平台分设计评论 UI**,而是一套交互模型,差异收敛成能力位。渲染层只消费中性 `PrComment` 树 + -`capabilities()`;各平台评论概念由 **adapter 归一**成同一棵嵌套结构。核心动作(读 / 回复 / 编辑 / 删除 / -草稿→确认发布)三家一致。**归一可行 = 不分叉**;只有当某平台模型无法被 `PrComment` 无损表达时,才重估「专门组件」。 +**Do not design a per-platform comment UI**; instead a single interaction model, with the differences converged into capability flags. The render layer consumes only the neutral `PrComment` tree + `capabilities()`; each platform's comment concepts are **normalized by the adapter** into the same nested structure. The core actions (read / reply / edit / delete / draft → confirm publish) are identical across the three. **Normalizable = no forking**; only when a platform's model cannot be expressed losslessly by `PrComment` do we reconsider a "dedicated component". -能力位(面向评论 UI):`resolvableThreads`(线程解决 + 折叠)、`suggestions`(行内建议一键应用)、 -`reviewGrouping`(决断 + 行内评论成组提交,映射到本地「草稿池→批量发布」,见 [评审闭环](03-review-workflow.md))、 -`commentOptimisticLock`(删改是否带 version)。能力位为 false 时按 §2 降级(隐藏 / 置灰)。 +Capability flags (comment-UI-facing): `resolvableThreads` (thread resolve + collapse), `suggestions` (one-click apply of an inline suggestion), `reviewGrouping` (review decision + inline comments submitted as a group, mapping to the local "draft pool → batch publish", see [Review workflow](03-review-workflow.md)), `commentOptimisticLock` (whether delete/edit carries `version`). When a flag is false, degrade per §2 (hide / grey out). --- -## 4. 平台差异化适配 - -### 4.1 Bitbucket Server / Data Center(REST API v1,≥ 7.0) - -- **发现**:dashboard 聚合端点 `/dashboard/pull-requests?role=REVIEWER&state=OPEN`,一次拿全跨项目跨仓库的待评审 PR。 -- **当前用户**:每个鉴权请求响应头带 `X-AUSERNAME`(slug),ping 时据此 + `/users/{slug}` 取 displayName。 -- **版本下限 7.0**:`ping()` 读 `application-properties` 版本,低于 7.0 拒绝(multilineMarker 等关键能力 7.0 起)。 -- **评论**:走 `/activities` 拿全部活动,过滤 `COMMENTED` + `ADDED`;单棵评论树,reply 走 `comment.comments[]` 嵌套。 -- **行内锚点**:`anchor{path, line, lineType(ADDED/REMOVED/CONTEXT), fileType(FROM/TO)}` + `diffType=EFFECTIVE` - (锚到「当前生效 diff」,PR 后续 push 仍跟着行走);多行用 multilineMarker。 -- **乐观锁**:评论删 / 改必带 `version`(query / body),不一致回 409;删除带 reply 的评论被拒(409)。 -- **审批**:`PUT …/participants/{userSlug}` 写 status(APPROVED / NEEDS_WORK / UNAPPROVED),幂等可来回切。 -- **合并**:`/merge` 一次给 `canMerge / conflicted / vetoes`(full 保真);`POST …/merge?version=N` 带乐观锁。 -- **clone**:pat → `https://<user>:<PAT>@host/scm/<proj>/<repo>.git`(用户名取 cachedUser);ssh → `git@host:<proj>/<repo>.git`(默认 7999 端口需 ssh config 配)。 - -### 4.2 GitHub(github.com + GitHub Enterprise Server,REST API v3) - -> 以下是「代码本身讲不清」的核心逻辑,务必随实现一起维护。 - -- **Base URL 与 host 推导**:连接 base 是 **API base**(github.com→`https://api.github.com`;GHE→`https://<host>/api/v3`)。 - clone / 头像 / 网页用的 **web/git host** 由 adapter 推导:`api.github.com → github.com`;GHE → 同 host(去掉 `/api/v3`)。 -- **发现(强限流 + 最终一致 + 两段取数)**:无 dashboard,用 Search `GET /search/issues?q=is:open is:pr review-requested:@me archived:false`。 - - search **约 30 次/分钟限流** → `capabilities.discoveryRateLimited=true`,该平台轮询间隔单独拉长。 - - search 返回的是 **issue 形态**(含 `repository_url` + `number`),需逐条再取 `GET /repos/{o}/{r}/pulls/{n}`(拿 head/base sha、 - mergeable、draft)+ `GET …/pulls/{n}/reviews`(算 reviewer 状态),并行 N+1。 - - 结果**最终一致**:刚被请求评审的 PR 可能短暂查不到——属预期,靠下一轮轮询补上。 -- **评论三分体系归一**:GitHub 把评论拆三套 —— - - issue 评论 `/issues/{n}/comments` = PR 级讨论(≈ summary,无线程); - - review 评论 `/pulls/{n}/comments` = 行内(带 `path/line/side/in_reply_to_id`); - - reviews `/pulls/{n}/reviews` = 决断。 - adapter 把 issue 评论作 summary、review 评论按 `in_reply_to_id` 还原成顶层 + 嵌套 reply,统一成 `PrComment` 树。 -- **行内锚点需 head sha**:`POST …/pulls/{n}/comments` 必带 `commit_id`(= PR head sha)。按抽象决策,**adapter 内部 - 先拉 PR 取 head sha** 再发,调用方无需改。side:内部 'old'→`LEFT` / 'new'→`RIGHT`。行号须落在该 commit 的 diff 内,否则 422。 -- **审批是追加事件**:通过→`POST …/reviews{event:APPROVE}`;需修改→`{event:REQUEST_CHANGES, body}`(GitHub 要求带 body); - 撤销→找当前用户最近一条 APPROVED/CHANGES_REQUESTED review,`PUT …/reviews/{id}/dismissals`。 - **不能审批自己的 PR**(422)→ UI 对自己作者的 PR 灰显审批按钮。「当前状态」取该用户最近一条决断性 review。 -- **评论删 / 改 / 回复无 version**:先按 inline(`/pulls/comments/{id}` 改删、`/pulls/{n}/comments/{id}/replies` 回复), - 404/422 退化为 issue 评论端点(`/issues/comments/{id}`、新建 issue 评论)。 -- **合并与可合并(partial)**:`mergeable`(bool|**null**,异步计算,初次可能 null)+ `mergeable_state` - (clean/dirty/blocked/behind/unstable)。逐条否决项无单一端点,由 adapter 按 `mergeable_state` **派生近似** - (fidelity=partial);`null` 不当 false,标「计算中」。合并 `PUT …/pulls/{n}/merge`。 -- **提交**:`/pulls/{n}/commits` 为 oldest-first,adapter **反转**为 newest-first。 -- **头像 / 附件**:头像直链 `<webBase>/<login>.png`;评论内嵌图片是绝对 URL(user-attachments / githubusercontent / GHE host), - 经 main 端带 PAT 代理拉(私有需鉴权)。 -- **Token 权限**:见 [代码平台配置 · GitHub PAT 权限参考](../../guide/01-code-platform.md)(经典 `repo`;细粒度 Pull requests RW + Contents RW + Metadata R)。 - -### 4.3 GitLab(gitlab.com + Self-Managed CE/EE,REST API v4) - -- **Base URL 与 host 推导**:连接 base 是 **API base**(gitlab.com→`https://gitlab.com/api/v4`;自建→`https://<host>/api/v4`), - 可留空默认官方。clone / 附件 / 网页用的 web host 由 adapter 推导(取 base 的 host,去掉 `/api/v4`)。鉴权走 `PRIVATE-TOKEN` 头。 -- **身份映射**:`projectKey`=namespace(**含嵌套 group**,如 `group/subgroup`)、`repoSlug`=project、`remoteId`=MR **iid**。 - 端点 `:id` 用 `encodeURIComponent(projectKey/repoSlug)`(GitLab 接受 URL-encoded 全路径作 project id)。MR web_url 解析出项目路径。 -- **发现(三类)**:`GET /merge_requests?scope=all&state=opened&...`(全局跨项目)。`discoveryFilters` = - 待我评审(`reviewer_username`)/ 我创建(`author_username`)/ 指派我(`assignee_username`);GitLab 无 "mentioned" - 概念故不含。poller 逐类轮询 union 打标,renderer 切标签。列表项再逐条取详情(`diff_refs` 三 sha + - `detailed_merge_status`)+(EE)`/approvals`(approved_by → reviewer 状态),N+1。 -- **评论 = discussions + notes**:`GET …/discussions` 一棵棵讨论,首 note 作顶层、其余作 reply;过滤 `system` note。 - inline = note 带 `position`(`new_path/new_line` 或 `old_path/old_line`)。reply 走 **discussion_id**(= `threadId`);改 / 删走 - **note_id**(= `remoteId`)—— 二者不同,故 renderer 回复入口改传 `threadId ?? remoteId`(Bitbucket/GitHub 不受影响)。 -- **行内锚点需三 sha**:`POST …/discussions{body, position}`,position 含 `base/start/head_sha`(adapter 内部先拉 MR 取 `diff_refs`)+ - `position_type:'text'` + 按 side 填 `new_line`/`old_line`。当前**单行**(`inlineMultiline=false`)。 -- **审批(edition 降级)**:approve/unapprove API **自 13.9 起为 Premium/Ultimate**,CE / EE-Free 无;GitLab 审批二元、**无 - needsWork**。`ping()` 经 `GET /metadata`(15.2+)的 `enterprise` 标志探测 edition(旧实例退 `/version` 保守按 CE); - `capabilities.reviewStatuses` = EE→`['approved','unapproved']` / CE→`[]`(UI 灰显)。注:`enterprise=true` 不绝对保证审批可用 - (EE-Free 无),故写路径仍优雅失败提示。 -- **可合并(full 保真)**:`detailed_merge_status`(15.6+,`mergeable` / `broken_status` / `not_approved` / `ci_must_pass` …)逐条派生 - veto + `has_conflicts` 定 conflicted;旧实例退 `merge_status`。合并 `PUT …/merge`。 -- **提交**:`/commits` 已是 newest-first,无需反转。 -- **头像 / 附件**:头像用 `avatar_url` 直链(仅本实例 host 才带 PAT);评论内嵌相对 `/uploads/...` 补成 - `<webBase>/<project>/uploads/...` 经 PAT 代理拉,外部 host 不带凭据。 +## 4. Platform-specific adaptation + +### 4.1 Bitbucket Server / Data Center (REST API v1, ≥ 7.0) + +- **Discovery**: the dashboard aggregation endpoint `/dashboard/pull-requests?role=REVIEWER&state=OPEN` returns all pending-review PRs across projects and repos in one call. +- **Current user**: every authenticated request's response carries the `X-AUSERNAME` header (slug); at ping time, `/users/{slug}` is used to get the displayName. +- **Version floor 7.0**: `ping()` reads the `application-properties` version and rejects anything below 7.0 (key capabilities such as multilineMarker exist only from 7.0). +- **Comments**: fetch all activities via `/activities`, filtering `COMMENTED` + `ADDED`; a single comment tree, with replies nested via `comment.comments[]`. +- **Inline anchor**: `anchor{path, line, lineType(ADDED/REMOVED/CONTEXT), fileType(FROM/TO)}` + `diffType=EFFECTIVE` (anchored to the "currently effective diff", so it follows the line even after subsequent pushes to the PR); multi-line uses the multilineMarker. +- **Optimistic lock**: comment delete / edit must carry `version` (query / body); a mismatch returns 409; deleting a comment that has replies is rejected (409). +- **Review**: `PUT …/participants/{userSlug}` writes status (APPROVED / NEEDS_WORK / UNAPPROVED); idempotent and freely switchable back and forth. +- **Merge**: `/merge` returns `canMerge / conflicted / vetoes` in one call (full fidelity); `POST …/merge?version=N` carries the optimistic lock. +- **Clone**: pat → `https://<user>:<PAT>@host/scm/<proj>/<repo>.git` (username taken from cachedUser); ssh → `git@host:<proj>/<repo>.git` (the default port 7999 must be set in ssh config). + +### 4.2 GitHub (github.com + GitHub Enterprise Server, REST API v3) + +> The following is the core logic that "the code itself can't make clear" — be sure to maintain it alongside the implementation. + +- **Base URL and host derivation**: the connection base is the **API base** (github.com → `https://api.github.com`; GHE → `https://<host>/api/v3`). The **web/git host** used for clone / avatars / web pages is derived by the adapter: `api.github.com → github.com`; GHE → the same host (with `/api/v3` stripped). +- **Discovery (heavy rate limit + eventual consistency + two-stage fetch)**: no dashboard, so use Search `GET /search/issues?q=is:open is:pr review-requested:@me archived:false`. + - search is **rate-limited to ~30/min** → `capabilities.discoveryRateLimited=true`, and this platform's poll interval is lengthened separately. + - search returns **issue-shaped** results (with `repository_url` + `number`), so each one must then fetch `GET /repos/{o}/{r}/pulls/{n}` (for head/base sha, mergeable, draft) + `GET …/pulls/{n}/reviews` (to compute reviewer status), an N+1 in parallel. + - the results are **eventually consistent**: a PR that was just requested for review may briefly not be found — this is expected, and the next poll round picks it up. +- **Normalizing the three-comment system**: GitHub splits comments into three sets — + - issue comments `/issues/{n}/comments` = PR-level discussion (≈ summary, no threads); + - review comments `/pulls/{n}/comments` = inline (with `path/line/side/in_reply_to_id`); + - reviews `/pulls/{n}/reviews` = decisions. + The adapter treats issue comments as the summary and reconstructs review comments by `in_reply_to_id` into top-level + nested replies, unified into a `PrComment` tree. +- **Inline anchor needs head sha**: `POST …/pulls/{n}/comments` must carry `commit_id` (= PR head sha). By the abstraction's decision, **the adapter internally fetches the PR to get the head sha** before posting, so the caller needs no change. side: internal 'old' → `LEFT` / 'new' → `RIGHT`. The line must fall within that commit's diff, otherwise 422. +- **Review is an append event**: approve → `POST …/reviews{event:APPROVE}`; needs work → `{event:REQUEST_CHANGES, body}` (GitHub requires a body); revoke → find the current user's most recent APPROVED/CHANGES_REQUESTED review and `PUT …/reviews/{id}/dismissals`. **You cannot review your own PR** (422) → the UI greys the review button on your own authored PR. The "current status" is taken from the user's most recent decision review. +- **Comment delete / edit / reply have no version**: try inline first (`/pulls/comments/{id}` for edit/delete, `/pulls/{n}/comments/{id}/replies` for reply), and on 404/422 fall back to the issue-comment endpoints (`/issues/comments/{id}`, create a new issue comment). +- **Merge and mergeability (partial)**: `mergeable` (bool | **null**, computed async, may be null on the first fetch) + `mergeable_state` (clean/dirty/blocked/behind/unstable). There is no single endpoint for the individual veto items, so the adapter **derives an approximation** from `mergeable_state` (fidelity = partial); `null` is not treated as false but marked "computing". Merge via `PUT …/pulls/{n}/merge`. +- **Commits**: `/pulls/{n}/commits` is oldest-first, and the adapter **reverses** it to newest-first. +- **Avatars / attachments**: avatars are a direct link `<webBase>/<login>.png`; comment-embedded images are absolute URLs (user-attachments / githubusercontent / GHE host), fetched via the main-side PAT proxy (private ones need auth). +- **Token permissions**: see [Code platform config · GitHub PAT permission reference](../../guide/01-code-platform.md) (classic `repo`; fine-grained Pull requests RW + Contents RW + Metadata R). + +### 4.3 GitLab (gitlab.com + Self-Managed CE/EE, REST API v4) + +- **Base URL and host derivation**: the connection base is the **API base** (gitlab.com → `https://gitlab.com/api/v4`; self-hosted → `https://<host>/api/v4`), and may be left blank to default to the official one. The web host used for clone / attachments / web pages is derived by the adapter (take the base's host, strip `/api/v4`). Auth goes through the `PRIVATE-TOKEN` header. +- **Identity mapping**: `projectKey` = namespace (**including nested groups**, e.g. `group/subgroup`), `repoSlug` = project, `remoteId` = MR **iid**. The endpoint `:id` uses `encodeURIComponent(projectKey/repoSlug)` (GitLab accepts the URL-encoded full path as the project id). The project path is parsed from the MR web_url. +- **Discovery (three categories)**: `GET /merge_requests?scope=all&state=opened&...` (global cross-project). `discoveryFilters` = Review Requested (`reviewer_username`) / Created (`author_username`) / Assigned (`assignee_username`); GitLab has no "mentioned" concept and so excludes it. The poller polls each category, unions and tags them, and the renderer switches tabs. List items then fetch details one by one (`diff_refs` three shas + `detailed_merge_status`) + (EE) `/approvals` (approved_by → reviewer status), an N+1. +- **Comments = discussions + notes**: `GET …/discussions` returns discussions one by one, the first note as top-level and the rest as replies; `system` notes are filtered out. inline = a note with `position` (`new_path/new_line` or `old_path/old_line`). Reply goes through **discussion_id** (= `threadId`); edit / delete go through **note_id** (= `remoteId`) — the two differ, so the renderer's reply entry passes `threadId ?? remoteId` (Bitbucket/GitHub are unaffected). +- **Inline anchor needs three shas**: `POST …/discussions{body, position}`, where position contains `base/start/head_sha` (the adapter internally fetches the MR to get `diff_refs`) + `position_type:'text'` + `new_line`/`old_line` filled per side. Currently **single-line** (`inlineMultiline=false`). +- **Review (edition degradation)**: the approve/unapprove API is **Premium/Ultimate from 13.9 on**, absent in CE / EE-Free; GitLab review is binary, with **no needsWork**. `ping()` probes the edition via the `enterprise` flag of `GET /metadata` (15.2+) (older instances fall back to `/version`, conservatively treated as CE); `capabilities.reviewStatuses` = EE → `['approved','unapproved']` / CE → `[]` (UI greyed). Note: `enterprise=true` does not absolutely guarantee review is available (EE-Free lacks it), so the write path still degrades gracefully with a hint. +- **Mergeability (full fidelity)**: `detailed_merge_status` (15.6+, `mergeable` / `broken_status` / `not_approved` / `ci_must_pass` …) derives vetoes one by one + `has_conflicts` determines conflicted; older instances fall back to `merge_status`. Merge via `PUT …/merge`. +- **Commits**: `/commits` is already newest-first, no reversal needed. +- **Avatars / attachments**: avatars use the `avatar_url` direct link (PAT attached only for the same instance host); comment-embedded relative `/uploads/...` are completed to `<webBase>/<project>/uploads/...` and fetched via the PAT proxy, with no credentials sent to external hosts. --- -## 5. 扩展与注意事项 - -- **加新平台 = 新 `@meebox/platform-<name>` 包**: - - 一个实现 `PlatformTransport` 的连接 client(自管鉴权 / 分页 / host 推导 / clone); - - 四个领域服务,分别 `extends` `BaseConnection` / `BasePullRequestService` / `BaseCommentService` / `BaseMediaService`,补平台端点与映射(映射作各服务私有方法,响应类型放 `types.ts`、跨领域工具放 `utils.ts`); - - 用 `composePlatformAdapter` 组装成容器适配器; - - adapters.ts 加 case + config schema(discriminatedUnion)+ 配置 UI 放开平台选项; - - 内部包两步登记(见 [AGENTS.md](../../../AGENTS.md))。 - - 建议先用既有平台的 **adapter 契约测试**作基线,新平台过套件再开。可只实现 / 测试单个领域,不必一次补齐全部。 -- **能力位驱动 UI**:审批/合并/评论交互一律读 `capabilities` + PR 状态分支,**不出现 `if (platform === ...)`**(守住接缝)。 -- **写路径有副作用**:合并不可逆;评论发布要幂等(成功落远端 id 防重发,见 [评审闭环](03-review-workflow.md)); - 审批 / 合并远端失败要给用户明确提示(toast),不可静默。 -- **作者字段双名**:展示名(中文/真名)与登录名(英文 id)分清——展示用前者,匹配「当前用户 / 是否自己的 PR」用后者。 -- **后续未尽项**:评论「解决线程 / suggestion 应用」UI(能力位已留位,未实现);真实 GHE / GitLab Self-Managed 端到端联调。 +## 5. Extension & caveats + +- **Adding a new platform = a new `@meebox/platform-<name>` package**: + - one connection client implementing `PlatformTransport` (self-managing auth / pagination / host derivation / clone); + - four domain services, each `extends` `BaseConnection` / `BasePullRequestService` / `BaseCommentService` / `BaseMediaService`, filling in the platform endpoints and mappings (mappings as private methods of each service, response types in `types.ts`, cross-domain utilities in `utils.ts`); + - assemble them into the container adapter with `composePlatformAdapter`; + - add a case in adapters.ts + config schema (discriminatedUnion) + expose the platform option in the config UI; + - the two internal-package registration steps (see [AGENTS.md](../../../AGENTS.md)). + + It's recommended to first use an existing platform's **adapter contract test** as the baseline, and only ship the new platform once it passes the suite. You may implement / test a single domain first, without filling in all of them at once. +- **Capability flags drive the UI**: review/merge/comment interactions always branch on `capabilities` + PR status, with **no `if (platform === ...)`** (keep the seam intact). +- **The write path has side effects**: merge is irreversible; comment publishing must be idempotent (on success, record the remote id to prevent re-sending, see [Review workflow](03-review-workflow.md)); a remote failure of review / merge must give the user a clear hint (toast), never silent. +- **Author field, two names**: keep the display name (Chinese / real name) and the login name (English id) distinct — display uses the former, matching "current user / whether it's my own PR" uses the latter. +- **Remaining open items**: the comment "resolve thread / apply suggestion" UI (the capability flags are reserved, not yet implemented); real GHE / GitLab Self-Managed end-to-end integration testing. diff --git a/docs/arch/01-platform/02-repo-mirror.md b/docs/arch/01-platform/02-repo-mirror.md index 15041a24..3256c4d2 100644 --- a/docs/arch/01-platform/02-repo-mirror.md +++ b/docs/arch/01-platform/02-repo-mirror.md @@ -1,41 +1,30 @@ -# 仓库镜像与 Diff - -## 职责与边界 - -把 PR 涉及的仓库镜像到本地,供 Diff 展示、blame、以及给 pr-agent 提供工作树。所有 git 操作走 -`simple-git` + 系统 `git`。 - -负责:bare 镜像的 clone/fetch、worktree 物化、按 sha 读文件与算 diff、blame、磁盘占用统计。 -不负责:从平台 REST 抓 diff(不用,平台 diff 会截断)、评论(见 [平台适配](01-adapter.md))。 - -## 核心设计 - -- **完整 bare 镜像(`--mirror`)**:每个仓库一份 bare 镜像,含**全部 refs**。关键是 Bitbucket 把 PR 源 - sha 放在 `refs/pull-requests/<id>/from`,普通 `--bare` 拉不到会导致 `git diff base...head` 找不到 head。 - 早期试过 `--filter=blob:none` partial clone 省盘,但 blame / pr-agent 需要历史 blob 时会触发按需拉取、 - 远端不全时直接 fatal —— 故改回完整 clone,磁盘代价交给可配置的 `repos_dir`(见 [状态存储](../99-core/01-state-storage.md))。 -- **首次 clone,后续增量 fetch**:fetch 用显式 refspec 覆盖式拉 `refs/heads/*` + `refs/pull-requests/*/from`。 -- **全局串行 sync 队列**:任意时刻只有一个仓库在 clone/fetch——多个调用方(切 PR / 定时)共用同一队列, - 不并发打远端、不抢 git 带宽,进度更稳;同一仓库的并发请求复用同一 in-flight Promise。读操作不走队列。 -- **worktree 物化**:从本地 bare **`git clone --local --no-checkout`** 派生独立 repo(同盘 objects 走 hardlink, - 磁盘 ~0、跨 mount 边界也成立),再建两个内部分支 `pr-<localId>/head` / `pr-<localId>/base` 指向 PR 的 head/base sha - (localId = 每-PR 稳定主键,与 PR 关联便于追溯、不带工具品牌前缀;无 localId 时回退随机 nonce)。 - pr-agent 的 LocalGitProvider 在这个 worktree 上算 diff(见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))。 -- **Diff 不 checkout 文件**:展示 diff 只需按 sha 读 blob(`git show <sha>:<path>`)+ 改动文件列表, - 不把文件 checkout 到磁盘,省 IO。Monaco 侧按文件懒加载,二进制/超大文件跳过。 -- **出站代理**:打远端的 clone/fetch 按代理配置注入 env(见 [网络与代理](../99-core/03-networking-proxy.md));本地只读操作不注入。 - -## 数据 / 接口契约 - -- **镜像路径**:`<repos_dir>/<host>/<projectKey>/<repoSlug>/bare`。 -- **主要能力**(对主进程,按名 + 语义):`syncMirror`(建 / 增量 fetch)· `materializeWorktree`(按 head / base 物化工作树)· `hasCommit`(预检是否需 fetch)· `listChangedFiles` · `getFileContent` · `getSize` · blame。 -- **进度事件**:clone / fetch 分阶段发 `start` / `progress` / `done` / `error`,经 IPC 推渲染层显示同步进度。 - -## 扩展与注意事项 - -- **`simple-git` 的 `.env()` 整体替换子进程 env**:注入代理 env 时务必 merge `process.env`,否则丢 `PATH`/`HOME`。 -- **LFS**:仅在需要的 worktree 实例上 opt-in 允许 unsafe filter,其余读操作保持严格模式。 -- **fresh clone 后 FS 可能未 flush**(Windows 尤甚):物化后等 git 能稳定 `rev-parse HEAD` 几次再返回,避免 - 紧接着的 diff 撞上 refs/packs 不一致。 -- **磁盘是大头**:仓库镜像 GB 级,`repos_dir` 可改到大盘;设置页展示总占用,提供清理。 -- **二进制文件**:diff/读取要对非 UTF-8 内容安全跳过(pr-agent 侧也有对应处理,见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))。 +# Repo mirror & diff + +## Responsibilities & boundaries + +Mirror the repos involved in a PR to local disk, to serve diff display, blame, and to provide pr-agent with a worktree. All git operations go through `simple-git` + the system `git`. + +Responsible for: bare-mirror clone/fetch, worktree materialization, reading files and computing diffs by sha, blame, disk-usage stats. Not responsible for: fetching diffs from the platform REST (not used, platform diffs get truncated), comments (see [Platform adaptation](01-adapter.md)). + +## Core design + +- **Full bare mirror (`--mirror`)**: one bare mirror per repo, containing **all refs**. The key point is that Bitbucket puts the PR source sha under `refs/pull-requests/<id>/from`, which a plain `--bare` won't pull, causing `git diff base...head` to not find head. We tried a `--filter=blob:none` partial clone early on to save disk, but when blame / pr-agent need a historical blob it triggers on-demand fetching, and when the remote is incomplete it fatals outright — so we reverted to a full clone, leaving the disk cost to the configurable `repos_dir` (see [State storage](../99-core/01-state-storage.md)). +- **First-time clone, incremental fetch thereafter**: fetch uses an explicit refspec to overwrite-pull `refs/heads/*` + `refs/pull-requests/*/from`. +- **Global serial sync queue**: at any moment only one repo is cloning/fetching — multiple callers (PR switch / scheduled) share one queue, so there's no concurrent hammering of the remote, no contention for git bandwidth, and steadier progress; concurrent requests for the same repo reuse the same in-flight Promise. Read operations do not go through the queue. +- **Worktree materialization**: derive an independent repo from the local bare via **`git clone --local --no-checkout`** (same-disk objects go through hardlink, disk cost ~0, and it also works across mount boundaries), then create two internal branches `pr-<localId>/head` / `pr-<localId>/base` pointing at the PR's head/base shas (localId = the stable per-PR primary key, associated with the PR for traceability and carrying no tool-brand prefix; falls back to a random nonce when there is no localId). pr-agent's LocalGitProvider computes the diff on this worktree (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)). +- **Diff does not checkout files**: displaying a diff only needs reading the blob by sha (`git show <sha>:<path>`) + the list of changed files, without checking files out to disk, saving IO. The Monaco side lazy-loads per file, and skips binary / oversized files. +- **Outbound proxy**: clone/fetch that hit the remote inject env per proxy config (see [Networking & proxy](../99-core/03-networking-proxy.md)); local read-only operations do not inject. + +## Data / interface contract + +- **Mirror path**: `<repos_dir>/<host>/<projectKey>/<repoSlug>/bare`. +- **Main capabilities** (to the main process, by name + semantics): `syncMirror` (create / incremental fetch) · `materializeWorktree` (materialize a worktree by head / base) · `hasCommit` (pre-check whether a fetch is needed) · `listChangedFiles` · `getFileContent` · `getSize` · blame. +- **Progress events**: clone / fetch emit staged `start` / `progress` / `done` / `error`, pushed via IPC to the render layer to show sync progress. + +## Extension & caveats + +- **`simple-git`'s `.env()` replaces the whole subprocess env**: when injecting proxy env, be sure to merge `process.env`, otherwise you lose `PATH`/`HOME`. +- **LFS**: opt in to allowing unsafe filter only on the worktree instances that need it; keep other read operations in strict mode. +- **After a fresh clone the FS may not be flushed** (especially on Windows): after materializing, wait until git can stably `rev-parse HEAD` a few times before returning, to avoid the immediately following diff hitting refs/packs inconsistency. +- **Disk is the big cost**: repo mirrors are on the GB scale; `repos_dir` can be moved to a large disk; the settings page shows total usage and offers cleanup. +- **Binary files**: diff/read must safely skip non-UTF-8 content (the pr-agent side has corresponding handling too, see [pr-agent runtime](../02-agent/05-pragent-runtime.md)). diff --git a/docs/arch/01-platform/03-review-workflow.md b/docs/arch/01-platform/03-review-workflow.md index ce6269a2..9b0e90b4 100644 --- a/docs/arch/01-platform/03-review-workflow.md +++ b/docs/arch/01-platform/03-review-workflow.md @@ -1,54 +1,38 @@ -# 评审 → 发布闭环 - -## 职责与边界 - -从「跑一次评审」到「评论落到远端」的完整链路:命令执行 → 输出解析为结构化 findings → 草稿池 → -用户逐条确认/编辑/拒绝/手动追加 → 批量发布为 inline 评论;以及评论的 reply/edit/delete 与 PR 合并。 - -负责:评审命令编排、输出解析、草稿状态机、发布。不负责:跑 pr-agent 本身(见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))、 -平台评论 API(见 [平台适配](01-adapter.md))。 - -## 核心设计 - -- **三个命令**:`/describe`(生成 PR 描述)、`/review`(生成评审 findings)、`/ask`(自由问答)。对话式 - 交互 + **队列模型**:并发执行 ≤ `pr_agent.max_concurrency`(默认 2)条 run,其余 FIFO 排队;每个 run - 独立 worktree(路径带 nonce)+ 独立子进程,并发安全;支持中断/重试;run 状态与实时 stdout - 跨 PR 切换存活(模块级 store,不随组件卸载丢)。 -- **输出解析为 findings**:pr-agent 把结果写进 worktree 的 markdown,解析层按 section 切分,把 `/review` 的 - 「key_issues / Recommended focus areas」段展开成多条 `code-feedback` finding(每条 title + body + anchor)。 -- **anchor(file:line 定位)双信号合并**: - - 主源:嵌入式运行时补的 `get_line_link` 让 header 渲染成 `[**header**](meebox:///<file>#L<s>-L<e>)`, - 解析取其中**结构化 anchor**(path 来自 provider 同源,最可靠)。 - - 兜底:prompt 要求模型在正文附 `[file:…, lines:…]` marker;当链接只有 path、无行号时(模型没填结构化 - start/end)用 marker 的行号补全(仅同文件才借,避免错配)。 - - 取不到则 anchor 留空,UI 把「跳转编辑」按钮 disable。 -- **草稿池(不直接发布)**:`/review` 成功后 code-feedback findings 自动入草稿池作候选;用户在 Diff 内联 - (DraftZone)编辑措辞 / 拒绝 / 手动追加;**显式确认后才批量 POST 远端**。再次 `/review` 会丢弃旧的 pending - 草稿、用新结果作候选(edited/posted/rejected/manual 保留)。 -- **finding 状态机**:`pending → accepted/edited/rejected/posted`;发布成功落 `posted_remote_id` 作幂等 key, - 防重发。 -- **发布走平台 inline 评论**:批量 `publishInlineComment`,内部 finding 锚点映射成平台锚点(见 [平台适配](01-adapter.md))。 -- **评论二次操作**:reply / edit / delete(带 can-edit/can-delete 预判:只允许操作自己作者的评论,远端再校验); - PR 合并按 `mergeStatus.canMerge` 控制入口,合并不可逆、远端二次校验。 -- **token 用量落 run**:主进程逐行捕获子进程 stderr 的 `@@MEEBOX_USAGE@@` 哨兵累加(见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md)), - 写入 `ReviewRun.tokenUsage`;UI run meta 展示 ↑输入 / ↓输出。 -- **LLM 失败识别**:pr-agent 可能 exit 0 但 stdout 其实是 LLM 全失败(认证错 / 无可用模型)→ 解析层标 llmFailure, - 落 failed 而非「完成」。 - -## 数据 / 接口契约 - -仅列承载设计含义的关键字段,完整字段见各类型定义: - -| 实体 | 用途 | 关键字段 | +# Review → publish workflow + +## Responsibilities & boundaries + +The full chain from "run one review" to "comments landing on the remote": command execution → output parsed into structured findings → draft pool → the user confirms / edits / rejects / manually appends each → batch-publish as inline comments; plus comment reply/edit/delete and PR merge. + +Responsible for: review-command orchestration, output parsing, the draft state machine, publishing. Not responsible for: running pr-agent itself (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)), the platform comment API (see [Platform adaptation](01-adapter.md)). + +## Core design + +- **Three commands**: `/describe` (generate the PR description), `/review` (generate review findings), `/ask` (free-form Q&A). Conversational interaction + a **queue model**: concurrent execution of ≤ `pr_agent.max_concurrency` (default 2) runs, the rest queued FIFO; each run gets an independent worktree (path carries a nonce) + an independent subprocess, concurrency-safe; supports abort/retry; run state and live stdout survive across PR switches (a module-level store, not lost on component unmount). +- **Parsing output into findings**: pr-agent writes the result into a markdown in the worktree, and the parse layer splits by section, expanding the `/review` "key_issues / Recommended focus areas" section into multiple `code-feedback` findings (each with title + body + anchor). +- **anchor (file:line locating) merges two signals**: + - Primary source: the embedded runtime's patched `get_line_link` renders the header as `[**header**](meebox:///<file>#L<s>-L<e>)`, and parsing takes its **structured anchor** (the path comes from the same source as the provider, most reliable). + - Fallback: the prompt asks the model to append a `[file:…, lines:…]` marker in the body; when the link has only a path and no line numbers (the model didn't fill in the structured start/end), the marker's line numbers complete it (borrowed only within the same file, to avoid mismatches). + - If none can be obtained, the anchor is left empty and the UI disables the "jump to edit" button. +- **Draft pool (does not publish directly)**: after `/review` succeeds, code-feedback findings automatically enter the draft pool as candidates; the user edits the wording / rejects / manually appends inline in the diff (DraftZone); **only after explicit confirmation are they batch-POSTed to the remote**. Running `/review` again discards the old pending drafts and uses the new results as candidates (edited/posted/rejected/manual are preserved). +- **finding state machine**: `pending → accepted/edited/rejected/posted`; on successful publish, `posted_remote_id` is recorded as the idempotency key to prevent re-sending. +- **Publishing goes through platform inline comments**: batch `publishInlineComment`, internally mapping the finding anchor to the platform anchor (see [Platform adaptation](01-adapter.md)). +- **Secondary comment operations**: reply / edit / delete (with can-edit/can-delete pre-checks: only your own authored comments may be operated on, re-validated on the remote); PR merge's entry is controlled by `mergeStatus.canMerge`, merge is irreversible and re-validated on the remote. +- **Token usage lands on the run**: the main process captures the subprocess stderr's `@@MEEBOX_USAGE@@` sentinel line by line and accumulates it (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)), writing to `ReviewRun.tokenUsage`; the UI run meta shows ↑input / ↓output. +- **LLM-failure detection**: pr-agent may exit 0 while stdout is actually a full LLM failure (auth error / no available model) → the parse layer marks llmFailure and lands failed rather than "completed". + +## Data / interface contract + +Only the key fields carrying design meaning are listed; for the complete fields see each type definition: + +| Entity | Purpose | Key fields | | --- | --- | --- | -| `Finding` | 单条评审发现(草稿 / 发布的最小单元) | `anchor{path,startLine?,endLine?}`(行内锚点)· `sectionKey`(归类)· 状态机 `severity` / `status` / `draft_body` / `posted_remote_id`(幂等发布凭据) | -| `ReviewRun` | 一次评审会话(持久化于 per-PR 目录,见 [状态存储](../99-core/01-state-storage.md)) | `tool` · `status` · `model` · `findings[]` · `tokenUsage{promptTokens,completionTokens,totalTokens,calls}` · `summary` | +| `Finding` | a single review finding (the smallest unit of a draft / published item) | `anchor{path,startLine?,endLine?}` (inline anchor) · `sectionKey` (categorization) · state machine `severity` / `status` / `draft_body` / `posted_remote_id` (idempotent-publish credential) | +| `ReviewRun` | one review session (persisted in the per-PR directory, see [State storage](../99-core/01-state-storage.md)) | `tool` · `status` · `model` · `findings[]` · `tokenUsage{promptTokens,completionTokens,totalTokens,calls}` · `summary` | -## 扩展与注意事项 +## Extension & caveats -- **`/improve` 不接**:pr-agent 社区版 + LocalGitProvider 下 `publish_code_suggestions` 不可用,故闭环改走 - 「复用 `/review` 的 code-feedback finding 作 inline 候选」。 -- **anchor 覆盖率**取决于模型是否填结构化行号 + 是否输出 marker;两路都用上以最大化覆盖。历史 run 无结构化 - `tokenUsage` 时 UI 回退到从 stdout 估算。 -- **草稿语义**:再跑 `/review` 只清 pending,避免误删用户已编辑/已发布的草稿。 -- 发布是有副作用的远端写,务必走幂等(posted_remote_id)。 +- **`/improve` is not wired up**: under pr-agent community edition + LocalGitProvider, `publish_code_suggestions` is unavailable, so the loop instead goes through "reusing `/review`'s code-feedback findings as inline candidates". +- **anchor coverage** depends on whether the model fills in structured line numbers + whether it outputs a marker; both paths are used to maximize coverage. When a historical run has no structured `tokenUsage`, the UI falls back to estimating from stdout. +- **Draft semantics**: rerunning `/review` clears only pending, to avoid mistakenly deleting the user's already-edited / already-published drafts. +- Publishing is a side-effectful remote write, so it must be idempotent (posted_remote_id). diff --git a/docs/arch/01-platform/04-comment-interactions.md b/docs/arch/01-platform/04-comment-interactions.md index 1d718592..9d6fb06a 100644 --- a/docs/arch/01-platform/04-comment-interactions.md +++ b/docs/arch/01-platform/04-comment-interactions.md @@ -1,127 +1,98 @@ -# 评论互动(Comment Interactions) +# Comment interactions -PR 评论之上的三类人工互动:**emoji 反应**、**@提及自动补全**、**图片附件上传**。三者都建立在既有评论 -读写闭环(见 [评审闭环](03-review-workflow.md))与平台适配层(见 [平台适配](01-adapter.md))之上,按平台能力位显隐降级。 +Three kinds of manual interaction on top of PR comments: **emoji reactions**, **@mention autocomplete**, and **image attachment upload**. All three build on the existing comment read/write loop (see [Review workflow](03-review-workflow.md)) and the platform adaptation layer (see [Platform adaptation](01-adapter.md)), showing / hiding and degrading per platform capability flag. -## 1. 职责与边界 +## 1. Responsibilities & boundaries -- **负责**:评论的「互动增强」——给已有评论加 / 取 emoji 反应并展示聚合;撰写评论 / 回复时 `@提及` - 用户的就地补全;撰写时粘贴图片上传并回填正文。 -- **不负责**:评论本体的增删改查与回复(属评审闭环 05)、行内评论锚定(属 diff)、评论正文的 markdown - 渲染与内嵌图片代理拉取(既有能力,本域仅在上传后复用其渲染)。 -- **边界原则**:互动是**纯增益**——任一项不被平台支持、或其数据获取失败,都不得影响评论列表的正常加载与 - 展示(best-effort,失败静默降级)。 +- **Responsible for**: the "interaction enhancements" on comments — adding / removing an emoji reaction on an existing comment and showing the aggregate; in-place autocompletion of `@mention`-ing a user while writing a comment / reply; pasting an image to upload while writing, and backfilling the body. +- **Not responsible for**: the CRUD and replies of the comment body itself (belongs to review workflow 05), inline-comment anchoring (belongs to diff), markdown rendering of the comment body and the proxied fetch of embedded images (an existing capability; this domain only reuses its rendering after an upload). +- **Boundary principle**: interaction is a **pure gain** — if any item is unsupported by the platform, or its data fetch fails, it must not affect the normal loading and display of the comment list (best-effort; silently degrade on failure). -## 2. 核心设计 +## 2. Core design -### 能力位驱动的显隐降级 +### Capability-flag-driven show/hide degradation -三项各由能力位声明(`commentReactions` / `commentAttachments`,以及 @提及无需后端能力),渲染层据此 -显 / 隐入口。平台不支持即整块不出现,不在调用处写 `if (platform === …)`(沿用 01 的降级范式)。其中 -`commentReactions` 取三态 `false | 'fixed' | 'free'`——`fixed` 仅固定集(GitHub 8 种、无搜索),`free` -支持任意 emoji(精选集 + 搜索)。 +Each of the three is declared by a capability flag (`commentReactions` / `commentAttachments`, and @mention needs no backend capability), and the render layer shows / hides the entry accordingly. If the platform doesn't support it, the whole block does not appear, and no `if (platform === …)` is written at the call site (following 01's degradation paradigm). `commentReactions` takes three states `false | 'fixed' | 'free'` — `fixed` is a fixed set only (GitHub's 8, no search), `free` supports any emoji (a curated set + search). -| 能力 | GitHub | Bitbucket | GitLab | +| Capability | GitHub | Bitbucket | GitLab | | --- | --- | --- | --- | -| 反应 `commentReactions` | `'fixed'`(8 种) | `'free'`(7.x+) | `'free'` | -| 附件 `commentAttachments` | ✗(无公开上传 API) | ✓ | ✓ | -| @提及补全 | ✓(无需能力位) | ✓ | ✓ | +| Reactions `commentReactions` | `'fixed'` (8) | `'free'` (7.x+) | `'free'` | +| Attachments `commentAttachments` | ✗ (no public upload API) | ✓ | ✓ | +| @mention autocomplete | ✓ (no capability flag needed) | ✓ | ✓ | -### emoji 反应:统一 emoji 字符为中性 key +### emoji reactions: unify the emoji character as a neutral key -各平台原生反应标识互不相同——GitHub 是固定 8 种 content(`+1`/`laugh`…)、GitLab 是 award emoji 名 -(`thumbsup`…)、Bitbucket 是 emoticon shortcut(`eyes`…)。中性模型 `PrReaction` 以 **Unicode emoji -字符**为 key(`{ emoji, count, mine }`),渲染层直接绘制、跨平台一致;**原生名 ↔ emoji 的映射由各 -平台 adapter 私有持有**(它最了解自家 API)。 +Each platform's native reaction identifier differs — GitHub uses a fixed set of 8 content values (`+1`/`laugh`…), GitLab uses award emoji names (`thumbsup`…), Bitbucket uses emoticon shortcuts (`eyes`…). The neutral model `PrReaction` keys on the **Unicode emoji character** (`{ emoji, count, mine }`), so the render layer draws it directly, consistent across platforms; **the native-name ↔ emoji mapping is held privately by each platform's adapter** (which knows its own API best). -选择器按模式取候选: +The picker takes candidates by mode: -- **`fixed`(GitHub)**:用 `REACTION_PICKER`——固定 8 种、无搜索(对齐 GitHub Reactions API 上限)。 -- **`free`(GitLab / Bitbucket)**:用**内置精选大集** `REACTION_EMOJIS`(~150 个高频 emoji + 标准 shortcode + 检索关键词)+ 搜索框。 -- **原生名映射**:GitLab award 名 / Bitbucket emoticon shortname 同为标准 emoji shortcode,故 free 两端的 char↔原生名映射(`emojiToReactionCode` / `reactionCodeToEmoji`)统一从该集派生。 -- **集外 emoji**:用户经 web 用集外 emoji 反应的仍按字符**显示**(best-effort:Bitbucket 从 twemoji url 码点解、GitLab 按 award 名回查),仅 picker 不提供。 -- **后向扩展点**:要加一种反应,在 `REACTION_EMOJIS` 追加一行(emoji + 正确 shortcode + 关键词)即可,free 两端 adapter 自动生效;fixed 集另在 `REACTION_PICKER` + GitHub content 映射维护。 +- **`fixed` (GitHub)**: use `REACTION_PICKER` — a fixed set of 8, no search (aligned with the GitHub Reactions API cap). +- **`free` (GitLab / Bitbucket)**: use the **built-in curated large set** `REACTION_EMOJIS` (~150 high-frequency emoji + standard shortcode + search keywords) + a search box. +- **Native-name mapping**: GitLab award names / Bitbucket emoticon shortnames are both standard emoji shortcodes, so the char ↔ native-name mapping for both free ends (`emojiToReactionCode` / `reactionCodeToEmoji`) is uniformly derived from that set. +- **Out-of-set emoji**: a reaction a user made via the web with an out-of-set emoji is still **displayed** by its character (best-effort: Bitbucket decodes the code point from the twemoji url, GitLab looks it back up by award name), only the picker doesn't offer it. +- **Backward extension point**: to add a reaction, append a row in `REACTION_EMOJIS` (emoji + correct shortcode + keywords) and both free ends' adapters take effect automatically; the fixed set is maintained separately in `REACTION_PICKER` + the GitHub content mapping. -刻意用**内置精选集**而非全量 Unicode / 第三方大词表,原因有二: +Deliberately using a **built-in curated set** rather than the full Unicode / a third-party large lexicon, for two reasons: -- 避免打包冗余与「比实例 Twemoji 版本新的 emoji 写入静默失败」(如 Bitbucket 实测自带 Twemoji 12.1.2、~1180 个,全量词表是其超集); -- 精选集 shortcode 可控、写入可靠。 +- avoid packaging bloat and "silent write failure of an emoji newer than the instance's Twemoji version" (e.g. Bitbucket measured to ship Twemoji 12.1.2, ~1180, of which a full lexicon is a superset); +- the curated set's shortcodes are controlled and its writes are reliable. -代价是长尾 emoji 搜不到——经评估对评审场景足够(含 alien 等常用项)。 +The cost is that long-tail emoji can't be searched — assessed as sufficient for the review scenario (including common items like alien). -选择器为避免被评论滚动容器裁切 / 与其它层级 z-index 干涉,经 **portal 渲染到 body + fixed 定位**,坐标 -由触发按钮位置 + 视口空间算出(上下自适应翻转、水平夹取),并随滚动 / 缩放重算;点击弹层外部 / Esc 收起。 -「加反应」按钮置于评论操作按钮行内,已有反应另起一行展示在其下。 +To avoid being clipped by the comment scroll container / interfering with other layers' z-index, the picker is rendered **via a portal to body + fixed positioning**, with coordinates computed from the trigger button's position + viewport space (auto-flip up/down, horizontal clamp), and recomputed on scroll / zoom; clicking outside the popover / Esc dismisses it. The "add reaction" button sits inline in the comment action-button row, and existing reactions are shown on a separate line below it. -**读取的有界化**:反应聚合的获取按平台差异处理,且一律 best-effort(单条失败不拖垮列表)—— +**Bounding the reads**: fetching the reaction aggregate is handled per platform difference, and always best-effort (a single failure doesn't drag down the list) — -- GitHub:评论响应自带反应计数(counts),无需额外请求即可展示;仅「当前用户是否反应过」(`mine`) - 需补查,且**只对有反应的评论**(counts>0)发请求,额外请求数受真实反应数约束。 -- GitLab:note 不内嵌 award,须逐 note 查 award_emoji(并行);单条失败 catch 成无反应。 -- Bitbucket:反应随评论 `properties.reactions` 一并返回,**零额外请求**。 +- GitHub: comment responses carry reaction counts, so display needs no extra request; only "whether the current user has reacted" (`mine`) needs a follow-up query, and **only for comments that have reactions** (counts > 0), so the number of extra requests is bounded by the real reaction count. +- GitLab: notes don't embed awards, so award_emoji must be queried per note (in parallel); a single failure is caught as no reaction. +- Bitbucket: reactions are returned along with the comment's `properties.reactions`, **zero extra requests**. -**切换语义**:`toggleReaction(add)` 幂等——add 时重复加按成功处理;remove 时 GitHub/GitLab 需先查到 -自己那条反应的 id 再删,不存在则跳过。成功后走既有「写后清缓存 + 广播 `comments:changed` + 重拉」模型, -不维护前端乐观态(与编辑 / 删除一致)。 +**Toggle semantics**: `toggleReaction(add)` is idempotent — on add, a duplicate add is treated as success; on remove, GitHub/GitLab must first find the id of one's own reaction and then delete it, skipping if it doesn't exist. On success it follows the existing "clear cache after write + broadcast `comments:changed` + refetch" model, maintaining no frontend optimistic state (consistent with edit / delete). -### @提及:参与者候选 + 客户端补全 +### @mention: participant candidates + client-side completion -通知由平台服务端对评论正文里的 `@name` 自动完成,故**后端零改动**即已生效;本域只做撰写时的补全 UX。 +Notification is done by the platform server automatically for `@name` in the comment body, so it takes effect with **zero backend change**; this domain only handles the completion UX while writing. -候选源刻意只取**本 PR 已加载的参与者**——评论作者(含 replies 递归)+ 提交作者——按 name 去重。这是 -**有界、零额外取数、安全**的来源:不向远端枚举全员(大组织几千人,既慢又触发限流,也避免越权拉取), -候选规模约等于参与者数(通常 < 20)。输入 `@` 后按查询串客户端过滤、展示前若干条;补全仅为便利,用户 -仍可自由手打任意 `@name`(平台据文本自行解析通知)。需要更广检索时,可在此基础上叠加「平台用户搜索 -端点」层(带 query 过滤 + 分页截断),但默认不开。 +The candidate source deliberately takes only the **participants already loaded for this PR** — comment authors (including replies recursively) + commit authors — deduped by name. This is a **bounded, zero-extra-fetch, safe** source: it doesn't enumerate everyone from the remote (a large org has thousands of people, which is slow and triggers rate limits, and it also avoids over-privileged fetching), and the candidate size roughly equals the participant count (usually < 20). After typing `@`, client-side filter by the query string and show the top few; completion is merely a convenience, and the user can still freely type any `@name` (the platform parses notifications from the text itself). When broader search is needed, a "platform user-search endpoint" layer (with query filter + pagination truncation) can be stacked on top of this, but it's off by default. -### 图片附件:平台原生上传 + 既有渲染复用 +### Image attachments: platform-native upload + reuse of existing rendering -粘贴图片 → 渲染层拦截 → 经 IPC 把字节交 adapter 上传 → 回填平台返回的 markdown 到正文。各平台: +Paste an image → the render layer intercepts → hand the bytes to the adapter via IPC to upload → backfill the platform-returned markdown into the body. Per platform: -- **GitLab**:上传到项目级 `/uploads`,返回 `![file](/uploads/<secret>/<file>)`;该相对 URL 经既有 - 附件代理(走带 PAT 的 API 下载端点)渲染,无额外渲染改动。 -- **Bitbucket**:上传到仓库级 attachments 端点(multipart 字段 `files`,须带 `X-Atlassian-Token: - no-check` 绕 XSRF),用响应的 `attachment:<repoId>/<id>` 形式 markdown;既有渲染已识别 `attachment:` - 协议。 -- **GitHub**:无公开附件上传 API(web 端走未文档化私有端点),能力位为假 → 渲染层不挂粘贴上传入口。 +- **GitLab**: upload to the project-level `/uploads`, returning `![file](/uploads/<secret>/<file>)`; that relative URL renders via the existing attachment proxy (via the PAT-carrying API download endpoint), with no extra rendering change. +- **Bitbucket**: upload to the repo-level attachments endpoint (multipart field `files`, must carry `X-Atlassian-Token: no-check` to bypass XSRF), using the response's `attachment:<repoId>/<id>`-form markdown; existing rendering already recognizes the `attachment:` protocol. +- **GitHub**: no public attachment upload API (the web end uses an undocumented private endpoint), capability flag false → the render layer doesn't attach a paste-upload entry. -上传期间禁用输入框,避免异步回填时正文已被改动导致插入位置漂移。 +During upload the input box is disabled, to avoid the insertion position drifting when the async backfill lands after the body has been changed. -## 3. 数据 / 接口契约 +## 3. Data / interface contract -**核心实体 / 常量**(仅列关键形状,完整定义见类型): +**Core entities / constants** (only the key shapes are listed; for the complete definitions see the types): -| 实体 | 用途 | 形状 / 关键字段 | +| Entity | Purpose | Shape / key fields | | --- | --- | --- | -| `PrReaction` | 中性反应模型,挂 `PrComment.reactions?` | `{ emoji, count, mine }`(emoji 字符为 key) | -| `REACTION_PICKER` | `fixed` 模式候选(共享常量) | 固定 8 种 emoji 字符 | -| `REACTION_EMOJIS` | `free` 模式候选 + 搜索源 + char↔code 映射来源(共享精选集) | `{ emoji, code, keywords }[]`(~150 条) | +| `PrReaction` | neutral reaction model, hung on `PrComment.reactions?` | `{ emoji, count, mine }` (emoji character as key) | +| `REACTION_PICKER` | `fixed`-mode candidates (shared constant) | a fixed set of 8 emoji characters | +| `REACTION_EMOJIS` | `free`-mode candidates + search source + char ↔ code mapping source (shared curated set) | `{ emoji, code, keywords }[]` (~150 entries) | -**能力位**:`commentReactions: false | 'fixed' | 'free'`;`commentAttachments`(布尔)。 +**Capability flags**: `commentReactions: false | 'fixed' | 'free'`; `commentAttachments` (boolean). -**服务接口**(方法名 + 语义,不列伪签名): +**Service interfaces** (method name + semantics, no pseudo-signatures): -- `CommentService.toggleReaction`:切换某评论的某 emoji 反应(幂等);`kind`(summary / inline)供 GitHub 选 issue / review 反应端点,其余平台忽略;不支持的平台抛错。 -- `MediaService.uploadAttachment`:上传图片附件并回填 markdown——入参 `CommentAttachmentUpload`(`{ fileName, contentType, bytes }`),返回 `CommentAttachmentResult`(`{ markdown }`)或 `null`(不支持)。 +- `CommentService.toggleReaction`: toggle a given emoji reaction on a given comment (idempotent); `kind` (summary / inline) lets GitHub pick the issue / review reaction endpoint, ignored by other platforms; throws on unsupported platforms. +- `MediaService.uploadAttachment`: upload an image attachment and backfill the markdown — input `CommentAttachmentUpload` (`{ fileName, contentType, bytes }`), returning `CommentAttachmentResult` (`{ markdown }`) or `null` (unsupported). -**IPC 通道**: +**IPC channels**: -- `comments:toggleReaction`:切换反应,成功后广播 `comments:changed`。 -- `comments:uploadAttachment`:字节走 `ArrayBuffer` 传输,main 端转 `Uint8Array` 交 adapter;仅产出 markdown、不动评论缓存。 +- `comments:toggleReaction`: toggle a reaction, broadcasting `comments:changed` on success. +- `comments:uploadAttachment`: bytes are transported as an `ArrayBuffer`, which the main side converts to `Uint8Array` and hands to the adapter; it only produces markdown and doesn't touch the comment cache. -**i18n**:反应入口走 `reactions.*`,上传状态走 `attachments.*`。 +**i18n**: the reaction entry goes through `reactions.*`, upload status through `attachments.*`. -## 4. 扩展与注意事项 +## 4. Extension & caveats -- **新增平台**:实现 `toggleReaction` / `uploadAttachment`、声明对应能力位、提供本平台「原生名 ↔ emoji」 - 映射即可;不支持的项保持默认(反应抛错 / 上传返回 null),能力位置假即整块隐藏。 -- **Bitbucket 反应形状已实测核定**:`properties.reactions[].emoticon` 给 `shortcut` + `url`(twemoji - SVG,文件名即 Unicode 码点,如 `1f440.svg`),无 `value`、无 `count` 字段(计数取 `users.length`)。 - 展示 emoji **优先从 url 码点解码**(对任意 emoji 都成立),回退 shortcut 名映射。emoticon shortcut - 命名不规范(如 `smile` / `laughing`),写入(toggle)用 `REACTION_EMOJIS` 建的 char→shortcode 表, - 新增反应种类时在该精选集补行并确保 shortcode 为真实实例接受。 -- **读取一律 best-effort**:反应 / award 的补查失败必须 catch 成「无反应」,绝不冒泡中断评论列表加载。 -- **@提及不扩成全员枚举**:候选默认限本 PR 参与者;若引入平台用户搜索,须带 query 过滤 + 结果截断 + - 防抖 + 取消在途,避免拉全量与限流(见对取数安全的约束)。 -- **附件渲染依赖既有代理**:上传只负责产出 markdown,内嵌图片的鉴权拉取与展示复用既有评论图片代理; - 新平台若采用新的 URL 形态,需同步让附件代理识别。 +- **Adding a new platform**: implement `toggleReaction` / `uploadAttachment`, declare the corresponding capability flags, and provide this platform's "native-name ↔ emoji" mapping; leave unsupported items at their default (reaction throws / upload returns null), and setting the capability flag false hides the whole block. +- **Bitbucket's reaction shape has been verified empirically**: `properties.reactions[].emoticon` gives `shortcut` + `url` (a twemoji SVG whose filename is the Unicode code point, e.g. `1f440.svg`), with no `value` and no `count` field (the count is taken from `users.length`). Display prefers **decoding the emoji from the url code point** (works for any emoji), falling back to the shortcut-name mapping. Emoticon shortcut naming is irregular (e.g. `smile` / `laughing`), so writes (toggle) use the char → shortcode table built from `REACTION_EMOJIS`; when adding a new reaction kind, append a row in that curated set and ensure the shortcode is one the real instance accepts. +- **Reads are always best-effort**: a follow-up query failure of reactions / awards must be caught as "no reaction", and must never bubble up to interrupt the comment list load. +- **@mention does not expand to enumerating everyone**: candidates default to this PR's participants; if platform user search is introduced, it must carry a query filter + result truncation + debounce + in-flight cancellation, to avoid full-list fetching and rate limiting (see the constraint on fetch safety). +- **Attachment rendering depends on the existing proxy**: upload only produces markdown, and the authenticated fetch and display of embedded images reuse the existing comment-image proxy; if a new platform adopts a new URL shape, the attachment proxy must be updated to recognize it accordingly. diff --git a/docs/arch/02-agent/01-agent.md b/docs/arch/02-agent/01-agent.md index b56cfc05..7f8e06ca 100644 --- a/docs/arch/02-agent/01-agent.md +++ b/docs/arch/02-agent/01-agent.md @@ -1,174 +1,173 @@ -# Agent 与上下文 +# Agent & context -## 职责与边界 +## Responsibilities & boundaries -Agent 是「对话即委派」与「自动预评审」共同的底座:一套可被规则约束、读取本地分层上下文、自主编排 pr-agent 工具的运行时。本篇讲 **Agent 的身份与上下文**——目录分层、上下文注入、工具红线、会话隔离、提示词模版;两类用法各成一篇: +The Agent is the shared foundation of both "conversation-as-delegation" and "automatic pre-review": a runtime that can be constrained by rules, reads the local layered context, and autonomously orchestrates pr-agent tools. This doc covers **the Agent's identity and context** — directory tiers, context injection, tool mutation red line, session isolation, prompt templates. The two usage modes each get their own doc: -- **会话 Agent 化**(交互式自然语言 → 委派)见 [会话 Agent 化](02-session.md)。 -- **AutoPilot 自动预评审**(轮询触发、跨 PR 调度)见 [AutoPilot 与调度](03-autopilot.md)。 +- **Agentic sessions** (interactive natural language → delegation) — see [Agentic sessions](02-session.md). +- **AutoPilot automatic pre-review** (poll-triggered, cross-PR scheduling) — see [AutoPilot & scheduling](03-autopilot.md). -负责:Agent 上下文目录(灵魂 / 规范 / 记忆 / 用户画像 / 规则)的加载与注入、工具目录与修改性操作的授权红线、会话隔离与可写记忆的并发、提示词模版与初始化。 +Owns: loading and injecting the Agent context directory (soul / conventions / memory / user profile / rules), the tool catalog and the authorization red line for mutating operations, session isolation and concurrency of writable memory, prompt templates and initialization. -不负责:自然语言路由与规划循环(见 [会话 Agent 化](02-session.md))、AutoPilot 候选筛选与调度(见 [AutoPilot 与调度](03-autopilot.md))、pr-agent 进程本身与 token 采集(见 [pr-agent 运行时](05-pragent-runtime.md))、findings 解析与草稿发布(见 [评审闭环](../01-platform/03-review-workflow.md))、规则匹配的正则语义(见 [规则](04-rules.md);本模块只承载规则正文的存储位置 `<agent.dir>/rules/`)、PR 发现 / 软删 / 索引(见 [状态存储](../99-core/01-state-storage.md))、平台写操作 API(见 [平台适配](../01-platform/01-adapter.md))。 +Does not own: natural-language routing and the planning loop (see [Agentic sessions](02-session.md)), AutoPilot candidate filtering and scheduling (see [AutoPilot & scheduling](03-autopilot.md)), the pr-agent process itself and token collection (see [pr-agent runtime](05-pragent-runtime.md)), findings parsing and draft publishing (see [Review workflow](../01-platform/03-review-workflow.md)), the regex semantics of rule matching (see [Rules](04-rules.md); this module only holds the storage location of rule bodies, `<agent.dir>/rules/`), PR discovery / soft-delete / index (see [State storage](../99-core/01-state-storage.md)), platform write-operation APIs (see [Platform adaptation](../01-platform/01-adapter.md)). -> 与 [规则系统](04-rules.md) 的关系:规则正文存于 Agent 目录的 `rules/` 子目录(`<agent.dir>/rules/`);其「一文件一规则 + frontmatter 匹配 + 取全部命中(封顶 N 条)按 Ruleset 分段拼接 + per-tool 注入 `EXTRA_INSTRUCTIONS`」的匹配语义由 [规则](04-rules.md) 定义,本模块只负责加载与注入。 +> Relationship to the [rules system](04-rules.md): rule bodies live in the `rules/` subdirectory of the Agent directory (`<agent.dir>/rules/`); their matching semantics — "one file one rule + frontmatter matching + take all matches (capped at N) concatenated in Ruleset segments + per-tool injection into `EXTRA_INSTRUCTIONS`" — are defined by [Rules](04-rules.md). This module only handles loading and injection. -## 核心设计 +## Core design -### Agent 目录:分层上下文 +### Agent directory: layered context -Agent 目录是 Agent 的**完整人格与知识来源**,挂载于配置 `agent.dir`(路径,空 = 回落默认位置 `~/.code-meeseeks/agent`);**无独立启用开关**——配了 LLM 且 pr-agent 就绪即可用。与应用数据解耦,可指向独立目录或团队 git repo。 +The Agent directory is the Agent's **complete persona and knowledge source**, mounted at the config `agent.dir` (a path; empty = fall back to the default location `~/.code-meeseeks/agent`). It has **no separate enable switch** — it works once an LLM is configured and pr-agent is ready. It is decoupled from application data and can point at a standalone directory or a team git repo. -目录约定(缺任一文件不阻断,缺则该层上下文为空): +Directory convention (a missing file is not blocking; if missing, that context tier is empty): ``` <agent.dir>/ -├── SOUL.md # 灵魂:核心职责、工作边界、语气基调(Agent 只读·默认由预制模版规定) -├── AGENTS.md # 工作规范:评审流程、AutoPilot 触发策略、工具使用红线(人写) -├── MEMORY.md # 长期记忆:跨 PR / 跨会话的事实沉淀(Agent 可追加,人可编辑) -├── USER.md # 用户画像:评审偏好与个人习惯(Agent 可追加,人可编辑) -├── README.md # 目录说明:各文件用途与项目地址引导(人用第三方 IDE 维护,非 Agent 上下文) -└── rules/ # 规则化注入:「一文件一规则 + frontmatter」(人写,见 04-rules) - └── example.md # 首次播种的禁用示例规则,删除后不补 +├── SOUL.md # Soul: core responsibilities, work boundaries, tone (Agent read-only · by default defined by the prebuilt template) +├── AGENTS.md # Work conventions: review flow, AutoPilot trigger policy, tool-use red line (human-written) +├── MEMORY.md # Long-term memory: facts accumulated across PRs / sessions (Agent may append, human may edit) +├── USER.md # User profile: review preferences and personal habits (Agent may append, human may edit) +├── README.md # Directory description: purpose of each file + pointer to the project (maintained by the human in a third-party IDE, not Agent context) +└── rules/ # Rule-based injection: "one file one rule + frontmatter" (human-written, see 04-rules) + └── example.md # A disabled example rule seeded once; not restored after deletion ``` -关键取舍: - -- **分层而非单文件**:`SOUL` 定职责边界(恒定)、`AGENTS` 定流程与红线(恒定)、`rules/` 定逐 PR - 命中的细则(结构化、可正则匹配)、`MEMORY` / `USER` 是**可写记忆**(Agent 在工作中沉淀、人可校订)。 -- **`SOUL.md` 对 Agent 只读**:灵魂是 Agent 自身无权改写的「宪法」——**禁止 Agent 修改 `SOUL.md`**, - 默认情况下其内容**完全由预制模版规定**(初始化时落地,见下「提示词模版与资源目录」)。约束在运行时强制:装配上下文时 - `SOUL.md` 只读注入,Agent 工具目录里没有写 `SOUL.md` 的能力;即便 LLM - 越权产出对它的写操作也被拒(与下「工具规范」修改类红线同源)。这样 Agent 无法自我重定义职责与边界。 - 仅人(或团队 git repo 的维护者)可改 `SOUL.md`。 -- **读写边界清晰**:`SOUL` 仅人可改(Agent 只读);`AGENTS` / `rules/` 人写为主;`MEMORY` / `USER` - 是 Agent 与人共写的可写记忆。 -- **整目录团队共享**:与 [规则](04-rules.md) 同理——把 `agent.dir` 指向一个 git repo,团队 clone - 即同一套灵魂 / 规范 / 规则。`MEMORY` / `USER` 虽可写,但仍属共享上下文(跨 PR 生效), - 写入走原子写(见下「会话隔离」)。 -- **空目录 = 退化为原生**:`agent.dir` 为空(未配置 Agent 目录)时,Agent 运行时降级—— - 自然语言回退到等价 `/ask`、AutoPilot 不可用、pr-agent 走原生行为。保证「不配置也能用」。 - -### 上下文注入:每次执行装配最新内容 - -**每次 Agent 执行都现读、现装配,无缓存**(与 [规则](04-rules.md)「每次 run 现读规则」一致), -确保用户刚改完 `SOUL.md` / 新写一条 MEMORY 立即生效。Agent 目录是寥寥几个小 Markdown, -现读开销在毫秒级、相对一次数秒的 LLM 调用可忽略;且天然 stale-proof——`agent.dir` 常指向团队 git -repo,外部 `git pull` 在应用之外发生,现读总能拿到最新。故**不引入内存缓存 / -文件监听作为加载权威**:监听器(跨平台可靠性坑、自写 `MEMORY/USER` 反触发回环)的收益主要是 UI -反应性而非 run 路径性能,可作为后续旁路信号(通知渲染层刷新「当前命中规则」chip), -但绝不让正确性依赖它。 - -一次装配的系统上下文按固定次序拼接: - -1. `SOUL.md` 正文 —— 人格与边界。 -2. `AGENTS.md` 正文 —— 工作规范与红线。 -3. **工具目录(tool catalog)** —— 环境内预定义的工具指令(`/describe`·`/review`·`/ask` 等)的名称、 - 语义、参数与**可用性标记**(读类 / 修改类),由运行时**注入**而非写死在提示词里。 - 新增工具只需在目录登记即对 Agent 可见。 -4. 命中的 `rules/` 规则正文 —— 按当前 PR 上下文 `{projectKey, repoSlug, targetBranch, tool}` - 匹配取首条(见 [规则](04-rules.md))。 -5. `MEMORY.md` + `USER.md` 正文 —— 长期记忆与用户画像。 -6. **当前 PR 元数据** —— 标题 / 描述 / 目标分支 / 变更概况。 -7. **当前会话快照** —— 本 PR 的 todo 与进度(见 [会话 Agent 化](02-session.md)),让 Agent 续上未完成的规划。 -8. **语言行为指令** —— 执行时注入的显式国际化规则,覆盖两类语言行为: - - **AI 输出语言**:Agent / 评审产物用目标语言输出,跟随 `config.language` / - `resolveLanguage`(沿用既有「AI 回复语言随界面语言」,见 [pr-agent 运行时](05-pragent-runtime.md) - 的响应语言注入、[i18n](../03-gui/04-i18n.md))。 - - **记忆写入语言**:Agent 向 `MEMORY.md` / `USER.md` **追加新记忆时用用户习惯语言记录**(默认取 - `config.language`,可由 `USER.md` 已记录的语言偏好细化),便于用户日后阅读自己的记忆。 - 这条写入行为规则**必须显式写进提示词**——否则 Agent 可能按模版的 en-US 或随机语言落记忆。 - -**三个语言概念解耦**(三者独立): - -1. **模版 / 上下文文件写成什么语言**:en-US 单份、用户可改写(见下「提示词模版与资源目录」)。 -2. **AI 输出语言**:跟随 `config.language`。 -3. **记忆写入语言**:用户习惯语言。 - -`SOUL.md` 可以是英文,输出与新记忆仍按用户语言走中文;反之亦然——由第 8 项这组执行时规则 -单点控制输出与写入两类行为。 - -工具目录的「可用性标记」是红线落地的关键(见下「工具规范」):修改类工具在未授权时以**禁用态**注入, -Agent 知其存在但不可调用。 - -### 工具规范:修改性操作红线 - -工具目录按副作用分两类,运行时**硬性**区别对待(不只靠提示词约束): - -- **读 / 分析类**(`/describe`·`/review`·`/ask`、读 diff、读 findings、读 PR 列表等):Agent - 始终可自主调用。注意 `/describe`·`/review` 本身只产出本地草稿、不写远端,属安全操作。 -- **修改类**(`/approve`、`/needswork`、发布 inline 评论、reply/edit/delete、合并 PR - 等一切对远端有副作用的写):**默认禁止 Agent 自主调用**。仅在两种授权下放行: - 1. **用户直接下达指令**(在会话里显式要求执行该操作); - 2. **规则显式授权**(`AGENTS.md` / `rules/` 中明确授予 AutoPilot 某项写权限,见 [AutoPilot 与调度](03-autopilot.md) 的「写权限扩展」)。 - -红线在运行时层强制:修改类工具在无授权时以**禁用态**注入工具目录,且执行入口二次校验授权标志——即便 -LLM「越权」产出一个 `/approve` 调用,运行时也拒绝并记入 transcript。这样「提示词被绕过」 -不等于「操作被执行」。 - -**工具清单单一真相源**:所有工具(id / 命令名 / 读改分类 / grant / 是否运行队列工具)集中声明在共享层的 -**统一注册表 `TOOLS`(tool-registry)**;运行工具枚举 `ReviewRunTool`、工具目录 `buildToolCatalog`、规划红线 -允许集均由它派生——新增 / 调整工具只改注册表一处。 - -### 会话隔离与规则共享 - -- **规则 / 上下文共享**:`agent.dir`(SOUL / AGENTS / MEMORY / USER / rules)是**全局单份**,所有 - PR 的 Agent 会话读同一套。改一处,处处生效。 -- **会话隔离**:每个 PR 的 Agent 会话状态(todo、进度、plan、transcript)**按 PR 隔离**,落在该 PR - 的 per-PR 目录下(见 [状态存储](../99-core/01-state-storage.md) 的 `state/prs/<hash>/`),互不串扰。不同 PR 并发跑 - Agent 安全。 -- **可写记忆的并发**:`MEMORY.md` / `USER.md` 是跨 PR 共享的可写文件,多个会话可能同时追加 → 走 - StateStore 同款**原子写(tmp → fsync → rename)**、Main 进程单写者串行化; - 追加语义优先(不整文件覆盖),降低并发互覆风险。 - -### 提示词模版与资源目录 - -- **工程内预建模版**:仓库内置一套默认 `SOUL.md` / `AGENTS.md` / `MEMORY.md` / `USER.md` / `README.md` - 与示例 `rules/`,作为 Agent 目录的**初始化骨架**。`README.md` 是面向用户的目录说明(各文件用途 + 指向项目 - GitHub 的引导),供用户用第三方 IDE 阅读 / 维护,不作为 Agent 上下文注入。 -- **模版统一 en-US 单份、不做 i18n**:模版是用户的**著作内容**而非产品 UI,故不提供多语变体——一律以 - **en-US** 落地(与项目 en-US 兜底一致)。用户初始化后可自由改写成目标语言(中文 / 日文 …); - 改的是自己的上下文文件,与 AI 输出语言互不绑定(输出语言由上「上下文注入」第 8 项的执行时国际化规则单点控制)。 -- **统一资源目录管理**:模版集中放在桌面应用的**单一资源目录**下, - 随应用打包(与嵌入式运行时等资源同级管理),由初始化逻辑按清单拷贝;不散落在各处。 -- **初始化时机与三类所有权**:「用时初始化」——每次加载前都先 scaffold 一次(不依赖首启 / 设置交互这类一次性时机), - 按文件所有权分三类处理: - - **用户所有(缺失即创建、不覆盖)**:`AGENTS.md` / `MEMORY.md` / `USER.md` / `README.md`——可编辑 Markdown, - 用户与 Agent 后续按各自权限改写;删除后下次 scaffold 会补回(保证基础骨架与目录说明长在)。 - - **应用所有(强制对齐模版)**:`SOUL.md`——见下条。 - - **首次播种(仅首次落地、删后不补)**:`rules/example.md`——示例规则**非必需**,仅在 Agent 目录**首次脚手架** - (以 `rules/` 子目录尚不存在判定)时播一份;用户删掉即永久消失,不会每次启动被「复活」。 -- **`SOUL.md` 默认由模版规定**:灵魂的内容**默认完全来自预制模版**(初始化落地的就是模版正文), - Agent 全程无权改写(见上「Agent 目录」)。这把「Agent 是谁、边界在哪」的定义权牢牢留在模版 / 维护者侧; - 个人或团队若要定制,仍由人去改 `agent.dir` 里的 `SOUL.md`(或在团队 git repo 中统一维护), - 而非交给 Agent 自演化。 - -## 数据 / 接口契约 - -**配置(`agent.*` 命名空间)**:完整字段与默认值见 [配置与凭据](../99-core/02-config-and-secrets.md) 的配置形状,本篇只点设计要点: - -- **无独立启用开关**:配了 LLM 且 pr-agent 就绪即可用;`agent.dir` 空 = 回落默认位置(非停用)。 -- `strategy.max_followup_asks`(条件性 `/ask` 硬上限)归 `strategy` 而非 `autopilot`——手动自动评审与 AutoPilot 共用同一微流程(见 [会话 Agent 化](02-session.md)、[AutoPilot 与调度](03-autopilot.md))。 -- `autopilot.grants` 为逐项写权限授权(默认全空 = 全拒)。 - -**Agent 目录文件清单**:`SOUL.md` / `AGENTS.md` / `MEMORY.md` / `USER.md` / `rules/*.md`(rules 的 -frontmatter schema 见 [规则](04-rules.md));另含 `README.md`(用户向目录说明,非注入上下文)与首次播种的 -`rules/example.md`(禁用示例,删后不补)。注入上下文仅取前者中的 `SOUL/AGENTS/MEMORY/USER` + 命中规则正文。 - -**`ToolCatalogEntry`**:`name` / `semantics` / `params` / `mutating`(bool) / `enabled`(按授权)——工具目录注入用,红线据 `mutating` + `enabled` 落地。 - -## 扩展与注意事项 - -- **红线是硬约束、非软提示**:修改类工具的授权校验必须落在运行时执行入口,不能只写进 `SOUL.md` 期望 - LLM 自觉;提示词与运行时双保险,运行时为准。 -- **可写记忆的失控风险**:Agent 持续往 `MEMORY.md` / `USER.md` 追加可能膨胀 / 噪声化 → - 需有体量上限或回收策略(后续可加 housekeeping),并保持人可随时编辑校订。 -- **语言三分**(三者解耦,别把「文件写成什么语言」与「输出 / 记忆用什么语言」绑死): - - **模版 / 上下文文件**:统一 en-US 单份、不做 i18n(用户可改写成任意语言)。 - - **AI 输出语言**:由执行时注入的国际化规则控制,跟随 `config.language`。 - - **记忆写入语言**:Agent 追加 `MEMORY.md` / `USER.md` 时用用户习惯语言记录,此行为规则须 - 显式注入提示词(见上「上下文注入」第 8 项、[i18n](../03-gui/04-i18n.md))。 -- **规则匹配语义与语言无关**。 -- **后续可扩展**:Agent 规划器可接本机 agentic CLI(claude / codex 等,复用 - [pr-agent 运行时](05-pragent-runtime.md) 的本地 CLI provider 思路)作为编排大脑; - 工具目录可纳入更多只读分析工具(按 changed_paths 聚焦、跨 PR 关联等)而不动红线框架。 +Key trade-offs: + +- **Layered rather than single-file**: `SOUL` defines responsibility boundaries (constant), `AGENTS` defines the flow and red line (constant), `rules/` defines the per-PR + matched detail rules (structured, regex-matchable), `MEMORY` / `USER` are **writable memory** (the Agent accumulates them during work, the human can revise). +- **`SOUL.md` is read-only to the Agent**: the soul is a "constitution" the Agent itself has no right to rewrite — **the Agent is forbidden to modify `SOUL.md`**, + and by default its content is **fully defined by the prebuilt template** (materialized at initialization, see "Prompt templates & resource directory" below). The constraint is enforced at runtime: when assembling context, + `SOUL.md` is injected read-only, and the Agent's tool catalog has no capability to write `SOUL.md`; even if the LLM + overreaches and produces a write operation against it, that is rejected (same source as the mutating red line under "Tool conventions" below). This way the Agent cannot redefine its own responsibilities and boundaries. + Only a human (or the maintainer of the team git repo) can change `SOUL.md`. +- **Clear read/write boundary**: `SOUL` is human-changeable only (Agent read-only); `AGENTS` / `rules/` are primarily human-written; `MEMORY` / `USER` + are writable memory co-written by Agent and human. +- **Whole-directory team sharing**: same idea as [Rules](04-rules.md) — point `agent.dir` at a git repo, and a team clone + gets the same soul / conventions / rules. Although `MEMORY` / `USER` are writable, they remain shared context (in effect across PRs); + writes go through atomic write (see "Session isolation" below). +- **Empty directory = degrade to native**: when `agent.dir` is empty (no Agent directory configured), the Agent runtime degrades — + natural language falls back to the equivalent `/ask`, AutoPilot is unavailable, and pr-agent uses native behavior. This guarantees "usable even without configuration". + +### Context injection: assemble the latest content on every run + +**Every Agent run reads fresh and assembles fresh, with no cache** (consistent with [Rules](04-rules.md)'s "read rules fresh on each run"), +ensuring that a user who just edited `SOUL.md` / wrote a new MEMORY entry sees it take effect immediately. The Agent directory is a handful of small Markdown files; +the fresh-read cost is milliseconds, negligible against an LLM call that takes seconds; and it is naturally stale-proof — `agent.dir` often points at a team git +repo, and an external `git pull` happens outside the app, so a fresh read always gets the latest. Hence we **do not introduce an in-memory cache / +file watcher as the loading authority**: a watcher (cross-platform reliability pitfalls, self-writing `MEMORY/USER` re-triggering a loop) mainly benefits UI +reactivity rather than run-path performance, and may serve later as a side-channel signal (notifying the render layer to refresh the "currently matched rules" chip), +but correctness must never depend on it. + +One assembly of the system context is concatenated in a fixed order: + +1. `SOUL.md` body — persona and boundaries. +2. `AGENTS.md` body — work conventions and the red line. +3. **Tool catalog** — the name, + semantics, params and **availability flag** (read-type / mutating) of the predefined tool instructions in the environment (`/describe` · `/review` · `/ask`, etc.), **injected** by the runtime rather than hard-coded into the prompt. + Adding a tool only requires registering it in the catalog for the Agent to see it. +4. Matched `rules/` bodies — matched against the current PR context `{projectKey, repoSlug, targetBranch, tool}`, + taking the first match (see [Rules](04-rules.md)). +5. `MEMORY.md` + `USER.md` bodies — long-term memory and user profile. +6. **Current PR metadata** — title / description / target branch / change overview. +7. **Current session snapshot** — this PR's todos and progress (see [Agentic sessions](02-session.md)), so the Agent can resume unfinished planning. +8. **Language behavior directives** — explicit i18n rules injected at execution time, covering two kinds of language behavior: + - **AI output language**: the Agent / review artifacts output in the target language, following `config.language` / + `resolveLanguage` (continuing the existing "AI reply language follows the UI language"; see the response-language injection in [pr-agent runtime](05-pragent-runtime.md) + and [i18n](../03-gui/04-i18n.md)). + - **Memory-write language**: when the Agent **appends new memory to `MEMORY.md` / `USER.md`, it records in the user's habitual language** (defaults to + `config.language`, and can be refined by a language preference already recorded in `USER.md`), so the user can later read their own memory. + This write-behavior rule **must be written explicitly into the prompt** — otherwise the Agent may record memory in the template's en-US or a random language. + +**Three decoupled language concepts** (all three independent): + +1. **What language the template / context files are written in**: a single en-US copy, user-editable (see "Prompt templates & resource directory" below). +2. **AI output language**: follows `config.language`. +3. **Memory-write language**: the user's habitual language. + +`SOUL.md` may be in English while output and new memory still follow the user's language, e.g. Chinese; and vice versa — the set of execution-time rules in item 8 +is the single point of control over both output and write behaviors. + +The tool catalog's "availability flag" is the key to enforcing the red line (see "Tool conventions" below): a mutating tool, while unauthorized, is injected in a **disabled state**, +so the Agent knows it exists but cannot call it. + +### Tool conventions: the red line for mutating operations + +The tool catalog is split into two classes by side effect, treated **hard-differently** at runtime (not just by prompt constraint): + +- **Read / analysis type** (`/describe` · `/review` · `/ask`, reading diff, reading findings, reading the PR list, etc.): the Agent + may always invoke them autonomously. Note that `/describe` · `/review` themselves only produce local drafts and do not write the remote — they are safe operations. +- **Mutating type** (`/approve`, `/needswork`, publishing an inline comment, reply/edit/delete, merging a PR, + and any write with a side effect on the remote): **by default the Agent is forbidden to invoke them autonomously**. They are released under only two authorizations: + 1. **The user issues a direct instruction** (explicitly asking for that operation in the session); + 2. **A rule grants it explicitly** (`AGENTS.md` / `rules/` explicitly grant AutoPilot some write permission, see "Write permission extension" in [AutoPilot & scheduling](03-autopilot.md)). + +The red line is enforced at the runtime layer: a mutating tool, while unauthorized, is injected into the tool catalog in a **disabled state**, and the execution entry re-checks the authorization flag — so even if the +LLM "overreaches" and produces an `/approve` call, the runtime rejects it and records it in the transcript. This way "the prompt was bypassed" +does not equal "the operation was executed". + +**Single source of truth for the tool list**: all tools (id / command name / read-vs-mutate classification / grant / whether it is a run-queue tool) are declared centrally in the +**unified registry `TOOLS` (tool-registry)** in the shared layer; the run-tool enum `ReviewRunTool`, the tool catalog `buildToolCatalog`, and the planning red line's +allow-set are all derived from it — adding / adjusting a tool changes only the one registry. + +### Session isolation & rule sharing + +- **Rule / context sharing**: `agent.dir` (SOUL / AGENTS / MEMORY / USER / rules) is a **single global copy**; all + PRs' Agent sessions read the same set. Change one place, it takes effect everywhere. +- **Session isolation**: each PR's Agent session state (todo, progress, plan, transcript) is **isolated per PR**, landing under that PR's + per-PR directory (see `state/prs/<hash>/` in [State storage](../99-core/01-state-storage.md)), with no cross-talk. Running Agents concurrently across different PRs is safe. +- **Concurrency of writable memory**: `MEMORY.md` / `USER.md` are writable files shared across PRs, and multiple sessions may append simultaneously → they go through the + same **atomic write (tmp → fsync → rename)** as StateStore, serialized by the single-writer Main process; + append semantics take priority (no whole-file overwrite), reducing the risk of concurrent mutual overwrite. + +### Prompt templates & resource directory + +- **Prebuilt templates in the repo**: the repo ships a default set of `SOUL.md` / `AGENTS.md` / `MEMORY.md` / `USER.md` / `README.md` + plus an example `rules/`, as the **initialization skeleton** of the Agent directory. `README.md` is a user-facing directory description (purpose of each file + a pointer to the project's + GitHub) for the user to read / maintain in a third-party IDE; it is not injected as Agent context. +- **Templates are a single en-US copy, no i18n**: the templates are the user's **authored content**, not product UI, so no multilingual variants are provided — they all land as + **en-US** (consistent with the project's en-US fallback). After initialization the user may freely rewrite them into a target language (Chinese / Japanese …); + they are editing their own context files, decoupled from the AI output language (the output language is controlled at a single point by the execution-time i18n rule in item 8 of "Context injection" above). +- **Unified resource-directory management**: the templates are placed centrally under the desktop app's **single resource directory**, + packaged with the app (managed alongside resources such as the embedded runtime), and copied per manifest by the initialization logic; not scattered around. +- **Initialization timing and three ownership classes**: "initialize on use" — scaffold once before every load (not relying on one-off timing such as first launch / settings interaction), + handled in three classes by file ownership: + - **User-owned (create if missing, never overwrite)**: `AGENTS.md` / `MEMORY.md` / `USER.md` / `README.md` — editable Markdown, + later rewritten by user and Agent per their respective permissions; if deleted, the next scaffold restores it (guaranteeing the base skeleton and directory description persist). + - **App-owned (force-aligned to the template)**: `SOUL.md` — see the next item. + - **Seeded once (landed only on first run, not restored after deletion)**: `rules/example.md` — the example rule is **not required**, seeded only on the Agent directory's **first scaffold** + (determined by the `rules/` subdirectory not yet existing); once the user deletes it, it disappears permanently and is not "revived" on every startup. +- **`SOUL.md` is defined by the template by default**: the soul's content **by default comes entirely from the prebuilt template** (what initialization lands is the template body), + and the Agent has no right to rewrite it throughout (see "Agent directory" above). This keeps the authority to define "who the Agent is and where its boundaries are" firmly on the template / maintainer side; + a person or team wanting to customize still has a human change `SOUL.md` inside `agent.dir` (or maintain it uniformly in the team git repo), + rather than handing it to Agent self-evolution. + +## Data / interface contract + +**Config (`agent.*` namespace)**: for the full fields and defaults see the config shape in [Config & secrets](../99-core/02-config-and-secrets.md); this doc only highlights the design points: + +- **No separate enable switch**: it works once an LLM is configured and pr-agent is ready; `agent.dir` empty = fall back to the default location (not disabled). +- `strategy.max_followup_asks` (the hard cap on conditional `/ask`) belongs to `strategy`, not `autopilot` — manual auto review and AutoPilot share the same micro-flow (see [Agentic sessions](02-session.md), [AutoPilot & scheduling](03-autopilot.md)). +- `autopilot.grants` is per-item write-permission grants (empty by default = all denied). + +**Agent directory file list**: `SOUL.md` / `AGENTS.md` / `MEMORY.md` / `USER.md` / `rules/*.md` (the rules' +frontmatter schema is in [Rules](04-rules.md)); plus `README.md` (user-facing directory description, not injected context) and the seeded-once +`rules/example.md` (a disabled example, not restored after deletion). The injected context takes only `SOUL/AGENTS/MEMORY/USER` from the former + the matched rule bodies. + +**`ToolCatalogEntry`**: `name` / `semantics` / `params` / `mutating`(bool) / `enabled`(per authorization) — used for tool-catalog injection; the red line is enforced from `mutating` + `enabled`. + +## Extension & caveats + +- **The red line is a hard constraint, not a soft hint**: the authorization check for mutating tools must land at the runtime execution entry; it cannot merely be written into `SOUL.md` hoping the + LLM behaves. Prompt and runtime are dual insurance; the runtime is authoritative. +- **Runaway risk of writable memory**: the Agent continually appending to `MEMORY.md` / `USER.md` may bloat / add noise → + a size cap or recycling policy is needed (housekeeping can be added later), while keeping it human-editable at any time. +- **Language triad** (all three decoupled; don't bind "what language the file is written in" to "what language output / memory uses"): + - **Template / context files**: a single en-US copy, no i18n (the user may rewrite into any language). + - **AI output language**: controlled by the i18n rule injected at execution time, following `config.language`. + - **Memory-write language**: when appending to `MEMORY.md` / `USER.md` the Agent records in the user's habitual language; this behavior rule must be + injected into the prompt explicitly (see item 8 of "Context injection" above, and [i18n](../03-gui/04-i18n.md)). +- **Rule matching semantics are language-independent**. +- **Extensible later**: the Agent planner can hook into a local agentic CLI (claude / codex, etc., reusing the local CLI provider approach of + [pr-agent runtime](05-pragent-runtime.md)) as the orchestration brain; + the tool catalog can take in more read-only analysis tools (focus by changed_paths, cross-PR correlation, etc.) without touching the red-line framework. diff --git a/docs/arch/02-agent/02-session.md b/docs/arch/02-agent/02-session.md index 6d42af73..f05aaa78 100644 --- a/docs/arch/02-agent/02-session.md +++ b/docs/arch/02-agent/02-session.md @@ -1,175 +1,173 @@ -# 会话 Agent 化 - -## 职责与边界 - -把渲染层的自然语言输入从「直接落 `/ask`」升级为「交给 Agent 运行时委派」:读本地 Agent 上下文(见 [Agent 与上下文](01-agent.md))、自主规划任务、按需编排多个 pr-agent 工具、把思考与结果留存输出。直接的斜杠工具指令(`/describe`·`/review`·`/ask`)仍维持「直达工具」语义、不经 Agent。 - -负责:输入路由(自然语言 vs 直达工具)、Agent 规划循环(ReAct)、过程留存(todo / transcript / 进度)、交互控制(一键自动评审 / Stop 暂停保态 / Continue 续跑)、运行态与既有队列共享。 - -不负责:Agent 目录与上下文装配、工具红线(见 [Agent 与上下文](01-agent.md))、轮询触发的自动预评审与跨 PR 调度(见 [AutoPilot 与调度](03-autopilot.md))、findings 解析与草稿发布(见 [评审闭环](../01-platform/03-review-workflow.md))。 - -## 核心设计 - -### 输入路由 - -在渲染层输入解析处分流: - -- `/describe`、`/review` 开头 → **直达工具**,维持既有「忽略其余文本、直接跑该 tool」语义。 -- `/ask <text>` → **直达工具**,文本作为 question 直跑 `/ask`。CLI 模式(claude/codex)下 `/ask` 会把 CLI - 子进程 cwd 落到(已净化的)一次性 worktree,取完整文件上下文作答——而非像 describe/review 只基于 diff - 在中性临时目录推理;机制(`MEEBOX_CLI_WORKDIR` + worktree 指令文件净化防注入)见 [pr-agent 运行时](05-pragent-runtime.md)。 - - **结构化分段输出**:提示词约束 `/ask` 按确定性标签 `<summary>` / `<analysis>` / `<suggestions>` 输出 - (pr-agent-bridge `prompts.ts`),解析层(poller `parseStructuredAsk`)按标签切成独立 finding:summary - 结论高亮展开、analysis 过程默认收起、suggestions 建议高亮。模型未遵循 / 无标签时整体回退普通解析。 - - **复评引用闭环**:review/improve 的 code finding 卡片可点「引用」→ 挂到输入栏发起复评 `/ask`(携带 - `referencedFinding` + finding 正文上下文)。复评模式额外产出 `<verdict>`(replace / keep / drop,落 - `ReviewRun.askVerdict`),结果卡出裁决 + **手动**采纳/关闭动作:采纳取代 → 建新评论草稿锚定原位置 + - 关闭原 finding;关闭关系独立存于 `findingClosures`(非草稿语义),原卡转关闭态并与复评卡互链。 - - **agent 自动评审自动关联 / 取代**:自动评审微流程里 judge 可对某条 review finding 出复评追问 - (`asks[].targetFindingId`,judge prompt 给 id 可寻址清单),asks 步以复评模式派发该追问(携 - `referencedFinding`),裁决 replace/drop 时**自动**建 `FindingClosure` 关闭被取代的原 finding(经 - `ReviewOrchestratorDeps.closeFinding`)。默认开启、保守(仅点名 + replace/drop 才关,keep / 未点名不动); - 新评论不自动落草稿,仍由用户在复评卡手动「采纳」。 -- 其余直接的工具 / 操作指令 → 维持各自原有调用。 -- **无斜杠的自然语言** → **交给 Agent 运行时**(旧行为是等价 `/ask`,此为本模块的核心改动)。 -- 未知 `/xxx` → 报错(不变)。 - -### Agent 规划循环 - -Agent 运行时是一层位于既有运行队列之上的编排器,拥有独立的 LLM 通道(复用 LLM -Profile 凭据、出站代理与 token 采集;见 [pr-agent 运行时](05-pragent-runtime.md)、[网络与代理](../99-core/03-networking-proxy.md))。一次会话: - -1. 读上下文(见 [Agent 与上下文](01-agent.md))→ 产出 / 更新 **任务清单(todo)**,落盘到本 PR 工作目录。 -2. 逐步执行:每步或是一次规划 / 判断的 LLM 调用,或是一次工具调用(把 `/describe`·`/review`·`/ask` - 作为「工具」**入既有运行队列**执行,复用 worktree、并发与取消)。 -3. 每步的**思考摘要**与**工具调用结果**写入会话 transcript,并实时流式推送渲染层;todo - 项随之标记完成、进度落盘。 -4. 满足完成条件或触达步数上限即收尾。 - -**工具选择与路由:哪些决策在「同一次 LLM 调用」内完成**:自由规划 Agent(自然语言入口)是一个 -ReAct 循环——**每一轮就是一次编排级 LLM 调用**(一个 `AgentStep`):输入「上下文、既往多轮对话、 -至此进展」,输出一段 **JSON 动作**。关键边界是——**路由、工具选择、收尾、记忆这几类「决策」全在 -这一次调用内拍板**;真正耗时的是动作里被选中的工具去 pr-agent 执行、以及循环本身的多轮往返。 - -一段动作(单次调用的产物)可同时承载: - -- `thought`:本轮思考摘要(留档 + 流式推送)。 -- **工具选择**,三选一:`tool`(单个,`/ask` 可带 `question`);`tools`(**一次并行多选只读工具**, - 如 `["/describe","/review"]`,**上限 3**、彼此错开 100~200ms 起跑);或不选工具直接 - `final`(收尾)。 -- `recommendation`:评审类收尾的非约束性判定(`approve` / `needs_work` / `manual_review` + 理由), - 与 `final` 在**同一次调用**产出,供 UI 展示判定徽标。 -- `remember`:主动记下的**非隐私**条目,按目标可写文件分组(`user` → USER.md / `memory` → MEMORY.md - / `agents` → AGENTS.md),随本轮动作一并返回、收尾后落盘(**`SOUL.md` 永不写**)。每条**必带** - `section`:把条目**抽象归纳**后归入目标文件里最贴切的既有 `## 专题章节`(命中则追加到该节末尾、不存在则 - 文件末尾新建),而非统一堆到单一记录区,便于跨会话上下文管理。**无法归入某个专题的条目不是耐久记忆** - (多半是本 PR 的发现)——直接丢弃、不记录(无兜底区)。 - -**路由策略**(写进规划提示词,也在这同一次调用里裁决):自然对话(问候 / 自我介绍 / 澄清)直接 -`final` 回答、不调工具;评审领域外的任务礼貌拒绝、不调工具;与本 PR 相关但无明确工具指向时默认 -`/ask` 兜底(带聚焦问题);评审收尾固定为 `## 摘要` / `## 关键发现` / `## 建议` + `recommendation`。 - -**单次调用内 vs 跨多次调用**——这条边界是读懂运行态与计量(见下「步 vs 子任务」)的关键: - -- **同一次 LLM 调用内**:路由判定、(可并行的)工具选择、收尾答复、判定建议、记忆写入意图。 -- **跨多次调用 / 多进程**:被选中工具的实际执行(各为独立 `ReviewRun` / pr-agent run)、ReAct 的 - 多轮循环(每轮一次新的规划调用)、以及固定微流程(见 [AutoPilot 与调度](03-autopilot.md))里 judge / summary 各自的独立 LLM 调用。 +# Agentic sessions + +## Responsibilities & boundaries + +Upgrade the render layer's natural-language input from "route straight to `/ask`" to "hand off to the Agent runtime for delegation": read the local Agent context (see [Agent & context](01-agent.md)), plan tasks autonomously, orchestrate multiple pr-agent tools as needed, and retain and output the reasoning and results. Direct slash-tool instructions (`/describe` · `/review` · `/ask`) still keep the "direct tool" semantics and do not go through the Agent. + +Owns: input routing (natural language vs direct tool), the Agent planning loop (ReAct), process retention (todo / transcript / progress), interaction control (one-click auto review / Stop to pause preserving state / Continue to resume), and sharing the run state with the existing queue. + +Does not own: the Agent directory and context assembly, the tool red line (see [Agent & context](01-agent.md)), poll-triggered automatic pre-review and cross-PR scheduling (see [AutoPilot & scheduling](03-autopilot.md)), findings parsing and draft publishing (see [Review workflow](../01-platform/03-review-workflow.md)). + +## Core design + +### Input routing + +Split at the render-layer input parser: + +- Starts with `/describe`, `/review` → **direct tool**, keeping the existing "ignore the rest of the text, run that tool directly" semantics. +- `/ask <text>` → **direct tool**, the text runs `/ask` directly as the question. In CLI mode (claude/codex), `/ask` sets the CLI + subprocess cwd to a (sanitized) throwaway worktree and answers with the full file context — rather than reasoning only from the diff in a neutral temp directory the way describe/review do; the mechanism (`MEEBOX_CLI_WORKDIR` + worktree instruction-file sanitization against injection) is in [pr-agent runtime](05-pragent-runtime.md). + - **Structured segmented output**: the prompt constrains `/ask` to output under deterministic tags `<summary>` / `<analysis>` / `<suggestions>` + (pr-agent-bridge `prompts.ts`), and the parse layer (poller `parseStructuredAsk`) splits by tag into separate findings: summary + conclusion highlighted and expanded, analysis process collapsed by default, suggestions highlighted. When the model does not comply / has no tags, the whole thing falls back to plain parsing. + - **Re-review reference closed loop**: a code finding card from review/improve has a "quote" button → attach it to the input bar to start a re-review `/ask` (carrying + `referencedFinding` + the finding body as context). Re-review mode additionally produces `<verdict>` (replace / keep / drop, landing in + `ReviewRun.askVerdict`), and the result card shows the verdict + **manual** adopt/close actions: adopt-supersede → create a new comment draft anchored at the original location + + close the original finding; the close relationship is stored independently in `findingClosures` (not draft semantics), the original card turns to closed state and cross-links with the re-review card. + - **Agent auto review auto-associates / supersedes**: in the auto-review micro-flow the judge can raise a re-review follow-up ask against a given review finding + (`asks[].targetFindingId`, the judge prompt gives an addressable id list), the asks step dispatches that follow-up ask in re-review mode (carrying + `referencedFinding`), and on a replace/drop verdict it **automatically** creates a `FindingClosure` to close the superseded original finding (via + `ReviewOrchestratorDeps.closeFinding`). On by default and conservative (only closes on named + replace/drop; keep / unnamed leave it untouched); + new comments do not auto-draft, still left for the user to "adopt" manually on the re-review card. +- Other direct tool / operation instructions → keep their own existing invocation. +- **Natural language without a slash** → **hand off to the Agent runtime** (the old behavior was the equivalent `/ask`; this is the core change of this module). +- Unknown `/xxx` → error (unchanged). + +### Agent planning loop + +The Agent runtime is an orchestrator layer sitting atop the existing run queue, with its own LLM channel (reusing the LLM +Profile credentials, the outbound proxy and token collection; see [pr-agent runtime](05-pragent-runtime.md), [Networking & proxy](../99-core/03-networking-proxy.md)). One session: + +1. Read context (see [Agent & context](01-agent.md)) → produce / update the **task list (todo)**, persisted to this PR's working directory. +2. Execute step by step: each step is either one LLM call for planning / judgment, or one tool call (running `/describe` · `/review` · `/ask` + **into the existing run queue** as a "tool", reusing worktree, concurrency and cancellation). +3. Each step's **thought summary** and **tool-call result** are written to the session transcript and streamed to the render layer in real time; the todo + items are marked done and progress is persisted. +4. Wrap up once the completion condition is met or the step cap is reached. + +**Tool selection & routing: which decisions are made within "one LLM call"**: the free-planning Agent (natural-language entry) is a +ReAct loop — **each round is one orchestration-level LLM call** (one `AgentStep`): inputs are "context, prior multi-turn conversation, +progress so far", output is a block of **JSON action**. The key boundary is — **routing, tool selection, wrap-up, and memory, these "decisions", are all decided within +this single call**; what actually takes time is the tools selected in the action running against pr-agent, plus the loop's own multi-round round-trips. + +One action (the product of a single call) can carry simultaneously: + +- `thought`: this round's thought summary (retained + streamed). +- **Tool selection**, one of three: `tool` (single, `/ask` may carry `question`); `tools` (**one parallel multi-select of read-only tools**, + e.g. `["/describe","/review"]`, **cap 3**, staggered 100–200ms apart at start); or no tool selected and go straight to + `final` (wrap up). +- `recommendation`: the non-binding verdict at a review-type wrap-up (`approve` / `needs_work` / `manual_review` + reason), + produced in the **same call** as `final`, for the UI to show a verdict badge. +- `remember`: a proactively recorded **non-private** entry, grouped by target writable file (`user` → USER.md / `memory` → MEMORY.md + / `agents` → AGENTS.md), returned together with this round's action and persisted after wrap-up (**`SOUL.md` is never written**). Each entry **must carry** + `section`: the entry is **abstracted and generalized** and placed under the most fitting existing `## topic section` in the target file (append to that section's end if matched, or create a new one at the file's end if absent), + rather than dumped uniformly into a single record area, for easier cross-session context management. **An entry that cannot fit any topic is not durable memory** + (most likely a finding of this PR) — dropped, not recorded (no fallback area). + +**Routing policy** (written into the planning prompt, also decided within this same call): natural conversation (greeting / self-introduction / clarification) answers directly with +`final` and calls no tool; a task outside the review domain is politely declined, no tool; something related to this PR but with no clear tool target defaults to +`/ask` as a fallback (with a focused question); a review wrap-up is fixed as `## Summary` / `## Key findings` / `## Suggestions` + `recommendation`. + +**Within one call vs across multiple calls** — this boundary is the key to reading the run state and the metering (see "Step vs sub-task" below): + +- **Within one LLM call**: the routing decision, the (parallelizable) tool selection, the wrap-up answer, the verdict recommendation, the memory-write intent. +- **Across multiple calls / processes**: the actual execution of selected tools (each a separate `ReviewRun` / pr-agent run), the multi-round + ReAct loop (each round a new planning call), and, in the fixed micro-flow (see [AutoPilot & scheduling](03-autopilot.md)), judge / summary each as its own separate LLM call. ```mermaid flowchart TD - U["自然语言输入"] --> CALL["规划 LLM 调用(一轮 = 一个 AgentStep)<br/>输入:上下文 + 既往多轮对话 + 至此进展"] + U["Natural-language input"] --> CALL["Planning LLM call (one round = one AgentStep)<br/>Input: context + prior multi-turn conversation + progress so far"] - subgraph ONECALL["这一次 LLM 调用的产物(一段 JSON 动作)— 以下决策全在同一次调用内拍板"] + subgraph ONECALL["The product of this one LLM call (a block of JSON action) — all decisions below are decided within the same call"] direction TB - CALL --> ROUTE{"路由"} - ROUTE -- "对话 / 领域外 → 直接答 / 礼貌拒绝" --> FIN["final(+ recommendation 评审判定)"] - ROUTE -- "需要工具" --> SEL["tool / tools 多选<br/>≤3 并行只读 · 无明确指向 → /ask 兜底"] - FIN -. 可附带 .-> REM["remember:user / memory / agents(非隐私)"] - SEL -. 可附带 .-> REM + CALL --> ROUTE{"Route"} + ROUTE -- "Conversation / out-of-domain → answer directly / decline politely" --> FIN["final (+ recommendation review verdict)"] + ROUTE -- "Needs a tool" --> SEL["tool / tools multi-select<br/>≤3 parallel read-only · no clear target → /ask fallback"] + FIN -. optional .-> REM["remember: user / memory / agents (non-private)"] + SEL -. optional .-> REM end - SEL --> EXEC["工具执行(跨进程 / 并行)<br/>各为独立 ReviewRun · pr-agent run"] - EXEC -. "结果回喂 → 下一轮规划调用" .-> CALL - FIN --> DONE["收尾:落多轮对话消息 + 判定徽标"] - REM --> MEM["按专题章节落盘 USER / MEMORY / AGENTS.md(SOUL 永不写)"] + SEL --> EXEC["Tool execution (cross-process / parallel)<br/>each a separate ReviewRun · pr-agent run"] + EXEC -. "results fed back → next planning call" .-> CALL + FIN --> DONE["Wrap up: land the multi-turn conversation message + verdict badge"] + REM --> MEM["Persist to USER / MEMORY / AGENTS.md by topic section (SOUL never written)"] ``` -固定微流程([AutoPilot 与调度](03-autopilot.md) 的自动评审 / AutoPilot)是另一种形态:工具序列**预定**(describe + review → 条件 -追问 → 总结),judge / summary 各是**独立**的编排级 LLM 调用,不走上面这套「单次调用内自由多选」的 -规划——两者互补,见下「步 vs 子任务」的计量口径。 +The fixed micro-flow (the auto review / AutoPilot of [AutoPilot & scheduling](03-autopilot.md)) is another form: the tool sequence is **predetermined** (describe + review → conditional +follow-up → summary), and judge / summary are each a **separate** orchestration-level LLM call, not going through the "free multi-select within one call" +planning above — the two are complementary; see the metering basis in "Step vs sub-task" below. -### 步 vs 子任务(pr-agent run)的计量边界 +### Metering boundary: step vs sub-task (pr-agent run) -**编排 agent**(交互式即 PR 自身的 agent,autopilot -即各 PR 的子 agent)**不只是规划分发**——每一步(`AgentStep`)是一次 plan / judge / -工具分发的**编排级 LLM 调用**:规划 todo、读 findings 判断是否追问、收尾总结都算它的步。而 -`/describe`·`/review`·`/ask` 这些**拆给 pr-agent 子进程跑的任务**,在编排层维度**只计「分发」 -那一步**;pr-agent run **内部**自己的多次 LLM 调用**不计入 `stepCount`**——它是一条独立 `ReviewRun`, -按自身 `tokenUsage` 计量。由此两点: +The **orchestration agent** (interactive is the PR's own agent; under autopilot it is each PR's sub-agent) is **not merely plan-and-dispatch** — each step (`AgentStep`) is one plan / judge / +tool-dispatch **orchestration-level LLM call**: planning the todo, reading findings to decide whether to follow up, and the wrap-up summary all count as its steps. Meanwhile +`/describe` · `/review` · `/ask` — **tasks handed off to run in a pr-agent subprocess** — count at the orchestration layer as **only the "dispatch" +step**; a pr-agent run's own **internal multiple LLM calls do not count toward `stepCount`** — it is a separate `ReviewRun`, +metered by its own `tokenUsage`. Hence two points: -1. 步数是**编排级**概念,衡量编排 agent 的决策回合,不被子任务内部复杂度撑大; -2. **token 两层都采**——编排开销 + 各 pr-agent run 用量都归入会话计量(见下「扩展与注意事项」的 - Agent LLM 成本),只是不混进步数。 +1. Step count is an **orchestration-level** concept, measuring the orchestration agent's decision rounds, not inflated by a sub-task's internal complexity; +2. **Tokens are collected at both layers** — orchestration overhead + each pr-agent run's usage both go into the session metering (see "Agent LLM cost" in "Extension & caveats" below), + just not mixed into the step count. -[AutoPilot 与调度](03-autopilot.md) 的步数公式正按此口径数:describe / review / 每个 ask / summary 各算一步。 +The step-count formula in [AutoPilot & scheduling](03-autopilot.md) counts by exactly this basis: describe / review / each ask / summary each count as one step. -### 过程留存 +### Process retention -思考步骤、工具结果、todo、进度均持久化于本 PR 工作目录,跨 PR 切换与组件卸载存活(与 -[评审闭环](../01-platform/03-review-workflow.md) 的 run store 保活一致),可事后回看。 +Thought steps, tool results, todos and progress are all persisted in this PR's working directory, surviving cross-PR switches and component unmount (consistent with the +run store persistence in [Review workflow](../01-platform/03-review-workflow.md)), and can be reviewed afterward. -### 交互控制(聊天框) +### Interaction control (chat box) -- **一键自动评审按钮**:在聊天框「指令(`/`)按钮」右侧提供一个**自动 Review 按钮**,点击即对当前 - PR 触发 [AutoPilot 与调度](03-autopilot.md) 的自动评审微流程(`/describe`+`/review`→ 仅严重问题条件性追问 → 总结)。 - 它是**用户直接发起**:走 `user` 优先级、**立即执行**,不受 AutoPilot 总开关 / 最小间隔 / - 台账去重约束(那三道闸只管后台自动触发)。等于把 AutoPilot 的微流程作为一个手动动作随时复用。 -- **Stop(暂停保态)**:聊天框 Stop 按钮**一键停止当前 PR 下的所有 Agent 任务**——中止该 PR - 正在执行的 run(复用 AbortController)、清掉其在等待队列里的 agent 任务, - 但**保留上下文状态**(`AgentSession` / todo / 进度 / transcript 原样落盘,会话状态置 `paused`)。 - 这区别于既有 `cancel`「丢弃不留痕」:Stop 是**可续的暂停**。 -- **Continue(续跑)**:对 `paused` 会话一键续跑——从保留的 todo / 进度接着规划执行(会话循环本就「读会话快照续上未完成规划」,Continue 即复用这条路径),无需从头重来。 -- **运行态可见且共享**:PR 会话**直接展示该 PR 下正在执行的自动任务**——AutoPilot / Agent 编排出的 - step 与 user 手动发起的 run **同进一条会话时间线**(同一 transcript + step 流式推送), - 不存在「后台跑、会话看不见」的隐形通道。三者**共享同一运行态占用**:复用既有跨 PR 保活的 - run-state store(见 [评审闭环](../01-platform/03-review-workflow.md)、[GUI 交互](../03-gui/01-ui-interaction.md))与同一条运行队列 / - 并发预算(见 [AutoPilot 与调度](03-autopilot.md) 的调度)——一个 AutoPilot run 像手动 run 一样占用可见的并发槽,状态栏活动 chip / - 队列浮层与 PR 会话对它的呈现一致。切走再切回该 PR,正在跑的自动任务仍在原位。 +- **One-click auto review button**: to the right of the chat box's "instruction (`/`) button" there is an **auto Review button**; clicking it triggers the + [AutoPilot & scheduling](03-autopilot.md) auto-review micro-flow for the current PR (`/describe`+`/review` → conditional follow-up only on severe issues → summary). + It is **directly user-initiated**: goes at `user` priority, **executes immediately**, and is not subject to the AutoPilot master switch / minimum interval / + ledger dedup (those three gates govern only background auto-triggering). It effectively reuses AutoPilot's micro-flow as a manual action on demand. +- **Stop (pause preserving state)**: the chat box's Stop button **stops all Agent tasks under the current PR in one click** — aborts the PR's + running run (reusing `AbortController`), clears its Agent tasks waiting in the queue, + but **preserves the context state** (`AgentSession` / todo / progress / transcript persisted as-is, session status set to `paused`). + This differs from the existing `cancel` "discard without a trace": Stop is a **resumable pause**. +- **Continue (resume)**: resume a `paused` session in one click — plan and execute from the preserved todo / progress (the session loop already "reads the session snapshot to resume unfinished planning", and Continue reuses this path), no need to start over. +- **Run state visible and shared**: a PR session **directly shows the auto tasks running under that PR** — the + steps orchestrated by AutoPilot / Agent and the runs the user manually initiates **go into the same session timeline** (same transcript + step streaming), + with no hidden channel of "running in the background, invisible to the session". All three **share the same run-state occupancy**: they reuse the existing cross-PR-persisted + run-state store (see [Review workflow](../01-platform/03-review-workflow.md), [GUI interaction](../03-gui/01-ui-interaction.md)) and the same run queue / + concurrency budget (see the scheduling in [AutoPilot & scheduling](03-autopilot.md)) — an AutoPilot run occupies a visible concurrency slot just like a manual run, and the status-bar activity chip / + queue popover and the PR session present it consistently. Switch away and back to the PR, and the running auto task is still in place. -### 规避超长任务 +### Avoiding overly long tasks -会话受**步数上限** `agent.max_steps` 约束(默认取小值)。设计立场是「review -场景单次自动化操作通常不需要 10 个任务」——Agent 应倾向「少而准」的工具编排而非无限发散; -触达上限即停并在 transcript 标注「因步数上限中止」,绝不静默截断。AutoPilot 路径另有更紧的预算(见 -[AutoPilot 与调度](03-autopilot.md))。 +A session is constrained by the **step cap** `agent.max_steps` (defaults to a small value). The design stance is "a single automated operation in a review +scenario usually doesn't need 10 tasks" — the Agent should lean toward "few and precise" tool orchestration rather than unbounded divergence; +reaching the cap stops and annotates the transcript "aborted due to step cap", never silently truncating. The AutoPilot path has a tighter budget of its own (see +[AutoPilot & scheduling](03-autopilot.md)). -## 数据 / 接口契约 +## Data / interface contract -**per-PR 会话布局**(落在 [状态存储](../99-core/01-state-storage.md) 的 `state/prs/<hash>/` 下,按 PR 隔离): +**Per-PR session layout** (lands under `state/prs/<hash>/` in [State storage](../99-core/01-state-storage.md), isolated per PR): ``` state/prs/<hash>/agent/ -├── session.json # AgentSession:会话元数据 + todo + 进度 + 步数 -└── transcript.json # AgentStep[]:思考摘要 + 工具调用 + 结果(流式落盘) +├── session.json # AgentSession: session metadata + todo + progress + step count +└── transcript.json # AgentStep[]: thought summary + tool calls + results (streamed to disk) ``` -核心形状(以名称与形状描述,不绑定实现): +Core shapes (described by name and shape, not bound to implementation): -- `AgentSession`(**每个 PR 一份**):`status`(`running` | `paused` | `done` | `failed` | `cancelled`)· - `todo[]`(任务项 + 完成态)· `stepCount` · `maxSteps` · `summary?`(本 PR 收尾总结,受 `summary_max_chars` 限长)· - `recommendation?`(`approve` | `needs_work` | `manual_review` + 理由;**非约束性**,不触发任何写操作)· - 计时与终止原因(含「步数上限中止」「用户暂停」)。`paused` 可续,由 `agent:stop` 置、`agent:continue` 复活。 -- `AgentStep`:`kind`(`plan` | `tool` | `judge`)· `thought` · `toolCall?`(tool + 入参)· `result?` · `tokenUsage?`。 +- `AgentSession` (**one per PR**): `status` (`running` | `paused` | `done` | `failed` | `cancelled`) · + `todo[]` (task items + done state) · `stepCount` · `maxSteps` · `summary?` (this PR's wrap-up summary, length-limited by `summary_max_chars`) · + `recommendation?` (`approve` | `needs_work` | `manual_review` + reason; **non-binding**, triggers no write operation) · + timing and termination reason (including "aborted due to step cap", "user paused"). `paused` is resumable, set by `agent:stop` and revived by `agent:continue`. +- `AgentStep`: `kind` (`plan` | `tool` | `judge`) · `thought` · `toolCall?` (tool + args) · `result?` · `tokenUsage?`. -**IPC 通道**(沿用 `invoke<K>` + `IpcChannels` 约束): +**IPC channels** (following the `invoke<K>` + `IpcChannels` constraint): -- `agent:run`:自然语言入口,发起一个 PR 的 Agent 会话。 -- `agent:stop` / `agent:continue`:暂停保态(中止当前 PR 全部 Agent 任务、置 `paused`、留状态)/ 续跑 `paused` 会话。**区别于** `agent:cancel`(丢弃不留痕)。 -- `agent:getSession`:读会话快照(含 todo / 进度 / `summary`)。 -- `agent:stepProgress`(push):会话步骤的流式推送(思考 + 工具结果)。 -- 一键自动评审 `agent:autoReview` 见 [AutoPilot 与调度](03-autopilot.md)。 +- `agent:run`: the natural-language entry, initiating a PR's Agent session. +- `agent:stop` / `agent:continue`: pause preserving state (abort all Agent tasks under this PR, set `paused`, keep state) / resume a `paused` session. **Distinct from** `agent:cancel` (discard without a trace). +- `agent:getSession`: read the session snapshot (including todo / progress / `summary`). +- `agent:stepProgress` (push): streaming of session steps (thought + tool result). +- The one-click auto review `agent:autoReview` is in [AutoPilot & scheduling](03-autopilot.md). -## 扩展与注意事项 +## Extension & caveats -- **Agent LLM 成本**:规划 / 判定的编排级 LLM 调用独立于 pr-agent,token 同样采集并归入会话计量,UI - 需能区分「Agent 编排开销」与「评审本体开销」,避免成本不可见。 +- **Agent LLM cost**: the orchestration-level LLM calls for planning / judgment are separate from pr-agent, their tokens are likewise collected and go into the session metering; the UI + must distinguish "Agent orchestration overhead" from "review-proper overhead", so cost isn't invisible. diff --git a/docs/arch/02-agent/03-autopilot.md b/docs/arch/02-agent/03-autopilot.md index 86443914..78730f75 100644 --- a/docs/arch/02-agent/03-autopilot.md +++ b/docs/arch/02-agent/03-autopilot.md @@ -1,221 +1,219 @@ -# AutoPilot 与调度 +# AutoPilot & scheduling -## 职责与边界 +## Responsibilities & boundaries -把「新 / 变更 PR 进来自动预跑评审」做成**默认关闭、可一键启用**的后台自动化,由 `AGENTS.md` 规定其触发与例外策略;决策权仍在评审者(草稿不自动发布,修改性操作受红线约束,见 [Agent 与上下文](01-agent.md) 的工具规范)。 +Make "auto-pre-run a review when a new / changed PR comes in" a **default-off, one-click-enable** background automation, with its trigger and exception policy governed by `AGENTS.md`; the decision still rests with the reviewer (drafts are not auto-published, mutating operations are constrained by the red line, see the tool conventions in [Agent & context](01-agent.md)). -负责:轮询触发与准入门控、批量 LLM 判定(例外规则)、规划 agent ↔ 各 PR 子 agent 的分层执行、有界微流程与步数预算、台账去重与结果展示、用户优先的有序调度。 +Owns: poll triggering and admission gating, batch LLM judgment (exception rules), the layered execution of planner ↔ per-PR sub-agents, the bounded micro-flow and step budget, ledger dedup and result display, and user-first ordered scheduling. -不负责:交互式会话与自然语言路由(见 [会话 Agent 化](02-session.md))、Agent 目录与工具红线(见 [Agent 与上下文](01-agent.md))、findings 解析 / 草稿池 / 发布(见 [评审闭环](../01-platform/03-review-workflow.md))。 +Does not own: interactive sessions and natural-language routing (see [Agentic sessions](02-session.md)), the Agent directory and tool red line (see [Agent & context](01-agent.md)), findings parsing / draft pool / publishing (see [Review workflow](../01-platform/03-review-workflow.md)). -## 核心设计 +## Core design -### 分层架构:规划 agent(主)↔ 各 PR agent(子) +### Layered architecture: planner (main) ↔ per-PR agent (sub) -autopilot 不是「一个大 agent 串跑所有 PR」,而是**主子两层 + 两个并发域**: +Autopilot is not "one big agent running all PRs serially", but **main-sub two layers + two concurrency domains**: -- **主 = 规划 agent(planner,跨 PR、唯一)**:批量判定哪些 PR 要评审、为每个待评审 PR - 拟定子任务计划、派发;**不碰工具、不出总结**。 -- **子 = 每个 PR 的 agent(独立)**:各自执行 planner 派的**有界微流程**,并**在本 PR - 子任务结束后产出本 PR 的总结**(逐 PR,无全局总结)。 -- **并发域一:Agent 编排层 = 全局单并发**——planner 与各 PR agent 的推理由**单个后台 worker - 串行**驱动(成本有界、顺序确定)。 -- **并发域二:pr_agent 工具层 = 并行**——子 agent 派发的 `/describe`·`/review`·`/ask` - 走**共享运行队列并行**消化(与用户任务同池、用户优先,见下「调度」)。 +- **Main = planner (cross-PR, single)**: batch-judges which PRs to review, drafts a sub-task plan for each PR to review, + and dispatches; **touches no tool, produces no summary**. +- **Sub = per-PR agent (independent)**: each executes the **bounded micro-flow** the planner dispatched, and **produces this PR's + summary after this PR's sub-task finishes** (per PR, no global summary). +- **Concurrency domain one: the Agent orchestration layer = a single global concurrency** — the reasoning of the planner and each per-PR agent is driven by a **single background worker + serially** (bounded cost, deterministic order). +- **Concurrency domain two: the pr_agent tool layer = parallel** — the `/describe` · `/review` · `/ask` dispatched by sub-agents + are consumed **in parallel via the shared run queue** (same pool as user tasks, user-first, see "Scheduling" below). ```mermaid flowchart TD - P["Poller tick · PR 变更"] --> G{"AutoPilot 闸<br/>启用? 准入门控? 候选去重?"} - G -- 否 --> X["本轮跳过"] - G -- 是 --> PL + P["Poller tick · PR change"] --> G{"AutoPilot gate<br/>enabled? admission gating? candidate dedup?"} + G -- no --> X["skip this round"] + G -- yes --> PL - subgraph ORCH["① Agent 编排层 — 全局单并发 (单 worker 串行)"] + subgraph ORCH["① Agent orchestration layer — single global concurrency (single worker serial)"] direction TB - PL["主 · 规划 agent / planner (跨 PR)"] - PL --> J["批量判定 ≤ batch_size<br/>逐 PR: 评审 / 跳过 + 原因"] - J --> PLAN["为待评审 PR 拟定子任务计划"] - PLAN --> D{"派发"} - D --> A1["子 · PR#1 agent"] - D --> A2["子 · PR#2 agent"] - D --> An["子 · PR#N agent"] - A1 --> M1["describe → review → 条件追问(≤2) → 本 PR 总结"] - A2 --> M2["(同上, 独立执行)"] - An --> Mn["(同上, 独立执行)"] + PL["Main · planner (cross-PR)"] + PL --> J["Batch judge ≤ batch_size<br/>per PR: review / skip + reason"] + J --> PLAN["Draft a sub-task plan for PRs to review"] + PLAN --> D{"Dispatch"} + D --> A1["Sub · PR#1 agent"] + D --> A2["Sub · PR#2 agent"] + D --> An["Sub · PR#N agent"] + A1 --> M1["describe → review → conditional follow-up(≤2) → this PR's summary"] + A2 --> M2["(same, executed independently)"] + An --> Mn["(same, executed independently)"] end - M1 -. "工具调用 (agent 低优先级)" .-> Q + M1 -. "tool call (agent low priority)" .-> Q M2 -.-> Q Mn -.-> Q - subgraph TOOLQ["② pr_agent 工具层 — 并行 (max_concurrency · 含 user 任务)"] + subgraph TOOLQ["② pr_agent tool layer — parallel (max_concurrency · includes user tasks)"] direction TB - Q["共享运行队列<br/>user 高 / agent 低"] + Q["Shared run queue<br/>user high / agent low"] Q --> R1["worktree run"] Q --> R2["worktree run"] end - M1 --> OUT["逐 PR 结果产物<br/>草稿 + 总结 + recommendation"] + M1 --> OUT["per-PR result artifacts<br/>draft + summary + recommendation"] M2 --> OUT Mn --> OUT - OUT --> UI["PR 级展示 (与会话无关)<br/>列表徽标 / PR 详情结果条目<br/>不发布远端 · 决策在人"] + OUT --> UI["PR-level display (session-independent)<br/>list badge / PR-detail result entry<br/>not published to remote · the human decides"] ``` -### 触发与准入 - -**启用开关**:底部状态栏 **AutoPilot 按钮**,默认**禁用**,用户手动启用;状态持久化于配置 -(`agent.autopilot.enabled`,默认 `false`)。禁用时下述逻辑完全不跑。 - -**触发节奏**:AutoPilot 挂在 Poller 的「PR 变更」回调上,**评估节奏对齐轮询**——每个 poller tick -(间隔 = `poller.interval_seconds`)评估一遍,**不单设独立的最小间隔守卫**;准入门控 + 台账去重已防止重复 -评审 / 打爆 LLM,全局 `busy` 锁防止上一遍未完又叠跑。此外**启用开关时(关 → 开)立即触发一次 poll**, -让本轮即时评估、不必等下个轮询周期。 - -**准入门控**(自上而下,任一不满足即跳过该 PR): - -1. **分类 + 状态硬门控**——只对**「待我评审」分类**(`discoveryFilters` 含 `review-requested`)下、 - **「待处理」状态**(`localStatus === 'pending'`)的 PR 触发;已通过 / 标记需修改、或非「待我评审」 - 的一律不自动评审。不支持发现分类的平台(`discoveryFilters` 为空)天然不命中。 -2. **已评审即止**——会话中一旦已有 `/describe` 或 `/review` 的有效产出(成功或正在跑,手动或自动皆算) - 即判定已评审过 / 评审中,不再自动触发(见 `hasReviewOutput`);评审**失败无产出**则不算、下轮可重试。 -3. **跳过去重(台账)**——仅排除「本版本已被 LLM 判定 skip」的 PR(台账 `decision='skipped'` 且 - `autoReviewedUpdatedAt` 等于当前 `updatedAt`),避免对判过 skip 的 PR 反复重判;无产出又未被 skip 的 - 待评审 PR 一律放行(**不再因台账里有任意记录就拦下**——「已成功评审」由准入闸 2 用产出判定,不靠台账)。 - -**自动评审状态记录(ledger)**:每个 PR 记录一份 AutoPilot 台账(评审时所对应的 PR `updatedAt` / 判定 -结果与原因 / 建议倾向)。台账主要供: - -- PR 列表的建议徽标(★,手动 / 自动一视同仁); -- 上述「跳过去重」(只看 `decision='skipped'`)。 - -PR 被推新 commit(`updatedAt` 变)后,旧 skip 记录自然失效、可再次进入候选。 - -**移除 / purge 即终止**:每轮 poll tick 后,对**已不在本地 PR 列表**(被移除 / 软删后 purge)的 PR, -若其上仍有在执行的 agent 操作(编排控制器 + 派发到运行队列的工具 run),一律直接终止——PR 都没了, -继续评审无意义且空耗 LLM / 占用 worktree。 - -### 批量判定(例外规则) - -候选 PR 不无脑全跑,先过一道 LLM 判定: - -1. 收集候选 PR 的**标题 + 描述**,组织成结构化清单。 -2. **单次上下文规模受限**:每批至多 `agent.autopilot.batch_size`(默认 10)个 PR;超出按批跨轮处理, - 并 `log` 出被推迟的数量(不静默截断)。 -3. 喂入 LLM,按规则逐 PR 判「是否值得自动评审」并附原因——例如**分支合并 / 回合并类 PR 可跳过**、 - 纯依赖升级可跳过等,例外规则在 `AGENTS.md` 里可扩充。 - - **分支合并信号只是证据、不是裁决**:是否「纯分支合并」**以实际提交结构判定**(拉 commits 看是否 - **全为 merge commit**),绝不仅凭源分支名(`classifyBranchMerge`)。源分支为主干(`main`/`dev` 等) - 单独只作背景信号一并交给 judge,**不构成跳过理由**——避免误伤「源分支恰为主干」的 fork 原创 PR。 - judge 综合标题/描述 + 这些信号自行选择评审 / 跳过。 -4. 判定结果落台账(含「skipped + 原因」,便于审计与 UI 展示)。 - -### 分层执行:规划与微流程 - -判为「评审」后,autopilot **不由单个 agent 串跑所有 PR**,而是分两层(这也是「规避超长任务」的结构性手段): - -- **规划层(规划 agent / planner,跨 PR)**:上面的批量判定即其职责——逐 PR 定「评审 / 跳过」, - 并为每个待评审 PR **拟定子任务计划**(默认即下述微流程,亦可经 `AGENTS.md` 规则定制步骤)。 - planner **只规划与分发**,自身不跑工具、不产 PR 总结,预算极小。 -- **执行层(每个 PR 各自的 agent,独立)**:拿到计划后,**各 PR 的 agent 独立完成自己的子任务**, - 并**在本 PR 子任务结束后产出本 PR 的总结**。总结是**逐 PR**的、由该 PR 的 agent 收尾——**不存在跨 - PR 的全局总结**。 - -> 交互式入口无 planner:用户直接与某个 PR 的 agent 对话(见 [会话 Agent 化](02-session.md));planner 是 autopilot 跨 PR 专属。 - -每个 PR 的 agent 执行如下**有界微流程**(即 planner 派发的子任务计划),按**低优先级**入工具队列(见下「调度」): - -1. `/describe` + `/review` —— 生成描述与 findings,产物进既有草稿池(见 [评审闭环](../01-platform/03-review-workflow.md))。 -2. **仅对严重问题条件性追问** —— **默认不追问**。该 PR 的 agent 读工具输出(findings 及其 - `severity`),仅当出现**特别恶性 / 高严重度**的疑点(例如疑似安全漏洞、数据损坏、严重逻辑缺陷且需核实上下文)才考虑就该点补跑 - `/ask`。**硬上限 ≤2 个问题**(`agent.strategy.max_followup_asks`,默认 2): - 没有严重问题就一个都不问,绝不为追问而追问。`/ask` 是只读工具,属红线放行范围(见 [Agent 与上下文](01-agent.md) 的工具规范)。 -3. **逐 PR 收尾总结(严格限长)** —— **由该 PR 的 agent 在本 PR 子任务全部结束后**产出一段**严格限长**的总结 - (受 `agent.summary_max_chars` 约束,默认数百字内;超限须**自行压缩、不截断要点**;无跨 PR 全局总结)。内容含**要点、风险、 - 以及是否建议通过的倾向**——给出 `approve` / `needs_work` / `manual_review` 三档之一 + 一句理由。 - 落盘为**挂在 PR 上的结果产物**(`summary` + `recommendation` + 步骤日志)。**关键——展示不依赖任何 agent 会话视图**: - autopilot 是**后台异步任务**,故总结**不能**寄生在聊天 transcript。它经 **PR 级、与会话无关**的三个 surface 呈现: - - **PR 列表**项的 `recommendation` **小徽标**:跨 PR triage,由轻量台账直接读,无需加载会话。 - - **PR 详情的评审结果区**:把本次 autopilot 产物(草稿 findings + 总结 + `recommendation` chip)作为一条 - **结果条目**并入既有 run / 评审面板,**打开 PR 即见、无需开聊天**。 - - 可选的**完成通知 / 未读角标**「autopilot 预评审完成」。 - - 步骤日志是**可按需展开的审计留档**。**仅为非约束性建议**:给「建议通过」不等于执行 `/approve`、「建议修改」也不触发 - `/needswork`——真通过 / 打回仍是评审者手动点按(红线见 [Agent 与上下文](01-agent.md) 的工具规范);结果条目可放 - **「采纳为 PR 状态」按钮**把建议一键转成手动操作,但点按始终在人。 - -**步骤计划可经规则定制(plan)**:上述微流程是**默认序列**(`describe-review` → `judge` → `asks` → -`summary`,由步骤注册表 `REVIEW_STEP_REGISTRY` 组装、`assembleReviewSteps` 装配),而非写死。批量判定时, -规划 agent 可据 `AGENTS.md` 规则为单个 PR 给出**自定义计划**(一组有序步骤 id),从而**跳过 / 重排 / -增删**步骤。可用步骤 id:`describe-review`、`improve`(生成代码改进建议,独立、默认不含、规则要时纳入)、 -`judge`、`asks`、`summary`——例如「配置类 PR 只生成描述与 findings、跳过追问」得 `["describe-review", -"summary"]`。计划**省略或非法时回落默认全集**:合法性校验 `isValidReviewPlan`——步骤 id 须在注册表内,且含 -`judge` / `summary` 时必须先含 `describe-review`(后两步读其产物);判定层与微流程驱动处**双重守卫**。 -**仅 autopilot 走计划**:手动评审按钮不经判定层、恒跑默认全集。「跳过整篇」仍用判定的 `review:false`。 -新增工具步 = 在 `REVIEW_STEP_REGISTRY` 登记 + 并入 `ReviewStepKind`(工具本身见统一注册表 `TOOLS`)。 - -**不自动发布**:上述全部产物——草稿、追问回答、总结——**只落本地、进待确认状态**,进应用即见, -**不自动写远端**(除非下「写权限扩展」显式授权)。决策权仍在评审者。 - -### 步数预算与写权限 - -**步数上限——分层各有预算,结构推导而非借用 `agent.max_steps`**: - -- **规划 agent(planner)**:只做「批量判定 + 派发」,预算极小(一次 judge pass + 分发),不自由展开。 -- **每个 PR 的 agent**:**不自由规划**,只执行 planner 派的微流程——默认模板,或规则定制的计划(在 - 注册表既有步骤内裁剪 / 重排 / 增删,最多纳入一个 `improve` 步,**不会自由展开**);模板内另一可变处是 - 0..N 个条件性追问。故步数上限**由模板形状 + 计划推导**,不套用更宽的交互式 `agent.max_steps`:硬上限 ≈ - `2(describe + review)+ max_followup_asks + 1(summary)` + 少量判定开销, - **唯一能推高它的可调量是 `max_followup_asks`**。另设结构化硬 backstop(按上式推导、运行期不超过它)兜底, - 防自循环把背景任务撑爆;触顶即停并标注「autopilot 步数上限中止」。背景自动化的步数因此**可预测、随模板而定**。 - -**写权限扩展(受工具红线约束)**:保留后续能力——若用户在 `AGENTS.md` / `rules/`(见 [规则](04-rules.md))中**明确授权**, -AutoPilot 可执行自动发布 comment、自动 `approve` / `needswork`。**默认全部拒绝**;授权是逐项、 -可审计的显式开关,运行时按 [Agent 与上下文](01-agent.md) 工具规范的硬校验放行。 - -### 调度:用户优先的有序队列 - -调度分**两个并发域**,分别约束「agent 编排」与「pr_agent 工具运行」——这是本设计的关键取舍: - -- **Agent 编排层 —— 全局单并发**:planner 与各 PR agent 的**推理循环**由**单个后台 autopilot worker - 串行驱动**,全局一次只有一个 agent 在「思考 / 分发」。理由:agent 推理是不可预测、花 token - 的部分,串行化让背景成本有界、顺序确定,避免 N 个并发规划循环同时烧钱。该并发度**固定为 1**(非 - `max_concurrency`)。 -- **pr_agent 工具运行层 —— 并行**:`/describe`·`/review`·`/ask` 子进程仍走既有**共享运行队列**(见 - [评审闭环](../01-platform/03-review-workflow.md) 的 `max_concurrency` + worktree 并发模型),**可并行调用**——跨 PR、 - 且与用户任务并发。单并发的 agent 循环只管「分发」,重活在工具层并行消化,throughput - 不被串行推理卡住。 -- **优先级泳道(工具层)**:`user`(手动发起,高)/ `agent`(planner 与各 PR agent 派发,低)。 - 高优先级在等待队列**插到所有低优先级之前**,但**不打断**正在执行的 run(执行不可抢占,避免半截 - worktree / 部分副作用);同级 FIFO。`QueueItem` 增 `priority` / `origin`(user / agent / - autopilot)字段,复用既有 `AbortController` / `queueChanged` 机制。 - -效果:autopilot 的 agent 推理串行、便宜、可控;它派发的 review 子进程与用户随时点的 `/review` -在工具队列里**并行**消化、用户优先。三种 `origin` **共用同一工具队列、并发预算与运行态 store**—— -没有隐形后台执行:自动任务与手动任务一样占可见并发槽、一样在状态栏与对应 PR 视图实时可见(见 -[会话 Agent 化](02-session.md) 的「运行态可见且共享」)。 - -## 数据 / 接口契约 - -**台账布局**(落在 [状态存储](../99-core/01-state-storage.md) 的 `state/prs/<hash>/agent/autopilot.json`;planner pass 记于顶层 `state/agent/`)。 - -核心形状(以名称与形状描述,不绑定实现): - -- `AutopilotLedger`(**每 PR 一条,供列表徽标直接读、无需加载会话**):`autoReviewedUpdatedAt` · - `decision`(`review` | `skipped`)· `reason` · `recommendation?`(`approve` | `needs_work` | - `manual_review`)· `summaryRef`(指向该 PR 子 agent 会话的总结)· `at`。 -- `PlannerPass`(**规划 agent,跨 PR,不落 per-PR 目录**):`batch[]`(逐 PR 判定 + 子任务计划)· `tokenUsage` · `at`。 -- 运行队列 `QueueItem` 增补:`priority`(`user` | `agent`)· `origin`(`user` | `agent` | `autopilot`)。 -- **并发模型**:agent 编排层**固定单并发**(单后台 worker,非 `max_concurrency`)/ pr_agent 工具层 - **`max_concurrency`**(共享队列,见上「调度」)。 - -**IPC 通道**(沿用 `invoke<K>` + `IpcChannels` 约束): - -- `agent:autoReview`:一键自动评审按钮——对当前 PR 立即跑微流程(`user` 优先级,不经 AutoPilot 三道闸;见 [会话 Agent 化](02-session.md) 的交互控制)。 -- `agent:autopilotToggle` / `agent:autopilotState`:启停 AutoPilot / 读其状态与台账。 -- 既有 `pragent:*` 队列通道复用,仅扩展 `priority` / `origin`。 - -## 扩展与注意事项 - -- **防雪崩靠多道闸**:AutoPilot 的安全性来自**准入门控**(分类 + 状态)+**台账去重**+**批量上限** - (`batch_size`)+ 全局 `busy` 锁 + 评估节奏对齐轮询,任一缺失都可能在大量 PR / 高频轮询下打爆 LLM 配额。 -- **批量判定尤其要控规模**:候选清单喂 LLM 的 `batch_size` 是成本与上下文的关键旋钮,超出按批跨轮、不静默截断。 -- **写权限是硬约束**:自动写操作(发布 / approve / needswork)默认全拒,授权逐项且经运行时硬校验放行(见 [Agent 与上下文](01-agent.md))。 +### Triggering & admission + +**Enable switch**: the bottom status bar's **AutoPilot button**, **disabled by default**, enabled manually by the user; the state is persisted in config +(`agent.autopilot.enabled`, default `false`). When disabled, the logic below does not run at all. + +**Trigger cadence**: AutoPilot hooks onto the Poller's "PR change" callback; the **evaluation cadence aligns with polling** — it evaluates once per poller tick +(interval = `poller.interval_seconds`), with **no separate minimum-interval guard**; admission gating + ledger dedup already prevent duplicate +review / hammering the LLM, and the global `busy` lock prevents a new round stacking on an unfinished one. Additionally, **on flipping the switch on (off → on) it triggers a poll immediately**, +so this round is evaluated at once, without waiting for the next polling cycle. + +**Admission gating** (top-down; if any is unmet, skip that PR): + +1. **Category + status hard gate** — trigger only for PRs under the **"Review Requested" category** (`discoveryFilters` contains `review-requested`) and in + **"pending" status** (`localStatus === 'pending'`); already approved / marked needs work, or not "Review Requested", are never auto-reviewed. Platforms that don't support discovery categories (`discoveryFilters` empty) naturally don't match. +2. **Stop once reviewed** — once the session already has a valid `/describe` or `/review` output (succeeded or running, manual or auto) + it is judged already-reviewed / reviewing, and no longer auto-triggered (see `hasReviewOutput`); a review that **failed with no output** does not count and can be retried next round. +3. **Skip dedup (ledger)** — exclude only PRs "already judged skip by the LLM for this version" (ledger `decision='skipped'` and + `autoReviewedUpdatedAt` equals the current `updatedAt`), to avoid re-judging a PR that was judged skip; a PR to review with no output and not skipped is always let through (**no longer blocked just because the ledger has any record at all** — "already reviewed successfully" is judged by output at admission gate 2, not by the ledger). + +**Auto-review state record (ledger)**: each PR records an AutoPilot ledger (the PR `updatedAt` at review time / judgment +result and reason / recommendation leaning). The ledger mainly serves: + +- The recommendation badge in the PR list (★, treating manual / auto alike); +- The above "skip dedup" (looking only at `decision='skipped'`). + +After a PR is pushed a new commit (`updatedAt` changes), the old skip record naturally invalidates and it can re-enter the candidate set. + +**Removal / purge = terminate**: after each poll tick, for a PR **no longer in the local PR list** (purged after removal / soft-delete), +if it still has running agent operations (the orchestration controller + tool runs dispatched to the run queue), terminate them outright — the PR is gone, +so continuing to review is pointless and wastes LLM / occupies a worktree. + +### Batch judgment (exception rules) + +Candidate PRs aren't all run blindly; they first pass an LLM judgment: + +1. Collect the candidates' **title + description** into a structured list. +2. **Single-context size is bounded**: at most `agent.autopilot.batch_size` (default 10) PRs per batch; excess is handled across rounds in batches, + and the deferred count is `log`ged (no silent truncation). +3. Feed the LLM, which judges per PR "is it worth auto-reviewing" with a reason — e.g. **branch-merge / back-merge PRs can be skipped**, + a pure dependency bump can be skipped, etc.; the exception rules are extensible in `AGENTS.md`. + - **A branch-merge signal is only evidence, not the verdict**: whether it is a "pure branch merge" is **judged by the actual commit structure** (pull commits to see whether they are + **all merge commits**), never solely by the source branch name (`classifyBranchMerge`). A source branch being a trunk (`main`/`dev`, etc.) + alone is only a background signal handed to the judge and **does not constitute a skip reason** — to avoid mistakenly harming an original fork PR whose "source branch happens to be a trunk". + The judge weighs title/description + these signals and chooses to review / skip. +4. The judgment lands in the ledger (including "skipped + reason", for auditing and UI display). + +### Layered execution: planning & the micro-flow + +Once judged "review", autopilot **does not run all PRs serially in a single agent**, but splits into two layers (this is also the structural means of "avoiding overly long tasks"): + +- **Planning layer (planner, cross-PR)**: the batch judgment above is its job — decide "review / skip" per PR, + and **draft a sub-task plan** for each PR to review (defaults to the micro-flow below, and the steps can also be customized via `AGENTS.md` rules). + The planner **only plans and dispatches**; it runs no tool itself, produces no PR summary, and has a tiny budget. +- **Execution layer (each PR's own agent, independent)**: given the plan, **each PR's agent independently completes its own sub-task**, + and **produces this PR's summary after this PR's sub-task finishes**. The summary is **per PR**, wrapped up by that PR's agent — **there is no cross-PR + global summary**. + +> The interactive entry has no planner: the user talks directly with a specific PR's agent (see [Agentic sessions](02-session.md)); the planner is autopilot's cross-PR exclusive. + +Each PR's agent executes the following **bounded micro-flow** (i.e. the sub-task plan the planner dispatched), entering the tool queue at **low priority** (see "Scheduling" below): + +1. `/describe` + `/review` — generate the description and findings; the artifacts enter the existing draft pool (see [Review workflow](../01-platform/03-review-workflow.md)). +2. **Conditional follow-up only on severe issues** — **no follow-up by default**. This PR's agent reads the tool output (findings and their + `severity`), and only when a **particularly malignant / high-severity** suspicion arises (e.g. a suspected security vulnerability, data corruption, a severe logic defect that needs context verification) does it consider re-running + `/ask` on that point. **Hard cap ≤2 questions** (`agent.strategy.max_followup_asks`, default 2): + with no severe issue it asks nothing at all, never following up for the sake of it. `/ask` is a read-only tool, within the red-line-allowed range (see the tool conventions in [Agent & context](01-agent.md)). +3. **Per-PR wrap-up summary (strictly length-limited)** — produced **by that PR's agent after all of this PR's sub-tasks finish**, a **strictly length-limited** summary + (constrained by `agent.summary_max_chars`, default within a few hundred characters; over the limit it must **compress itself, not truncate key points**; no cross-PR global summary). The content includes **key points, risks, + and whether it leans toward approving** — giving one of the three tiers `approve` / `needs_work` / `manual_review` + a one-line reason. + It lands as a **result artifact attached to the PR** (`summary` + `recommendation` + step log). **Key — display depends on no agent session view**: + autopilot is a **background async task**, so the summary **cannot** parasitize the chat transcript. It is presented via three **PR-level, session-independent** surfaces: + - The `recommendation` **badge** on the **PR list** item: cross-PR triage, read directly from the lightweight ledger, no session load needed. + - The **PR detail's review-result area**: fold this autopilot artifact (draft findings + summary + `recommendation` chip) as a + **result entry** into the existing run / review panel, **visible on opening the PR, no chat needed**. + - An optional **completion notification / unread badge** "autopilot pre-review done". + + The step log is an **audit record expandable on demand**. **Only a non-binding recommendation**: "recommend approve" does not equal executing `/approve`, "recommend changes" does not trigger + `/needswork` either — actually approving / rejecting is still a manual click by the reviewer (red line in the tool conventions of [Agent & context](01-agent.md)); the result entry can offer an + **"adopt as PR status" button** to turn the recommendation into a manual action in one click, but the click always rests with the human. + +**The step plan can be customized via rules (plan)**: the above micro-flow is the **default sequence** (`describe-review` → `judge` → `asks` → +`summary`, assembled by the step registry `REVIEW_STEP_REGISTRY` and `assembleReviewSteps`), not hard-coded. During batch judgment, the +planner can, per `AGENTS.md` rules, give a **custom plan** (an ordered set of step ids) for a single PR, thus **skipping / reordering / +adding-removing** steps. Available step ids: `describe-review`, `improve` (generate code-improvement suggestions, standalone, off by default, included when a rule wants it), +`judge`, `asks`, `summary` — e.g. "a config-type PR only generates the description and findings, skip the follow-up" gives `["describe-review", +"summary"]`. When the plan **is omitted or invalid, it falls back to the full default set**: validity check `isValidReviewPlan` — step ids must be in the registry, and if it contains +`judge` / `summary` it must first contain `describe-review` (the latter two read its artifacts); **double-guarded** at the judgment layer and the micro-flow driver. +**Only autopilot goes through a plan**: the manual review button does not pass the judgment layer and always runs the full default set. "Skip the whole thing" still uses the judgment's `review:false`. +Adding a tool step = register it in `REVIEW_STEP_REGISTRY` + add it to `ReviewStepKind` (the tool itself is in the unified registry `TOOLS`). + +**No auto-publish**: all the above artifacts — drafts, follow-up answers, summary — **only land locally, in a to-be-confirmed state**, visible on entering the app, +**not auto-written to the remote** (unless explicitly granted under "Write permission extension" below). The decision still rests with the reviewer. + +### Step budget & write permission + +**Step cap — each layer has its own budget, structurally derived rather than borrowing `agent.max_steps`**: + +- **Planner**: only "batch judgment + dispatch", a tiny budget (one judge pass + dispatch), no free divergence. +- **Each PR's agent**: **does not plan freely**, only executes the planner-dispatched micro-flow — the default template, or the rule-customized plan (trim / reorder / add-remove + within the registry's existing steps, at most including one `improve` step, **never freely diverging**); the other variable within the template is + 0..N conditional follow-ups. Hence the step cap is **derived from the template shape + the plan**, not borrowing the wider interactive `agent.max_steps`: the hard cap ≈ + `2 (describe + review) + max_followup_asks + 1 (summary)` + a little judgment overhead, + and **the only tunable that can raise it is `max_followup_asks`**. A separate structured hard backstop (derived by the formula above, never exceeded at runtime) provides a safety net, + preventing a self-loop from blowing up the background task; on hitting the ceiling it stops and annotates "autopilot aborted due to step cap". Background automation's step count is therefore **predictable, determined by the template**. + +**Write permission extension (constrained by the tool red line)**: a reserved future capability — if the user **explicitly grants** in `AGENTS.md` / `rules/` (see [Rules](04-rules.md)), +AutoPilot may perform auto-publish comment, auto `approve` / `needswork`. **All denied by default**; grants are per-item, +auditable explicit switches, released at runtime per the hard check of the [Agent & context](01-agent.md) tool conventions. + +### Scheduling: a user-first ordered queue + +Scheduling splits into **two concurrency domains**, constraining "agent orchestration" and "pr_agent tool running" respectively — this is the key trade-off of this design: + +- **Agent orchestration layer — single global concurrency**: the **reasoning loops** of the planner and each PR agent are driven **serially by a single background autopilot worker**, + with globally only one agent "thinking / dispatching" at a time. Reason: agent reasoning is the unpredictable, token-costly + part, and serializing it keeps the background cost bounded and the order deterministic, avoiding N concurrent planning loops burning money at once. This concurrency is **fixed at 1** (not + `max_concurrency`). +- **pr_agent tool-running layer — parallel**: the `/describe` · `/review` · `/ask` subprocesses still go through the existing **shared run queue** (see + the `max_concurrency` + worktree concurrency model in [Review workflow](../01-platform/03-review-workflow.md)), **callable in parallel** — across PRs, + and concurrent with user tasks. The single-concurrency agent loop only handles "dispatch"; the heavy work is consumed in parallel at the tool layer, so throughput + isn't blocked by serial reasoning. +- **Priority swimlanes (tool layer)**: `user` (manually initiated, high) / `agent` (dispatched by the planner and each PR agent, low). + A high-priority item is **inserted before all low-priority items** in the waiting queue, but **does not preempt** a running run (execution is non-preemptible, to avoid a half-done + worktree / partial side effect); same-priority is FIFO. `QueueItem` gains `priority` / `origin` (user / agent / + autopilot) fields, reusing the existing `AbortController` / `queueChanged` mechanism. + +Effect: autopilot's agent reasoning is serial, cheap, controllable; the review subprocesses it dispatches and the `/review` the user clicks at any time +are consumed **in parallel** in the tool queue, user-first. The three `origin`s **share the same tool queue, concurrency budget and run-state store** — +no hidden background execution: an auto task occupies a visible concurrency slot like a manual task, and is visible in real time in the status bar and the corresponding PR view (see +"Run state visible and shared" in [Agentic sessions](02-session.md)). + +## Data / interface contract + +**Ledger layout** (lands at `state/prs/<hash>/agent/autopilot.json` in [State storage](../99-core/01-state-storage.md); a planner pass is recorded at the top level `state/agent/`). + +Core shapes (described by name and shape, not bound to implementation): + +- `AutopilotLedger` (**one per PR, read directly for the list badge, no session load needed**): `autoReviewedUpdatedAt` · + `decision` (`review` | `skipped`) · `reason` · `recommendation?` (`approve` | `needs_work` | + `manual_review`) · `summaryRef` (points to the summary of that PR's sub-agent session) · `at`. +- `PlannerPass` (**the planner, cross-PR, not landing in a per-PR directory**): `batch[]` (per-PR judgment + sub-task plan) · `tokenUsage` · `at`. +- Run-queue `QueueItem` additions: `priority` (`user` | `agent`) · `origin` (`user` | `agent` | `autopilot`). +- **Concurrency model**: the agent orchestration layer is **fixed single-concurrency** (single background worker, not `max_concurrency`) / the pr_agent tool layer is + **`max_concurrency`** (shared queue, see "Scheduling" above). + +**IPC channels** (following the `invoke<K>` + `IpcChannels` constraint): + +- `agent:autoReview`: the one-click auto review button — run the micro-flow immediately for the current PR (`user` priority, bypassing AutoPilot's three gates; see the interaction control in [Agentic sessions](02-session.md)). +- `agent:autopilotToggle` / `agent:autopilotState`: start/stop AutoPilot / read its state and ledger. +- The existing `pragent:*` queue channels are reused, only extended with `priority` / `origin`. + +## Extension & caveats + +- **Avalanche prevention relies on multiple gates**: AutoPilot's safety comes from **admission gating** (category + status) + **ledger dedup** + **batch cap** + (`batch_size`) + the global `busy` lock + the evaluation cadence aligned with polling; missing any one could blow up the LLM quota under many PRs / high-frequency polling. +- **Batch judgment especially must control size**: the `batch_size` feeding the candidate list to the LLM is the key knob for cost and context; excess is handled across rounds in batches, no silent truncation. +- **Write permission is a hard constraint**: auto write operations (publish / approve / needswork) are all denied by default, and grants are per-item and released via a runtime hard check (see [Agent & context](01-agent.md)). diff --git a/docs/arch/02-agent/04-rules.md b/docs/arch/02-agent/04-rules.md index 25b2d3fe..abbab51c 100644 --- a/docs/arch/02-agent/04-rules.md +++ b/docs/arch/02-agent/04-rules.md @@ -1,59 +1,59 @@ -# 规则系统 - -## 职责与边界 - -让评审带上团队/仓库的编码规约:存储用户写的规则、按 PR 上下文匹配、把命中规则注入 pr-agent 的 -`extra_instructions`。 - -负责:规则加载/匹配/注入。不负责:pr-agent 调用(见 [pr-agent 运行时](05-pragent-runtime.md))、规则正文怎么写(用户的事)。 - -## 核心设计 - -- **规则目录归于 Agent 目录 `<agent.dir>/rules/`**:规则正文是 Agent 知识来源的一部分,随 Agent 目录统一管理 - (见 [Agent](01-agent.md)「Agent 目录」),不再单设顶层 `rules.*` 配置。`agent.dir` 留空时默认 `~/.code-meeseeks/agent`; - 团队把 `agent.dir` 指向一个 git repo,所有人 clone 即同一套规则与上下文。规则纯「读」,跟可变状态隔离。 -- **Markdown + YAML frontmatter,一文件一规则**:递归扫 `<agent.dir>/rules/` 下所有 `.md`(**目录层级不参与匹配**,纯组织, - 跳过隐藏目录)。frontmatter 是结构化元数据,**markdown 正文就是注入给 pr-agent 的 `extra_instructions`**——人能读、 - git diff 清楚、无重复内容。 - - **遍历性能兜底**:单目录递归收集 `.md` 封顶 `MAX_RULE_FILES`(200)个,到达即停并 warn——防止 `agent.dir` 被误指向 - 超大目录树时一次加载扫穿海量文件。 -- **匹配语义:每次 run 现读 + 取全部命中(封顶 N 条)**: - 1. 扫所有 `.md`,解析 frontmatter(解析失败 → warn + 跳过,不阻断)。 - 2. 按 `priority desc + 文件路径 asc` 预排序。 - 3. 对当前 `{ projectKey, repoSlug, targetBranch, tool }` 逐条 `.test()`:`enabled=false` / `tools` 不含当前工具 / - `applies_to.<字段>` 正则不匹配 → 跳;字段缺省视为命中。 - 4. **取全部命中**,封顶 `DEFAULT_MAX_MATCHED_RULES`(20)条(超出按排序丢弃靠后者,安全兜底防 prompt 膨胀)。 - 5. 多条正文经 `combineRuleInstructions` 以 `## Ruleset N` 分段拼接(frontmatter 已在加载期被 gray-matter 剥离, - 不入正文),作 `extra_instructions`;分段标题让模型区分不同规约、互不串味。 - 「全局基础 + 项目 override」仍可用 priority 数字表达(基础低、项目高 → 排在前、Ruleset 序靠前)。 -- **per-tool 注入**:`/review` → `PR_REVIEWER__EXTRA_INSTRUCTIONS`、`/describe` → `PR_DESCRIPTION__EXTRA_INSTRUCTIONS`。 - Agentic 评审 / 规划走系统上下文的「Matched rules」段(同一拼接口径)。命中规则数在评审执行时经日志输出。 -- **失败安全**:单文件 frontmatter 坏掉只跳过它,其余规则照常加载。 - -## 数据 / 接口契约 - -规则文件 frontmatter(字段均可省,省 = 匹配任意;值是**正则源串**): +# Rules system + +## Responsibilities & boundaries + +Let the review carry the team's/repo's coding conventions: store user-written rules, match by PR context, and inject matched rules into pr-agent's +`extra_instructions`. + +Owns: rule loading/matching/injection. Does not own: pr-agent invocation (see [pr-agent runtime](05-pragent-runtime.md)), how the rule body is written (the user's business). + +## Core design + +- **The rules directory belongs to the Agent directory `<agent.dir>/rules/`**: rule bodies are part of the Agent's knowledge source, managed uniformly with the Agent directory + (see "Agent directory" in [Agent](01-agent.md)), no longer a separate top-level `rules.*` config. When `agent.dir` is empty it defaults to `~/.code-meeseeks/agent`; + a team points `agent.dir` at a git repo, and everyone's clone gets the same rules and context. Rules are pure "read", isolated from mutable state. +- **Markdown + YAML frontmatter, one file one rule**: recursively scan all `.md` under `<agent.dir>/rules/` (**directory levels don't participate in matching**, purely organizational, + skipping hidden directories). The frontmatter is structured metadata, and **the markdown body is exactly the `extra_instructions` injected into pr-agent** — human-readable, + clear in git diff, no duplicate content. + - **Traversal-performance backstop**: recursively collecting `.md` in a single directory is capped at `MAX_RULE_FILES` (200); on reaching it, it stops and warns — preventing an `agent.dir` mistakenly pointed at + a huge directory tree from scanning through a massive number of files in one load. +- **Matching semantics: read fresh on each run + take all matches (capped at N)**: + 1. Scan all `.md`, parse frontmatter (parse failure → warn + skip, non-blocking). + 2. Pre-sort by `priority desc + file path asc`. + 3. `.test()` each against the current `{ projectKey, repoSlug, targetBranch, tool }`: `enabled=false` / `tools` not containing the current tool / + an `applies_to.<field>` regex not matching → skip; a missing field counts as a match. + 4. **Take all matches**, capped at `DEFAULT_MAX_MATCHED_RULES` (20) (excess discarded per the sort order from the back, a safety net against prompt bloat). + 5. Multiple bodies are concatenated by `combineRuleInstructions` in `## Ruleset N` segments (frontmatter has already been stripped by gray-matter at load time, + not entering the body) as `extra_instructions`; the segment headings let the model distinguish different conventions without cross-contamination. + "Global base + project override" can still be expressed with the priority number (base low, project high → sorted first, earlier Ruleset order). +- **Per-tool injection**: `/review` → `PR_REVIEWER__EXTRA_INSTRUCTIONS`, `/describe` → `PR_DESCRIPTION__EXTRA_INSTRUCTIONS`. + Agentic review / planning goes through the "Matched rules" segment of the system context (same concatenation basis). The matched-rule count is logged during review execution. +- **Fail-safe**: a single file's broken frontmatter only skips that file; the rest of the rules load as usual. + +## Data / interface contract + +Rule-file frontmatter (all fields optional; omit = match any; a value is a **regex source string**): ```markdown --- applies_to: - project: "^FX$" # projectKey 正则 - repo: "^fx-.*" # repoSlug 正则 - target_branch: "^(master|main)$" # PR base 分支名 正则 -tools: [review] # 缺省 [review] -priority: 50 # 缺省 0;越大越优先 + project: "^FX$" # projectKey regex + repo: "^fx-.*" # repoSlug regex + target_branch: "^(master|main)$" # PR base branch name regex +tools: [review] # defaults to [review] +priority: 50 # defaults to 0; larger is higher priority enabled: true --- -# 正文即 extra_instructions(给 pr-agent 的规约) +# The body is extra_instructions (the convention given to pr-agent) ``` -配置:规则目录固定为 `<agent.dir>/rules/`(`agent.dir` 见 [配置与凭据](../99-core/02-config-and-secrets.md),空 = 默认 `~/.code-meeseeks/agent`)。 +Config: the rules directory is fixed at `<agent.dir>/rules/` (`agent.dir` is in [Config & secrets](../99-core/02-config-and-secrets.md), empty = default `~/.code-meeseeks/agent`). -## 扩展与注意事项 +## Extension & caveats -- **用户需懂正则**(学习成本);UI 可后续给「按项目匹配」等 helper 自动 wrap 成 `^X$`。 -- **多条命中全部生效**:命中的规则按 Ruleset 分段全部注入(封顶 20 条),UI 的「命中规则」chip 显示命中条数、 - 预览弹窗按 Ruleset 逐条列出,便于确认本次 review 受哪些规约约束。 -- frontmatter 未做严格 schema 校验,类型不对静默 fallback 默认值;规则多了可加严。 -- 可扩展方向:按 `changed_paths` 匹配、规则 lint/预览、规则市场(导入导出 .md 包)、命中上限可配置。 +- **The user must know regex** (learning cost); the UI can later provide helpers like "match by project" that auto-wrap into `^X$`. +- **Multiple matches all take effect**: matched rules are all injected in Ruleset segments (capped at 20), the UI's "matched rules" chip shows the match count, + and the preview popover lists them one by one per Ruleset, making it easy to confirm which conventions constrain this review. +- Frontmatter is not strictly schema-validated; a wrong type silently falls back to the default value; validation can tighten once there are many rules. +- Extensible directions: match by `changed_paths`, rule lint/preview, a rule marketplace (import/export `.md` packs), a configurable match cap. diff --git a/docs/arch/02-agent/05-pragent-runtime.md b/docs/arch/02-agent/05-pragent-runtime.md index 038083eb..6fc56739 100644 --- a/docs/arch/02-agent/05-pragent-runtime.md +++ b/docs/arch/02-agent/05-pragent-runtime.md @@ -1,169 +1,168 @@ -# pr-agent 集成与运行时 +# pr-agent integration & runtime -## 职责与边界 +## Responsibilities & boundaries -把第三方的 pr-agent(Python)接进来跑 `/describe` `/review` `/ask`,并解决「运行时从哪来、怎么无侵入 -改它的行为、怎么拿真实 token 用量」。 +Bring the third-party pr-agent (Python) in to run `/describe` `/review` `/ask`, and solve "where the runtime comes from, how to change its behavior non-invasively, how to get real token usage". -负责:调用桥(多策略)、随 app 打包的嵌入式 Python 运行时、对 pr-agent 的 monkeypatch 补丁体系、 -token 用量采集、注入 env。不负责:输出解析与草稿(见 [评审闭环](../01-platform/03-review-workflow.md))、git/worktree(见 [仓库镜像](../01-platform/02-repo-mirror.md))。 +Owns: the invocation bridge (multi-strategy), the embedded Python runtime packaged with the app, the monkeypatch patch system over pr-agent, +token-usage collection, env injection. Does not own: output parsing and drafts (see [Review workflow](../01-platform/03-review-workflow.md)), git/worktree (see [Repo mirror](../01-platform/02-repo-mirror.md)). -## 核心设计 +## Core design -### 调用桥(策略模式) +### Invocation bridge (strategy pattern) -`PrAgentBridge` 两种策略,启动探测自动选、设置页可强制: +`PrAgentBridge` has two strategies, auto-selected by startup probe, forcible on the settings page: -- **embedded(默认)**:用随 app 打包的嵌入式解释器跑 `python -m pr_agent.cli`,免用户装任何东西。 -- **local-cli**:用系统 `pr-agent` CLI(高级用户自管 Python)。 +- **embedded (default)**: run `python -m pr_agent.cli` with the embedded interpreter packaged with the app, so the user installs nothing. +- **local-cli**: use the system `pr-agent` CLI (advanced users self-manage Python). -> Docker 策略已移除:容器文件系统装载效率低、与「零依赖」定位不符,嵌入式本地进程已覆盖全部场景。 +> The Docker strategy has been removed: container filesystem mounting is inefficient and inconsistent with the "zero dependencies" positioning; the embedded local process already covers all scenarios. -统一以 **LocalGitProvider 模式**在物化好的 worktree 上跑(`CONFIG__GIT_PROVIDER=local`,cwd=worktree)。 -注意 pr-agent 社区版 LocalGitProvider 的反直觉点:`--pr_url` 槽位填的是 **target 分支名**(不是 URL), -仓库根靠 cwd 的 `.git` 父目录定位。输出不走 stdout——pr-agent 把结果**写到 worktree 根的 markdown 文件** -(`/describe`→`description.md`、`/review` `/ask`→`review.md`),收尾从文件读,stdout 仅留作日志。 +It uniformly runs in **LocalGitProvider mode** on a materialized worktree (`CONFIG__GIT_PROVIDER=local`, cwd=worktree). +Note the counterintuitive point of pr-agent community-edition's LocalGitProvider: the `--pr_url` slot is filled with the **target branch name** (not a URL), +and the repo root is located via the `.git` parent directory of the cwd. Output does not go to stdout — pr-agent **writes results to markdown files at the worktree root** +(`/describe`→`description.md`, `/review` `/ask`→`review.md`), and the wrap-up reads from the file, with stdout kept only as a log. -升级 pr-agent ≈ 改版本号,对主体代码零影响(这是选「外挂进程」而非「TS 重写」的根本原因)。 +Upgrading pr-agent ≈ changing the version number, with zero impact on the main code (this is the fundamental reason for choosing an "external process" over a "TS rewrite"). -### 嵌入式运行时 +### Embedded runtime -随 app 打包**可重定位的 CPython**(python-build-standalone 的 install_only 构建)+ 构建期隔离安装 -**pinned 版本的 pr-agent**。组装脚本按一份 manifest(pin 的 python 版本 + pr-agent 版本)下载解释器、 -`pip install pr-agent==<ver>`、注入 shim、做 `import pr_agent` 冒烟。运行时作为 `extraResources` 落在 -asar 之外(原生解释器 + `.so/.pyd` 必须是真实文件)。由构建机宿主平台组装,与目标平台一致。 +Packaged with the app is a **relocatable CPython** (python-build-standalone's install_only build) + a build-time isolated install of a +**pinned version of pr-agent**. The assembly script, per a manifest (pinned python version + pr-agent version), downloads the interpreter, +`pip install pr-agent==<ver>`, injects the shim, and does an `import pr_agent` smoke test. The runtime lands outside the asar as `extraResources` +(the native interpreter + `.so/.pyd` must be real files). It is assembled by the build machine's host platform, matching the target platform. -### monkeypatch shim(无侵入改 pr-agent) +### monkeypatch shim (change pr-agent non-invasively) -集中管理**所有**对 pr-agent 的行为改造,上游源码保持原封。源码在 `apps/desktop/scripts/pragent-shim/`: -薄加载器 `sitecustomize.py`(CPython 启动经 `site` 自动 import,无需挂载 / `PYTHONPATH`)+ 按领域拆分的 -`meebox_pragent_shim/` 包: +Manages centrally **all** behavior modifications to pr-agent, keeping the upstream source untouched. The source is in `apps/desktop/scripts/pragent-shim/`: +a thin loader `sitecustomize.py` (auto-imported by `site` at CPython startup, no mounting / `PYTHONPATH` needed) + the domain-split +`meebox_pragent_shim/` package: ``` meebox_pragent_shim/ -├── __init__.py # apply():注册全部惰性 post-import hook -├── runtime.py # finder 注册 · 版本守卫 _EXPECTED_PRAGENT_VERSION · 日志 -├── usage.py # @@MEEBOX_USAGE@@ token 哨兵 -├── patches/ # 各 pr_agent 模块的 patch(module):local_git_provider · litellm_handler · load_yaml -└── cli/ # 本地 CLI provider:parsers · specs(_CLI_SPECS) · install +├── __init__.py # apply(): register all lazy post-import hooks +├── runtime.py # finder registration · version guard _EXPECTED_PRAGENT_VERSION · logging +├── usage.py # @@MEEBOX_USAGE@@ token sentinel +├── patches/ # patch(module) for each pr_agent module: local_git_provider · litellm_handler · load_yaml +└── cli/ # local CLI provider: parsers · specs(_CLI_SPECS) · install ``` -设计原则: - -- **惰性 post-import hook**:注册 meta_path finder,仅当目标模块**真正被 import**(= 真实 run)时才打补丁; - 绝不在 sitecustomize 阶段 eager import pr_agent(否则拖慢每次启动/探测/pip)。各 patch 模块对 `pr_agent` - 的 import 一律在 patch 函数体内,顶层只 import 同包的 runtime/usage——故 import 本包不触发 pr_agent 加载。 -- **同模块的多个补丁合进一个 patch_fn**:同模块注册多个 finder 会互相遮蔽,只有最前一个生效。 -- **版本守卫**:补丁依赖 pr-agent 特定版本内部实现 → 运行期 `_EXPECTED_PRAGENT_VERSION` ≠ 实际安装版本即 - **跳过全部补丁并打 stderr WARNING**(安全降级);构建期强校验 shim 常量 == manifest 版本,不一致直接 fail。 - -当前补丁: -- **二进制安全 diff**:原 `get_diff_files` 对每个文件无脑 utf-8 decode,遇二进制崩 → 改为解码失败跳过。 -- **anchor 行号**(详见 [评审闭环](../01-platform/03-review-workflow.md)):补 `get_line_link` 返回 `meebox:///<file>#L<s>-L<e>`, - 让 `/review` 的 key_issues 渲染带上结构化 file:line。 -- **Anthropic 去 temperature**:新 Claude 型号弃用 temperature,把全 `anthropic/*` 纳入「不发 temperature」集合。 -- **load_yaml 容错**:anchor marker 独占一行会破 YAML → 解析失败时剥掉 marker 重试,避免整个 review 崩。 -- **本地 CLI provider**:`MEEBOX_CLI_MODE` 置位时整体替换 `chat_completion` 为「调本机 CLI」版(见下)。 -- **token usage 采集**:见下。 - -### 真实 token 用量 - -inline 包 pr-agent 的 `_get_completion`,从返回的 `response.usage` 取 `prompt/completion/total_tokens`, -以哨兵行 `@@MEEBOX_USAGE@@ {json}` 打到 **stderr**;主进程逐行捕获、按前缀累加、落到 run(见 [评审闭环](../01-platform/03-review-workflow.md))。 -**为什么 inline 而非 litellm callback**:litellm 的 async 回调走后台 logging worker,短命 CLI 退出过快会被丢; -inline 在 await 链里必在退出前执行,可靠。只取 token、不取 cost → 统一设 `LITELLM_LOCAL_MODEL_COST_MAP=True` -关掉 litellm 的远端价格表联网(弱网会 SSL 超时)。另在 patch 时置 `litellm.suppress_debug_info=True`:编排 chat -通道以子进程 **stdout** 作模型回复,而 litellm 对未进本地 `model_cost` 表的新模型(如 `claude-opus-4-8`)在 cost/token -计量里调 `get_llm_provider` 失败时会先 `print` 装饰性的「Provider List: …」(ANSI 红字)再抛错(错误被吞、不影响结果), -该 print 会污染 stdout、漏进评审总结——置此开关关掉这些 print。 - -### 本地 CLI provider - -让用户**不填 API key、改用本机已装且已登录的 agentic CLI**(一期仅 **Claude Code**)跑评审。LLM Profile 里 -新增 `provider='cli'`,`model` 字段填命令名(`claude`)。其余 provider 走 litellm 直连 API,cli 模式则**完全绕过 -litellm**。 - -- **接入点**:env `MEEBOX_CLI_MODE=1` + `MEEBOX_CLI_BIN=claude`(由 `buildPragentEnv` 注入)→ shim 把 - `LiteLLMAIHandler.chat_completion` 整体换成「起 `claude -p --output-format json` 子进程、prompt 走 stdin、 - 解析 JSON 的 `result` 文本 + `usage`」的版本,返回 `(text, "stop")`。**只依赖 `base_ai_handler` 的稳定契约, - 不受版本守卫限制**(区别于其它依赖内部实现的补丁,放在版本守卫之前)。子进程调用逻辑抽在 `cli/install.py` - 的 `run_cli_chat`,`_install_cli_chat_completion`(服务 pr-agent 工具 run)与编排 chat 通道共用它。 -- **编排 chat 通道 CLI 短路**:上一条服务的是 **pr-agent 工具 run**(`/describe` `/review` `/ask` 经 `pr_agent.cli`, - 必经 `chat_completion`)。**编排自有步骤**(路由 / judge / summary 经 `meebox_pragent_shim.chat`)则在 CLI 模式 - **直接调 `run_cli_chat`、不 import pr_agent / litellm**——CLI 路径本就不用 litellm,无谓地拉起整套 pr_agent + - litellm import 会给每次 chat 子进程白增数百 ms~1s+ 启动开销,而编排一个流程要调多次。API 模式无此短路(litellm - 即 HTTP 客户端、不可绕),仍复用被补丁的 `LiteLLMAIHandler` 以继承 provider 路由 / 去 temperature / 提示缓存 / - usage 哨兵。 -- **prompt 走 stdin**:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限;system/user 合并成 - 一段喂入(CLI 无独立 system 槽)。cwd 默认落到中性临时目录,避免吃到被评审仓库的 `CLAUDE.md`/`AGENTS.md`。 -- **`/ask` 例外(取完整文件上下文)**:自由问答需读真实文件,仅对 `/ask` 由主进程下发 env `MEEBOX_CLI_WORKDIR` - = 物化好的 worktree,shim 据此把子进程 cwd 落到 worktree(`describe`/`review` 不下发、维持中性临时目录)。 - 落 cwd 前主进程先**清空该 worktree 内仓库自带的 agent 指令文件**(`CLAUDE.md`/`AGENTS.md`/`GEMINI.md`/`.cursor` - 规则 / `.github/copilot-instructions.md`,见 `services/pr-agent/worktree-sanitize.ts`)——worktree 即 PR HEAD、 - 作者可控,不清空则 CLI 会自动加载这些指令、被评审 PR 可经此注入 / 污染回答;worktree 用后即弃,就地清空无副作用。 -- **沿用 CLI 自身登录态**:子进程继承 `HOME`/`USERPROFILE`,CLI 读自己的登录凭据(如 `~/.claude`)运行。 - 为避免本机环境里残留的 API key 串入、覆盖 CLI 自身的登录方式,shim 显式从子进程 env **剥掉 - `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`**。使用的模型、额度与合规均由该 CLI 的账户与用户授权决定。 -- **代理自动透传**:子进程 env 由 `os.environ` 拷贝而来(仅剔除上面两个 API key),`HTTP(S)_PROXY` / `NO_PROXY` - 原样保留 → `claude` 出站自动走用户配置的代理(见 [网络与代理](../99-core/03-networking-proxy.md)),无需另设。 -- **token usage**:从 claude JSON 的 `usage` 构造同款 `@@MEEBOX_USAGE@@` 哨兵,主进程同一套累加。↑输入总量取 - `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`(模型实际处理的全部输入侧);其中 - `cache_read_input_tokens`(命中量)**单列上抛**供 UI 拆分展示,顶层 `num_turns`(agentic 轮次)一并上抛。详见 - 下「CLI 模式的 token 计量与提示缓存」。 -- **一期边界**:仅 `claude`(UI 校验拦下 codex 等,命令框可输入留待后续);并发为「一次调用一个子进程」;父进程被 - 超时 SIGKILL 时子 `claude` 可能短暂遗留(孤儿),后续可补 kill 传播。 - -### CLI 模式的 token 计量与提示缓存 - -CLI(claude / codex)模式下运行卡片的 token 数常远超模型单请求上下文窗口(如 `/ask` 出现 ↑数百万),这**不是超限、也不是计量 -错误**,而是 agentic 多轮 + 提示缓存的自然结果。要点: - -- **累计语义**:`claude -p` 是 agentic headless,一次 run 内部会有多个模型轮次(`num_turns`)。顶层 `usage` 是**该会话所有轮次 - 的累加**,每轮都把不断增长的对话 / 工具结果重新送进模型,故 token 随轮次叠加。单轮从不超窗口(CLI 自身做上下文压缩),累计值 - 膨胀属正常。UI 据 `num_turns` 展示轮次(单轮不显示),帮助理解「这是 N 轮的总量、非单请求规模」。 -- **`cache_read` vs `cache_creation`(命中 ≠ 写入)**:Anthropic 提示缓存把输入分三段——`input_tokens`(新内容)、 - `cache_creation_input_tokens`(**写入**缓存,计费 1.25×/2×)、`cache_read_input_tokens`(**读取命中**,计费 0.1×)。UI 的 - 「缓存命中量(⛁)」**只取 `cache_read`**,写入不计作命中。多轮里每轮都重读已缓存前缀,`cache_read` 跨轮累加,故对多轮 run 常 - 呈现「缓存命中量 ≈ 输入总量」(绝大部分输入都是缓存读)。**codex 走 OpenAI 约定**:`input_tokens` 本身**已含**缓存、命中量字段名为 - `cached_input_tokens`,故采集层(`cli/install.py`)两种字段名都识别——Anthropic 的 `cache_read_input_tokens` 需累加进总量、 - codex 的 `cached_input_tokens` 仅作命中量不再计入总量。 -- **缓存暖化与任务顺序**:claude CLI 自身在缓存 TTL(5min / 1h)内对相同前缀做服务端缓存(基础 system prompt、工具定义,乃至相同 - 的 diff 段)。**先跑过 `describe` 会暖好缓存**,随后对同 PR 的 `review` 多为 `cache_read` 命中、真正新增输入很小 → 表现为「输入 - 很少、几乎全是缓存读」。这是顺序执行带来的正常优化,不是统计异常。 -- **并行启动对缓存命中的影响**:run 队列(`services/pr-agent/run-queue.ts` 的 `pump()`)在并发未达上限时同步连续起跑,故同 PR 的 - describe / review / improve 几乎同时启动(~100ms 间隔)。Anthropic 缓存条目要等**首个请求写完**才可被后续读,并行启动时后发 - 请求读不到尚未落地的缓存 → 共享前缀各自 miss + 各自写、命中率下降。但**影响有限**:受影响的只是跨 run 的小共享前缀(基础 system - ~20–30k,通常已被其它 claude 活动暖好),而大额 `cache_read` 来自**单 run 内部的多轮重读**、与并行无关。故一般**不值得**为缓存复用 - 而串行化同 PR 任务;若确要最大化跨 run 复用,可考虑「describe 先行、完成后再放行其余」的轻量调度(收益有限,未实现)。 -- **litellm 路径与跨模型缓存适配**:API 模式下显式 `cache_control` 标记**仅 Anthropic 系生效**(原生 / Bedrock / Vertex Claude)。 - `_apply_system_prompt_cache`(`patches/litellm_handler.py`)对 Anthropic 标 1h TTL 缓存,覆盖两类调用: - - **编排 chat 通道**(`MEEBOX_CHAT_CACHE` 置位、system 含 `CACHE_BREAK`)按断点缓存全局稳定前缀; - - **pr-agent 工具 run**(`/review` `/describe` `/improve` `/ask`,无 `CACHE_BREAK`)整段缓存 system——pr-agent 的指令 + 输出格式约 12k 字符、仅随配置/语言/规则变、跨 PR 稳定(可变的 diff 在 user 侧不进缓存),故同配置下跨运行 1h 内命中。 - - OpenAI / DeepSeek 走**自动前缀缓存**(无需标记,把稳定内容放前缀 - 即可命中,shim 对非 Anthropic 自动剥除标记拼回纯文本);openai-compatible(DashScope / 火山 / vLLM)能否命中取决于后端, - `cache_control` 一律被忽略。两条路径(CLI / API)都采集 `cache_read`(API 取 Anthropic `cache_read_input_tokens` 或 OpenAI - `prompt_tokens_details.cached_tokens`),UI 展示一致。 - -### 注入 env - -每次 run 给子进程注入:LLM provider 凭据(`OPENAI__KEY` / `DEEPSEEK__KEY` / `ANTHROPIC__KEY` 等,按 provider 分族; -cli 模式不下发任何密钥,只给 `MEEBOX_CLI_MODE` / `MEEBOX_CLI_BIN` 两个哨兵)+ -模型名 + 响应语言 + 命中规则的 `EXTRA_INSTRUCTIONS`(见 [规则](04-rules.md))+ 出站代理(见 [网络与代理](../99-core/03-networking-proxy.md))。 - -## 数据 / 接口契约 - -- **策略**:`'auto' | 'embedded' | 'local-cli'`(配置 `pr_agent.strategy`)。 -- **run 选项**:`prUrl` / `tool('describe'|'review'|'ask')` / `cwd`(worktree) / `targetBranch` / `env` / `extraArgs` / - `onLine`(stdout/stderr 实时回调) / `signal`(取消)。 -- **运行时 manifest**:pin 的 python 主次版本 + pr-agent 版本(升级时与 shim 的 `_EXPECTED_PRAGENT_VERSION` 同步)。 -- **shim 调试**:`MEEBOX_SHIM_DEBUG=1` → shim 打 stderr 诊断。 - -## 扩展与注意事项 - -- **升级 pr-agent**:改 manifest 版本 → 同步 shim 的 `_EXPECTED_PRAGENT_VERSION` → 重新验证各补丁(构建期会强校验 - 两处一致,漏同步直接 fail;运行期不符则降级 + WARNING)。 -- **改了 shim**:跑一次 `prepare:pragent` 即重新同步进 vendor(幂等跳过分支也会同步 shim),无需 `--force` 全量重建。 -- **流式模型丢 usage**:个别需强制流式的模型用 MockResponse、无 usage,token 采集对它们缺失(非流式不受影响)。 -- **启动开销**:嵌入式本地进程下当前无明显瓶颈。若大 PR 再现问题,可裁剪 pr-agent 内部预处理(env 开关)。 -- **平台范围**:嵌入式运行时初版只出 Windows x64 + macOS arm64(见 [打包与发布](../../development/packaging-release.md))。 +Design principles: + +- **Lazy post-import hook**: register a meta_path finder that patches only when the target module is **actually imported** (= a real run); + never eager-import pr_agent at the sitecustomize stage (which would slow down every startup/probe/pip). Each patch module's import of `pr_agent` + is inside the patch function body; the top level only imports the same package's runtime/usage — so importing this package doesn't trigger pr_agent loading. +- **Multiple patches of the same module merge into one patch_fn**: registering multiple finders for the same module shadow each other, and only the frontmost takes effect. +- **Version guard**: patches depend on a specific pr-agent version's internal implementation → if `_EXPECTED_PRAGENT_VERSION` ≠ the actually installed version at runtime, + **skip all patches and emit a stderr WARNING** (safe degradation); at build time it hard-checks the shim constant == the manifest version, failing outright on mismatch. + +Current patches: +- **Binary-safe diff**: the original `get_diff_files` blindly utf-8-decodes every file and crashes on binary → changed to skip on decode failure. +- **anchor line numbers** (details in [Review workflow](../01-platform/03-review-workflow.md)): patch `get_line_link` to return `meebox:///<file>#L<s>-L<e>`, + letting `/review`'s key_issues render with a structured file:line. +- **Anthropic drops temperature**: new Claude models deprecate temperature, so all `anthropic/*` are put into the "don't send temperature" set. +- **load_yaml tolerance**: an anchor marker taking a whole line breaks YAML → on parse failure, strip the marker and retry, avoiding a whole review crash. +- **Local CLI provider**: when `MEEBOX_CLI_MODE` is set, replace `chat_completion` wholesale with the "call the local CLI" version (see below). +- **token usage collection**: see below. + +### Real token usage + +Inline-wrap pr-agent's `_get_completion`, take `prompt/completion/total_tokens` from the returned `response.usage`, +and print it to **stderr** as the sentinel line `@@MEEBOX_USAGE@@ {json}`; the main process captures line by line, accumulates by prefix, and lands it on the run (see [Review workflow](../01-platform/03-review-workflow.md)). +**Why inline rather than a litellm callback**: litellm's async callback goes through a background logging worker, and a short-lived CLI exiting too fast gets it dropped; +inline within the await chain must execute before exit, so it's reliable. Only tokens are taken, not cost → uniformly set `LITELLM_LOCAL_MODEL_COST_MAP=True` +to turn off litellm's remote price-table networking (a weak network causes SSL timeout). Also set `litellm.suppress_debug_info=True` at patch time: the orchestration chat +channel uses the subprocess **stdout** as the model reply, and for a new model not in the local `model_cost` table (e.g. `claude-opus-4-8`), litellm, on failing to call +`get_llm_provider` in cost/token metering, first `print`s a decorative "Provider List: …" (ANSI red text) before raising (the error is swallowed and doesn't affect the result), +and that print would pollute stdout and leak into the review summary — this switch turns those prints off. + +### Local CLI provider + +Let the user run reviews **without filling in an API key, using a locally installed and logged-in agentic CLI** (phase one is **Claude Code** only). The LLM Profile +gains `provider='cli'`, with the `model` field holding the command name (`claude`). Other providers go through litellm direct-to-API, while cli mode **fully bypasses +litellm**. + +- **Entry point**: env `MEEBOX_CLI_MODE=1` + `MEEBOX_CLI_BIN=claude` (injected by `buildPragentEnv`) → the shim swaps + `LiteLLMAIHandler.chat_completion` wholesale for the version that "spawns a `claude -p --output-format json` subprocess, feeds the prompt via stdin, + parses the JSON `result` text + `usage`", returning `(text, "stop")`. It **depends only on the stable contract of `base_ai_handler`, + free of the version guard** (unlike the other patches that depend on internal implementation, it goes before the version guard). The subprocess-invocation logic is factored into `run_cli_chat` in + `cli/install.py`, shared by `_install_cli_chat_completion` (serving pr-agent tool runs) and the orchestration chat channel. +- **Orchestration chat channel CLI shortcut**: the previous item serves the **pr-agent tool run** (`/describe` `/review` `/ask` via `pr_agent.cli`, + which must pass `chat_completion`). The **orchestration's own steps** (routing / judge / summary via `meebox_pragent_shim.chat`) in CLI mode + **call `run_cli_chat` directly, without importing pr_agent / litellm** — the CLI path doesn't use litellm anyway, and needlessly spinning up the whole pr_agent + + litellm import would add hundreds of ms to 1s+ of startup overhead per chat subprocess, while orchestration calls many times per flow. API mode has no such shortcut (litellm + is the HTTP client, unbypassable), still reusing the patched `LiteLLMAIHandler` to inherit provider routing / temperature-drop / prompt cache / + usage sentinel. +- **Prompt via stdin**: the review prompt contains the full diff (tens of KB), and passing via argv would hit the command-line length limit; system/user are merged into + one block to feed in (the CLI has no separate system slot). cwd defaults to a neutral temp directory, avoiding picking up the reviewed repo's `CLAUDE.md`/`AGENTS.md`. +- **`/ask` exception (take full file context)**: free Q&A needs to read real files, so only for `/ask` the main process hands down env `MEEBOX_CLI_WORKDIR` + = the materialized worktree, and the shim accordingly sets the subprocess cwd to the worktree (`describe`/`review` don't get it, keeping the neutral temp directory). + Before setting cwd, the main process first **clears the repo's own agent instruction files inside that worktree** (`CLAUDE.md`/`AGENTS.md`/`GEMINI.md`/`.cursor` + rules / `.github/copilot-instructions.md`, see `services/pr-agent/worktree-sanitize.ts`) — the worktree is the PR HEAD, + author-controlled, and if not cleared the CLI would auto-load these instructions, letting the reviewed PR inject / pollute the answer through them; the worktree is discarded after use, so clearing in place has no side effect. +- **Reuse the CLI's own login state**: the subprocess inherits `HOME`/`USERPROFILE`, and the CLI reads its own login credentials (e.g. `~/.claude`) to run. + To avoid a stray API key in the local environment leaking in and overriding the CLI's own login method, the shim explicitly strips + `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from the subprocess env. The model used, the quota and compliance are all decided by that CLI's account and the user's authorization. +- **Proxy auto pass-through**: the subprocess env is copied from `os.environ` (only the two API keys above removed), and `HTTP(S)_PROXY` / `NO_PROXY` + are kept as-is → `claude`'s egress automatically goes through the user-configured proxy (see [Networking & proxy](../99-core/03-networking-proxy.md)), no extra setup needed. +- **token usage**: from the claude JSON's `usage`, construct the same `@@MEEBOX_USAGE@@` sentinel, accumulated by the same main-process path. The ↑ total input takes + `input_tokens + cache_read_input_tokens + cache_creation_input_tokens` (the entire input side the model actually processed); of which + `cache_read_input_tokens` (the cache read) is **listed out separately** for UI split display, and the top-level `num_turns` (agentic turns) is thrown up alongside. Details in + "Token metering & prompt cache in CLI mode" below. +- **Phase-one boundary**: `claude` only (UI validation blocks codex, etc.; the command box accepts input pending later work); concurrency is "one subprocess per call"; when the parent process is + SIGKILLed on timeout, the child `claude` may briefly linger (orphan), and kill propagation can be added later. + +### Token metering & prompt cache in CLI mode + +In CLI (claude / codex) mode a run card's token count is often far beyond the model's single-request context window (e.g. `/ask` showing ↑ millions); this is **not over-limit, nor a metering +error**, but the natural result of agentic multi-turn + prompt caching. Key points: + +- **Accumulative semantics**: `claude -p` is agentic headless, and one run internally has multiple model turns (`num_turns`). The top-level `usage` is **the accumulation across all turns + of that session**; each turn re-feeds the ever-growing conversation / tool results into the model, so tokens stack with the turns. A single turn never exceeds the window (the CLI itself does context compression), and the accumulated value + bloating is normal. The UI shows the turns per `num_turns` (not shown for a single turn), helping the reader understand "this is the total across N turns, not a single-request scale". +- **`cache_read` vs `cache_creation` (a read hit ≠ a write)**: Anthropic prompt caching splits input into three parts — `input_tokens` (new content), + `cache_creation_input_tokens` (**written** to cache, billed 1.25×/2×), `cache_read_input_tokens` (**read hit**, billed 0.1×). The UI's + "cache read (⛁)" **takes only `cache_read`**; a write does not count as a hit. In multi-turn, every turn re-reads the cached prefix, and `cache_read` accumulates across turns, so a multi-turn run often + shows "cache read ≈ total input" (the vast majority of input is cache reads). **codex follows the OpenAI convention**: `input_tokens` itself **already includes** cache, and the hit field name is + `cached_input_tokens`, so the collection layer (`cli/install.py`) recognizes both field names — Anthropic's `cache_read_input_tokens` must be accumulated into the total, while + codex's `cached_input_tokens` counts only as the hit and is not added to the total again. +- **Cache warming & task order**: the claude CLI itself server-side-caches the same prefix within the cache TTL (5min / 1h) (the base system prompt, tool definitions, even the same + diff segment). **Running `describe` first warms the cache**, and a subsequent `review` on the same PR is mostly `cache_read` hits, with very little truly new input → showing "very + little input, almost all cache reads". This is a normal optimization from sequential execution, not a statistical anomaly. +- **Effect of parallel startup on cache hits**: the run queue (`pump()` in `services/pr-agent/run-queue.ts`) starts synchronously and continuously when concurrency is below the cap, so the same PR's + describe / review / improve start almost simultaneously (~100ms apart). An Anthropic cache entry can only be read by later requests after the **first request finishes writing**, and at parallel startup a later + request can't read the not-yet-landed cache → the shared prefix each misses + each writes, and the hit rate drops. But **the impact is limited**: only the small cross-run shared prefix is affected (the base system + ~20–30k, usually already warmed by other claude activity), while the large `cache_read` comes from **the multi-turn re-reads within a single run**, unrelated to parallelism. So it is generally **not worth** + serializing same-PR tasks for cache reuse; if you really want to maximize cross-run reuse, consider lightweight scheduling of "describe first, release the rest after it finishes" (limited gain, not implemented). +- **litellm path & cross-model cache adaptation**: in API mode the explicit `cache_control` marker **takes effect only on the Anthropic family** (native / Bedrock / Vertex Claude). + `_apply_system_prompt_cache` (`patches/litellm_handler.py`) marks a 1h-TTL cache for Anthropic, covering two kinds of calls: + - **Orchestration chat channel** (`MEEBOX_CHAT_CACHE` set, system contains `CACHE_BREAK`) caches the globally stable prefix by breakpoint; + - **pr-agent tool run** (`/review` `/describe` `/improve` `/ask`, no `CACHE_BREAK`) caches the whole system — pr-agent's instructions + output format are about 12k chars, vary only by config/language/rules, and are stable across PRs (the variable diff is on the user side and not cached), so under the same config it hits across runs within 1h. + + OpenAI / DeepSeek use **automatic prefix caching** (no marking needed; put stable content in the prefix + to hit; the shim auto-strips the markers for non-Anthropic and reassembles plain text); whether openai-compatible (DashScope / Volcano / vLLM) hits depends on the backend, + and `cache_control` is always ignored. Both paths (CLI / API) collect `cache_read` (API takes Anthropic's `cache_read_input_tokens` or OpenAI's + `prompt_tokens_details.cached_tokens`), and the UI display is consistent. + +### Env injection + +Each run injects into the subprocess: LLM provider credentials (`OPENAI__KEY` / `DEEPSEEK__KEY` / `ANTHROPIC__KEY`, etc., grouped by provider family; +cli mode sends no secret, only the two sentinels `MEEBOX_CLI_MODE` / `MEEBOX_CLI_BIN`) + +model name + response language + the matched rules' `EXTRA_INSTRUCTIONS` (see [Rules](04-rules.md)) + the outbound proxy (see [Networking & proxy](../99-core/03-networking-proxy.md)). + +## Data / interface contract + +- **Strategy**: `'auto' | 'embedded' | 'local-cli'` (config `pr_agent.strategy`). +- **Run options**: `prUrl` / `tool('describe'|'review'|'ask')` / `cwd`(worktree) / `targetBranch` / `env` / `extraArgs` / + `onLine`(stdout/stderr real-time callback) / `signal`(cancellation). +- **Runtime manifest**: pinned python major.minor version + pr-agent version (synced with the shim's `_EXPECTED_PRAGENT_VERSION` on upgrade). +- **shim debugging**: `MEEBOX_SHIM_DEBUG=1` → the shim prints stderr diagnostics. + +## Extension & caveats + +- **Upgrading pr-agent**: change the manifest version → sync the shim's `_EXPECTED_PRAGENT_VERSION` → re-verify each patch (the build time hard-checks + the two are consistent, failing outright if a sync is missed; at runtime a mismatch degrades + WARNING). +- **Changed the shim**: run `prepare:pragent` once to re-sync into vendor (the idempotent skip branch also syncs the shim), no `--force` full rebuild needed. +- **Streaming models drop usage**: a few models that must force streaming use MockResponse and have no usage, so token collection is missing for them (non-streaming is unaffected). +- **Startup overhead**: no obvious bottleneck currently under the embedded local process. If a large PR reproduces the issue, pr-agent's internal preprocessing can be trimmed (env switch). +- **Platform scope**: the embedded runtime's first release ships only Windows x64 + macOS arm64 (see [Packaging & release](../../development/packaging-release.md)). diff --git a/docs/arch/02-agent/06-tool-token-cost.md b/docs/arch/02-agent/06-tool-token-cost.md index 819902b9..2114e8d6 100644 --- a/docs/arch/02-agent/06-tool-token-cost.md +++ b/docs/arch/02-agent/06-tool-token-cost.md @@ -1,80 +1,80 @@ -# 工具 token 成本与上下文分层 +# Tool token cost & context tiers -## 职责与边界 +## Responsibilities & boundaries -聚焦 pr-agent 各工具(`/describe` `/review` `/improve` `/ask`)与自由对话 Agent 的**上下文来源与 token -成本模型**:上下文分几层、成本在哪一层放量、以何种手段收敛而不牺牲评审深度。运行时机制(调用桥 / 嵌入式 -Python / monkeypatch / token 采集)见 [pr-agent 集成与运行时](05-pragent-runtime.md);会话 Agent 化与规划循环见 -[会话 Agent 化](02-session.md);worktree 物化见 [仓库镜像](../01-platform/02-repo-mirror.md)。 +Focus on the **context sources and token cost model** of the pr-agent tools (`/describe` `/review` `/improve` `/ask`) and the free-conversation Agent: +how many tiers the context has, at which tier cost scales up, and by what means to converge it without sacrificing review depth. The runtime mechanisms (invocation bridge / embedded +Python / monkeypatch / token collection) are in [pr-agent integration & runtime](05-pragent-runtime.md); agentic sessions and the planning loop are in +[Agentic sessions](02-session.md); worktree materialization is in [Repo mirror](../01-platform/02-repo-mirror.md). -结论先行:成本放量集中在「本机 agentic CLI 提供方下的 `/ask`」与「自由对话 Agent 的规划循环」——前者一次 -探索可跨多轮读文件,后者可连发多次这样的 `/ask`。**不存在「diff-only 的盲评审」**:pr-agent 默认已把变更的 -近端上下文(所在函数 / 类、周边行、整份新文件、best-practices)注入 prompt。 +Conclusion first: cost scaling concentrates in "`/ask` under the local agentic CLI provider" and "the free-conversation Agent's planning loop" — the former can +read files across multiple turns in one exploration, the latter can fire multiple such `/ask` in a row. **There is no "diff-only blind review"**: by default pr-agent already injects the +near-end context of the change (the enclosing function / class, surrounding lines, the whole new file, best-practices) into the prompt. -## 三层上下文模型 +## Three-tier context model -评审上下文并非「diff vs 全仓」二选一,而是三层递进: +Review context is not "diff vs whole repo, pick one" but three progressive tiers: -| 层 | 上下文 | 成本 | 本仓何处使用 | +| Tier | Context | Cost | Where used in this repo | |----|--------|------|-------------| -| 1 · 裸 diff | 仅 `+/-` 行 | 极低 | 无(不单独使用) | -| 2 · **展开 diff(pr-agent 默认)** | diff + 动态上下文补到**所在函数 / 类** + hunk 周边行 + 整份新文件 + best-practices | **有界、确定**(受 `MAX_MODEL_TOKENS` 约束) | `/review` `/describe` `/improve`,以及 `/ask` 的基座 | -| 3 · agentic 探索 | 按需读任意文件、跟调用链、跨文件核对 | **无界**(轮次 × 累计上下文,近平方增长) | 仅 `/ask` + CLI 提供方(下发 worktree cwd);自由对话 Agent 可跨步多次触发 | +| 1 · bare diff | only `+/-` lines | very low | none (not used standalone) | +| 2 · **expanded diff (pr-agent default)** | diff + dynamic context extended to the **enclosing function / class** + hunk surrounding lines + the whole new file + best-practices | **bounded, deterministic** (constrained by `MAX_MODEL_TOKENS`) | `/review` `/describe` `/improve`, and the base of `/ask` | +| 3 · agentic exploration | read any file on demand, follow the call chain, cross-file cross-check | **unbounded** (turns × accumulated context, near-quadratic growth) | only `/ask` + the CLI provider (hands down the worktree cwd); the free-conversation Agent can trigger it multiple times across steps | -第 2 层是 pr-agent 对「裸 diff 不足以评审」的既定答案:以确定成本纳入变更的近端爆炸半径,而非放 agent 自由 -探索。pr-agent 默认即开([configuration.toml](../../../apps/desktop/vendor/pragent/python/Lib/site-packages/pr_agent/settings/configuration.toml): -`allow_dynamic_context=true`、`patch_extra_lines_before=5`/`_after=1`、`max_extra_lines_before_dynamic_context=10`、 -`best_practices` 等),本应用未覆盖这些默认,故 `/review` 天然享有第 2 层上下文——缺的是**远端**上下文(别处 -文件的调用方、跨模块契约、数个文件外的不变量)。 +Tier 2 is pr-agent's established answer to "a bare diff isn't enough to review": include the change's near-end blast radius at a deterministic cost, rather than letting the agent explore +freely. pr-agent has it on by default ([configuration.toml](../../../apps/desktop/vendor/pragent/python/Lib/site-packages/pr_agent/settings/configuration.toml): +`allow_dynamic_context=true`, `patch_extra_lines_before=5`/`_after=1`, `max_extra_lines_before_dynamic_context=10`, +`best_practices`, etc.); this app does not override these defaults, so `/review` naturally enjoys tier-2 context — what's missing is the **remote** context (callers in other +files, cross-module contracts, invariants several files away). -第 3 层只在 CLI-`/ask`:`buildInvocation` 仅当 `tool==='ask' && provider==='cli'` 时下发 `MEEBOX_CLI_WORKDIR`,让 -agentic CLI 在 worktree 里读文件、多轮迭代。此时 CLI **仍先拿到 pr-agent 渲染的展开 diff**(见 pr_questions 模板 -的 `The PR Git Diff`)——它不是「只有问题、从零翻找」,而是「已有第 2 层、再探第 3 层」。 +Tier 3 is only in CLI-`/ask`: `buildInvocation` hands down `MEEBOX_CLI_WORKDIR` only when `tool==='ask' && provider==='cli'`, letting the +agentic CLI read files in the worktree and iterate across turns. At this point the CLI **still first receives the pr-agent-rendered expanded diff** (see the `The PR Git Diff` of the pr_questions template) — +it is not "only the question, searching from scratch", but "already has tier 2, then explores tier 3". -## 成本驱动 +## Cost drivers -- **CLI-`/ask` 的 agentic 探索(第 3 层)**:worktree 可读、无轮次上限,倾向整文件通读 / 全仓扫描;每轮携带 - 不断增长的对话 + 工具结果重传,近平方增长。CLI 模式下 `MAX_MODEL_TOKENS` 被忽略(CLI 自管上下文窗口), - 无应用侧输入上限。 -- **自由对话 Agent 的规划循环**:仅受「Agent 最大步数」`max_steps`(默认 8)约束,可跨多步连发 `/ask`,每次都是 - 一次第 3 层探索——若不另加约束,成本随步数叠加而失控。 +- **CLI-`/ask`'s agentic exploration (tier 3)**: the worktree is readable with no turn cap, tending toward whole-file read-throughs / whole-repo scans; each turn carries the + ever-growing conversation + tool results and re-transmits, near-quadratic growth. In CLI mode `MAX_MODEL_TOKENS` is ignored (the CLI self-manages the context window), + with no app-side input cap. +- **The free-conversation Agent's planning loop**: constrained only by the "Agent max steps" `max_steps` (default 8), it can fire `/ask` in a row across multiple steps, each being + one tier-3 exploration — without extra constraints, cost gets out of control as the steps stack. -第 2 层(含 API 提供方的 `/ask`、所有 review/describe/improve)是单轮、受 token 预算硬约束的确定成本。 +Tier 2 (including the API provider's `/ask`, all review/describe/improve) is a single-turn, token-budget-hard-constrained deterministic cost. -## 优化措施 +## Optimization measures -目标:让第 2 层足够充分(廉价、确定),让第 3 层的**必要**探索更高效、**数量**受控——而非砍掉探索降级评审。 +Goal: make tier 2 sufficiently full (cheap, deterministic), and make tier 3's **necessary** exploration more efficient and its **quantity** controlled — rather than cutting exploration to degrade the review. -### 已实现 +### Implemented -- **CLI-`/ask` 只读代码检索指引**:`buildExtraInstructions` 的 `worktreeRetrievalDirective`(仅 CLI 提供方注入) - 引导以 diff 为改动真源、**定向搜符号 · 只读所需行段**替代整文件通读与全仓扫描,够用即止。刻意只用**只读** - 工具集——headless(无 TTY)下 claude default 权限模式对内置只读工具(Read/Grep、以及 `grep`·`git log/show` - 等只读 Bash 命令)静默放行、无授权摩擦,但对非只读工具(写、以及 `rg` 等不在内置只读白名单的命令)不是 - 拒绝而是**直接中止会话**,故明确「用 `grep` 不用 `rg`」、禁止改动类命令。只依赖「内置只读工具静默放行」这一 - 跨版本稳定语义,不加 `--allowedTools`/`--permission-mode` 等依赖具体 claude 版本的启动参数。 -- **自由对话 Agent 的 `/ask` 预算**:规划循环按配置「追问数量」`max_followup_asks` 对本会话 `/ask` 计数封顶, - 达上限即在红线校验处拒绝新的 `/ask` 并回喂(促模型据现有上下文收尾或改用只读工具);`describe/review/improve` - 不受此约束、`max_steps` 不变。**count-only**:始终按配置的追问数量生效,与「自动追问」开关无关(开关仅约束 - 评审微流程的条件追问)。评审微流程侧的条件追问(judge 步)与本预算共用同一配置值。 +- **CLI-`/ask` read-only code-retrieval directive**: `buildExtraInstructions`'s `worktreeRetrievalDirective` (injected only for the CLI provider) + guides taking the diff as the true source of the change, **searching for symbols in a targeted way · reading only the needed line ranges** instead of whole-file read-throughs and whole-repo scans, stopping once sufficient. It deliberately uses only the **read-only** + tool set — under headless (no TTY), claude default permission mode silently allows built-in read-only tools (Read/Grep, plus `grep` · `git log/show` + and other read-only Bash commands) with no authorization friction, but for a non-read-only tool (writes, plus `rg` and other commands not in the built-in read-only whitelist) it does not + reject but **aborts the session outright**, so it makes explicit "use `grep` not `rg`" and forbids mutating commands. It relies only on the cross-version-stable semantic of "built-in read-only tools are silently allowed", + without adding `--allowedTools`/`--permission-mode` and other startup args that depend on a specific claude version. +- **The free-conversation Agent's `/ask` budget**: the planning loop caps this session's `/ask` count per the configured "follow-up count" `max_followup_asks`, + and on reaching the cap rejects a new `/ask` at the red-line check and feeds it back (prompting the model to wrap up from the existing context or switch to a read-only tool); `describe/review/improve` + are not subject to this, and `max_steps` is unchanged. **count-only**: it always takes effect per the configured follow-up count, independent of the "auto follow-up" switch (the switch only constrains + the conditional follow-up of the review micro-flow). The review-micro-flow-side conditional follow-up (the judge step) shares the same config value as this budget. -### 已否决 +### Rejected -- **给 CLI-`/ask` 前置注入统一 diff**:CLI-`/ask` 的 prompt 已由 pr_questions 模板携带 pr-agent 的展开 diff(第 2 - 层),再注入一份只是重复上下文、徒增 prompt,且不减少第 3 层探索(探索是为拿 diff 之外的远端上下文)。 +- **Pre-injecting a unified diff for CLI-`/ask`**: the CLI-`/ask` prompt already carries pr-agent's expanded diff via the pr_questions template (tier + 2), and injecting another copy only duplicates the context, bloats the prompt, and does not reduce tier-3 exploration (which exists to get the remote context beyond the diff). -### 待评估(暂缓) +### Under evaluation (deferred) -- **codegraph 作为检索工具**:把「盲读」换成「定向查(定义 / 调用方 / 影响面)」,收敛第 3 层浪费。**仅适用于 - 有工具循环的 agentic 路径(`/ask`)**:作为 MCP server 挂给 headless CLI。`/review` 是单轮工具、无工具循环, - codegraph 对它只能是 **orchestrator 侧预注入**(组 prompt 时选取远端上下文拼进第 2 层),而非「传给 review 的 - 工具」。成本:worktree 每次物化即临时,需每次建图或改对长驻镜像增量维护并保鲜;claude 支持 MCP、codex 不支持 - 外部 MCP 注入。宜在既有措施仍不足时再上。 -- **单次 `/ask` 的 agentic 轮次上限**(claude `--max-turns`):作安全兜底、非成本主手段;依赖具体 CLI 版本对该 - 旗标的支持,暂不引入。 +- **codegraph as a retrieval tool**: swap "blind reading" for "targeted query (definition / callers / impact surface)", converging tier-3 waste. **Applies only to + the agentic path with a tool loop (`/ask`)**: mounted as an MCP server for the headless CLI. `/review` is a single-turn tool with no tool loop, and + codegraph for it can only be **orchestrator-side pre-injection** (select the remote context when composing the prompt and splice it into tier 2), rather than "a tool passed to review". + Cost: a worktree is temporary on each materialization, requiring building the graph each time or switching to incremental maintenance and freshness-keeping on a long-lived mirror; claude supports MCP, codex does not support + external MCP injection. Better to adopt it only when the existing measures still fall short. +- **Agentic turn cap for a single `/ask`** (claude `--max-turns`): as a safety net, not a primary cost lever; it depends on a specific CLI version's support for that + flag, so not introduced for now. -## 关联 +## Related / See also -- [pr-agent 集成与运行时](05-pragent-runtime.md):调用桥 / 嵌入式运行时 / monkeypatch / token 采集 / env 注入。 -- [会话 Agent 化](02-session.md):规划循环(ReAct)、步数上限、过程留存。 -- [仓库镜像](../01-platform/02-repo-mirror.md):worktree 物化与三点 diff 口径。 +- [pr-agent integration & runtime](05-pragent-runtime.md): invocation bridge / embedded runtime / monkeypatch / token collection / env injection. +- [Agentic sessions](02-session.md): the planning loop (ReAct), the step cap, process retention. +- [Repo mirror](../01-platform/02-repo-mirror.md): worktree materialization and the three-dot diff basis. diff --git a/docs/arch/03-gui/01-ui-interaction.md b/docs/arch/03-gui/01-ui-interaction.md index 3c4af803..b5cb6926 100644 --- a/docs/arch/03-gui/01-ui-interaction.md +++ b/docs/arch/03-gui/01-ui-interaction.md @@ -1,110 +1,110 @@ -# GUI 与交互 +# GUI & interaction -## 职责与边界 +## Responsibilities & boundaries -渲染层(React)的整体布局、各面板职责、跨 PR 状态保活与关键交互约定。渲染层只做展示与交互, -所有数据/IO 经 IPC 调主进程(见 [架构总览](../00-overview.md))。 +The overall layout of the render layer (React), each panel's responsibilities, cross-PR state persistence, and key interaction conventions. The render layer only handles display and interaction; +all data/IO goes through IPC to the main process (see [Architecture overview](../00-overview.md)). -负责:UI 结构、面板交互、前端状态保活、外链/模态/本地偏好等交互规范。不负责:业务逻辑与 IO(在主进程各模块)。 +Owns: UI structure, panel interaction, front-end state persistence, and interaction conventions such as external links / modals / local preferences. Does not own: business logic and IO (those live in the main-process modules). -## 核心设计 +## Core design -### 布局 +### Layout -根组件挂载后做一次 bootstrap(并行拉 app 信息 / 配置 / PR 列表 / pr-agent 状态 / 连接 / 上次同步), -之后是自绘标题栏 + 三栏 + 状态栏的主界面,外加按需浮层: +After the root component mounts it runs a one-time bootstrap (fetching app info / config / PR list / pr-agent status / connections / last sync in parallel), +then renders the main UI of a custom-drawn title bar + three columns + status bar, plus on-demand overlays: ```mermaid flowchart TB - subgraph APP["主界面(无边框自绘)"] + subgraph APP["Main UI (frameless, custom-drawn)"] direction TB - TBAR["TitleBar:品牌名 · 选中 PR 标题 · 系统窗控(整条可拖拽)"] - subgraph BODY["三栏(宽度可拖拽 / 可收起)"] + TBAR["TitleBar: brand name · selected PR title · system window controls (whole bar draggable)"] + subgraph BODY["Three columns (draggable width / collapsible)"] direction LR - SB["Sidebar · 左<br/>待评审 PR 列表<br/>分组折叠 · 过滤 · 搜索"] - MP["MainPane · 中<br/>变更 / 评论 / 提交 / 详情<br/>「变更」= DiffView"] - CP["ChatPane · 右<br/>对话驱动 pr-agent<br/>/describe · /review · /ask"] + SB["Sidebar · left<br/>Review-requested PR list<br/>grouped & collapsible · filter · search"] + MP["MainPane · center<br/>Changes / Comments / Commits / Details<br/>'Changes' = DiffView"] + CP["ChatPane · right<br/>conversation-driven pr-agent<br/>/describe · /review · /ask"] end - SBAR["StatusBar:pr-agent 状态/队列 · 同步进度 · 最近同步 · LLM"] + SBAR["StatusBar: pr-agent status/queue · sync progress · last sync · LLM"] TBAR --- BODY BODY --- SBAR end - OV["浮层(按需):SettingsModal · OnboardingWizard · 二层模态 · toast"] - APP -. 覆盖 .-> OV + OV["Overlays (on demand): SettingsModal · OnboardingWizard · second-level modals · toast"] + APP -. covers .-> OV ``` -各区职责: +Responsibilities of each area: -- **TitleBar(顶)**:无边框窗口的自绘标题栏(见下「无边框窗口」),展示品牌名 + 选中 PR 标题,整条可拖拽窗口。 -- **Sidebar(左)**:待评审 PR 列表,按 `项目/仓库` 分组 + 手风琴折叠,updatedAt 倒序,状态过滤 + 搜索; - 宽度可拖拽、可整体收起。 -- **MainPane(中)**:选中 PR 的详情,分「变更 / 评论 / 提交 / 详情」标签页。变更页即 **DiffView**。 -- **ChatPane(右)**:对话式驱动 pr-agent(`/describe` `/review` `/ask`),默认收起;宽度可拖拽。 -- **StatusBar(底)**:pr-agent 状态 / 队列、仓库同步进度、最近同步时间、当前 LLM 等胶囊。 -- **浮层**:SettingsModal(设置)、OnboardingWizard(首启向导)、各确认/编辑二层模态、操作级 toast。 +- **TitleBar (top)**: the custom-drawn title bar of the frameless window (see "Frameless window" below), showing the brand name + selected PR title; the whole bar drags the window. +- **Sidebar (left)**: the review-requested PR list, grouped by `project/repo` with accordion collapse, sorted by `updatedAt` descending, with status filter + search; + draggable width, collapsible as a whole. +- **MainPane (center)**: details of the selected PR, split into "Changes / Comments / Commits / Details" tabs. The Changes tab is **DiffView**. +- **ChatPane (right)**: conversation-driven pr-agent (`/describe` `/review` `/ask`), collapsed by default; draggable width. +- **StatusBar (bottom)**: pills for pr-agent status / queue, repo sync progress, last sync time, current LLM, etc. +- **Overlays**: SettingsModal (settings), OnboardingWizard (setup wizard), various confirm/edit second-level modals, and action-level toasts. -### 关键面板 +### Key panels -- **DiffView**:Monaco 并排 diff + 文件树(图标/Git 着色/草稿与评论 chip)+ 行内评论(view zone)+ - blame + 跨文件搜索 + 行内草稿编辑(DraftZone)。文件切换只渲染当前文件。 -- **ChatPane**:run 卡片(RunMeta 显示模型名 + ↑输入/↓输出 token);finding 卡片可「→ 编辑」(跳 Diff - 并进入草稿编辑)/「✗ 拒绝」;队列串行、可中断/重试;命令补全 + 历史。 -- **DraftsPanel**:「草稿」标签页,跨文件浏览草稿,单条/批量发布,与「评论」标签对照本地未发 vs 远端已发。 -- **SettingsModal**:连接 / LLM / 代理 / 规则目录 / 轮询 / repos_dir 的可视化 CRUD,子项用二层编辑模态; - 连接/代理带「测试」。 -- **OnboardingWizard**:首启引导配代码平台(+ 可选 LLM)。 +- **DiffView**: Monaco side-by-side diff + file tree (icons / Git coloring / draft & comment chips) + inline comments (view zone) + + blame + cross-file search + inline draft editing (DraftZone). Switching files only renders the current file. +- **ChatPane**: run cards (RunMeta shows the model name + ↑input/↓output tokens); finding cards support "→ Edit" (jump to the Diff + and enter draft editing) / "✗ Reject"; the queue is serial, interruptible/retryable; command completion + history. +- **DraftsPanel**: the "Drafts" tab, browsing drafts across files, publishing single/batch, and cross-referencing local-unsent vs. remote-published against the "Comments" tab. +- **SettingsModal**: visual CRUD for connection / LLM / proxy / rules directory / polling / repos_dir, with second-level edit modals for sub-items; + connection/proxy come with a "Test" action. +- **OnboardingWizard**: first-launch onboarding that configures the code platform (+ optional LLM). -### 跨 PR 状态保活 +### Cross-PR state persistence -pr-agent run 的实时状态、仓库同步、草稿都用**模块级 store**(`useSyncExternalStore`)持有,并在根组件 -启动时把主进程的事件流(run 进度 / 队列变化 / 同步进度 / 草稿变化)接入。这样切换 PR 时运行中的状态、 -实时 stdout、草稿列表不随组件卸载丢失。 +The live status of a pr-agent run, repo sync, and drafts are all held in **module-level stores** (`useSyncExternalStore`), and the root component +wires the main process's event streams (run progress / queue changes / sync progress / draft changes) into them on startup. This way, when switching PRs, running state, +live stdout, and the draft list are not lost when components unmount. -### 组件分层:App = 组合根,领域逻辑归 hooks +### Component layering: App = composition root, domain logic lives in hooks -`App.tsx` 是**组合根(composition root)**——只做三件事:调用各领域 hook、装配视图模型(廉价派生)、把数据与回调透传给 TitleBar / Sidebar / MainPane / ChatPane / StatusBar 等子组件。**不在 App 内堆领域逻辑**(状态机、effect、IPC 调用、ref 同步等),这些下沉到 `renderer/src/hooks/use*.ts` 各自的领域 hook: +`App.tsx` is the **composition root** — it does only three things: call each domain hook, assemble view models (cheap derivations), and pass data and callbacks through to child components such as TitleBar / Sidebar / MainPane / ChatPane / StatusBar. **No domain logic is piled inside App** (state machines, effects, IPC calls, ref sync, etc.); those sink into the respective domain hooks under `renderer/src/hooks/use*.ts`: -- `usePullRequests`(列表 / 选中 / 审批 / 合并 / 刷新 / 已读)、`useBootstrap`(启动 + 全局生命周期)、`usePrNavigation`(发现分类 / 活跃·归档范围 / 归档懒加载 / 按 URL 打开 / 定位跳转 / 通知点击导航 / 跨组件 Diff·Tab 跳转意图)、`useGlobalShortcuts`(窗口级快捷键)、`usePanelLayout`、`useDockBadge`、`useTheme`、`useToast`、`useUpdateNotice`、`useExternalLinkGuard`、`useAppStores`。 -- **判据**:一段逻辑若含自身的 state/effect/ref 同步、或可独立测试,就该是一个 hook;若只是「把 A 的输出接到 B 的入参」的装配与廉价派生(如按能力位过滤可见筛选项),留在组合根。 -- **避免反向臃肿**:相互依赖的状态(如 scope / selectedId / discoveryFilter)收在**同一个** hook,别拆成多个靠参数互穿 setter 的小 hook。hook 之间有依赖时按「数据源 → 派生」单向组合(如 `usePrNavigation` 组合在 `usePullRequests` 之上)。 +- `usePullRequests` (list / selection / review decision / merge / refresh / read), `useBootstrap` (startup + global lifecycle), `usePrNavigation` (discovery category / active·archived scope / archive lazy-load / open by URL / locate-and-jump / notification-click navigation / cross-component Diff·Tab jump intent), `useGlobalShortcuts` (window-level shortcuts), `usePanelLayout`, `useDockBadge`, `useTheme`, `useToast`, `useUpdateNotice`, `useExternalLinkGuard`, `useAppStores`. +- **Criterion**: a piece of logic that carries its own state/effect/ref sync, or that can be tested independently, should be a hook; if it is merely the "wire A's output into B's input" assembly and cheap derivation (e.g. filtering visible filter options by capability flags), it stays in the composition root. +- **Avoid reverse bloat**: interdependent state (e.g. scope / selectedId / discoveryFilter) is kept in **one** hook, not split into several small hooks that thread setters through each other's params. When hooks have dependencies, compose them one-directionally as "data source → derivation" (e.g. `usePrNavigation` composes on top of `usePullRequests`). -历史上 App 会随特性迭代「长回来」(通知、快捷键等细分领域内联堆积)——发现 App 又变臃肿时,按上述判据把成形的领域抽成 hook、回归组合根。 +Historically App has tended to "grow back" as features iterate (notifications, shortcuts, and other sub-domains inlined and piling up) — when you notice App bloating again, extract the matured domains into hooks per the criterion above and return to the composition root. -### 无边框窗口 +### Frameless window -主窗口去掉系统原生标题栏(`titleBarStyle: 'hidden'`),由渲染层自绘一条 36px 标题栏(VS Code 风), -让深色主题从顶贯通到底。窗控按钮**不自绘**,交由系统画以保留原生行为(Snap Layouts / 双击最大化 / 吸附): +The main window removes the native OS title bar (`titleBarStyle: 'hidden'`), and the render layer custom-draws a 36px title bar (VS Code style), +letting the dark theme run continuously from top to bottom. Window control buttons are **not custom-drawn**; the system draws them to preserve native behavior (Snap Layouts / double-click to maximize / snapping): -- **macOS**:保留红绿灯,`trafficLightPosition` 下移到自绘标题栏内;标题栏左侧留 72px 占位避让。 -- **Windows / Linux**:`titleBarOverlay` 让系统在右上画最小化/最大化/关闭,渲染层只接管中间标题区, - **勿在右上角放可点元素**(会被 overlay 覆盖)。`titleBarOverlay.height` 必须与渲染层 `.app-titlebar` 高度(36px)一致。 +- **macOS**: keep the traffic lights, with `trafficLightPosition` shifted down into the custom title bar; leave a 72px spacer on the left of the title bar to avoid overlap. +- **Windows / Linux**: `titleBarOverlay` lets the system draw minimize/maximize/close at the top-right; the render layer only takes over the middle title area, + **do not place clickable elements in the top-right corner** (they get covered by the overlay). `titleBarOverlay.height` must match the render layer's `.app-titlebar` height (36px). -拖拽实现:整条标题栏 `-webkit-app-region: drag`,其中的按钮/链接/输入等交互元素各自 `no-drag`,否则点击被当成拖窗。 +Drag implementation: the whole title bar is `-webkit-app-region: drag`, while the buttons/links/inputs and other interactive elements within it are each `no-drag`, otherwise a click is treated as a window drag. -平台差异经 `AppInfo.platform`(bootstrap 时由主进程下发)判定,渲染层不直接读 `process`。 +Platform differences are decided via `AppInfo.platform` (delivered by the main process at bootstrap); the render layer does not read `process` directly. -### 交互约定 +### Interaction conventions -- **外链统一外开**:所有 UGC(评论 / PR 描述 / finding / chat)里的 `http(s)` 链接点击都走系统默认浏览器 - (capture 阶段全局拦截 + `app:openExternal`),不在应用窗口内导航覆盖界面。 -- **二层模态背景点击只关本层**:嵌套模态(连接/LLM/代理编辑、确认框)的 backdrop 点击 `stopPropagation`, - 不冒泡到外层设置模态的关闭(含 createPortal 的确认框,React 合成事件仍按组件树冒泡)。 -- **操作级 toast vs 整屏错误**:远端动作(审批/合并/发布)失败弹 toast,区别于 bootstrap 致命错误的整屏报错。 -- **窗口聚焦自动刷新**:窗口重新获得焦点时主动拉一次 PR meta(跟上「切到平台改完再切回」场景)。 -- **布局偏好持久化**:侧栏/对话宽度与折叠态、diff 视图模式等存 localStorage。 +- **All external links open externally**: every `http(s)` link click inside UGC (comments / PR description / findings / chat) goes to the system default browser + (global interception at the capture phase + `app:openExternal`), never navigating inside the app window to cover the UI. +- **Second-level modal backdrop click only closes its own layer**: for nested modals (connection/LLM/proxy editing, confirm dialogs) the backdrop click calls `stopPropagation`, + so it does not bubble up to close the outer settings modal (including createPortal confirm dialogs — React synthetic events still bubble along the component tree). +- **Action-level toast vs. full-screen error**: a failed remote action (review decision/merge/publish) raises a toast, distinct from the full-screen error of a fatal bootstrap failure. +- **Auto-refresh on window focus**: when the window regains focus, proactively fetch PR meta once (to follow the "switch to the platform, make edits, then switch back" scenario). +- **Layout preferences persisted**: sidebar/chat width and collapse state, diff view mode, etc. are stored in localStorage. -## 数据 / 接口契约 +## Data / interface contract -- 渲染层经 preload 暴露的泛型 `invoke<K>(channel, req)` 调主进程;事件订阅经 `subscribe(event, cb)`。 - 全部由 `IpcChannels` 类型映射约束(见 [总览](../00-overview.md))。 -- 领域类型(PR / Finding / ReviewRun / Draft / 配置)来自 `shared`,前后端共享。 +- The render layer calls the main process via the generic `invoke<K>(channel, req)` exposed through preload; event subscription goes via `subscribe(event, cb)`. + All of it is constrained by the `IpcChannels` type map (see [Overview](../00-overview.md)). +- Domain types (PR / Finding / ReviewRun / Draft / config) come from `shared`, shared between front and back end. -## 扩展与注意事项 +## Extension & caveats -- **新交互一律走 IPC + 类型映射**:渲染层不直接碰 Node / 文件 / 网络。 -- **跨 PR 需存活的状态进模块级 store**,不要塞组件 useState(切 PR 即丢)。 -- **App.tsx 保持组合根**:新交互的领域逻辑下沉到 `hooks/use*.ts`,App 只装配(见「组件分层」);发现 App 又变臃肿即按域抽 hook。 -- **安全基线**:`contextIsolation` 开、无 `nodeIntegration`、CSP;preload 只暴露白名单能力。 -- **二层模态**新增时记得 backdrop `stopPropagation`,否则会连带关掉外层。 -- **无边框标题栏高度**改动时,渲染层 `.app-titlebar` 与主进程 `titleBarOverlay.height` 两处须同步,否则 Windows 窗控与标题区错位;标题栏内新增交互元素记得标 `no-drag`。 -- Monaco 的 worker、view zone(行内评论/草稿)渲染较重,注意大 PR 下的懒加载与销毁。 +- **New interactions always go through IPC + the type map**: the render layer never touches Node / files / network directly. +- **State that must survive across PRs goes into a module-level store**, not component `useState` (switching PRs loses it). +- **Keep App.tsx a composition root**: the domain logic of a new interaction sinks into `hooks/use*.ts`, and App only assembles (see "Component layering"); when App bloats again, extract hooks by domain. +- **Security baseline**: `contextIsolation` on, no `nodeIntegration`, CSP; preload exposes only whitelisted capabilities. +- **When adding a second-level modal** remember the backdrop `stopPropagation`, otherwise it will also close the outer layer. +- **When changing the frameless title bar height**, the render layer's `.app-titlebar` and the main process's `titleBarOverlay.height` must stay in sync, otherwise the Windows window controls and title area misalign; remember to mark newly added interactive elements in the title bar as `no-drag`. +- Monaco's workers and view zones (inline comments/drafts) are heavy to render; watch lazy loading and disposal on large PRs. diff --git a/docs/arch/03-gui/02-command-palette.md b/docs/arch/03-gui/02-command-palette.md index 8a08205a..0aea67c4 100644 --- a/docs/arch/03-gui/02-command-palette.md +++ b/docs/arch/03-gui/02-command-palette.md @@ -1,62 +1,62 @@ -# 命令面板 +# Command palette -## 职责与边界 +## Responsibilities & boundaries -标题栏内嵌的 VS Code 风命令面板:快捷操作的统一输入入口,并为「没有直接交互入口」的功能提供可发现、键盘可达的归口。 +A VS Code-style command palette embedded in the title bar: a unified input entry for quick actions, and the discoverable, keyboard-reachable home for features that have "no direct interaction entry". -负责:命令的注册与分域归类、按当前界面语言搜索、两级选择交互、命令的即时执行。不负责:命令背后各功能的实现(复用既有设置 / IPC 能力,不另起一套)、斜杠命令解析(chat 自有,见 [评审闭环](../01-platform/03-review-workflow.md),后续可并入同一注册表)。 +Owns: registering commands and grouping them by domain, searching by the current UI language, two-level selection interaction, and immediate command execution. Does not own: the implementation behind each command (it reuses existing settings / IPC capabilities rather than starting a new one), or slash-command parsing (chat has its own; see [Review workflow](../01-platform/03-review-workflow.md), which may later merge into the same registry). -## 功能设计 +## Functional design -命令「做什么、怎么组织 / 门控 / 执行」的设计;具体命令清单见「[命令与快捷键一览](#命令与快捷键一览)」。 +The design of "what a command does, and how it is organized / gated / executed"; for the concrete command list see "[Commands & shortcuts](#commands--shortcuts)". -- **注册表 + 分域文件**:命令实现按领域拆分到各自文件,一个注册表聚合所有领域;上层只认「构建顶层命令」这一个入口。新增领域 = 加一个领域文件 + 在注册表登记,上层与交互层不动。 -- **领域与命令组织**:命令按领域分组,**领域按英文名字典序固定排列**;每条顶层命令带一个领域 `category`,作为命令名前缀展示并参与搜索(搜领域名即可筛出该域全部命令)。现有三域 **PR / 评审 / 设置**。 -- **统一门控(`when`)**:命令可声明 `when()` 谓词,注册表统一据此过滤——返回 false 即不出现,各领域不再各写 `if`(如「运行自动评审」`when` 为「有选中 PR」;平台无某发现分类则其命令本就不生成,数据驱动门控)。执行期的瞬时守卫(如同一 PR 已在跑的**重入保护**)放在 `run` 内按点击实时判定(比可见性更可靠),走与 ChatPane 一键评审同一 `agent:run` 通道、运行态经事件 / store 反映。 -- **即时生效复用既有原语**:命令执行一律复用设置页同一套「即时生效 + 写盘 + 同步前端配置」链路(界面语言走 i18n 运行时切换 + 持久化、主题走外观 store 派生、模型 / 代理走对应 config 写入 IPC),**不另写一套**,保证与设置页行为一致。深链类命令(打开关于 / 模型分区)打开设置面板并定位到指定分区。 -- **二级选项惰性求值**:进入容器命令时才计算其选项,并读当前配置标注「生效项」(打勾)。模型列表末尾固定「添加模型…」入口(无预设时即唯一项),打开设置的模型分区新建。 -- **自由文本输入命令**:命令可声明 `input`(占位提示 + `run(text)`),进入二级层后输入框转为接受任意文本、回车提交(与 `options` / 顶层 `run` 互斥)。已落地「PR:打开 URL」——按 path 形态解析链接(忽略 host / query / 尾缀),本地已存在(活跃 / 归档)则直接定位,否则远端鉴权拉取后存入归档冷存储(随归档生命周期到期清理)再打开;无活动连接 / 链接无效 / 无权限以错误码经 toast 反馈。后端契约见 IPC `prs:openByUrl` 与 [状态存储](../99-core/01-state-storage.md)。 +- **Registry + per-domain files**: command implementations are split by domain into their own files, and one registry aggregates all domains; the upper layer knows only the single entry "build the top-level commands". Adding a domain = add a domain file + register it in the registry, without touching the upper or interaction layers. +- **Domain and command organization**: commands are grouped by domain, and **domains are fixed in dictionary order of their English names**; each top-level command carries a domain `category`, shown as a prefix of the command name and included in search (searching the domain name filters out all commands in that domain). The three current domains are **PR / Review / Settings**. +- **Unified gating (`when`)**: a command can declare a `when()` predicate, and the registry filters uniformly by it — return false and it does not appear, so domains no longer each write their own `if` (e.g. "Run auto review"'s `when` is "there is a selected PR"; if a platform lacks a discovery category, its command simply is not generated — data-driven gating). Transient runtime guards (such as **re-entry protection** when the same PR is already running) live inside `run`, decided live on click (more reliable than visibility), going through the same `agent:run` channel as ChatPane's one-click review, with run state reflected via events / store. +- **Immediate effect reuses existing primitives**: command execution always reuses the same "take effect immediately + write to disk + sync front-end config" pipeline as the settings page (UI language goes through i18n runtime switch + persistence, theme via the appearance store derivation, model / proxy via the corresponding config-write IPC), **not a second implementation**, guaranteeing behavior identical to the settings page. Deep-link commands (open About / the model section) open the settings panel and locate the given section. +- **Second-level options are lazily evaluated**: options are computed only when entering a container command, reading the current config to mark the "active item" (checked). The model list has a fixed "Add model…" entry at the end (the sole entry when there are no profiles), which opens the settings model section to create one. +- **Free-text input commands**: a command can declare `input` (placeholder hint + `run(text)`); after entering the second level the input box switches to accepting arbitrary text and submitting on Enter (mutually exclusive with `options` / a top-level `run`). "PR: Open URL" has landed — it parses the link by its path shape (ignoring host / query / suffix); if it already exists locally (active / archived) it locates it directly, otherwise it authenticates and fetches it remotely, stores it in the archive cold storage (expiring on the archive lifecycle), and opens it; no active connection / invalid link / no permission is fed back via toast with an error code. The backend contract is IPC `prs:openByUrl` and [State storage](../99-core/01-state-storage.md). -## 命令与快捷键一览 +## Commands & shortcuts -当前已实现的命令、功能与窗口级快捷键(macOS 用符号、其余平台用文字;无快捷键以 — 表示)。 +The currently implemented commands, their functions, and window-level shortcuts (macOS uses symbols, other platforms use text; no shortcut is shown as —). -| 领域 | 命令 | 功能 | macOS | Windows / Linux | +| Domain | Command | Function | macOS | Windows / Linux | | --- | --- | --- | --- | --- | -| — | 打开命令面板 | 唤起并聚焦标题栏命令输入框 | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>P</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd> | -| PR | 查看「待我评审 / 我创建 / 指派我 / 提及我」 | 切到对应发现分类(随平台能力,逐类各一条一级命令) | — | — | -| PR | 查看已关闭 | 切到归档(已关闭)范围浏览退场 PR | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>H</kbd> | <kbd>Ctrl</kbd>+<kbd>H</kbd> | -| PR | 分类筛选 | 二级:按状态筛选(待处理 / 全部 / 冲突 / 可合并 等,随平台门控) | — | — | -| PR | 打开 URL | 自由文本二级层:粘贴 / 输入当前平台 PR 链接打开(含他人 / 已退场 PR) | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>U</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>U</kbd> | -| PR | 切换 PR 列表 | 折叠 / 展开左侧 PR 列表(侧栏) | <kbd>⌘</kbd>+<kbd>B</kbd> | <kbd>Ctrl</kbd>+<kbd>B</kbd> | -| 评审 | 运行自动评审 | 对当前选中 PR 跑自动评审(需有选中 PR;重入保护) | <kbd>F5</kbd> | <kbd>F5</kbd> | -| 评审 | 切换 AutoPilot | 开 / 关 AutoPilot 预评审 | — | — | -| 评审 | 切换对话面板 | 折叠 / 展开右侧对话面板(ChatPane) | <kbd>⌘</kbd>+<kbd>J</kbd> | <kbd>Ctrl</kbd>+<kbd>J</kbd> | -| 设置 | 切换显示语言 | 二级:选界面语言(即时切换 + 持久化) | — | — | -| 设置 | 切换主题 | 二级:选编辑器配色主题(含「跟随系统」) | — | — | -| 设置 | 切换模型 | 二级:选 LLM 预设(末尾「添加模型…」入口) | — | — | -| 设置 | 切换代理 | 开 / 关网络代理 | — | — | -| 设置 | 打开设置 | 打开设置面板 | — | — | -| 设置 | 打开关于 | 打开设置面板「关于」分区 | — | — | -| 设置 | 打开 DevTools | 打开 Electron DevTools(分离窗口) | <kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> | - -## 交互规范 - -- **触发与位置**:输入框常驻标题栏中部(绝对居中浮层,不占流、不挤压两侧),快捷键打开并聚焦(窗口级渲染层监听)。PR 标题留在左侧原位(避开右上 Windows 窗控);标题过长逼近命令框时右缘渐隐、没入浮层下(输入框不透明底遮盖),短标题不显。 -- **两级导航**:顶层为命令列表;选中「容器型」命令(切换语言 / 主题 / 模型)或「自由文本」命令(打开 URL)原地进入二级层——选项列表或纯输入。**最多两级**;二级层左侧显示前缀提示符(`prefixLabel`,如「URL ›」,否则回退命令名),**空查询按 `Backspace` 回退上一级**,`Esc` 退出整个面板。叶子命令(打开设置 / 关于 / DevTools、开关代理)直接执行。 -- **双语展示 + 中英检索**:命令文案随界面语言本地化;**非英语界面**下命令附**英文次行**(对齐 VS Code 显示语言),英语界面只显一行。搜索 haystack **恒含中英两套**——始终支持英文检索(即便次行未显)。语言切换后命令清单按新语言重建(以语言为 key,而非依赖 t 引用变化)。 -- **快捷键**:常用命令配**窗口级**快捷键(非系统级 globalShortcut——后者应用未聚焦也触发、非所需;跨平台按 mac `⌘`/`⌥` 与其余 `Ctrl`/`Shift` 区分)。选键**避雷优先于助记**:避开腾讯系截图(`…+A`)、重载、`Cmd+Enter` 等冲突,宁缺勿凑(如 AutoPilot 暂无键位)。有键位的命令在面板内以 VS Code 风「一键一框」展示(命令声明按键 token 数组、由平台相关格式化产生)。键位清单见上表。 -- **标题栏共存**:命令面板浮层与 PR 标题、品牌名、平台窗控(mac 红绿灯 / Windows overlay)共处自绘标题栏;新增标题栏元素须遵守拖拽区 / no-drag 划分,且勿放到右上角窗控覆盖区。 - -## 数据 / 接口契约 - -- **命令上下文(CommandContext)**:当前配置 + 同步前端配置的钩子 + 「打开设置面板(可指定初始分区)」+ 当前语言的翻译函数。各领域命令构建器都接收它。 -- **顶层命令(RootCommand)**:`id` / 本地化 `title` / 领域 `category`;二者择一——叶子的 `run`,或容器的 `options`(惰性返回二级选项 + 进入后的输入占位提示)。 -- **二级选项(CommandOption)**:`id` / `title` / 是否 `active`(生效项打勾)/ `run`。 -- **设置面板深链**:设置面板接受「初始分区」入参;命令面板的「打开关于 / 添加模型」据此定位分区。 - -## 扩展与注意事项 - -- **新增领域命令**:新增一个领域文件导出该域的命令构建器,并在注册表登记即可;命令文案在四语言 locale 的 `commandPalette` 命名空间补齐(递归字典序)。 -- **斜杠命令归并**:现阶段不纳入(规划未定)。未来可让 chat 斜杠命令与命令面板共用同一注册表(命令模型已含 `when` 类上下文判定的扩展空间),避免两套定义漂移。 -- **键位维护**:面板内的键位提示(上表 / `shortcut` token 数组)仅作展示,实际按键匹配在窗口级监听里另行判定(见 [GUI 交互](01-ui-interaction.md) 的全局快捷键),二者各自维护——改键位时两处同步。 +| — | Open command palette | Summon and focus the title-bar command input box | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>P</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd> | +| PR | View "Review Requested / Created / Assigned / Mentioned" | Switch to the corresponding discovery category (per platform capability, one first-level command each) | — | — | +| PR | View closed | Switch to the archived (closed) scope to browse retired PRs | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>H</kbd> | <kbd>Ctrl</kbd>+<kbd>H</kbd> | +| PR | Category filter | Second level: filter by status (pending / all / conflicting / mergeable, etc., gated per platform) | — | — | +| PR | Open URL | Free-text second level: paste / type a PR link on the current platform to open it (including others' / retired PRs) | <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>U</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>U</kbd> | +| PR | Toggle PR list | Collapse / expand the left PR list (sidebar) | <kbd>⌘</kbd>+<kbd>B</kbd> | <kbd>Ctrl</kbd>+<kbd>B</kbd> | +| Review | Run auto review | Run auto review on the currently selected PR (requires a selected PR; re-entry protection) | <kbd>F5</kbd> | <kbd>F5</kbd> | +| Review | Toggle AutoPilot | Turn AutoPilot pre-review on / off | — | — | +| Review | Toggle chat panel | Collapse / expand the right chat panel (ChatPane) | <kbd>⌘</kbd>+<kbd>J</kbd> | <kbd>Ctrl</kbd>+<kbd>J</kbd> | +| Settings | Switch display language | Second level: pick the UI language (immediate switch + persistence) | — | — | +| Settings | Switch theme | Second level: pick the editor color theme (including "Follow system") | — | — | +| Settings | Switch model | Second level: pick an LLM profile ("Add model…" entry at the end) | — | — | +| Settings | Toggle proxy | Turn the network proxy on / off | — | — | +| Settings | Open settings | Open the settings panel | — | — | +| Settings | Open About | Open the settings panel's "About" section | — | — | +| Settings | Open DevTools | Open the Electron DevTools (detached window) | <kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> | + +## Interaction conventions + +- **Trigger and position**: the input box is docked in the middle of the title bar (an absolutely centered overlay that does not take flow or squeeze the sides), opened and focused by shortcut (a window-level render-layer listener). The PR title stays in its place on the left (avoiding the Windows window controls at the top-right); when a long title approaches the command box the right edge fades out and sinks under the overlay (the input box's opaque background covers it), and short titles are unaffected. +- **Two-level navigation**: the top level is the command list; selecting a "container" command (switch language / theme / model) or a "free-text" command (open URL) enters the second level in place — an option list or plain input. **At most two levels**; the second level shows a prefix hint on the left (`prefixLabel`, e.g. "URL ›", otherwise falling back to the command name), and **an empty query with `Backspace` returns to the previous level**, while `Esc` exits the whole palette. Leaf commands (open settings / About / DevTools, toggle proxy) execute directly. +- **Bilingual display + Chinese-English search**: command wording is localized to the UI language; **in non-English UIs** a command carries an **English second line** (aligning with VS Code's display-language behavior), while an English UI shows a single line. The search haystack **always contains both Chinese and English** — English search is always supported (even when the second line is not shown). After a language switch the command list is rebuilt in the new language (keyed by language, rather than relying on a change of the `t` reference). +- **Shortcuts**: common commands are given **window-level** shortcuts (not the system-level `globalShortcut` — that fires even when the app is unfocused, which is not wanted; cross-platform, mac `⌘`/`⌥` vs. `Ctrl`/`Shift` elsewhere). Key selection **prioritizes avoiding conflicts over mnemonics**: steer clear of Tencent-suite screenshots (`…+A`), reload, `Cmd+Enter`, and other conflicts, preferring none over a forced fit (e.g. AutoPilot has no key for now). Commands with keys are shown in the palette in VS Code's "one key, one box" style (the command declares a key-token array, and platform-specific formatting renders it). See the key list in the table above. +- **Coexistence in the title bar**: the command-palette overlay shares the custom-drawn title bar with the PR title, brand name, and platform window controls (mac traffic lights / Windows overlay); newly added title-bar elements must obey the drag-region / no-drag split and must not be placed in the top-right window-control cover area. + +## Data / interface contract + +- **Command context (CommandContext)**: the current config + a hook to sync front-end config + "open the settings panel (with an optional initial section)" + the translation function for the current language. Every domain's command builder receives it. +- **Top-level command (RootCommand)**: `id` / localized `title` / domain `category`; one of two — a leaf's `run`, or a container's `options` (lazily returns second-level options + the input placeholder hint shown after entering). +- **Second-level option (CommandOption)**: `id` / `title` / whether `active` (the active item is checked) / `run`. +- **Settings-panel deep link**: the settings panel accepts an "initial section" argument; the command palette's "open About / add model" locates the section by it. + +## Extension & caveats + +- **Adding a domain command**: add a domain file exporting that domain's command builder and register it in the registry; fill in the command wording under the `commandPalette` namespace of all four locales (recursive dictionary order). +- **Slash-command merge**: not included for now (planning undecided). In the future, chat slash commands and the command palette could share the same registry (the command model already has room for `when`-style context predicates), avoiding drift between two sets of definitions. +- **Key maintenance**: the in-palette key hints (the table above / the `shortcut` token array) are display only; the actual key matching is decided separately in the window-level listener (see the global shortcuts in [GUI interaction](01-ui-interaction.md)), and the two are maintained independently — keep both in sync when changing a key. diff --git a/docs/arch/03-gui/03-notifications.md b/docs/arch/03-gui/03-notifications.md index 1b490f61..ebf13053 100644 --- a/docs/arch/03-gui/03-notifications.md +++ b/docs/arch/03-gui/03-notifications.md @@ -1,78 +1,78 @@ -# 消息通知(系统通知 + dock 角标) +# Notifications (system notifications + dock badge) -把「与我相关」的事件从应用内提示扩展到操作系统层:被 @、被回复、有新 PR 时弹原生系统通知(toast),并在 macOS dock 图标上显示「待回应」计数角标。用户向使用说明见 [docs/guide/](../../guide/README.md);列表内的未读点名计数见 [状态存储](../99-core/01-state-storage.md) 的「未读点名计数」。 +Extends "relevant to me" events from in-app hints out to the operating-system layer: raises a native system notification (toast) when I am @-mentioned, replied to, or a new PR appears, and shows a "needs response" count badge on the macOS dock icon. See the user-facing guide at [docs/guide/](../../guide/zh-CN/README.md); for the in-list unread mention count see "unread mention count" in [State storage](../99-core/01-state-storage.md). -## 范围 +## Scope -- **系统通知(toast)**:Windows + macOS 原生通知,按事件类型分别开关——面向评审的(新 PR / 评论回复 / 评论 @)与面向「我创建的」PR 的(新评论 / 被标记需修改 / 出现冲突)。 -- **macOS dock 角标**:dock 图标上显示「@我 / 回复我」待回应总数。 -- 不纳入常驻状态栏 / Windows 任务栏 overlay 与闪烁(成本偏重,收益有限)。 +- **System notifications (toast)**: native Windows + macOS notifications, toggled per event type — the review-facing ones (new PR / comment reply / comment @) and the ones for "PRs I created" (new comment / marked needs work / conflict appeared). +- **macOS dock badge**: the total "needs response" count of "@me / replied to me" shown on the dock icon. +- Excludes a persistent status bar / Windows taskbar overlay and flashing (heavy cost, limited benefit). -## 两条数据路径 +## Two data paths -### 1. 系统通知:poll 事件投影 → 主进程 toast +### 1. System notifications: poll-event projection → main-process toast -- **投影(poller)**:`pollOnce` 在常规扫描中顺带产出本轮「值得提醒」事件 `PollNotificationEvent[]`(`kind: new_pr | mention | reply | authored_comment | authored_needs_work | authored_conflict` + PR 标识/标题 + 条数),经 `onNotify` 回调交主进程。复用既有评论拉取,按下文「评论跟踪触发」决定何时扫。 - - **新 PR**:`isAdded` 的 PR。 - - **@ / 回复**:评论扫描用 [`collectMentionsToMe`](../../../packages/poller/src/unread.ts)(按「父评论作者是我=reply / 正文 @我=mention」分类,每条命中带评论作者),取**晚于历史游标 `lastMentionAt`** 的命中、按类型聚合条数。 - - **「我创建的」PR(作者为本人,`pr.author` == 当前用户)**——仅对这类 PR 额外产出: - - `authored_comment`:他人新评论(用 [`collectCommentsFromOthers`](../../../packages/poller/src/unread.ts) 收全部非本人评论,取晚于独立游标 `lastCommentAt` 的;自己的评论不计,故不会因自评误报)。 - - `authored_needs_work`:新出现的「需修改」评审人(本轮在 needsWork、上一轮 `needsWorkReviewers` 不在)。 - - `authored_conflict`:合并冲突 `hasConflict` false→true。 - - 三者的「上一轮快照」(`lastCommentAt` / `needsWorkReviewers` / `hasConflict`)存于索引条目;快照字段缺失(升级前旧索引)时按基线只播种、不补发。 - - 事件还带 `repo` / `connectionId` / `actor`(发起人:new_pr=PR 作者,mention/reply/authored_comment=该类最新一条命中的评论作者,authored_needs_work=新标记需修改的评审人,authored_conflict=PR 作者)。 -- **防风暴**:仅在**已有基线**(本轮之前索引非空)时产出事件——首启 / 清库后的首轮只建基线、不弹通知;新发现 PR 的历史评论不投影为 mention/reply(`prev` 不存在则跳过)。 -- **仅「待处理」**:事件只对 `localStatus === 'pending'` 的 PR 产出(投影处 `notifiable = hadBaseline && localStatus === 'pending'` 门控)——已 approve / 标记 needs_work 的 PR 不再打扰。「待处理」天然覆盖「待我评审」(未决断)与「我创建的」(作者非评审人 → 恒 pending)两类。 -- **点击定位**:mention/reply 事件带 `comment`(最新一条命中评论的 `remoteId` + `anchor`),供点击跳转。 +- **Projection (poller)**: `pollOnce` incidentally produces this round's "worth alerting" events `PollNotificationEvent[]` (`kind: new_pr | mention | reply | authored_comment | authored_needs_work | authored_conflict` + PR identity/title + count) during the regular scan, handing them to the main process via the `onNotify` callback. It reuses the existing comment fetch, deciding when to scan per "comment-tracking trigger" below. + - **new PR**: PRs that are `isAdded`. + - **@ / reply**: comment scanning uses [`collectMentionsToMe`](../../../packages/poller/src/unread.ts) (classifying by "parent-comment author is me = reply / body @s me = mention", each hit carrying the comment author), taking hits **later than the historical cursor `lastMentionAt`** and aggregating counts by type. + - **"PRs I created" (author is me, `pr.author` == current user)** — only for these PRs are the following additionally produced: + - `authored_comment`: others' new comments (using [`collectCommentsFromOthers`](../../../packages/poller/src/unread.ts) to collect all non-self comments, taking those later than the independent cursor `lastCommentAt`; own comments are excluded, so self-comments never misfire). + - `authored_needs_work`: a newly appearing "needs work" reviewer (in needsWork this round, not in `needsWorkReviewers` last round). + - `authored_conflict`: merge conflict `hasConflict` false→true. + - The "last-round snapshot" of the three (`lastCommentAt` / `needsWorkReviewers` / `hasConflict`) is stored on the index entry; when a snapshot field is missing (an old index from before the upgrade), only seed against the baseline, do not back-fire. + - The event also carries `repo` / `connectionId` / `actor` (the initiator: new_pr = the PR author, mention/reply/authored_comment = the comment author of that type's latest hit, authored_needs_work = the reviewer newly marking needs work, authored_conflict = the PR author). +- **Storm prevention**: events are produced only when **a baseline already exists** (the index was non-empty before this round) — the first round after first launch / a wiped store only builds the baseline, no notification; the historical comments of a newly discovered PR are not projected as mention/reply (skipped when `prev` does not exist). +- **"Pending" only**: events are produced only for PRs with `localStatus === 'pending'` (gated at the projection with `notifiable = hadBaseline && localStatus === 'pending'`) — a PR already approved / marked needs_work is no longer disturbed. "Pending" naturally covers both "Review Requested" (undecided) and "created by me" (the author is not a reviewer → always pending). +- **Click to locate**: mention/reply events carry `comment` (the `remoteId` + `anchor` of the latest hit comment), for click-to-jump. -#### 评论跟踪触发(何时拉评论扫描) +#### Comment-tracking trigger (when to fetch and scan comments) -评论扫描**仅对「待处理」(notifiable) PR 进行**——评论跟踪与通知投影同范围,已决断的 PR 不再拉评论(其未读点名计数也不再推进)。是否拉评论按平台能力 [`commentCountIncludesReplies`](../../../packages/shared/src/platform.ts) 分级,根因是各平台对「评论变化」暴露的免费信号粒度不同: +Comment scanning is **done only for "pending" (notifiable) PRs** — comment tracking and notification projection share the same scope, and a decided PR is no longer fetched for comments (its unread mention count also stops advancing). Whether to fetch comments is tiered by the platform capability [`commentCountIncludesReplies`](../../../packages/shared/src/platform.ts); the root cause is that platforms expose "comment change" free signals at different granularities: -| 平台 | `updatedAt` 随评论跳? | `commentCount` 取值 | 含回复? | 触发策略 | +| Platform | Does `updatedAt` bump with comments? | `commentCount` value | Includes replies? | Trigger strategy | | --- | --- | --- | --- | --- | -| GitHub | 顶层会、行内不一定 | `comments + review_comments` | ✅(行内回复即 review_comment) | 仅 `updatedAt` 或 `commentCount` 变化时扫 | -| GitLab | 评论(note)会 | `user_notes_count` | ✅(回复也是 note) | 仅 `updatedAt` 或 `commentCount` 变化时扫 | -| Bitbucket | **一律不跳** | `properties.commentCount`(**仅顶层**) | ❌ | 对待处理 PR **每轮兜底扫一次** | +| GitHub | top-level does, inline not necessarily | `comments + review_comments` | ✅ (an inline reply is a review_comment) | scan only when `updatedAt` or `commentCount` changes | +| GitLab | comments (notes) do | `user_notes_count` | ✅ (a reply is also a note) | scan only when `updatedAt` or `commentCount` changes | +| Bitbucket | **never bumps** | `properties.commentCount` (**top-level only**) | ❌ | **fall back to scanning once per round** for pending PRs | -- **含回复的平台(`commentCountIncludesReplies=true`)**:计数是可靠的「含回复」增量信号,`commentCount`(随 PR 发现列表免费返回、无额外请求)或 `updatedAt` 任一变化才拉评论——省请求。 -- **不含回复的平台(Bitbucket,`false`)**:`updatedDate` 不随评论跳变、`commentCount` 又只数顶层评论(回复不计),**无任何免费的「含回复」信号**;若仍按计数/更新时间门控会漏掉「回复」类通知(实测:回复既不顶 `updatedDate` 也不进 `commentCount`)。故对待处理 PR 每轮兜底拉一次评论列表比对游标——成本被「待处理 PR 数」收敛(轮询间隔通常 5 分钟),可接受。 -- `commentCount` 镜像存入 [`PrIndexEntry`](../../../packages/poller/src/pr-state.ts) 供下轮比对;平台不提供时为 undefined,退回仅按 `updatedAt` 判定。 -- **落地(main)**:[`services/notifications.ts`](../../../apps/desktop/src/main/services/notifications.ts) 的 `showPollNotifications` 经 `bootstrap/poller.ts` 的 `onNotify` 接线。按通知配置(总开关 + 分类型)过滤后:最多前 `INDIVIDUAL_LIMIT`(5)条逐条弹(各带头像富样式 + 点击定位);溢出部分(第 6 条起)折叠为一条「查看更多最新动态」提示,点击仅打开主界面、不定位。文案走主进程 i18n(`notifications.*`)。 -- **样式**: - - **Windows** 走 `toastXml` 富样式(ToastGeneric):圆形发起人头像(`appLogoOverride` + `hint-crop="circle"`)+ 标题行带类型 emoji(🔀 PR / 💬 @ / ↩️ 回复)+ 正文 `#编号 标题` + 归属行仓库 `项目/仓库`。toast 仅一个小图标槽,故头像占槽、类型用 emoji 标记。 - - **头像**:[`services/avatar.ts`](../../../apps/desktop/src/main/services/avatar.ts) 的 `ensureAvatarFile` 按 `(connectionId, slug)` 复用头像磁盘缓存(与 `app:userAvatar` 同约定),缺失则经 adapter 拉取落盘;因 toast `<image src>` 需本地文件 + 可识别扩展名,在裸字节 `.bin` 之外按嗅探的 content-type 旁挂一份 `.png`/`.jpg`。svg / 未知格式或拉取失败时降级为无头像。 - - **其他平台**:用 `title` / `body` 文本(body 附带仓库行),不含头像——Electron 在 macOS 固定显示应用图标、不支持 per-notification 头像。Windows toastXml 构造失败也回退到此文本路径。 -- **头像接线**:`onNotify` 经 `createPoller` 的 `getConnectionRuntime`(惰性 getter,poller 早于连接运行时构建)按 `connectionId` 取 adapter 拉头像。 +- **Platforms that include replies (`commentCountIncludesReplies=true`)**: the count is a reliable "includes replies" delta signal, so comments are fetched only when either `commentCount` (returned for free with the PR discovery list, no extra request) or `updatedAt` changes — saving requests. +- **Platforms that do not include replies (Bitbucket, `false`)**: `updatedDate` does not bump with comments, and `commentCount` only counts top-level comments (replies excluded), so there is **no free "includes replies" signal at all**; still gating by count/update time would miss "reply"-type notifications (measured: a reply neither bumps `updatedDate` nor enters `commentCount`). Hence for pending PRs, fall back to fetching the comment list once per round and comparing against the cursor — the cost is bounded by the "number of pending PRs" (the polling interval is usually 5 minutes), which is acceptable. +- `commentCount` is mirrored into [`PrIndexEntry`](../../../packages/poller/src/pr-state.ts) for the next round's comparison; when the platform does not provide it, it is undefined and we fall back to deciding by `updatedAt` alone. +- **Landing (main)**: `showPollNotifications` in [`services/notifications.ts`](../../../apps/desktop/src/main/services/notifications.ts) is wired via `onNotify` in `bootstrap/poller.ts`. After filtering by notification config (master switch + per type): at most the first `INDIVIDUAL_LIMIT` (5) are shown one by one (each with a rich avatar style + click-to-locate); the overflow (from the 6th) is collapsed into a single "see more recent activity" hint that, on click, only opens the main UI without locating. Wording goes through main-process i18n (`notifications.*`). +- **Styling**: + - **Windows** uses the `toastXml` rich style (ToastGeneric): a circular initiator avatar (`appLogoOverride` + `hint-crop="circle"`) + a title line with a type emoji (🔀 PR / 💬 @ / ↩️ reply) + the body `#number title` + an attribution line with the repo `project/repo`. A toast has only one small image slot, so the avatar takes the slot and the type is marked by emoji. + - **Avatar**: `ensureAvatarFile` in [`services/avatar.ts`](../../../apps/desktop/src/main/services/avatar.ts) reuses an on-disk avatar cache keyed by `(connectionId, slug)` (the same convention as `app:userAvatar`), fetching via the adapter and writing to disk when missing; because a toast `<image src>` needs a local file + a recognizable extension, alongside the raw-byte `.bin` it writes a companion `.png`/`.jpg` per the sniffed content-type. For svg / unknown formats or a failed fetch it degrades to no avatar. + - **Other platforms**: use `title` / `body` text (the body appends the repo line), without an avatar — on macOS Electron always shows the app icon and does not support per-notification avatars. A failed Windows toastXml build also falls back to this text path. +- **Avatar wiring**: `onNotify` fetches the avatar via `createPoller`'s `getConnectionRuntime` (a lazy getter, since the poller is built before the connection runtime) by taking the adapter for `connectionId`. -### 点击导航 +### Click navigation -通知点击除聚焦窗口外,主进程经 `broadcast('notification:activate', { localId, kind, anchor })` 推导航意图(`anchor` = inline 评论的 `{path,line}`,否则 null)。renderer(App.tsx 订阅)据此:定位目标 PR(不在活跃列表则忽略)→ 切回活跃范围 + 必要时切到含它的发现分类 → 选中并标已读 → 再按类型定位: +Besides focusing the window, a notification click has the main process push a navigation intent via `broadcast('notification:activate', { localId, kind, anchor })` (`anchor` = an inline comment's `{path,line}`, otherwise null). The renderer (App.tsx subscribing) acts on it: locate the target PR (ignore if not in the active list) → switch back to the active scope + if necessary switch to the discovery category containing it → select it and mark read → then locate by type: -- **new_pr**:仅选中该 PR; -- **inline 评论**(anchor 非空):经 `pendingDiffNav` 切 Diff 标签并跳到该文件行(复用 finding/草稿跳转通道); -- **summary 评论**(anchor 为 null):经 PrPanel 的 `pendingTab` 切到「活动」对话标签(不做评论级精确滚动)。 +- **new_pr**: only select that PR; +- **inline comment** (anchor non-null): switch to the Diff tab via `pendingDiffNav` and jump to that file line (reusing the finding/draft jump channel); +- **summary comment** (anchor null): switch to the "Activity" chat tab via PrPanel's `pendingTab` (no comment-level precise scroll). -### 2. dock 角标:renderer 派生 → 主进程落地 +### 2. Dock badge: renderer derives → main process applies -- renderer [`useDockBadge`](../../../apps/desktop/src/renderer/src/hooks/useDockBadge.ts) 据活跃 PR 列表的 `unreadMentionCount` 求和(各 PR 已封顶 10),随通知总开关 `enabled` 门控(关则置 0;角标无独立开关),经 `app:setBadgeCount` 推给主进程;[`applyBadgeCount`](../../../apps/desktop/src/main/services/notifications.ts) 仅在 macOS 调 `app.setBadgeCount`。 -- 计数源自 renderer 已派生的数据(避免主进程重复从状态库派生),故放在渲染层 hook、随 PR 列表 / 配置变化重算。 +- The renderer's [`useDockBadge`](../../../apps/desktop/src/renderer/src/hooks/useDockBadge.ts) sums the `unreadMentionCount` of the active PR list (each PR is already capped at 10), gated by the notification master switch `enabled` (0 when off; the badge has no independent switch), and pushes it to the main process via `app:setBadgeCount`; [`applyBadgeCount`](../../../apps/desktop/src/main/services/notifications.ts) calls `app.setBadgeCount` on macOS only. +- The count comes from data the renderer has already derived (avoiding the main process re-deriving from the state store), so it lives in a render-layer hook and recomputes as the PR list / config change. -## 配置 +## Config -`config.notifications`([config.ts](../../../packages/shared/src/config.ts),写入走 `config:setNotifications`、设置页「通知」分区): +`config.notifications` ([config.ts](../../../packages/shared/src/config.ts), written via `config:setNotifications`, the settings-page "Notifications" section): -| 字段 | 含义 | +| Field | Meaning | | --- | --- | -| `enabled` | 总开关;关闭后既不弹系统通知也不亮 dock 角标 | -| `new_pr` / `reply` / `mention` | 面向评审的分类型系统通知开关 | -| `authored_comment` / `authored_needs_work` / `authored_conflict` | 面向「我创建的」PR 的分类型开关(新评论 / 被标记需修改 / 出现冲突);默认开 | +| `enabled` | Master switch; when off, neither system notifications are raised nor the dock badge lit | +| `new_pr` / `reply` / `mention` | Per-type system-notification switches for review-facing events | +| `authored_comment` / `authored_needs_work` / `authored_conflict` | Per-type switches for "PRs I created" (new comment / marked needs work / conflict appeared); on by default | -macOS dock「待回应」计数角标随总开关默认启用,无独立配置项。 +The macOS dock "needs response" count badge is enabled by default with the master switch, with no independent config item. -## OS 权限约束 +## OS permission constraints -- **macOS**:首次弹通知由系统接管授权,应用无法强制开启;用户在「系统设置→通知」关闭后只能静默降级(注意 `Notification.isSupported()` 仅表示平台支持通知,**不反映授权态**,被关后代码仍会 `show()` 而被系统丢弃,主进程 Notification API 也无法查询授权状态)。当前为 ad-hoc 签名、未公证(见 [packaging-release](../../development/packaging-release.md)),通知可工作但归属标识不如 Developer ID + 公证可靠;且授权按 bundle id 记录,开发态以「Electron」身份注册、与安装版「Code Meeseeks」是不同条目。 - - **授权引导**:设置「通知」分区在 macOS 提供「打开系统通知设置」按钮(IPC `app:openNotificationSettings`,主进程 `shell.openExternal` 深链通知面板、pane id 随版本回退),由用户自行授权——应用不能代为开启。 -- **Windows**:toast 依赖 `AppUserModelId`(与安装包 `appId` 一致,启动时设定),否则可能不显示;无弹窗式授权。**点击激活回传应用**还要求应用为已安装、有开始菜单快捷方式且 AUMID 注册到注册表的正式应用——未打包的开发态(`electron-vite dev`)通常能弹 toast 但点击无法激活/路由回进程(JS `click` 不触发),属预期限制,安装版正常。 -- dock 角标免权限。 +- **macOS**: authorization for the first notification is taken over by the system; the app cannot force it on. After a user disables it in "System Settings → Notifications" it can only degrade silently (note that `Notification.isSupported()` only means the platform supports notifications, **not the authorization state** — once disabled the code still calls `show()` and the system discards it, and the main-process Notification API cannot query the authorization state either). Currently the build is ad-hoc signed, not notarized (see [packaging-release](../../development/packaging-release.md)); notifications work but the attribution identity is less reliable than Developer ID + notarization; also, authorization is recorded by bundle id, and the dev build registers as "Electron" while the installed "Code Meeseeks" is a different entry. + - **Authorization guidance**: on macOS the settings "Notifications" section offers an "Open system notification settings" button (IPC `app:openNotificationSettings`, the main process `shell.openExternal` deep-links to the notifications pane, the pane id falling back per version) for the user to authorize themselves — the app cannot enable it on their behalf. +- **Windows**: the toast relies on `AppUserModelId` (matching the installer's `appId`, set at startup), otherwise it may not show; there is no popup-style authorization. **Click activation calling back into the app** further requires the app to be an installed formal app with a Start Menu shortcut and its AUMID registered in the registry — an unpackaged dev build (`electron-vite dev`) can usually raise a toast but the click cannot activate/route back into the process (JS `click` does not fire), which is an expected limitation; the installed build works normally. +- The dock badge needs no permission. diff --git a/docs/arch/03-gui/04-i18n.md b/docs/arch/03-gui/04-i18n.md index 05ed85bc..434764e6 100644 --- a/docs/arch/03-gui/04-i18n.md +++ b/docs/arch/03-gui/04-i18n.md @@ -1,98 +1,98 @@ -# 国际化(i18n) +# Internationalization (i18n) -## 职责与边界 +## Responsibilities & boundaries -负责 GUI 文本的多语言化:把界面可见文本从代码中抽离为按语言组织的资源,运行时按用户选择的语言渲染。 +Owns the multi-language rendering of GUI text: extracting UI-visible text out of the code into resources organized by language, and rendering at runtime in the user's chosen language. -- **负责**:渲染层 UI 文本、主进程面向用户的文本(目录选择对话框标题、抛给渲染层并最终展示的错误消息)。 -- **不负责**: - - LLM **生成内容**的语言——由 pr-agent 的 `response language`(透传解析后的有效语言)控制,不在本模块。 - - pr-agent 输出里 LLM 不翻译的**硬编码英文模板词**——由独立的「输出模板翻译」机制处理(见下「pr-agent 输出模板翻译」)。 +- **Owns**: render-layer UI text, and main-process user-facing text (directory-picker dialog titles, and error messages thrown to the render layer and ultimately shown). +- **Does not own**: + - The language of LLM **generated content** — controlled by pr-agent's `response language` (passing through the resolved effective language), not in this module. + - **Hard-coded English template words** in pr-agent output that the LLM does not translate — handled by a separate "output template translation" mechanism (see "pr-agent output template translation" below). -key 是**中立标识符**(如 `chatPane.emptySelectPrTitle`),`zh-CN` / `en-US` / `ja-JP` / `de-DE` 是**对等的译文集**,无源/译层级(各 locale 满覆盖)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文)。新增语言只需补一份 locale 资源并在受支持列表登记。 +Keys are **neutral identifiers** (e.g. `chatPane.emptySelectPrTitle`); `zh-CN` / `en-US` / `ja-JP` / `de-DE` are **equivalent translation sets** with no source/translation hierarchy (each locale is fully covered). **The default / fallback language is `en-US`** (the i18n standard: a missing key falls back to English, not Chinese). Adding a language only requires supplying one locale resource and registering it in the supported list. -## 核心设计 +## Core design -### 方案选型 +### Solution choice -采用 **react-i18next**。理由:React 生态主流、`useTranslation` hook 与组件渲染天然契合、插值与复数能力完备、与 i18next-parser / 主流翻译平台的资源格式对齐。语言选择复用既有的 `config.language`,不另立配置。 +Uses **react-i18next**. Rationale: mainstream in the React ecosystem, the `useTranslation` hook fits component rendering naturally, its interpolation and plural capabilities are complete, and its resource format aligns with i18next-parser / mainstream translation platforms. Language selection reuses the existing `config.language` rather than introducing a separate config. -### 语言解析(config 为空时按 OS 回落英语) +### Language resolution (fall back to English per OS when config is empty) -`config.language` 是「期望语言」,**默认空串 = 自动**。统一的 `resolveLanguage(configLang, osLocales)`(置于 shared,main / renderer 共用):非空且受支持则用它;为空时按操作系统偏好语言(renderer 取 `navigator.languages`,main 取 `app.getPreferredSystemLanguages()`)逐个匹配;都不中则回落 **英语**(en-US),而非中文——多语言产品不应默认强制中文。解析结果同时驱动 UI 与 pr-agent 输出语言,二者保持一致。 +`config.language` is the "desired language", **defaulting to an empty string = auto**. The unified `resolveLanguage(configLang, osLocales)` (placed in shared, shared by main / renderer): use it if non-empty and supported; when empty, match against the operating system's preferred languages one by one (the renderer takes `navigator.languages`, main takes `app.getPreferredSystemLanguages()`); if none match, fall back to **English** (en-US) rather than Chinese — a multi-language product should not default to forcing Chinese. The resolution result drives both the UI and the pr-agent output language, keeping the two consistent. -### 双运行时各持一份资源 +### Two runtimes each hold their own resources -Electron 主进程(Node)与渲染层(React)是两个独立运行时,各自初始化一份 i18next: +The Electron main process (Node) and the render layer (React) are two independent runtimes, each initializing its own i18next: -- **渲染层**:入口同步初始化 i18next(首帧渲染前完成,避免闪烁);初始语言取「持久化的上次语言」(localStorage,`config.language` 经 IPC 异步到达、启动时拿不到),App 拿到配置后 `resolveLanguage` 解析并 `changeLanguage`、**首屏等目标语言加载完再渲染**,**支持运行时实时切换**。组件通过 `useTranslation()` 取 `t`,语言变化自动重渲。 -- **主进程**:独立 i18next 实例(纯 Node,无 React),启动时按解析后的有效语言 **一次性定档**(`getMainLanguage()` 供 pr-agent 响应语言复用)。主进程文案改语言后**重启生效**——符合其「dialog/错误消息」的性质。 +- **Render layer**: the entry initializes i18next synchronously (finished before the first frame renders, avoiding flicker); the initial language is "the last persisted language" (localStorage, since `config.language` arrives asynchronously via IPC and is not available at startup). After the App gets the config, `resolveLanguage` resolves it and calls `changeLanguage`, and **the first screen waits for the target language to finish loading before rendering**, **supporting live switching at runtime**. Components take `t` via `useTranslation()` and re-render automatically on language change. +- **Main process**: an independent i18next instance (pure Node, no React), **fixed once** at startup to the resolved effective language (`getMainLanguage()` for pr-agent response-language reuse). Changing the main-process language **takes effect on restart** — fitting its "dialog/error-message" nature. -两份资源结构一致、各自维护,不共享文件。 +The two resource sets have the same structure, are maintained separately, and do not share files. -### 资源加载:默认静态 + 其余懒加载 +### Resource loading: default static + the rest lazy-loaded -渲染层只把**默认/兜底语言 `en-US` 静态打包**进入口(保证首帧 `t()` 可用、并作 `fallbackLng`);`zh-CN` / `ja-JP` / `de-DE` 经 `i18next-resources-to-backend` + Vite 动态 `import()` **按需懒加载**,各自拆成独立 chunk,仅当切到该语言时才拉取,不进入口包(语言越多收益越大)。`partialBundledLanguages` 让静态与懒加载共存;`useSuspense: false` 使切换懒加载语言时不抛 Suspense、加载完自动重渲。主进程不涉及打包体积,全部静态,`fallbackLng` 同样取 `en-US`。 +The render layer statically bundles only the **default/fallback language `en-US`** into the entry (guaranteeing `t()` works on the first frame, and serving as `fallbackLng`); `zh-CN` / `ja-JP` / `de-DE` are **lazy-loaded on demand** via `i18next-resources-to-backend` + Vite dynamic `import()`, each split into its own chunk and fetched only when switching to that language, never entering the entry bundle (the more languages, the bigger the gain). `partialBundledLanguages` lets static and lazy-loaded coexist; `useSuspense: false` avoids throwing Suspense when switching to a lazy-loaded language, re-rendering automatically once loaded. The main process is not concerned with bundle size and is all static, with `fallbackLng` also `en-US`. -> **不要开 `nonExplicitSupportedLngs`**:它会把带地区码的 `zh-CN` 按基码 `zh` 规整后查找,而资源 bundle 按 `zh-CN` 注册 → 命名空间落空、`t()` 退化成裸 key(整页不翻译)。语言进 i18n 前已由 `resolveLanguage` / `matchSupportedLanguage` 规整成精确受支持码,无需 i18next 再做非精确匹配。 +> **Do not enable `nonExplicitSupportedLngs`**: it normalizes the region-coded `zh-CN` to the base code `zh` before lookup, while the resource bundle is registered under `zh-CN` → the namespace comes up empty and `t()` degrades to bare keys (the whole page untranslated). Before a language reaches i18n it is already normalized by `resolveLanguage` / `matchSupportedLanguage` into an exact supported code, so i18next need not do non-exact matching. -### key 组织 +### Key organization -- **按组件命名空间**:顶层 object 对应一个组件(命名空间 = 组件文件名的 camelCase),同文件内多个子组件共用该命名空间。跨组件复用的公共词(关闭 / 编辑 / 删除 / 取消 / 保存 / 确定)集中在 `common`。 -- **属性类**文案以语义后缀区分(如 `...Title` / `...Aria` / `...Placeholder`)。 -- 每个 key 在各语言资源(`zh-CN` / `en-US` / `ja-JP` / `de-DE`,对等无主次)各落一份译文,**各份都要加**。 -- 资源文件保持**递归字典序**:与翻译工具(i18next-parser `sort` / Crowdin、Lokalise、Weblate 导出)的输出一致,使同步与多人协作只产生「真实内容」的小 diff,减少合并冲突。运行时不依赖 key 顺序。 +- **By component namespace**: a top-level object corresponds to one component (namespace = the camelCase of the component file name), and multiple sub-components in the same file share that namespace. Common words reused across components (close / edit / delete / cancel / save / confirm) are centralized in `common`. +- **Attribute-type** wording is distinguished by a semantic suffix (e.g. `...Title` / `...Aria` / `...Placeholder`). +- Each key gets one translation in every language resource (`zh-CN` / `en-US` / `ja-JP` / `de-DE`, equivalent with no primary/secondary), **added to all of them**. +- Resource files keep **recursive dictionary order**: consistent with the output of translation tools (i18next-parser `sort` / Crowdin, Lokalise, Weblate exports), so syncing and multi-person collaboration produce only "real content" small diffs, reducing merge conflicts. The runtime does not depend on key order. -### 插值与复数 +### Interpolation and plurals -- **插值**:`t(key, { name })` 配资源 `"…{{name}}…"`。 -- **复数**:仅当文案随数量变化、且目标语言有单复数形态时启用。key 以 CLDR 复数类别为后缀(英文需 `_one` / `_other`,中文只需 `_other`),调用传 `{ count }`。 -- **关键取舍/坑**:i18next **仅** `count` 这一变量名触发复数解析。普通的「数量插值」(不需要单复数变形,如 `待审 PR 数:{{n}}`)必须用别的变量名(`n` 等),否则会被误当作复数 key 去找 `_other` 而落空。 +- **Interpolation**: `t(key, { name })` with a resource `"…{{name}}…"`. +- **Plurals**: enabled only when the wording varies with a number and the target language has singular/plural forms. Keys are suffixed with the CLDR plural category (English needs `_one` / `_other`, Chinese only `_other`), and the call passes `{ count }`. +- **Key trade-off / pitfall**: i18next triggers plural resolution **only** on the variable name `count`. A plain "count interpolation" (needing no singular/plural inflection, e.g. `PRs to review: {{n}}`) must use another variable name (`n`, etc.), otherwise it is mistaken for a plural key looking for `_other` and comes up empty. -### 非组件模块 +### Non-component modules -纯函数 / 非组件模块(如错误格式化、工具函数)不能用 hook:直接引用默认 i18n 实例调 `t`,或把 `t` 作为参数从调用方(组件)传入,以保留语言切换时的响应性。 +Pure functions / non-component modules (e.g. error formatting, utility functions) cannot use hooks: reference the default i18n instance directly to call `t`, or pass `t` in as a parameter from the caller (the component), to preserve responsiveness on language change. -### pr-agent 输出模板翻译 +### pr-agent output template translation -`translatePrAgentLabels` 是**独立于 UI i18n 的另一套机制**:pr-agent 在其源码里硬编码了一批结构化模板词(section 标题、固定 label、checkbox 文字),LLM 不改写它们,`response language` 也管不到,故中文环境下仍以英文出现。本机制在**渲染期**把这些英文模板词字面量替换为目标语言。 +`translatePrAgentLabels` is **a separate mechanism from UI i18n**: pr-agent hard-codes a batch of structured template words in its source (section titles, fixed labels, checkbox text) that the LLM does not rewrite and that `response language` does not reach either, so they still appear in English in a Chinese environment. This mechanism replaces these English template-word literals with the target language **at render time**. -- 这是「按英文原文匹配、整段 blob 子串替换」,与 i18next 的「按 key 取串」访问模型不同,故**不进 react-i18next 的 locale 资源**;各语言的 `<英文模板 → 译文>` 表作为**独立领域的 JSON** 维护(与 UI locale 分开),由替换引擎加载。 -- 替换键即英文原文本身,会随 pr-agent 升级而变——这也是不宜当作 i18next 稳定语义 key 的原因。 -- **语言感知**:按当前 UI 语言选择对应字典;无字典的语言(如英文,pr-agent 输出本就是英文)原样返回(passthrough)。引擎按 key 长度倒序替换(避免短键吃掉长键子串),故 JSON 存储顺序不影响正确性,可按 pr-agent 输出模板分组以便维护。 -- 字典随 pr-agent 版本维护,升级上游需 spot-check 模板词变化。 +- This is "match by the English original, substring-replace across the whole blob", differing from i18next's "fetch a string by key" access model, so it **does not go into react-i18next's locale resources**; each language's `<English template → translation>` table is maintained as **its own domain's JSON** (separate from the UI locales), loaded by the replacement engine. +- The replacement key is the English original itself, which changes as pr-agent upgrades — which is also why it should not be a stable i18next semantic key. +- **Language-aware**: it picks the corresponding dictionary by the current UI language; a language without a dictionary (e.g. English, whose pr-agent output is already English) is returned as-is (passthrough). The engine replaces in descending key-length order (so a short key does not swallow a long key's substring), so the JSON storage order does not affect correctness and can be grouped by pr-agent output template for maintainability. +- The dictionary is maintained per pr-agent version; upgrading upstream requires spot-checking template-word changes. -## 数据 / 接口契约 +## Data / interface contract -- **语言代码**:受支持语言为 `zh-CN` / `en-US` / `ja-JP` / `de-DE`(集中在 shared 的 `SUPPORTED_LANGUAGES`);`config.language` 或 OS locale 可能给出大小写 / 缺地区 / 多地区变体(如 `zh-Hans-CN`),经 `matchSupportedLanguage` / `resolveLanguage` 规整。 -- **资源形状**:`{ 命名空间: { key: 译文字符串 } }`,复数 key 带 CLDR 类别后缀(日语同中文仅 `_other`,德语同英语需 `_one` / `_other`)。各语言文件按各自 CLDR 复数规则对称。 -- **pr-agent 模板字典**:按语言注册的 `<英文模板 → 译文>` 映射表;新增目标语言 = 注册一份新字典。 +- **Language codes**: the supported languages are `zh-CN` / `en-US` / `ja-JP` / `de-DE` (centralized in shared's `SUPPORTED_LANGUAGES`); `config.language` or the OS locale may give a case / region-missing / multi-region variant (e.g. `zh-Hans-CN`), normalized by `matchSupportedLanguage` / `resolveLanguage`. +- **Resource shape**: `{ namespace: { key: translation string } }`, with plural keys carrying the CLDR category suffix (Japanese like Chinese needs only `_other`, German like English needs `_one` / `_other`). Each language file is symmetric per its own CLDR plural rules. +- **pr-agent template dictionary**: a per-language `<English template → translation>` mapping table; adding a target language = registering a new dictionary. -## 扩展与注意事项 +## Extension & caveats -### 新增界面文本 +### Adding UI text -1. 组件内用 `t('命名空间.key', { …插值 })` 引用; -2. 在**各语言** locale 资源(`zh-CN` / `en-US` / `ja-JP` / `de-DE`)同名命名空间下各加该 key 的对应译文; -3. 保持文件递归字典序。 +1. Reference it in the component with `t('namespace.key', { …interpolation })`; +2. Add the corresponding translation for that key under the same-named namespace in **every language** locale resource (`zh-CN` / `en-US` / `ja-JP` / `de-DE`); +3. Keep the file in recursive dictionary order. -### 不纳入 i18n 的内容 +### Content not covered by i18n -技术 token(命令字面如 `/review`、provider id、URL、Monaco language id、配置字段名常量等)、代码注释、日志(`console.*` / logger)、纯开发者诊断用且不展示给用户的异常文案——一律保留原样,不抽取。拿不准是否面向用户的,倾向保留并标注。 +Technical tokens (command literals such as `/review`, provider ids, URLs, Monaco language ids, config field-name constants, etc.), code comments, logs (`console.*` / logger), and exception text that is purely developer-diagnostic and not shown to users — all kept as-is, not extracted. When unsure whether something is user-facing, lean toward keeping it and flag it. -### 翻译规范(en-US 及后续语言遵循上下文场景) +### Translation conventions (en-US and later languages follow the context scenario) -- **依场景定调**:按钮 / 标签 / 菜单用简短祈使或名词短语("Send"、"Settings"),不写整句;提示 / 空态 / 错误用完整书面句;`title` / `aria-label` 可略详(含快捷键、后果说明)。中英不必逐字对应——以目标语言地道、贴合该 UI 位置的表达为准。 -- **术语一致**:同一概念全局统一译法(review→评审、draft→草稿、finding、approve / needs work…),不混用同义词;产品 / 品牌名与技术 token 保持原文(PR、Bitbucket、token、blame、provider 名等)。 -- **保结构**:插值占位 `{{…}}`、Markdown、emoji / 箭头(↑ ⚠️ ✓)、快捷键(Cmd/Ctrl+Enter)、标点节奏(分隔符 `·` 等)原样保留;中文全角标点在英文文案改回半角。 -- **控长度**:状态栏 chip、按钮等空间受限处译文尽量短,避免撑破布局。 +- **Set tone by scenario**: buttons / labels / menus use short imperatives or noun phrases ("Send", "Settings"), not full sentences; hints / empty states / errors use complete written sentences; `title` / `aria-label` may be slightly more detailed (including shortcuts, consequence notes). Chinese and English need not correspond word-for-word — favor idiomatic target-language phrasing that fits the UI position. +- **Terminology consistency**: translate one concept consistently everywhere (review, draft, finding, approve / needs work…), without mixing synonyms; keep product / brand names and technical tokens in the original (PR, Bitbucket, token, blame, provider names, etc.). +- **Preserve structure**: interpolation placeholders `{{…}}`, Markdown, emoji / arrows (↑ ⚠️ ✓), shortcuts (Cmd/Ctrl+Enter), and punctuation rhythm (separators like `·`) are kept as-is; Chinese full-width punctuation is changed back to half-width in English wording. +- **Control length**: in space-constrained places like status-bar chips and buttons, keep translations as short as possible to avoid breaking the layout. -### 现状与待办 +### Current status & TODO -- `en-US` / `ja-JP` / `de-DE` 为机器初稿,发布前建议人工 review。 -- 设置页暂无语言切换入口(`config.language` 已预留扩展点),后续按需补;当前可改 config.yaml 或依赖 OS 自动匹配。 -- 新增语言: - - shared `SUPPORTED_LANGUAGES` + `matchSupportedLanguage` 登记; - - 补一份该语言 locale 资源(renderer + main,注意其 CLDR 复数规则); - - 在 pr-agent 模板字典注册表加对应 `<英文模板 → 译文>`; - - 渲染层 backend 的动态 import 自动覆盖新文件,无需改加载逻辑。 +- `en-US` / `ja-JP` / `de-DE` are machine first drafts; a human review before release is recommended. +- The settings page has no language-switch entry yet (`config.language` reserves the extension point), to be added as needed later; for now one can edit config.yaml or rely on OS auto-matching. +- Adding a language: + - register it in shared `SUPPORTED_LANGUAGES` + `matchSupportedLanguage`; + - supply one locale resource for that language (renderer + main, minding its CLDR plural rules); + - add the corresponding `<English template → translation>` in the pr-agent template dictionary registry; + - the render-layer backend's dynamic import automatically covers the new file, no change to the loading logic needed. diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md index d0d0cafa..917b7be9 100644 --- a/docs/arch/04-integration/01-service-api.md +++ b/docs/arch/04-integration/01-service-api.md @@ -1,177 +1,218 @@ -# 服务监听与本地 API - -## 职责与边界 - -在主进程内提供一个**本地 HTTP API**,把应用既有的 PR 发现 / 浏览 / Agent 操作能力,以语言无关的 -线协议暴露给**外部 agent / 工具 / 脚本**(经 [CLI](02-cli.md) 或直接 HTTP 调用)。是继渲染层 IPC -之后的**第二个前端**——同一套主进程 service 层,换一层入站协议。 - -负责:服务监听开关与生命周期、bearer token 鉴权、请求路由与响应封装、把内部能力映射成稳定的 HTTP 契约。 - -开放的**写操作**限定为评审动作:approve / needswork(远端评审决断)与顶层 comment(发评论),复用 -GUI 同源 controller(见下「写边界」)。另有 `POST …/refresh` 触发一次本地轮询刷新——虽用 POST,但 -**无远端写副作用**(纯读远端 + 落本地),不属评审写动作范畴,与合并 / 变更类工具的禁令无关。 - -**不负责**: - -- **合并与 pr-agent 变更类工具** —— merge(合并 PR)、pr-agent 的 publish 等变更工具不开放;有此需求由 - 调用方自行用平台 API 实现(见下「写边界」)。 -- **多用户 / 远端服务形态** —— 仍是单用户本地应用,API 只是本机(或可选局域网)的入站通道,不引入账户体系。 -- 业务逻辑本身 —— 复用 IPC controller 同源的 service 层,不在 HTTP 侧另起一套实现。 - -## 核心设计 - -### 默认关闭、强制鉴权 - -- **默认不启用**:`config.yaml` 新增 `service` 段,`enabled` 默认 `false`。不开启则主进程不监听任何端口, - 对外零暴露面。 -- **强制 bearer token**:开启监听即要求 token——所有请求须带 `Authorization: Bearer <token>`,缺失 / 不匹配 - 直接拒绝(401 + 错误码)。**没有「关闭鉴权」选项**。开关首次打开时若 token 为空则**自动生成**一枚高强度 - 随机 token(`crypto.randomBytes` → base64url / hex),保证「启用」与「有 token」原子绑定。 -- **比对用常数时间**:token 校验走常数时间比较,避免计时侧信道。 -- **token 落盘策略同既有凭据**:token 明文存 `config.yaml`(与平台 token / LLM key / 代理密码一致,经 - `SecretStore` 抽象读写、绝不进日志 / 异常栈),属已知风险、文件权限收紧。将来切 keychain 时随既有凭据一并迁移。 - -### 监听地址与端口 - -- **默认仅 loopback**:`host` 默认 `127.0.0.1`,只本机可达。这是绝大多数「本机外部 agent 集成」场景的安全默认。 -- **可选 `0.0.0.0`**:允许配置为监听所有网卡(供同网段的远端 agent / CI 节点接入)。这是**显式高风险选项**—— - 设置页与文档须给安全警示(token 即唯一防线、建议配合防火墙 / 反代)。绑 `0.0.0.0` 时 token 强度与保密尤为关键。 -- **固定安全默认端口**:默认 `18765`(可在配置中改)。取 10000+ 既避开拥挤的 8xxx 开发 / 系统服务段 - (3000 / 5173 / 8000 / 8080 / 8888 等),又稳落在**临时端口范围(ephemeral,Windows 49152+ / Linux 32768+) - 之下**——固定监听端口若落进临时段可能与系统瞬时出站 socket 抢占,`18765` 处于已注册端口段、无此风险。 - 端口被占用导致监听失败时,记录错误并以非致命方式提示(不阻塞应用启动)。 - -### HTTP 实现:最小依赖 - -- 用 Node 内置 `http` 起服务 + **极简手写路由**(按 method + path 模式匹配),**不引入 express 等重型框架**—— - 与项目「优先复用、最小依赖」一致,端点数量有限、无需框架。 -- 统一中间环节:JSON body 解析(带最大 body 上限)、请求超时、鉴权校验、错误 → 响应封装、访问日志 - (记 method / path / status / 耗时,**不记** token 与敏感 body)。 - -### 路由复用 service 层 - -- HTTP route handler 经与 IPC controller **同一个进程级 `ControllerContext`**(`getContext()`)取用 service - (`ctx.pr` / `ctx.orchestrator` / `ctx.poller` / `ctx.connectionRuntime` 等),**不重复业务逻辑**。 -- 原则:核心能力沉在 service 层,IPC 与 HTTP 各自只做**薄封装 + 协议适配**。新增 API 端点前,先确保对应能力 - 在 service 层有可复用方法(必要时把 controller 内联逻辑下沉到 service)。 -- **路由按领域分模块**:HTTP 路由处理器按业务领域(系统性 / PR / Agent)分置于独立模块,聚合层只做**注册与路径 - 匹配**、不含业务逻辑——与 [CLI](02-cli.md) 命令树的领域划分对称,新增端点归入对应域、便于定位与扩展。 - -### CLI 版本兼容门控 - -服务端对**所有 API 调用**统一做 CLI 版本门控(不按端点差异化): - -- CLI 每个请求带版本头 `X-Meebox-CLI-Version: <version>` 声明自身版本(取自与 app 同源的版本,见 [CLI](02-cli.md))。 -- 服务端集中管理**最低可兼容 CLI 版本**,请求中间件比对:版本头**可解析且低于下限** → 拦截,返回 - `426 Upgrade Required` + 错误码 `SV_CLIENT_TOO_OLD`(meta 带 `minVersion` / `clientVersion`)。 -- **默认宽松**:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 `dev` 构建)→ 放行,保证既有 CLI - 默认可用;破坏性线协议变更时上调最低版本即门控掉更旧的 CLI。 -- **按版本线比对、忽略预发布后缀**:比对前把版本 coerce 到 `major.minor.patch`。CLI 与 app 同源发版,预发布 - 构建(如 `0.9.0-alpha.1`)与其正式版同属一条版本线、共享同一线协议;semver 中预发布低于其正式版,若不忽略 - 后缀会把与下限同线的预发布 CLI 误判为过旧(下限 `0.9.0` 时连 `0.9.0-alpha.1` 都会被拦)。下限即版本线粒度 - 的破坏性变更闸门,不区分同线内的预发布序号。 -- CLI 收到该错误码即输出明确的「CLI 过旧、请升级」提示(含双方版本),不裸报 HTTP 错误。 - -### 写边界(放开评审动作,拒绝合并与变更类 Agent 工具) - -- 开放的写操作**限定评审动作**:`POST …/approve`·`…/needswork`(远端评审决断,复用 `prs:setLocalStatus`—— - 先写远端评审状态、再落本地)与 `POST …/comment`(发顶层评论,复用 `comments:create`)。均为真实远端写。 -- Agent 指令(`…/agent/instruct`)**仍限只读工具**(`/describe`·`/review`·`/ask`·`/improve`);变更类工具 - (`/publish` 等,见工具注册表 `kind: 'mutating'`)**在 API 层即被硬拒绝**——与 Agent 自身 grant 授权闸 - **相互独立**:即便某 PR 的 AutoPilot grants 授予了写权限,经 API 的 instruct 仍不得触发写工具。评审写动作 - 改走上面的 approve / needswork / comment 专用端点,不经 instruct。 -- **不开放合并(merge)**:对远端影响大且不可逆,暂不纳入 API。 -- **无二次确认**:API 无交互确认通道;已开放的写端点直接执行(调用方自负授权),需交互确认的动作(如合并) - 干脆不开放。 - -### 生命周期与热生效 - -- **启动时机**:在主进程完成连接 / IPC 初始化(`ControllerContext` 就绪)之后、轮询启动前后启动监听器; - 仅当 `service.enabled` 为真才实际 `listen`。 -- **优雅关闭**:应用退出(`before-quit`)时关闭监听、停止接收新连接、放行 in-flight 请求后退出。 -- **热生效**:`enabled` / `host` / `port` / token 变更 → 写盘 + 内存同步 + **停旧监听起新监听**(端口 / 地址变更 - 必然重建;token 变更即时生效,旧 token 立刻失效)。与既有「保存即热生效」一致,无需重启应用。 - -### 并发与资源 - -- 只读 `GET` 端点可并发处理。 -- Agent 写入型动作(触发 review / 指令 / 聊天)**复用既有 run 队列与 Orchestrator 的单工作者 + 并发上限**, - 不绕过调度——API 触发与 GUI 触发在同一队列里排队,互不抢占语义保持一致。 - -## 数据 / 接口契约 - -### 配置(`config.yaml` 顶层 `service`) +# Local API service + +## Responsibilities & boundaries + +Provide a **local HTTP API** inside the main process that exposes the app's existing PR discovery / browsing / Agent +capabilities over a language-agnostic wire protocol to **external agents / tools / scripts** (via the [CLI](02-cli.md) or +direct HTTP calls). It is the **second front-end** after the renderer's IPC — the same main-process service layer behind a +different inbound protocol. + +Responsible for: the service-listener toggle and lifecycle, bearer-token authentication, request routing and response +enveloping, and mapping internal capabilities onto a stable HTTP contract. + +The exposed **write operations** are limited to review actions: approve / needswork (remote review decisions) and top-level +comment (posting a comment), reusing the GUI's same-source controller (see "Write boundary" below). There is also +`POST …/refresh`, which triggers one local polling refresh — though it uses POST, it has **no remote write side effect** +(pure remote read + local persist), so it is not a review write action and is unrelated to the ban on merge / change-type +tools. + +**Not responsible for**: + +- **Merge and pr-agent change-type tools** — merge (merging a PR), pr-agent's publish, and other mutating tools are not + exposed; if needed the caller implements them itself via the platform API (see "Write boundary" below). +- **Multi-user / remote-service shape** — it remains a single-user local app; the API is only a local (or optionally LAN) + inbound channel and introduces no account system. +- Business logic itself — it reuses the same service layer as the IPC controllers, without a separate implementation on the + HTTP side. + +## Core design + +### Off by default, authentication enforced + +- **Disabled by default**: `config.yaml` gains a `service` section, with `enabled` defaulting to `false`. When off, the main + process listens on no port — zero external surface. +- **Mandatory bearer token**: enabling the listener requires a token — every request must carry + `Authorization: Bearer <token>`; missing / mismatched is rejected outright (401 + error code). There is **no "disable auth" + option**. When the toggle is first turned on and the token is empty, a high-strength random token is **auto-generated** + (`crypto.randomBytes` → base64url / hex), guaranteeing that "enabled" and "has a token" are atomically bound. +- **Constant-time comparison**: token verification uses constant-time comparison to avoid a timing side channel. +- **Token storage follows the existing credential policy**: the token is stored in plaintext in `config.yaml` (same as + platform tokens / LLM keys / proxy passwords, read/written through the `SecretStore` abstraction, never entering logs / + exception traces) — a known risk mitigated by tightened file permissions. When migrating to a keychain in the future it + moves along with the existing credentials. + +### Listen address and port + +- **Loopback only by default**: `host` defaults to `127.0.0.1`, reachable only locally. This is the secure default for the + vast majority of "local external-agent integration" scenarios. +- **Optional `0.0.0.0`**: can be configured to listen on all interfaces (for same-subnet remote agents / CI nodes to connect). + This is an **explicit high-risk option** — the settings page and docs must give a security warning (the token is the only + line of defense; a firewall / reverse proxy is recommended). When bound to `0.0.0.0`, token strength and secrecy are + especially critical. +- **Fixed secure default port**: defaults to `18765` (changeable in config). Choosing 10000+ both avoids the crowded 8xxx + development / system-service range (3000 / 5173 / 8000 / 8080 / 8888, etc.) and sits **below the ephemeral port range** + (Windows 49152+ / Linux 32768+) — a fixed listen port landing in the ephemeral range could contend with the system's + transient outbound sockets, whereas `18765` is in the registered-port range with no such risk. If binding fails because the + port is taken, log the error and warn non-fatally (without blocking app startup). + +### HTTP implementation: minimal dependencies + +- Use Node's built-in `http` to start the server + a **minimal hand-written router** (matching by method + path pattern), + **without pulling in a heavy framework like express** — consistent with the project's "prefer reuse, minimal dependencies"; + the endpoint count is limited and needs no framework. +- Shared middle stages: JSON body parsing (with a max body limit), request timeout, auth verification, error → response + enveloping, and access logging (recording method / path / status / latency, but **not** the token or sensitive body). + +### Routes reuse the service layer + +- HTTP route handlers obtain services through the **same process-level `ControllerContext`** (`getContext()`) as the IPC + controllers (`ctx.pr` / `ctx.orchestrator` / `ctx.poller` / `ctx.connectionRuntime`, etc.), **without duplicating business + logic**. +- Principle: core capabilities live in the service layer; IPC and HTTP each do only a **thin wrapper + protocol adaptation**. + Before adding an API endpoint, first ensure the corresponding capability has a reusable method in the service layer (sinking + inlined controller logic into the service where necessary). +- **Routes are split by domain into modules**: HTTP route handlers are placed in separate modules by business domain + (system-level / PR / Agent); the aggregation layer only does **registration and path matching** and contains no business + logic — symmetric with the domain split of the [CLI](02-cli.md) command tree; new endpoints go into the corresponding + domain for easy location and extension. + +### CLI version compatibility gating + +The server applies CLI version gating uniformly to **all API calls** (not differentiated per endpoint): + +- The CLI carries a version header `X-Meebox-CLI-Version: <version>` on every request declaring its own version (taken from + the same source as the app; see [CLI](02-cli.md)). +- The server centrally manages the **minimum compatible CLI version**; request middleware compares: a version header that is + **parseable and below the floor** → intercept, return `426 Upgrade Required` + error code `SV_CLIENT_TOO_OLD` (meta carries + `minVersion` / `clientVersion`). +- **Lenient by default**: a missing version header (old CLI / non-CLI clients) or an unparseable version (e.g. a local `dev` + build) → pass through, guaranteeing existing CLIs work by default; on a breaking wire-protocol change, raise the minimum + version to gate out older CLIs. +- **Compare by version line, ignoring the prerelease suffix**: coerce the version to `major.minor.patch` before comparing. + The CLI and app release from the same source; a prerelease build (e.g. `0.9.0-alpha.1`) belongs to the same version line as + its stable release and shares the same wire protocol; in semver a prerelease ranks below its stable version, so not ignoring + the suffix would misjudge a prerelease CLI on the same line as the floor as too old (with a floor of `0.9.0`, even + `0.9.0-alpha.1` gets blocked). The floor is the breaking-change gate at version-line granularity and does not distinguish + prerelease ordinals within the same line. +- On receiving this error code the CLI prints a clear "CLI too old, please upgrade" message (with both versions), rather than + raising a raw HTTP error. + +### Write boundary (allow review actions, reject merge and change-type Agent tools) + +- The exposed write operations are **limited to review actions**: `POST …/approve` · `…/needswork` (remote review decisions, + reusing `prs:setLocalStatus` — write the remote review status first, then persist locally) and `POST …/comment` (post a + top-level comment, reusing `comments:create`). All are real remote writes. +- Agent instructions (`…/agent/instruct`) are **still limited to read-only tools** (`/describe` · `/review` · `/ask` · + `/improve`); change-type tools (`/publish`, etc.; see the tool registry `kind: 'mutating'`) are **hard-rejected at the API + layer** — this is **independent of** the Agent's own grant authorization gate: even if a PR's AutoPilot grants have granted + write permission, an instruct via the API still may not trigger write tools. Review write actions go through the dedicated + approve / needswork / comment endpoints above, not through instruct. +- **Merge not exposed**: high-impact and irreversible on the remote, so not included in the API for now. +- **No secondary confirmation**: the API has no interactive confirmation channel; already-exposed write endpoints execute + directly (the caller bears responsibility for authorization); actions that need interactive confirmation (e.g. merge) are + simply not exposed. + +### Lifecycle and taking effect immediately + +- **Startup timing**: start the listener after the main process finishes connection / IPC initialization + (`ControllerContext` ready), around when polling starts; only actually `listen` when `service.enabled` is true. +- **Graceful shutdown**: on app exit (`before-quit`), close the listener, stop accepting new connections, drain in-flight + requests, then exit. +- **Take effect immediately**: changing `enabled` / `host` / `port` / token → write to disk + sync in memory + **stop the old + listener and start a new one** (a port / address change necessarily rebuilds; a token change takes effect immediately and + the old token is invalidated at once). Consistent with the existing "save to take effect immediately", no app restart + required. + +### Concurrency and resources + +- Read-only `GET` endpoints can be processed concurrently. +- Agent write-type actions (trigger review / instruct / chat) **reuse the existing run queue and the Orchestrator's single + worker + concurrency cap**, without bypassing scheduling — API-triggered and GUI-triggered runs queue in the same queue, + keeping non-preemptive semantics consistent. + +## Data / interface contract + +### Config (`config.yaml` top-level `service`) ```yaml service: - enabled: false # 总开关;默认关 = 不监听、零暴露面 - host: 127.0.0.1 # 监听地址;可设 0.0.0.0(高风险,需安全警示) - port: 18765 # 固定安全默认端口(10000+,避开 8xxx 拥挤段且低于临时端口范围),可改 - token: '' # bearer token;启用且为空时自动生成;明文落盘(同既有凭据策略) + enabled: false # master switch; off by default = not listening, zero exposure + host: 127.0.0.1 # listen address; can be 0.0.0.0 (high risk, needs a security warning) + port: 18765 # fixed secure default port (10000+, avoids the crowded 8xxx range and stays below the ephemeral range), changeable + token: '' # bearer token; auto-generated when enabled and empty; stored in plaintext (same as the existing credential policy) ``` -### 鉴权 +### Authentication -- 请求头:`Authorization: Bearer <token>`;缺失 / 不匹配 → `401` + 错误码。 -- token 经 `SecretStore` 读写,响应 / 日志中不回显。 +- Request header: `Authorization: Bearer <token>`; missing / mismatched → `401` + error code. +- The token is read/written through `SecretStore` and is never echoed in responses / logs. -### 统一响应封套 +### Uniform response envelope ```jsonc -// 成功 +// success { "ok": true, "data": <T> } -// 失败(复用 AppError 的 code + 可序列化 meta;前端 / CLI 按码本地化) +// failure (reuses AppError's code + serializable meta; frontend / CLI localize by code) { "ok": false, "error": { "code": "ESV0001", "meta": { /* ... */ } } } ``` -- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写工具被拒(未开放的写操作)/ `404` 资源不存在 / - `409` 冲突 / `426` CLI 版本过旧 / `500` 内部错误。 -- 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)): - 如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。 +- HTTP status codes align with semantics: `400` validation failure / `401` unauthorized / `403` write tool rejected (a + non-exposed write operation) / `404` resource not found / `409` conflict / `426` CLI version too old / `500` internal error. +- Adds a new **`SV` (service) error-code domain** (`E`+`SV`+four digits, see [error-code spec](../99-core/04-error-codes.md)): + e.g. invalid token, write operation rejected, listener not ready; parallel to the existing `AG`/`PR`/`NT` domains. -### 端点(`/api/v1`,逐条对应 [CLI](02-cli.md) 命令) +### Endpoints (`/api/v1`, one-to-one with [CLI](02-cli.md) commands) -读端点用 `GET`、写端点用 `POST`。列表返回**精简投影**,其余读端点返回同源结构。 +Read endpoints use `GET`, write endpoints use `POST`. The list returns a **compact projection**; other read endpoints return +the same-source structure. -| Method & Path | 用途 | 复用的内部能力 | +| Method & Path | Purpose | Reused internal capability | | --- | --- | --- | -| `GET /api/v1/whoami` | 当前身份:活动连接 PAT 所属用户(`name`/`displayName`/`slug`)+ 集成平台 + 连接显示名;无活动连接各项 null | 连接摘要(当前用户 + 平台) | -| `GET /api/v1/categories` | 当前启用平台下可用的分类标签:`categories`(`PrDiscoveryFilter`)+ `statuses`(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | -| `POST /api/v1/refresh` | 触发一次立即轮询刷新(拉取所有连接的最新 PR、落本地),返回本轮计数汇总(`PollResult`:fetched / changed / added / removed / errors);等价 GUI 手动刷新,无远端写副作用 | `poller.tick`(`prs:refresh` 同源) | -| `GET /api/v1/version` | 服务端(桌面应用)版本(`{ version }`),供 CLI `version` 命令同时展示客户端 + 服务端版本 | `buildAppInfo().appVersion`(`app:info` 同源) | -| `GET /api/v1/prs` | PR 列表(**精简投影** `PrListItem`:字段序 id/title/author/createdAt 优先,去 description、人员仅 slug);query:`category`(一级)/`status`(二级)/`q`(检索)/`skip`+`limit`(分页,默认 limit 100) | `prs:list` + 列表筛选谓词 + 视图投影 | -| `GET /api/v1/prs/{id}` | 描述详情(完整 `StoredPullRequest`:标题 / 描述 / 作者 / 分支 / 时间 / 状态 / 合并态) | `StoredPullRequest` | -| `GET /api/v1/prs/{id}/diff` | 变更文件列表;带 `?path=&side=base\|head` 时取单文件内容 | `diff:listChangedFiles` / `diff:getFileContent` 同源 | -| `GET /api/v1/prs/{id}/activity` | 动态(评论 / 提交更新 / 评审决断归并的时间线) | `diff:listActivity` 同源 | -| `GET /api/v1/prs/{id}/commits` | 提交列表(`PrCommit[]`) | `diff:listCommits` 同源 | -| `GET /api/v1/prs/{id}/reviewers` | 评审人审批状态(`Reviewer[]`,含各人 `status`) | `StoredPullRequest.reviewers` | -| `GET /api/v1/prs/{id}/agent` | Agent 当前执行状态(`AgentSession`:status / 进度 / 总结 / 建议) | `agent:getSession` 同源 | -| `GET /api/v1/prs/{id}/agent/conversation` | 历史会话(`AgentMessage[]`) | `agent:getConversation` 同源 | -| `POST /api/v1/prs/{id}/agent/review` | 执行 auto review(固定评审微流程 describe→review→[追问]→总结) | `agent:run` 同源 | -| `POST /api/v1/prs/{id}/agent/instruct` | 发送 Agent 指令(**仅只读工具**:describe / review / ask / improve;写工具硬拒绝) | 只读工具派发(复用 run 队列) | -| `POST /api/v1/prs/{id}/agent/chat` | 发送自然语言聊天(可触发 Agent 规划与任务执行) | `agent:ask` / `agent:enqueueMessage` 同源 | -| `POST /api/v1/prs/{id}/agent/stop` | 中断该 PR 运行中的 Agent(思考 / 执行任意阶段即时停;PR 级,非按单个 run) | `agent:stop` 同源 | -| `POST /api/v1/prs/{id}/approve` | 评审决断「通过」(写远端评审状态 + 落本地) | `prs:setLocalStatus` 同源 | -| `POST /api/v1/prs/{id}/needswork` | 评审决断「需修改」(写远端评审状态 + 落本地) | `prs:setLocalStatus` 同源 | -| `POST /api/v1/prs/{id}/comment` | 发一条顶层评论(body 为正文,空则 400) | `comments:create` 同源 | - -- `{id}` 即 PR 的 `localId`——跨平台稳定 PR 标识(内部哈希,非平台 `remoteId`);列表投影里对外命名为 `id`,所有 PR 维度端点以它定位。 -- 过程步骤(transcript)暂不在初版 API 内开放,作为将来扩展位(见下)。 - -### 新增 IPC(设置页驱动) - -- `config:setService`:写 `service` 段 → 写盘 + 内存同步 + 重建监听器(热生效)。 -- `config:generateServiceToken`:重新生成 token → 写盘 + 即时失效旧 token,返回新 token 供 UI 展示 / 复制。 - -## 扩展与注意事项 - -- **加新端点先下沉 service**:HTTP 与 IPC 必须共用 service 方法,避免逻辑分叉;端点是 service 能力的薄投影。 -- **写边界是硬约束**:评审写动作仅经 approve / needswork / comment 专用端点;Agent `instruct` 的只读工具 - 白名单在 API 层强校验、独立于 Agent grant 闸——新增 Agent 工具时同步确认其 `kind` 与是否纳入 instruct - 白名单,默认排除一切 `mutating`。放开新的写端点须显式评估远端副作用(合并等高影响动作暂不开放)。 -- **`0.0.0.0` 安全警示不可省**:设置页与使用文档须明确暴露范围与风险;token 是唯一防线。 -- **端口冲突**:监听失败以非致命方式提示,不拖垮应用启动;提示用户改端口。 -- **进度推送是将来扩展位**:初版以「轮询 `GET .../agent` 拉状态」为主;如需实时进度,可在同一监听器上加 - SSE / WebSocket 推送 Agent step 事件(复用现有 `agent:stepProgress` 广播),不改既有 REST 契约。 -- **契约稳定性**:`/api/v1` 前缀预留版本演进位;响应封套与错误码领域一旦发布需保持兼容(CLI 与第三方依赖它)。 +| `GET /api/v1/whoami` | Current identity: the user the active connection's PAT belongs to (`name`/`displayName`/`slug`) + integration platform + connection display name; each field null when there is no active connection | connection summary (current user + platform) | +| `GET /api/v1/categories` | Available category labels under the currently enabled platform: `categories` (`PrDiscoveryFilter`) + `statuses` (status / merge-state filters), trimmed per platform capability | platform capability flags + list-filter semantics | +| `POST /api/v1/refresh` | Trigger one immediate polling refresh (fetch the latest PRs across all connections, persist locally), returning this round's count summary (`PollResult`: fetched / changed / added / removed / errors); equivalent to a GUI manual refresh, no remote write side effect | `poller.tick` (same source as `prs:refresh`) | +| `GET /api/v1/version` | Server (desktop app) version (`{ version }`), so the CLI `version` command can show both client + server versions | `buildAppInfo().appVersion` (same source as `app:info`) | +| `GET /api/v1/prs` | PR list (**compact projection** `PrListItem`: field order prioritizes id/title/author/createdAt, drops description, people as slug only); query: `category` (level one) / `status` (level two) / `q` (search) / `skip`+`limit` (pagination, default limit 100) | `prs:list` + list-filter predicate + view projection | +| `GET /api/v1/prs/{id}` | Description detail (full `StoredPullRequest`: title / description / author / branch / time / status / merge state) | `StoredPullRequest` | +| `GET /api/v1/prs/{id}/diff` | Changed-file list; with `?path=&side=base\|head` fetches single-file content | `diff:listChangedFiles` / `diff:getFileContent` same source | +| `GET /api/v1/prs/{id}/activity` | Activity (timeline merging comments / commit updates / review decisions) | `diff:listActivity` same source | +| `GET /api/v1/prs/{id}/commits` | Commit list (`PrCommit[]`) | `diff:listCommits` same source | +| `GET /api/v1/prs/{id}/reviewers` | Reviewer approval status (`Reviewer[]`, with each person's `status`) | `StoredPullRequest.reviewers` | +| `GET /api/v1/prs/{id}/agent` | The Agent's current execution status (`AgentSession`: status / progress / summary / recommendation) | `agent:getSession` same source | +| `GET /api/v1/prs/{id}/agent/conversation` | Conversation history (`AgentMessage[]`) | `agent:getConversation` same source | +| `POST /api/v1/prs/{id}/agent/review` | Run auto review (the fixed review micro-flow describe→review→[follow-up ask]→summary) | `agent:run` same source | +| `POST /api/v1/prs/{id}/agent/instruct` | Send an Agent instruction (**read-only tools only**: describe / review / ask / improve; write tools hard-rejected) | read-only tool dispatch (reuses the run queue) | +| `POST /api/v1/prs/{id}/agent/chat` | Send natural-language chat (can trigger Agent planning and task execution) | `agent:ask` / `agent:enqueueMessage` same source | +| `POST /api/v1/prs/{id}/agent/stop` | Interrupt the running Agent for this PR (stops immediately at any thinking / execution stage; PR-level, not per individual run) | `agent:stop` same source | +| `POST /api/v1/prs/{id}/approve` | Review decision "approve" (write remote review status + persist locally) | `prs:setLocalStatus` same source | +| `POST /api/v1/prs/{id}/needswork` | Review decision "needs work" (write remote review status + persist locally) | `prs:setLocalStatus` same source | +| `POST /api/v1/prs/{id}/comment` | Post a top-level comment (body is the content, `400` if empty) | `comments:create` same source | + +- `{id}` is the PR's `localId` — a cross-platform stable PR identifier (an internal hash, not the platform `remoteId`); in the + list projection it is named `id` externally, and all PR-scoped endpoints locate by it. +- Step transcripts are not exposed in the initial API; they are reserved as a future extension slot (see below). + +### New IPC (driven by the settings page) + +- `config:setService`: write the `service` section → write to disk + sync in memory + rebuild the listener (take effect + immediately). +- `config:generateServiceToken`: regenerate the token → write to disk + immediately invalidate the old token, returning the + new token for the UI to display / copy. + +## Extension & caveats + +- **Sink into the service before adding a new endpoint**: HTTP and IPC must share the service method to avoid logic forking; + an endpoint is a thin projection of a service capability. +- **The write boundary is a hard constraint**: review write actions go only through the dedicated approve / needswork / + comment endpoints; the read-only-tool whitelist for the Agent `instruct` is strictly validated at the API layer, + independent of the Agent grant gate — when adding a new Agent tool, confirm in sync its `kind` and whether it enters the + instruct whitelist, excluding all `mutating` by default. Exposing a new write endpoint requires explicitly assessing remote + side effects (high-impact actions like merge remain unexposed). +- **The `0.0.0.0` security warning cannot be omitted**: the settings page and usage docs must make the exposure scope and risk + explicit; the token is the only line of defense. +- **Port conflict**: a failed listen warns non-fatally without dragging down app startup; prompt the user to change the port. +- **Progress push is a future extension slot**: the initial version relies mainly on "poll `GET .../agent` to pull status"; + if real-time progress is needed, SSE / WebSocket pushing Agent step events can be added on the same listener (reusing the + existing `agent:stepProgress` broadcast) without changing the existing REST contract. +- **Contract stability**: the `/api/v1` prefix reserves room for version evolution; once the response envelope and error-code + domain are published they must stay compatible (the CLI and third parties depend on them). diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index fbf5b5aa..f100ddb8 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -1,162 +1,197 @@ -# CLI 工具(meebox) +# CLI tool (meebox) -## 职责与边界 +## Responsibilities & boundaries -提供一个**独立分发的跨平台命令行客户端**,经[本地 API](01-service-api.md) 消费应用能力,供外部 -agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 +Provide an **independently distributed cross-platform command-line client** that consumes the app's capabilities through the +[local API](01-service-api.md), so external agents / scripts / CI can fold meebox's PR discovery, browsing, and Agent +operations into automation flows. The command is named **`meebox`**. -负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 -约定退出码。提供浏览与**评审写动作**(approve / needswork / comment)——与服务端写边界一致。 +Responsible for: wrapping API endpoints into a handy command tree, parsing connection / auth config, output for two +consumption modes (human / machine — text / JSON), and an exit-code convention. It provides browsing and **review write +actions** (approve / needswork / comment) — consistent with the server's write boundary. -**不负责**: +**Not responsible for**: -- 业务逻辑 —— CLI 是 API 的瘦客户端,不内置任何评审 / 平台逻辑。 -- **合并与变更类 Agent 工具**(merge / publish 等)—— 不提供对应命令;API 本就不开放(见 [服务端写边界](01-service-api.md))。 -- 桌面应用本体 —— CLI **不内嵌进安装包**,是独立可分发物(见下「分发」)。 +- Business logic — the CLI is a thin client over the API and embeds no review / platform logic. +- **Merge and change-type Agent tools** (merge / publish, etc.) — no corresponding commands; the API itself does not expose + them (see [server write boundary](01-service-api.md)). +- The desktop app itself — the CLI is **not embedded in the installer**; it is an independent distributable (see + "Distribution" below). -## 核心设计 +## Core design -### 技术栈与仓库形态决策(Go 评估) +### Tech-stack and repo-shape decision (Go evaluation) -CLI 优先技术栈定为 **Go**,并就「是否内嵌当前项目」给出结论——**该问题分两层,结论不同**: +The preferred tech stack for the CLI is settled as **Go**, and the "whether to embed it in the current project" question has a +conclusion — **the question has two layers, with different conclusions**: -1. **是否打进 Electron 安装包:否。** CLI 面向外部自动化、经 HTTP 与应用通信,无需随桌面包分发;打进去 - 只会无谓增大安装体积。二者是**相互独立的可分发物**。 -2. **源码是否放进本仓库(monorepo):是,但作为独立的顶层 `cli/` 目录、自带 `go.mod`,不纳入 npm - workspaces / Nx。** Go 有独立的模块系统与构建缓存,与 npm/Nx 的工程模型不兼容;强行包成 Nx project - (run-commands 壳)徒增复杂、且让 Go 工具链成为全仓开发的前置。CLI 与主工程的**唯一耦合是 HTTP/JSON - 线协议**(语言无关),无代码级共享,故「同仓、独立构建」最自然。 +1. **Whether to bundle it into the Electron installer: no.** The CLI targets external automation and talks to the app over + HTTP; it need not ship with the desktop package, and bundling it would only needlessly bloat the install size. The two are + **independent distributables**. +2. **Whether to place the source in this repo (monorepo): yes, but as a standalone top-level `cli/` directory with its own + `go.mod`, not part of npm workspaces / Nx.** Go has its own module system and build cache, incompatible with the npm/Nx + engineering model; forcibly wrapping it into an Nx project (a run-commands shell) adds complexity and makes the Go toolchain + a prerequisite for whole-repo development. The CLI's **only coupling with the main project is the HTTP/JSON wire protocol** + (language-agnostic), with no code-level sharing, so "same repo, independent build" is the most natural. -**为什么 Go 适合做这个 CLI**:静态链接小体积二进制、`GOOS`/`GOARCH` 一条命令交叉编译出全平台、启动快、 -无运行时依赖——正是分发型 CLI 的理想形态。相较把 Node/TS 用 pkg / SEA 打包(产物数十 MB、交叉编译脆弱、 -冷启动慢),Go 在分发体验上明显占优。 +**Why Go suits this CLI**: a small statically linked binary, cross-compilation for all platforms with a single +`GOOS`/`GOARCH` command, fast startup, no runtime dependency — exactly the ideal shape for a distribution CLI. Compared with +bundling Node/TS via pkg / SEA (tens of MB, fragile cross-compilation, slow cold start), Go is clearly ahead on distribution +experience. -**代价与应对——类型契约同步**:Go 端无法编译期复用 `shared` 的 TS 类型。 +**Cost and mitigation — type-contract sync**: the Go side cannot reuse `shared`'s TS types at compile time. -- **初期**:API 端点少而稳,**手写 Go struct 对齐文档契约**即可(成本低)。 -- **将来**:若契约增长,引入 OpenAPI / JSON Schema 作单一事实源,生成 TS 侧校验 + Go 侧 client, - 消除手工漂移。 +- **Initially**: the API endpoints are few and stable, so **hand-written Go structs aligned to the doc contract** suffice + (low cost). +- **Later**: if the contract grows, introduce OpenAPI / JSON Schema as the single source of truth, generating TS-side + validation + a Go-side client to eliminate manual drift. -### 连接与鉴权 +### Connection and authentication -CLI 需 API base URL + token。来源优先级(高 → 低): +The CLI needs an API base URL + token. Source priority (high → low): -1. 命令行 flag:`--api-url` / `--token`; -2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; -3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置)。 +1. Command-line flags: `--api-url` / `--token`; +2. Environment variables: `MEEBOX_API_URL` / `MEEBOX_TOKEN`; +3. The CLI's own config file `~/.code-meeseeks/cli.yaml` (same directory as the GUI's `config.yaml` but a separate file, + isolating the two configs). -连接信息须**显式提供**(flag / 环境变量 / `cli.yaml` 三者之一),token 缺失即报鉴权错误。`meebox login ---token <token> [--server <url>]` 把 token(与可选 server,默认 loopback)写入 `cli.yaml`,免去后续每次传参—— -它是 CLI 唯一的配置**写入**命令,与 `cli.yaml` 的读取(上述优先级)配对,使配置管理自洽。 +Connection info must be **explicitly provided** (one of flag / environment variable / `cli.yaml`); a missing token raises an +auth error. `meebox login --token <token> [--server <url>]` writes the token (and an optional server, defaulting to loopback) +into `cli.yaml`, saving passing arguments every time thereafter — it is the CLI's only config **write** command, paired with +the reading of `cli.yaml` (the priority above), making config management self-contained. -**不读取 GUI 主配置**:CLI 刻意**不**读应用主配置 `~/.code-meeseeks/config.yaml`。该文件承载连接层机密 -(各代码平台的访问令牌等),若从中静默取服务令牌,等于让 CLI 触达其本不应接触的凭据——属预期外的越权访问, -故移除此前的「本机自动发现」设计。环境变量 `MEEBOX_TOKEN` 是本机免逐次传参的推荐方式(配合 shell / CI 环境注入)。 +**Does not read the GUI's main config**: the CLI deliberately does **not** read the app's main config +`~/.code-meeseeks/config.yaml`. That file carries connection-layer secrets (access tokens for each code platform, etc.); +silently taking the service token from it would let the CLI reach credentials it should not touch — an out-of-scope +over-reach, so the earlier "local auto-discovery" design was removed. The environment variable `MEEBOX_TOKEN` is the +recommended way to avoid passing arguments each time on a local machine (paired with shell / CI environment injection). -### 命令结构 +### Command structure ```text -meebox [全局 flag] <组> <命令> [参数] +meebox [global flags] <group> <command> [args] -全局 flag:--api-url · --token · --output (yaml|json) · --quiet +global flags: --api-url · --token · --output (yaml|json) · --quiet ``` -命令分两类——**根层级系统性命令** 与 **两个领域组**: +Commands fall into two classes — **root-level system commands** and **two domain groups**: -- **系统性命令(根层级)** —— `login`(保存凭据到 `cli.yaml`)、`whoami`(身份)、`version`(客户端 + - 服务端版本)、`skill`(打印内嵌的 SKILL.md):与具体 PR / Agent 无关的工具 / 会话层操作,直接置于根层级、 - 不套领域组(符合 `kubectl version` / `gh auth` 等惯例)。 -- **`pr`** —— PR 相关操作:浏览 + 评审写动作,并含 `categories`(`pr list` 的筛选词表)与 `refresh` - (触发一次拉取、刷新 PR 列表)。 -- **`agent`** —— 评审 Agent 操作。 +- **System commands (root level)** — `login` (save credentials to `cli.yaml`), `whoami` (identity), `version` (client + + server version), `skill` (print the embedded SKILL.md): tool / session-level operations unrelated to a specific PR / Agent, + placed directly at the root level without a domain group (following conventions like `kubectl version` / `gh auth`). +- **`pr`** — PR-related operations: browsing + review write actions, plus `categories` (the filter vocabulary for `pr list`) + and `refresh` (trigger one fetch, refresh the PR list). +- **`agent`** — review Agent operations. -`pr` / `agent` 下的 PR 维度子命令用**必填 flag `--pr <id>`** 传 PR 标识(`id` 由 `pr list` 输出获得)—— -agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 `pr` 平级;根层级系统性命令与 -`pr categories` / `pr refresh` / `pr list` 非 PR 维度,无需 `--pr`。 +PR-scoped subcommands under `pr` / `agent` pass the PR identifier via the **required flag `--pr <id>`** (`id` is obtained from +`pr list` output) — agent is **not nested inside `pr`** (avoiding the duplicated `pr` in `pr agent … --pr`) and sits as a +peer of `pr`; the root-level system commands and `pr categories` / `pr refresh` / `pr list` are not PR-scoped and need no +`--pr`. -| 命令 | 用途 | 对应 API | +| Command | Purpose | Corresponding API | | --- | --- | --- | -| `meebox login --token <token> [--server <url>]` | 保存 token(与可选 server,默认 loopback)到 `cli.yaml`,供后续命令免传参 | —(本地写,无 API) | -| `meebox whoami` | 当前身份(用户 + 平台 + 连接名) | `GET /whoami` | -| `meebox version` | 客户端(CLI)+ 服务端(应用)版本;服务端不可达时仅客户端、退出码仍 0 | `GET /version` | -| `meebox skill` | 打印构建时 `go:embed` 内嵌的 agent 使用说明(SKILL.md) | —(本地,无 API) | -| `meebox pr categories` | 列当前启用平台的分类标签(`categories` 一级 + `statuses` 二级)——`pr list` 的筛选词表 | `GET /categories` | -| `meebox pr refresh` | 触发一次立即轮询刷新(拉取最新 PR、落本地),返回本轮计数汇总(fetched / changed / added / removed / errors);等价 GUI 手动刷新 | `POST /refresh` | -| `meebox pr list [--category <一级>] [--status <二级>] [--query <检索>] [--skip N] [--limit N]` | PR 列表(精简投影 + 分页,默认 limit 100) | `GET /prs` | -| `meebox pr show --pr <id>` | 描述详情 | `GET /prs/{id}` | -| `meebox pr diff --pr <id> [--file <path>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | `GET /prs/{id}/diff` | -| `meebox pr activity --pr <id>` | 动态(时间线) | `GET /prs/{id}/activity` | -| `meebox pr commits --pr <id>` | 提交列表 | `GET /prs/{id}/commits` | -| `meebox pr reviewers --pr <id>` | 评审人审批状态 | `GET /prs/{id}/reviewers` | -| `meebox pr approve --pr <id>` | 评审决断「通过」(真实远端写) | `POST /prs/{id}/approve` | -| `meebox pr needswork --pr <id>` | 评审决断「需修改」(真实远端写) | `POST /prs/{id}/needswork` | -| `meebox pr comment --pr <id> <message>` | 发一条顶层评论(真实远端写) | `POST /prs/{id}/comment` | -| `meebox agent status --pr <id>` | Agent 当前执行状态 | `GET /prs/{id}/agent` | -| `meebox agent history --pr <id>` | 历史会话 | `GET /prs/{id}/agent/conversation` | -| `meebox agent review --pr <id>` | 执行 auto review | `POST /prs/{id}/agent/review` | -| `meebox agent instruct --pr <id> <command> [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | -| `meebox agent chat --pr <id> <message>` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | -| `meebox agent stop --pr <id>` | 中断该 PR 运行中的 Agent(PR 级) | `POST /prs/{id}/agent/stop` | -| `meebox agent run list --pr <id>` | 该 PR 运行队列中的 pr-agent runs(active + waiting) | `GET /prs/{id}/agent/runs` | -| `meebox agent run cancel --pr <id> --run <runId>` | 按 run 取消一个 pr-agent 工具调用 | `POST /prs/{id}/agent/runs/{runId}/cancel` | - -- `<id>` 为 PR 的 `localId`(列表投影里对外命名为 `id`,由 `pr list` 输出获得)。 -- 评审写动作走 `pr approve` / `pr needswork` / `pr comment` 专用命令;变更类工具(publish 等)不在 `instruct` - 白名单内,传入即被服务端拒绝(CLI 亦前置友好报错)。merge(合并)不提供。 -- 中断粒度:`agent stop` 停整个 PR 的 Agent;`agent run cancel` 只取消指定的单个 pr-agent run。 - -### 输出与退出码 - -- **`--output yaml`(默认)**:把响应渲染为 YAML(类 k8s `-o yaml`)——结构化又可读,便于人交互式查看。 - 与 JSON 一样是对响应数据的**通用转换**,不做逐命令表格 / formatter(省去手写 struct 的契约同步负担)。 -- **`--output json`**:原样输出 API `data`,供外部 agent / 脚本机器消费。agent 传参无门槛,故默认面向人优化(YAML)、 - 机器集成显式取 `json`;两者字段形状同源、皆稳定。 -- **退出码约定**:`0` 成功;非 0 表错误并按类别区分(如 `2` 鉴权失败、`3` 资源不存在、`1` 通用错误); - 错误信息打 `stderr`,携带服务端返回的错误码(`ESV*` 等),便于脚本分支处理。 - -### 实现选型 - -- Go + 命令树库(如 cobra)+ 标准 `net/http` client,**最小依赖**。 -- 错误码 / 响应封套与服务端契约一一对齐(见 [服务端契约](01-service-api.md))。 - -## 数据 / 接口契约 - -- **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 - (`~/.code-meeseeks/cli.yaml`)。连接信息须显式提供;CLI 不读 GUI 主配置 `config.yaml`(含连接层机密)。 -- **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 -- **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 -- **二进制与压缩包命名**:`meebox-cli-<version>-<os>-<arch>.<ext>`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), - 附 `.sha256` 校验和。`<version>` 取自 `apps/desktop/package.json`(与 app 同源,唯一真相源),发布前置校验其与 `v*` tag 一致。 -- **压缩包内容 = 可直接投放的 skill 目录**:除二进制外一并打包 `LICENSE` + `README.md` + `SKILL.md`。解压到 - agent 的 skills 目录即得一个可用 skill——`SKILL.md`(frontmatter `name: meebox`)教 agent 用法,紧邻其驱动 - 的二进制。这是 CLI「面向 agent 交付」的主形态。 -- **二进制自述(`go:embed`)**:同一份 `SKILL.md` 经 `go:embed` 于构建期内嵌进二进制,`meebox skill` 打印之。 - 即便二进制脱离压缩包(如 `go install` 或裸放 `PATH`)也能自述用法,且内嵌内容与随包 `SKILL.md` 构建期一致。 - 刻意**不做** `--manifest` 之类的 function-calling JSON——skill 的消费形态是 markdown,非工具 schema 注入; - 真有此需求应从命令树生成、而非另手维护一份 JSON。 - -## 分发与 CI - -- **覆盖平台**:Windows x64、macOS arm64、Linux x64 / arm64。 -- **随主工程一起发布**:发布流程的 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译矩阵)产出 - 四平台压缩包(含二进制 + `LICENSE` + `README.md` + `SKILL.md`)+ 校验和,与桌面安装包一并上传到**同一个 - GitHub Release**(由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 -- 版本号**取自 `apps/desktop/package.json`(与 app 同源)**,经 `-ldflags` 注入 `cmd.version`——不独立依赖 git tag(发布前置校验 tag 与之一致),确保 CLI 与服务端 API 契约版本可对应。 -- **一键安装脚本(macOS / Linux)**:`tools/cli/install.sh` 经 `curl … | bash` 一条命令完成安装——探测系统 / - 架构 → 取匹配的 Release 压缩包 → 校验 SHA-256 → 解出 `meebox` 装入 `PATH`(默认 `/usr/local/bin`,不可写回退 - `~/.local/bin`;`MEEBOX_VERSION` / `MEEBOX_BIN_DIR` 可覆盖)。刻意**不落地 `SKILL.md`**(已内嵌、`meebox skill` - 可导出)。Windows 不在脚本覆盖内,走手动下载。 - -## 扩展与注意事项 - -- **写边界与服务端一致**:仅提供评审写动作(approve / needswork / comment);合并与变更类 Agent 工具不提供。 - 新增命令前先确认对应 API 端点已存在,写端点须与服务端写边界对齐。 -- **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 -- **不触碰 GUI 机密**:CLI 不读应用主配置 `~/.code-meeseeks/config.yaml`(含各平台访问令牌等连接层机密);服务令牌须经 flag / 环境变量 / `cli.yaml` 显式提供,避免越权触达预期外凭据。 -- **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 -- **版本兼容门控**:CLI 每次请求带 `X-Meebox-CLI-Version` 头声明自身版本(与 app 同源);服务端据集中管理的 - 最低可兼容版本统一门控,CLI 收到 `SV_CLIENT_TOO_OLD`(HTTP 426)即输出「过旧、请升级」提示。见 - [服务端契约](01-service-api.md) 的「CLI 版本兼容门控」。 -- **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 -- **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` / - `NO_PROXY`;loopback(`127.0.0.1` / `localhost`)默认直连不走代理——无需自实现代理逻辑。 +| `meebox login --token <token> [--server <url>]` | Save the token (and an optional server, defaulting to loopback) to `cli.yaml`, so later commands need no arguments | — (local write, no API) | +| `meebox whoami` | Current identity (user + platform + connection name) | `GET /whoami` | +| `meebox version` | Client (CLI) + server (app) version; when the server is unreachable, client only, exit code still 0 | `GET /version` | +| `meebox skill` | Print the agent usage guide (SKILL.md) embedded at build time via `go:embed` | — (local, no API) | +| `meebox pr categories` | List the category labels for the currently enabled platform (`categories` level one + `statuses` level two) — the filter vocabulary for `pr list` | `GET /categories` | +| `meebox pr refresh` | Trigger one immediate polling refresh (fetch the latest PRs, persist locally), returning this round's count summary (fetched / changed / added / removed / errors); equivalent to a GUI manual refresh | `POST /refresh` | +| `meebox pr list [--category <level one>] [--status <level two>] [--query <search>] [--skip N] [--limit N]` | PR list (compact projection + pagination, default limit 100) | `GET /prs` | +| `meebox pr show --pr <id>` | Description detail | `GET /prs/{id}` | +| `meebox pr diff --pr <id> [--file <path>] [--side base\|head]` | Without `--file`, list changed files; with it, fetch that file's content | `GET /prs/{id}/diff` | +| `meebox pr activity --pr <id>` | Activity (timeline) | `GET /prs/{id}/activity` | +| `meebox pr commits --pr <id>` | Commit list | `GET /prs/{id}/commits` | +| `meebox pr reviewers --pr <id>` | Reviewer approval status | `GET /prs/{id}/reviewers` | +| `meebox pr approve --pr <id>` | Review decision "approve" (real remote write) | `POST /prs/{id}/approve` | +| `meebox pr needswork --pr <id>` | Review decision "needs work" (real remote write) | `POST /prs/{id}/needswork` | +| `meebox pr comment --pr <id> <message>` | Post a top-level comment (real remote write) | `POST /prs/{id}/comment` | +| `meebox agent status --pr <id>` | The Agent's current execution status | `GET /prs/{id}/agent` | +| `meebox agent history --pr <id>` | Conversation history | `GET /prs/{id}/agent/conversation` | +| `meebox agent review --pr <id>` | Run auto review | `POST /prs/{id}/agent/review` | +| `meebox agent instruct --pr <id> <command> [args]` | Send an Agent instruction (read-only only: describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | +| `meebox agent chat --pr <id> <message>` | Natural-language chat (can trigger task execution) | `POST /prs/{id}/agent/chat` | +| `meebox agent stop --pr <id>` | Interrupt the running Agent for this PR (PR-level) | `POST /prs/{id}/agent/stop` | +| `meebox agent run list --pr <id>` | The pr-agent runs in this PR's run queue (active + waiting) | `GET /prs/{id}/agent/runs` | +| `meebox agent run cancel --pr <id> --run <runId>` | Cancel one pr-agent tool call by run | `POST /prs/{id}/agent/runs/{runId}/cancel` | + +- `<id>` is the PR's `localId` (named `id` externally in the list projection, obtained from `pr list` output). +- Review write actions go through the dedicated `pr approve` / `pr needswork` / `pr comment` commands; change-type tools + (publish, etc.) are not in the `instruct` whitelist and are rejected by the server if passed (the CLI also errors friendly + up front). merge is not provided. +- Interrupt granularity: `agent stop` stops the entire PR's Agent; `agent run cancel` cancels only the specified single + pr-agent run. + +### Output and exit codes + +- **`--output yaml` (default)**: render the response as YAML (like k8s `-o yaml`) — structured yet readable, convenient for + interactive human viewing. Like JSON, it is a **generic transform** of the response data, without per-command tables / + formatters (sparing the contract-sync burden of hand-written structs). +- **`--output json`**: output the API `data` verbatim, for external agents / scripts to consume by machine. Passing arguments + has no barrier for an agent, so the default is optimized for humans (YAML) while machine integration explicitly takes + `json`; both have the same-source field shape and are both stable. +- **Exit-code convention**: `0` success; non-zero for errors, distinguished by category (e.g. `2` auth failure, `3` resource + not found, `1` generic error); error messages go to `stderr`, carrying the error code returned by the server (`ESV*`, etc.) + for scripts to branch on. + +### Implementation choices + +- Go + a command-tree library (e.g. cobra) + the standard `net/http` client, **minimal dependencies**. +- Error codes / response envelope align one-to-one with the server contract (see [server contract](01-service-api.md)). + +## Data / interface contract + +- **Config source priority**: flag > env (`MEEBOX_API_URL` / `MEEBOX_TOKEN`) > CLI config file + (`~/.code-meeseeks/cli.yaml`). Connection info must be explicitly provided; the CLI does not read the GUI's main config + `config.yaml` (which holds connection-layer secrets). +- **Output modes**: `yaml` (default, human, like k8s `-o yaml`) / `json` (machine, outputs the API `data`); both are generic + transforms of the response data. +- **Exit codes**: `0` success / `1` generic / `2` auth / `3` not found (extended as needed). +- **Binary and archive naming**: `meebox-cli-<version>-<os>-<arch>.<ext>` (Windows / macOS use `.zip`, Linux uses `.tar.gz`), + with a `.sha256` checksum. `<version>` is taken from `apps/desktop/package.json` (same source as the app, the single source + of truth), verified against the `v*` tag as a release prerequisite. +- **Archive content = a directly droppable skill directory**: besides the binary it also packages `LICENSE` + `README.md` + + `SKILL.md`. Extracting it into an agent's skills directory yields a usable skill — `SKILL.md` (frontmatter `name: meebox`) + teaches the agent the usage, right next to the binary it drives. This is the CLI's primary "delivery to agents" form. +- **Binary self-description (`go:embed`)**: the same `SKILL.md` is embedded into the binary at build time via `go:embed`, and + `meebox skill` prints it. Even a binary separated from its archive (e.g. `go install` or dropped bare onto `PATH`) can + describe its own usage, and the embedded content matches the packaged `SKILL.md` at build time. It deliberately **does not** + produce a `--manifest`-style function-calling JSON — a skill's consumption form is markdown, not tool-schema injection; if + that need arises it should be generated from the command tree, not maintained separately as JSON. + +## Distribution & CI + +- **Platform coverage**: Windows x64, macOS arm64, Linux x64 / arm64. +- **Released together with the main project**: the release flow's **Go build job** (`actions/setup-go` + `GOOS`/`GOARCH` + cross-compilation matrix) produces four-platform archives (binary + `LICENSE` + `README.md` + `SKILL.md`) + checksums, and + uploads them alongside the desktop installer to the **same GitHub Release** (triggered by the existing `v*` tag, see + [release flow](../../../AGENTS.md)). +- The version number is **taken from `apps/desktop/package.json` (same source as the app)** and injected into `cmd.version` via + `-ldflags` — it does not independently depend on the git tag (the tag is verified against it as a release prerequisite), + ensuring the CLI and server API contract versions correspond. +- **One-shot install script (macOS / Linux)**: `tools/cli/install.sh` installs in one `curl … | bash` command — detect system + / architecture → fetch the matching Release archive → verify SHA-256 → extract `meebox` and install it onto `PATH` (default + `/usr/local/bin`, falling back to `~/.local/bin` if not writable; `MEEBOX_VERSION` / `MEEBOX_BIN_DIR` can override). It + deliberately **does not lay down `SKILL.md`** (already embedded, exportable via `meebox skill`). Windows is not covered by + the script; use manual download. + +## Extension & caveats + +- **Write boundary consistent with the server**: only review write actions are provided (approve / needswork / comment); + merge and change-type Agent tools are not. Before adding a command, first confirm the corresponding API endpoint exists; a + write command must align with the server write boundary. +- **Add the endpoint before a new command**: the CLI must not bypass the API to reach app internals directly; fill a + capability gap by adding an endpoint on the [server](01-service-api.md) first. +- **Do not touch GUI secrets**: the CLI does not read the app's main config `~/.code-meeseeks/config.yaml` (which holds + connection-layer secrets such as per-platform access tokens); the service token must be explicitly provided via flag / + environment variable / `cli.yaml`, avoiding over-reaching to out-of-scope credentials. +- **Contract-drift protection**: initially, be sure to keep hand-written structs updated in sync with the server contract; + once the contract grows, switch to OpenAPI / Schema code generation. +- **Version compatibility gating**: the CLI carries the `X-Meebox-CLI-Version` header on every request declaring its own + version (same source as the app); the server gates uniformly by its centrally managed minimum compatible version, and on + receiving `SV_CLIENT_TOO_OLD` (HTTP 426) the CLI prints a "too old, please upgrade" message. See the "CLI version + compatibility gating" in the [server contract](01-service-api.md). +- **JSON stability first**: `--output json` is the main automation path; its field shape is treated as an external contract + and must stay compatible as it evolves. +- **Proxy via environment variables**: the HTTP client uses Go `net/http`'s default transport, naturally honoring the standard + `HTTP(S)_PROXY` / `NO_PROXY`; loopback (`127.0.0.1` / `localhost`) connects directly by default without a proxy — no need to + implement proxy logic ourselves. diff --git a/docs/arch/99-core/01-state-storage.md b/docs/arch/99-core/01-state-storage.md index ced07a86..6cdeb417 100644 --- a/docs/arch/99-core/01-state-storage.md +++ b/docs/arch/99-core/01-state-storage.md @@ -1,127 +1,88 @@ -# 状态存储与数据模型 +# State storage & data model -## 职责与边界 +## Responsibilities & boundaries -持久化 PR 元数据、评论缓存、评审 run、连接/已观察仓库等。一期用 **JSON 文件**(非 SQLite), -封装在 `StateStore` 接口后,将来可平滑换实现。 +Persist PR metadata, the comment cache, review runs, connections / observed repos, and so on. Phase one uses **JSON files** (not SQLite), wrapped behind the `StateStore` interface so the implementation can be swapped smoothly later. -负责:状态读写、PR 目录布局、软删与清理、路径安全。不负责:配置/凭据(见 [配置与凭据](02-config-and-secrets.md))、 -仓库镜像(见 [仓库镜像](../01-platform/02-repo-mirror.md))。 +In scope: state read/write, PR directory layout, soft-delete and cleanup, path safety. Out of scope: config / credentials (see [Config & secrets](02-config-and-secrets.md)) and the repo mirror (see [Repo mirror](../01-platform/02-repo-mirror.md)). -## 核心设计 +## Core design -### 存储模型 +### Storage model -- **JSON 文件 + `StateStore` 抽象**:`read/write/delete/deleteDir/list`。一期实现 `JsonFileStateStore`, - 每个 key 一个文件(相对 `state/` 根)。**原子写**:tmp → fsync → rename,避免崩溃留半截文件。 - 单写者(Main 进程独占),无文件锁。所有文件带 `schema_version`。 - 为什么不上 SQLite:规模小(最多数百 PR / 数千 finding);native 模块要按 Electron 版本逐平台重编译, - CI 矩阵麻烦。触发阈值(单文件 >10MB 频繁读写 / 跨实体复杂查询 / 实测瓶颈)到了再换实现,业务层不变。 -- **PR localId = hash**:`sha1("<platform>|<connectionId>|<group>|<repo>|<remoteId>").slice(0,12)`。 - 理由:Bitbucket PR id 是 per-repo 递增的,同连接不同 repo 会撞号;hash 唯一且路径友好(无 `:`/`/`,跨平台免 sanitize); - 多平台共用同一 identity。 -- **per-PR 目录布局**(活跃 PR 落 `state/prs/`,退场 PR 整树搬到平级的 `archived/prs/` 冷存储): +- **JSON files + the `StateStore` abstraction**: `read/write/delete/deleteDir/list`. Phase one implements `JsonFileStateStore`, one file per key (relative to the `state/` root). **Atomic write**: tmp → fsync → rename, so a crash never leaves a half-written file. Single writer (the Main process holds it exclusively), no file locks. Every file carries a `schema_version`. + Why not SQLite: the scale is small (at most a few hundred PRs / a few thousand findings); native modules must be recompiled per platform per Electron version, which complicates the CI matrix. Swap the implementation once a trigger threshold is hit (a single file >10MB read/written frequently / complex cross-entity queries / a measured bottleneck), with the business layer unchanged. +- **PR localId = hash**: `sha1("<platform>|<connectionId>|<group>|<repo>|<remoteId>").slice(0,12)`. + Rationale: Bitbucket PR ids are per-repo incremental, so different repos on the same connection collide; the hash is unique and path-friendly (no `:`/`/`, no cross-platform sanitize needed); and multiple platforms share one identity. +- **Per-PR directory layout** (active PRs live in `state/prs/`; a retired PR has its whole tree relocated to the sibling `archived/prs/` cold storage): ``` ~/.code-meeseeks/ ├── state/prs/ - │ ├── index.json # hash → PrIndexEntry(lookup + 退场判定的单一来源;含活跃 + 归档全部条目) - │ └── <hash>/ # 仅活跃(在场)PR - │ ├── meta.json # 完整 PR 元数据 StoredPullRequest(自带 platform 字段) - │ ├── comments.json # 评论快照 + cache key - │ ├── read-state.json # 用户已读水位(未读标记派生用,仅 markRead 写) - │ └── runs/<runId>.json # 评审会话(跟 PR 同寿命) - └── archived/prs/<hash>/ # 退场(软删)PR 的同形冷存储;grace 期满整树清掉 + │ ├── index.json # hash → PrIndexEntry (single source for lookup + retirement decisions; holds all active + archived entries) + │ └── <hash>/ # active (present) PRs only + │ ├── meta.json # full PR metadata StoredPullRequest (carries its own platform field) + │ ├── comments.json # comment snapshot + cache key + │ ├── read-state.json # user read watermark (used to derive the unread marker; written only by markRead) + │ └── runs/<runId>.json # review session (same lifetime as the PR) + └── archived/prs/<hash>/ # isomorphic cold storage for retired (soft-deleted) PRs; the whole tree is purged when the grace period expires ``` - - **活跃 / 归档物理分离**:`state/` 与 `archived/` 是两个平级的 `StateStore` 根。活跃存储只装在场 PR; - PR 退场时其 `prs/<hash>/` 整树经 `relocateTree(state → archived)` 搬入冷存储,复活时反向搬回。 - 默认列表只读活跃存储(`listStoredPullRequests` 天然不含归档);归档除供生命周期清理外,另由「已关闭」 - 视图按需读取——`listArchivedPullRequests(state, archived)`(按索引 `archivedAt` 非空筛、逐个 meta 从归档存储读) - 出列表,打开某条时 `findPrOrThrow` 在活跃库未命中后兜底归档存储,使其 diff / 评论等路径仍可解析。 - - **per-PR 存储按归档状态路由(写安全 + 补充评审)**:已关闭范围对**已合并 / 仍开放**的 PR 仍允许补充评论 + - 补跑 AI 评审(仅不提供合并 / 审批)。这要求 per-PR 子树读写(评论缓存 / 草稿 / 关闭关系 / 评审 run / 会话 / - 台账 / diff-base 缓存)经 `PrService.storeForPr(localId)`(据索引 `archivedAt` 解析)落到正确的根——否则对归档 PR - 的写会进活跃存储,被下轮 poll 对账(`relocateTree` 源覆盖目的、**先清空目的**)连同归档数据一并误删。**仅浏览(declined) - 的写入由前端按 PR 状态门控**(不渲染评论 / 评审入口),但存储路由仍是兜底正确性的前提。 - - **索引仍单点**:`index.json` 只在活跃存储维护,是「哪些 hash 存在 + archivedAt」的唯一真相,覆盖活跃 + 归档全部条目; - 数据所在根由 `archivedAt` 是否为空隐含决定(空=活跃存储 / 非空=归档存储)。 - - 另有 `connections.json` / `watched-repos.json` / `posted-comments.json`(横向幂等记录,与 PR 目录解耦)。 -- **评论缓存按 PR `updatedAt` 失效**:`comments.json` 存写入时的 `pr_updated_at`;与当前 PR meta 的 `updatedAt` - 不一致即 stale → 重拉。Bitbucket 任何 PR 变更都跳 `updatedAt`,是足够保险的 cache key。 -- **安全 invariant**: - - **拉取失败不动本地**:某连接 `listPendingPullRequests` 抛错 → 不写其名下 PR、不软删、不剔索引; - 全失败则索引零写入、mtime 不变(避免误触 watcher/备份)。poll 是唯一权威,远端是最终 truth。 - - **路径越界屏障**:所有 fs 操作过 `subpathInside` 检查(拒 `..` / 绝对路径 / 清空根),因为 key 由调用方 - 拼接(含 PR localId / runId),必须防未净化输入越界读写。 -- **读宽容、写幂等**:状态文件被外部删/坏时,读返回 null/skip、下一轮 poll 重建;不做启动 reconcile / 自动备份。 -- **启动清扫孤儿 tmp**:原子写「tmp → rename」中,进程在两步之间被强杀 / 退出(如关窗瞬间的 in-flight 异步写)会留下 `*.tmp` - 孤儿、跨会话累积。`sweepStaleTmpFiles` 在启动早期、任何写入之前删掉全部 `*.tmp`——单写者前提下此刻无 in-flight 写,凡 tmp 皆为 - 上次会话孤儿,可放心删;**绝不在运行期清扫**,以免误删并发写 / rename 重试正在用的 tmp(冲突场景不误删多余文件)。 -- **启动清扫归档孤儿**:按索引遍历的硬清够不到「索引丢失 / 重建后失去条目」的归档数据(poll 只从远端补回活跃 PR), - 会在 `archived/prs/` 永久滞留。`sweepOrphanedArchivedPrs`(启动期、写入前)以无索引方式兜底:walk `archived/prs/*`, - 对「统一索引无对应条目 **且** 目录 mtime 超 grace」的整树删掉(mtime 作 archivedAt 的代理)。双重保守避免误删暂时 - 不在索引里的目录;机制为 `JsonFileStateStore.sweepOrphanDirs`。**正常清理仍走统一索引的到期硬清,此处仅补索引丢失的缺口。** - -### 业务生命周期 - -PR 在存储中的**在场状态**随其远端生命周期流转——由 `index.json` 的 `archivedAt` 是否为空界定(空 = 活跃存储 / 非空 = 归档冷存储),跨存储迁移一律经 `relocateTree`(先清空目的、源末删、幂等可重来): + - **Active / archived are physically separated**: `state/` and `archived/` are two sibling `StateStore` roots. Active storage holds only present PRs; when a PR is retired, its `prs/<hash>/` whole tree is moved into cold storage via `relocateTree(state → archived)`, and moved back on revive. The default list reads only active storage (`listStoredPullRequests` naturally excludes archives); beyond serving lifecycle cleanup, archives are also read on demand by the "Closed" view — `listArchivedPullRequests(state, archived)` (filters on the index by non-empty `archivedAt`, reading each meta from archive storage) produces the list, and when one is opened `findPrOrThrow` falls back to archive storage after missing in the active store, keeping its diff / comment / other paths resolvable. + - **Per-PR storage is routed by archived state (write safety + supplemental review)**: within the closed scope, **merged / still-open** PRs still allow adding comments + re-running AI review (only merge / approval are withheld). This requires per-PR subtree read/write (comment cache / drafts / close relations / review runs / sessions / ledger / diff-base cache) to land in the correct root via `PrService.storeForPr(localId)` (resolved from the index's `archivedAt`) — otherwise a write to an archived PR would go into active storage and be wrongly deleted along with the archived data by the next poll's reconcile (`relocateTree` has the source overwrite the destination, **clearing the destination first**). **Writes for browse-only (declined) PRs are gated by the frontend per PR status** (the comment / review entry points are not rendered), but storage routing remains the prerequisite for backstop correctness. + - **The index stays the single point**: `index.json` is maintained only in active storage and is the sole truth for "which hashes exist + archivedAt", covering all active + archived entries; the root the data lives in is implied by whether `archivedAt` is empty (empty = active storage / non-empty = archive storage). + - There are also `connections.json` / `watched-repos.json` / `posted-comments.json` (cross-cutting idempotency records, decoupled from the PR directory). +- **Comment cache invalidated by PR `updatedAt`**: `comments.json` stores the `pr_updated_at` at write time; if it disagrees with the current PR meta's `updatedAt`, it is stale → re-fetch. Bitbucket bumps `updatedAt` on any PR change, so it is a sufficiently safe cache key. +- **Safety invariants**: + - **A fetch failure doesn't touch local data**: if a connection's `listPendingPullRequests` throws → don't write its PRs, don't soft-delete, don't drop from the index; if all fail, the index gets zero writes and its mtime is unchanged (avoiding falsely triggering watchers / backups). The poll is the sole authority; the remote is the final truth. + - **Path-escape barrier**: every fs operation passes a `subpathInside` check (rejects `..` / absolute paths / clearing the root), because the key is concatenated by the caller (including PR localId / runId) and must be guarded against unsanitized input reading/writing out of bounds. +- **Lenient reads, idempotent writes**: when a state file is externally deleted / corrupted, the read returns null/skip and the next poll rebuilds it; there is no startup reconcile / auto-backup. +- **Sweep orphan tmp files at startup**: in the atomic write "tmp → rename", a process force-killed / exiting between the two steps (e.g. an in-flight async write at the instant a window closes) leaves `*.tmp` orphans that accumulate across sessions. `sweepStaleTmpFiles` deletes all `*.tmp` early at startup, before any write — under the single-writer premise there is no in-flight write at this moment, so every tmp is an orphan from the last session and can be safely deleted; **never sweep during runtime**, to avoid wrongly deleting a tmp being used by a concurrent write / rename retry (so a conflict scenario doesn't delete spare files by mistake). +- **Sweep orphan archives at startup**: index-driven hard cleanup can't reach archived data whose "index entry was lost / dropped after a rebuild" (the poll only refills active PRs from the remote), which would linger forever in `archived/prs/`. `sweepOrphanedArchivedPrs` (at startup, before any write) is an index-free backstop: it walks `archived/prs/*` and deletes the whole tree of any that "has no corresponding entry in the unified index **and** whose directory mtime exceeds the grace period" (mtime stands in for archivedAt). The double conservatism avoids wrongly deleting a directory temporarily absent from the index; the mechanism is `JsonFileStateStore.sweepOrphanDirs`. **Normal cleanup still goes through the unified index's expiry-based hard cleanup; this only fills the gap of a lost index.** + +### Business lifecycle + +A PR's **presence state** in storage flows with its remote lifecycle — delimited by whether `index.json`'s `archivedAt` is empty (empty = active storage / non-empty = archive cold storage); cross-storage migration always goes through `relocateTree` (clear the destination first, delete the source last, idempotent and re-runnable): ```mermaid stateDiagram-v2 - state "活跃存储 state/" as Active - state "归档冷存储 archived/" as Archived - [*] --> Active: poll 发现(待评审 / 我创建) - [*] --> Archived: 按 URL 打开(直接入归档) - Active --> Archived: 退场软删(merged / declined / 非 reviewer) - Archived --> Active: 远端复现 → 复活 - Archived --> [*]: grace 期满 → 两端整树清除 + state "active storage state/" as Active + state "archive cold storage archived/" as Archived + [*] --> Active: poll discovers (review requested / created) + [*] --> Archived: opened by URL (goes straight to archive) + Active --> Archived: retire soft-delete (merged / declined / no longer reviewer) + Archived --> Active: reappears remotely → revive + Archived --> [*]: grace period expires → whole tree purged from both ends note right of Archived - 对账每轮收敛:凡 archivedAt 非空必在 archived/ + reconcile converges each round: any non-empty archivedAt must be in archived/ end note ``` -- **软删 + 1 周 grace + 冷存储搬迁**:PR 从远端 reviewer 列表消失(merged/declined/不再是 reviewer)→ 把整树搬入 - `archived/` 冷存储、再标 `archivedAt`(搬迁先于索引落盘,崩溃可幂等重来),不立即删盘;窗口内 UI 隐藏但数据保留, - 远端复现时整树搬回活跃存储、自动复活;grace 期满下轮 poll 从**归档 + 活跃两端**整目录清掉(两端清以兜旧布局 / - 异常 split-brain 残留)。便于事后回看。 -- **按 URL 打开的 PR 直接入归档**:命令面板「打开 URL」拉取的他人 PR(未正式请求你评审)由 IPC `prs:openByUrl` - 鉴权拉取后**直接 `writePrMeta(archiveStore)` + 写一条 `archivedAt=now` 的索引条目**——既不在远端 reviewer 列表、 - 也无需先经活跃态,故一开始就当归档对待,**复用同一 grace 清理与对账**(grace 期满即被两端清扫)。写索引时紧邻重读 - 做 read-modify-write 以缩小与 poll 重写索引的竞态窗口。镜像沿用打开详情时的懒拉取(含源分支已删按 PR 头引用定位)。 -- **对账(最终一致)**:每轮 poll 遍历 archived 条目时,未到 grace 的若数据仍滞留活跃存储 → 整树搬入归档存储 - (`relocateTree(state → archived)`,已就位者源缺失即 no-op)。覆盖升级前旧布局的存量、异常 split-brain 残留、 - 中断的搬迁,使「凡 archived 必在 archived/」自动收敛——无需迁移脚本。对账只搬数据、不改索引,不破「全失败 poll 零索引写」不变式。 -- **未读标记**:`listStoredPullRequests` 派生 `StoredPullRequest.unread`(不持久化)。规则:**从未打开过**(无 read-state)即 - 未读——覆盖新分配 / 请求评审的新到达,以及清空目录 / 全新安装后涌入的 PR;**打开过之后**则看源 head 又变(新 commit)或已读 - 时间后有「@我 / 回复我」评论(`index.lastMentionAt > read-state.lastReadAt`)。两类状态**分文件、分写者**以避开竞态:**已读水位** - `read-state.json`(`lastReadHeadSha`+`lastReadAt`)**仅** `prs:markRead`(用户打开 PR)写、poll 一概不碰;**mention 游标** - `index.lastMentionAt` 由 poll 独占维护(poll 整体重写 index.json,不会覆盖用户水位),仅在 PR `updatedAt` 跳变时拉评论扫描、与 - 历史取较大值,成本与活动量成正比。commit 检测对各平台通用、不依赖 `updatedAt`。早期开发版不做升级兼容(不抑制旧存量泛红,清库 / 重装即可)。 -- **未读点名计数**:同一次评论扫描里,除游标外还把「@我 / 回复我」评论的 `createdAt` 累积进 `index.mentionAts`(与历史并集去重、 - 按时间降序留最近 10 条)。`listStoredPullRequests` 据此派生 `StoredPullRequest.unreadMentionCount`(不持久化):数其中晚于 - `read-state.lastReadAt` 的条数(从未打开过则全计)。与 `unread` **并存、互不替代**——计数封顶 10(UI 满额显示「10+」),UI 在有计数时 - 以数字标记替换未读圆点、否则仍显示圆点。同属 poll 独占维护、与已读水位解耦。 - -## 数据 / 接口契约 - -**抽象接口** - -- `StateStore`:键值式状态读写抽象——`read` / `write` / `delete` / `deleteDir` / `list`(key 为相对 `state/` 根的路径,`deleteDir` / `list` 按前缀批量)。 -- `relocateTree`:跨 store 整树搬迁(list→read→write→deleteDir),活跃⇄归档间搬 PR 子树;目标先清空(源为权威)、源末删(幂等可重来)、源缺失即 no-op,不破 `StateStore` 抽象。 - -**核心实体**(仅列承载设计含义的关键字段,完整字段见各类型定义;所有持久化文件含 `schema_version`) - -| 实体(文件) | 用途 | 关键字段 | +- **Soft-delete + 1-week grace + relocation to cold storage**: when a PR disappears from the remote reviewer list (merged/declined/no longer a reviewer) → move the whole tree into `archived/` cold storage, then mark `archivedAt` (relocation precedes writing the index, so a crash is idempotently re-runnable); it is not deleted from disk immediately. Within the window the UI hides it but the data is retained; when it reappears remotely the whole tree moves back to active storage and auto-revives; when the grace period expires the next poll purges the whole directory from **both the archive and active ends** (clearing both ends catches old-layout / abnormal split-brain residue). This makes after-the-fact review convenient. +- **PRs opened by URL go straight to the archive**: someone else's PR pulled via the command palette "Open URL" (which never formally requested your review) is, after IPC `prs:openByUrl` authenticated-fetches it, written **directly with `writePrMeta(archiveStore)` + an index entry of `archivedAt=now`** — it is neither on the remote reviewer list nor needs to pass through the active state first, so it is treated as archived from the start, **reusing the same grace cleanup and reconcile** (purged from both ends once the grace period expires). When writing the index, it immediately re-reads and does a read-modify-write to shrink the race window against the poll rewriting the index. The mirror follows the lazy fetch of opening details (including locating a deleted source branch by the PR head ref). +- **Reconcile (eventual consistency)**: each poll round, while iterating the archived entries, if one not yet past grace still has data lingering in active storage → move the whole tree into archive storage (`relocateTree(state → archived)`; a no-op if already in place with the source missing). This covers pre-upgrade old-layout leftovers, abnormal split-brain residue, and interrupted relocations, converging "anything archived must be in archived/" automatically — no migration script needed. Reconcile only moves data, never touches the index, and doesn't break the "an all-failed poll writes zero to the index" invariant. +- **Unread marker**: `listStoredPullRequests` derives `StoredPullRequest.unread` (not persisted). Rules: **never opened** (no read-state) means unread — covering newly assigned / review-requested arrivals as well as the PRs flooding in after clearing the directory / a fresh install; **once opened**, it looks at whether the source head changed again (a new commit) or there is an "@me / reply to me" comment after the read time (`index.lastMentionAt > read-state.lastReadAt`). The two kinds of state are **split across files, with separate writers** to avoid races: the **read watermark** `read-state.json` (`lastReadHeadSha` + `lastReadAt`) is written **only** by `prs:markRead` (the user opening the PR) and never touched by the poll; the **mention cursor** `index.lastMentionAt` is maintained exclusively by the poll (the poll rewrites index.json wholesale and never overwrites the user watermark), fetching comments to scan only when the PR's `updatedAt` jumps and taking the max against history, at a cost proportional to the activity volume. Commit detection is common across platforms and doesn't depend on `updatedAt`. The early dev builds do no upgrade compatibility (they don't suppress old stock going red across the board; just clear the store / reinstall). +- **Unread mention count**: in the same comment scan, beyond the cursor it also accumulates the `createdAt` of "@me / reply to me" comments into `index.mentionAts` (unioned with and deduped against history, kept in descending time order to the most recent 10). `listStoredPullRequests` derives `StoredPullRequest.unreadMentionCount` from this (not persisted): counting those later than `read-state.lastReadAt` (all of them if never opened). It **coexists with, and does not replace, `unread`** — the count caps at 10 (the UI shows "10+" at capacity), and the UI replaces the unread dot with a numeric marker when there is a count, otherwise still showing the dot. It is likewise maintained exclusively by the poll and decoupled from the read watermark. + +## Data / interface contract + +**Abstract interfaces** + +- `StateStore`: a key-value state read/write abstraction — `read` / `write` / `delete` / `deleteDir` / `list` (the key is a path relative to the `state/` root; `deleteDir` / `list` operate on a prefix in bulk). +- `relocateTree`: cross-store whole-tree relocation (list→read→write→deleteDir), moving a PR subtree between active ⇄ archive; the destination is cleared first (the source is authoritative), the source is deleted last (idempotent, re-runnable), and a missing source is a no-op — without breaking the `StateStore` abstraction. + +**Core entities** (only the key fields carrying design meaning are listed; see each type definition for the full fields; every persisted file carries `schema_version`) + +| Entity (file) | Purpose | Key fields | | --- | --- | --- | -| `PrIndexEntry`(`index.json`) | lookup + 退场判定的单一来源(含活跃 + 归档全部条目) | `identity` · `updatedAt` · `archivedAt\|null`(空 = 活跃 / 非空 = 归档)· mention 游标 `lastMentionAt?` · `mentionAts?`(最近 10 条,未读点名计数据此派生) | -| `StoredPullRequest`(`meta.json`) | 完整 PR 元数据,`platform` 自描述 | 派生态 `unread` / `unreadMentionCount` 不持久化 | -| `PrReadStateFile`(`read-state.json`) | 用户已读水位,仅 `prs:markRead` 写 | `lastReadHeadSha` · `lastReadAt` | -| `ReviewRun`(`runs/<runId>.json`) | 评审会话(详见 [评审闭环](../01-platform/03-review-workflow.md)) | `findings` · `tokenUsage` · `model` · 状态机字段 | +| `PrIndexEntry` (`index.json`) | single source for lookup + retirement decisions (all active + archived entries) | `identity` · `updatedAt` · `archivedAt\|null` (empty = active / non-empty = archived) · mention cursor `lastMentionAt?` · `mentionAts?` (most recent 10; the unread mention count is derived from this) | +| `StoredPullRequest` (`meta.json`) | full PR metadata, `platform` self-describing | derived states `unread` / `unreadMentionCount` not persisted | +| `PrReadStateFile` (`read-state.json`) | user read watermark, written only by `prs:markRead` | `lastReadHeadSha` · `lastReadAt` | +| `ReviewRun` (`runs/<runId>.json`) | review session (see [Review workflow](../01-platform/03-review-workflow.md)) | `findings` · `tokenUsage` · `model` · state-machine fields | -横向幂等记录(与 PR 目录解耦):`connections.json` / `watched-repos.json` / `posted-comments.json`。 +Cross-cutting idempotency records (decoupled from the PR directory): `connections.json` / `watched-repos.json` / `posted-comments.json`. -## 扩展与注意事项 +## Extension & caveats -- **升级 SQLite**:只换 `StateStore` 实现 + 数据迁移,接口与业务不动。 -- **grace 期 / hash 长度 / cache key** 都是直觉取值,留了可调空间(改动需清空 `state/` 重拉)。 -- **本地开发**:schema 变更阶段直接清空 `state/` 重拉,不写迁移脚本。 -- 孤儿目录(索引无条目)暂不主动清理,占盘但不影响功能,后续加 housekeeping。 +- **Upgrading to SQLite**: swap only the `StateStore` implementation + data migration; the interface and business stay put. +- **grace period / hash length / cache key** are all intuitive values with room to tune (a change requires clearing `state/` and re-fetching). +- **Local development**: during a schema-change phase, just clear `state/` and re-fetch rather than writing a migration script. +- Orphan directories (no index entry) are not actively cleaned for now — they take disk space but don't affect functionality; add housekeeping later. diff --git a/docs/arch/99-core/02-config-and-secrets.md b/docs/arch/99-core/02-config-and-secrets.md index b8a35416..27057614 100644 --- a/docs/arch/99-core/02-config-and-secrets.md +++ b/docs/arch/99-core/02-config-and-secrets.md @@ -1,81 +1,69 @@ -# 配置与凭据 +# Config & secrets -## 职责与边界 +## Responsibilities & boundaries -统一管理应用配置与敏感凭据:单一 `config.yaml`(连接、LLM、规则、轮询、代理等)+ 凭据抽象 + -设置页可视化编辑 + 首启向导。 +Uniformly manage application config and sensitive credentials: a single `config.yaml` (connections, LLM, rules, polling, proxy, etc.) + a credential abstraction + a visual editor on the settings page + a setup wizard. -负责:配置 schema、读写与热更新、凭据存取抽象、设置 UI。不负责:各子系统怎么用这些配置(见对应分篇)。 +In scope: config schema, read/write and hot-reload, the credential store/retrieve abstraction, the settings UI. Out of scope: how each subsystem uses this config (see the respective doc). -## 核心设计 +## Core design -- **单一 `config.yaml`(含敏感字段)**:配置与凭据合并在一个文件,不拆 `secrets.yaml`(减心智)。文件权限 - 收紧(Unix 600 / Windows ACL)。**应用数据目录固定** `~/.code-meeseeks/`(config/state/logs/),仅 - `workspace.repos_dir` 可改(见 [状态存储](01-state-storage.md))。 -- **schema 用 zod 定义 + 全字段默认值**:解析时缺字段补默认,老配置自动兼容、新增字段非破坏性。顶层形状见下「数据 / 接口契约」的 `config.yaml` 示例。 -- **凭据抽象 `SecretStore`**:所有 token / API key 读写经它,不直接 `fs`。一期实现把凭据存在 `config.yaml` - (`ConfigFileSecretStore`);预留 keytar/OS Keychain 实现,将来只换注入、业务零改动。凭据**绝不进日志/异常栈**。 -- **多套 LLM 预设(profiles)**:`llm.profiles[]` 每条独立 `provider / model / base_url / api_key`,`active_id` - 切当前生效。内置 provider 选项(openai / openai-compatible / deepseek / anthropic / dashscope / - volcengine-ark / cli);按 provider 决定注入哪族 env(见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))。本地 Ollama - 经 openai-compatible 的 `/v1` 端点接入(旧 `ollama` 值自动迁移)。 -- **热更新(写盘 + 内存同步)**:每个设置项保存时写 `config.yaml` **并**更新内存中的 config,必要时热重建 - 受影响运行时(如连接/代理变更重建 adapter、轮询间隔热替换定时器),无需重启。 -- **设置页可视化 CRUD**:连接、LLM 预设、代理、规则目录、轮询间隔、`repos_dir` 都能在设置页编辑; - 连接/LLM 有「测试」入口(ping / 代理连通)。也提供「用系统关联程序打开 config.yaml」直接编辑(适合高级用户, - 减少冗余 UI)。 -- **首启配置向导**:首次启动自动建 `~/.code-meeseeks/` + 默认 `config.yaml`;引导配代码平台连接(+ 可选 LLM), - 最快路径进入可用状态。 +- **A single `config.yaml` (including sensitive fields)**: config and credentials are merged into one file rather than splitting out a `secrets.yaml` (less mental overhead). File permissions are tightened (Unix 600 / Windows ACL). **The application data directory is fixed** at `~/.code-meeseeks/` (config/state/logs/); only `workspace.repos_dir` is changeable (see [State storage](01-state-storage.md)). +- **Schema defined with zod + full-field defaults**: on parse, missing fields are filled with defaults, so old configs stay auto-compatible and new fields are non-breaking. The top-level shape is shown in the `config.yaml` example under "Data / interface contract" below. +- **Credential abstraction `SecretStore`**: all token / API key reads/writes go through it, never `fs` directly. Phase one implements storing credentials in `config.yaml` (`ConfigFileSecretStore`); a keytar/OS Keychain implementation is reserved so that later only the injection is swapped, with zero business change. Credentials **never enter logs / exception stacks**. +- **Multiple LLM profiles**: `llm.profiles[]`, each with its own `provider / model / base_url / api_key`, with `active_id` selecting the active one. The built-in provider options (openai / openai-compatible / deepseek / anthropic / dashscope / volcengine-ark / cli); the provider decides which family of env is injected (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)). A local Ollama connects via openai-compatible's `/v1` endpoint (the old `ollama` value is auto-migrated). +- **Hot-reload (write to disk + in-memory sync)**: on saving each setting, write `config.yaml` **and** update the in-memory config, hot-rebuilding the affected runtime when necessary (e.g. a connection/proxy change rebuilds the adapter, a polling-interval change hot-swaps the timer), without restart. +- **Visual CRUD on the settings page**: connections, LLM profiles, proxy, the rules directory, polling interval, and `repos_dir` can all be edited on the settings page; connections/LLM have a "Test" entry point (ping / proxy connectivity). There is also "Open config.yaml with the system-associated program" for direct editing (suited to advanced users, reducing redundant UI). +- **Setup wizard**: on first launch it auto-creates `~/.code-meeseeks/` + a default `config.yaml`, and guides configuring the code-platform connection (+ optional LLM), the fastest path to a usable state. -## 数据 / 接口契约 +## Data / interface contract -应用配置是单一 `config.yaml`,顶层形状(节选;缺字段由 zod 补默认值,老配置非破坏性兼容): +The application config is a single `config.yaml`, with this top-level shape (excerpt; missing fields are filled with defaults by zod, old configs are non-breaking compatible): ```yaml -language: '' # UI / pr-agent 输出语言;空 = 按 OS 自动、回落英语 -appearance: # 纯前端展示项(主进程仅据主题设原生窗口 themeSource) - editor_theme: auto # 'auto' 跟随系统深浅,或内置 / 第三方主题 id -connections: [] # 代码平台连接(含 token 等鉴权字段) -active_connection_id: '' # 当前唯一启用的连接 id(同时只启用一条) -llm: # 多套 LLM 预设,按 active_id 切当前生效 - profiles: [] # 每条独立 provider / model / base_url / api_key +language: '' # UI / pr-agent output language; empty = auto by OS, fall back to English +appearance: # pure frontend display items (the main process only sets the native window themeSource from the theme) + editor_theme: auto # 'auto' follows system light/dark, or a built-in / third-party theme id +connections: [] # code-platform connections (including token and other auth fields) +active_connection_id: '' # the single currently enabled connection id (only one enabled at a time) +llm: # multiple LLM profiles, switching the active one by active_id + profiles: [] # each with its own provider / model / base_url / api_key active_id: '' - context_tokens: 128000 # 输入上下文裁剪上限(token,32k~1M) -agent: # 高阶 Agent - dir: '' # 人格 / 知识 / 规则目录;空 = 默认位置 - max_steps: 8 # 单会话步数上限 - summary_max_chars: 800 # 收尾总结篇幅上限 - autopilot: { enabled: false } # AutoPilot 预评审(默认关;另含 batch_size / grants) - strategy: # 自动评审行为策略(手动 + AutoPilot 共用) - auto_followup: true # 是否启用自动追问 + context_tokens: 128000 # input-context truncation cap (tokens, 32k~1M) +agent: # high-level Agent + dir: '' # persona / knowledge / rules directory; empty = default location + max_steps: 8 # max steps per session + summary_max_chars: 800 # cap on the wrap-up summary length + autopilot: { enabled: false } # AutoPilot pre-review (off by default; also holds batch_size / grants) + strategy: # auto-review behavior strategy (shared by manual + AutoPilot) + auto_followup: true # whether to enable automatic follow-up asks max_followup_asks: 2 - max_code_suggestions: 4 # 单次代码建议 / 发现数量上限(2~8) -poller: { interval_seconds: 300 } # 轮询间隔(秒,≥30) -proxy: { enabled: false } # 出站代理;默认关 = 直连 -notifications: # 消息通知;enabled 为总开关 + max_code_suggestions: 4 # cap on code suggestions / findings per run (2~8) +poller: { interval_seconds: 300 } # polling interval (seconds, ≥30) +proxy: { enabled: false } # outbound proxy; off by default = direct connection +notifications: # notifications; enabled is the master switch enabled: true - new_pr: true # 分类型系统通知开关 + new_pr: true # per-type system-notification switches reply: true mention: true -pr_agent: # pr-agent 运行时 +pr_agent: # pr-agent runtime strategy: auto # auto | embedded | local-cli - max_concurrency: 2 # 评审并发数(1~8) -update: { check_enabled: true } # 启动检测新版(仅提示,不自动下载) + max_concurrency: 2 # review concurrency (1~8) +update: { check_enabled: true } # check for a new version at startup (prompt only, no auto-download) workspace: - repos_dir: ~/.code-meeseeks/repos # 唯一可迁移到大盘的数据子目录 + repos_dir: ~/.code-meeseeks/repos # the only data subdirectory relocatable to a large disk ``` -- **凭据抽象 `SecretStore`**:`get` / `set` / `delete`——所有 token / API key 经它读写,不直接碰 `fs`,凭据绝不进日志 / 异常栈。 -- **设置相关 IPC**(保存即热生效): - - 分项写入:`config:setConnections` / `setLlm` / `setProxy` / `setAgent` / `setPoller` / `setReposDir` / `setNotifications`; - - 读取:`config:read`; - - 连通性测试:`config:testConnection` / `config:testProxy`(ping / 代理连通); - - 其它:「用系统关联程序打开 config 文件」。 +- **Credential abstraction `SecretStore`**: `get` / `set` / `delete` — all token / API key reads/writes go through it, never touching `fs` directly, and credentials never enter logs / exception stacks. +- **Settings-related IPC** (a save takes effect immediately): + - Per-item writes: `config:setConnections` / `setLlm` / `setProxy` / `setAgent` / `setPoller` / `setReposDir` / `setNotifications`; + - Read: `config:read`; + - Connectivity tests: `config:testConnection` / `config:testProxy` (ping / proxy connectivity); + - Other: "Open the config file with the system-associated program". -## 扩展与注意事项 +## Extension & caveats -- **凭据明文落盘**:当前安全模型——文件权限收紧 + 文档提示风险;面向开发者群体可接受。切 keytar 时只换 - `SecretStore` 实现。 -- **配置向后兼容**:加新字段务必带默认值(zod `.default`),并考虑旧形态迁移(如 LLM 从单配置迁到 profiles 的兼容)。 -- **`repos_dir` 改动**是低频操作,可能需重启/挂起轮询。 -- `~/.code-meeseeks/` 不可迁移(仅 `repos_dir` 可搬到大盘);config/state/logs 总量小,固定路径便于备份定位。 +- **Credentials stored in plaintext**: the current security model — tightened file permissions + documented risk warning; acceptable for a developer audience. When switching to keytar, only the `SecretStore` implementation changes. +- **Config backward compatibility**: any new field must carry a default (zod `.default`) and consider migration from the old shape (e.g. the compatibility of migrating LLM from a single config to profiles). +- **Changing `repos_dir`** is a low-frequency operation and may require a restart / suspending polling. +- `~/.code-meeseeks/` is not relocatable (only `repos_dir` can move to a large disk); config/state/logs are small in total, and a fixed path makes backups easy to locate. diff --git a/docs/arch/99-core/03-networking-proxy.md b/docs/arch/99-core/03-networking-proxy.md index 2d4e7eab..6bef953d 100644 --- a/docs/arch/99-core/03-networking-proxy.md +++ b/docs/arch/99-core/03-networking-proxy.md @@ -1,71 +1,57 @@ -# 出站网络与代理 +# Outbound network & proxy -## 职责与边界 +## Responsibilities & boundaries -让所有**出站网络**在企业内网/受限网络下可控:开关打开后,统一经一个 **HTTP 代理**出网; -本地地址(loopback)直连。覆盖三类出口: +Make all **outbound network** controllable in enterprise-intranet / restricted networks: once the switch is on, everything egresses uniformly through one **HTTP proxy**; local addresses (loopback) connect directly. It covers three egress classes: -- **LLM 调用**(pr-agent 内嵌的 LLM 客户端)—— 首要目标,内网下没它整个评审不可用。 -- **代码平台 REST**(轮询 / 评论 / 头像 / 附件 / 合并 / 连接探测)。 -- **git over HTTPS**(clone / fetch)。 +- **LLM calls** (the LLM client embedded in pr-agent) — the primary target; without it, the whole review is unusable on an intranet. +- **Code-platform REST** (polling / comments / avatars / attachments / merge / connection probing). +- **git over HTTPS** (clone / fetch). -**不负责**:git over **SSH** 的代理——HTTP 代理对 SSH 不直接适用,跨平台也无统一手段 -(macOS/Linux 有 `nc`,Windows 没有)。SSH 用户自行在 `~/.ssh/config` 配 `ProxyCommand`。 +**Out of scope**: proxying git over **SSH** — an HTTP proxy doesn't apply directly to SSH, and there is no uniform cross-platform means (macOS/Linux have `nc`, Windows doesn't). SSH users configure `ProxyCommand` in their own `~/.ssh/config`. -一期只支持 **HTTP 代理**(含 Basic Auth);socks5 暂不实现,但配置上留了协议扩展位。 +Phase one supports only an **HTTP proxy** (including Basic Auth); socks5 is not implemented yet, but the config leaves a protocol-extension slot. -## 核心设计 +## Core design -- **单一全局代理 + loopback 直连**:开关开 → LLM、代码平台、git(HTTPS) 三类出口全走代理; - 只有 `localhost / 127.0.0.1 / ::1`(含本地 Ollama 等本地服务)自动直连。不做「按 host 选择性 - 走代理」的复杂策略——用户环境里代理就是统一出网通道,简单一致。 -- **配置面最小**:只暴露 开关 / 地址 / 端口 / Basic Auth(用户名、密码)。loopback 绕过是内置 - 行为、不暴露。协议字段进配置但 UI 不渲染(当前仅 http),为将来加 socks5 留位——新增协议值 - 对存量配置非破坏性。 -- **三种注入形态,按出口取用**(这是本模块的核心): - - **子进程出口**(pr-agent、git)认 `HTTP(S)_PROXY` / `NO_PROXY` 环境变量 → 给子进程注入这组 env - (`NO_PROXY` 固定含 loopback)。 - - **进程内 fetch 出口**(代码平台 REST 走 Node 的 undici fetch)**默认不认** proxy 环境变量,必须 - 显式给 dispatcher(代理 Agent)→ 在构造平台客户端时,对**非 loopback** 目标包一个带代理 - dispatcher 的 fetch 注入进去;loopback / 关闭时不注入(走默认直连)。 - - 由此统一到一个中心组件,按代理配置产出以下各项,各出口只消费、不各自实现: - - 子进程 env; - - undici 代理 dispatcher / fetch; - - loopback 判断; - - 连通性自检。 -- **不取费用、不联网拉价格表**:token 用量只取 API 返回值,故底层 LLM 库的远端价格表无用且会在 - 弱网超时——强制只用本地价格表、彻底不联网(详见 [pr-agent 运行时](../02-agent/05-pragent-runtime.md))。 -- **热生效**:改代理配置 → 写盘 + 更新内存配置 + 重建平台 adapter(REST 即时生效);pr-agent / git - 出口在下次操作时读最新配置,无需重启。 +- **A single global proxy + loopback direct connection**: switch on → all three egresses (LLM, code platform, git(HTTPS)) go through the proxy; only `localhost / 127.0.0.1 / ::1` (including a local Ollama and other local services) auto-connect directly. There is no complex "selectively proxy per host" policy — in the user's environment the proxy is the uniform egress channel, simple and consistent. +- **Minimal config surface**: it exposes only switch / host / port / Basic Auth (username, password). The loopback bypass is built-in behavior, not exposed. The protocol field goes into config but the UI doesn't render it (currently http only), leaving room to add socks5 later — a new protocol value is non-breaking to existing configs. +- **Three injection forms, consumed per egress** (this is the core of this module): + - **Subprocess egress** (pr-agent, git) recognizes the `HTTP(S)_PROXY` / `NO_PROXY` environment variables → inject this set of env into the subprocess (`NO_PROXY` always includes loopback). + - **In-process fetch egress** (code-platform REST goes through Node's undici fetch) **does not recognize** proxy env vars by default and must be handed an explicit dispatcher (proxy Agent) → when constructing the platform client, wrap a fetch with a proxy dispatcher around **non-loopback** targets and inject it; loopback / off cases inject nothing (using the default direct connection). + - This unifies into one central component that, per the proxy config, produces the following items, each egress only consuming rather than implementing its own: + - subprocess env; + - undici proxy dispatcher / fetch; + - loopback determination; + - connectivity self-check. +- **No cost fetching, no network price-table pulls**: token usage is taken solely from the API return value, so the underlying LLM library's remote price table is useless and would time out on a weak network — force using only the local price table, with no network at all (see [pr-agent runtime](../02-agent/05-pragent-runtime.md)). +- **Takes effect immediately**: change the proxy config → write to disk + update in-memory config + rebuild the platform adapter (REST takes effect immediately); the pr-agent / git egresses read the latest config on their next operation, with no restart. -## 数据 / 接口契约 +## Data / interface contract -配置(`config.yaml` 顶层 `proxy`): +Config (`config.yaml` top-level `proxy`): ```yaml proxy: - enabled: false # 总开关;false = 全部直连,等同历史行为 - protocol: http # 协议枚举,预留扩展;一期仅 http - host: '' # 代理地址 + enabled: false # master switch; false = all direct, equivalent to historical behavior + protocol: http # protocol enum, reserved for extension; phase one is http only + host: '' # proxy address port: 8080 - username: '' # Basic Auth,可空 - password: '' # 可空(明文落盘,同 config 既有策略) + username: '' # Basic Auth, may be empty + password: '' # may be empty (stored in plaintext, same as config's existing policy) ``` -IPC 通道: +IPC channels: -- `config:setProxy`:入参 `{ proxy }` → 写盘 + 内存同步 + 重建 adapter(REST 即时生效)。 -- `config:testProxy`:入参 `{ proxy }` → 返回 `{ ok, reason? }`,经该代理试连一个外部地址验证连通; - 代理认证失败(407)归为失败并给原因。 +- `config:setProxy`: input `{ proxy }` → write to disk + in-memory sync + rebuild adapter (REST takes effect immediately). +- `config:testProxy`: input `{ proxy }` → returns `{ ok, reason? }`, verifying connectivity by trying to reach an external address through that proxy; proxy auth failure (407) is classified as a failure with a reason. -代理 URL 形态:`http://[username:password@]host:port`(凭据 URL 编码)。 +Proxy URL form: `http://[username:password@]host:port` (credentials URL-encoded). -## 扩展与注意事项 +## Extension & caveats -- **加 socks5**:协议枚举追加 `socks5`;undici 代理 Agent 不支持 socks,REST 出口需换成基于 socks - 建连的 dispatcher;子进程出口(pr-agent / git)的 socks 由其底层库原生支持。届时 UI 显现协议选择。 -- **代理感知 fetch 的类型**:Node `fetch` 的 `dispatcher` 不在标准 `RequestInit` 类型里,注入处需类型断言。 -- **替换子进程 env 时必须 merge 现有 env**:git 封装库设置 env 是整体替换,漏 merge 会丢 `PATH`/`HOME`。 -- **代码平台默认随全局代理**:内网平台若被代理误伤(连不上),属边缘场景,再按需加「平台直连」开关; - 当前不做。 -- **凭据明文**:代理密码与既有 config 一样明文落盘,不额外加密;面向开发者群体、文档提示风险。 +- **Adding socks5**: append `socks5` to the protocol enum; the undici proxy Agent doesn't support socks, so the REST egress needs to switch to a socks-based dispatcher; the subprocess egress (pr-agent / git) supports socks natively via its underlying library. At that point the UI reveals the protocol choice. +- **The type of proxy-aware fetch**: Node `fetch`'s `dispatcher` is not in the standard `RequestInit` type, so the injection point needs a type assertion. +- **Must merge the existing env when replacing subprocess env**: the git wrapper library sets env by wholesale replacement, so missing the merge would lose `PATH`/`HOME`. +- **Code platform defaults to following the global proxy**: if an intranet platform is collaterally harmed by the proxy (unreachable), that is an edge case — add a "platform direct connection" switch as needed later; not done for now. +- **Plaintext credentials**: the proxy password, like the existing config, is stored in plaintext with no extra encryption; for a developer audience, with a documented risk warning. diff --git a/docs/arch/99-core/04-error-codes.md b/docs/arch/99-core/04-error-codes.md index 09801064..f2fdce24 100644 --- a/docs/arch/99-core/04-error-codes.md +++ b/docs/arch/99-core/04-error-codes.md @@ -1,105 +1,103 @@ -# 错误码与错误传递 +# Error codes & error propagation -## 职责与边界 +## Responsibilities & boundaries -把**后端(主进程 + 内部库)抛给前端、最终面向用户展示的错误**,统一收敛为**错误码**承载; -本地化由**前端**负责(按码查 i18n 文案)。后端不再各自拼面向用户的本地化字符串。 +Uniformly converge **errors that the backend (main process + internal libraries) throws to the frontend and ultimately shows to the user** onto **error codes** as their carrier; localization is the **frontend**'s job (look up the i18n copy by code). The backend no longer assembles user-facing localized strings itself. -- **负责**:跨 IPC 抵达渲染层、需要展示给用户的错误的「编码 + 传递 + 兜底」契约;错误码命名空间与注册表。 -- **不负责**: - - **开发者排障的内部异常 / 后台日志**——仍用英语、不做 i18n(见 [i18n](../03-gui/04-i18n.md) 与 `AGENTS.md`「包内异常用英语」「后台日志用英语」)。错误码面向用户,不取代技术异常。 - - LLM 生成内容、pr-agent 输出模板的语言(见 [i18n](../03-gui/04-i18n.md))。 +- **In scope**: the "encode + propagate + fall back" contract for errors that cross IPC to the render layer and need to be shown to the user; the error-code namespace and registry. +- **Out of scope**: + - **Internal exceptions for developer troubleshooting / backend logs** — still English, no i18n (see [i18n](../03-gui/04-i18n.md) and `AGENTS.md` "in-package exceptions in English" / "backend logs in English"). Error codes are user-facing; they don't replace technical exceptions. + - The language of LLM-generated content and pr-agent output templates (see [i18n](../03-gui/04-i18n.md)). -> 边界判据:**「这条错误是否会跨 IPC 展示给用户」**。是 → 错误码 + 前端 i18n;否(纯开发者向 / 日志)→ 英语技术异常。 +> Boundary test: **"does this error cross IPC to be shown to the user"**. Yes → error code + frontend i18n; No (pure developer-facing / logs) → English technical exception. -## 核心设计 +## Core design -### 1. 错误码格式 +### 1. Error-code format ``` -E + <两字母领域标签> + <四位数字> 共 7 位,正则 ^E[A-Z]{2}\d{4}$ +E + <two-letter domain tag> + <four digits> 7 chars total, regex ^E[A-Z]{2}\d{4}$ ``` -示例:`EAG0001`、`EUI0001`、`ECF0001`、`ENT0407`。 +Examples: `EAG0001`, `EUI0001`, `ECF0001`, `ENT0407`. -- **领域标签**(两字母大写): +- **Domain tags** (two uppercase letters): - | 标签 | 领域 | + | Tag | Domain | | --- | --- | - | `AG` | Agent(评审编排 / AutoPilot / pr-agent 调用等) | - | `UI` | GUI(渲染层交互 / 窗口 / 展示) | - | `CF` | Config(配置与凭据 / 设置 / 向导) | - | `NT` | 网络(出站请求 / 代理 / 平台 API / 版本检查等) | - | `PR` | PR / 草稿(草稿池 / 发布闭环 / PR 级操作等) | + | `AG` | Agent (review orchestration / AutoPilot / pr-agent calls, etc.) | + | `UI` | GUI (render-layer interaction / windows / display) | + | `CF` | Config (config & secrets / settings / wizard) | + | `NT` | Network (outbound requests / proxy / platform API / version check, etc.) | + | `PR` | PR / draft (draft pool / publish loop / PR-level operations, etc.) | - 新增领域**一律追加在末尾**(与平台展示顺序约定同源思路):取一个未用的两字母大写、登记进注册表、并在本表补一行。 + A new domain is **always appended at the end** (same reasoning as the platform display-order convention): pick an unused two-uppercase-letter tag, register it, and add a row to this table. -- **四位数字**:每个领域 `0001`–`9999` **顺序分配**,注册表为唯一真相源(不复用、不跳改语义)。 - 每个领域保留 `0000` 作**「未分类」兜底码**(如 `EAG0000` = 未归类的 Agent 错误)。 +- **Four digits**: each domain is assigned `0001`–`9999` **in order**, with the registry as the single source of truth (no reuse, no semantic reassignment). Each domain reserves `0000` as the **"uncategorized" fallback code** (e.g. `EAG0000` = an uncategorized Agent error). -### 2. 统一错误对象(AppError) +### 2. Unified error object (AppError) -业务领域统一封装为单一错误类型 **`AppError`**(继承 `Error`): +Business domains are uniformly wrapped in a single error type **`AppError`** (extending `Error`): -- `code`:错误码(上述格式),决定语义与前端 i18n key。 -- `meta?`:**扩展参数对象**,承载两类内容(仅放**可序列化的标量** string / number / boolean,不放大对象、不放敏感值): - - i18n 插值参数(如 `{ tool: 'review' }`); - - 诊断字段(如 `{ status: 407 }`)。 -- `message`(开发者向):英文短描 + 含 code,供后台日志与 stack 阅读(后台日志仍英语,不影响用户文案)。 +- `code`: the error code (the format above), which determines the semantics and the frontend i18n key. +- `meta?`: an **extension parameter object** carrying two kinds of content (only **serializable scalars** string / number / boolean — no large objects, no sensitive values): + - i18n interpolation params (e.g. `{ tool: 'review' }`); + - diagnostic fields (e.g. `{ status: 407 }`). +- `message` (developer-facing): a short English description + the code, for backend logs and stack reading (backend logs stay English, not affecting the user copy). -业务侧抛错统一 `throw new AppError(code, meta?)`,不再各自拼本地化字符串。`AppError` 的构造与编解码集中在**共享层**(main / renderer / 内部库共用同一份),保证两端对码与 wire 形态的认知一致。 +The business side throws uniformly with `throw new AppError(code, meta?)`, no longer assembling localized strings itself. The construction and encode/decode of `AppError` are centralized in the **shared layer** (main / renderer / internal libraries share one copy), guaranteeing both ends agree on the code and the wire format. -### 3. 传输契约(跨 IPC) +### 3. Transport contract (across IPC) -**关键约束**:Electron 的 IPC 以**结构化克隆**传递错误,**只可靠保留 `Error.message`**(`name`/`stack` 多数情况在;**自定义属性 `code`/`meta` 会被丢弃**)。故 `code` 与 `meta` **必须编码进 `message`**,才能必达前端。 +**Key constraint**: Electron's IPC passes errors via **structured clone**, and **only `Error.message` is reliably preserved** (`name`/`stack` are present in most cases; **the custom properties `code`/`meta` are dropped**). So `code` and `meta` **must be encoded into `message`** to reach the frontend reliably. -- **wire 形态**:`AppError` 跨 IPC 时,其 `message` 为**规范化编码串**——以固定 sentinel 前缀打头、其后接 `{code, meta}` 的单行 JSON,例如: +- **Wire format**: when an `AppError` crosses IPC, its `message` is a **normalized encoded string** — led by a fixed sentinel prefix, followed by single-line JSON of `{code, meta}`, e.g.: ``` @meebox/err {"code":"EAG0002","msg":"EAG0002","meta":{"tool":"review"}} ``` - 渲染层在收到的 message 中**定位 sentinel 并解析其后的 JSON**(容忍 Electron 给 message 加的 `Error invoking remote method '…': ` 前缀——只认 sentinel 起的尾段)。 -- **非 `AppError` 抛出**(第三方库 / 尚未封装的错误):渲染层解码失败 → 归入**全局未知兜底码**,原始 message 作诊断保留(不隐藏)。 -- **可选:结果信封**。除「抛错」外,新接口也可显式返回 `{ ok: false, error: { code, meta } }` 信封(不经异常通道)。现状以 throw 为主,信封为后续可选,二者前端处理一致(都拿到 `{code, meta}`)。 + The render layer **locates the sentinel in the received message and parses the JSON after it** (tolerating the `Error invoking remote method '…': ` prefix Electron adds to the message — it only reads the tail starting from the sentinel). +- **Non-`AppError` throws** (third-party libraries / not-yet-wrapped errors): the render layer's decode fails → they fall into the **global unknown fallback code**, with the original message retained as diagnostics (not hidden). +- **Optional: result envelope**. Besides "throwing", a new interface may also explicitly return an `{ ok: false, error: { code, meta } }` envelope (not via the exception channel). Currently throwing is the mainstay and the envelope is an optional follow-up; the frontend handles both the same (both yield `{code, meta}`). -### 4. 前端处理(i18n + 兜底) +### 4. Frontend handling (i18n + fallback) -渲染层把收到的错误统一解码为 `{ code, meta }`,再做本地化: +The render layer uniformly decodes a received error into `{ code, meta }`, then localizes: -- **i18n key 约定**:`errors.<CODE>`(如 `errors.EAG0001`),`meta` 经 i18next 插值(变量名与 meta 字段对应)。各 locale 对等覆盖(见 [i18n](../03-gui/04-i18n.md)「对等译文集」「递归字典序」)。 -- **兜底**:未知码 / 解码失败 → 通用错误文案(`errors.unknown` 之类)+ **显示原始码**(如「错误 ENT0407」),既不误导也便于用户报障。 -- **取代现状**:现渲染层是对英文 message 做**正则模式匹配**来选 i18n key(脆弱、随 message 文案漂移)。改造后:**先解码 AppError → 按码精确映射**;正则匹配**降级为兜底**(仅用于第三方 / 历史未编码错误)。 +- **i18n key convention**: `errors.<CODE>` (e.g. `errors.EAG0001`), with `meta` interpolated by i18next (variable names correspond to meta fields). Each locale covers it equivalently (see [i18n](../03-gui/04-i18n.md) "equivalent translation sets" / "recursive dictionary order"). +- **Fallback**: an unknown code / decode failure → generic error copy (`errors.unknown` or the like) + **show the original code** (e.g. "Error ENT0407"), which neither misleads nor hinders the user filing a report. +- **Replacing the status quo**: the current render layer does **regex pattern matching** on the English message to pick the i18n key (fragile, drifting with the message copy). After the rework: **decode AppError first → map precisely by code**; regex matching is **downgraded to a fallback** (used only for third-party / historically un-encoded errors). -### 5. 与既有约定的关系 +### 5. Relationship to existing conventions -- **后台日志 / 包内技术异常**:仍英语、不 i18n(开发者向)。`AppError.message` 的英文短描即服务于此。 -- **面向用户、跨 IPC 的错误**:走 `AppError` + 码 + 前端 i18n。 -- **主进程 i18n 的错误文案逐步迁出**:当前主进程 i18n 资源里承载的错误消息(pr-agent / 代理 / 版本检查等),按本规范迁到「码 + 渲染层 i18n」;主进程 i18n 仅保留**确属 main 侧、非跨 IPC**的面向用户文本(如系统对话框标题)。迁移分阶段进行,不要求一次性替换。 +- **Backend logs / in-package technical exceptions**: still English, no i18n (developer-facing). `AppError.message`'s short English description serves exactly this. +- **User-facing errors that cross IPC**: go through `AppError` + code + frontend i18n. +- **Main-process i18n error copy migrates out gradually**: the error messages currently carried in the main-process i18n resources (pr-agent / proxy / version check, etc.) migrate to "code + render-layer i18n" per this spec; main-process i18n keeps only user-facing text that is **truly main-side and non-cross-IPC** (e.g. system-dialog titles). The migration proceeds in phases, not requiring a one-shot replacement. -## 数据 / 接口契约 +## Data / interface contract -- **`AppError`**:`{ name: 'AppError'; code: ErrorCode; meta?: Record<string, string | number | boolean> }`,`message` 为 §3 的 wire 编码串。 -- **`ErrorCode` 注册表**(共享层、唯一真相源):枚举所有错误码并标注其领域(及可选的 meta 字段约定);新增码必须在此登记。 -- **wire 编码**:`message` 的 sentinel + JSON 规范(§3),main / renderer 共用同一编解码。 -- **i18n 资源**:渲染层各 locale 的 `errors.<CODE>` 条目(对等覆盖)+ `errors.unknown` 兜底。 +- **`AppError`**: `{ name: 'AppError'; code: ErrorCode; meta?: Record<string, string | number | boolean> }`, with `message` being the wire-encoded string of §3. +- **`ErrorCode` registry** (shared layer, single source of truth): enumerates all error codes and annotates their domain (and the optional meta-field convention); a new code must be registered here. +- **wire encoding**: the sentinel + JSON spec for `message` (§3), with main / renderer sharing one codec. +- **i18n resources**: the render layer's per-locale `errors.<CODE>` entries (equivalent coverage) + the `errors.unknown` fallback. -### 示例(非全量;全量以代码注册表为准) +### Examples (not exhaustive; the full set is per the code registry) -**完整码表不在文档维护**——以共享层 `ERROR_CODES` 注册表为唯一真相源,文档只给几条示例帮助理解形态: +**The full code table is not maintained in the docs** — the shared-layer `ERROR_CODES` registry is the single source of truth; the docs give only a few examples to help understand the form: -| 码 | 领域 | 语义 | meta | +| Code | Domain | Semantics | meta | | --- | --- | --- | --- | -| `EAG0002` | Agent | 该 PR 的 `/{tool}` 任务重复触发(带插值参数) | `tool` | -| `ENT0407` | 网络 | 代理认证失败(407) | — | -| `EPR0001` | PR | 草稿不存在(可能已被删除) | — | -| `EAG0000` | Agent | 各领域 `0000` 为「未分类」兜底码 | — | - -## 扩展与注意事项 - -- **新增码**:在注册表选定领域 + 取下一个空号登记,并在各 locale 补 `errors.<CODE>`(保持递归字典序)。 -- **新增领域**:两字母大写、登记、领域表追加在末尾;不复用、不改既有码语义。 -- **`meta` 纪律**:只放可序列化标量,供插值 / 诊断;勿放大对象、PII、凭据。 -- **兜底优先级**:精确码 → 领域 `0000` → 全局 `errors.unknown`(始终显示原始码)。 -- **不破坏开发者体验**:编码进 message 的同时保留英文短描,日志可读;技术异常该英语仍英语。 -- **迁移策略**:新错误一律走码;旧错误(主进程 i18n / 渲染层正则)逐步替换,正则匹配长期保留为第三方错误兜底。 +| `EAG0002` | Agent | the `/{tool}` task for that PR is duplicate-triggered (with an interpolation param) | `tool` | +| `ENT0407` | Network | proxy auth failure (407) | — | +| `EPR0001` | PR | the draft doesn't exist (may have been deleted) | — | +| `EAG0000` | Agent | each domain's `0000` is the "uncategorized" fallback code | — | + +## Extension & caveats + +- **Adding a code**: pick the domain in the registry + take the next free number and register it, and add `errors.<CODE>` to each locale (keeping recursive dictionary order). +- **Adding a domain**: two uppercase letters, register, append to the domain table at the end; no reuse, no changing existing code semantics. +- **`meta` discipline**: only serializable scalars, for interpolation / diagnostics; no large objects, PII, or credentials. +- **Fallback priority**: exact code → domain `0000` → global `errors.unknown` (always show the original code). +- **Don't hurt the developer experience**: while encoded into the message, the short English description is retained so logs stay readable; technical exceptions that should be English stay English. +- **Migration strategy**: new errors always go through codes; old errors (main-process i18n / render-layer regex) are replaced gradually, with regex matching kept long-term as a fallback for third-party errors. diff --git a/docs/arch/README.md b/docs/arch/README.md index 080c35fe..52c60853 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -1,67 +1,67 @@ -# 架构设计文档(Architecture) +# Architecture design docs (Architecture) -按**模块领域**沉淀**当前的设计与实现结论**——「现在是怎样、为什么这样、怎么扩展」。 -理解和维护某个模块时,这里是**首选入口**。 +Captures the **current design and implementation conclusions** organized **by module domain** — "how it is now, why it's this way, how to extend it". +When understanding or maintaining a module, this is the **preferred entry point**. -## 与其它文档的关系 +## Relationship to other docs -- **本目录(arch/)= 当前结论**:随实现演进持续更新,是某模块的「事实来源」。 -- **ROADMAP = 高层视角**:项目定位、已交付能力、风险与下一步;不放实现细节(细节在这里)。 +- **This directory (arch/) = current conclusions**: continuously updated as the implementation evolves; the "source of truth" for a module. +- **ROADMAP**: delivered capabilities, ongoing evolution, risks and next steps; no implementation details (those live here). -## 每篇骨架约定 +## Per-doc skeleton convention -为便于检索与维护,每篇统一结构: +To aid searching and maintenance, every doc follows a uniform structure: -1. **职责与边界** —— 这个模块负责什么、不负责什么。 -2. **核心设计** —— 当前采用的设计 + 关键取舍(为什么这样,而非别的)。 -3. **数据 / 接口契约** —— 对外类型、IPC 通道、文件格式等稳定契约(用名称与形状描述)。 -4. **扩展与注意事项** —— 如何扩展、维护时需注意的要点。 +1. **Responsibilities & boundaries** — what this module is and isn't responsible for. +2. **Core design** — the design currently adopted + key trade-offs (why this way, not another). +3. **Data / interface contract** — outward-facing types, IPC channels, file formats and other stable contracts (described by name and shape). +4. **Extension & caveats** — how to extend it, and the points to watch when maintaining it. -> 原则:**描述设计,不引用代码文件**。文档是耐久的设计说明,不绑定具体文件路径 -> (路径会过时,也会让文档沦为文件索引)。需要时点到概念名 / 类型名 / 函数名即可, -> 由读者凭名字在代码里检索。 +> Principle: **describe the design; don't reference code files**. A doc is a durable design description, not bound to specific file paths +> (paths go stale, and they reduce a doc to a file index). Point to a concept / type / function name where needed, +> and let the reader grep by name in the code. > -> 数据结构描述走**分级折中**:内部领域类型给「名 + 用途 + 关键字段」(完整字段交类型名去 grep), -> 序列化 / IPC / 磁盘文件等稳定契约给紧凑 shape 块;抽象接口列方法名 + 语义、不写伪签名。 +> Data-structure descriptions take a **tiered compromise**: internal domain types get "name + purpose + key fields" (grep the type name for the full field set); +> serialization / IPC / on-disk file and other stable contracts get a compact shape block; abstract interfaces list method names + semantics, no pseudo-signatures. -## 模块清单 +## Module index -按专题分目录、目录与文档均带两位序号前缀(平台优先:平台集成 → Agent → GUI → 基础设施): +Grouped into per-topic directories; both directories and docs carry a two-digit ordinal prefix (platform-first: platform integration → Agent → GUI → infrastructure): ```text docs/arch/ -├── 00-overview.md 架构总览:进程模型 / IPC / 数据流 / 模块关系 -├── 01-platform/ 平台集成与 PR 操作 -│ ├── 01-adapter.md 代码平台适配(PlatformAdapter / 能力位与降级 / 多平台差异化 / clone 协议) -│ ├── 02-repo-mirror.md 仓库镜像与 Diff(bare clone / worktree / blame) -│ ├── 03-review-workflow.md 评审→发布闭环(命令 / findings 解析 / 草稿池 / 发布 / merge) -│ └── 04-comment-interactions.md 评论互动(emoji 反应 / @提及补全 / 图片附件;能力位降级 / 三平台差异) -├── 02-agent/ Agent 与规则 -│ ├── 01-agent.md Agent 与上下文(目录分层 / 上下文注入 / 工具红线 / 会话隔离 / 模版) -│ ├── 02-session.md 会话 Agent 化(输入路由 / 规划循环 / 过程留存 / 交互控制) -│ ├── 03-autopilot.md AutoPilot 与调度(自动预评审 / 准入闸 / 批量判定 / 微流程 / 优先级队列) -│ ├── 04-rules.md 规则系统(frontmatter / 匹配优先级;正文存 `<agent.dir>/rules/`) -│ ├── 05-pragent-runtime.md pr-agent 集成与运行时(bridge / 嵌入式 Python / sitecustomize / token usage) -│ └── 06-tool-token-cost.md 工具 token 成本与上下文分层(三层上下文模型 / 成本放量根因 / 只读检索指引 · /ask 预算 · codegraph 评估) -├── 03-gui/ GUI 与交互 -│ ├── 01-ui-interaction.md 渲染层布局 / 面板 / 跨 PR 保活 / 交互约定 -│ ├── 02-command-palette.md 命令面板(标题栏入口 / 两级选择 / 按语言搜索 / 注册表 + 分域) -│ ├── 03-notifications.md 消息通知(poll 事件投影 / 系统通知 toast / macOS dock 角标 / OS 权限降级) -│ └── 04-i18n.md 国际化(react-i18next / 双运行时 / key 命名 / 翻译规范 / 模板翻译) -├── 04-integration/ 外部集成扩展与 CLI -│ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 读+评审写边界 / 路由复用 service) -│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 显式连接配置 / 跨平台分发) -└── 99-core/ 基础设施 - ├── 01-state-storage.md 状态存储与数据模型(StateStore / per-PR 目录 / 存储模型 + 业务生命周期) - ├── 02-config-and-secrets.md 配置与凭据(config.yaml / SecretStore / 设置页 / 首启向导) - ├── 03-networking-proxy.md 出站网络与代理(HTTP 代理统一 / loopback 直连 / SSH) - └── 04-error-codes.md 错误码与错误传递(AppError + meta / 跨 IPC 编码 / 前端按码 i18n / 注册表) +├── 00-overview.md Architecture overview: process model / IPC / data flow / module relationships +├── 01-platform/ Platform integration & PR operations +│ ├── 01-adapter.md Code-platform adaptation (PlatformAdapter / capability flags & degradation / multi-platform differentiation / clone protocol) +│ ├── 02-repo-mirror.md Repo mirror & Diff (bare clone / worktree / blame) +│ ├── 03-review-workflow.md Review→publish loop (commands / findings parsing / draft pool / publish / merge) +│ └── 04-comment-interactions.md Comment interactions (emoji reactions / @mention completion / image attachments; capability-flag degradation / three-platform differences) +├── 02-agent/ Agent & rules +│ ├── 01-agent.md Agent & context (directory tiers / context injection / tool mutation red line / session isolation / templates) +│ ├── 02-session.md Agentic sessions (input routing / planning loop / process retention / interaction control) +│ ├── 03-autopilot.md AutoPilot & scheduling (automatic pre-review / admission gate / batch decisioning / micro-flow / priority queue) +│ ├── 04-rules.md Rules system (frontmatter / match priority; bodies stored under `<agent.dir>/rules/`) +│ ├── 05-pragent-runtime.md pr-agent integration & runtime (bridge / embedded Python / sitecustomize / token usage) +│ └── 06-tool-token-cost.md Tool token cost & context tiers (three-tier context model / root causes of cost blow-up / read-only retrieval guidance · /ask budget · codegraph evaluation) +├── 03-gui/ GUI & interaction +│ ├── 01-ui-interaction.md Render-layer layout / panels / cross-PR state persistence / interaction conventions +│ ├── 02-command-palette.md Command palette (title-bar entry / two-level selection / search by language / registry + domain grouping) +│ ├── 03-notifications.md Notifications (poll event projection / system notification toast / macOS dock badge / OS permission degradation) +│ └── 04-i18n.md Internationalization (react-i18next / dual runtime / key naming / translation conventions / template translation) +├── 04-integration/ External integration extensions & CLI +│ ├── 01-service-api.md Local API service & listener (loopback default / mandatory token / read + review write boundary / routes reuse the service layer) +│ └── 02-cli.md CLI tool (standalone Go binary / command tree / explicit connection config / cross-platform distribution) +└── 99-core/ Infrastructure + ├── 01-state-storage.md State storage & data model (StateStore / per-PR directory / storage model + business lifecycle) + ├── 02-config-and-secrets.md Config & credentials (config.yaml / SecretStore / settings page / setup wizard) + ├── 03-networking-proxy.md Outbound network & proxy (unified HTTP proxy / loopback direct connection / SSH) + └── 04-error-codes.md Error codes & error propagation (AppError + meta / cross-IPC encoding / frontend i18n by code / registry) ``` -> 打包 / 构建 / 签名 不属于产品子系统,已移到开发专题:[`../development/packaging-release.md`](../development/packaging-release.md)。 +> Packaging / build / signing are not product subsystems; they have moved to the development topic: [`../development/packaging-release.md`](../development/packaging-release.md). -## 编号规则 +## Numbering rules -- **两级编号**:专题目录两位前缀(`01-platform` / `02-agent` / `03-gui` / `99-core`),目录内文档再两位前缀、从 `01` 起;`00-overview.md` 与本 README 留在根。 -- 编号只为排序与稳定引用,不代表强依赖;新增文档取所属目录的下一个空号。 -- **`99-core` 取末位 `99`**:基础设施专题恒置末尾,新增功能专题依次取 `04`、`05`… 插在它之前,无需为其腾挪编号。 +- **Two-level numbering**: topic directories take a two-digit prefix (`01-platform` / `02-agent` / `03-gui` / `99-core`), docs within a directory take a further two-digit prefix starting at `01`; `00-overview.md` and this README stay at the root. +- Numbering is only for ordering and stable references, not a hard dependency; a new doc takes the next free number in its directory. +- **`99-core` takes the last slot `99`**: the infrastructure topic is always pinned last; new feature topics take `04`, `05`… in turn, inserted before it, with no need to renumber it. diff --git a/docs/arch/glossary.md b/docs/arch/glossary.md new file mode 100644 index 00000000..8e66d345 --- /dev/null +++ b/docs/arch/glossary.md @@ -0,0 +1,151 @@ +# Glossary — canonical terminology + +This appendix locks the **canonical English rendering** of the recurring domain terms used across the architecture docs (`docs/arch/`). When writing or editing an arch doc, use the term in the **English** column; keep identifiers in the **Keep verbatim** section unchanged. The goal is one concept → one term, so the docs read consistently and map cleanly onto the (English) codebase. The Chinese column records the source term for traceability. + +> These docs describe design, not code files. Prefer concept / type / function names over file paths, and let the reader grep by name. + +## Disambiguations (read first) + +A few concept pairs are easy to collapse into one English word — keep them distinct: + +- **recommendation** vs **verdict** — `recommendation` is the non-binding review outcome (`approve` / `needs_work` / `manual_review`); a **(re-review) verdict** is the outcome of a re-review (`supersede` / `keep` / `withdraw`). Never render both as "verdict". +- **dispatch** vs **distribution** — routing a task (planner → sub-agents, tool → run queue) is **dispatch**; shipping a binary/artifact is **distribution / distribute**. +- **revoke** vs **dismiss** — the neutral review status action (approve / needs work / revoke) is **revoke**; the GitHub-specific API action (`.../dismissals`) is **dismiss**, used only when describing GitHub internals. +- **retire** vs **archive** — a PR leaving the active reviewer list is **retired** (lifecycle); **archive / archived** refers strictly to the cold storage location (`archived/prs/`). +- **follow-up ask** (noun) vs **follow up** (verb) — the noun is always "follow-up ask" (matches `max_followup_asks`); avoid bare "followup". +- **setup wizard** (prose) vs **`OnboardingWizard`** (the component) — use "setup wizard" in prose; write `OnboardingWizard` only when naming the identifier. + +## Product / domain terms + +| English (canonical) | Chinese | Notes | +| --- | --- | --- | +| review | 评审 | verb & noun | +| finding | 评审发现 / 发现 | keep `finding` in UI/agent contexts | +| draft / draft pool / draft candidate | 草稿 / 草稿池 / 草稿候选 | | +| re-review / re-review reference | 复评 / 复评引用 | | +| (re-review) verdict | 裁决(复评) | supersede / keep / withdraw — see disambiguations | +| supersede / keep / withdraw | 取代 / 保留 / 撤销(复评裁决) | | +| recommendation | 建议(评审结论) | approve / needs_work / manual_review — see disambiguations | +| follow-up ask / conditional follow-up | 追问 / 条件性追问 | noun; verb is "follow up" | +| micro-flow | 微流程 | hyphenated | +| orchestration / orchestrator / orchestration-level | 编排 / 编排器 / 编排级 | | +| planning loop (ReAct loop) / planner | 规划循环 / 规划 agent | | +| per-PR agent / sub-agent | 子 agent / 各 PR agent | | +| pre-review / auto review | 预评审 / 自动评审 | | +| admission gating / admission gate | 准入门控 / 准入闸 | not "gatekeeping" | +| ledger | 台账 | `AutopilotLedger` | +| dedup / dedupe | 去重 | | +| red line / tool mutation red line / red-line check | 红线 / 工具红线 / 红线校验 | | +| mutating tool / read · read-only tool | 修改类工具 / 读类·只读工具 | | +| grant | 授权(写权限) | README "per-item authorization" | +| agentic sessions | 会话 Agent 化 | doc-title concept | +| conversation-as-delegation | 对话即委派 | | +| direct tool (invocation) | 直达工具 | | +| tool catalog / tool registry | 工具目录 / 工具注册表 | | +| step / step count / max steps | 步 / 步数 / 步数上限 | | +| sub-task | 子任务 | | +| discovery category (filter) | 发现分类 | `PrDiscoveryFilter` | +| Review Requested / Created / Assigned / Mentioned | 待我评审 / 我创建 / 指派我 / 提及我 | lock to the UI labels | +| pending | 待处理 | `LocalPrStatus` | +| approve / needs work / revoke | 通过 / 需修改 / 撤销(审批) | | +| review decision | 决断(审批) | | +| unread / mention (@me) / reply | 未读 / 点名(@我)/ 回复我 | | +| unread mention count | 未读点名计数 | | +| cursor | 游标(lastMentionAt) | | +| archive / cold storage / soft-delete / retire / revive / reconcile | 归档 / 冷存储 / 软删 / 退场 / 复活 / 对账 | see disambiguations (retire vs archive) | +| grace period | grace 期 | | +| repo mirror / bare mirror / materialize | 仓库镜像 / bare 镜像 / 物化 | | +| three-dot diff / expanded diff / bare diff | 三点 diff / 展开 diff / 裸 diff | | +| match / matched (rule) / matched-rules chip | 命中(规则)/ 命中规则 chip | | +| rule / rules (rule system) / inject (injection) | 规则 / 规则系统 / 注入 | `extra_instructions` | +| capability (flag) / capability descriptor | 能力位 / 能力描述符 | `PlatformCapabilities` | +| graceful degradation / capability degradation | 能力降级 / 功能降级 | | +| grey out (disabled) / hidden (not rendered) | 置灰 / 隐藏不渲染 | | +| platform adaptation / domain service / domain split | 平台适配 / 领域服务 / 领域拆分 | | +| transport port | 传输端口 | `PlatformTransport` | +| optimistic lock / anchor | 乐观锁 / 锚点 | | +| inline comment / top-level comment · summary comment | 行内评论 / 顶层评论 · summary 评论 | | +| merge veto / publish | 合并否决 / 发布(评论) | | +| polling / Poller / projection (event projection) | 轮询 / Poller / 投影 | keep `Poller` | +| activity timeline | 活动时间线 | | +| system notification / toast / dock badge | 系统通知 / toast / dock 角标 | | +| setup wizard | 首启向导 / 首启配置向导 | component is `OnboardingWizard` | +| command palette | 命令面板 | | +| composition root / domain hook / module-level store | 组合根 / 领域 hook / 模块级 store | | +| frameless window / custom-drawn title bar | 无边框窗口 / 自绘标题栏 | | +| cross-PR state persistence | 跨 PR 保活 | | +| overview ruler | 总览标尺 | Monaco term | +| outbound network / egress / direct connection | 出站网络 / 出站 / 直连 | | +| credential / stored in plaintext | 凭据 / 明文落盘 | `SecretStore` | +| take effect immediately / hot-reload | 热生效 / 热更新 | | +| profile / active item | 预设(LLM)/ 生效项 | | +| local CLI provider / local CLI mode | 本地 CLI provider / CLI 模式 | | +| embedded runtime / (invocation) bridge / sentinel | 嵌入式运行时 / 调用桥 / 哨兵(行) | `@@MEEBOX_USAGE@@` | +| prompt cache / cache read (cache hit) / token usage | 提示缓存 / 命中量(cache)/ token 用量 | | +| context tiers / layered context / context injection | 上下文分层 / 三层上下文 / 上下文注入 | | +| writable memory / long-term memory / user profile / soul | 可写记忆 / 长期记忆 / 用户画像 / 灵魂 | `USER.md` / `SOUL.md` | +| seed / scaffold | 播种(示例规则)/ 脚手架 | | +| error code / domain tag / fallback code | 错误码 / 领域标签 / 兜底码 | `ErrorCode` | +| wire format / wire contract / structured clone / envelope | 传输契约 / wire 形态 / 结构化克隆 / 信封 | | +| local API service / write boundary / write action | 服务监听 / 本地 API / 写边界 / 写动作 | | +| change-type (mutating) tool / gating / compact projection | 变更类工具 / 门控 / 精简投影 | | +| dispatch / distribution | 分发(任务)/ 分发(发布物) | see disambiguations | +| cross-compile / thin client / command tree / domain group | 交叉编译 / 瘦客户端 / 命令树 / 领域组 | | +| root-level system command | 系统性命令(根层级) | | + +## Skeleton headings (recur per doc) + +| English | Chinese | +| --- | --- | +| Responsibilities & boundaries | 职责与边界 | +| Core design | 核心设计 | +| Data / interface contract | 数据 / 接口契约 | +| Extension & caveats | 扩展与注意事项 | +| Scope | 范围 | +| Functional design | 功能设计 | +| Related / See also | 关联 | +| Legend | 图说 | +| Key trade-offs | 关键取舍 | +| Implemented / Rejected / Under evaluation (deferred) | 已实现 / 已否决 / 待评估(暂缓) | +| Commands & shortcuts | 命令与快捷键一览 | +| Interaction conventions | 交互规范 | + +## Stock phrases (house style) + +| English | Chinese | +| --- | --- | +| Describe the design; don't reference code files | 描述设计,不引用代码文件 | +| source of truth / single source of truth | 事实来源 / 单一真相源 | +| the human decides | 决策权在人 | +| high-level view | 高层视角 | +| adapt / degrade per platform capability | 按平台能力自适应降级 | +| best-effort; silently degrade on failure | best-effort / 失败静默降级 | +| read fresh / assemble fresh on each run | 每次 run 现读现装配 | +| zero extra fetches | 零额外取数 | +| bounded / unbounded | 有界 / 无界 | +| conclusion first | 结论先行 | +| local-first / data stays local | 本地优先 / 数据留在本地 | +| zero dependencies / works out of the box | 零依赖 / 开箱即用 | +| doesn't silently truncate | 不静默截断 | +| idempotent / single writer / atomic write | 幂等 / 单写者 / 原子写 | +| backstop / safety net | 安全兜底 | + +## Keep verbatim (identifiers — do not translate) + +Type / identifier / API names stay exactly as written in code. Non-exhaustive, by area: + +- **Platform core**: `PlatformAdapter`, `PlatformConnection`, `PullRequestService`, `CommentService`, `MediaService`, `PlatformTransport`, `PlatformDomainService`, `ConnectionContext`, `composePlatformAdapter` +- **Shared domain types**: `PrIdentity`, `PrComment`, `PrCommentAnchor`, `PrDiffRefs`, `PrReaction`, `MergeStatus`, `MergeVeto`, `MergeVetoCode`, `PlatformCapabilities`, `PrDiscoveryFilter`, `LocalPrStatus`, `PrListItem` +- **Git layer**: `worktree`, `bare clone`, `blame`, `simple-git`, `refspec`, `hardlink`, `LFS` +- **Electron / IPC**: `IPC`, `ipcMain.handle`, `invoke`, `IpcChannels`, `contextBridge`, `contextIsolation`, `nodeIntegration`, `CSP`, `preload`, Main, Renderer +- **pr-agent runtime**: `pr-agent`, `LocalGitProvider`, `litellm`, `sitecustomize`, `monkeypatch`, `shim`, `meta_path finder`, `CPython`, `extraResources`, `asar`, `MockResponse` +- **Env / sentinel / token fields**: `@@MEEBOX_USAGE@@`, `MEEBOX_CLI_MODE`, `MEEBOX_CLI_BIN`, `MEEBOX_CLI_WORKDIR`, `MEEBOX_CHAT_CACHE`, `CACHE_BREAK`, `EXTRA_INSTRUCTIONS`, `extra_instructions`, `num_turns`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `cached_input_tokens` +- **Rules**: `frontmatter`, `gray-matter`, `Ruleset`, `applies_to`, `priority` +- **Agent / orchestration**: `ReviewRun`, `Finding`, `AgentSession`, `AgentStep`, `ReviewRunTool`, `buildToolCatalog`, `ToolCatalogEntry`, `TOOLS`, `tool-registry`, `REVIEW_STEP_REGISTRY`, `ReviewStepKind`, `AutopilotLedger`, `PlannerPass`, `QueueItem`, `AutoPilot`, `planner`, `ReAct`, `AbortController` +- **State storage**: `StateStore`, `JsonFileStateStore`, `StoredPullRequest`, `PrIndexEntry`, `PrReadStateFile`, `relocateTree`, `schema_version`, `localId`, `remoteId`, `subpathInside` +- **Config / errors**: `AppError`, `ErrorCode`, `ERROR_CODES`, `SecretStore`, `zod` +- **i18n**: `react-i18next`, `useTranslation`, `resolveLanguage`, `matchSupportedLanguage`, `SUPPORTED_LANGUAGES`, `fallbackLng`, `nonExplicitSupportedLngs`, `partialBundledLanguages`, `translatePrAgentLabels`, `CLDR` +- **Renderer**: `Monaco`, `DiffView`, `DraftZone`, `ChatPane`, `MainPane`, `Sidebar`, `StatusBar`, `TitleBar`, `OnboardingWizard`, `SettingsModal`, `DraftsPanel`, `view zone`, `useSyncExternalStore`, `useDockBadge`, `RunMeta` +- **Networking**: `PAT`, `SSH`, `loopback`, `undici`, `dispatcher`, `HTTP(S)_PROXY`, `NO_PROXY`, `ProxyFetchFactory`, `ProxyCommand`, `Basic Auth`, `socks5` +- **CLI / integration**: `meebox`, `cobra`, `GOOS`/`GOARCH`, `go:embed`, `SKILL.md`, `cli.yaml`, `config.yaml`, `MEEBOX_API_URL`, `MEEBOX_TOKEN`, `X-Meebox-CLI-Version` +- **Platform API nouns** (keep per platform): `dashboard`, `X-AUSERNAME`, `multilineMarker`, `in_reply_to_id`, `mergeable_state`, `detailed_merge_status`, `discussions`, `notes`, `award emoji`, `Twemoji`, `X-Atlassian-Token` diff --git a/docs/development/README.md b/docs/development/README.md index 9fc30487..c3fc4f76 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -1,168 +1,167 @@ -# 开发指南 +# Development Guide -面向贡献者 / 自行构建的开发者。产品介绍见根 [README](../../README.md),路线与进展见 [ROADMAP](../ROADMAP.md)。 +For contributors and developers building from source. For the product overview see the root [README](../../README.md); for the roadmap and progress see the [ROADMAP](../ROADMAP.md). -> 本目录(`docs/development/`)是**开发专题**:本指南为入口,另含 [打包与发布](./packaging-release.md)(含 CI)与 [macOS 构建与发布](./mac-build.md)。模块/子系统设计见 [`../modules/`](../modules/)。 +> This directory (`docs/development/`) is the **development track**: this guide is the entry point, alongside [Packaging & release](./packaging-release.md) (incl. CI) and [macOS build & release](./mac-build.md). Module / subsystem design lives in [`../arch/`](../arch/README.md). -> 代码内部统一用中性代号 **meebox**(npm 作用域 `@meebox/*`),对外品牌为 Code Meeseeks。 -> 数据目录 `~/.code-meeseeks/`。上游 pr-agent 为第三方依赖,不在重命名范围内。 +> The codebase uses the neutral codename **meebox** throughout (npm scope `@meebox/*`); the public brand is Code Meeseeks. The data directory is `~/.code-meeseeks/`. Upstream pr-agent is a third-party dependency and is out of the renaming scope. -## 1. 前置环境 +## 1. Prerequisites -| 依赖 | 版本 | 说明 | +| Dependency | Version | Notes | | --- | --- | --- | -| Node.js | ≥ 20(开发用 22) | 见根 `package.json` engines | -| npm | ≥ 10 | 用 workspaces,勿用 pnpm/yarn | -| Git | 任意近期版本 | 仓库镜像 + diff 走系统 `git` | +| Node.js | ≥ 20 (22 for development) | See the root `package.json` engines | +| npm | ≥ 10 | Uses workspaces — do not use pnpm/yarn | +| Git | Any recent version | Repo mirroring + diff go through system `git` | -打包时还会用到(已作为 devDependencies 安装,无需系统级安装):内嵌运行时下载走 `undici` + `tar`,无需系统 `curl` / `python` / `docker`。 +Packaging also needs (already installed as devDependencies, no system-level install required): the embedded-runtime download goes through `undici` + `tar`, so no system `curl` / `python` / `docker` is needed. -> **代理**:`prepare:pragent` 会从 GitHub 下载 python-build-standalone。若在受限网络下,设置 `HTTPS_PROXY` 环境变量,或给脚本传 `--proxy http://host:port`。 +> **Proxy**: `prepare:pragent` downloads python-build-standalone from GitHub. On a restricted network, set the `HTTPS_PROXY` environment variable, or pass `--proxy http://host:port` to the script. -## 2. 安装依赖 +## 2. Install dependencies ```bash git clone <repo-url> cd <repo> -npm ci # 安装全部 workspace 依赖 +npm ci # install all workspace dependencies ``` -仓库用 **Git LFS** 跟踪图标 / 图片(见根 `.gitattributes`)。若 clone 后图片显示为指针文本: +The repo tracks icons / images with **Git LFS** (see the root `.gitattributes`). If images show up as pointer text after cloning: ```bash git lfs pull ``` -## 3. 组装内嵌 pr-agent 运行时(首次必跑) +## 3. Assemble the embedded pr-agent runtime (required on first run) -应用默认走 `embedded` 策略:内嵌一份可重定位 CPython + 固定版本 pr-agent。开发态启动前需先组装: +The app defaults to the `embedded` strategy: a bundled relocatable CPython + a pinned pr-agent version. Assemble it before starting in dev mode: ```bash npm --prefix apps/desktop run prepare:pragent ``` -- 产物落在 `apps/desktop/vendor/pragent/`(已 gitignore,约 600+ MB)。 -- 幂等:已组装则跳过;`--force` 强制重建。 -- 平台自动识别:Windows x64 / macOS arm64(详见 [打包与发布](./packaging-release.md))。 -- 此脚本需 **Node 22+**(用到较新内置能力);其余开发命令 Node 20 即可。 +- The output lands in `apps/desktop/vendor/pragent/` (gitignored, ~600+ MB). +- Idempotent: skipped if already assembled; `--force` forces a rebuild. +- Auto-detects the platform: Windows x64 / macOS arm64 (see [Packaging & release](./packaging-release.md)). +- This script needs **Node 22+** (uses newer built-ins); the other dev commands work on Node 20. -## 4. 启动开发态 +## 4. Start dev mode ```bash -npm --prefix apps/desktop run dev # electron-vite dev(HMR) +npm --prefix apps/desktop run dev # electron-vite dev (HMR) ``` -首次启动会自动创建 `~/.code-meeseeks/` 工作目录 + 默认 `config.yaml`。在设置页或直接编辑该文件配置 Bitbucket Server 连接与 LLM Provider。 +The first launch automatically creates the `~/.code-meeseeks/` working directory + a default `config.yaml`. Configure a code platform connection and an LLM provider on the Settings page or by editing that file directly. -> 改动 **main 进程 / workspace 包** 代码后,HMR 不一定生效,需重启 `dev`。renderer 代码热更正常。 +> After changing **main-process / workspace-package** code, HMR may not apply — restart `dev`. Renderer code hot-reloads normally. -## 5. 常用脚本 +## 5. Common scripts -仓库用 Nx 编排,根目录脚本对所有包生效: +The repo is orchestrated with Nx; the root scripts apply to all packages: ```bash npm run build # nx run-many -t build -npm run typecheck # 全包类型检查 -npm run test # vitest(nx run-many -t test) +npm run typecheck # type-check all packages +npm run test # vitest (nx run-many -t test) npm run lint # eslint -npm run format # prettier 写入 -npm run graph # nx 依赖图 +npm run format # prettier write +npm run graph # nx dependency graph ``` -单包可加 `nx` 前缀,例如: +Target a single package with the `nx` prefix, e.g.: ```bash npx nx typecheck @meebox/desktop npx nx test @meebox/poller ``` -## 6. 打包安装包 +## 6. Build installers ```bash -# Windows:出 NSIS 安装包到 apps/desktop/release/ +# Windows: produce an NSIS installer into apps/desktop/release/ npm --prefix apps/desktop run dist -# 仅打 unpacked 目录(更快的烟雾测试,不出安装器) +# Only produce the unpacked directory (a faster smoke test, no installer) npm --prefix apps/desktop run pack ``` -`dist` = `prepare:pragent` + `electron-vite build` + `electron-builder`。 +`dist` = `prepare:pragent` + `electron-vite build` + `electron-builder`. -- **图标**:源在 `assets/icons/`,electron-builder 显式引用(`win.icon` 指向 `icon.ico`)。重新生成 `.ico` 见 [assets/README](../../assets/README.md)。 -- **macOS**:签名 / 公证 / 免费 ad-hoc 路线见 [macOS 构建与发布](./mac-build.md)。 -- **CI 发布**:推 `v*` tag 触发自动出 Windows + macOS(arm64) 包并挂 Release。统一的构建/签名/CI 设计见 [打包与发布](./packaging-release.md)(workflow: [.github/workflows/release.yml](../../.github/workflows/release.yml))。 +- **Icons**: sources in `assets/icons/`, referenced explicitly by electron-builder (`win.icon` points at `icon.ico`). To regenerate the `.ico` see [assets/README](../../assets/README.md). +- **macOS**: signing / notarization / the free ad-hoc route are covered in [macOS build & release](./mac-build.md). +- **CI release**: pushing a `v*` tag triggers automatic Windows + macOS(arm64) builds attached to the Release. The unified build / signing / CI design is in [Packaging & release](./packaging-release.md) (workflow: [.github/workflows/release.yml](../../.github/workflows/release.yml)). -## 7. 仓库结构 +## 7. Repository layout ``` . ├── apps/ -│ └── desktop/ # Electron 应用(main / preload / renderer) -│ ├── scripts/ # 内嵌运行时组装脚本 + sitecustomize shim -│ ├── build-resources/ # 打包资源(entitlements / afterPack 钩子) -│ └── vendor/pragent/ # 内嵌运行时(gitignore,prepare:pragent 生成) +│ └── desktop/ # Electron app (main / preload / renderer) +│ ├── scripts/ # embedded-runtime assembly script + sitecustomize shim +│ ├── build-resources/ # packaging resources (entitlements / afterPack hooks) +│ └── vendor/pragent/ # embedded runtime (gitignored, generated by prepare:pragent) ├── packages/ -│ ├── shared/ # 跨进程共享类型 / IPC 契约 / config schema -│ ├── config/ # 配置加载与校验 -│ ├── logger/ # pino 日志 -│ ├── platform-bitbucket-server/ # Bitbucket Server 平台适配 -│ ├── poller/ # PR 轮询发现 + 草稿池 -│ ├── pr-agent-bridge/ # pr-agent 调用(embedded / local-cli 策略) -│ ├── repo-mirror/ # 仓库镜像(partial clone + diff/blame) -│ ├── rules/ # 规则目录加载与匹配 -│ └── state-store/ # JSON 状态存储(原子写) -├── docs/ # ROADMAP / modules 设计 / development 开发专题 -├── assets/ # 品牌 / 图标资源(LFS) -└── tools/ # 探针等辅助脚本 +│ ├── shared/ # cross-process shared types / IPC contract / config schema +│ ├── config/ # config loading and validation +│ ├── logger/ # pino logging +│ ├── platform-bitbucket-server/ # Bitbucket Server platform adapter +│ ├── poller/ # PR polling discovery + draft pool +│ ├── pr-agent-bridge/ # pr-agent invocation (embedded / local-cli strategies) +│ ├── repo-mirror/ # repo mirroring (partial clone + diff/blame) +│ ├── rules/ # rules directory loading and matching +│ └── state-store/ # JSON state storage (atomic writes) +├── docs/ # ROADMAP / arch design / development track +├── assets/ # brand / icon assets (LFS) +└── tools/ # helper scripts (icon gen, third-party notices, CLI install) ``` -## 8. 数据目录 +## 8. Data directory -应用数据固定在 `~/.code-meeseeks/`(跨 OS 一致): +App data lives at a fixed `~/.code-meeseeks/` (consistent across OSes): ``` ~/.code-meeseeks/ -├── config.yaml # 连接 / LLM / repos_dir 等全部配置(含明文凭据,权限收紧) -├── state/ # PR 元数据 / 评论缓存 / 评审 run / 草稿(per-PR 目录) -├── logs/ # 滚动日志 -└── repos/ # 仓库镜像(默认位置,repos_dir 可改到其它盘) +├── config.yaml # all config: connections / LLM / repos_dir etc. (plaintext credentials, tightened permissions) +├── state/ # PR metadata / comment cache / review runs / drafts (per-PR directories) +├── logs/ # rolling logs +└── repos/ # repo mirrors (default location; repos_dir can point at another disk) ``` -数据模型与容错设计见 [arch/03 状态存储与数据模型](../arch/03-state-storage.md)。 +For the data model and fault-tolerance design see [arch · State storage & data model](../arch/99-core/01-state-storage.md). -## 9. 测试约定 +## 9. Testing conventions -- 新建包的测试放包内 `tests/` 目录,从 `'../src/...'` 引入(旧包遗留在 `src/` 的不强制迁移)。 -- 提交前跑 `npm run typecheck && npm run test`。 +- Put a new package's tests in its `tests/` directory, importing from `'../src/...'` (legacy packages with tests still under `src/` are not forced to migrate). +- Run `npm run typecheck && npm run test` before committing. -## 10. 调试技巧 +## 10. Debugging tips -### 强制进入首启配置向导 +### Force the first-launch onboarding wizard -首启向导只在「没有有效的 active 连接」时出现。已经配好连接后想反复调试向导,用 localStorage 开关(不动配置、可反复切换): +The onboarding wizard only appears when there is "no valid active connection". Once a connection is configured, to debug the wizard repeatedly, use a localStorage flag (doesn't touch config, toggles freely): -应用运行中打开 DevTools(设置页「打开 DevTools」或主进程菜单),在 Console 执行: +While the app is running, open DevTools (Settings page "Open DevTools", or the main-process menu) and run in the Console: ```js localStorage.setItem('meebox.forceOnboarding', '1'); location.reload(); ``` -刷新后即进入向导。走完向导(点「进入应用」)会自动清掉该 flag 回到主界面;也可手动关闭: +After reloading you enter the wizard. Completing it (clicking "Enter the app") automatically clears the flag and returns to the main UI; you can also clear it manually: ```js localStorage.removeItem('meebox.forceOnboarding'); location.reload(); ``` -### 首启向导里打开 DevTools +### Open DevTools inside the onboarding wizard -首启向导没有菜单 / 状态栏入口。欢迎页(第 1 步)**连续点击 logo 7 次**(每次间隔 < 800ms)即可打开 DevTools,用于在向导阶段排障。 +The onboarding wizard has no menu / status-bar entry. On the welcome page (step 1), **click the logo 7 times in a row** (each within < 800 ms) to open DevTools, for troubleshooting during the wizard stage. -### 模拟「发现新版本」状态栏 chip +### Simulate the "update available" status-bar chip -dev 版本通常高于线上 latest,自然不会触发更新提示。在 DevTools Console 模拟以验证状态栏 chip: +A dev build is usually higher than the online latest, so it won't naturally trigger the update prompt. Simulate it in the DevTools Console to verify the status-bar chip: ```js -window.dispatchEvent(new CustomEvent('meebox:debug-update')); // 显示 v9.9.9 -window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: { latestVersion: '1.2.3' } })); // 自定义版本号 -window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: null })); // 清除 +window.dispatchEvent(new CustomEvent('meebox:debug-update')); // shows v9.9.9 +window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: { latestVersion: '1.2.3' } })); // custom version +window.dispatchEvent(new CustomEvent('meebox:debug-update', { detail: null })); // clear ``` diff --git a/docs/development/mac-build.md b/docs/development/mac-build.md index 54aac906..91a74052 100644 --- a/docs/development/mac-build.md +++ b/docs/development/mac-build.md @@ -1,127 +1,128 @@ -# macOS 构建与发布(Code Meeseeks · arm64) +# macOS Build & Release (Code Meeseeks · arm64) -> 状态:**已在 macOS arm64 验证通过**(2026-06,macOS 26.5 / Apple Silicon)—— -> 本机走完 `prepare:pragent` → `electron-vite build` → `electron-builder` 全链路,出 dmg、 -> ad-hoc 签名、装 dmg 后 GUI 正常启动,嵌入式 python 实际 exec + `import pr_agent` 通过。 -> 关联 [pr-agent 集成与运行时](../arch/04-pragent-runtime.md)。 +> Status: **verified on macOS arm64** (2026-06, macOS 26.5 / Apple Silicon) — +> the full `prepare:pragent` → `electron-vite build` → `electron-builder` chain was run locally, producing a dmg, +> ad-hoc signed; after installing the dmg the GUI launches normally and the embedded Python actually execs + +> `import pr_agent` passes. Related: [pr-agent integration & runtime](../arch/02-agent/05-pragent-runtime.md). -作为开源项目,**不申请 Apple Developer ID($99/年)**,走**免费 ad-hoc 路线**: +As an open-source project, we **do not enroll in the Apple Developer ID ($99/yr)** and take the **free ad-hoc route**: -- **本地开发**:在你自己的 mac 上 ad-hoc 出包即可跑。 -- **发布**:GitHub Actions(公开仓库 macOS runner 免费)自动出包、ad-hoc 签名、挂 Release。 -- **代价**:包**未公证**,用户首次打开需手动"仍要打开"(或走 Homebrew)。这是免费路线唯一的体验损失。 +- **Local development**: an ad-hoc build on your own Mac runs fine. +- **Release**: GitHub Actions (macOS runners are free for public repos) builds, ad-hoc signs, and attaches to the Release automatically. +- **Trade-off**: the package is **not notarized**, so first-launch requires the user to manually "Open Anyway" (or use Homebrew). This is the only UX cost of the free route. -> 关键认知:Apple Silicon 上**任何 Mach-O 必须有签名才能运行**——ad-hoc 签名(`codesign -s -`, -> 免费、无需账号)满足"能跑",公证(需 Developer ID + Apple 公证服务)只负责"去掉 Gatekeeper 警告"。 -> 两者独立。我们做前者,跳过后者。 +> Key insight: on Apple Silicon, **any Mach-O must be signed to run** — ad-hoc signing (`codesign -s -`, +> free, no account needed) satisfies "can run", while notarization (needs a Developer ID + Apple's notary service) only +> handles "remove the Gatekeeper warning". The two are independent. We do the former and skip the latter. --- -## 1. 本地开发 +## 1. Local development ```bash -# 仅运行(不签名,dev electron 直接跑) +# Run only (no signing; dev electron runs directly) npm --prefix apps/desktop run dev -# 本地出包测试(在 mac 上):dist 会跑 prepare:pragent + build + electron-builder -# afterPack 钩子自动 ad-hoc 递归签名(含嵌入式 python),arm64 上即可启动 +# Local build test (on a Mac): dist runs prepare:pragent + build + electron-builder +# the afterPack hook auto ad-hoc recursively signs (including the embedded Python), so it launches on arm64 npm --prefix apps/desktop run dist -# 产物:apps/desktop/release/code-meeseeks-<version>-mac-arm64.dmg +# Output: apps/desktop/release/code-meeseeks-<version>-mac-arm64.dmg ``` -`prepare:pragent` 会组装 `aarch64-apple-darwin` 的 CPython + pr-agent 到 -`apps/desktop/vendor/pragent/`(解释器 `python/bin/python3`,main 的 `resolveEmbeddedPython` -已按平台分支)。 +`prepare:pragent` assembles the `aarch64-apple-darwin` CPython + pr-agent into +`apps/desktop/vendor/pragent/` (interpreter at `python/bin/python3`; the main process's `resolveEmbeddedPython` +already branches per platform). -## 2. 发布(GitHub Actions) +## 2. Release (GitHub Actions) -[.github/workflows/release.yml](../../.github/workflows/release.yml):推 `v*` tag 触发,矩阵 -`windows-latest` + `macos-14`(arm64) 各自出包,挂到该 tag 的 Release。 +[.github/workflows/release.yml](../../.github/workflows/release.yml): pushing a `v*` tag triggers a matrix of +`windows-latest` + `macos-14` (arm64), each building its own package and attaching to that tag's Release. ```bash git tag v0.1.0 && git push origin v0.1.0 ``` -mac job 在 arm64 runner 上原生构建,afterPack 做 ad-hoc 签名。**全程不需要 Apple 账号 / 凭据。** +The mac job builds natively on an arm64 runner, with afterPack doing the ad-hoc signing. **No Apple account / credentials needed at any point.** -## 3. ad-hoc 签名机制 +## 3. Ad-hoc signing mechanism -[build-resources/after-pack.cjs](../../apps/desktop/build-resources/after-pack.cjs)(electron-builder -`afterPack` 钩子): +[build-resources/after-pack.cjs](../../apps/desktop/build-resources/after-pack.cjs) (the electron-builder +`afterPack` hook): -- 仅 mac 动作;win/linux 跳过。 -- 无 Apple 凭据 env → 对 `.app` 递归 `codesign --force --deep --sign -`(ad-hoc)。 -- 有凭据 env → 跳过,交回 electron-builder 走正式签名 + 公证(见 §6)。 +- Mac-only action; skipped on win/linux. +- No Apple credential env → recursively `codesign --force --deep --sign -` (ad-hoc) the `.app`. +- With credential env → skipped, handing back to electron-builder for proper signing + notarization (see §6). -## 4. 嵌入式 Python 签名(已验证:`--deep` 足够) +## 4. Embedded Python signing (verified: `--deep` is sufficient) -`vendor/pragent` 经 `extraResources` 进 `<App>.app/Contents/Resources/pragent/`,内含 -python 二进制 + 上千个 `.dylib/.so`。担心点是 `codesign --deep` 是否真能递归签到所有 -Resources 下的散装 Mach-O(漏签 → 运行时 `code signature invalid` / python 子进程崩)。 +`vendor/pragent` goes into `<App>.app/Contents/Resources/pragent/` via `extraResources`, containing the +Python binary + thousands of `.dylib/.so`. The concern was whether `codesign --deep` really recurses into every +loose Mach-O under Resources (a missed signature → runtime `code signature invalid` / the Python subprocess crashes). -**arm64 实测结论:`after-pack.cjs` 的 `--force --deep --sign -` 已完整覆盖。** 验证: -对 `Contents/Resources/pragent` 下全部 Mach-O(python3.12 解释器 + 散装 `.so/.dylib`, -本次 43 个)逐个 `codesign --verify --strict` → 0 失败;包内嵌入式 python 直接 exec + -`import pr_agent` → exit 0。**无需 sweep 补签**,保持现配置。 +**Empirical conclusion on arm64: `after-pack.cjs`'s `--force --deep --sign -` covers everything.** Verified by: +running `codesign --verify --strict` on every Mach-O under `Contents/Resources/pragent` (the python3.12 interpreter + +loose `.so/.dylib`, 43 this time) → 0 failures; and the bundled embedded Python execs + +`import pr_agent` → exit 0. **No sweep re-signing needed** — keep the current config. -> 兜底(若将来某发布遇到个别 `.so` 漏签):在 `after-pack.cjs` 改成"先 sweep 后整签"—— -> 遍历 `Contents/Resources/pragent` 下所有 Mach-O 逐个 `codesign --force --sign -`,再签整个 -> `.app`。目前不需要。 +> Fallback (if a future release hits a missed `.so` signature): change `after-pack.cjs` to "sweep first, then sign the whole" — +> iterate over every Mach-O under `Contents/Resources/pragent`, `codesign --force --sign -` each, then sign the whole +> `.app`. Not needed today. -## 5. 用户首次打开(未公证 → 绕过 Gatekeeper) +## 5. First launch for users (not notarized → bypass Gatekeeper) -Release 说明里需写明(任选其一): +State this in the Release notes (any one of): -- **右键 → 打开 → 仍要打开**(首次);或 -- **系统设置 → 隐私与安全性 → 仍要打开**(macOS Sequoia 起右键方式部分场景失效,走这里);或 -- 终端去隔离属性: +- **Right-click → Open → Open Anyway** (first time); or +- **System Settings → Privacy & Security → Open Anyway** (since macOS Sequoia the right-click path fails in some cases — use this); or +- Remove the quarantine attribute in a terminal: ```bash xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app" ``` -**Homebrew Cask**(面向技术用户,体验更顺):发一个 cask 指向 GitHub Release 的 dmg, -`brew install --cask code-meeseeks` 安装时自动处理隔离属性。后续可加。 +**Homebrew Cask** (for technical users, a smoother experience): publish a cask pointing at the GitHub Release dmg; +`brew install --cask code-meeseeks` handles the quarantine attribute automatically on install. Can be added later. -## 6. 升级到公证(将来若申请 Developer ID,可选) +## 6. Upgrading to notarization (optional, if a Developer ID is obtained later) -workflow **无需改结构**,只需: +The workflow **needs no structural change**, only: -1. 仓库配 secrets:`MAC_CSC_LINK`(证书 .p12 base64) / `MAC_CSC_KEY_PASSWORD` / - `APPLE_API_KEY` / `APPLE_API_KEY_ID` / `APPLE_API_ISSUER`。 -2. electron-builder.yml `mac:` 加回 `hardenedRuntime: true` + `entitlements` / - `entitlementsInherit: build-resources/entitlements.mac.plist` + `notarize: true`。 - ([entitlements.mac.plist](../../apps/desktop/build-resources/entitlements.mac.plist) 已备好: - `disable-library-validation` 让嵌入式 python 在 hardened runtime 下能加载第三方 dylib。) +1. Configure repo secrets: `MAC_CSC_LINK` (cert .p12, base64) / `MAC_CSC_KEY_PASSWORD` / + `APPLE_API_KEY` / `APPLE_API_KEY_ID` / `APPLE_API_ISSUER`. +2. In electron-builder.yml `mac:`, add back `hardenedRuntime: true` + `entitlements` / + `entitlementsInherit: build-resources/entitlements.mac.plist` + `notarize: true`. + ([entitlements.mac.plist](../../apps/desktop/build-resources/entitlements.mac.plist) is ready: + `disable-library-validation` lets the embedded Python load third-party dylibs under the hardened runtime.) -afterPack 检测到凭据 env 会自动让位,electron-builder 接管正式签名 + 公证,产出双击即开的 dmg。 +When afterPack detects the credential env it steps aside automatically, electron-builder takes over proper signing + +notarization, and the result is a double-click-to-open dmg. -## 7. 验证清单(mac 上) +## 7. Verification checklist (on a Mac) ```bash APP="apps/desktop/release/mac-arm64/Code Meeseeks.app" -codesign -dv --verbose=4 "$APP" # ad-hoc 路线:Signature=adhoc -codesign --verify --deep --strict "$APP" # 递归校验(含嵌入 python)通过 +codesign -dv --verbose=4 "$APP" # ad-hoc route: Signature=adhoc +codesign --verify --deep --strict "$APP" # recursive verification (incl. embedded Python) passes ``` -装 dmg → 首次按 §5 绕过 → 启动应用: -- 窗口/Dock 显示新图标。 -- 状态栏 `PR Agent: <ver>`(embedded 绿)。 -- 跑一次 `/review` → 确认嵌入式 python 子进程**没崩**(这是 ad-hoc 签名是否覆盖到 python 的真正判据)。 +Install the dmg → bypass on first launch per §5 → start the app: +- The new icon shows in the window/Dock. +- The status bar shows `PR Agent: <ver>` (embedded, green). +- Run one `/review` → confirm the embedded Python subprocess **does not crash** (the real test of whether ad-hoc signing reached Python). -## 8. 风险 / 待办 +## 8. Risks / TODO -- 嵌入式运行时体积大 → 签名耗时;CI 上 mac runner 对公开仓库免费,私有仓库分钟数贵 10x。 -- ~~嵌入式 python 是否需要 sweep 补签~~ —— **已验证 `--deep` 足够,无需 sweep**(§4)。 -- 未公证 → 依赖用户绕过 / Homebrew,对非技术用户有门槛(§5)。 -- Intel(x64) 暂不出(仅 arm64);需要时 electron-builder.yml mac.arch 加 x64。 +- The embedded runtime is large → signing takes time; mac runners are free for public repos, ~10x more expensive in minutes for private ones. +- ~~Whether the embedded Python needs sweep re-signing~~ — **verified `--deep` is sufficient, no sweep needed** (§4). +- Not notarized → relies on the user bypassing / Homebrew, a barrier for non-technical users (§5). +- Intel (x64) not shipped for now (arm64 only); add `x64` to electron-builder.yml `mac.arch` when needed. -### 本地复现踩坑(仅本机,CI 不受影响) +### Local-repro gotchas (local only, CI unaffected) -CI 用 `actions/checkout` 的 `lfs: true` + GitHub runner 网络,下列两点不会触发;在自己 mac -上手动 `electron-builder` 时可能遇到: +CI uses `actions/checkout` with `lfs: true` + the GitHub runner network, so the two below don't trigger there; you may hit them +when running `electron-builder` manually on your own Mac: -- **图标 LFS 指针**:`assets/icons/icon.png` 走 Git LFS。本机若没装 git-lfs,checkout 得到的是 - 131 字节指针文件 → electron-builder 转图标 `LoadImage` 崩。解:`brew install git-lfs && git lfs pull`。 -- **pip 连 pypi 超时**:`prepare:pragent` 用嵌入式解释器 `pip install pr-agent`,pip 默认 15s - 超时,内网/慢网下 `aiohttp` 等会报 `from versions: none`(实为连不上索引,非版本不存在)。 - 解:`PIP_DEFAULT_TIMEOUT=120 npm run prepare:pragent`(或配 pip 镜像)。 +- **Icon LFS pointer**: `assets/icons/icon.png` is a Git LFS asset. Without git-lfs installed locally, checkout yields a + 131-byte pointer file → electron-builder's icon conversion `LoadImage` crashes. Fix: `brew install git-lfs && git lfs pull`. +- **pip pypi timeout**: `prepare:pragent` uses the embedded interpreter to `pip install pr-agent`; pip's default 15s + timeout means `aiohttp` etc. report `from versions: none` on an intranet/slow network (actually "can't reach the index", + not "version doesn't exist"). Fix: `PIP_DEFAULT_TIMEOUT=120 npm run prepare:pragent` (or configure a pip mirror). diff --git a/docs/development/packaging-release.md b/docs/development/packaging-release.md index 092ce693..2a4f4416 100644 --- a/docs/development/packaging-release.md +++ b/docs/development/packaging-release.md @@ -1,67 +1,67 @@ -# 打包与发布(构建 / 签名 / CI) +# Packaging & Release (Build / Signing / CI) -## 职责与边界 +## Responsibility & scope -把应用 + 嵌入式运行时打成各平台安装包并发布。覆盖 electron-builder 配置、嵌入式运行时随包、 -代码签名策略、图标、CI 发布流程。 +Package the app + embedded runtime into per-platform installers and publish them. Covers electron-builder config, bundling the embedded runtime, the code-signing strategy, icons, and the CI release flow. -负责:构建/打包/签名/出包/CI。不负责:嵌入式运行时怎么组装(见 [pr-agent 集成与运行时](../arch/04-pragent-runtime.md))。macOS 签名细节另见 [macOS 构建与发布](./mac-build.md)。 +In scope: build / package / sign / produce artifacts / CI. Out of scope: how the embedded runtime is assembled (see [pr-agent integration & runtime](../arch/02-agent/05-pragent-runtime.md)). macOS signing details are in [macOS build & release](./mac-build.md). -## 核心设计 +## Core design -- **构建链**:`prepare:pragent`(组装嵌入式运行时)→ electron-vite `build`(main/preload/renderer)→ - electron-builder 出包。 -- **嵌入式运行时随包**:`vendor/pragent`(CPython + pinned pr-agent)经 electron-builder 的 `extraResources` - 落在 **asar 之外**(原生解释器 + `.so/.pyd` 必须是真实文件,不能进 asar);`__pycache__` 排除瘦身。 - 打包平台与目标平台一致(由构建机宿主组装)。 -- **目标产物**:Windows → NSIS x64;macOS → dmg arm64。发布只聚焦 **Windows x64 + macOS arm64**; - **Linux 暂不计划**,Intel / win arm64 视需求后续。(electron-builder 配置里即便保留 linux 段也不在发布范围。) -- **macOS 免费签名路线(ad-hoc)**:不申请 Apple Developer ID($99/年)。afterPack 钩子对 `.app` 做 - **ad-hoc 递归签名**(`codesign --deep --sign -`)——Apple Silicon 上任何 Mach-O 必须有签名才能运行 - (含嵌入式 python 的上千个 `.so/.dylib`,实测 `--deep` 已完整覆盖,无需逐个补签)。**代价**:不公证, - 用户首次打开需手动「仍要打开」(或走 Homebrew)。**升级公证**:仓库配齐 Apple 签名 secrets 后,afterPack - 检测到凭据会自动让位给 electron-builder 的正式签名 + 公证,workflow 结构不变。 -- **图标按平台分**:Windows 用 `.ico`;macOS 用**专用深色圆角图标**——满铺透明 glyph 在 macOS(尤其新系统) - 会被套圆角并垫白底,故单出一张「深色 squircle + 留边 glyph」给 mac;图标源走 Git LFS。 -- **CI 发布**:推 `v*` tag 触发,矩阵 `windows-latest` + `macos-14`(arm64) 各自原生出包并挂到该 tag 的 Release; - checkout 必须 `lfs: true`(否则图标是 LFS 指针 → 转图标崩)。 +- **Build chain**: `prepare:pragent` (assemble the embedded runtime) → electron-vite `build` (main/preload/renderer) → + electron-builder produces the package. +- **Embedded runtime bundled**: `vendor/pragent` (CPython + pinned pr-agent) goes **outside the asar** via electron-builder's `extraResources` + (the native interpreter + `.so/.pyd` must be real files, not inside the asar); `__pycache__` is excluded to slim it down. + The packaging platform matches the target platform (assembled by the build host). +- **Target artifacts**: Windows → NSIS x64; macOS → dmg arm64. The release focuses on **Windows x64 + macOS arm64** only; + **Linux is not planned**, Intel / win arm64 are follow-ups if needed. (Even if electron-builder config keeps a linux section, it's out of release scope.) +- **macOS free signing route (ad-hoc)**: no Apple Developer ID ($99/yr). The afterPack hook does + **ad-hoc recursive signing** of the `.app` (`codesign --deep --sign -`) — on Apple Silicon any Mach-O must be signed to run + (including the embedded Python's thousands of `.so/.dylib`; empirically `--deep` covers everything, no per-file re-signing needed). **Trade-off**: not notarized, so + first launch requires the user to manually "Open Anyway" (or use Homebrew). **Upgrade to notarization**: once the repo has the Apple signing secrets, afterPack + detects the credentials and steps aside to electron-builder's proper signing + notarization; the workflow structure is unchanged. +- **Per-platform icons**: Windows uses `.ico`; macOS uses a **dedicated dark rounded icon** — a full-bleed transparent glyph on macOS (especially newer systems) + gets a rounded mask and a white backing, so we ship a separate "dark squircle + inset glyph" for mac; icon sources go through Git LFS. +- **CI release**: pushing a `v*` tag triggers a matrix of `windows-latest` + `macos-14` (arm64), each building natively and attaching to that tag's Release; + checkout must use `lfs: true` (otherwise the icon is an LFS pointer → icon conversion crashes). -## 数据 / 接口契约 +## Data / interface contract -- **触发**:push tag `v*`(或手动 workflow_dispatch)。 -- **产物命名**:`code-meeseeks-<version>-{win-x64.exe | mac-arm64.dmg}`。 -- **可选签名凭据**(配齐则自动转正式签名+公证):证书 .p12(base64)+ 密码、App Store Connect API key 等 - 以仓库 secrets 注入;缺失则走 ad-hoc。 +- **Trigger**: push a `v*` tag (or manual workflow_dispatch). +- **Artifact naming**: `code-meeseeks-<version>-{win-x64.exe | mac-arm64.dmg}`. +- **Optional signing credentials** (if complete, auto-switches to proper signing + notarization): cert .p12 (base64) + password, App Store Connect API key, etc. + injected as repo secrets; missing → ad-hoc route. -## 扩展与注意事项 +## Extensions & notes -- **本地复现坑**(CI 不受影响,因为用 `lfs:true` + 干净环境):本机没装 git-lfs → 图标是指针 → 转图标崩 - (`brew install git-lfs && git lfs pull`);`prepare:pragent` 的 pip 弱网超时 → `PIP_DEFAULT_TIMEOUT=120` 或配镜像。 -- **嵌入式运行时体积大**→ 签名耗时;公开仓库 macOS runner 免费,私有仓库分钟数贵。 -- **首次打开(未公证)**:Release 说明需写明绕过方式(右键打开 / 系统设置允许 / `xattr -dr com.apple.quarantine`), - 或提供 Homebrew Cask。 -- **升级公证**:见上;同时 mac 段需加回 hardenedRuntime + entitlements + notarize(entitlements 已备好 - `disable-library-validation` 让嵌入式 python 在 hardened runtime 下能加载第三方 dylib)。 +- **Local-repro gotchas** (CI unaffected, since it uses `lfs:true` + a clean env): git-lfs not installed locally → the icon is a pointer → icon conversion crashes + (`brew install git-lfs && git lfs pull`); `prepare:pragent`'s pip times out on a weak network → `PIP_DEFAULT_TIMEOUT=120` or configure a mirror. +- **The embedded runtime is large** → signing takes time; macOS runners are free for public repos, more expensive in minutes for private ones. +- **First launch (not notarized)**: the Release notes must state the bypass (right-click Open / allow in System Settings / `xattr -dr com.apple.quarantine`), + or provide a Homebrew Cask. +- **Upgrade to notarization**: see above; the mac section also needs hardenedRuntime + entitlements + notarize added back (entitlements are ready with + `disable-library-validation` so the embedded Python can load third-party dylibs under the hardened runtime). -## 发布前置清单(打 tag 前必做) +## Pre-release checklist (mandatory before tagging) -在**同一批改动**里完成,随发版经 `dev` → `master`——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: +Complete these **in the same batch of changes**, flowing through `dev` → `master` with the release — miss any step and CI won't error (only `::warning::`) but will produce a wrong Release: -1. **版本号** —— 把 [apps/desktop/package.json](../../apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直取此值——不改则安装包文件名与 tag 不符。改完 `npm install` 同步 lockfile。 -2. **CHANGELOG** —— 把 [CHANGELOG.md](../../CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - <YYYY-MM-DD>`,并在文件底部补 `[<版本>]: …/compare/…` 链接引用。**发布即消费掉 Unreleased、不留空段**;下一笔开发期 changelog 改动时再新建。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文——缺段则正文回退、无变更说明。**若正式版内容来自此前的 alpha/预发布**:开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应 `[<x>-alpha.N]:` 链接引用(内容并入正式版段,不留空壳 stub);尚无对应正式版的其它预发布段保留。 -3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 +1. **Version** — set the `version` in [apps/desktop/package.json](../../apps/desktop/package.json) to the target version (drop the `v` prefix; prereleases carry a suffix like `0.5.0-alpha.1`). electron-builder's `artifactName: code-meeseeks-${version}-...` reads this value directly — not updating it means the installer filename won't match the tag. After the change, run `npm install` to sync the lockfile. +2. **CHANGELOG** — the changelog is **bilingual, two files**: [CHANGELOG.md](../../CHANGELOG.md) is the English canonical and [CHANGELOG.zh-CN.md](../../CHANGELOG.zh-CN.md) is the Chinese mirror (**keep both in sync**). In **each** file, rename `## [Unreleased]` to `## [<version>] - <YYYY-MM-DD>` and add a `[<version>]: …/compare/…` link reference at the bottom. **Releasing consumes Unreleased — leave no empty section**; create a new one (in both files) on the next development-phase changelog change. release.yml extracts the `## [<version>]` section **from the English canonical** (CHANGELOG.md) literally to inject into the Release body — the GitHub Release page is **single-language English**, and RELEASE_NOTES.md **deep-links to this version on the bilingual [website changelog](https://huhamhire.github.io/code-meeseeks/changelog)** (built from both files, where readers switch language) for other languages; a missing section means the body falls back with no change notes. The deep-link is a stable, date-independent anchor `changelog#v<version with dots → hyphens>` (e.g. `0.10.0` → `#v0-10-0`): [sync-docs.mjs](../../website/scripts/sync-docs.mjs) injects that `{#…}` anchor onto each version heading in the site copy (GitHub's CHANGELOG stays clean), and release.yml builds the same slug from the tag to fill RELEASE_NOTES.md's `%%CHANGELOG_URL%%` placeholder — so the two always agree without hand-maintenance. **If the stable release's content comes from a prior alpha/prerelease**: the development phase usually has no separate Unreleased (the content is already in the prerelease section), so just rename that prerelease section to the stable-version section and remove the corresponding `[<x>-alpha.N]:` link reference (the content merges into the stable section, no empty stub left); other prerelease sections with no corresponding stable version are kept. +3. **Proofread** — confirm the `## [<version>]` section covers every key point (Added / Changed / Fixed) merged into `dev` since the last version, **in both language files** (the English canonical and the Chinese mirror must not drift). -tag 名与 package.json 版本必须一致(`v<版本>`)。名含 `-` 的预发布 tag(如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 +The tag name and the package.json version must match (`v<version>`). A prerelease tag with a `-` in the name (e.g. `-alpha.N`) is automatically marked prerelease by release.yml and does not claim Latest. -**版本号规则(`-dev`)**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](../../apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,`npm install` 同步 lockfile),标记开发态。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面改成目标号(`0.7.0-alpha.N` 或 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](../../apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range)与构建。 +**Version-number rule (`-dev`)**: right after each stable release, `dev` immediately bumps [apps/desktop/package.json](../../apps/desktop/package.json) to **the next version's `-dev` prerelease number** (e.g. after shipping `0.6.0`, switch to `0.7.0-dev`, and `npm install` to sync the lockfile), marking the development state. `-dev` is a development marker only — **not tagged, not released**; at release time change it to the target number (`0.7.0-alpha.N` or `0.7.0`) per above. `-dev` is valid semver (`0.6.0` < `0.7.0-dev` < `0.7.0`), so it doesn't affect update checking ([update-check.ts](../../apps/desktop/src/main/utils/update-check.ts) compares with `semver.gt`, not a range) or the build. -## CHANGELOG 撰写风格(面向用户、求简) +## CHANGELOG writing style (user-facing, concise) -- 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**; -- 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止; -- 重构类任务**前后端合并**为一条总结、不展开实现细节; -- 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响; -- 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良; -- **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险; -- **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security); -- 外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 +- **Bilingual, two files, English canonical**: write each entry in both [CHANGELOG.md](../../CHANGELOG.md) (English canonical / fallback) and [CHANGELOG.zh-CN.md](../../CHANGELOG.zh-CN.md) (Chinese mirror); they must stay in sync (same versions, same points). The style points below apply to both; +- The version intro `>` block goes straight into "highlights of this release", with points laid out as a **bulleted list** rather than piled into long sentences, and **no version-ordinal intros like "the first / Nth stable release"**; +- Added is categorized by **feature scenario** using an indented nested list, each point one sentence to the point; +- Refactor-type work is **merged front-and-back-end** into a single summary, without expanding implementation detail; +- Fixed **omits the "how it was fixed" mechanism**, each item one sentence stating only the symptom/impact fixed; +- Throughout, no IPC channel names, function names, file paths, or field names or other implementation details — foreground new features and improvements; +- **Install / upgrade notes** (the ⚠️ warnings in the version intro, e.g. uninstall the old version first, per-machine elevation, etc.) are security-critical and are **kept in full, not subject to trimming** — these get injected into the GitHub Release body by release.yml, and trimming them would let users miss upgrade risks; +- **Section headings carry an emoji, per language**: the English canonical uses `### ✨ Added / ♻️ Changed / 🔧 Fixed / 🗑️ Removed / 🔒 Security`, the Chinese mirror uses `### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全` (Keep a Changelog's Added / Changed / Deprecated / Removed / Fixed / Security); +- Habitually credit external contributors' PRs (like `(#65, thanks @user)`). diff --git a/docs/guide/00-getting-started.md b/docs/guide/00-getting-started.md index ac10afba..9324f7f3 100644 --- a/docs/guide/00-getting-started.md +++ b/docs/guide/00-getting-started.md @@ -1,37 +1,39 @@ -# 安装与首次使用 +# Installation & First Use -## 系统要求 +**English** · [简体中文](zh-CN/00-getting-started.md) -- **操作系统**:Windows 10 / 11(x64)或 macOS(Apple Silicon / arm64)。当前不提供 Intel Mac 与 Linux 安装包。 -- **git**:本机需已安装 git,并在 PATH 中。客户端克隆仓库、读取本地 diff 都依赖系统 git。 -- 评审与生成内容需要一个可用的 LLM(见 [LLM 配置](02-llm.md));嵌入式运行时已随应用打包,无需另装 Python 或 Docker。 +## System requirements -## 安装 +- **Operating system**: Windows 10 / 11 (x64) or macOS (Apple Silicon / arm64). Intel Mac and Linux installers are not currently provided. +- **git**: git must be installed on your machine and on your PATH. The client relies on system git to clone repositories and read local diffs. +- Reviewing and generating content requires a working LLM (see [LLM setup](02-llm.md)); the embedded runtime is bundled with the app, so no separate Python or Docker install is needed. -从项目的 GitHub Releases 页面下载对应平台的安装包: +## Installation -- **Windows**:`code-meeseeks-<版本>-win-x64.exe`(NSIS 安装程序),双击按提示安装。 -- **macOS**:`code-meeseeks-<版本>-mac-arm64.dmg`,打开后将应用拖入「应用程序」。 +Download the installer for your platform from the project's GitHub Releases page: -### macOS 首次打开 +- **Windows**: `code-meeseeks-<version>-win-x64.exe` (NSIS installer) — double-click and follow the prompts. +- **macOS**: `code-meeseeks-<version>-mac-arm64.dmg` — open it and drag the app into "Applications". -当前 macOS 包为 ad-hoc 签名、未做公证,首次打开会被系统拦下。任选一种方式放行: +### First launch on macOS -- 在「应用程序」中**右键点击应用 → 打开 → 仍要打开**; -- 或 **系统设置 → 隐私与安全性**,在拦截提示处点「仍要打开」; -- 或在终端执行 `xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app"`。 +The current macOS package is ad-hoc signed and not notarized, so the first launch is blocked by the system. Allow it with any one of these: -## 首次使用 +- In "Applications", **right-click the app → Open → Open anyway**; +- Or **System Settings → Privacy & Security**, and click "Open Anyway" at the block prompt; +- Or run in a terminal: `xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app"`. -首次启动会自动创建数据目录并打开**配置向导**,按引导最快进入可用状态: +## First use -1. 配置一条**代码平台连接**——详见 [代码平台配置](01-code-platform.md)。 -2. (可选)配置 **LLM**——详见 [LLM 配置](02-llm.md)。不配也能浏览 PR,但 `/describe`、`/review` 需要可用的 LLM。 +The first launch automatically creates the data directory and opens the **setup wizard**, guiding you to a working state as quickly as possible: -完成向导后,客户端开始轮询,列出待你评审的 PR。 +1. Configure a **code platform connection** — see [Code platform setup](01-code-platform.md). +2. (Optional) Configure an **LLM** — see [LLM setup](02-llm.md). You can browse PRs without one, but `/describe` and `/review` require a working LLM. -## 接下来 +Once the wizard is done, the client starts polling and lists the PRs awaiting your review. -- 选中一个 PR:查看 diff,运行 `/describe`、`/review`,并进行评论 / 审批 / 合并。 -- 处于内网 / 受限网络:先配置[网络代理](03-proxy.md)。 -- 已有 Claude / Codex 等订阅:可用[本地 CLI 模式](02-llm.md#本地-cli-模式)以本机登录态执行评审。 +## Next steps + +- Select a PR: view the diff, run `/describe` and `/review`, and comment / approve / merge. +- On an intranet / restricted network: configure the [network proxy](03-proxy.md) first. +- Already have a Claude / Codex subscription: use [local CLI mode](02-llm.md#local-cli-mode) to run reviews under your machine's login session. diff --git a/docs/guide/01-code-platform.md b/docs/guide/01-code-platform.md index eeeb337d..2e62f710 100644 --- a/docs/guide/01-code-platform.md +++ b/docs/guide/01-code-platform.md @@ -1,187 +1,189 @@ -# 代码平台配置 +# Code Platform Setup -接入你的代码托管平台,客户端才能发现待评审的 PR、读 diff、发评论 / 审批 / 合并。目前支持: +**English** · [简体中文](zh-CN/01-code-platform.md) -- **GitHub**(github.com 与 GitHub Enterprise Server) -- **Bitbucket Server / Data Center**(REST API v1,≥ 7.0) -- **GitLab**(gitlab.com 与 Self-Managed,CE / EE,REST API v4,≥ 13.8,推荐 15.6+) +Connect your code-hosting platform so the client can discover PRs awaiting review, read diffs, and post comments / approve / merge. Currently supported: -## 添加连接 +- **GitHub** (github.com and GitHub Enterprise Server) +- **Bitbucket Server / Data Center** (REST API v1, ≥ 7.0) +- **GitLab** (gitlab.com and Self-Managed, CE / EE, REST API v4, ≥ 13.8, 15.6+ recommended) -在 **设置 → 连接**(或首启向导)新建一条连接,填写: +## Adding a connection -| 字段 | 说明 | +Under **Settings → Connections** (or the first-launch wizard), create a connection and fill in: + +| Field | Description | | --- | --- | -| 显示名 | 给人看的名字,可任意取 | -| Base URL | 平台 API 地址,见下方各平台说明 | -| 访问令牌(PAT) | 平台生成的 Personal Access Token,用于 REST API 鉴权 | -| Clone 协议 | `pat`(默认,HTTPS 内嵌令牌克隆)或 `ssh`(走系统 `~/.ssh/config`) | +| Display name | A human-readable name, pick anything | +| Base URL | The platform API address, see per-platform notes below | +| Access token (PAT) | A Personal Access Token issued by the platform, used for REST API auth | +| Clone protocol | `pat` (default, HTTPS clone with the token embedded) or `ssh` (uses the system `~/.ssh/config`) | -> 连接可配置多条,但**同时只启用一条**进行轮询;按 id 查历史 PR 不受影响。 -> 建议按最小授权配置访问令牌。连接保存后可点「测试」验证连通。 +> You can configure multiple connections, but **only one is active for polling at a time**; looking up historical PRs by id is unaffected. +> Configure access tokens with least privilege. After saving a connection, click "Test" to verify connectivity. -## Clone 协议 +## Clone protocol -- **pat(默认)**:克隆走 HTTPS,URL 里内嵌令牌,无需额外配置。 -- **ssh**:克隆走 `git@host:...`,端口 / 密钥由系统 `~/.ssh/config` 决定,**与 PAT 无关**(PAT 仅用于 REST API)。GHE / Bitbucket 自定义 SSH 端口(如 Bitbucket 默认 7999)需在 ssh config 里配好。 +- **pat (default)**: clone over HTTPS with the token embedded in the URL, no extra configuration needed. +- **ssh**: clone over `git@host:...`, with port / key determined by the system `~/.ssh/config`, **independent of the PAT** (the PAT is only for the REST API). A custom SSH port on GHE / Bitbucket (e.g. Bitbucket's default 7999) must be configured in your ssh config. -## 平台能力对比 +## Platform capability comparison -各平台原生支持的能力不同,客户端按**活动连接的平台**动态调整界面——不支持的操作对应的按钮或标签不渲染、或灰显不可用。下表汇总主要差异(✅ 支持 / ❌ 不支持)。 +Different platforms natively support different capabilities, and the client adjusts the UI dynamically based on **the active connection's platform** — buttons or labels for unsupported operations are not rendered, or are shown disabled. The table below summarizes the main differences (✅ supported / ❌ not supported). -| 分类 | 能力 | GitHub | Bitbucket | GitLab | +| Category | Capability | GitHub | Bitbucket | GitLab | | --- | --- | :---: | :---: | :---: | -| 发现过滤 | 待我评审(请求我评审的 PR / MR) | ✅ | ✅ | ✅ | -| 发现过滤 | 我创建的(我作为作者的 PR / MR) | ✅ | ✅ | ✅ | -| 发现过滤 | 指派给我 | ✅ | ❌ | ✅ | -| 发现过滤 | 提及我(正文 / 评论 @ 我) | ✅ | ❌ | ❌ | -| 评论 | 发 / 回复 / 编辑 / 删除评论 | ✅ | ✅ | ✅ | -| 评论 | 行内评论 | ✅ | ✅ | ✅¹ | -| 审批 | 通过 | ✅ | ✅ | ✅² | -| 审批 | 需修改 | ✅ | ✅ | ❌ | -| 审批 | 撤销 | ✅ | ✅ | ✅² | -| 合并 | 合并 PR / MR | ✅ | ✅ | ✅ | -| 合并 | 合并受阻原因展示 | ✅³ | ✅ | ✅ | - -> 侧栏「发现分类」标签按平台能力显示,不支持的分类不渲染对应标签。 +| Discovery filter | Awaiting my review (PRs / MRs that requested my review) | ✅ | ✅ | ✅ | +| Discovery filter | Authored by me (PRs / MRs where I'm the author) | ✅ | ✅ | ✅ | +| Discovery filter | Assigned to me | ✅ | ❌ | ✅ | +| Discovery filter | Mentions me (@ me in body / comments) | ✅ | ❌ | ❌ | +| Comments | Post / reply / edit / delete comments | ✅ | ✅ | ✅ | +| Comments | Inline comments | ✅ | ✅ | ✅¹ | +| Approval | Approve | ✅ | ✅ | ✅² | +| Approval | Needs work | ✅ | ✅ | ❌ | +| Approval | Dismiss | ✅ | ✅ | ✅² | +| Merge | Merge PR / MR | ✅ | ✅ | ✅ | +| Merge | Show reasons merge is blocked | ✅³ | ✅ | ✅ | + +> The sidebar's "discovery category" tabs are shown per platform capability; unsupported categories don't render a tab. > -> - ¹ GitLab 的行内评论仅支持单行选区,GitHub / Bitbucket 支持跨多行。 -> - ² GitLab 审批为 **EE(Premium / Ultimate)** 功能,社区版(CE)灰显不可用;且 GitLab 审批是二元的——**仅「通过 / 撤销」,无「需修改」**。详见 [3.2 CE / EE 审批差异](#32-ce--ee-审批差异)。 -> - ³ GitHub 仅给出近似的可合并状态,Bitbucket / GitLab 展示精确的受阻原因(冲突 / 待审批 / 流水线未过等)。 +> - ¹ GitLab's inline comments support a single-line selection only, while GitHub / Bitbucket support multi-line. +> - ² GitLab approval is an **EE (Premium / Ultimate)** feature, disabled on the community edition (CE); and GitLab approval is binary — **only "Approve / Dismiss", no "Needs work"**. See [3.2 CE / EE approval differences](#32-ce--ee-approval-differences). +> - ³ GitHub gives only an approximate mergeable status, while Bitbucket / GitLab show the precise blocking reason (conflicts / pending approval / pipeline not passed, etc.). --- -## 一、GitHub:Personal Access Token 权限参考 +## 1. GitHub: Personal Access Token permission reference -接入 GitHub(github.com 或 GitHub Enterprise Server)需要一个 **Personal Access Token (PAT)**。本节给出最小权限集。 +Connecting to GitHub (github.com or GitHub Enterprise Server) requires a **Personal Access Token (PAT)**. This section gives the minimal permission set. -> 连接里的 **Base URL**:github.com **留空即可**(默认 `https://api.github.com`);GitHub Enterprise Server 填**实例地址**即可,如 `https://<你的 GHE 域名>`——`/api/v3` 会自动补全(手填完整 API base 也兼容)。 +> The **Base URL** in the connection: for github.com **leave it empty** (defaults to `https://api.github.com`); for GitHub Enterprise Server, just enter the **instance address**, e.g. `https://<your-ghe-domain>` — `/api/v3` is appended automatically (entering a full API base by hand also works). -### 1.1 经典 Token(Classic PAT)— 推荐 +### 1.1 Classic Token (Classic PAT) — recommended -本客户端会**跨项目 / 跨仓库**轮询发现待评审的 PR,覆盖范围通常不固定。经典 token 按 scope 授权、自动覆盖你有权限的全部仓库,最契合这种用法,是本客户端的推荐方式。 +This client polls **across projects / repos** to discover PRs awaiting review, and the coverage is usually not fixed. A classic token is authorized by scope and automatically covers every repo you have access to, which best fits this usage — it's the recommended approach for this client. -适用 github.com 与 GHE Server。创建:**Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token**。 +Works with both github.com and GHE Server. Create it under: **Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token**. -勾选的 scope: +Scopes to check: -| Scope | 用途 | 何时需要 | +| Scope | Purpose | When needed | | --- | --- | --- | -| `repo` | 私有仓库的 PR 读写、评论、审批、合并、克隆 | 评审**私有**仓库时(最常见) | -| `public_repo` | 仅公开仓库的上述操作 | 只评审**公开**仓库时(替代 `repo`) | -| `read:user` | 读取当前用户信息(`/user`) | 可选;多数情况无需单独勾也能用 | +| `repo` | PR read/write, comments, approval, merge, and clone for private repos | When reviewing **private** repos (most common) | +| `public_repo` | The same operations for public repos only | When reviewing **public** repos only (in place of `repo`) | +| `read:user` | Read current user info (`/user`) | Optional; in most cases you don't need to check it separately | -**评审私有仓库 → 勾 `repo` 即可**;只评审公开仓库 → 勾 `public_repo`。 +**Reviewing private repos → check `repo`**; reviewing public repos only → check `public_repo`. -> **组织开启了 SAML SSO**:生成 token 后,需在 token 页面点 **Configure SSO / Authorize** 为对应组织授权,否则访问该组织仓库会 403。 +> **Org has SAML SSO enabled**: after generating the token, click **Configure SSO / Authorize** on the token page to authorize it for the org, otherwise accessing that org's repos returns 403. -### 1.2 细粒度 Token(Fine-grained PAT)— 仅适合固定仓库集 +### 1.2 Fine-grained Token (Fine-grained PAT) — only for a fixed set of repos -细粒度 token 必须**逐个枚举授权仓库**,权限更细、更安全,但需要预先固定仓库范围——与本客户端跨项目轮询的用法不太契合(新增项目 / 仓库都要回去补授权)。**仅当你只评审固定的少数仓库**时适合用它;否则建议用上面的经典 token。 +A fine-grained token must **enumerate authorized repos one by one** — finer-grained and more secure, but it requires fixing the repo scope in advance, which doesn't fit this client's cross-project polling well (adding a project / repo means going back to grant it). **Only suitable when you review a fixed, small set of repos**; otherwise, use the classic token above. -创建:**Settings → Developer settings → Personal access tokens → Fine-grained tokens**。 +Create it under: **Settings → Developer settings → Personal access tokens → Fine-grained tokens**. -- **Repository access**:选择你要评审的仓库(或某组织下全部)。 -- **Repository permissions**(仓库权限): +- **Repository access**: choose the repos you want to review (or all under an org). +- **Repository permissions**: -| 权限 | 级别 | 用途 | +| Permission | Level | Purpose | | --- | --- | --- | -| Metadata | Read(强制,自动包含) | 基础元数据 / 仓库可见 | -| Pull requests | **Read and write** | 列 PR、读评论、发行内 / 普通评论、回复 / 编辑 / 删除、提交审批(通过 / 需修改 / 撤销) | -| Contents | **Read and write** | 克隆仓库(read)+ **合并 PR**(merge 写目标分支,需 write) | -| Checks | Read(可选) | 让「可合并状态」更准确(识别必需检查未通过) | -| Commit statuses | Read(可选) | 同上,识别 status 检查 | +| Metadata | Read (mandatory, auto-included) | Basic metadata / repo visibility | +| Pull requests | **Read and write** | List PRs, read comments, post inline / regular comments, reply / edit / delete, submit reviews (approve / needs work / dismiss) | +| Contents | **Read and write** | Clone the repo (read) + **merge PRs** (merge writes the target branch, needs write) | +| Checks | Read (optional) | Makes "mergeable status" more accurate (detect required checks not passing) | +| Commit statuses | Read (optional) | Same as above, detect status checks | -最小可用集:**Pull requests: RW + Contents: RW + Metadata: R**。 -只读不合并也不发评论的话,可降到 Pull requests / Contents 均为 Read(但本客户端的评论 / 审批 / 合并会不可用)。 +Minimal working set: **Pull requests: RW + Contents: RW + Metadata: R**. +For read-only (no merge, no comments), you can drop Pull requests / Contents both to Read (but this client's comment / approve / merge will be unavailable). -> 细粒度 token 在 **GitHub Enterprise Server** 上的可用性随版本而定;较老的 GHE 只支持经典 token——这种情况用上面的 Classic PAT(`repo`)。 +> Availability of fine-grained tokens on **GitHub Enterprise Server** varies by version; older GHE supports classic tokens only — in that case use the Classic PAT (`repo`) above. -### 1.3 按客户端操作对应的权限速查 +### 1.3 Quick reference: permissions per client operation -| 客户端操作 | 端点 | Classic | Fine-grained | +| Client operation | Endpoint | Classic | Fine-grained | | --- | --- | --- | --- | -| 发现待我评审的 PR | `GET /search/issues` | repo / public_repo | Pull requests: R | -| 读 PR / 评论 / 提交 | `GET /pulls`、`/issues/{n}/comments`、`/pulls/{n}/commits` | 同上 | Pull requests: R(+ Contents: R 取提交) | -| 发 / 改 / 删 评论、回复 | `POST/PATCH/DELETE …/comments` | 同上 | Pull requests: **RW** | -| 审批(通过 / 需修改 / 撤销) | `POST …/reviews`、`PUT …/reviews/{id}/dismissals` | 同上 | Pull requests: **RW** | -| 合并 PR | `PUT …/pulls/{n}/merge` | 同上 | Contents: **RW** | -| 克隆仓库(本地 diff) | git over HTTPS(PAT) | 同上 | Contents: **R** | -| 头像 / 评论内嵌图片 | 资源 URL(带 token) | 同上 | 无需额外 | - -### 1.4 注意事项 - -- **不能审批自己创建的 PR**:GitHub 限制(会 422)。客户端已对自己作者的 PR 灰显审批按钮。 -- **合并需要 Contents 写权限**:仅给 Pull requests 写而漏了 Contents 写时,评论 / 审批可用但合并会失败。 -- **SSH 克隆**:连接的 Clone 协议选 SSH 时,走系统 `~/.ssh/config`,与 PAT 无关(PAT 仅用于 REST API)。 -- **限流**:发现走 GitHub Search(约 30 次/分钟),客户端会按平台节流。 -- **安全**:按最小必要范围授权,离职 / 泄露时及时吊销。 +| Discover PRs awaiting my review | `GET /search/issues` | repo / public_repo | Pull requests: R | +| Read PR / comments / commits | `GET /pulls`, `/issues/{n}/comments`, `/pulls/{n}/commits` | same as above | Pull requests: R (+ Contents: R for commits) | +| Post / edit / delete comments, reply | `POST/PATCH/DELETE …/comments` | same as above | Pull requests: **RW** | +| Approval (approve / needs work / dismiss) | `POST …/reviews`, `PUT …/reviews/{id}/dismissals` | same as above | Pull requests: **RW** | +| Merge PR | `PUT …/pulls/{n}/merge` | same as above | Contents: **RW** | +| Clone repo (local diff) | git over HTTPS (PAT) | same as above | Contents: **R** | +| Avatars / images embedded in comments | resource URL (with token) | same as above | nothing extra needed | + +### 1.4 Notes + +- **You can't approve your own PR**: a GitHub restriction (returns 422). The client already disables the approve button for PRs you authored. +- **Merge requires Contents write**: if you grant Pull requests write but miss Contents write, comment / approve work but merge fails. +- **SSH clone**: when a connection's clone protocol is SSH, it uses the system `~/.ssh/config`, independent of the PAT (the PAT is only for the REST API). +- **Rate limits**: discovery uses GitHub Search (about 30 req/min), and the client throttles per platform. +- **Security**: grant the minimal necessary scope, and revoke promptly on departure / leak. --- -## 二、Bitbucket Server / Data Center +## 2. Bitbucket Server / Data Center -- **Base URL**:填服务器根地址,如 `https://bitbucket.your-company.com`。 -- **访问令牌**:在 Bitbucket 个人设置 → **HTTP access tokens(个人访问令牌)** 创建。 -- **权限**:授予目标项目 / 仓库的 **Repository: Write**(写含读)。 - - 只读评审(不评论 / 不合并)可降到 **Repository: Read**,但客户端的评论 / 审批 / 合并将不可用。 - - 合并 PR 需要仓库写权限。 -- **克隆 URL 形态**:pat → `https://<user>:<PAT>@host/scm/<proj>/<repo>.git`(用户名取当前登录用户);ssh → `git@host:<proj>/<repo>.git`。 +- **Base URL**: enter the server root address, e.g. `https://bitbucket.your-company.com`. +- **Access token**: create it under Bitbucket personal settings → **HTTP access tokens (Personal access tokens)**. +- **Permissions**: grant **Repository: Write** (write includes read) for the target project / repo. + - Read-only review (no comments / no merge) can drop to **Repository: Read**, but the client's comment / approve / merge will be unavailable. + - Merging a PR requires repo write permission. +- **Clone URL forms**: pat → `https://<user>:<PAT>@host/scm/<proj>/<repo>.git` (the username is the current logged-in user); ssh → `git@host:<proj>/<repo>.git`. --- -## 三、GitLab(gitlab.com / Self-Managed,CE / EE) +## 3. GitLab (gitlab.com / Self-Managed, CE / EE) -接入 GitLab(gitlab.com 或自建 Self-Managed 实例)需要一个 **Personal Access Token (PAT)**。本节给出最小权限集。 +Connecting to GitLab (gitlab.com or a self-hosted Self-Managed instance) requires a **Personal Access Token (PAT)**. This section gives the minimal permission set. -> 连接里的 **Base URL**:gitlab.com **留空即可**(默认 `https://gitlab.com/api/v4`);Self-Managed 填**实例地址**即可,如 `https://<你的 GitLab 域名>`——`/api/v4` 会自动补全(手填完整 API base 也兼容)。 +> The **Base URL** in the connection: for gitlab.com **leave it empty** (defaults to `https://gitlab.com/api/v4`); for Self-Managed, just enter the **instance address**, e.g. `https://<your-gitlab-domain>` — `/api/v4` is appended automatically (entering a full API base by hand also works). -创建:**右上角头像 → Edit profile → Access Tokens**(或 `User Settings → Access Tokens`)→ Add new token,勾选 scope 并设置有效期。 +Create it under: **top-right avatar → Edit profile → Access Tokens** (or `User Settings → Access Tokens`) → Add new token, checking a scope and setting an expiry. -> **版本兼容**:接入走 GitLab REST API v4,覆盖 gitlab.com SaaS 与 Self-Managed(CE / EE)。 -> - **推荐 GitLab 15.6 及以上**:`/metadata`(15.2+)自动探测 edition、`detailed_merge_status`(15.6+)令可合并状态 full 保真,体验最完整。 -> - **最低 GitLab 13.8**:「待我评审」发现依赖 MR Reviewers 的 `reviewer_username` 筛选(13.8 起提供);更低版本该过滤不可用,可改用「我创建的 / 分配给我的」发现过滤。 -> - **13.8 ~ 15.5 自动降级**:缺 `/metadata` 退回 `/version`(保守按 CE、审批 UI 灰显),缺 `detailed_merge_status` 退回 `merge_status`(可合并判断略粗);发现 / 评论 / 合并 / clone 均正常。 -> - **审批(通过 / 撤销)**:属 EE Premium / Ultimate(MR 审批 API 自 13.9),经 edition 探测启用,CE 灰显,详见下文 3.2。 +> **Version compatibility**: the integration uses GitLab REST API v4, covering gitlab.com SaaS and Self-Managed (CE / EE). +> - **GitLab 15.6+ recommended**: `/metadata` (15.2+) auto-detects the edition, and `detailed_merge_status` (15.6+) makes the mergeable status full-fidelity, for the most complete experience. +> - **GitLab 13.8 minimum**: "awaiting my review" discovery relies on the MR Reviewers `reviewer_username` filter (available since 13.8); on lower versions that filter is unavailable, and you can use the "authored by me / assigned to me" discovery filters instead. +> - **13.8 – 15.5 auto-degrade**: without `/metadata`, it falls back to `/version` (conservatively assuming CE, with the approval UI disabled); without `detailed_merge_status`, it falls back to `merge_status` (a slightly coarser mergeable judgment); discovery / comments / merge / clone all work normally. +> - **Approval (approve / dismiss)**: an EE Premium / Ultimate feature (the MR approvals API since 13.9), enabled via edition detection, disabled on CE — see 3.2 below. -### 3.1 Scope(最小授权) +### 3.1 Scope (minimal authorization) -GitLab PAT 按 scope 授权,自动覆盖你有权限的全部项目,契合本客户端跨项目轮询发现 MR 的用法。 +A GitLab PAT is authorized by scope and automatically covers every project you have access to, fitting this client's cross-project polling for MR discovery. -| Scope | 用途 | 何时需要 | +| Scope | Purpose | When needed | | --- | --- | --- | -| `api` | 完整 REST API 读写:MR 发现、读 / 发 / 改 / 删评论与回复、审批(EE)、合并 | 需要评论 / 审批 / 合并时(最常见,**推荐**) | -| `read_api` | 只读 REST API | 仅浏览(不评论 / 不审批 / 不合并)时,替代 `api` | -| `read_repository` | Git-over-HTTPS 克隆 / 拉取私有项目 | Clone 协议为 `pat` 且 token 只给了 `read_api` 时补上 | +| `api` | Full REST API read/write: MR discovery, read / post / edit / delete comments and replies, approval (EE), merge | When you need to comment / approve / merge (most common, **recommended**) | +| `read_api` | Read-only REST API | For browsing only (no comment / approve / merge), in place of `api` | +| `read_repository` | Git-over-HTTPS clone / fetch of private projects | Add this when the clone protocol is `pat` and the token was given only `read_api` | -**推荐:单勾 `api`**——它已涵盖 REST API 写操作与 HTTPS 克隆,最省心。 -只读浏览:`read_api` +(pat 克隆再加)`read_repository`。 +**Recommended: check `api` alone** — it already covers REST API write operations and HTTPS clone, the most hassle-free. +Read-only browsing: `read_api` + (for pat clone, add) `read_repository`. -### 3.2 CE / EE 审批差异 +### 3.2 CE / EE approval differences -GitLab 的 MR 审批 API(`approve` / `unapprove`)自 13.9 起属 **Premium / Ultimate(EE 付费版)** 功能,且 GitLab 审批是二元的——**只有「通过 / 撤销」,无「需修改」**。 +GitLab's MR approvals API (`approve` / `unapprove`) has been a **Premium / Ultimate (paid EE)** feature since 13.9, and GitLab approval is binary — **only "Approve / Dismiss", no "Needs work"**. -- 客户端经 `/metadata` 探测实例 edition,据此降级审批能力: - - **EE(Premium 及以上)**:审批按钮可用(通过 / 撤销)。 - - **CE / 社区版**:无审批 API,审批按钮 **灰显不可用**;发现 / 评论 / 合并照常。 -- 可合并状态走 `detailed_merge_status`,对合并阻塞原因(冲突 / 待审批 / 流水线未过等)full 保真展示。 +- The client detects the instance edition via `/metadata` and degrades the approval capability accordingly: + - **EE (Premium and above)**: the approval buttons are available (approve / dismiss). + - **CE / community edition**: no approval API, so the approval buttons are **disabled**; discovery / comments / merge work as usual. +- Mergeable status uses `detailed_merge_status`, showing merge-blocking reasons (conflicts / pending approval / pipeline not passed, etc.) with full fidelity. -### 3.3 按客户端操作对应的权限速查 +### 3.3 Quick reference: permissions per client operation -| 客户端操作 | 端点 | 所需 scope | +| Client operation | Endpoint | Scope required | | --- | --- | --- | -| 发现待我评审的 MR | `GET /merge_requests?reviewer_username=…` | `read_api` / `api` | -| 读 MR / 评论(discussions) | `GET …/merge_requests/{iid}`、`/discussions` | `read_api` / `api` | -| 发 / 改 / 删评论、回复 | `POST/PUT/DELETE …/discussions[/notes]` | `api` | -| 审批(通过 / 撤销,仅 EE) | `POST …/approve`、`/unapprove` | `api` | -| 合并 MR | `PUT …/merge` | `api` | -| 克隆仓库(本地 diff) | git over HTTPS(PAT) | `read_repository`(或 `api`) | -| 头像 / 评论内嵌图片 | 资源 URL(带 token) | 无需额外 | - -### 3.4 注意事项 - -- **克隆 URL 形态**:pat → `https://<user>:<PAT>@host/<group>/<repo>.git`(用户名取当前登录用户,支持嵌套 group);ssh → `git@host:<group>/<repo>.git`。 -- **嵌套 group**:路径含多级 group(如 `group/subgroup/proj`)已正确解析。 -- **审批自己的 MR**:受项目「阻止作者审批」等服务端设置约束,按 GitLab 规则裁决,客户端透传 API 结果。 -- **SSH 克隆**:Clone 协议选 SSH 时走系统 `~/.ssh/config`,与 PAT 无关(PAT 仅用于 REST API)。 -- **安全**:按最小必要 scope 授权并设置有效期,离职 / 泄露时及时吊销。 +| Discover MRs awaiting my review | `GET /merge_requests?reviewer_username=…` | `read_api` / `api` | +| Read MR / comments (discussions) | `GET …/merge_requests/{iid}`, `/discussions` | `read_api` / `api` | +| Post / edit / delete comments, reply | `POST/PUT/DELETE …/discussions[/notes]` | `api` | +| Approval (approve / dismiss, EE only) | `POST …/approve`, `/unapprove` | `api` | +| Merge MR | `PUT …/merge` | `api` | +| Clone repo (local diff) | git over HTTPS (PAT) | `read_repository` (or `api`) | +| Avatars / images embedded in comments | resource URL (with token) | nothing extra needed | + +### 3.4 Notes + +- **Clone URL forms**: pat → `https://<user>:<PAT>@host/<group>/<repo>.git` (the username is the current logged-in user, nested groups supported); ssh → `git@host:<group>/<repo>.git`. +- **Nested groups**: paths with multiple group levels (e.g. `group/subgroup/proj`) are parsed correctly. +- **Approving your own MR**: subject to server-side settings such as the project's "prevent author approval", adjudicated per GitLab's rules; the client passes through the API result. +- **SSH clone**: when the clone protocol is SSH, it uses the system `~/.ssh/config`, independent of the PAT (the PAT is only for the REST API). +- **Security**: grant the minimal necessary scope and set an expiry, revoking promptly on departure / leak. diff --git a/docs/guide/02-llm.md b/docs/guide/02-llm.md index 65059450..dd833205 100644 --- a/docs/guide/02-llm.md +++ b/docs/guide/02-llm.md @@ -1,150 +1,152 @@ -# LLM 配置 +# LLM Setup -评审内容由 LLM 生成(底层走 pr-agent + litellm)。在 **设置 → LLM** 配置一条或多条「LLM 预设」,用 `active` 切换当前生效的那条。每条预设独立保存 服务商 / 模型 / Base URL / API Key。 +**English** · [简体中文](zh-CN/02-llm.md) -## 预设字段 +Review content is generated by an LLM (under the hood via pr-agent + litellm). Under **Settings → LLM**, configure one or more "LLM profiles" and use `active` to switch which one is in effect. Each profile independently stores its provider / model / Base URL / API Key. -| 字段 | 说明 | +## Profile fields + +| Field | Description | | --- | --- | -| 名称 | 预设标识(字母 / 数字 / `-` / `_`,1–32 字符),用于切换与日志 | -| Provider | LLM 服务商,决定鉴权与路由方式(见下表) | -| Model | 模型名(多数 provider 只填型号名,客户端自动补 litellm 前缀) | -| Base URL | API 端点;多数官方 provider 有默认值,留空即可 | -| API Key | 鉴权密钥;本地类(本地 CLI / 自建无鉴权服务)不需要 | +| Name | Profile identifier (letters / digits / `-` / `_`, 1–32 chars), used for switching and logs | +| Provider | LLM provider; determines the auth and routing method (see table below) | +| Model | Model name (for most providers, just the model name — the client adds the litellm prefix automatically) | +| Base URL | API endpoint; most official providers have a default, so leave it empty | +| API Key | Auth key; not needed for local types (local CLI / self-hosted service without auth) | -## Provider 一览 +## Provider overview -| Provider | 说明 | Model 示例 | 需 Key | Base URL | +| Provider | Description | Model example | Key needed | Base URL | | --- | --- | --- | --- | --- | -| OpenAI | 官方 OpenAI API | `gpt-4o` / `gpt-4o-mini` | 是 | 默认 endpoint,留空 | -| Anthropic | 官方 Anthropic API | `claude-opus-4-8` / `claude-sonnet-4-6` | 是 | 默认 | -| DeepSeek | 官方 DeepSeek API | `deepseek-v4-pro` / `deepseek-v4-flash` | 是 | 默认 | -| 阿里百炼 (DashScope) | OpenAI 兼容入口,含千问 / DeepSeek-on-DashScope | `qwen-max` / `qwen-plus` | 是 | 已内置默认 | -| 火山方舟 (Volcengine Ark) | OpenAI 兼容入口,含豆包 / DeepSeek-on-Ark | `ep-xxxxx` / `doubao-pro-32k` | 是 | 已内置默认 | -| OpenAI 兼容 | 任意遵循 OpenAI 协议的服务(vLLM / 自建代理 / 中转 / **本地 Ollama**) | 平台特定 | 视服务而定 | **必填** | -| **本地 CLI** | 用本机 agentic CLI 执行评审,**不直连 API**(**实验性**,见下文) | `claude` / `codex` | 否 | 不适用 | - -> **关于模型前缀**:各 provider 只需填模型名,客户端会按 provider 自动补全 litellm 路由前缀;已手动带前缀的不会重复添加。 +| OpenAI | Official OpenAI API | `gpt-4o` / `gpt-4o-mini` | Yes | Default endpoint, leave empty | +| Anthropic | Official Anthropic API | `claude-opus-4-8` / `claude-sonnet-4-6` | Yes | Default | +| DeepSeek | Official DeepSeek API | `deepseek-v4-pro` / `deepseek-v4-flash` | Yes | Default | +| Alibaba Bailian (DashScope) | OpenAI-compatible endpoint, includes Qwen / DeepSeek-on-DashScope | `qwen-max` / `qwen-plus` | Yes | Built-in default | +| Volcengine Ark | OpenAI-compatible endpoint, includes Doubao / DeepSeek-on-Ark | `ep-xxxxx` / `doubao-pro-32k` | Yes | Built-in default | +| OpenAI-compatible | Any service that follows the OpenAI protocol (vLLM / self-hosted proxy / relay / **local Ollama**) | Platform-specific | Depends on service | **Required** | +| **Local CLI** | Run reviews via a local agentic CLI, **without connecting to an API directly** (**experimental**, see below) | `claude` / `codex` | No | N/A | + +> **About model prefixes**: for each provider you only enter the model name, and the client completes the litellm routing prefix automatically based on the provider; a prefix you added manually is not duplicated. > -> - Anthropic → 默认补 `anthropic/` -> - DeepSeek → 默认补 `deepseek/` -> - OpenAI 兼容 / 阿里百炼 / 火山方舟 → 默认补 `openai/` -> - OpenAI → 直接使用内置模型名,不加前缀 -> - 本地 CLI → 填的是命令名,不涉及前缀 +> - Anthropic → prefixes `anthropic/` by default +> - DeepSeek → prefixes `deepseek/` by default +> - OpenAI-compatible / Alibaba Bailian / Volcengine Ark → prefix `openai/` by default +> - OpenAI → uses the built-in model name directly, no prefix +> - Local CLI → what you enter is a command name, so prefixes don't apply -## 配置示例 +## Configuration examples -按 Provider 给出设置页各字段的填法(**名称**仅作标识、随意取;除特别说明外 Model 只填型号名,路由前缀客户端自动补全)。 +Below is how to fill in each Settings-page field per provider (the **Name** is just an identifier — pick anything; unless noted otherwise, Model takes only the model name and the client completes the routing prefix automatically). ### OpenAI -- Provider:`OpenAI` -- Model:`gpt-4o-mini` -- Base URL:留空(默认 `https://api.openai.com`) -- API Key:`sk-…` +- Provider: `OpenAI` +- Model: `gpt-4o-mini` +- Base URL: leave empty (defaults to `https://api.openai.com`) +- API Key: `sk-…` ### Anthropic -- Provider:`Anthropic` -- Model:`claude-sonnet-4-6` -- Base URL:留空 -- API Key:`sk-ant-…` +- Provider: `Anthropic` +- Model: `claude-sonnet-4-6` +- Base URL: leave empty +- API Key: `sk-ant-…` ### DeepSeek -- Provider:`DeepSeek` -- Model:`deepseek-v4-pro` -- Base URL:留空 -- API Key:`sk-…` +- Provider: `DeepSeek` +- Model: `deepseek-v4-pro` +- Base URL: leave empty +- API Key: `sk-…` -### 阿里百炼(DashScope) +### Alibaba Bailian (DashScope) -- Provider:`阿里百炼` -- Model:`qwen-plus` -- Base URL:留空(已内置默认) -- API Key:DashScope 密钥 +- Provider: `Alibaba Bailian` +- Model: `qwen-plus` +- Base URL: leave empty (built-in default) +- API Key: DashScope key -### 火山方舟(Volcengine Ark) +### Volcengine Ark -- Provider:`火山方舟` -- Model:`doubao-pro-32k`(或推理接入点 `ep-…`) -- Base URL:留空(已内置默认) -- API Key:Ark 密钥 +- Provider: `Volcengine Ark` +- Model: `doubao-pro-32k` (or an inference endpoint `ep-…`) +- Base URL: leave empty (built-in default) +- API Key: Ark key -### OpenAI 兼容(自建 / 中转 / 本地 Ollama) +### OpenAI-compatible (self-hosted / relay / local Ollama) -Provider 选 **OpenAI 兼容**,Base URL **必填**为目标服务的 `/v1` 端点,Model 填平台特定型号名。两个常见场景: +Choose **OpenAI-compatible** as the Provider, set the Base URL (**required**) to the target service's `/v1` endpoint, and enter the platform-specific model name in Model. Two common scenarios: -**自建 vLLM / 中转** +**Self-hosted vLLM / relay** -- Provider:`OpenAI 兼容` -- Model:`qwen2.5-72b-instruct` -- Base URL:`http://10.0.0.5:8000/v1` -- API Key:视服务而定(无鉴权留空) +- Provider: `OpenAI-compatible` +- Model: `qwen2.5-72b-instruct` +- Base URL: `http://10.0.0.5:8000/v1` +- API Key: depends on the service (leave empty if no auth) -**本地 Ollama** +**Local Ollama** -- Provider:`OpenAI 兼容` -- Model:`qwen2.5`(需先 `ollama pull`) -- Base URL:`http://localhost:11434/v1` -- API Key:留空 +- Provider: `OpenAI-compatible` +- Model: `qwen2.5` (run `ollama pull` first) +- Base URL: `http://localhost:11434/v1` +- API Key: leave empty -> Ollama 自带 OpenAI 兼容端点,走此路径即可,无需单独渠道(旧 `ollama` 预设升级后自动迁移为此形态)。 +> Ollama ships an OpenAI-compatible endpoint, so this path is all you need — no separate channel required (an old `ollama` profile is automatically migrated to this form on upgrade). -> **本地 CLI** 预设的配置见下方独立章节。 +> Configuration for the **Local CLI** profile is covered in its own section below. -## 本地 CLI 模式 +## Local CLI mode -进阶选项:不直连任何 LLM API,而是经你授权调用本机已安装并登录的 **agentic CLI**(当前支持 `claude` / `codex`),在本地子进程中执行评审。该 CLI 以其自身的登录会话与计费策略运行,相关额度与合规由你自行负责。 +An advanced option: instead of connecting directly to any LLM API, invoke — with your authorization — an **agentic CLI** already installed and logged in on your machine (currently `claude` / `codex` are supported), running the review in a local subprocess. That CLI runs under its own login session and billing policy, and the associated quota and compliance are your responsibility. -> 🧪 **实验性能力**:本地 CLI 模式依赖第三方 CLI 的命令行接口与输出格式,这些**不在本项目控制范围内**。上游 CLI 的版本更新可能更改参数、输出结构或登录 / 计费策略,导致本模式行为变化甚至无法持续工作;本项目不对其稳定性与持续可用性作担保。设置页对该类预设标注「实验性」徽标以示提醒。若评审异常,请优先核对所用 CLI 的版本与登录态。 +> 🧪 **Experimental capability**: local CLI mode depends on the command-line interface and output format of a third-party CLI, which are **outside this project's control**. An upstream CLI update may change its arguments, output structure, or login / billing policy, altering this mode's behavior or breaking it entirely; this project makes no guarantee about its stability or continued availability. The Settings page marks such profiles with an "experimental" badge as a reminder. If a review misbehaves, check the version and login state of the CLI you're using first. > -> **完全由你授权**:仅当你新建并启用此预设、在 **CLI 命令** 字段填入命令名后,客户端才会调用对应命令行;这一行为完全出于你的显式授权,并使用你本机的登录态。 +> **Entirely under your authorization**: only when you create and enable this profile and fill in a command name in the **CLI command** field will the client invoke that command line; this behavior is entirely on your explicit authorization and uses your machine's login state. -### 配置方法 +### How to configure -1. 在本机安装对应 CLI 并完成登录。 -2. 进入 设置 → LLM,新建预设,**Provider 选「本地 CLI」**。 -3. 在 **CLI 命令** 字段填入命令名,如 `claude` 或 `codex`。 -4. 保存并设为 active。 +1. Install the corresponding CLI on your machine and log in. +2. Go to Settings → LLM, create a new profile, and **choose "Local CLI" as the Provider**. +3. Enter the command name in the **CLI command** field, e.g. `claude` or `codex`. +4. Save and set it as active. -### 关键行为 +### Key behaviors -- **以本机登录态运行**:评审请求交由本机 CLI 处理,沿用其默认模型与登录会话,不使用此处或环境中的 API Key。 -- **实际模型**:由本机 CLI 的默认模型 / 账户档位决定,**不由此处输入决定**(此处填写的是命令名,非模型名)。 -- **代理自动透传**:开启[网络代理](03-proxy.md)后,CLI 的出站请求会自动经代理,无需额外配置。 +- **Runs under your machine's login state**: the review request is handled by the local CLI, using its default model and login session — it does not use the API Key set here or in the environment. +- **Actual model**: determined by the local CLI's default model / account tier, **not by what you enter here** (what you enter is a command name, not a model name). +- **Proxy passthrough is automatic**: once the [network proxy](03-proxy.md) is enabled, the CLI's outbound requests go through the proxy automatically, with no extra configuration. -> 前提:本机须已安装对应命令、位于 PATH 中且已登录,否则评审会因找不到命令而失败;评审消耗计入该 CLI 账户自身的额度。 +> Prerequisite: the corresponding command must be installed on your machine, on the PATH, and logged in, otherwise the review fails because the command is not found; review usage counts against that CLI account's own quota. -## 进阶:评审并发数 +## Advanced: review concurrency -应用支持多个评审任务**并发执行**(例如同时对多个 PR 跑 `/review`,互不阻塞)。并发数由配置项 `pr_agent.max_concurrency` 控制,**默认 2**,取值范围 **1~8**。 +The app supports running multiple review tasks **concurrently** (e.g. running `/review` on several PRs at once, without blocking one another). The concurrency is controlled by the `pr_agent.max_concurrency` setting, **default 2**, range **1–8**. -在设置页「AI」分区拖动「评审任务并发」滑块即可调整(热生效,无需重启);亦可手动编辑 `~/.code-meeseeks/config.yaml`: +Drag the "Review task concurrency" slider in the "AI" section of the Settings page to adjust it (hot-applied, no restart needed); you can also edit `~/.code-meeseeks/config.yaml` by hand: ```yaml pr_agent: - max_concurrency: 3 # 1~8,默认 2 + max_concurrency: 3 # 1–8, default 2 ``` -调高的注意事项(按此判断设多少): +Things to weigh before raising it (use these to decide how high to go): -- **LLM 限流 / 费用**:并发越高,同一时刻打向 LLM 的请求越多。自带 Key 的付费档位通常可承受 2~3;免费 / 低档位易触发限流(HTTP 429),宜保持 `1`。 -- **本地 CLI 模式**:每个并发任务会各起一个本机 CLI 子进程,是否支持多会话取决于该 CLI 本身,建议先小范围验证。 -- **本机资源**:每个并发任务占用一个独立运行时进程与一份临时工作目录,并发越高越吃 CPU / 内存 / 磁盘。 +- **LLM rate limits / cost**: the higher the concurrency, the more requests hit the LLM at once. A paid tier with your own key can usually handle 2–3; free / low tiers easily trip rate limits (HTTP 429), so keep it at `1`. +- **Local CLI mode**: each concurrent task spawns its own local CLI subprocess, and whether multiple sessions are supported depends on the CLI itself — validate on a small scale first. +- **Local resources**: each concurrent task occupies a separate runtime process and a temporary working directory, so higher concurrency consumes more CPU / memory / disk. -> 设置页调整即时生效(调小不打断在跑的任务,随其完成自然收敛);手改 `config.yaml` 后需重启应用生效。设为 `1` 即退回串行执行(逐个排队)。 +> Adjustments on the Settings page take effect immediately (lowering it does not interrupt running tasks — it settles naturally as they finish); editing `config.yaml` by hand requires an app restart. Setting it to `1` reverts to serial execution (one at a time). -## 进阶:上下文长度 +## Advanced: context length -评审前会把改动内容(diff、命中规则、PR 上下文等)拼成 prompt 发给模型。当输入超过模型上下文窗口时,pr-agent 会**按上下文长度上限裁剪**输入以适配模型。该上限由配置项 `llm.context_tokens` 控制,**默认 128000**(token),取值范围 **32k~1M**。 +Before a review, the changes (diff, matched rules, PR context, etc.) are assembled into a prompt and sent to the model. When the input exceeds the model's context window, pr-agent **trims the input to the context-length limit** to fit the model. This limit is controlled by the `llm.context_tokens` setting, **default 128000** (tokens), range **32k–1M**. -在设置页「AI」分区拖动「上下文长度」滑块即可调整(下次评审生效),提供 32k / 64k / 128k / 256k / 512k / 1M 等主要习惯档位;亦可手动编辑 `~/.code-meeseeks/config.yaml`: +Drag the "Context length" slider in the "AI" section of the Settings page to adjust it (effective on the next review), offering the usual tiers of 32k / 64k / 128k / 256k / 512k / 1M; you can also edit `~/.code-meeseeks/config.yaml` by hand: ```yaml llm: - context_tokens: 256000 # 32000~1000000,默认 128000 + context_tokens: 256000 # 32000–1000000, default 128000 ``` -- 设置得**与所用模型的实际上下文窗口相称**:超过模型能力时上游会报错;设得过小则长 PR 会被提前截断、漏看部分改动。 -- **对本地 CLI 模式不生效**:本地 CLI(如 claude)自行管理上下文,本项不参与裁剪。 +- Set it **commensurate with the actual context window of the model you use**: exceeding the model's capacity causes an upstream error; setting it too small truncates long PRs early and misses part of the changes. +- **Does not apply to local CLI mode**: a local CLI (e.g. claude) manages its own context, so this setting takes no part in trimming. diff --git a/docs/guide/03-proxy.md b/docs/guide/03-proxy.md index 05b1cdf4..6ba4c004 100644 --- a/docs/guide/03-proxy.md +++ b/docs/guide/03-proxy.md @@ -1,31 +1,33 @@ -# 网络代理配置 +# Network Proxy Setup -企业内网 / 受限网络下,让所有**出站网络**统一经一个 **HTTP 代理**出网。在 **设置 → 代理** 配置。 +**English** · [简体中文](zh-CN/03-proxy.md) -## 覆盖范围 +On a corporate intranet / restricted network, route all **outbound traffic** through a single **HTTP proxy**. Configure it under **Settings → Proxy**. -开关打开后,以下三类出口统一走代理: +## Coverage -- **LLM 调用**(pr-agent 内嵌的 LLM 客户端)—— 内网下没它整个评审不可用。 -- **代码平台 REST**(轮询 / 评论 / 头像 / 附件 / 合并 / 连接探测)。 -- **git over HTTPS**(clone / fetch)。 +Once the toggle is on, these three egress paths all go through the proxy: -**本地地址自动直连**:`localhost / 127.0.0.1 / ::1`(含本地 Ollama 等本地服务)不经代理。 +- **LLM calls** (the LLM client embedded in pr-agent) — without it, the entire review flow is unusable on an intranet. +- **Code platform REST** (polling / comments / avatars / attachments / merge / connection probes). +- **git over HTTPS** (clone / fetch). -## 配置项 +**Local addresses connect directly, automatically**: `localhost / 127.0.0.1 / ::1` (including a local Ollama and other local services) bypass the proxy. -| 字段 | 说明 | +## Settings + +| Field | Description | | --- | --- | -| 开关 | 总开关;关 = 全部直连(默认) | -| 地址 / 端口 | 代理 host 与 port | -| 用户名 / 密码 | Basic Auth,可留空 | +| Toggle | Master switch; off = everything connects directly (default) | +| Host / Port | Proxy host and port | +| Username / Password | Basic Auth, may be left empty | -> 保存即热生效:修改后立即重建平台客户端,REST 立即走新代理;LLM / git 出口在下次操作时读最新配置。 -> 配好后可点「测试」,客户端会经该代理试连一个外部地址验证连通(代理认证失败 407 会报原因)。 +> Saving takes effect immediately: the platform client is rebuilt right away and REST goes through the new proxy at once; the LLM / git egress paths read the latest config on their next operation. +> After configuring, click "Test" and the client will try to reach an external address through the proxy to verify connectivity (a proxy auth failure, 407, reports the reason). -## 注意事项 +## Notes -- **SSH 克隆不走代理**:HTTP 代理对 SSH 不直接适用,跨平台也无统一手段。若 Clone 协议选 SSH,请自行在 `~/.ssh/config` 配 `ProxyCommand`。 -- **本地 CLI 模式同样走代理**:用[本地 CLI 模式](02-llm.md#本地-cli-模式)评审时,CLI 子进程继承代理环境变量,出站自动走代理。 -- **socks5 暂不支持**:一期仅 HTTP 代理(含 Basic Auth)。 -- **平台被代理误伤**:内网代码平台若因走代理反而连不上,属边缘场景,当前未提供「平台直连」单独开关。 +- **SSH clone does not use the proxy**: an HTTP proxy does not apply directly to SSH, and there is no uniform cross-platform mechanism for it. If your clone protocol is SSH, configure `ProxyCommand` yourself in `~/.ssh/config`. +- **Local CLI mode also uses the proxy**: when reviewing with [local CLI mode](02-llm.md#local-cli-mode), the CLI subprocess inherits the proxy environment variables, so its outbound traffic goes through the proxy automatically. +- **socks5 is not yet supported**: this first phase supports HTTP proxies only (including Basic Auth). +- **Platform caught in the crossfire**: if an intranet code platform becomes unreachable precisely because it is routed through the proxy, that is an edge case; there is currently no separate "platform direct connection" toggle. diff --git a/docs/guide/04-config-reference.md b/docs/guide/04-config-reference.md index 8d906856..59fbb846 100644 --- a/docs/guide/04-config-reference.md +++ b/docs/guide/04-config-reference.md @@ -1,15 +1,17 @@ -# 配置文件参考 +# Config File Reference -所有配置集中在唯一的配置文件 **`~/.code-meeseeks/config.yaml`**(YAML 格式)。日常使用通过应用内 **设置** 页与首启向导可视化编辑即可,无需手动改文件;本篇是完整的结构与字段参考,供批量配置、高级参数调整、问题排查时查阅。 +**English** · [简体中文](zh-CN/04-config-reference.md) -- **编辑方式**:设置页保存即写盘并热更新;设置页「用系统关联程序打开 config.yaml」可直接编辑文件。 -- **生效时机**:连接 / LLM / 代理 / 语言 / 并发数等改动保存后即时重建生效;个别高级参数(如 `workspace.repos_dir`)需重启应用。 -- **凭据说明**:访问令牌、API Key、代理密码以**明文**保存在本文件中(与配置结构隔离但不加密)。请按最小权限申请凭据、妥善保护本文件,泄露后及时吊销。 +All configuration lives in a single config file, **`~/.code-meeseeks/config.yaml`** (YAML format). For day-to-day use, visual editing via the in-app **Settings** page and the first-launch wizard is enough — no need to edit the file by hand; this document is the complete structure and field reference, for bulk configuration, advanced parameter tuning, and troubleshooting. -## 完整示例 +- **How to edit**: saving on the Settings page writes to disk and hot-reloads; the Settings page's "Open config.yaml with the system default app" lets you edit the file directly. +- **When it takes effect**: connection / LLM / proxy / language / concurrency, etc. are rebuilt and applied immediately on save; a few advanced parameters (such as `workspace.repos_dir`) require an app restart. +- **A note on credentials**: access tokens, API keys, and proxy passwords are stored in **plaintext** in this file (isolated from the config structure but not encrypted). Request credentials with least privilege, protect this file well, and revoke promptly on leak. + +## Full example ```yaml -language: '' # 空 = 按系统语言自动、回落英语;或显式 zh-CN / en-US / ja-JP / de-DE +language: '' # empty = auto by system language, falling back to English; or explicitly zh-CN / en-US / ja-JP / de-DE appearance: editor_theme: auto @@ -31,6 +33,8 @@ agent: auto_followup: true max_followup_asks: 2 max_code_suggestions: 4 + code_suggestion_spec: '' + code_suggestion_layout: '' poller: interval_seconds: 300 @@ -69,7 +73,7 @@ connections: - id: my-bitbucket kind: bitbucket-server base_url: https://bitbucket.example.com - display_name: 公司 Bitbucket + display_name: Company Bitbucket auth: type: pat token: <BITBUCKET_HTTP_ACCESS_TOKEN> @@ -99,198 +103,222 @@ llm: api_key: <OPENAI_API_KEY> ``` -## 顶层字段 +## Top-level fields -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `language` | string | `''`(自动) | 界面与 pr-agent 生成内容使用的语言(ISO locale,如 `zh-CN` / `en-US` / `ja-JP` / `de-DE`)。**默认空 = 自动**:按系统偏好语言匹配,无合适项回落英语。设置页可切换(热生效)。 | -| `appearance` | object | — | 界面与编辑器外观(主题 / 字体),见下。 | -| `workspace` | object | — | 工作目录设置,见下。 | -| `agent` | object | — | 高阶 Agent 与 AutoPilot 设置(Agent 目录、个性化规则均归于此),见下。 | -| `poller` | object | — | PR 轮询设置,见下。 | -| `proxy` | object | — | 出站网络代理设置,见下(详见 [网络代理配置](03-proxy.md))。 | -| `pr_agent` | object | — | pr-agent 运行时设置,见下。 | -| `notifications` | object | — | 系统通知与 dock 角标设置,见下。 | -| `service` | object | — | 本地 API 服务(CLI / 外部集成入口)设置,见下(详见 [CLI 命令行工具](06-cli.md))。 | -| `update` | object | — | 版本更新检测设置,见下。 | -| `connections` | array | `[]` | 代码平台连接列表,见下(详见 [代码平台配置](01-code-platform.md))。 | -| `active_connection_id` | string | `''` | 当前启用的连接 `id`,见下。 | -| `llm` | object | — | LLM 预设设置,见下(详见 [LLM 配置](02-llm.md))。 | - -## `appearance` — 外观 - -界面与编辑器的纯前端展示项(主进程仅据主题设原生窗口明暗)。均在设置页可视化调整、即时生效。 - -| 字段 | 类型 | 默认 | 说明 | +| `language` | string | `''` (auto) | Language used by the UI and pr-agent-generated content (ISO locale, e.g. `zh-CN` / `en-US` / `ja-JP` / `de-DE`). **Default empty = auto**: matched against your system's preferred language, falling back to English if none fits. Switchable on the Settings page (hot-applied). | +| `appearance` | object | — | UI and editor appearance (theme / font), see below. | +| `workspace` | object | — | Working directory settings, see below. | +| `agent` | object | — | Advanced Agent and AutoPilot settings (the Agent directory and personalized rules both belong here), see below. | +| `poller` | object | — | PR polling settings, see below. | +| `proxy` | object | — | Outbound network proxy settings, see below (details in [Network proxy setup](03-proxy.md)). | +| `pr_agent` | object | — | pr-agent runtime settings, see below. | +| `notifications` | object | — | System notification and dock badge settings, see below. | +| `service` | object | — | Local API service (CLI / external integration entry) settings, see below (details in [CLI tool](06-cli.md)). | +| `update` | object | — | Version update-check settings, see below. | +| `connections` | array | `[]` | Code platform connection list, see below (details in [Code platform setup](01-code-platform.md)). | +| `active_connection_id` | string | `''` | The `id` of the currently active connection, see below. | +| `llm` | object | — | LLM profile settings, see below (details in [LLM setup](02-llm.md)). | + +## `appearance` — appearance + +Purely front-end display items for the UI and editor (the main process only sets the native window's light/dark from the theme). All are visually adjustable on the Settings page and take effect immediately. + +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `editor_theme` | enum | `auto` | 全局主题(Monaco 编辑器与整个界面共用):`auto` 跟随系统深 / 浅色,其余为内置 / 第三方主题 id。 | -| `editor_font_family` | string | `''` | 编辑器等宽字体族(CSS font-family,可逗号分隔多候选)。留空 = 内置 mono 字体栈。 | -| `editor_font_size` | integer | `14` | 编辑器字号(px),限合理范围。 | +| `editor_theme` | enum | `auto` | Global theme (shared by the Monaco editor and the whole UI): `auto` follows the system dark / light, the rest are built-in / third-party theme ids. | +| `editor_font_family` | string | `''` | The editor's monospace font family (CSS font-family, comma-separated candidates allowed). Empty = the built-in mono font stack. | +| `editor_font_size` | integer | `14` | Editor font size (px), constrained to a reasonable range. | -## `workspace` — 工作目录 +## `workspace` — working directory -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `repos_dir` | string | `~/.code-meeseeks/repos` | 仓库本地镜像(bare clone)的存放目录。改动需重启应用后完全生效。支持 `~` 展开。 | +| `repos_dir` | string | `~/.code-meeseeks/repos` | Directory storing the repos' local mirrors (bare clones). Changes fully take effect only after an app restart. Supports `~` expansion. | -## `agent` — 高阶 Agent 与 AutoPilot +## `agent` — Advanced Agent & AutoPilot -高阶 Agent 把自然语言请求转成自主规划 + 多工具编排(设计见 [docs/arch/02-agent/01-agent.md](../arch/02-agent/01-agent.md))。**Agent 目录** `<agent.dir>/` 是 Agent 的完整人格与知识来源,其固定布局为: +The advanced Agent turns natural-language requests into autonomous planning + multi-tool orchestration (design in [docs/arch/02-agent/01-agent.md](../arch/02-agent/01-agent.md)). The **Agent directory** `<agent.dir>/` is the Agent's complete persona and knowledge source, with a fixed layout: ``` <agent.dir>/ -├── SOUL.md # 灵魂:核心职责与边界(只读) -├── AGENTS.md # 工作规范与红线 -├── MEMORY.md # 长期记忆(可写) -├── USER.md # 用户画像(可写) -└── rules/ # 个性化规则目录(原 rules.dir 并入此处,结构见 自定义评审规则) +├── SOUL.md # Soul: core responsibilities and boundaries (read-only) +├── AGENTS.md # Working conventions and red lines +├── MEMORY.md # Long-term memory (writable) +├── USER.md # User profile (writable) +└── rules/ # Personalized rules directory (the former rules.dir folded in here; structure in Custom review rules) ``` -Agent **无独立启用开关**——配置了 LLM 且 pr-agent 就绪即可用。`dir` 留空时回落到工作目录下的默认位置 `~/.code-meeseeks/agent`(启动期幂等脚手架自动补齐缺失文件);配自定义路径可指向一个 git 仓库,便于团队共享上下文与规则。 +The Agent **has no separate enable toggle** — it's available once an LLM is configured and pr-agent is ready. When `dir` is empty it falls back to the default location under the working directory, `~/.code-meeseeks/agent` (a startup-time idempotent scaffold fills in any missing files); a custom path can point at a git repo, making it easy for a team to share context and rules. -> **从旧 `rules.*` 迁移**:早期版本的个性化规则配置在顶层 `rules.dir`;现已并入 `<agent.dir>/rules/`,**不再读取旧 `rules.*` 字段**。把原规则目录的内容移入 `<agent.dir>/rules/` 即可(规则文件结构不变,见 [自定义评审规则](05-rules.md))。 +> **Migrating from the old `rules.*`**: earlier versions configured personalized rules at the top-level `rules.dir`; this is now folded into `<agent.dir>/rules/`, and **the old `rules.*` fields are no longer read**. Just move the contents of your original rules directory into `<agent.dir>/rules/` (the rule file structure is unchanged, see [Custom review rules](05-rules.md)). -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `dir` | string | `''` | Agent 目录路径。留空 = 工作目录下默认 `~/.code-meeseeks/agent`。支持 `~` 展开。可指向 git 仓库以共享。 | -| `max_steps` | integer | `8` | 单次会话的 Agent 规划步数上限,`1`–`50`。 | -| `summary_max_chars` | integer | `800` | 收尾总结的严格篇幅上限(字符),`100`–`4000`。 | -| `autopilot` | object | — | AutoPilot 预评审设置,见下。 | -| `strategy` | object | — | Agent 行为策略(作用于手动自动评审与 AutoPilot),见下。 | +| `dir` | string | `''` | Agent directory path. Empty = the default `~/.code-meeseeks/agent` under the working directory. Supports `~` expansion. Can point at a git repo to share. | +| `max_steps` | integer | `8` | Upper bound on the Agent's planning steps per session, `1`–`50`. | +| `summary_max_chars` | integer | `800` | Strict length limit (characters) of the closing summary, `100`–`4000`. | +| `autopilot` | object | — | AutoPilot pre-review settings, see below. | +| `strategy` | object | — | Agent behavior strategy (applies to manual auto-review and AutoPilot), see below. | -### `agent.autopilot` — AutoPilot 预评审 +### `agent.autopilot` — AutoPilot pre-review -轮询发现待评审 PR 后自动预跑 `/describe` + `/review`,进应用即见待确认草稿(决策权仍在评审者)。准入控制只放行「待我评审·待处理」且未评审过的 PR,PR 被移除 / purge 即终止在途任务。 +After polling discovers PRs awaiting review, automatically pre-runs `/describe` + `/review`, so a draft awaits your confirmation the moment you open the app (the decision still rests with the reviewer). Admission control only lets through "awaiting my review · pending" PRs not yet reviewed; removing / purging a PR terminates an in-flight task. -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `enabled` | boolean | `false` | AutoPilot 总开关。状态栏可切换;`false` 时调度逻辑完全不跑。 | -| `batch_size` | integer | `10` | 单批 LLM 判定的 PR 上限,`1`–`50`。 | -| `grants` | array | `[]` | 逐项写权限授权(默认空 = 全拒),如 `approve` / `needs_work` / `publish_comment`;运行期按红线硬校验放行。 | +| `enabled` | boolean | `false` | AutoPilot master switch. Toggleable in the status bar; when `false` the scheduling logic doesn't run at all. | +| `batch_size` | integer | `10` | Upper bound on PRs per single LLM-judged batch, `1`–`50`. | +| `grants` | array | `[]` | Per-item write-permission grants (default empty = deny all), e.g. `approve` / `needs_work` / `publish_comment`; enforced against the red lines at runtime. | -### `agent.strategy` — Agent 行为策略 +### `agent.strategy` — Agent behavior strategy -作用于自动评审微流程(手动「自动评审」与 AutoPilot 共用),非 AutoPilot 专属。 +Applies to the auto-review micro-flow (shared by manual "auto review" and AutoPilot), not AutoPilot-exclusive. -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `auto_followup` | boolean | `true` | 评审阶段是否启用**自动追问**(条件性 `/ask`)。关闭则跳过判读 + 追问、直接总结,省一次 LLM 调用与追问开销。 | -| `max_followup_asks` | integer | `2` | 自动追问数量上限(条件性 `/ask` 的硬上限),`0`–`5`。仅 `auto_followup` 开启时生效;`0` 等同关闭。 | -| `max_code_suggestions` | integer | `4` | 单次 `/review`、`/improve`、`/ask` 生成的代码建议数量上限,`2`–`8`。 | +| `auto_followup` | boolean | `true` | Whether the review stage enables **automatic follow-up** (conditional `/ask`). Disabling it skips the interpretation + follow-up and summarizes directly, saving an LLM call and the follow-up cost. | +| `max_followup_asks` | integer | `2` | Upper bound on automatic follow-ups (a hard cap on conditional `/ask`), `0`–`5`. Effective only when `auto_followup` is on; `0` is equivalent to off. | +| `max_code_suggestions` | integer | `4` | Upper bound on code suggestions generated by a single `/review`, `/improve`, or `/ask`, `2`–`8`. | +| `code_suggestion_spec` | string | `''` | Free-text spec injected into `/improve`, `/review`, `/ask` to shape how each suggestion is written (soft constraint). Empty = nothing injected. See below. | +| `code_suggestion_layout` | string | `''` | Deterministic markdown layout for the review-draft comment created from a suggestion. Empty = the default layout. See below. | + +#### Code-suggestion spec & layout + +Two optional fields control how AI code suggestions read and how they land in a review-draft comment. Both are editable from **Settings → Agent → Strategy** via an inline editor, so you don't have to hand-edit the config file. + +- **`code_suggestion_spec`** — a *soft* guideline appended to the model's instructions for `/improve`, `/review`, and `/ask`. Use it to steer the content structure of each suggestion, e.g. _"Structure every suggestion as three sections — Problem, Analysis, Suggestion — each on its own line."_ The model generally complies but is not guaranteed to. +- **`code_suggestion_layout`** — a *deterministic* markdown template applied when a suggestion becomes a review-draft comment (substitution is exact, no dependency on the model). Placeholders: + +| Placeholder | Value | +| --- | --- | +| `<TITLE>` | Localized "AI suggestion" label | +| `<SUGGESTIONS>` | The suggestion body | +| `<HOME>` | Project website | +| `<PR>` | Current PR link | +| `<MODEL>` | Current active model name | + +If the layout contains `<SUGGESTIONS>` it is used as the full comment body; otherwise the whole string is prepended as a prefix before the body. Empty falls back to the default layout: + +```text +[<TITLE>](<HOME>) (<MODEL>) +<SUGGESTIONS> +``` -## `poller` — PR 轮询 +## `poller` — PR polling -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `interval_seconds` | integer | `300` | 自动轮询拉取待评审 PR 的间隔秒数,最小 `30`。 | +| `interval_seconds` | integer | `300` | The interval in seconds for automatically polling PRs awaiting review, minimum `30`. | -## `proxy` — 出站网络代理 +## `proxy` — outbound network proxy -开启后 LLM 调用、代码平台 REST、git HTTPS 统一经代理;loopback / 本地地址(含本地 Ollama)自动直连。SSH 方式的 git 拉取不走此配置,需在 `~/.ssh/config` 自配。 +When enabled, LLM calls, code platform REST, and git HTTPS all go through the proxy; loopback / local addresses (including a local Ollama) connect directly. git fetch over SSH does not use this config — configure it yourself in `~/.ssh/config`. -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `enabled` | boolean | `false` | 代理总开关。关闭 = 全部直连。 | -| `protocol` | enum | `http` | 当前仅支持 `http`。 | -| `host` | string | `''` | 代理主机地址。 | -| `port` | integer | `8080` | 代理端口,`1`–`65535`。 | -| `username` | string | `''` | Basic Auth 用户名,无鉴权留空。 | -| `password` | string | `''` | Basic Auth 密码,无鉴权留空。 | +| `enabled` | boolean | `false` | Proxy master switch. Off = everything connects directly. | +| `protocol` | enum | `http` | Currently only `http` is supported. | +| `host` | string | `''` | Proxy host address. | +| `port` | integer | `8080` | Proxy port, `1`–`65535`. | +| `username` | string | `''` | Basic Auth username, empty if no auth. | +| `password` | string | `''` | Basic Auth password, empty if no auth. | -## `pr_agent` — 运行时 +## `pr_agent` — runtime -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `strategy` | enum | `auto` | 运行时策略:`auto` 优先随包内嵌运行时、缺失则回退系统 `pr-agent` CLI;亦可显式 `embedded` / `local-cli` 强制。 | -| `max_concurrency` | integer | `2` | 评审任务并发数,`1`–`8`。设置页「AI」分区可调(热生效,无需重启),亦可手改本文件,详见 [LLM 配置 · 进阶:评审并发数](02-llm.md#进阶评审并发数)。 | +| `strategy` | enum | `auto` | Runtime strategy: `auto` prefers the bundled embedded runtime and falls back to the system `pr-agent` CLI if missing; you can also force `embedded` / `local-cli` explicitly. | +| `max_concurrency` | integer | `2` | Review task concurrency, `1`–`8`. Adjustable in the "AI" section of the Settings page (hot-applied, no restart), or edit this file by hand — details in [LLM setup · Advanced: review concurrency](02-llm.md#advanced-review-concurrency). | -## `notifications` — 消息通知 +## `notifications` — notifications -系统通知(toast)与 macOS dock「待回应」角标开关。`enabled` 为总开关(关闭后既不弹通知也不亮角标);其余各项按事件类型分别控制系统通知——`new_pr` / `reply` / `mention` 面向「待我评审」等场景,`authored_*` 面向「我创建的」PR。系统通知受 OS 权限约束,用户在系统设置关闭后应用静默降级。设置页可调,即时生效。 +Toggles for system notifications (toasts) and the macOS dock "awaiting response" badge. `enabled` is the master switch (when off, neither notifications pop nor the badge lights up); the rest control system notifications per event type — `new_pr` / `reply` / `mention` target scenarios like "awaiting my review", and `authored_*` target "authored by me" PRs. System notifications are subject to OS permissions, and the app silently degrades if the user disables them in system settings. Adjustable on the Settings page, effective immediately. -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `enabled` | boolean | `true` | 通知总开关;关闭后不弹系统通知、不亮 dock 角标。 | -| `new_pr` | boolean | `true` | 出现新的待我评审 PR 时弹通知。 | -| `reply` | boolean | `true` | 收到评论回复时弹通知。 | -| `mention` | boolean | `true` | 评论中被 @ 提及时弹通知。 | -| `authored_comment` | boolean | `true` | 我创建的 PR 收到他人新评论时弹通知。 | -| `authored_needs_work` | boolean | `true` | 我创建的 PR 被评审标记「需修改」时弹通知。 | -| `authored_conflict` | boolean | `true` | 我创建的 PR 出现合并冲突时弹通知。 | +| `enabled` | boolean | `true` | Notification master switch; when off, no system notifications pop and the dock badge doesn't light up. | +| `new_pr` | boolean | `true` | Pop a notification when a new PR awaiting my review appears. | +| `reply` | boolean | `true` | Pop a notification when a comment reply is received. | +| `mention` | boolean | `true` | Pop a notification when @-mentioned in a comment. | +| `authored_comment` | boolean | `true` | Pop a notification when a PR I authored receives a new comment from someone else. | +| `authored_needs_work` | boolean | `true` | Pop a notification when a PR I authored is marked "needs work" in review. | +| `authored_conflict` | boolean | `true` | Pop a notification when a PR I authored develops a merge conflict. | -## `service` — 本地 API 服务 +## `service` — local API service -本机 HTTP API 服务监听配置,供 `meebox` CLI 与外部脚本 / agent 访问应用能力(详见 [CLI 命令行工具](06-cli.md))。默认关闭、零暴露面;开启即**强制** bearer token 鉴权。设置页「集成」分区可视化开关、查看 / 复制 / 重新生成令牌,即时生效(开关 / 地址 / 端口变更停旧起新,token 变更下次请求生效)。 +Listen configuration for the local HTTP API service, providing app capabilities to the `meebox` CLI and external scripts / agents (details in [CLI tool](06-cli.md)). Off by default with zero exposure; enabling it **enforces** bearer token auth. Visually toggle and view / copy / regenerate the token in the "Integration" section of the Settings page, effective immediately (a toggle / host / port change stops the old and starts the new; a token change takes effect on the next request). -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `enabled` | boolean | `false` | 服务总开关。关闭 = 不监听、无暴露面。 | -| `host` | string | `127.0.0.1` | 监听地址。默认仅本机可达;设 `0.0.0.0` 暴露到局域网(高风险,此时令牌是唯一防线)。 | -| `port` | integer | `18765` | 监听端口,`1`–`65535`。 | -| `token` | string | `''` | 访问令牌(bearer)。首次开启自动生成;设置页可重新生成(旧令牌即时失效)。以明文保存。 | +| `enabled` | boolean | `false` | Service master switch. Off = not listening, no exposure. | +| `host` | string | `127.0.0.1` | Listen address. Default reachable only from the local machine; set `0.0.0.0` to expose to the LAN (high risk — the token is the only line of defense then). | +| `port` | integer | `18765` | Listen port, `1`–`65535`. | +| `token` | string | `''` | Access token (bearer). Auto-generated on first enable; regeneratable on the Settings page (the old token is invalidated immediately). Stored in plaintext. | -## `update` — 版本更新检测 +## `update` — version update check -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `check_enabled` | boolean | `true` | 启动时(及设置页手动触发)查 GitHub Releases 最新稳定版并与当前版本比对,有新版仅**提示**去下载(不自动下载 / 安装)。设为 `false` 关闭检测——此开关仅经手改本文件调整(设置页展示更新状态、不提供开关)。 | +| `check_enabled` | boolean | `true` | On startup (and on manual trigger from the Settings page), check the latest stable version on GitHub Releases and compare it against the current version; if newer, only **prompt** you to go download it (no auto-download / install). Set to `false` to disable the check — this toggle is adjustable only by editing this file (the Settings page shows update status but offers no toggle). | -## `connections` — 代码平台连接 +## `connections` — code platform connections -`connections` 是数组,每个元素是一条连接。`kind` 决定平台类型与字段形态。 +`connections` is an array, each element being one connection. `kind` determines the platform type and field shape. -### 公共字段 +### Common fields -| 字段 | 类型 | 说明 | +| Field | Type | Description | | --- | --- | --- | -| `id` | string | 连接唯一标识,被 `active_connection_id` 引用。 | -| `kind` | enum | 平台类型:`github` / `bitbucket-server` / `gitlab`。 | -| `display_name` | string | 显示名称(设置页与状态栏展示)。 | -| `auth.type` | literal | 固定 `pat`。 | -| `auth.token` | string | 访问令牌(PAT)。所需权限见 [代码平台配置](01-code-platform.md)。 | -| `clone.protocol` | enum | git 克隆协议:`pat`(默认,HTTPS,URL 内嵌用户名 + PAT)/ `ssh`(走系统 `~/.ssh/config`)。 | +| `id` | string | Connection unique identifier, referenced by `active_connection_id`. | +| `kind` | enum | Platform type: `github` / `bitbucket-server` / `gitlab`. | +| `display_name` | string | Display name (shown on the Settings page and status bar). | +| `auth.type` | literal | Fixed `pat`. | +| `auth.token` | string | Access token (PAT). For required permissions see [Code platform setup](01-code-platform.md). | +| `clone.protocol` | enum | git clone protocol: `pat` (default, HTTPS, username + PAT embedded in the URL) / `ssh` (uses the system `~/.ssh/config`). | ### `kind: github` -| 字段 | 类型 | 说明 | +| Field | Type | Description | | --- | --- | --- | -| `base_url` | string (URL) | GitHub API base。**可选**:留空默认 `https://api.github.com`(github.com);GitHub Enterprise Server 填实例地址 `https://<ghe-host>`,`/api/v3` 自动补全(手填完整 API base 亦可)。clone / web 域名由应用自动推导。 | +| `base_url` | string (URL) | GitHub API base. **Optional**: empty defaults to `https://api.github.com` (github.com); for GitHub Enterprise Server enter the instance address `https://<ghe-host>`, and `/api/v3` is appended automatically (a full API base by hand also works). The clone / web domains are derived by the app automatically. | ### `kind: bitbucket-server` -| 字段 | 类型 | 说明 | +| Field | Type | Description | | --- | --- | --- | -| `base_url` | string (URL) | Bitbucket Server / Data Center 地址,如 `https://bitbucket.example.com`。**必填**。 | +| `base_url` | string (URL) | Bitbucket Server / Data Center address, e.g. `https://bitbucket.example.com`. **Required**. | ### `kind: gitlab` -| 字段 | 类型 | 说明 | +| Field | Type | Description | | --- | --- | --- | -| `base_url` | string (URL) | GitLab API base。**可选**:留空默认 `https://gitlab.com/api/v4`(gitlab.com);Self-Managed 填实例地址 `https://<gitlab-host>`,`/api/v4` 自动补全(手填完整 API base 亦可)。clone / web 域名由应用自动推导。 | +| `base_url` | string (URL) | GitLab API base. **Optional**: empty defaults to `https://gitlab.com/api/v4` (gitlab.com); for Self-Managed enter the instance address `https://<gitlab-host>`, and `/api/v4` is appended automatically (a full API base by hand also works). The clone / web domains are derived by the app automatically. | -## `active_connection_id` — 当前启用连接 +## `active_connection_id` — currently active connection -| 类型 | 默认 | 说明 | +| Type | Default | Description | | --- | --- | --- | -| string | `''` | 取值为某条连接的 `id`。同时只启用一条:仅这条被轮询,PR 列表与状态栏只反映它。空串 / 指向不存在的 id 时不轮询任何连接(由界面引导启用一条)。`connections` 仍保留全部配置,历史 PR 不受影响。 | +| string | `''` | Takes the `id` of one connection. Only one is active at a time: only it is polled, and the PR list and status bar reflect only it. An empty string / an id that doesn't exist polls no connection (the UI guides you to enable one). `connections` still retains all configuration, and historical PRs are unaffected. | -## `llm` — LLM 预设 +## `llm` — LLM profiles -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `profiles` | array | `[]` | LLM 预设列表,每条独立 provider / model / base_url / api_key。 | -| `active_id` | string | `''` | 当前生效的预设 `id`。空串 / 指向不存在的 id 时,评审不注入任何 LLM 环境变量(pr-agent 退回读取 shell 环境变量)。 | -| `context_tokens` | integer | `128000` | 裁剪输入内容的上下文长度上限(token),`32000`–`1000000`。超长改动按此截断以适配模型。**对本地 CLI 模式不生效**(CLI 工具自管上下文)。设置页「AI」分区可调(下次评审生效)。 | +| `profiles` | array | `[]` | LLM profile list, each with independent provider / model / base_url / api_key. | +| `active_id` | string | `''` | The `id` of the profile currently in effect. An empty string / an id that doesn't exist injects no LLM environment variables into the review (pr-agent falls back to reading shell environment variables). | +| `context_tokens` | integer | `128000` | Context-length limit (tokens) for trimming input content, `32000`–`1000000`. Over-long changes are truncated to this to fit the model. **Does not apply to local CLI mode** (the CLI tool manages its own context). Adjustable in the "AI" section of the Settings page (effective on the next review). | -### 单条预设(`profiles[]`) +### A single profile (`profiles[]`) -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `id` | string | — | 预设唯一标识,被 `active_id` 引用。 | -| `label` | string | `''` | 显示名称,留空时界面用 provider + model 兜底。 | -| `provider` | enum | `openai-compatible` | 服务商:`openai` / `anthropic` / `deepseek` / `dashscope`(阿里百炼)/ `volcengine-ark`(火山方舟)/ `openai-compatible`(任意 OpenAI 协议兼容服务,含本地 Ollama 的 `/v1`)/ `cli`(本机 agentic CLI,不直连 API)。旧值 `ollama` 自动迁移为 `openai-compatible`。 | -| `base_url` | string | `''` | API 端点。多数官方 provider 留空走默认;`openai-compatible` / 自部署需填。 | -| `model` | string | `''` | 模型名。多数 provider 只填型号名,应用按 provider 自动补 litellm 前缀;`cli` 模式此处填命令名(如 `claude`)。 | -| `api_key` | string | `''` | 鉴权密钥。本地类(本地 CLI / 无鉴权自建服务)留空。 | - -各 provider 的取值示例与本地 CLI 模式说明见 [LLM 配置](02-llm.md)。 +| `id` | string | — | Profile unique identifier, referenced by `active_id`. | +| `label` | string | `''` | Display name; when empty the UI falls back to provider + model. | +| `provider` | enum | `openai-compatible` | Provider: `openai` / `anthropic` / `deepseek` / `dashscope` (Alibaba Bailian) / `volcengine-ark` (Volcengine Ark) / `openai-compatible` (any OpenAI-protocol-compatible service, including a local Ollama's `/v1`) / `cli` (a local agentic CLI, no direct API connection). The old value `ollama` is auto-migrated to `openai-compatible`. | +| `base_url` | string | `''` | API endpoint. Most official providers leave it empty for the default; `openai-compatible` / self-hosted must fill it in. | +| `model` | string | `''` | Model name. Most providers take only the model name and the app adds the litellm prefix automatically; in `cli` mode this holds a command name (e.g. `claude`). | +| `api_key` | string | `''` | Auth key. Empty for local types (local CLI / self-hosted service without auth). | + +For value examples per provider and local CLI mode notes, see [LLM setup](02-llm.md). diff --git a/docs/guide/05-rules.md b/docs/guide/05-rules.md index 85afee45..8db4bec3 100644 --- a/docs/guide/05-rules.md +++ b/docs/guide/05-rules.md @@ -1,23 +1,25 @@ -# 自定义评审规则 +# Custom Review Rules -评审规则让你把团队规约、风格偏好、关注点注入 AI 评审:命中的规则正文会作为 `extra_instructions` 传给 pr-agent,影响 `/review`(及可选的 `/describe`)的产出。 +**English** · [简体中文](zh-CN/05-rules.md) -规则是**纯文件**:一个规则目录下,每个 `.md` 文件就是一条规则。`frontmatter`(文件顶部的 YAML)声明这条规则**何时命中**,正文(markdown)是命中后**注入给 AI 的指令**。 +Review rules let you inject your team's conventions, style preferences, and areas of concern into the AI review: the body of a matched rule is passed to pr-agent as `extra_instructions`, shaping the output of `/review` (and, optionally, `/describe`). -## 规则目录 +Rules are **plain files**: within a rules directory, each `.md` file is one rule. The `frontmatter` (the YAML at the top of the file) declares **when the rule matches**, and the body (markdown) is the **instruction injected into the AI** once it matches. -规则目录是 **Agent 目录下的 `rules/` 子目录**:`<agent.dir>/rules/`(见 [配置文件参考 · agent](04-config-reference.md#agent--高阶-agent-与-autopilot))。无需单独配置规则路径——只要把规则文件放进该目录即可生效。 +## Rules directory + +The rules directory is the **`rules/` subdirectory under the Agent directory**: `<agent.dir>/rules/` (see [Config file reference · agent](04-config-reference.md#agent--advanced-agent--autopilot)). There's no separate rules path to configure — just drop rule files into that directory and they take effect. ``` -<agent.dir>/ # 默认 ~/.code-meeseeks/agent,可由 agent.dir 指向自定义 / git 仓库 -└── rules/ # 规则目录:放入 .md 规则文件 +<agent.dir>/ # defaults to ~/.code-meeseeks/agent; agent.dir can point at a custom / git repo +└── rules/ # rules directory: put .md rule files here ├── fx-amount.md └── api-breaking.md ``` -`agent.dir` 留空时默认 `~/.code-meeseeks/agent`;指向一个 git 仓库即可让团队共享与版本化规则。目录下可按子目录组织,应用会递归扫描所有 `.md`。 +When `agent.dir` is empty it defaults to `~/.code-meeseeks/agent`; pointing it at a git repo lets a team share and version their rules. You can organize files into subdirectories — the app recursively scans all `.md` files. -## 规则文件结构 +## Rule file structure ```markdown --- @@ -30,53 +32,53 @@ priority: 10 enabled: true --- -- 公共方法必须有 JSDoc,说明参数与返回值。 -- 金额一律用整数分存储,禁止浮点。 -- 对外接口变更需在 PR 描述里标注「Breaking」。 +- Public methods must have JSDoc describing parameters and return values. +- Always store amounts as integer cents; floating point is forbidden. +- Changes to external interfaces must be flagged "Breaking" in the PR description. ``` -- `---` 之间是 **frontmatter(YAML)**,声明命中条件;可整段省略。 -- `---` 之后是**正文(markdown)**,作为命中后注入 AI 的指令,用清晰的祈使句逐条写效果最好。 +- Between the `---` markers is the **frontmatter (YAML)**, declaring match conditions; the whole block may be omitted. +- After the `---` is the **body (markdown)**, injected as the AI's instruction once matched — clear, imperative, item-by-item statements work best. -### frontmatter 字段 +### frontmatter fields -| 字段 | 类型 | 默认 | 说明 | +| Field | Type | Default | Description | | --- | --- | --- | --- | -| `applies_to.project` | 正则源串 | 省略 = 匹配任意 | 命中项目标识:Bitbucket 为 project key,GitHub 为组织 / 用户名。 | -| `applies_to.repo` | 正则源串 | 省略 = 匹配任意 | 命中仓库 slug。 | -| `applies_to.target_branch` | 正则源串 | 省略 = 匹配任意 | 命中 PR 的**目标分支**名。 | -| `tools` | 数组 | `[review]` | 规则作用的工具,可取 `review` / `describe`。默认只作用于 `/review`(评审规约注入 `/describe` 会让描述偏题)。 | -| `priority` | 数字 | `0` | 多条规则同时命中时的取舍权重,越大越优先(见下「命中与取舍」)。 | -| `enabled` | 布尔 | `true` | 单条规则开关;`false` 时跳过该文件。 | -| `custom_labels` | 数组 | `[]` | 预留字段,当前版本解析但尚未注入 pr-agent。 | +| `applies_to.project` | regex source string | omitted = matches any | Matches the project identifier: for Bitbucket the project key, for GitHub the org / username. | +| `applies_to.repo` | regex source string | omitted = matches any | Matches the repo slug. | +| `applies_to.target_branch` | regex source string | omitted = matches any | Matches the PR's **target branch** name. | +| `tools` | array | `[review]` | The tools the rule applies to; may be `review` / `describe`. Defaults to `/review` only (injecting review conventions into `/describe` makes the description drift off-topic). | +| `priority` | number | `0` | Weight for tie-breaking when multiple rules match at once — higher is more preferred (see "Matching & tie-breaking" below). | +| `enabled` | boolean | `true` | Per-rule toggle; when `false`, the file is skipped. | +| `custom_labels` | array | `[]` | Reserved field; parsed in the current version but not yet injected into pr-agent. | -> **正则说明**:`applies_to.*` 的值是正则**源串**,**不自动加锚点** `^`/`$`,是否精确匹配由你自己写。例如 `fx` 会匹配任何含 `fx` 的名字;要精确匹配写 `^fx$`。非法正则会被忽略(视为该字段未配置)。 +> **About regex**: the values of `applies_to.*` are regex **source strings**, **not auto-anchored** with `^`/`$` — whether the match is exact is up to you. For example, `fx` matches any name containing `fx`; for an exact match write `^fx$`. An invalid regex is ignored (treated as if the field were unset). -## 命中与取舍 +## Matching & tie-breaking -对某个 PR 执行某个工具时,规则按以下逻辑筛选: +When running a given tool on a given PR, rules are filtered by this logic: -1. **工具过滤**:规则的 `tools` 不含当前工具 → 不命中。**注意 `tools` 缺省为 `[review]`,并非「对所有工具生效」**——不写 `tools` 的规则只作用于 `/review`,不会影响 `/describe`;要同时约束 `/describe`,须显式写 `tools: [describe, review]`。 -2. **范围匹配**:`applies_to` 的每个字段——省略即匹配任意;配置了则该字段值需通过其正则 `.test()`。三项是 **AND** 关系(都要满足)。 +1. **Tool filter**: if the rule's `tools` does not include the current tool → no match. **Note that `tools` defaults to `[review]`, not "applies to all tools"** — a rule without `tools` applies only to `/review` and does not affect `/describe`; to also constrain `/describe`, write `tools: [describe, review]` explicitly. +2. **Scope match**: for each `applies_to` field — omitted means match any; if set, the field value must pass its regex `.test()`. The three are combined with **AND** (all must hold). -> 与 `applies_to` 的「省略 = 匹配任意」相反,`tools` 的缺省是一个**具体默认值** `[review]`,不是「任意工具」——这点容易混淆,记住「不写 = 仅 review」。 +> Unlike `applies_to`'s "omitted = matches any", the default of `tools` is a **concrete default value** `[review]`, not "any tool" — this is an easy point of confusion, so remember "unset = review only". -> **多条规则一并生效**:同一 PR + 工具命中的多条规则会**全部注入**评审——按 `priority` 降序、再按文件路径升序排列,各规则正文以 `Ruleset 1 / 2 / …` 分段拼接传给 AI,互不串味。`priority` 决定排列先后(越大越靠前)。为安全起见单次最多注入 **20** 条命中规则,超出按排序丢弃靠后者。 +> **Multiple rules apply together**: all rules matching the same PR + tool are **injected together** — sorted by `priority` descending, then by file path ascending, with each rule's body concatenated as `Ruleset 1 / 2 / …` segments and passed to the AI, so they don't bleed into one another. `priority` determines the ordering (higher goes first). As a safeguard, at most **20** matched rules are injected per run; any beyond that are dropped from the tail of the sort order. -## 全局基础规约 +## Global base conventions -不写 frontmatter(或留空)的规则文件 = **匹配任意 PR** 的基础规约(`tools` 默认 `[review]`)。适合放一份团队通用约定: +A rule file without frontmatter (or with it empty) = base conventions that **match any PR** (`tools` defaults to `[review]`). Good for a shared set of team-wide conventions: ```markdown -评审时请重点关注: -- 错误处理是否完整,是否吞掉异常。 -- 是否有重复代码可抽取复用。 -- 命名是否清晰、与周边代码风格一致。 +When reviewing, focus on: +- Whether error handling is complete and no exceptions are swallowed. +- Whether there's duplicated code that could be extracted and reused. +- Whether naming is clear and consistent with the surrounding code style. ``` -## 示例 +## Examples -**按目标分支收紧**:只对合并到 `release/*` 的 PR 强化检查。 +**Tighten by target branch**: harden checks only for PRs merging into `release/*`. ```markdown --- @@ -86,11 +88,11 @@ tools: [review] priority: 20 --- -- 这是发布分支,禁止引入新依赖。 -- 任何行为变更必须有对应测试覆盖。 +- This is a release branch; introducing new dependencies is forbidden. +- Any behavior change must have corresponding test coverage. ``` -**按仓库定制**:只对某仓库生效。 +**Customize by repo**: apply only to a specific repo. ```markdown --- @@ -98,13 +100,13 @@ applies_to: repo: '^payment-service$' --- -- 涉及金额计算的改动需双人复核,评审中标注风险点。 +- Changes involving amount calculations need two-person review; flag risk points in the review. ``` -## 注意事项 +## Notes -- **改动即生效**:规则文件增删改后,下次触发评审即按最新内容加载,无需重启。 -- **单文件解析失败不影响整体**:某个文件 frontmatter YAML 写坏 / 字段类型不对,应用会跳过该文件并继续加载其余规则。 -- **当前命中提示**:选中 PR 后,chat 面板会显示本次命中的规则条数,点击可预览全部命中规则(按 Ruleset 分段列出),便于确认本次评审受哪些规则约束。 +- **Changes take effect immediately**: after adding / removing / editing rule files, the next review loads the latest content, no restart needed. +- **A single parse failure doesn't affect the rest**: if one file's frontmatter YAML is malformed or a field has the wrong type, the app skips that file and continues loading the others. +- **Current-match hint**: after selecting a PR, the chat panel shows how many rules matched this run; click to preview all matched rules (listed by Ruleset segment), so you can confirm which rules constrain this review. -> 设计与实现细节见架构文档 [docs/arch/02-agent/04-rules.md](../arch/02-agent/04-rules.md)。 +> For design and implementation details, see the architecture doc [docs/arch/02-agent/04-rules.md](../arch/02-agent/04-rules.md). diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index f83704a5..2391bda6 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -1,161 +1,140 @@ -# CLI 命令行工具(meebox) +# CLI Tool (meebox) -`meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 -评审 Agent 操作接入脚本、CI 或外部 agent。命令行提供**浏览与评审操作**,含评审决断(approve / needswork) -与发评论;不含合并(merge)等高影响写操作。 +**English** · [简体中文](zh-CN/06-cli.md) -## 1. 开启本地 API 服务 +`meebox` is the cross-platform command-line tool shipped with each release. It reaches the app's capabilities through the local "local API service", making it easy to wire PR browsing and review-agent operations into scripts, CI, or an external agent. The CLI provides **browsing and review actions**, including review decisions (approve / needswork) and posting comments; it does not include high-impact write operations such as merge. -CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 集成** 开启: +## 1. Enable the local API service -- 打开「本地 API 服务」开关(首次开启会自动生成一枚访问令牌)。 -- **监听地址**:默认 `http://127.0.0.1:18765`(仅本机可达)。如需被同网段的其他机器 / CI 访问,可把 - host 改为 `0.0.0.0` 或本机局域网 IP——此时**令牌是唯一防线**,请妥善保密并配合防火墙。 -- **访问令牌**:可显示 / 复制 / 重新生成;重新生成后旧令牌立即失效。 +The CLI depends on the app's local API service, which is off by default and must first be enabled under **Settings → Integration**: -## 2. 获取 CLI +- Turn on the "Local API service" toggle (the first time you enable it, an access token is generated automatically). +- **Listen address**: defaults to `http://127.0.0.1:18765` (reachable only from the local machine). To let other machines / CI on the same network reach it, change the host to `0.0.0.0` or your machine's LAN IP — at that point **the token is the only line of defense**, so keep it secret and pair it with a firewall. +- **Access token**: can be shown / copied / regenerated; regenerating invalidates the old token immediately. -**macOS / Linux 一键安装**——自动下载最新版、校验 SHA-256、装到 `PATH`: +## 2. Get the CLI + +**One-line install on macOS / Linux** — downloads the latest version, verifies its SHA-256, and installs to `PATH`: ```bash curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash ``` -脚本自动探测系统 / 架构并拉取匹配的 Release 压缩包,把 `meebox` 装到 `/usr/local/bin`(不可写则回退 -`~/.local/bin`)。可用环境变量 `MEEBOX_VERSION`(装指定版本)、`MEEBOX_BIN_DIR`(指定安装目录)调整。 -无需单独安装 `SKILL.md`——它已内嵌进二进制(`meebox skill` 可打印)。 +The script auto-detects your OS / architecture, pulls the matching Release archive, and installs `meebox` to `/usr/local/bin` (falling back to `~/.local/bin` if that isn't writable). Use the environment variables `MEEBOX_VERSION` (install a specific version) and `MEEBOX_BIN_DIR` (specify the install directory) to adjust. There's no need to install `SKILL.md` separately — it's embedded in the binary (`meebox skill` prints it). -**手动下载**(Windows,或不便用脚本时):从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) -下载对应平台压缩包(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 放到 `PATH`。 +**Manual download** (Windows, or when the script is inconvenient): from the [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases), download the archive for your platform (`meebox-cli-<version>-<os>-<arch>.zip` / `.tar.gz`), extract it, and put `meebox` on your `PATH`. -覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。压缩包内含 `meebox` 二进制、`LICENSE`、`README.md` -与 `SKILL.md`(作为 agent skill 投放见 [第 6 节](#6-作为-agent-skill-集成))。 +Covered platforms: Windows x64, macOS arm64, Linux x64 / arm64. The archive contains the `meebox` binary, `LICENSE`, `README.md`, and `SKILL.md` (for dropping in as an agent skill, see [section 6](#6-integrating-as-an-agent-skill)). -## 3. 连接方式 +## 3. How to connect -`meebox` 按以下优先级解析 API 地址与令牌(高 → 低): +`meebox` resolves the API address and token in the following priority order (high → low): -1. 命令行参数:`--api-url` / `--token` -2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` -3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) +1. Command-line flags: `--api-url` / `--token` +2. Environment variables: `MEEBOX_API_URL` / `MEEBOX_TOKEN` +3. CLI config file: `~/.code-meeseeks/cli.yaml` (fields `api_url` / `token`) -连接信息须**显式提供**其一。令牌在设置页「集成」分区查看 / 复制。最省事的方式是用 `meebox login` 存一次令牌 -(写入 `cli.yaml`),之后所有命令免传参: +Connection info must be **provided explicitly** by one of these. View / copy the token in the "Integration" section of the Settings page. The easiest approach is to store the token once with `meebox login` (written to `cli.yaml`), after which all commands need no arguments: ```bash -meebox login --token <令牌> # 默认连本机 http://127.0.0.1:18765 -meebox login --token <令牌> --server http://<主机>:18765 # 指定远端服务 -meebox pr list # 后续命令直接用已存的凭据 +meebox login --token <token> # connects to http://127.0.0.1:18765 by default +meebox login --token <token> --server http://<host>:18765 # specify a remote service +meebox pr list # subsequent commands use the stored credentials ``` -或用环境变量(适合 CI / shell 注入): +Or use environment variables (handy for CI / shell injection): ```bash export MEEBOX_API_URL=http://127.0.0.1:18765 -export MEEBOX_TOKEN=<令牌> +export MEEBOX_TOKEN=<token> meebox pr list ``` -远端访问(服务监听 `0.0.0.0`)同样显式提供地址与令牌: +Remote access (service listening on `0.0.0.0`) likewise provides the address and token explicitly: ```bash -meebox --api-url http://<主机>:18765 --token <令牌> pr list +meebox --api-url http://<host>:18765 --token <token> pr list ``` -> CLI **不读取** GUI 主配置 `~/.code-meeseeks/config.yaml`:该文件含代码平台访问令牌等连接层机密, -> 不从中取服务令牌,避免越权触达预期外的凭据。API 地址默认 `http://127.0.0.1:18765`(未显式指定时)。 +> The CLI **does not read** the GUI's main config `~/.code-meeseeks/config.yaml`: that file holds connection-layer secrets such as code-platform access tokens, and the service token is not taken from it, to avoid over-reaching into credentials it doesn't need. The API address defaults to `http://127.0.0.1:18765` (when not specified explicitly). -## 4. 命令 +## 4. Commands ```text -meebox [全局参数] <组> <命令> [参数] +meebox [global flags] <group> <command> [args] ``` -根层级的系统性命令 `whoami` / `version` 与具体 PR 无关;其余命令分 `pr`(PR 操作,含 `categories` 筛选词表 -与 `refresh` 刷新)与 `agent`(评审 Agent 操作)两个领域组,其 PR 维度子命令用**必填参数 `--pr <id>`** 指定 -PR(`id` 由 `meebox pr list` 输出获得)。 +The root-level system commands `whoami` / `version` are unrelated to any specific PR; the rest are split into two domain groups — `pr` (PR operations, including the `categories` filter vocabulary and `refresh`) and `agent` (review-agent operations). Their PR-scoped subcommands specify the PR with the **required flag `--pr <id>`** (the `id` comes from `meebox pr list` output). -| 命令 | 用途 | +| Command | Purpose | | --- | --- | -| `meebox login --token <令牌> [--server <地址>]` | 保存令牌(与可选服务地址)到 `cli.yaml`,后续命令免传参 | -| `meebox whoami` | 当前登录身份与集成平台(用户 + 平台 + 连接名) | -| `meebox version` | 客户端(CLI)+ 服务端(应用)版本;未连接服务端时仅显示客户端版本 | -| `meebox skill` | 打印内嵌的使用说明(SKILL.md),便于二进制脱离压缩包时自述用法 | -| `meebox pr categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选)——`pr list` 的筛选词表 | -| `meebox pr refresh` | 触发一次立即刷新(拉取最新 PR),返回本轮变化计数(新增 / 变更 / 移除等);等同 GUI 里的手动刷新 | -| `meebox pr list [--category <一级>] [--status <二级>] [--query <检索>] [--skip N] [--limit N]` | PR 列表(精简字段 + 分页,默认 limit 100) | -| `meebox pr show --pr <id>` | PR 描述详情 | -| `meebox pr diff --pr <id> [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | -| `meebox pr activity --pr <id>` | 活动时间线(评论 / 提交 / 评审决断) | -| `meebox pr commits --pr <id>` | 提交列表 | -| `meebox pr reviewers --pr <id>` | 评审人审批状态 | -| `meebox pr approve --pr <id>` | 将 PR 标记为「通过」(发送真实评审决断到平台) | -| `meebox pr needswork --pr <id>` | 将 PR 标记为「需修改」(发送真实评审决断到平台) | -| `meebox pr comment --pr <id> <消息>` | 发一条顶层评论到平台 | -| `meebox agent status --pr <id>` | 评审 Agent 当前执行状态 | -| `meebox agent history --pr <id>` | 历史会话 | -| `meebox agent review --pr <id>` | 执行一次自动评审 | -| `meebox agent instruct --pr <id> <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | -| `meebox agent chat --pr <id> <消息>` | 发送自然语言消息(可触发 Agent 任务) | -| `meebox agent stop --pr <id>` | 中断该 PR 运行中的评审 Agent(整体停) | -| `meebox agent run list --pr <id>` | 列出该 PR 运行中 / 排队中的 pr-agent runs | -| `meebox agent run cancel --pr <id> --run <runId>` | 按 run id 取消单个 pr-agent 工具调用 | - -其中 `<id>` 为 PR 的本地标识(列表里的 `id` 字段),由 `meebox pr list` 输出获得。 - -## 5. 输出格式 - -全局参数 `--output`: - -- **`yaml`(默认)**:结构化又易读(类 kubectl `-o yaml`),适合人在终端查看。 -- **`json`**:适合脚本 / 外部 agent 机器解析。 +| `meebox login --token <token> [--server <address>]` | Save the token (and optional service address) to `cli.yaml`; subsequent commands need no arguments | +| `meebox whoami` | Current login identity and integrated platform (user + platform + connection name) | +| `meebox version` | Client (CLI) + server (app) version; when not connected to a server, only the client version is shown | +| `meebox skill` | Print the embedded usage guide (SKILL.md), so the binary can describe its own usage when separated from the archive | +| `meebox pr categories` | List the category labels available on the current platform (top-level discovery categories + second-level status / merge-state filters) — the filter vocabulary for `pr list` | +| `meebox pr refresh` | Trigger an immediate refresh (fetch the latest PRs), returning this round's change counts (added / changed / removed, etc.); equivalent to the manual refresh in the GUI | +| `meebox pr list [--category <top-level>] [--status <second-level>] [--query <search>] [--skip N] [--limit N]` | PR list (compact fields + pagination, default limit 100) | +| `meebox pr show --pr <id>` | PR description details | +| `meebox pr diff --pr <id> [--file <path>] [--side base\|head]` | Without `--file`, list changed files; with it, fetch that file's content | +| `meebox pr activity --pr <id>` | Activity timeline (comments / commits / review decisions) | +| `meebox pr commits --pr <id>` | Commit list | +| `meebox pr reviewers --pr <id>` | Reviewer approval status | +| `meebox pr approve --pr <id>` | Mark the PR as "approved" (sends a real review decision to the platform) | +| `meebox pr needswork --pr <id>` | Mark the PR as "needs work" (sends a real review decision to the platform) | +| `meebox pr comment --pr <id> <message>` | Post a top-level comment to the platform | +| `meebox agent status --pr <id>` | The review agent's current execution status | +| `meebox agent history --pr <id>` | Session history | +| `meebox agent review --pr <id>` | Run an automatic review | +| `meebox agent instruct --pr <id> <instruction> [args]` | Send a review instruction (`describe` / `review` / `ask` / `improve`) | +| `meebox agent chat --pr <id> <message>` | Send a natural-language message (may trigger an agent task) | +| `meebox agent stop --pr <id>` | Interrupt the running review agent for this PR (stops it as a whole) | +| `meebox agent run list --pr <id>` | List the running / queued pr-agent runs for this PR | +| `meebox agent run cancel --pr <id> --run <runId>` | Cancel a single pr-agent tool call by run id | + +Here `<id>` is the PR's local identifier (the `id` field in the list), obtained from `meebox pr list` output. + +## 5. Output format + +The global flag `--output`: + +- **`yaml` (default)**: structured yet readable (like kubectl `-o yaml`), suited to a human viewing it in a terminal. +- **`json`**: suited to machine parsing by scripts / external agents. ```bash meebox pr list --output json | jq '.[].title' ``` -**退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 +**Exit codes**: `0` on success; non-zero on error (`2` auth failure, `3` resource not found, `1` other); error messages go to `stderr`. -## 6. 作为 Agent Skill 集成 +## 6. Integrating as an agent skill -`meebox` 的主要交付形态是**可直接投放的 agent skill**:发布压缩包除二进制外一并含 `SKILL.md` / -`README.md` / `LICENSE`,整个解压目录即是一个可用 skill。 +`meebox`'s primary delivery form is a **ready-to-drop agent skill**: besides the binary, the release archive also contains `SKILL.md` / `README.md` / `LICENSE`, and the whole extracted directory is a usable skill. -- **投放即用**:把解压目录放进 agent 的 skills 目录(如 `~/.claude/skills/meebox/`)。`SKILL.md` - (frontmatter `name: meebox`)向 agent 说明命令树、连接方式与写边界,紧邻其驱动的二进制。 -- **二进制自述**:同一份 `SKILL.md` 于构建期经 `go:embed` 内嵌进二进制,`meebox skill` 可打印之—— - 二进制即便脱离压缩包(如单独放入 `PATH`)也能取回用法,且内容与随包文档构建期一致。 -- **仅有二进制的 fallback**:若手头只有 `meebox` 二进制(缺压缩包 / `SKILL.md` 文件),用 `meebox skill` - 即可从二进制导出说明、就地重建 skill 目录,无需另找原始文件: +- **Drop in and go**: put the extracted directory into the agent's skills directory (e.g. `~/.claude/skills/meebox/`). `SKILL.md` (frontmatter `name: meebox`) describes the command tree, connection method, and write boundaries to the agent, right next to the binary it drives. +- **Self-describing binary**: the same `SKILL.md` is embedded into the binary at build time via `go:embed`, and `meebox skill` prints it — so even when the binary is separated from the archive (e.g. placed alone on `PATH`), it can retrieve its usage, with content matching the packaged docs at build time. +- **Binary-only fallback**: if all you have is the `meebox` binary (no archive / `SKILL.md` file), `meebox skill` exports the guide from the binary and rebuilds the skill directory in place, with no need to hunt down the original files: ```bash mkdir -p ~/.claude/skills/meebox - cp "$(command -v meebox)" ~/.claude/skills/meebox/ # 二进制放入 skill 目录 - meebox skill > ~/.claude/skills/meebox/SKILL.md # 从内嵌副本导出说明 + cp "$(command -v meebox)" ~/.claude/skills/meebox/ # put the binary into the skill directory + meebox skill > ~/.claude/skills/meebox/SKILL.md # export the guide from the embedded copy ``` - 导出的内容与该二进制同源,天然匹配当前版本。 -- **集成流程**:读 `SKILL.md` 了解能力 → `meebox login` 存一次凭据 → 以 `meebox pr list` / `pr show` / - `agent review` 等浏览与驱动评审 → 用 `meebox pr approve` / `needswork` / `comment` 记录结论;机器消费统一 - 取 `--output json`(其字段形状为稳定契约)。 -- **边界内建**:仅开放浏览 + 评审写动作,不含合并与变更类工具(详见下「注意事项」),agent 集成天然不会触发 - 高影响远端操作。 -- **框架无关的接入**:`SKILL.md` 的自动发现是 Claude Code 的 skill 约定,并非跨框架标准。其它 agent / 脚本 - 无需依赖该约定即可集成——直接以 shell 调用 `meebox`、用 `meebox skill` 或 `--help` 取用法、`--output json` - 取结构化结果。真正可移植的接口是「命令行 + JSON」,`SKILL.md` 自动发现只是 Claude 生态的锦上添花。 + The exported content shares its source with that binary, so it naturally matches the current version. +- **Integration flow**: read `SKILL.md` to learn the capabilities → `meebox login` to store credentials once → browse and drive reviews with `meebox pr list` / `pr show` / `agent review`, etc. → record conclusions with `meebox pr approve` / `needswork` / `comment`; machine consumers uniformly use `--output json` (whose field shapes are a stable contract). +- **Boundaries built in**: only browsing + review write actions are exposed, with no merge or change-type tools (see "Notes" below), so agent integration inherently cannot trigger high-impact remote operations. +- **Framework-agnostic integration**: `SKILL.md` auto-discovery is a Claude Code skill convention, not a cross-framework standard. Other agents / scripts can integrate without relying on it — invoke `meebox` directly from the shell, get usage via `meebox skill` or `--help`, and get structured results via `--output json`. The truly portable interface is "command line + JSON"; `SKILL.md` auto-discovery is just a nice extra in the Claude ecosystem. -## 网络代理 +## Network proxy -`meebox` 遵循标准的 HTTP 代理环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,大小写均可),无需额外配置: +`meebox` honors the standard HTTP proxy environment variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, any case), with no extra configuration: -- 访问**本机**服务(`127.0.0.1` / `localhost`)自动直连、不走代理。 -- 访问**远端**服务(如经 `0.0.0.0` 暴露的机器)时,若设了 `HTTP_PROXY` 则经其出网;可用 `NO_PROXY` 排除特定主机。 +- Access to the **local** service (`127.0.0.1` / `localhost`) connects directly, bypassing the proxy. +- Access to a **remote** service (e.g. a machine exposed via `0.0.0.0`) goes through `HTTP_PROXY` if set; use `NO_PROXY` to exclude specific hosts. -## 注意事项 +## Notes -- **写能力范围**:CLI 提供评审写动作——`pr approve` / `pr needswork`(发送真实评审决断)与 `pr comment` - (发顶层评论);但**不提供合并(merge)与变更类 Agent 工具(publish 等)**,有此需求请自行对接代码平台。 -- **令牌安全**:服务令牌在 GUI 的 `~/.code-meeseeks/config.yaml` 明文存储;若写入 CLI 的 `~/.code-meeseeks/cli.yaml` - 同为明文。监听 `0.0.0.0` 暴露到局域网时尤需保密,并及时通过「重新生成」吊销泄露的令牌。 -- **版本兼容**:若 `meebox` 版本低于应用要求的下限,任意命令都会收到「CLI 过旧、请升级」提示(含双方版本); - 按上文「获取 CLI」重装最新版即可。CLI 与应用版本同源发布,正常同步升级不会遇到。 +- **Scope of write capability**: the CLI provides review write actions — `pr approve` / `pr needswork` (sending real review decisions) and `pr comment` (posting a top-level comment); but it **does not provide merge or change-type agent tools (publish, etc.)** — for those, integrate with the code platform yourself. +- **Token safety**: the service token is stored in plaintext in the GUI's `~/.code-meeseeks/config.yaml`; if written into the CLI's `~/.code-meeseeks/cli.yaml`, it is likewise plaintext. Keep it especially secret when listening on `0.0.0.0` exposes it to the LAN, and promptly revoke a leaked token via "Regenerate". +- **Version compatibility**: if `meebox`'s version is below the minimum the app requires, any command returns a "CLI too old, please upgrade" notice (with both versions); reinstall the latest per "Get the CLI" above. The CLI and app are released from the same source, so a normal in-sync upgrade won't hit this. diff --git a/docs/guide/README.md b/docs/guide/README.md index 445da964..a8142ab2 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -1,24 +1,26 @@ -# 使用说明 +# User Guide -Code Meeseeks 是本地运行的 PR 评审客户端:连上你的代码平台 → 拉取待评审的 PR → 用 LLM(pr-agent)做 `/describe` `/review`,评论 / 审批 / 合并都在本地客户端完成。 +**English** · [简体中文](zh-CN/README.md) -本目录是**面向用户**的安装与配置说明(开发/架构文档见 [../arch/](../arch/README.md))。各项配置都在应用内 **设置** 页可视化编辑,首次启动也有配置向导引导最快进入可用状态。 +Code Meeseeks is a locally-run PR review client: connect to your code platform → pull the PRs awaiting your review → run `/describe` and `/review` with an LLM (pr-agent), and do all commenting / approving / merging right in the local client. -## 篇目 +This directory is the **user-facing** installation and configuration guide (for development / architecture docs see [../arch/](../arch/README.md)). Every setting is visually editable on the in-app **Settings** page, and a first-launch setup wizard guides you to a working state as quickly as possible. -| 文档 | 内容 | +## Contents + +| Document | Covers | | --- | --- | -| [00 · 安装与首次使用](00-getting-started.md) | 系统要求、下载安装、首次启动配置向导 | -| [01 · 代码平台配置](01-code-platform.md) | 接入 GitHub / Bitbucket Server / GitLab:Base URL、访问令牌(PAT)权限、Clone 协议 | -| [02 · LLM 配置](02-llm.md) | 选 LLM 服务商与模型;含进阶的本地 CLI 模式(经授权调用本机 agentic CLI,以本机登录态执行评审) | -| [03 · 网络代理配置](03-proxy.md) | 内网 / 受限网络下统一走 HTTP 代理出网 | -| [04 · 配置文件参考](04-config-reference.md) | `config.yaml` 完整结构与各配置项功能说明(含高级参数) | -| [05 · 自定义评审规则](05-rules.md) | 编写规则 `.md` 文件:frontmatter 命中条件 + 正文注入 AI 的评审指令 | -| [06 · CLI 命令行工具](06-cli.md) | 开启本地 API 服务 + 用 `meebox` 命令行浏览 PR / 操作评审 Agent(供脚本 / 外部 agent 集成) | +| [00 · Installation & first use](00-getting-started.md) | System requirements, download & install, first-launch setup wizard | +| [01 · Code platform setup](01-code-platform.md) | Connecting GitHub / Bitbucket Server / GitLab: Base URL, access token (PAT) permissions, clone protocol | +| [02 · LLM setup](02-llm.md) | Choosing an LLM provider and model; includes the advanced local CLI mode (invoke your machine's agentic CLI, with your authorization, to run reviews under its local session) | +| [03 · Network proxy setup](03-proxy.md) | Route all outbound traffic through an HTTP proxy on intranet / restricted networks | +| [04 · Config file reference](04-config-reference.md) | Full structure of `config.yaml` and every field's purpose (including advanced parameters) | +| [05 · Custom review rules](05-rules.md) | Writing rule `.md` files: frontmatter match conditions + body instructions injected into the AI | +| [06 · CLI tool](06-cli.md) | Enable the local API service + use the `meebox` CLI to browse PRs / drive the review agent (for scripts / external agent integration) | -## 通用须知 +## General notes -- **数据目录固定** `~/.code-meeseeks/`:配置、状态、日志都在这里;`config.yaml` 是唯一配置文件,含连接 / LLM / 代理等全部设置。 -- **凭据安全**:访问令牌、API Key、代理密码等请按最小授权配置,及时吊销泄露的令牌。 -- **保存即生效**:设置页每项保存后写盘并热更新,无需重启;连接 / 代理变更会即时重建生效。 -- **高级编辑**:设置页提供「用系统关联程序打开 config.yaml」,可直接改文件(适合批量 / 高级配置)。 +- **Fixed data directory** `~/.code-meeseeks/`: config, state, and logs all live here; `config.yaml` is the sole config file, holding all connection / LLM / proxy settings. +- **Credential safety**: configure access tokens, API keys, proxy passwords, etc. with least privilege, and revoke leaked tokens promptly. +- **Save takes effect immediately**: each setting is written to disk and hot-reloaded on save, no restart needed; connection / proxy changes are rebuilt and applied instantly. +- **Advanced editing**: the Settings page offers "Open config.yaml with the system default app" so you can edit the file directly (handy for bulk / advanced configuration). diff --git a/docs/guide/zh-CN/00-getting-started.md b/docs/guide/zh-CN/00-getting-started.md new file mode 100644 index 00000000..4e95e00c --- /dev/null +++ b/docs/guide/zh-CN/00-getting-started.md @@ -0,0 +1,39 @@ +# 安装与首次使用 + +[English](../00-getting-started.md) · **简体中文** + +## 系统要求 + +- **操作系统**:Windows 10 / 11(x64)或 macOS(Apple Silicon / arm64)。当前不提供 Intel Mac 与 Linux 安装包。 +- **git**:本机需已安装 git,并在 PATH 中。客户端克隆仓库、读取本地 diff 都依赖系统 git。 +- 评审与生成内容需要一个可用的 LLM(见 [LLM 配置](02-llm.md));嵌入式运行时已随应用打包,无需另装 Python 或 Docker。 + +## 安装 + +从项目的 GitHub Releases 页面下载对应平台的安装包: + +- **Windows**:`code-meeseeks-<版本>-win-x64.exe`(NSIS 安装程序),双击按提示安装。 +- **macOS**:`code-meeseeks-<版本>-mac-arm64.dmg`,打开后将应用拖入「应用程序」。 + +### macOS 首次打开 + +当前 macOS 包为 ad-hoc 签名、未做公证,首次打开会被系统拦下。任选一种方式放行: + +- 在「应用程序」中**右键点击应用 → 打开 → 仍要打开**; +- 或 **系统设置 → 隐私与安全性**,在拦截提示处点「仍要打开」; +- 或在终端执行 `xattr -dr com.apple.quarantine "/Applications/Code Meeseeks.app"`。 + +## 首次使用 + +首次启动会自动创建数据目录并打开**配置向导**,按引导最快进入可用状态: + +1. 配置一条**代码平台连接**——详见 [代码平台配置](01-code-platform.md)。 +2. (可选)配置 **LLM**——详见 [LLM 配置](02-llm.md)。不配也能浏览 PR,但 `/describe`、`/review` 需要可用的 LLM。 + +完成向导后,客户端开始轮询,列出待你评审的 PR。 + +## 接下来 + +- 选中一个 PR:查看 diff,运行 `/describe`、`/review`,并进行评论 / 审批 / 合并。 +- 处于内网 / 受限网络:先配置[网络代理](03-proxy.md)。 +- 已有 Claude / Codex 等订阅:可用[本地 CLI 模式](02-llm.md#本地-cli-模式)以本机登录态执行评审。 diff --git a/docs/guide/zh-CN/01-code-platform.md b/docs/guide/zh-CN/01-code-platform.md new file mode 100644 index 00000000..efc0368f --- /dev/null +++ b/docs/guide/zh-CN/01-code-platform.md @@ -0,0 +1,189 @@ +# 代码平台配置 + +[English](../01-code-platform.md) · **简体中文** + +接入你的代码托管平台,客户端才能发现待评审的 PR、读 diff、发评论 / 审批 / 合并。目前支持: + +- **GitHub**(github.com 与 GitHub Enterprise Server) +- **Bitbucket Server / Data Center**(REST API v1,≥ 7.0) +- **GitLab**(gitlab.com 与 Self-Managed,CE / EE,REST API v4,≥ 13.8,推荐 15.6+) + +## 添加连接 + +在 **设置 → 连接**(或首启向导)新建一条连接,填写: + +| 字段 | 说明 | +| --- | --- | +| 显示名 | 给人看的名字,可任意取 | +| Base URL | 平台 API 地址,见下方各平台说明 | +| 访问令牌(PAT) | 平台生成的 Personal Access Token,用于 REST API 鉴权 | +| Clone 协议 | `pat`(默认,HTTPS 内嵌令牌克隆)或 `ssh`(走系统 `~/.ssh/config`) | + +> 连接可配置多条,但**同时只启用一条**进行轮询;按 id 查历史 PR 不受影响。 +> 建议按最小授权配置访问令牌。连接保存后可点「测试」验证连通。 + +## Clone 协议 + +- **pat(默认)**:克隆走 HTTPS,URL 里内嵌令牌,无需额外配置。 +- **ssh**:克隆走 `git@host:...`,端口 / 密钥由系统 `~/.ssh/config` 决定,**与 PAT 无关**(PAT 仅用于 REST API)。GHE / Bitbucket 自定义 SSH 端口(如 Bitbucket 默认 7999)需在 ssh config 里配好。 + +## 平台能力对比 + +各平台原生支持的能力不同,客户端按**活动连接的平台**动态调整界面——不支持的操作对应的按钮或标签不渲染、或灰显不可用。下表汇总主要差异(✅ 支持 / ❌ 不支持)。 + +| 分类 | 能力 | GitHub | Bitbucket | GitLab | +| --- | --- | :---: | :---: | :---: | +| 发现过滤 | 待我评审(请求我评审的 PR / MR) | ✅ | ✅ | ✅ | +| 发现过滤 | 我创建的(我作为作者的 PR / MR) | ✅ | ✅ | ✅ | +| 发现过滤 | 指派给我 | ✅ | ❌ | ✅ | +| 发现过滤 | 提及我(正文 / 评论 @ 我) | ✅ | ❌ | ❌ | +| 评论 | 发 / 回复 / 编辑 / 删除评论 | ✅ | ✅ | ✅ | +| 评论 | 行内评论 | ✅ | ✅ | ✅¹ | +| 审批 | 通过 | ✅ | ✅ | ✅² | +| 审批 | 需修改 | ✅ | ✅ | ❌ | +| 审批 | 撤销 | ✅ | ✅ | ✅² | +| 合并 | 合并 PR / MR | ✅ | ✅ | ✅ | +| 合并 | 合并受阻原因展示 | ✅³ | ✅ | ✅ | + +> 侧栏「发现分类」标签按平台能力显示,不支持的分类不渲染对应标签。 +> +> - ¹ GitLab 的行内评论仅支持单行选区,GitHub / Bitbucket 支持跨多行。 +> - ² GitLab 审批为 **EE(Premium / Ultimate)** 功能,社区版(CE)灰显不可用;且 GitLab 审批是二元的——**仅「通过 / 撤销」,无「需修改」**。详见 [3.2 CE / EE 审批差异](#32-ce--ee-审批差异)。 +> - ³ GitHub 仅给出近似的可合并状态,Bitbucket / GitLab 展示精确的受阻原因(冲突 / 待审批 / 流水线未过等)。 + +--- + +## 一、GitHub:Personal Access Token 权限参考 + +接入 GitHub(github.com 或 GitHub Enterprise Server)需要一个 **Personal Access Token (PAT)**。本节给出最小权限集。 + +> 连接里的 **Base URL**:github.com **留空即可**(默认 `https://api.github.com`);GitHub Enterprise Server 填**实例地址**即可,如 `https://<你的 GHE 域名>`——`/api/v3` 会自动补全(手填完整 API base 也兼容)。 + +### 1.1 经典 Token(Classic PAT)— 推荐 + +本客户端会**跨项目 / 跨仓库**轮询发现待评审的 PR,覆盖范围通常不固定。经典 token 按 scope 授权、自动覆盖你有权限的全部仓库,最契合这种用法,是本客户端的推荐方式。 + +适用 github.com 与 GHE Server。创建:**Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token**。 + +勾选的 scope: + +| Scope | 用途 | 何时需要 | +| --- | --- | --- | +| `repo` | 私有仓库的 PR 读写、评论、审批、合并、克隆 | 评审**私有**仓库时(最常见) | +| `public_repo` | 仅公开仓库的上述操作 | 只评审**公开**仓库时(替代 `repo`) | +| `read:user` | 读取当前用户信息(`/user`) | 可选;多数情况无需单独勾也能用 | + +**评审私有仓库 → 勾 `repo` 即可**;只评审公开仓库 → 勾 `public_repo`。 + +> **组织开启了 SAML SSO**:生成 token 后,需在 token 页面点 **Configure SSO / Authorize** 为对应组织授权,否则访问该组织仓库会 403。 + +### 1.2 细粒度 Token(Fine-grained PAT)— 仅适合固定仓库集 + +细粒度 token 必须**逐个枚举授权仓库**,权限更细、更安全,但需要预先固定仓库范围——与本客户端跨项目轮询的用法不太契合(新增项目 / 仓库都要回去补授权)。**仅当你只评审固定的少数仓库**时适合用它;否则建议用上面的经典 token。 + +创建:**Settings → Developer settings → Personal access tokens → Fine-grained tokens**。 + +- **Repository access**:选择你要评审的仓库(或某组织下全部)。 +- **Repository permissions**(仓库权限): + +| 权限 | 级别 | 用途 | +| --- | --- | --- | +| Metadata | Read(强制,自动包含) | 基础元数据 / 仓库可见 | +| Pull requests | **Read and write** | 列 PR、读评论、发行内 / 普通评论、回复 / 编辑 / 删除、提交审批(通过 / 需修改 / 撤销) | +| Contents | **Read and write** | 克隆仓库(read)+ **合并 PR**(merge 写目标分支,需 write) | +| Checks | Read(可选) | 让「可合并状态」更准确(识别必需检查未通过) | +| Commit statuses | Read(可选) | 同上,识别 status 检查 | + +最小可用集:**Pull requests: RW + Contents: RW + Metadata: R**。 +只读不合并也不发评论的话,可降到 Pull requests / Contents 均为 Read(但本客户端的评论 / 审批 / 合并会不可用)。 + +> 细粒度 token 在 **GitHub Enterprise Server** 上的可用性随版本而定;较老的 GHE 只支持经典 token——这种情况用上面的 Classic PAT(`repo`)。 + +### 1.3 按客户端操作对应的权限速查 + +| 客户端操作 | 端点 | Classic | Fine-grained | +| --- | --- | --- | --- | +| 发现待我评审的 PR | `GET /search/issues` | repo / public_repo | Pull requests: R | +| 读 PR / 评论 / 提交 | `GET /pulls`、`/issues/{n}/comments`、`/pulls/{n}/commits` | 同上 | Pull requests: R(+ Contents: R 取提交) | +| 发 / 改 / 删 评论、回复 | `POST/PATCH/DELETE …/comments` | 同上 | Pull requests: **RW** | +| 审批(通过 / 需修改 / 撤销) | `POST …/reviews`、`PUT …/reviews/{id}/dismissals` | 同上 | Pull requests: **RW** | +| 合并 PR | `PUT …/pulls/{n}/merge` | 同上 | Contents: **RW** | +| 克隆仓库(本地 diff) | git over HTTPS(PAT) | 同上 | Contents: **R** | +| 头像 / 评论内嵌图片 | 资源 URL(带 token) | 同上 | 无需额外 | + +### 1.4 注意事项 + +- **不能审批自己创建的 PR**:GitHub 限制(会 422)。客户端已对自己作者的 PR 灰显审批按钮。 +- **合并需要 Contents 写权限**:仅给 Pull requests 写而漏了 Contents 写时,评论 / 审批可用但合并会失败。 +- **SSH 克隆**:连接的 Clone 协议选 SSH 时,走系统 `~/.ssh/config`,与 PAT 无关(PAT 仅用于 REST API)。 +- **限流**:发现走 GitHub Search(约 30 次/分钟),客户端会按平台节流。 +- **安全**:按最小必要范围授权,离职 / 泄露时及时吊销。 + +--- + +## 二、Bitbucket Server / Data Center + +- **Base URL**:填服务器根地址,如 `https://bitbucket.your-company.com`。 +- **访问令牌**:在 Bitbucket 个人设置 → **HTTP access tokens(个人访问令牌)** 创建。 +- **权限**:授予目标项目 / 仓库的 **Repository: Write**(写含读)。 + - 只读评审(不评论 / 不合并)可降到 **Repository: Read**,但客户端的评论 / 审批 / 合并将不可用。 + - 合并 PR 需要仓库写权限。 +- **克隆 URL 形态**:pat → `https://<user>:<PAT>@host/scm/<proj>/<repo>.git`(用户名取当前登录用户);ssh → `git@host:<proj>/<repo>.git`。 + +--- + +## 三、GitLab(gitlab.com / Self-Managed,CE / EE) + +接入 GitLab(gitlab.com 或自建 Self-Managed 实例)需要一个 **Personal Access Token (PAT)**。本节给出最小权限集。 + +> 连接里的 **Base URL**:gitlab.com **留空即可**(默认 `https://gitlab.com/api/v4`);Self-Managed 填**实例地址**即可,如 `https://<你的 GitLab 域名>`——`/api/v4` 会自动补全(手填完整 API base 也兼容)。 + +创建:**右上角头像 → Edit profile → Access Tokens**(或 `User Settings → Access Tokens`)→ Add new token,勾选 scope 并设置有效期。 + +> **版本兼容**:接入走 GitLab REST API v4,覆盖 gitlab.com SaaS 与 Self-Managed(CE / EE)。 +> - **推荐 GitLab 15.6 及以上**:`/metadata`(15.2+)自动探测 edition、`detailed_merge_status`(15.6+)令可合并状态 full 保真,体验最完整。 +> - **最低 GitLab 13.8**:「待我评审」发现依赖 MR Reviewers 的 `reviewer_username` 筛选(13.8 起提供);更低版本该过滤不可用,可改用「我创建的 / 分配给我的」发现过滤。 +> - **13.8 ~ 15.5 自动降级**:缺 `/metadata` 退回 `/version`(保守按 CE、审批 UI 灰显),缺 `detailed_merge_status` 退回 `merge_status`(可合并判断略粗);发现 / 评论 / 合并 / clone 均正常。 +> - **审批(通过 / 撤销)**:属 EE Premium / Ultimate(MR 审批 API 自 13.9),经 edition 探测启用,CE 灰显,详见下文 3.2。 + +### 3.1 Scope(最小授权) + +GitLab PAT 按 scope 授权,自动覆盖你有权限的全部项目,契合本客户端跨项目轮询发现 MR 的用法。 + +| Scope | 用途 | 何时需要 | +| --- | --- | --- | +| `api` | 完整 REST API 读写:MR 发现、读 / 发 / 改 / 删评论与回复、审批(EE)、合并 | 需要评论 / 审批 / 合并时(最常见,**推荐**) | +| `read_api` | 只读 REST API | 仅浏览(不评论 / 不审批 / 不合并)时,替代 `api` | +| `read_repository` | Git-over-HTTPS 克隆 / 拉取私有项目 | Clone 协议为 `pat` 且 token 只给了 `read_api` 时补上 | + +**推荐:单勾 `api`**——它已涵盖 REST API 写操作与 HTTPS 克隆,最省心。 +只读浏览:`read_api` +(pat 克隆再加)`read_repository`。 + +### 3.2 CE / EE 审批差异 + +GitLab 的 MR 审批 API(`approve` / `unapprove`)自 13.9 起属 **Premium / Ultimate(EE 付费版)** 功能,且 GitLab 审批是二元的——**只有「通过 / 撤销」,无「需修改」**。 + +- 客户端经 `/metadata` 探测实例 edition,据此降级审批能力: + - **EE(Premium 及以上)**:审批按钮可用(通过 / 撤销)。 + - **CE / 社区版**:无审批 API,审批按钮 **灰显不可用**;发现 / 评论 / 合并照常。 +- 可合并状态走 `detailed_merge_status`,对合并阻塞原因(冲突 / 待审批 / 流水线未过等)full 保真展示。 + +### 3.3 按客户端操作对应的权限速查 + +| 客户端操作 | 端点 | 所需 scope | +| --- | --- | --- | +| 发现待我评审的 MR | `GET /merge_requests?reviewer_username=…` | `read_api` / `api` | +| 读 MR / 评论(discussions) | `GET …/merge_requests/{iid}`、`/discussions` | `read_api` / `api` | +| 发 / 改 / 删评论、回复 | `POST/PUT/DELETE …/discussions[/notes]` | `api` | +| 审批(通过 / 撤销,仅 EE) | `POST …/approve`、`/unapprove` | `api` | +| 合并 MR | `PUT …/merge` | `api` | +| 克隆仓库(本地 diff) | git over HTTPS(PAT) | `read_repository`(或 `api`) | +| 头像 / 评论内嵌图片 | 资源 URL(带 token) | 无需额外 | + +### 3.4 注意事项 + +- **克隆 URL 形态**:pat → `https://<user>:<PAT>@host/<group>/<repo>.git`(用户名取当前登录用户,支持嵌套 group);ssh → `git@host:<group>/<repo>.git`。 +- **嵌套 group**:路径含多级 group(如 `group/subgroup/proj`)已正确解析。 +- **审批自己的 MR**:受项目「阻止作者审批」等服务端设置约束,按 GitLab 规则裁决,客户端透传 API 结果。 +- **SSH 克隆**:Clone 协议选 SSH 时走系统 `~/.ssh/config`,与 PAT 无关(PAT 仅用于 REST API)。 +- **安全**:按最小必要 scope 授权并设置有效期,离职 / 泄露时及时吊销。 diff --git a/docs/guide/zh-CN/02-llm.md b/docs/guide/zh-CN/02-llm.md new file mode 100644 index 00000000..b392815f --- /dev/null +++ b/docs/guide/zh-CN/02-llm.md @@ -0,0 +1,152 @@ +# LLM 配置 + +[English](../02-llm.md) · **简体中文** + +评审内容由 LLM 生成(底层走 pr-agent + litellm)。在 **设置 → LLM** 配置一条或多条「LLM 预设」,用 `active` 切换当前生效的那条。每条预设独立保存 服务商 / 模型 / Base URL / API Key。 + +## 预设字段 + +| 字段 | 说明 | +| --- | --- | +| 名称 | 预设标识(字母 / 数字 / `-` / `_`,1–32 字符),用于切换与日志 | +| Provider | LLM 服务商,决定鉴权与路由方式(见下表) | +| Model | 模型名(多数 provider 只填型号名,客户端自动补 litellm 前缀) | +| Base URL | API 端点;多数官方 provider 有默认值,留空即可 | +| API Key | 鉴权密钥;本地类(本地 CLI / 自建无鉴权服务)不需要 | + +## Provider 一览 + +| Provider | 说明 | Model 示例 | 需 Key | Base URL | +| --- | --- | --- | --- | --- | +| OpenAI | 官方 OpenAI API | `gpt-4o` / `gpt-4o-mini` | 是 | 默认 endpoint,留空 | +| Anthropic | 官方 Anthropic API | `claude-opus-4-8` / `claude-sonnet-4-6` | 是 | 默认 | +| DeepSeek | 官方 DeepSeek API | `deepseek-v4-pro` / `deepseek-v4-flash` | 是 | 默认 | +| 阿里百炼 (DashScope) | OpenAI 兼容入口,含千问 / DeepSeek-on-DashScope | `qwen-max` / `qwen-plus` | 是 | 已内置默认 | +| 火山方舟 (Volcengine Ark) | OpenAI 兼容入口,含豆包 / DeepSeek-on-Ark | `ep-xxxxx` / `doubao-pro-32k` | 是 | 已内置默认 | +| OpenAI 兼容 | 任意遵循 OpenAI 协议的服务(vLLM / 自建代理 / 中转 / **本地 Ollama**) | 平台特定 | 视服务而定 | **必填** | +| **本地 CLI** | 用本机 agentic CLI 执行评审,**不直连 API**(**实验性**,见下文) | `claude` / `codex` | 否 | 不适用 | + +> **关于模型前缀**:各 provider 只需填模型名,客户端会按 provider 自动补全 litellm 路由前缀;已手动带前缀的不会重复添加。 +> +> - Anthropic → 默认补 `anthropic/` +> - DeepSeek → 默认补 `deepseek/` +> - OpenAI 兼容 / 阿里百炼 / 火山方舟 → 默认补 `openai/` +> - OpenAI → 直接使用内置模型名,不加前缀 +> - 本地 CLI → 填的是命令名,不涉及前缀 + +## 配置示例 + +按 Provider 给出设置页各字段的填法(**名称**仅作标识、随意取;除特别说明外 Model 只填型号名,路由前缀客户端自动补全)。 + +### OpenAI + +- Provider:`OpenAI` +- Model:`gpt-4o-mini` +- Base URL:留空(默认 `https://api.openai.com`) +- API Key:`sk-…` + +### Anthropic + +- Provider:`Anthropic` +- Model:`claude-sonnet-4-6` +- Base URL:留空 +- API Key:`sk-ant-…` + +### DeepSeek + +- Provider:`DeepSeek` +- Model:`deepseek-v4-pro` +- Base URL:留空 +- API Key:`sk-…` + +### 阿里百炼(DashScope) + +- Provider:`阿里百炼` +- Model:`qwen-plus` +- Base URL:留空(已内置默认) +- API Key:DashScope 密钥 + +### 火山方舟(Volcengine Ark) + +- Provider:`火山方舟` +- Model:`doubao-pro-32k`(或推理接入点 `ep-…`) +- Base URL:留空(已内置默认) +- API Key:Ark 密钥 + +### OpenAI 兼容(自建 / 中转 / 本地 Ollama) + +Provider 选 **OpenAI 兼容**,Base URL **必填**为目标服务的 `/v1` 端点,Model 填平台特定型号名。两个常见场景: + +**自建 vLLM / 中转** + +- Provider:`OpenAI 兼容` +- Model:`qwen2.5-72b-instruct` +- Base URL:`http://10.0.0.5:8000/v1` +- API Key:视服务而定(无鉴权留空) + +**本地 Ollama** + +- Provider:`OpenAI 兼容` +- Model:`qwen2.5`(需先 `ollama pull`) +- Base URL:`http://localhost:11434/v1` +- API Key:留空 + +> Ollama 自带 OpenAI 兼容端点,走此路径即可,无需单独渠道(旧 `ollama` 预设升级后自动迁移为此形态)。 + +> **本地 CLI** 预设的配置见下方独立章节。 + +## 本地 CLI 模式 + +进阶选项:不直连任何 LLM API,而是经你授权调用本机已安装并登录的 **agentic CLI**(当前支持 `claude` / `codex`),在本地子进程中执行评审。该 CLI 以其自身的登录会话与计费策略运行,相关额度与合规由你自行负责。 + +> 🧪 **实验性能力**:本地 CLI 模式依赖第三方 CLI 的命令行接口与输出格式,这些**不在本项目控制范围内**。上游 CLI 的版本更新可能更改参数、输出结构或登录 / 计费策略,导致本模式行为变化甚至无法持续工作;本项目不对其稳定性与持续可用性作担保。设置页对该类预设标注「实验性」徽标以示提醒。若评审异常,请优先核对所用 CLI 的版本与登录态。 +> +> **完全由你授权**:仅当你新建并启用此预设、在 **CLI 命令** 字段填入命令名后,客户端才会调用对应命令行;这一行为完全出于你的显式授权,并使用你本机的登录态。 + +### 配置方法 + +1. 在本机安装对应 CLI 并完成登录。 +2. 进入 设置 → LLM,新建预设,**Provider 选「本地 CLI」**。 +3. 在 **CLI 命令** 字段填入命令名,如 `claude` 或 `codex`。 +4. 保存并设为 active。 + +### 关键行为 + +- **以本机登录态运行**:评审请求交由本机 CLI 处理,沿用其默认模型与登录会话,不使用此处或环境中的 API Key。 +- **实际模型**:由本机 CLI 的默认模型 / 账户档位决定,**不由此处输入决定**(此处填写的是命令名,非模型名)。 +- **代理自动透传**:开启[网络代理](03-proxy.md)后,CLI 的出站请求会自动经代理,无需额外配置。 + +> 前提:本机须已安装对应命令、位于 PATH 中且已登录,否则评审会因找不到命令而失败;评审消耗计入该 CLI 账户自身的额度。 + +## 进阶:评审并发数 + +应用支持多个评审任务**并发执行**(例如同时对多个 PR 跑 `/review`,互不阻塞)。并发数由配置项 `pr_agent.max_concurrency` 控制,**默认 2**,取值范围 **1~8**。 + +在设置页「AI」分区拖动「评审任务并发」滑块即可调整(热生效,无需重启);亦可手动编辑 `~/.code-meeseeks/config.yaml`: + +```yaml +pr_agent: + max_concurrency: 3 # 1~8,默认 2 +``` + +调高的注意事项(按此判断设多少): + +- **LLM 限流 / 费用**:并发越高,同一时刻打向 LLM 的请求越多。自带 Key 的付费档位通常可承受 2~3;免费 / 低档位易触发限流(HTTP 429),宜保持 `1`。 +- **本地 CLI 模式**:每个并发任务会各起一个本机 CLI 子进程,是否支持多会话取决于该 CLI 本身,建议先小范围验证。 +- **本机资源**:每个并发任务占用一个独立运行时进程与一份临时工作目录,并发越高越吃 CPU / 内存 / 磁盘。 + +> 设置页调整即时生效(调小不打断在跑的任务,随其完成自然收敛);手改 `config.yaml` 后需重启应用生效。设为 `1` 即退回串行执行(逐个排队)。 + +## 进阶:上下文长度 + +评审前会把改动内容(diff、命中规则、PR 上下文等)拼成 prompt 发给模型。当输入超过模型上下文窗口时,pr-agent 会**按上下文长度上限裁剪**输入以适配模型。该上限由配置项 `llm.context_tokens` 控制,**默认 128000**(token),取值范围 **32k~1M**。 + +在设置页「AI」分区拖动「上下文长度」滑块即可调整(下次评审生效),提供 32k / 64k / 128k / 256k / 512k / 1M 等主要习惯档位;亦可手动编辑 `~/.code-meeseeks/config.yaml`: + +```yaml +llm: + context_tokens: 256000 # 32000~1000000,默认 128000 +``` + +- 设置得**与所用模型的实际上下文窗口相称**:超过模型能力时上游会报错;设得过小则长 PR 会被提前截断、漏看部分改动。 +- **对本地 CLI 模式不生效**:本地 CLI(如 claude)自行管理上下文,本项不参与裁剪。 diff --git a/docs/guide/zh-CN/03-proxy.md b/docs/guide/zh-CN/03-proxy.md new file mode 100644 index 00000000..48ef6dde --- /dev/null +++ b/docs/guide/zh-CN/03-proxy.md @@ -0,0 +1,33 @@ +# 网络代理配置 + +[English](../03-proxy.md) · **简体中文** + +企业内网 / 受限网络下,让所有**出站网络**统一经一个 **HTTP 代理**出网。在 **设置 → 代理** 配置。 + +## 覆盖范围 + +开关打开后,以下三类出口统一走代理: + +- **LLM 调用**(pr-agent 内嵌的 LLM 客户端)—— 内网下没它整个评审不可用。 +- **代码平台 REST**(轮询 / 评论 / 头像 / 附件 / 合并 / 连接探测)。 +- **git over HTTPS**(clone / fetch)。 + +**本地地址自动直连**:`localhost / 127.0.0.1 / ::1`(含本地 Ollama 等本地服务)不经代理。 + +## 配置项 + +| 字段 | 说明 | +| --- | --- | +| 开关 | 总开关;关 = 全部直连(默认) | +| 地址 / 端口 | 代理 host 与 port | +| 用户名 / 密码 | Basic Auth,可留空 | + +> 保存即热生效:修改后立即重建平台客户端,REST 立即走新代理;LLM / git 出口在下次操作时读最新配置。 +> 配好后可点「测试」,客户端会经该代理试连一个外部地址验证连通(代理认证失败 407 会报原因)。 + +## 注意事项 + +- **SSH 克隆不走代理**:HTTP 代理对 SSH 不直接适用,跨平台也无统一手段。若 Clone 协议选 SSH,请自行在 `~/.ssh/config` 配 `ProxyCommand`。 +- **本地 CLI 模式同样走代理**:用[本地 CLI 模式](02-llm.md#本地-cli-模式)评审时,CLI 子进程继承代理环境变量,出站自动走代理。 +- **socks5 暂不支持**:一期仅 HTTP 代理(含 Basic Auth)。 +- **平台被代理误伤**:内网代码平台若因走代理反而连不上,属边缘场景,当前未提供「平台直连」单独开关。 diff --git a/docs/guide/zh-CN/04-config-reference.md b/docs/guide/zh-CN/04-config-reference.md new file mode 100644 index 00000000..bb1d17c0 --- /dev/null +++ b/docs/guide/zh-CN/04-config-reference.md @@ -0,0 +1,324 @@ +# 配置文件参考 + +[English](../04-config-reference.md) · **简体中文** + +所有配置集中在唯一的配置文件 **`~/.code-meeseeks/config.yaml`**(YAML 格式)。日常使用通过应用内 **设置** 页与首启向导可视化编辑即可,无需手动改文件;本篇是完整的结构与字段参考,供批量配置、高级参数调整、问题排查时查阅。 + +- **编辑方式**:设置页保存即写盘并热更新;设置页「用系统关联程序打开 config.yaml」可直接编辑文件。 +- **生效时机**:连接 / LLM / 代理 / 语言 / 并发数等改动保存后即时重建生效;个别高级参数(如 `workspace.repos_dir`)需重启应用。 +- **凭据说明**:访问令牌、API Key、代理密码以**明文**保存在本文件中(与配置结构隔离但不加密)。请按最小权限申请凭据、妥善保护本文件,泄露后及时吊销。 + +## 完整示例 + +```yaml +language: '' # 空 = 按系统语言自动、回落英语;或显式 zh-CN / en-US / ja-JP / de-DE + +appearance: + editor_theme: auto + editor_font_family: '' + editor_font_size: 14 + +workspace: + repos_dir: ~/.code-meeseeks/repos + +agent: + dir: '' + max_steps: 8 + summary_max_chars: 800 + autopilot: + enabled: false + batch_size: 10 + grants: [] + strategy: + auto_followup: true + max_followup_asks: 2 + max_code_suggestions: 4 + code_suggestion_spec: '' + code_suggestion_layout: '' + +poller: + interval_seconds: 300 + +proxy: + enabled: false + protocol: http + host: '' + port: 8080 + username: '' + password: '' + +pr_agent: + strategy: auto + max_concurrency: 2 + +notifications: + enabled: true + new_pr: true + reply: true + mention: true + authored_comment: true + authored_needs_work: true + authored_conflict: true + +service: + enabled: false + host: 127.0.0.1 + port: 18765 + token: '' + +update: + check_enabled: true + +connections: + - id: my-bitbucket + kind: bitbucket-server + base_url: https://bitbucket.example.com + display_name: 公司 Bitbucket + auth: + type: pat + token: <BITBUCKET_HTTP_ACCESS_TOKEN> + clone: + protocol: pat + - id: my-github + kind: github + base_url: https://api.github.com + display_name: GitHub + auth: + type: pat + token: <GITHUB_PERSONAL_ACCESS_TOKEN> + clone: + protocol: pat + +active_connection_id: my-bitbucket + +llm: + active_id: default + context_tokens: 128000 + profiles: + - id: default + label: OpenAI + provider: openai + base_url: '' + model: gpt-4o + api_key: <OPENAI_API_KEY> +``` + +## 顶层字段 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `language` | string | `''`(自动) | 界面与 pr-agent 生成内容使用的语言(ISO locale,如 `zh-CN` / `en-US` / `ja-JP` / `de-DE`)。**默认空 = 自动**:按系统偏好语言匹配,无合适项回落英语。设置页可切换(热生效)。 | +| `appearance` | object | — | 界面与编辑器外观(主题 / 字体),见下。 | +| `workspace` | object | — | 工作目录设置,见下。 | +| `agent` | object | — | 高阶 Agent 与 AutoPilot 设置(Agent 目录、个性化规则均归于此),见下。 | +| `poller` | object | — | PR 轮询设置,见下。 | +| `proxy` | object | — | 出站网络代理设置,见下(详见 [网络代理配置](03-proxy.md))。 | +| `pr_agent` | object | — | pr-agent 运行时设置,见下。 | +| `notifications` | object | — | 系统通知与 dock 角标设置,见下。 | +| `service` | object | — | 本地 API 服务(CLI / 外部集成入口)设置,见下(详见 [CLI 命令行工具](06-cli.md))。 | +| `update` | object | — | 版本更新检测设置,见下。 | +| `connections` | array | `[]` | 代码平台连接列表,见下(详见 [代码平台配置](01-code-platform.md))。 | +| `active_connection_id` | string | `''` | 当前启用的连接 `id`,见下。 | +| `llm` | object | — | LLM 预设设置,见下(详见 [LLM 配置](02-llm.md))。 | + +## `appearance` — 外观 + +界面与编辑器的纯前端展示项(主进程仅据主题设原生窗口明暗)。均在设置页可视化调整、即时生效。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `editor_theme` | enum | `auto` | 全局主题(Monaco 编辑器与整个界面共用):`auto` 跟随系统深 / 浅色,其余为内置 / 第三方主题 id。 | +| `editor_font_family` | string | `''` | 编辑器等宽字体族(CSS font-family,可逗号分隔多候选)。留空 = 内置 mono 字体栈。 | +| `editor_font_size` | integer | `14` | 编辑器字号(px),限合理范围。 | + +## `workspace` — 工作目录 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `repos_dir` | string | `~/.code-meeseeks/repos` | 仓库本地镜像(bare clone)的存放目录。改动需重启应用后完全生效。支持 `~` 展开。 | + +## `agent` — 高阶 Agent 与 AutoPilot + +高阶 Agent 把自然语言请求转成自主规划 + 多工具编排(设计见 [docs/arch/02-agent/01-agent.md](../../arch/02-agent/01-agent.md))。**Agent 目录** `<agent.dir>/` 是 Agent 的完整人格与知识来源,其固定布局为: + +``` +<agent.dir>/ +├── SOUL.md # 灵魂:核心职责与边界(只读) +├── AGENTS.md # 工作规范与红线 +├── MEMORY.md # 长期记忆(可写) +├── USER.md # 用户画像(可写) +└── rules/ # 个性化规则目录(原 rules.dir 并入此处,结构见 自定义评审规则) +``` + +Agent **无独立启用开关**——配置了 LLM 且 pr-agent 就绪即可用。`dir` 留空时回落到工作目录下的默认位置 `~/.code-meeseeks/agent`(启动期幂等脚手架自动补齐缺失文件);配自定义路径可指向一个 git 仓库,便于团队共享上下文与规则。 + +> **从旧 `rules.*` 迁移**:早期版本的个性化规则配置在顶层 `rules.dir`;现已并入 `<agent.dir>/rules/`,**不再读取旧 `rules.*` 字段**。把原规则目录的内容移入 `<agent.dir>/rules/` 即可(规则文件结构不变,见 [自定义评审规则](05-rules.md))。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `dir` | string | `''` | Agent 目录路径。留空 = 工作目录下默认 `~/.code-meeseeks/agent`。支持 `~` 展开。可指向 git 仓库以共享。 | +| `max_steps` | integer | `8` | 单次会话的 Agent 规划步数上限,`1`–`50`。 | +| `summary_max_chars` | integer | `800` | 收尾总结的严格篇幅上限(字符),`100`–`4000`。 | +| `autopilot` | object | — | AutoPilot 预评审设置,见下。 | +| `strategy` | object | — | Agent 行为策略(作用于手动自动评审与 AutoPilot),见下。 | + +### `agent.autopilot` — AutoPilot 预评审 + +轮询发现待评审 PR 后自动预跑 `/describe` + `/review`,进应用即见待确认草稿(决策权仍在评审者)。准入控制只放行「待我评审·待处理」且未评审过的 PR,PR 被移除 / purge 即终止在途任务。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | AutoPilot 总开关。状态栏可切换;`false` 时调度逻辑完全不跑。 | +| `batch_size` | integer | `10` | 单批 LLM 判定的 PR 上限,`1`–`50`。 | +| `grants` | array | `[]` | 逐项写权限授权(默认空 = 全拒),如 `approve` / `needs_work` / `publish_comment`;运行期按红线硬校验放行。 | + +### `agent.strategy` — Agent 行为策略 + +作用于自动评审微流程(手动「自动评审」与 AutoPilot 共用),非 AutoPilot 专属。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `auto_followup` | boolean | `true` | 评审阶段是否启用**自动追问**(条件性 `/ask`)。关闭则跳过判读 + 追问、直接总结,省一次 LLM 调用与追问开销。 | +| `max_followup_asks` | integer | `2` | 自动追问数量上限(条件性 `/ask` 的硬上限),`0`–`5`。仅 `auto_followup` 开启时生效;`0` 等同关闭。 | +| `max_code_suggestions` | integer | `4` | 单次 `/review`、`/improve`、`/ask` 生成的代码建议数量上限,`2`–`8`。 | +| `code_suggestion_spec` | string | `''` | 注入 `/improve`、`/review`、`/ask` 的自由文本规范,用于约束每条建议的写法(软约束)。留空 = 不注入。见下。 | +| `code_suggestion_layout` | string | `''` | 由建议生成评审草稿评论时的确定性 Markdown 排版。留空 = 使用默认排版。见下。 | + +#### 代码建议规范与排版 + +两个可选字段用于控制 AI 代码建议的写法,以及它如何落入评审草稿评论。两者都可在 **设置 → Agent → 策略** 里通过内联编辑器修改,无需手改配置文件。 + +- **`code_suggestion_spec`** —— 附加到模型指令中的**软**规范,作用于 `/improve`、`/review`、`/ask`。用于引导每条建议的内容结构,例如 _「每条建议按三段组织:问题、分析、建议,各占一行。」_ 模型通常会遵守,但不保证。 +- **`code_suggestion_layout`** —— 建议转为评审草稿评论时套用的**确定性** Markdown 模板(精确替换,不依赖模型)。占位符: + +| 占位符 | 取值 | +| --- | --- | +| `<TITLE>` | 本地化的「AI 建议」文案 | +| `<SUGGESTIONS>` | 建议正文 | +| `<HOME>` | 项目官网 | +| `<PR>` | 当前 PR 链接 | +| `<MODEL>` | 当前激活的模型名 | + +模板含 `<SUGGESTIONS>` 时作为完整评论正文,否则整段作为前缀拼在正文之前。留空则回退到默认排版: + +```text +[<TITLE>](<HOME>) (<MODEL>) +<SUGGESTIONS> +``` + +## `poller` — PR 轮询 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `interval_seconds` | integer | `300` | 自动轮询拉取待评审 PR 的间隔秒数,最小 `30`。 | + +## `proxy` — 出站网络代理 + +开启后 LLM 调用、代码平台 REST、git HTTPS 统一经代理;loopback / 本地地址(含本地 Ollama)自动直连。SSH 方式的 git 拉取不走此配置,需在 `~/.ssh/config` 自配。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | 代理总开关。关闭 = 全部直连。 | +| `protocol` | enum | `http` | 当前仅支持 `http`。 | +| `host` | string | `''` | 代理主机地址。 | +| `port` | integer | `8080` | 代理端口,`1`–`65535`。 | +| `username` | string | `''` | Basic Auth 用户名,无鉴权留空。 | +| `password` | string | `''` | Basic Auth 密码,无鉴权留空。 | + +## `pr_agent` — 运行时 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `strategy` | enum | `auto` | 运行时策略:`auto` 优先随包内嵌运行时、缺失则回退系统 `pr-agent` CLI;亦可显式 `embedded` / `local-cli` 强制。 | +| `max_concurrency` | integer | `2` | 评审任务并发数,`1`–`8`。设置页「AI」分区可调(热生效,无需重启),亦可手改本文件,详见 [LLM 配置 · 进阶:评审并发数](02-llm.md#进阶评审并发数)。 | + +## `notifications` — 消息通知 + +系统通知(toast)与 macOS dock「待回应」角标开关。`enabled` 为总开关(关闭后既不弹通知也不亮角标);其余各项按事件类型分别控制系统通知——`new_pr` / `reply` / `mention` 面向「待我评审」等场景,`authored_*` 面向「我创建的」PR。系统通知受 OS 权限约束,用户在系统设置关闭后应用静默降级。设置页可调,即时生效。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `enabled` | boolean | `true` | 通知总开关;关闭后不弹系统通知、不亮 dock 角标。 | +| `new_pr` | boolean | `true` | 出现新的待我评审 PR 时弹通知。 | +| `reply` | boolean | `true` | 收到评论回复时弹通知。 | +| `mention` | boolean | `true` | 评论中被 @ 提及时弹通知。 | +| `authored_comment` | boolean | `true` | 我创建的 PR 收到他人新评论时弹通知。 | +| `authored_needs_work` | boolean | `true` | 我创建的 PR 被评审标记「需修改」时弹通知。 | +| `authored_conflict` | boolean | `true` | 我创建的 PR 出现合并冲突时弹通知。 | + +## `service` — 本地 API 服务 + +本机 HTTP API 服务监听配置,供 `meebox` CLI 与外部脚本 / agent 访问应用能力(详见 [CLI 命令行工具](06-cli.md))。默认关闭、零暴露面;开启即**强制** bearer token 鉴权。设置页「集成」分区可视化开关、查看 / 复制 / 重新生成令牌,即时生效(开关 / 地址 / 端口变更停旧起新,token 变更下次请求生效)。 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | 服务总开关。关闭 = 不监听、无暴露面。 | +| `host` | string | `127.0.0.1` | 监听地址。默认仅本机可达;设 `0.0.0.0` 暴露到局域网(高风险,此时令牌是唯一防线)。 | +| `port` | integer | `18765` | 监听端口,`1`–`65535`。 | +| `token` | string | `''` | 访问令牌(bearer)。首次开启自动生成;设置页可重新生成(旧令牌即时失效)。以明文保存。 | + +## `update` — 版本更新检测 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `check_enabled` | boolean | `true` | 启动时(及设置页手动触发)查 GitHub Releases 最新稳定版并与当前版本比对,有新版仅**提示**去下载(不自动下载 / 安装)。设为 `false` 关闭检测——此开关仅经手改本文件调整(设置页展示更新状态、不提供开关)。 | + +## `connections` — 代码平台连接 + +`connections` 是数组,每个元素是一条连接。`kind` 决定平台类型与字段形态。 + +### 公共字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 连接唯一标识,被 `active_connection_id` 引用。 | +| `kind` | enum | 平台类型:`github` / `bitbucket-server` / `gitlab`。 | +| `display_name` | string | 显示名称(设置页与状态栏展示)。 | +| `auth.type` | literal | 固定 `pat`。 | +| `auth.token` | string | 访问令牌(PAT)。所需权限见 [代码平台配置](01-code-platform.md)。 | +| `clone.protocol` | enum | git 克隆协议:`pat`(默认,HTTPS,URL 内嵌用户名 + PAT)/ `ssh`(走系统 `~/.ssh/config`)。 | + +### `kind: github` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `base_url` | string (URL) | GitHub API base。**可选**:留空默认 `https://api.github.com`(github.com);GitHub Enterprise Server 填实例地址 `https://<ghe-host>`,`/api/v3` 自动补全(手填完整 API base 亦可)。clone / web 域名由应用自动推导。 | + +### `kind: bitbucket-server` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `base_url` | string (URL) | Bitbucket Server / Data Center 地址,如 `https://bitbucket.example.com`。**必填**。 | + +### `kind: gitlab` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `base_url` | string (URL) | GitLab API base。**可选**:留空默认 `https://gitlab.com/api/v4`(gitlab.com);Self-Managed 填实例地址 `https://<gitlab-host>`,`/api/v4` 自动补全(手填完整 API base 亦可)。clone / web 域名由应用自动推导。 | + +## `active_connection_id` — 当前启用连接 + +| 类型 | 默认 | 说明 | +| --- | --- | --- | +| string | `''` | 取值为某条连接的 `id`。同时只启用一条:仅这条被轮询,PR 列表与状态栏只反映它。空串 / 指向不存在的 id 时不轮询任何连接(由界面引导启用一条)。`connections` 仍保留全部配置,历史 PR 不受影响。 | + +## `llm` — LLM 预设 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `profiles` | array | `[]` | LLM 预设列表,每条独立 provider / model / base_url / api_key。 | +| `active_id` | string | `''` | 当前生效的预设 `id`。空串 / 指向不存在的 id 时,评审不注入任何 LLM 环境变量(pr-agent 退回读取 shell 环境变量)。 | +| `context_tokens` | integer | `128000` | 裁剪输入内容的上下文长度上限(token),`32000`–`1000000`。超长改动按此截断以适配模型。**对本地 CLI 模式不生效**(CLI 工具自管上下文)。设置页「AI」分区可调(下次评审生效)。 | + +### 单条预设(`profiles[]`) + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `id` | string | — | 预设唯一标识,被 `active_id` 引用。 | +| `label` | string | `''` | 显示名称,留空时界面用 provider + model 兜底。 | +| `provider` | enum | `openai-compatible` | 服务商:`openai` / `anthropic` / `deepseek` / `dashscope`(阿里百炼)/ `volcengine-ark`(火山方舟)/ `openai-compatible`(任意 OpenAI 协议兼容服务,含本地 Ollama 的 `/v1`)/ `cli`(本机 agentic CLI,不直连 API)。旧值 `ollama` 自动迁移为 `openai-compatible`。 | +| `base_url` | string | `''` | API 端点。多数官方 provider 留空走默认;`openai-compatible` / 自部署需填。 | +| `model` | string | `''` | 模型名。多数 provider 只填型号名,应用按 provider 自动补 litellm 前缀;`cli` 模式此处填命令名(如 `claude`)。 | +| `api_key` | string | `''` | 鉴权密钥。本地类(本地 CLI / 无鉴权自建服务)留空。 | + +各 provider 的取值示例与本地 CLI 模式说明见 [LLM 配置](02-llm.md)。 diff --git a/docs/guide/zh-CN/05-rules.md b/docs/guide/zh-CN/05-rules.md new file mode 100644 index 00000000..c6fe45c6 --- /dev/null +++ b/docs/guide/zh-CN/05-rules.md @@ -0,0 +1,112 @@ +# 自定义评审规则 + +[English](../05-rules.md) · **简体中文** + +评审规则让你把团队规约、风格偏好、关注点注入 AI 评审:命中的规则正文会作为 `extra_instructions` 传给 pr-agent,影响 `/review`(及可选的 `/describe`)的产出。 + +规则是**纯文件**:一个规则目录下,每个 `.md` 文件就是一条规则。`frontmatter`(文件顶部的 YAML)声明这条规则**何时命中**,正文(markdown)是命中后**注入给 AI 的指令**。 + +## 规则目录 + +规则目录是 **Agent 目录下的 `rules/` 子目录**:`<agent.dir>/rules/`(见 [配置文件参考 · agent](04-config-reference.md#agent--高阶-agent-与-autopilot))。无需单独配置规则路径——只要把规则文件放进该目录即可生效。 + +``` +<agent.dir>/ # 默认 ~/.code-meeseeks/agent,可由 agent.dir 指向自定义 / git 仓库 +└── rules/ # 规则目录:放入 .md 规则文件 + ├── fx-amount.md + └── api-breaking.md +``` + +`agent.dir` 留空时默认 `~/.code-meeseeks/agent`;指向一个 git 仓库即可让团队共享与版本化规则。目录下可按子目录组织,应用会递归扫描所有 `.md`。 + +## 规则文件结构 + +```markdown +--- +applies_to: + project: '^FX$' + repo: '^fx-.*' + target_branch: '^(main|release/.*)$' +tools: [review] +priority: 10 +enabled: true +--- + +- 公共方法必须有 JSDoc,说明参数与返回值。 +- 金额一律用整数分存储,禁止浮点。 +- 对外接口变更需在 PR 描述里标注「Breaking」。 +``` + +- `---` 之间是 **frontmatter(YAML)**,声明命中条件;可整段省略。 +- `---` 之后是**正文(markdown)**,作为命中后注入 AI 的指令,用清晰的祈使句逐条写效果最好。 + +### frontmatter 字段 + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `applies_to.project` | 正则源串 | 省略 = 匹配任意 | 命中项目标识:Bitbucket 为 project key,GitHub 为组织 / 用户名。 | +| `applies_to.repo` | 正则源串 | 省略 = 匹配任意 | 命中仓库 slug。 | +| `applies_to.target_branch` | 正则源串 | 省略 = 匹配任意 | 命中 PR 的**目标分支**名。 | +| `tools` | 数组 | `[review]` | 规则作用的工具,可取 `review` / `describe`。默认只作用于 `/review`(评审规约注入 `/describe` 会让描述偏题)。 | +| `priority` | 数字 | `0` | 多条规则同时命中时的取舍权重,越大越优先(见下「命中与取舍」)。 | +| `enabled` | 布尔 | `true` | 单条规则开关;`false` 时跳过该文件。 | +| `custom_labels` | 数组 | `[]` | 预留字段,当前版本解析但尚未注入 pr-agent。 | + +> **正则说明**:`applies_to.*` 的值是正则**源串**,**不自动加锚点** `^`/`$`,是否精确匹配由你自己写。例如 `fx` 会匹配任何含 `fx` 的名字;要精确匹配写 `^fx$`。非法正则会被忽略(视为该字段未配置)。 + +## 命中与取舍 + +对某个 PR 执行某个工具时,规则按以下逻辑筛选: + +1. **工具过滤**:规则的 `tools` 不含当前工具 → 不命中。**注意 `tools` 缺省为 `[review]`,并非「对所有工具生效」**——不写 `tools` 的规则只作用于 `/review`,不会影响 `/describe`;要同时约束 `/describe`,须显式写 `tools: [describe, review]`。 +2. **范围匹配**:`applies_to` 的每个字段——省略即匹配任意;配置了则该字段值需通过其正则 `.test()`。三项是 **AND** 关系(都要满足)。 + +> 与 `applies_to` 的「省略 = 匹配任意」相反,`tools` 的缺省是一个**具体默认值** `[review]`,不是「任意工具」——这点容易混淆,记住「不写 = 仅 review」。 + +> **多条规则一并生效**:同一 PR + 工具命中的多条规则会**全部注入**评审——按 `priority` 降序、再按文件路径升序排列,各规则正文以 `Ruleset 1 / 2 / …` 分段拼接传给 AI,互不串味。`priority` 决定排列先后(越大越靠前)。为安全起见单次最多注入 **20** 条命中规则,超出按排序丢弃靠后者。 + +## 全局基础规约 + +不写 frontmatter(或留空)的规则文件 = **匹配任意 PR** 的基础规约(`tools` 默认 `[review]`)。适合放一份团队通用约定: + +```markdown +评审时请重点关注: +- 错误处理是否完整,是否吞掉异常。 +- 是否有重复代码可抽取复用。 +- 命名是否清晰、与周边代码风格一致。 +``` + +## 示例 + +**按目标分支收紧**:只对合并到 `release/*` 的 PR 强化检查。 + +```markdown +--- +applies_to: + target_branch: '^release/.*' +tools: [review] +priority: 20 +--- + +- 这是发布分支,禁止引入新依赖。 +- 任何行为变更必须有对应测试覆盖。 +``` + +**按仓库定制**:只对某仓库生效。 + +```markdown +--- +applies_to: + repo: '^payment-service$' +--- + +- 涉及金额计算的改动需双人复核,评审中标注风险点。 +``` + +## 注意事项 + +- **改动即生效**:规则文件增删改后,下次触发评审即按最新内容加载,无需重启。 +- **单文件解析失败不影响整体**:某个文件 frontmatter YAML 写坏 / 字段类型不对,应用会跳过该文件并继续加载其余规则。 +- **当前命中提示**:选中 PR 后,chat 面板会显示本次命中的规则条数,点击可预览全部命中规则(按 Ruleset 分段列出),便于确认本次评审受哪些规则约束。 + +> 设计与实现细节见架构文档 [docs/arch/02-agent/04-rules.md](../../arch/02-agent/04-rules.md)。 diff --git a/docs/guide/zh-CN/06-cli.md b/docs/guide/zh-CN/06-cli.md new file mode 100644 index 00000000..ac4673f7 --- /dev/null +++ b/docs/guide/zh-CN/06-cli.md @@ -0,0 +1,163 @@ +# CLI 命令行工具(meebox) + +[English](../06-cli.md) · **简体中文** + +`meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 +评审 Agent 操作接入脚本、CI 或外部 agent。命令行提供**浏览与评审操作**,含评审决断(approve / needswork) +与发评论;不含合并(merge)等高影响写操作。 + +## 1. 开启本地 API 服务 + +CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 集成** 开启: + +- 打开「本地 API 服务」开关(首次开启会自动生成一枚访问令牌)。 +- **监听地址**:默认 `http://127.0.0.1:18765`(仅本机可达)。如需被同网段的其他机器 / CI 访问,可把 + host 改为 `0.0.0.0` 或本机局域网 IP——此时**令牌是唯一防线**,请妥善保密并配合防火墙。 +- **访问令牌**:可显示 / 复制 / 重新生成;重新生成后旧令牌立即失效。 + +## 2. 获取 CLI + +**macOS / Linux 一键安装**——自动下载最新版、校验 SHA-256、装到 `PATH`: + +```bash +curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash +``` + +脚本自动探测系统 / 架构并拉取匹配的 Release 压缩包,把 `meebox` 装到 `/usr/local/bin`(不可写则回退 +`~/.local/bin`)。可用环境变量 `MEEBOX_VERSION`(装指定版本)、`MEEBOX_BIN_DIR`(指定安装目录)调整。 +无需单独安装 `SKILL.md`——它已内嵌进二进制(`meebox skill` 可打印)。 + +**手动下载**(Windows,或不便用脚本时):从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) +下载对应平台压缩包(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 放到 `PATH`。 + +覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。压缩包内含 `meebox` 二进制、`LICENSE`、`README.md` +与 `SKILL.md`(作为 agent skill 投放见 [第 6 节](#6-作为-agent-skill-集成))。 + +## 3. 连接方式 + +`meebox` 按以下优先级解析 API 地址与令牌(高 → 低): + +1. 命令行参数:`--api-url` / `--token` +2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` +3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) + +连接信息须**显式提供**其一。令牌在设置页「集成」分区查看 / 复制。最省事的方式是用 `meebox login` 存一次令牌 +(写入 `cli.yaml`),之后所有命令免传参: + +```bash +meebox login --token <令牌> # 默认连本机 http://127.0.0.1:18765 +meebox login --token <令牌> --server http://<主机>:18765 # 指定远端服务 +meebox pr list # 后续命令直接用已存的凭据 +``` + +或用环境变量(适合 CI / shell 注入): + +```bash +export MEEBOX_API_URL=http://127.0.0.1:18765 +export MEEBOX_TOKEN=<令牌> +meebox pr list +``` + +远端访问(服务监听 `0.0.0.0`)同样显式提供地址与令牌: + +```bash +meebox --api-url http://<主机>:18765 --token <令牌> pr list +``` + +> CLI **不读取** GUI 主配置 `~/.code-meeseeks/config.yaml`:该文件含代码平台访问令牌等连接层机密, +> 不从中取服务令牌,避免越权触达预期外的凭据。API 地址默认 `http://127.0.0.1:18765`(未显式指定时)。 + +## 4. 命令 + +```text +meebox [全局参数] <组> <命令> [参数] +``` + +根层级的系统性命令 `whoami` / `version` 与具体 PR 无关;其余命令分 `pr`(PR 操作,含 `categories` 筛选词表 +与 `refresh` 刷新)与 `agent`(评审 Agent 操作)两个领域组,其 PR 维度子命令用**必填参数 `--pr <id>`** 指定 +PR(`id` 由 `meebox pr list` 输出获得)。 + +| 命令 | 用途 | +| --- | --- | +| `meebox login --token <令牌> [--server <地址>]` | 保存令牌(与可选服务地址)到 `cli.yaml`,后续命令免传参 | +| `meebox whoami` | 当前登录身份与集成平台(用户 + 平台 + 连接名) | +| `meebox version` | 客户端(CLI)+ 服务端(应用)版本;未连接服务端时仅显示客户端版本 | +| `meebox skill` | 打印内嵌的使用说明(SKILL.md),便于二进制脱离压缩包时自述用法 | +| `meebox pr categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选)——`pr list` 的筛选词表 | +| `meebox pr refresh` | 触发一次立即刷新(拉取最新 PR),返回本轮变化计数(新增 / 变更 / 移除等);等同 GUI 里的手动刷新 | +| `meebox pr list [--category <一级>] [--status <二级>] [--query <检索>] [--skip N] [--limit N]` | PR 列表(精简字段 + 分页,默认 limit 100) | +| `meebox pr show --pr <id>` | PR 描述详情 | +| `meebox pr diff --pr <id> [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | +| `meebox pr activity --pr <id>` | 活动时间线(评论 / 提交 / 评审决断) | +| `meebox pr commits --pr <id>` | 提交列表 | +| `meebox pr reviewers --pr <id>` | 评审人审批状态 | +| `meebox pr approve --pr <id>` | 将 PR 标记为「通过」(发送真实评审决断到平台) | +| `meebox pr needswork --pr <id>` | 将 PR 标记为「需修改」(发送真实评审决断到平台) | +| `meebox pr comment --pr <id> <消息>` | 发一条顶层评论到平台 | +| `meebox agent status --pr <id>` | 评审 Agent 当前执行状态 | +| `meebox agent history --pr <id>` | 历史会话 | +| `meebox agent review --pr <id>` | 执行一次自动评审 | +| `meebox agent instruct --pr <id> <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | +| `meebox agent chat --pr <id> <消息>` | 发送自然语言消息(可触发 Agent 任务) | +| `meebox agent stop --pr <id>` | 中断该 PR 运行中的评审 Agent(整体停) | +| `meebox agent run list --pr <id>` | 列出该 PR 运行中 / 排队中的 pr-agent runs | +| `meebox agent run cancel --pr <id> --run <runId>` | 按 run id 取消单个 pr-agent 工具调用 | + +其中 `<id>` 为 PR 的本地标识(列表里的 `id` 字段),由 `meebox pr list` 输出获得。 + +## 5. 输出格式 + +全局参数 `--output`: + +- **`yaml`(默认)**:结构化又易读(类 kubectl `-o yaml`),适合人在终端查看。 +- **`json`**:适合脚本 / 外部 agent 机器解析。 + +```bash +meebox pr list --output json | jq '.[].title' +``` + +**退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 + +## 6. 作为 Agent Skill 集成 + +`meebox` 的主要交付形态是**可直接投放的 agent skill**:发布压缩包除二进制外一并含 `SKILL.md` / +`README.md` / `LICENSE`,整个解压目录即是一个可用 skill。 + +- **投放即用**:把解压目录放进 agent 的 skills 目录(如 `~/.claude/skills/meebox/`)。`SKILL.md` + (frontmatter `name: meebox`)向 agent 说明命令树、连接方式与写边界,紧邻其驱动的二进制。 +- **二进制自述**:同一份 `SKILL.md` 于构建期经 `go:embed` 内嵌进二进制,`meebox skill` 可打印之—— + 二进制即便脱离压缩包(如单独放入 `PATH`)也能取回用法,且内容与随包文档构建期一致。 +- **仅有二进制的 fallback**:若手头只有 `meebox` 二进制(缺压缩包 / `SKILL.md` 文件),用 `meebox skill` + 即可从二进制导出说明、就地重建 skill 目录,无需另找原始文件: + + ```bash + mkdir -p ~/.claude/skills/meebox + cp "$(command -v meebox)" ~/.claude/skills/meebox/ # 二进制放入 skill 目录 + meebox skill > ~/.claude/skills/meebox/SKILL.md # 从内嵌副本导出说明 + ``` + + 导出的内容与该二进制同源,天然匹配当前版本。 +- **集成流程**:读 `SKILL.md` 了解能力 → `meebox login` 存一次凭据 → 以 `meebox pr list` / `pr show` / + `agent review` 等浏览与驱动评审 → 用 `meebox pr approve` / `needswork` / `comment` 记录结论;机器消费统一 + 取 `--output json`(其字段形状为稳定契约)。 +- **边界内建**:仅开放浏览 + 评审写动作,不含合并与变更类工具(详见下「注意事项」),agent 集成天然不会触发 + 高影响远端操作。 +- **框架无关的接入**:`SKILL.md` 的自动发现是 Claude Code 的 skill 约定,并非跨框架标准。其它 agent / 脚本 + 无需依赖该约定即可集成——直接以 shell 调用 `meebox`、用 `meebox skill` 或 `--help` 取用法、`--output json` + 取结构化结果。真正可移植的接口是「命令行 + JSON」,`SKILL.md` 自动发现只是 Claude 生态的锦上添花。 + +## 网络代理 + +`meebox` 遵循标准的 HTTP 代理环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,大小写均可),无需额外配置: + +- 访问**本机**服务(`127.0.0.1` / `localhost`)自动直连、不走代理。 +- 访问**远端**服务(如经 `0.0.0.0` 暴露的机器)时,若设了 `HTTP_PROXY` 则经其出网;可用 `NO_PROXY` 排除特定主机。 + +## 注意事项 + +- **写能力范围**:CLI 提供评审写动作——`pr approve` / `pr needswork`(发送真实评审决断)与 `pr comment` + (发顶层评论);但**不提供合并(merge)与变更类 Agent 工具(publish 等)**,有此需求请自行对接代码平台。 +- **令牌安全**:服务令牌在 GUI 的 `~/.code-meeseeks/config.yaml` 明文存储;若写入 CLI 的 `~/.code-meeseeks/cli.yaml` + 同为明文。监听 `0.0.0.0` 暴露到局域网时尤需保密,并及时通过「重新生成」吊销泄露的令牌。 +- **版本兼容**:若 `meebox` 版本低于应用要求的下限,任意命令都会收到「CLI 过旧、请升级」提示(含双方版本); + 按上文「获取 CLI」重装最新版即可。CLI 与应用版本同源发布,正常同步升级不会遇到。 diff --git a/docs/guide/zh-CN/README.md b/docs/guide/zh-CN/README.md new file mode 100644 index 00000000..8116bce6 --- /dev/null +++ b/docs/guide/zh-CN/README.md @@ -0,0 +1,26 @@ +# 使用说明 + +[English](../README.md) · **简体中文** + +Code Meeseeks 是本地运行的 PR 评审客户端:连上你的代码平台 → 拉取待评审的 PR → 用 LLM(pr-agent)做 `/describe` `/review`,评论 / 审批 / 合并都在本地客户端完成。 + +本目录是**面向用户**的安装与配置说明(开发/架构文档见 [../arch/](../../arch/README.md))。各项配置都在应用内 **设置** 页可视化编辑,首次启动也有配置向导引导最快进入可用状态。 + +## 篇目 + +| 文档 | 内容 | +| --- | --- | +| [00 · 安装与首次使用](00-getting-started.md) | 系统要求、下载安装、首次启动配置向导 | +| [01 · 代码平台配置](01-code-platform.md) | 接入 GitHub / Bitbucket Server / GitLab:Base URL、访问令牌(PAT)权限、Clone 协议 | +| [02 · LLM 配置](02-llm.md) | 选 LLM 服务商与模型;含进阶的本地 CLI 模式(经授权调用本机 agentic CLI,以本机登录态执行评审) | +| [03 · 网络代理配置](03-proxy.md) | 内网 / 受限网络下统一走 HTTP 代理出网 | +| [04 · 配置文件参考](04-config-reference.md) | `config.yaml` 完整结构与各配置项功能说明(含高级参数) | +| [05 · 自定义评审规则](05-rules.md) | 编写规则 `.md` 文件:frontmatter 命中条件 + 正文注入 AI 的评审指令 | +| [06 · CLI 命令行工具](06-cli.md) | 开启本地 API 服务 + 用 `meebox` 命令行浏览 PR / 操作评审 Agent(供脚本 / 外部 agent 集成) | + +## 通用须知 + +- **数据目录固定** `~/.code-meeseeks/`:配置、状态、日志都在这里;`config.yaml` 是唯一配置文件,含连接 / LLM / 代理等全部设置。 +- **凭据安全**:访问令牌、API Key、代理密码等请按最小授权配置,及时吊销泄露的令牌。 +- **保存即生效**:设置页每项保存后写盘并热更新,无需重启;连接 / 代理变更会即时重建生效。 +- **高级编辑**:设置页提供「用系统关联程序打开 config.yaml」,可直接改文件(适合批量 / 高级配置)。 diff --git a/package-lock.json b/package-lock.json index 5ba5dfe0..1e0deeb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", diff --git a/package.json b/package.json index 39ae7b33..bce677b6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "meebox", "version": "0.0.0", "private": true, - "description": "Local-first GUI for AI-assisted code review, built on pr-agent", + "description": "AI code-review desktop client, local-first and semi-automatic, for individual reviewers, built on pr-agent", "license": "Apache-2.0", "workspaces": [ "apps/*", diff --git a/packages/agent/package.json b/packages/agent/package.json index 27792f41..ab0292b5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -2,7 +2,7 @@ "name": "@meebox/agent", "version": "0.0.0", "private": true, - "description": "高阶 Agent 核心:Agent 目录加载(SOUL/AGENTS/MEMORY/USER + rules)、上下文装配、模版脚手架;编排与 AutoPilot 后续接入(见 docs/arch/02-agent/01-agent.md)", + "description": "High-level Agent core: Agent-directory loading (SOUL/AGENTS/MEMORY/USER + rules), context assembly, template scaffolding; orchestration and AutoPilot wired in later (see docs/arch/02-agent/01-agent.md)", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/agent/src/agent-files.ts b/packages/agent/src/agent-files.ts index bdea702a..dd5b919b 100644 --- a/packages/agent/src/agent-files.ts +++ b/packages/agent/src/agent-files.ts @@ -12,16 +12,17 @@ import { AGENT_FILES, AGENT_RULES_SUBDIR, EMPTY_FILES } from './constants.js'; import type { AgentContext, AgentContextFiles, LoadAgentContextOptions } from './types.js'; /** - * Agent 目录域(见 docs/arch/02-agent/01-agent.md「Agent 目录」):把同属「on-disk agent 文件」职责的 - * 布局解析 / 初始化模版 / 脚手架 / 现读装配(上下文 + 规则)收口到一处。文件清单常量(AGENT_FILES / - * AGENT_RULES_SUBDIR)见 constants.ts;记忆**写入**侧(appendAgentNotes)在 memory.ts。 + * Agent directory domain (see docs/arch/02-agent/01-agent.md「Agent 目录」): consolidates the + * responsibilities of "on-disk agent files" — layout resolution / init templates / scaffolding / + * live-read assembly (context + rules) — in one place. File-list constants (AGENT_FILES / + * AGENT_RULES_SUBDIR) are in constants.ts; the memory **write** side (appendAgentNotes) is in memory.ts. */ -// ── 布局 ── +// ── layout ── export type AgentContextKind = keyof typeof AGENT_FILES; -/** 给定 agentDir,解析各上下文文件与 rules 目录的绝对路径。 */ +/** Given agentDir, resolve the absolute paths of each context file and the rules directory. */ export function resolveAgentPaths(agentDir: string): { soul: string; agents: string; @@ -38,35 +39,37 @@ export function resolveAgentPaths(agentDir: string): { }; } -// ── 初始化模版 ── +// ── init templates ── /** - * Agent 目录初始化模版(统一 **en-US**、不做 i18n,见 docs/arch/02-agent/01-agent.md「提示词模版」)。 - * 模版正文是 `resources/` 下的独立 `.md` 资源文件,构建期经 Vite `?raw` 内联;本文件只保留**加载/清单逻辑**。 + * Agent directory init templates (uniformly **en-US**, no i18n, see docs/arch/02-agent/01-agent.md「提示词模版」). + * Template bodies are standalone `.md` resource files under `resources/`, inlined at build time via Vite `?raw`; + * this file keeps only the **load/manifest logic**. * - * 三类所有权: - * - 用户所有(`managed` 缺省):AGENTS / MEMORY / USER / README——缺失即创建,已存在不覆盖,用户可自由改写 - * (含改成目标语言);Agent 亦可经记忆机制追写其中部分。 - * - 应用所有(`managed: true`):`SOUL.md`——由应用统一下发,**加载时强制对齐内置模版**,不保留本地改动, - * 以便随版本统一推送 Agent 行为更新。Agent 与用户都不应改写它(改了也会在下次加载被对齐回模版)。 - * - 首次播种(`seedOnce: true`):`rules/example.md`——仅在 Agent 目录**首次脚手架**时落地一份示例, - * 之后绝不补齐:用户删掉即永久消失(示例非必需文件,不应每次启动被「复活」)。 + * Three ownership kinds: + * - User-owned (`managed` omitted): AGENTS / MEMORY / USER / README — created if missing, not overwritten if present, + * users may freely rewrite them (including into a target language); the Agent may also append to some via the memory mechanism. + * - App-owned (`managed: true`): `SOUL.md` — issued uniformly by the app, **forcibly aligned to the built-in template on load**, + * local edits are not preserved, so Agent behavior updates can be pushed uniformly across versions. Neither the Agent nor the user + * should rewrite it (edits will be aligned back to the template on the next load). + * - Seed-once (`seedOnce: true`): `rules/example.md` — an example is written only on the Agent directory's **first scaffold**, + * never replenished afterward: once the user deletes it, it is gone permanently (the example is not a required file and should not be "revived" on every startup). */ export interface AgentTemplate { - /** 相对 agentDir 的文件路径。 */ + /** File path relative to agentDir. */ path: string; contents: string; - /** 应用所有:每次脚手架强制对齐到模版(覆盖本地改动)。缺省为用户所有,仅缺失时创建。 */ + /** App-owned: forcibly aligned to the template on each scaffold (overwrites local edits). Defaults to user-owned, created only when missing. */ managed?: boolean; - /** 首次播种:仅 Agent 目录首次脚手架时创建一次,删除后不再补齐(与 `managed` 互斥)。 */ + /** Seed-once: created only once on the Agent directory's first scaffold, not replenished after deletion (mutually exclusive with `managed`). */ seedOnce?: boolean; } /** - * 默认模版清单: - * - 用户所有缺失即创建(幂等、不覆盖):AGENTS / MEMORY / USER / README; - * - 应用所有(SOUL)强制对齐模版; - * - 首次播种(rules/example.md)仅首次脚手架落地、删除后不补。 + * Default template manifest: + * - User-owned created if missing (idempotent, no overwrite): AGENTS / MEMORY / USER / README; + * - App-owned (SOUL) forcibly aligned to the template; + * - Seed-once (rules/example.md) written only on the first scaffold, not replenished after deletion. */ export const AGENT_TEMPLATES: AgentTemplate[] = [ { path: 'SOUL.md', contents: soul, managed: true }, @@ -77,7 +80,7 @@ export const AGENT_TEMPLATES: AgentTemplate[] = [ { path: 'rules/example.md', contents: ruleExample, seedOnce: true }, ]; -// ── 脚手架 ── +// ── scaffolding ── async function exists(p: string): Promise<boolean> { try { @@ -89,14 +92,14 @@ async function exists(p: string): Promise<boolean> { } /** - * 脚手架 / 对齐 agentDir:确保 rules/ 子目录存在,并按所有权处理模版—— - * - 用户所有:缺失即创建,已存在不覆盖(幂等)。 - * - 应用所有(managed,如 SOUL.md):强制对齐内置模版——缺失则创建,已存在但内容漂移则覆盖回模版。 - * - 首次播种(seedOnce,如 rules/example.md):仅 Agent 目录首次脚手架时创建,之后不补(删除即永久消失)。 + * Scaffold / align agentDir: ensure the rules/ subdirectory exists, and handle templates by ownership — + * - User-owned: created if missing, not overwritten if present (idempotent). + * - App-owned (managed, e.g. SOUL.md): forcibly aligned to the built-in template — created if missing, overwritten back to the template if present but drifted. + * - Seed-once (seedOnce, e.g. rules/example.md): created only on the Agent directory's first scaffold, not replenished afterward (deletion is permanent). * - * 「首次脚手架」以 `rules/` 子目录是否已存在判定:它由首次脚手架建立、之后长存(agentDir 本身由 bootstrap - * 预创建,不能作判据)。返回**本次实际写入**(新建或对齐)的文件相对路径列表;无写入返回空数组。 - * 见 docs/arch/02-agent/01-agent.md「提示词模版」。 + * "First scaffold" is determined by whether the `rules/` subdirectory already exists: it is created by the first scaffold and persists thereafter (agentDir itself is + * pre-created by bootstrap and cannot serve as the criterion). Returns the list of relative paths of files **actually written this time** (created or aligned); returns an empty array when nothing is written. + * See docs/arch/02-agent/01-agent.md「提示词模版」. */ export async function scaffoldAgentDir(agentDir: string): Promise<string[]> { if (!agentDir) throw new Error('scaffoldAgentDir: agentDir must not be empty'); @@ -106,11 +109,11 @@ export async function scaffoldAgentDir(agentDir: string): Promise<string[]> { const written: string[] = []; for (const tpl of AGENT_TEMPLATES) { - // 首次播种文件:仅首次脚手架落地,之后(rules/ 已存在)一律跳过——删除后不复活。 + // Seed-once file: written only on the first scaffold, skipped thereafter (rules/ already exists) — not revived after deletion. if (tpl.seedOnce && !firstInit) continue; const abs = path.join(agentDir, tpl.path); if (await exists(abs)) { - // 用户所有:保留本地内容。应用所有:内容与模版一致则跳过,漂移则覆盖对齐。 + // User-owned: keep local contents. App-owned: skip if contents match the template, overwrite to align if drifted. if (!tpl.managed) continue; const current = await readFile(abs, 'utf8').catch(() => null); if (current === tpl.contents) continue; @@ -123,9 +126,9 @@ export async function scaffoldAgentDir(agentDir: string): Promise<string[]> { return written; } -// ── 现读装配(上下文 + 规则)── +// ── live-read assembly (context + rules) ── -/** 读单个上下文文件;缺失(ENOENT)→ 空串,其它读失败 → 告警 + 空串(失败安全)。 */ +/** Read a single context file; missing (ENOENT) → empty string, other read failures → warn + empty string (fail-safe). */ async function readOptional( file: string, onWarn?: LoadAgentContextOptions['onWarn'], @@ -140,8 +143,8 @@ async function readOptional( } /** - * 现读现装配:每次执行重新读 Agent 目录的 SOUL / AGENTS / MEMORY / USER 与 rules/, - * **无缓存**(见 docs/arch/02-agent/01-agent.md「上下文注入」)。空 agentDir → 全空上下文(Agent 退化为原生)。 + * Live read and assemble: on each execution re-read the Agent directory's SOUL / AGENTS / MEMORY / USER and rules/, + * **no caching** (see docs/arch/02-agent/01-agent.md「上下文注入」). Empty agentDir → all-empty context (Agent degrades to native). */ export async function loadAgentContext( agentDir: string, @@ -167,8 +170,8 @@ export async function loadAgentContext( } /** - * 只加载规则(`<agentDir>/rules`),供「现读取首条命中规则」的注入路径用——无需读 - * SOUL/AGENTS 等上下文文件。空 agentDir → 空数组。 + * Load only the rules (`<agentDir>/rules`), for the injection path that "live-reads the first matching rule" — + * no need to read SOUL/AGENTS and other context files. Empty agentDir → empty array. */ export async function loadAgentRules( agentDir: string, diff --git a/packages/agent/src/autopilot-judge.ts b/packages/agent/src/autopilot-judge.ts index 182ba530..da94066a 100644 --- a/packages/agent/src/autopilot-judge.ts +++ b/packages/agent/src/autopilot-judge.ts @@ -4,7 +4,7 @@ import { PROMPT_TEMPLATES } from './prompts.js'; import { isValidReviewPlan, type ReviewPlan, type ReviewStepKind } from './steps/review/index.js'; import { extractJson, fillTemplate } from './utils/index.js'; -/** 解析 judge 给出的步骤计划:非数组 / 含非法 kind / 缺前置 describe-review → undefined(回落默认全集)。 */ +/** Parse the step plan given by the judge: non-array / contains invalid kind / missing prerequisite describe-review → undefined (falls back to the default full set). */ function parseReviewPlan(raw: unknown): ReviewPlan | undefined { if (!Array.isArray(raw)) return undefined; const steps = raw.filter((s): s is ReviewStepKind => typeof s === 'string') as ReviewStepKind[]; @@ -13,21 +13,21 @@ function parseReviewPlan(raw: unknown): ReviewPlan | undefined { } /** - * AutoPilot 批量判定(见 docs/arch/02-agent/03-autopilot.md「AutoPilot」的例外规则):把一批候选 PR 的 - * 标题 + 描述喂给 LLM,逐 PR 判「是否值得自动评审」并附原因(例如分支合并 / 回合并类、 - * 纯依赖升级可跳过)。纯逻辑:LLM 通道注入,可单测。 + * AutoPilot batch judge (see the exception rules in docs/arch/02-agent/03-autopilot.md「AutoPilot」): feed a batch of candidate PRs' + * titles + descriptions to the LLM, judge per PR "whether it is worth auto-reviewing" and attach a reason (e.g. branch merge / back-merge kind, + * pure dependency bumps can be skipped). Pure logic: the LLM channel is injected, unit-testable. */ export interface JudgeCandidate { prLocalId: string; title: string; description?: string; - /** 源 / 目标分支名(背景输入,助判分支合并 / 回合并)。 */ + /** Source / target branch names (background input, aids judging branch merge / back-merge). */ sourceBranch?: string; targetBranch?: string; - /** 据**实际提交结构**判出的「纯分支合并」(提交全为 merge commit,见 classifyBranchMerge)。 */ + /** "Pure branch merge" determined from the **actual commit structure** (commits are all merge commits, see classifyBranchMerge). */ branchMerge?: boolean; - /** 源分支为长期 / 集成分支(背景信号;不单独构成跳过理由,交 judge 权衡)。 */ + /** Source branch is a long-lived / integration branch (background signal; does not alone constitute a skip reason, left to the judge to weigh). */ sourceMainline?: boolean; } @@ -36,15 +36,15 @@ export interface JudgeDecision { review: boolean; reason: string; /** - * 该 PR 的评审执行计划(步骤序列)。本期判定**不产出**(恒省略 → 走 DEFAULT_REVIEW_PLAN);预留为后续 - * 「规则驱动步骤选择」的注入点:judge 提示词 + AGENTS.md 规则可逐 PR 给出计划(跳过 / 重排 / 增删步骤)。 + * The PR's review execution plan (step sequence). This iteration's judge **does not produce it** (always omitted → uses DEFAULT_REVIEW_PLAN); reserved as + * the injection point for later "rule-driven step selection": the judge prompt + AGENTS.md rules can give a per-PR plan (skip / reorder / add / remove steps). */ plan?: ReviewPlan; } export interface AutopilotJudgeInput { candidates: JudgeCandidate[]; - /** AGENTS.md 正文:例外规则来源(可在其中扩充跳过条件)。 */ + /** AGENTS.md body: source of exception rules (skip conditions can be extended within it). */ agentsRules?: string; } @@ -59,7 +59,7 @@ export async function judgeAutopilotBatch( ): Promise<AutopilotJudgeResult> { if (input.candidates.length === 0) return { decisions: [] }; - // 判定 system 基底外置在 resources/prompts/autopilot-judge.md;项目规则(AGENTS.md 正文)按需追加。 + // The judge system base is externalized in resources/prompts/autopilot-judge.md; project rules (AGENTS.md body) are appended as needed. const system = [ fillTemplate(PROMPT_TEMPLATES.autopilotJudge, {}), input.agentsRules?.trim() @@ -71,7 +71,7 @@ export async function judgeAutopilotBatch( const list = input.candidates .map((c, i) => { - // 分支合并信号作为**证据**列出(非定论):judge 据此 + 标题/描述自行权衡是否值得评审。 + // Branch merge signals are listed as **evidence** (not a verdict): the judge weighs them + title/description to decide if worth reviewing. const signals: string[] = []; if (c.branchMerge) signals.push('all commits are merge commits (likely a branch sync / back-merge)'); if (c.sourceMainline) signals.push('source is a long-lived / integration branch'); @@ -98,11 +98,11 @@ export async function judgeAutopilotBatch( const byId = new Map<string, JudgeDecision>(); for (const d of parsed?.decisions ?? []) { if (typeof d.prLocalId === 'string') { - // plan 非法 / 省略 → undefined(评审走默认全集);合法才带上,由 autopilot 透传给微流程。 + // Invalid / omitted plan → undefined (review uses the default full set); only attached if valid, passed through by autopilot to the micro-flow. const plan = parseReviewPlan(d.plan); byId.set(d.prLocalId, { prLocalId: d.prLocalId, - // 缺省 / 非显式 false → 评审(保守:宁可多评不漏) + // Omitted / not explicitly false → review (conservative: better to over-review than to miss) review: d.review !== false, reason: typeof d.reason === 'string' ? d.reason : '', ...(plan ? { plan } : {}), @@ -110,7 +110,7 @@ export async function judgeAutopilotBatch( } } - // 解析缺失的候选默认评审,保证每个候选都有决策。 + // Candidates missing from the parse default to review, ensuring every candidate has a decision. const decisions = input.candidates.map( (c) => byId.get(c.prLocalId) ?? { diff --git a/packages/agent/src/branch-merge.ts b/packages/agent/src/branch-merge.ts index 33543ee6..620844ff 100644 --- a/packages/agent/src/branch-merge.ts +++ b/packages/agent/src/branch-merge.ts @@ -1,20 +1,20 @@ /** - * 「纯分支合并」判定(AutoPilot 第一步 judge 的背景输入,见 docs/arch/02-agent/01-agent.md):分支合并 / 回合并 - * 把已评审过的分支改动同步到另一分支,无原创工作,自动预评审无意义。 + * "Pure branch merge" judge (background input for AutoPilot's first-step judge, see docs/arch/02-agent/01-agent.md): a branch merge / back-merge + * syncs already-reviewed branch changes to another branch, with no original work, so an automatic pre-review is meaningless. * - * **判定以实际提交结构为准**:PR 提交**全为 merge commit**(无原创非 merge 提交)→ 纯合并。需调用方先经 - * commits API 拉取提交(远端元数据,不碰本地 git)后传入;未提供 commits 则无法定论(`isBranchMerge:false`、 - * basis `inconclusive`),绝不仅凭分支名定论。 + * **The judgment is based on the actual commit structure**: PR commits are **all merge commits** (no original non-merge commits) → pure merge. The caller must first + * pull commits via the commits API (remote metadata, does not touch local git) and pass them in; if commits are not provided the result is inconclusive (`isBranchMerge:false`, + * basis `inconclusive`), never conclude from the branch name alone. * - * 源分支是否为长期 / 集成分支(main / develop / release/* 等)单独以 `sourceMainline` 给出——它**只是背景 - * 信号**(疑似回合并 / 同步的线索),不单独判定是否分支合并;调用方可据此决定是否值得拉 commits 复核,并 - * 把该信号一并交给 LLM judge 由其权衡,而非据此直接跳过。 + * Whether the source branch is a long-lived / integration branch (main / develop / release/* etc.) is given separately as `sourceMainline` — it is **only a background + * signal** (a hint of a suspected back-merge / sync), not judged alone for whether it is a branch merge; the caller may use it to decide whether it is worth pulling commits to recheck, and + * pass the signal along to the LLM judge to weigh, rather than skipping directly based on it. */ const MAINLINE_EXACT = new Set(['main', 'master', 'develop', 'dev', 'trunk']); const MAINLINE_PREFIX = ['release/', 'hotfix/']; -/** 源分支是否为长期 / 集成分支(其改动通常已在自身 PR 评审过)。 */ +/** Whether the source branch is a long-lived / integration branch (its changes are usually already reviewed in their own PR). */ export function isMainlineBranch(branch: string): boolean { const b = branch.trim().toLowerCase(); return MAINLINE_EXACT.has(b) || MAINLINE_PREFIX.some((p) => b.startsWith(p)); @@ -23,23 +23,23 @@ export function isMainlineBranch(branch: string): boolean { export interface BranchMergeInput { sourceBranch: string; targetBranch: string; - /** PR 提交(可选);(c) 拿不准时由调用方拉取后传入做 (b) 判定。 */ + /** PR commits (optional); (c) when uncertain, the caller pulls and passes them in to make the (b) judgment. */ commits?: ReadonlyArray<{ parents: string[] }>; } export interface BranchMergeVerdict { - /** 「纯分支合并」:提交全为 merge commit(无原创非 merge 提交)。仅在提供 commits 时可定论。 */ + /** "Pure branch merge": commits are all merge commits (no original non-merge commits). Conclusive only when commits are provided. */ isBranchMerge: boolean; - /** 判定依据:提交结构 / 未提供 commits 无法定论。 */ + /** Judgment basis: commit structure / inconclusive when commits are not provided. */ basis: 'commits' | 'inconclusive'; - /** 源分支是否为长期 / 集成分支(背景信号,供 judge 参考,不单独定论是否分支合并)。 */ + /** Whether the source branch is a long-lived / integration branch (background signal for the judge's reference, not judged alone for whether it is a branch merge). */ sourceMainline: boolean; } /** - * 判定一个 PR 是否「纯分支合并」。给了 commits 才能定论(全 merge commit → true);未给则 inconclusive - * (调用方据 `sourceMainline` 等决定是否拉 commits 复核,或交给 LLM judge)。分支名只填 `sourceMainline` - * 背景信号,不参与 isBranchMerge 定论。 + * Judge whether a PR is a "pure branch merge". Conclusive only when commits are given (all merge commits → true); otherwise inconclusive + * (the caller decides whether to pull commits to recheck based on `sourceMainline` etc., or defers to the LLM judge). The branch name only fills the `sourceMainline` + * background signal, and does not participate in the isBranchMerge conclusion. */ export function classifyBranchMerge(input: BranchMergeInput): BranchMergeVerdict { const sourceMainline = isMainlineBranch(input.sourceBranch); diff --git a/packages/agent/src/constants.ts b/packages/agent/src/constants.ts index 12d46601..4f6d05ae 100644 --- a/packages/agent/src/constants.ts +++ b/packages/agent/src/constants.ts @@ -2,26 +2,26 @@ import type { AgentRecommendationVerdict } from '@meebox/shared'; import type { AgentContextFiles } from './types.js'; /** - * 包内常量统一收口:所有值常量(标量 / 字符串 / 纯数据数组与映射)集中于此,便于复用与调参。 - * 不收的两类(非「值常量」):step 注册表(steps/review/index.ts 的 REVIEW_STEP_REGISTRY,实例化各 step 类的组合根)、 - * ?raw 资源载入对象(prompts.ts 的 PROMPT_TEMPLATES、templates.ts 的 AGENT_TEMPLATES)——它们是各自模块的本体。 + * Package-wide constants consolidated here: all value constants (scalars / strings / pure-data arrays and maps) are centralized here, for easy reuse and tuning. + * Two kinds not consolidated (not "value constants"): the step registry (REVIEW_STEP_REGISTRY in steps/review/index.ts, the composition root instantiating each step class), + * and ?raw resource-loaded objects (PROMPT_TEMPLATES in prompts.ts, AGENT_TEMPLATES in templates.ts) — they are the substance of their own modules. */ -// ── 评审判定 ── -/** 评审判定的合法取值:规划收尾(steps/planning)与评审微流程(steps/review)解析建议时共用的白名单。 */ +// ── review judge ── +/** Legal values for the review judge: the whitelist shared by planning summary (steps/planning) and the review micro-flow (steps/review) when parsing recommendations. */ export const VERDICTS: readonly AgentRecommendationVerdict[] = [ 'approve', 'needs_work', 'manual_review', ]; -// ── Agent 目录布局 ── +// ── Agent directory layout ── /** - * Agent 目录的固定文件布局(见 docs/arch/02-agent/01-agent.md「Agent 目录」)。 - * - SOUL.md 灵魂:核心职责与边界(Agent 只读,默认由模版规定) - * - AGENTS.md 工作规范与红线 - * - MEMORY.md 长期记忆(可写) - * - USER.md 用户画像(可写) + * The Agent directory's fixed file layout (see docs/arch/02-agent/01-agent.md「Agent 目录」). + * - SOUL.md soul: core responsibilities and boundaries (Agent read-only, defaults set by the template) + * - AGENTS.md work conventions and red lines + * - MEMORY.md long-term memory (writable) + * - USER.md user profile (writable) */ export const AGENT_FILES = { soul: 'SOUL.md', @@ -30,57 +30,57 @@ export const AGENT_FILES = { user: 'USER.md', } as const; -/** rules/ 子目录名:规则正文存放处,匹配语义见 @meebox/rules(docs/arch/02-agent/04-rules.md)。 */ +/** rules/ subdirectory name: where rule bodies are stored, matching semantics in @meebox/rules (docs/arch/02-agent/04-rules.md). */ export const AGENT_RULES_SUBDIR = 'rules'; -/** 全空上下文文件集:空 agentDir / 读失败时的失败安全回退(Agent 退化为原生)。 */ +/** All-empty context file set: the fail-safe fallback for empty agentDir / read failures (Agent degrades to native). */ export const EMPTY_FILES: AgentContextFiles = { soul: '', agents: '', memory: '', user: '' }; -// 工具清单(读 / 改 / grant)已收口到 @meebox/shared 的统一注册表 tool-registry(TOOLS); -// 工具目录由 buildToolCatalog 从中派生,见 tool-catalog.ts。 +// The tool list (read / write / grant) is consolidated into @meebox/shared's unified registry tool-registry (TOOLS); +// the tool catalog is derived from it by buildToolCatalog, see tool-catalog.ts. -// ── 工具并发错开(见 stagger.ts)── +// ── tool concurrency stagger (see stagger.ts) ── /** - * 把并发分发的工具调用相互错开一个累计的随机延迟:首个立即发出,其余各在前一个基础上再加 - * [MIN, MIN+SPAN]ms 起跑,避免不同工具在同一瞬间齐发、抢占子进程 spawn / LLM 网络。 - * 实际单步延迟 ∈ [100, 200]ms。 + * Stagger concurrently dispatched tool calls by a cumulative random delay: the first is sent immediately, each of the rest + * starts [MIN, MIN+SPAN]ms after the previous, avoiding different tools firing at the same instant and contending for subprocess spawn / LLM network. + * The actual per-step delay ∈ [100, 200]ms. */ export const STAGGER_MIN_MS = 100; export const STAGGER_SPAN_MS = 100; -// ── 规划(ReAct)── -/** 一次并行最多分发的工具数:多选时截断,防止一轮打出过多 pr-agent run。 */ +// ── planning (ReAct) ── +/** Max number of tools dispatched in parallel at once: truncated on multi-select, to prevent firing too many pr-agent runs in one round. */ export const MAX_PARALLEL_TOOLS = 3; /** - * 注入规划上下文的历史对话预算:单条字符上限 + 总字符预算(从最新往回累计、超预算即裁剪更早的)。 - * 约定会话上下文不超过 LLM 上下文窗口的一半——以字符近似 token 做保守封顶:64k 字符 ≈ 16~40k token。 + * History conversation budget injected into the planning context: per-message char cap + total char budget (accumulated from newest backward, trimming older ones once over budget). + * Convention is that the session context does not exceed half the LLM context window — using chars to approximate tokens as a conservative cap: 64k chars ≈ 16~40k tokens. */ export const HISTORY_MESSAGE_MAX = 2000; export const HISTORY_BUDGET_CHARS = 64000; -// ── 追问判读(steps/review)── -/** 追问判断用的精简系统提示:不带 agent 完整上下文(SOUL / 记忆 / 用户档 / 工具目录 / 规则 / PR 元数据)。 - * 这是一次轻量路由判读,仅凭 describe + review 结果判「是否有严重问题需追问」,与 AutoPilot 初判同思路。 */ +// ── follow-up ask judging (steps/review) ── +/** Compact system prompt for the follow-up ask judgment: without the agent's full context (SOUL / memory / user profile / tool catalog / rules / PR metadata). + * This is a lightweight routing judgment, deciding "whether there are severe issues needing a follow-up ask" from describe + review results alone, following the same idea as AutoPilot's initial judgment. */ export const JUDGE_SYSTEM = 'You are a senior code reviewer triaging review findings for follow-up. Be decisive and terse; reply with JSON only, no reasoning.'; -/** 追问判读的输出 token 上限:产物是极小 JSON(severe + 至多数条问题),无需大额度。 */ +/** Output token cap for the follow-up ask judgment: the product is tiny JSON (severe + at most a few issues), no large budget needed. */ export const JUDGE_MAX_OUTPUT_TOKENS = 1024; -/** 收尾总结的输出 token 上限:总结是整段 markdown 综合(三段 + 末尾判定 JSON),给足额度避免被 provider - * 默认上限截断(截断会连带丢掉末尾判定 → 回落 manual_review)。summaryMax 是软字符指引,这里是硬封顶。 */ +/** Output token cap for the summary: the summary is a whole markdown synthesis (three sections + a trailing judgment JSON), give ample budget to avoid being truncated by the provider's + * default cap (truncation would also drop the trailing judgment → falls back to manual_review). summaryMax is a soft char guide, this is a hard cap. */ export const SUMMARY_MAX_OUTPUT_TOKENS = 4096; -// ── AutoPilot 准入判读(autopilot-judge.ts)── -/** 候选 PR 描述喂判读 LLM 前的截断字符数:控制 prompt 体积,准入判读不需要完整描述。 */ +// ── AutoPilot admission judgment (autopilot-judge.ts) ── +/** Char truncation count before feeding candidate PR descriptions to the judging LLM: controls prompt size, the admission judgment does not need the full description. */ export const DESC_CLAMP = 600; -// ── 系统上下文装配(assemble.ts)── +// ── system context assembly (assemble.ts) ── /** - * 缓存断点标记:插在「全局稳定前缀」与「PR/运行相关尾部」之间(含两侧 --- 分隔)。嵌入式 shim 据此把 - * 稳定前缀单独标 Anthropic 提示缓存(1h)、跨 PR/运行命中,尾部保持纯文本;消费端分割 / 剥除后标记绝不 - * 进入发给模型的 prompt(litellm 分块、CLI 拼接均处理)。 - * **须与 scripts/pragent-shim/meebox_pragent_shim/runtime.py 的 `CACHE_BREAK` 逐字一致。** + * Cache-break marker: inserted between the "global stable prefix" and the "PR/run-related tail" (including the --- separators on both sides). The embedded shim uses it to + * mark the stable prefix alone for Anthropic prompt caching (1h), hitting across PRs/runs, while the tail stays plain text; after the consumer splits / strips it the marker never + * enters the prompt sent to the model (handled by both litellm chunking and CLI concatenation). + * **Must be byte-for-byte identical to `CACHE_BREAK` in scripts/pragent-shim/meebox_pragent_shim/runtime.py.** */ export const CACHE_BREAK = '\n\n---\n\n[[MEEBOX:CACHE_BREAK]]\n\n---\n\n'; diff --git a/packages/agent/src/memory.ts b/packages/agent/src/memory.ts index c31b6a2e..f446c12f 100644 --- a/packages/agent/src/memory.ts +++ b/packages/agent/src/memory.ts @@ -2,23 +2,23 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { resolveAgentPaths } from './agent-files.js'; -/** Agent 可写的上下文文件(SOUL.md 永远只读、绝不写)。 */ +/** Context files the Agent may write (SOUL.md is always read-only, never written). */ export type WritableAgentFile = 'user' | 'memory' | 'agents'; -/** 单条主动记忆:必带目标专题章节,写入对应 `## section` 末尾。 */ +/** A single proactive memory note: must carry a target topic section, written to the end of the corresponding `## section`. */ export interface MemoryNote { /** - * 目标章节标题(**不含** `## ` 前缀)。命中文件里已有的同名 `## 章节` → 追加到该节末尾; - * 不存在 → 在文件末尾新建该节。**必填**——无法归入某个专题的条目不是耐久记忆、不应记录。 + * Target section heading (**without** the `## ` prefix). Matches an existing `## section` of the same name in the file → appended to the end of that section; + * not present → creates that section at the end of the file. **Required** — an entry that cannot be filed under some topic is not durable memory and should not be recorded. */ section: string; - /** 条目正文(已是抽象归纳后的耐久陈述)。 */ + /** Entry body (already a durable statement after abstraction and generalization). */ note: string; } /** - * 把 bullets 插到 `## heading` 区段末尾(下一个 markdown 标题之前);该标题不存在则在 - * 文件末尾新建一节。返回新内容(纯字符串变换,不落盘)。 + * Insert bullets at the end of the `## heading` section (before the next markdown heading); if that heading does not exist, + * create a new section at the end of the file. Returns the new content (pure string transform, no disk write). */ function insertUnderHeading(content: string, heading: string, notes: readonly string[]): string { const bullets = notes.map((n) => `- ${n}`); @@ -32,7 +32,7 @@ function insertUnderHeading(content: string, heading: string, notes: readonly st return `${prefix}## ${heading}\n${bullets.join('\n')}\n`; } - // 区段末尾 = 下一个行首 markdown 标题(# ~ ######)所在行,或文件末。 + // Section end = the line of the next line-start markdown heading (# ~ ######), or the end of the file. let end = lines.length; for (let i = headingIdx + 1; i < lines.length; i++) { if (/^#{1,6}\s/.test(lines[i]!)) { @@ -40,7 +40,7 @@ function insertUnderHeading(content: string, heading: string, notes: readonly st break; } } - // 跳过区段尾部空行,插在最后一条内容之后(保持与后续章节的空行间隔)。 + // Skip trailing blank lines of the section, insert after the last content line (keeping the blank-line spacing from the following section). let insertAt = end; while (insertAt > headingIdx + 1 && lines[insertAt - 1]!.trim() === '') insertAt--; lines.splice(insertAt, 0, ...bullets); @@ -48,16 +48,16 @@ function insertUnderHeading(content: string, heading: string, notes: readonly st } /** - * 把 Agent 主动记下的条目按**专题章节**写入指定可写上下文文件: - * - user → USER.md:非隐私的用户信息(称呼 / 语言 / 评审习惯偏好) - * - memory → MEMORY.md:长期知识 / 仓库事实 - * - agents → AGENTS.md:工作规范 / 评审约定(**仅追加**,不改写、不删除既有红线) + * Write the entries the Agent proactively noted into the specified writable context file by **topic section**: + * - user → USER.md: non-private user info (form of address / language / review-habit preferences) + * - memory → MEMORY.md: long-term knowledge / repo facts + * - agents → AGENTS.md: work conventions / review conventions (**append-only**, does not rewrite or delete existing red lines) * - * 每条必带 `section`:命中已有 `## 章节`→ 追加到该节末尾;不存在 → 文件末尾新建该节。把条目归到 - * 专题章节而非单一堆叠区,便于跨会话上下文管理(抽象归纳在提示词把关;无法归类的条目不是耐久记忆、不记录)。 + * Each entry must carry a `section`: matches an existing `## section` → appended to the end of that section; not present → creates that section at the end of the file. Filing entries into + * topic sections rather than a single stacked area eases cross-session context management (abstraction and generalization are gated in the prompt; entries that cannot be filed are not durable memory and are not recorded). * - * 绝不写 SOUL.md。幂等去重:正文与现有内容重复(子串命中)或批内重复的不再追加;返回实际新增的条目正文。 - * 隐私边界由提示词在生成 notes 时把关(见 planner Protocol);此处只负责分节落盘与去重。 + * Never writes SOUL.md. Idempotent dedup: a body duplicated with existing content (substring hit) or duplicated within the batch is not appended again; returns the actually-added entry bodies. + * The privacy boundary is gated in the prompt when generating notes (see planner Protocol); here it only handles sectioned disk writing and dedup. */ export async function appendAgentNotes( agentDir: string, @@ -77,7 +77,7 @@ export async function appendAgentNotes( content = ''; } - // 幂等去重:正文与现有内容重复(子串命中)或批内重复的跳过。 + // Idempotent dedup: skip bodies duplicated with existing content (substring hit) or duplicated within the batch. const haystack = content.toLowerCase(); const seen = new Set<string>(); const fresh: typeof cleaned = []; @@ -89,7 +89,7 @@ export async function appendAgentNotes( } if (fresh.length === 0) return []; - // 按目标章节分组,保持各组内的原始顺序。 + // Group by target section, preserving the original order within each group. const groups = new Map<string, string[]>(); for (const n of fresh) { const bucket = groups.get(n.section); diff --git a/packages/agent/src/orchestrator.ts b/packages/agent/src/orchestrator.ts index 6bc7d3aa..8c92bf85 100644 --- a/packages/agent/src/orchestrator.ts +++ b/packages/agent/src/orchestrator.ts @@ -20,29 +20,29 @@ import { import type { AgentContext } from './types.js'; /** - * 结构化「评审微流程」编排器(见 docs/arch/02-agent/03-autopilot.md「AutoPilot」的有界微流程): - * describe → review →(仅严重问题)条件性追问 ≤N → 收尾总结 + 建议。 + * Structured "review microflow" orchestrator (see docs/arch/02-agent/03-autopilot.md "AutoPilot" bounded microflow): + * describe → review → (only for severe issues) conditional follow-up asks ≤N → summary + recommendation. * - * 这是**固定模板**而非自由 ReAct:流程由代码确定,LLM 只在两处做受限判断 - * (判严重性 / 出总结),故鲁棒、可预测、步数有界——契合 per-PR 子 agent 的设计。 - * 纯逻辑:工具分发(runTool)与 LLM 通道(chat)由调用方注入,便于单测与复用。 + * This is a **fixed template**, not free ReAct: the flow is determined by code, the LLM only makes limited judgments + * in two places (judge severity / produce summary), so it is robust, predictable, and step-bounded — fitting the per-PR sub-agent design. + * Pure logic: tool dispatch (runTool) and the LLM channel (chat) are injected by the caller, easing unit testing and reuse. */ export interface ToolText { text: string; usage?: TokenUsage; - /** 本次工具 run 的 id(PR3:judge 点名 / asks 复评关联需引用,review / ask 回填)。 */ + /** id of this tool run (PR3: needed to reference judge callouts / asks re-review association, backfilled by review / ask). */ runId?: string; - /** 解析出的结构化 findings(review 回填,供 judge 按 id 点名复评)。 */ + /** parsed structured findings (backfilled by review, for judge to call out for re-review by id). */ findings?: Finding[]; - /** 复评 /ask 的裁决(复评模式 ask 回填,asks 步据此自动关闭原 finding)。 */ + /** re-review /ask verdict (backfilled by ask in re-review mode; the asks step auto-closes the original finding accordingly). */ askVerdict?: AskVerdict; } export interface ReviewOrchestratorDeps { /** - * 分发一个只读 pr-agent 工具,返回结果(描述 / findings / 回答 + runId / findings / askVerdict)。 - * referencedContext / referencedFinding 仅复评模式 /ask 用:注入被复评评论上下文 + 结构化引用前向链。 + * Dispatch a read-only pr-agent tool, returning the result (description / findings / answer + runId / findings / askVerdict). + * referencedContext / referencedFinding are only for re-review mode /ask: inject the re-reviewed comment context + structured reference forward chain. */ runTool(call: { tool: ReviewRunTool; @@ -50,13 +50,13 @@ export interface ReviewOrchestratorDeps { referencedContext?: string; referencedFinding?: ReviewRun['referencedFinding']; }): Promise<ToolText>; - /** 经独立 LLM 通道做一次受限对话(判严重性 / 出总结)。maxOutputTokens 可给轻量路由判读封顶输出。 */ + /** Run one limited conversation over an independent LLM channel (judge severity / produce summary). maxOutputTokens can cap output for lightweight routing judgments. */ chat(input: { system: string; user: string; maxOutputTokens?: number }): Promise<ToolText>; - /** 每产生一个编排步骤即回调(持久化 / 流式推送)。 */ + /** Callback on each orchestration step produced (persistence / streaming push). */ onStep?(step: AgentStep): void | Promise<void>; /** - * PR3:复评 ask 裁决 replace/drop 时自动关闭被取代的原 review finding(建立 FindingClosure)。 - * 缺省 = 不关(单测 / 未接主进程时)。 + * PR3: when a re-review ask verdict is replace/drop, auto-close the superseded original review finding (establish a FindingClosure). + * Omitted = do not close (in unit tests / when not wired to the main process). */ closeFinding?(call: { runId: string; @@ -64,29 +64,29 @@ export interface ReviewOrchestratorDeps { byAskRunId: string; verdict: AskVerdict; }): Promise<void>; - /** 用户停止:每步边界检查,已 abort 即抛 `aborted` 中止微流程(思考阶段也能立即终止)。 */ + /** User stop: boundary check at each step; if already aborted, throw `aborted` to halt the microflow (can terminate immediately even during the thinking phase). */ signal?: AbortSignal; } export interface ReviewOrchestratorInput { context: AgentContext; pr: AssemblePrMeta; - /** 命中规则的已拼接正文(多条经 combineRuleInstructions 拼成);无命中传空 / null。 */ + /** Concatenated body of matched rules (multiple joined via combineRuleInstructions); pass empty / null when nothing matched. */ matchedRuleInstructions?: string | null; language?: string; - /** 步骤展示文案(主进程 i18n 解析后注入);省略回落 DEFAULT_STEP_LABELS(en-US)。 */ + /** Step display text (injected by the main process after i18n resolution); omitted falls back to DEFAULT_STEP_LABELS (en-US). */ labels?: AgentStepLabels; - /** 总结三段骨架标题(主进程 i18n 注入);省略回落 DEFAULT_SUMMARY_SECTIONS(en-US)。 */ + /** Summary three-section skeleton headings (injected by the main process i18n); omitted falls back to DEFAULT_SUMMARY_SECTIONS (en-US). */ summarySections?: readonly [string, string, string]; - /** 注入提示词的工具目录(含修改红线标注,见 buildToolCatalog)。 */ + /** Tool catalog injected into the prompt (with modification red-line annotations, see buildToolCatalog). */ toolCatalog?: ToolCatalogEntry[]; - /** 条件性追问 /ask 的硬上限(默认 2)。 */ + /** Hard cap on conditional follow-up asks (default 2). */ maxFollowupAsks?: number; - /** 总结篇幅的**参考**上限(默认 800 字符):仅作提示词里的软约束引导 LLM 收敛,**不**对产出做硬截断。 */ + /** **Reference** cap on summary length (default 800 chars): only a soft constraint in the prompt to guide the LLM to converge, **not** a hard truncation on the output. */ summaryMaxChars?: number; /** - * 执行计划(步骤序列)。省略 / 非法时用 DEFAULT_REVIEW_PLAN(即 describe-review → judge → asks → - * summary,与拆 plan 前一致)。仅 AutoPilot 路径会按规则注入自定义计划;手动评审恒省略走默认。 + * Execution plan (step sequence). When omitted / invalid, use DEFAULT_REVIEW_PLAN (i.e. describe-review → judge → asks → + * summary, same as before splitting the plan out). Only the AutoPilot path injects a custom plan per rules; manual review always omits it and takes the default. */ plan?: ReviewPlan; } @@ -99,8 +99,8 @@ export interface ReviewOrchestratorResult { terminationReason?: string; } -/** 评审总结三段式骨架标题的**默认值(en-US 兜底)**:顺序固定为 概述 / 关键发现 / 建议。多语言译文由 - * 调用方(主进程 i18n 资源)解析后经 input.summarySections 注入;未注入时回落本默认。 */ +/** **Default value (en-US fallback)** for the review summary's three-section skeleton headings: order is fixed as Summary / Key findings / Suggestions. Localized translations are + * resolved by the caller (main process i18n resources) and injected via input.summarySections; when not injected, falls back to this default. */ export const DEFAULT_SUMMARY_SECTIONS: readonly [string, string, string] = [ 'Summary', 'Key findings', @@ -108,28 +108,28 @@ export const DEFAULT_SUMMARY_SECTIONS: readonly [string, string, string] = [ ]; /** - * 编排 / 规划步骤行里**直接展示**给用户的固定文案(thought / 判读结果 / 兜底建议理由 / 拒绝前缀)。 - * 这些串经 transcript 持久化、由渲染层逐字显示(不走 i18next key 映射),故须在**生成时**就是目标语言文本: - * 由调用方(主进程 i18n 资源)解析后经 input.labels 注入,agent 内仅留 en-US 兜底(DEFAULT_STEP_LABELS)。 - * LLM 生成的自由 thought 本就跟随作答语言,不在此列;事后切 UI 语言不回改历史步骤(同总结正文)。 + * Fixed text **directly displayed** to the user in orchestration / planning step rows (thought / judge result / fallback recommendation reason / rejection prefix). + * These strings are persisted in the transcript and shown verbatim by the render layer (not via i18next key mapping), so they must already be target-language text **at generation time**: + * resolved by the caller (main process i18n resources) and injected via input.labels, with the agent keeping only an en-US fallback (DEFAULT_STEP_LABELS). + * Free-form thought generated by the LLM already follows the answer language and is not covered here; switching UI language afterward does not rewrite historical steps (same as the summary body). */ export interface AgentStepLabels { - /** 微流程「生成 PR 描述与审查发现」步思考(describe + review 合并为一行;二者并行执行)。 */ + /** Microflow "generate PR description and review findings" step thought (describe + review merged into one row; the two run in parallel). */ describeReview: string; - /** 微流程「生成代码改进建议」步思考(/improve;仅规则计划纳入时出现)。 */ + /** Microflow "generate code improvement suggestions" step thought (/improve; only appears when included by the rule plan). */ improve: string; - /** 微流程判读步思考。 */ + /** Microflow judge step thought. */ judge: string; - /** 判读结果:存在严重问题、将追问 n 个。 */ + /** Judge result: severe issues exist, will follow up with n asks. */ judgeSevere: (n: number) => string; - /** 判读结果:无严重问题、不追问。 */ + /** Judge result: no severe issues, no follow-up. */ judgeNone: string; - /** 收尾步思考。 */ + /** Summary step thought. */ summary: string; - /** 规划步:工具调用被红线拒绝的结果前缀(后接具体原因)。 */ + /** Planning step: result prefix when a tool call is rejected by the red line (followed by the specific reason). */ rejectedPrefix: string; } -/** 步骤文案默认值(en-US 兜底);多语言由主进程 i18n 解析后经 input.labels 注入,未注入时回落本默认。 */ +/** Default value for step text (en-US fallback); localized versions are resolved by the main process i18n and injected via input.labels, falling back to this default when not injected. */ export const DEFAULT_STEP_LABELS: AgentStepLabels = { describeReview: 'Generate the PR description and review findings', improve: 'Generate code improvement suggestions', @@ -141,9 +141,9 @@ export const DEFAULT_STEP_LABELS: AgentStepLabels = { }; /** - * 跑一次评审微流程:默认 describe → review →(仅严重问题)条件追问 ≤N → 收尾总结 + 建议。只用只读工具 - * (describe/review/ask),不碰修改类操作。驱动按 input.plan(省略 / 非法回落 DEFAULT_REVIEW_PLAN)经 - * assembleReviewSteps 顺序跑各步、与各步共享 StepRecorder。 + * Run one review microflow: by default describe → review → (only for severe issues) conditional follow-up asks ≤N → summary + recommendation. Uses only read-only tools + * (describe/review/ask), never touching modification operations. The driver runs each step in order via assembleReviewSteps per input.plan (omitted / invalid falls back to DEFAULT_REVIEW_PLAN), + * sharing a StepRecorder with each step. */ export async function runReviewMicroflow( deps: ReviewOrchestratorDeps, @@ -152,10 +152,10 @@ export async function runReviewMicroflow( const labels = input.labels ?? DEFAULT_STEP_LABELS; const rec = createStepRecorder(deps.onStep); const checkAbort = (): void => { - // 抛稳定 code 'aborted'(非本地化文案):主进程据 signal.aborted / 此 code 收尾为 paused 并落本地化文案。 + // Throw the stable code 'aborted' (not localized text): the main process finalizes as paused per signal.aborted / this code and lands localized text. if (deps.signal?.aborted) throw new Error('aborted'); }; - // base system context(工具目录留空:微流程不暴露自由工具选择)。 + // base system context (tool catalog left empty: the microflow does not expose free tool selection). const system = assembleSystemContext({ context: input.context, pr: input.pr, @@ -175,15 +175,15 @@ export async function runReviewMicroflow( bag: { asks: [], askResults: [] }, }; - // 计划:省略 / 非法(如 judge/summary 缺前置 describe-review)一律回落默认全集,避免坏计划崩在步骤里。 + // Plan: omitted / invalid (e.g. judge/summary missing the prerequisite describe-review) always falls back to the full default set, avoiding a bad plan crashing inside a step. const plan = input.plan && isValidReviewPlan(input.plan) ? input.plan : DEFAULT_REVIEW_PLAN; for (const step of assembleReviewSteps(plan)) await step.run(ctx); return { steps: rec.steps, summary: ctx.bag.summary ?? '', - // 兜底(收尾步未产出 recommendation):转人工复核、不带理由——解析失败的兜底无用户价值,前端按 - // 空 reason 隐藏灰字(与 summary-step 同口径)。 + // Fallback (summary step produced no recommendation): switch to manual review, without a reason — a parse-failure fallback has no user value, and the frontend hides the + // gray text on an empty reason (same convention as summary-step). recommendation: ctx.bag.recommendation ?? { verdict: 'manual_review', reason: '' }, tokenUsage: rec.usage, }; diff --git a/packages/agent/src/planner.ts b/packages/agent/src/planner.ts index c9b74ef9..49dd7ff8 100644 --- a/packages/agent/src/planner.ts +++ b/packages/agent/src/planner.ts @@ -19,11 +19,11 @@ import { import type { AgentContext } from './types.js'; /** - * 自由规划(ReAct)编排器(见 docs/arch/02-agent/02-session.md「会话 Agent 化」):交互式入口的自然语言请求由它 - * 处理——驱动反复跑 plan-cycle 步骤(每轮 chat 规划下一动作、调工具 / 收尾),到 final 或步数上限。与固定 - * 微流程(runReviewMicroflow)互补。本文件只留**公共类型 + 驱动**,单步逻辑见 steps/planning。 + * Free-planning (ReAct) orchestrator (see docs/arch/02-agent/02-session.md "session as agent"): handles natural-language requests from + * the interactive entry point — driving the plan-cycle step repeatedly (each round chat plans the next action, calls a tool / finalizes), until final or the step cap. Complements the fixed + * microflow (runReviewMicroflow). This file keeps only the **public types + driver**; single-step logic is in steps/planning. * - * 纯逻辑:chat / runTool 注入;红线经 assertToolAllowed 落地(在 plan-cycle 步骤里)。 + * Pure logic: chat / runTool injected; the red line is enforced via assertToolAllowed (inside the plan-cycle step). */ export interface PlanningToolResult { @@ -32,21 +32,21 @@ export interface PlanningToolResult { } export interface PlanningDeps { - /** 规划 LLM 调用(单 system + user)。 */ + /** Planning LLM call (single system + user). */ chat: (input: { system: string; user: string }) => Promise<PlanningToolResult>; - /** 分发一个工具,返回文本结果(红线已由编排器先行校验)。 */ + /** Dispatch a tool, returning a text result (the red line has already been validated by the orchestrator). */ runTool: (call: { tool: string; question?: string }) => Promise<PlanningToolResult>; onStep?: (step: AgentStep) => void | Promise<void>; - /** 用户暂停信号;abort 后循环在下一步前停下,返回 terminationReason='aborted'(稳定 code,主进程映射本地化)。 */ + /** User pause signal; after abort the loop stops before the next step and returns terminationReason='aborted' (stable code, mapped to localized text by the main process). */ signal?: AbortSignal; /** - * 取出运行期间排队的用户新消息(中途输入转向):每轮顶部调用,非空则并入当轮 progress,让 ReAct 据 - * 最新指令与当前进度重排下一步。返回的消息由实现方(主进程)负责持久化到会话(此处只注入、不再落盘)。 + * Drain new user messages queued during the run (mid-run input redirection): called at the top of each round; if non-empty, merge into this round's progress so ReAct can + * reorder the next step per the latest instruction and current progress. Returned messages are persisted to the session by the implementer (main process) (here we only inject, not persist). */ drainPendingInput?: () => Promise<string[]> | string[]; /** - * 计划(todo)更新回调:模型每轮给出 / 更新 plan 时调用,由实现方持久化(session.todo)+ 广播刷新。 - * 计划随轮回喂提示、收到新输入时重排——见 buildProtocol 的 plan 约定。 + * Plan (todo) update callback: called when the model gives / updates the plan each round, persisted (session.todo) + broadcast refresh by the implementer. + * The plan is fed back into the prompt each round and reordered when new input arrives — see the plan convention in buildProtocol. */ recordPlan?: (todo: AgentTodoItem[]) => void | Promise<void>; } @@ -55,30 +55,30 @@ export interface PlanningInput { context: AgentContext; pr: AssemblePrMeta; toolCatalog: ToolCatalogEntry[]; - /** 命中规则的已拼接正文(多条经 combineRuleInstructions 拼成);无命中传空 / null。 */ + /** Concatenated body of matched rules (multiple joined via combineRuleInstructions); pass empty / null when nothing matched. */ matchedRuleInstructions?: string | null; language?: string; - /** 步骤展示文案(主进程 i18n 解析后注入);省略回落 DEFAULT_STEP_LABELS(en-US)。 */ + /** Step display text (injected by the main process after i18n resolution); omitted falls back to DEFAULT_STEP_LABELS (en-US). */ labels?: AgentStepLabels; - /** 评审收尾骨架三段标题(主进程 i18n 注入 buildProtocol);省略回落 DEFAULT_SUMMARY_SECTIONS(en-US)。 */ + /** Review summary skeleton three-section headings (injected by the main process i18n into buildProtocol); omitted falls back to DEFAULT_SUMMARY_SECTIONS (en-US). */ summarySections?: readonly [string, string, string]; - /** 用户的自然语言请求。 */ + /** The user's natural-language request. */ userRequest: string; /** - * 既往多轮对话(用户 / 助手消息,按时间升序,不含本轮请求)。注入规划 LLM 的上下文,使 - * Agent 跨轮记住此前交流;**绝不**透传给 pr-agent 工具(工具只看 PR + 当轮问题)。 + * Prior multi-turn conversation (user / assistant messages, in ascending time order, excluding this round's request). Injected into the planning LLM's context so the + * agent remembers earlier exchanges across rounds; **never** passed through to pr-agent tools (tools only see PR + this round's question). */ history?: AgentMessage[]; /** - * 用户在 Diff 里选中的代码引用(自描述块)。注入当轮规划上下文,让 Agent 知道用户正盯着哪段代码; - * **绝不**透传给 pr-agent 工具(同 history 约束)。省略 = 本轮无选区引用。 + * Code reference selected by the user in the diff (self-describing block). Injected into this round's planning context so the agent knows which code the user is looking at; + * **never** passed through to pr-agent tools (same constraint as history). Omitted = no selection reference this round. */ referencedContext?: string; - /** 步数上限(默认 8)。 */ + /** Step cap (default 8). */ maxSteps?: number; /** - * 本会话 /ask 数量上限(遵循配置的「追问数量」max_followup_asks,默认 2):连续 /ask 各为一次 agentic - * 探索、成本高,按此封顶;与「自动追问」开关无关(开关仅约束评审微流程)。 + * Per-session /ask count cap (follows the configured "follow-up count" max_followup_asks, default 2): each consecutive /ask is an agentic + * exploration and costly, so cap it accordingly; unrelated to the "auto follow-up" toggle (the toggle only constrains the review microflow). */ maxFollowupAsks?: number; } @@ -87,15 +87,15 @@ export interface PlanningResult { steps: AgentStep[]; finalText: string; tokenUsage: TokenUsage; - /** 收尾建议(仅评审类请求;非约束性)。供 UI 展示判定徽标,与 AutoPilot / 微流程一致。 */ + /** Summary recommendation (only for review-type requests; non-binding). For the UI to show the judge badge, consistent with AutoPilot / the microflow. */ recommendation?: AgentRecommendation; - /** 本轮主动记下、待持久化到各可写文件的非隐私条目(去重后写盘由上层处理)。 */ + /** Non-private entries actively noted this round, pending persistence to each writable file (dedup and disk write handled by the upper layer). */ memories: AgentMemoryNotes; - /** 中止原因的稳定 code:'aborted'(用户暂停)/ 'max_steps'(步数上限);本地化文案由主进程映射。 */ + /** Stable code for the termination reason: 'aborted' (user pause) / 'max_steps' (step cap); localized text mapped by the main process. */ terminationReason?: string; } -/** Agent 主动记忆,按目标可写文件分组(键与 WritableAgentFile 对齐),各条带目标专题章节。 */ +/** Agent-authored memory, grouped by target writable file (keys aligned with WritableAgentFile), each entry with a target topic section. */ export interface AgentMemoryNotes { user: MemoryNote[]; memory: MemoryNote[]; @@ -107,8 +107,8 @@ function emptyMemoryNotes(): AgentMemoryNotes { } /** - * 驱动:现读现装配 system(含 Protocol)+ 会话上下文,反复跑 plan-cycle 步骤直至收尾 / 暂停 / 步数上限。 - * 单步逻辑(拼 prompt / 解析动作 / 红线 / 派发工具 / 中途输入 / 计划维护)见 steps/planning 的 planCycleStep。 + * Driver: assemble system (incl. Protocol) + session context on the fly, running the plan-cycle step repeatedly until finalization / pause / step cap. + * Single-step logic (build prompt / parse action / red line / dispatch tools / mid-run input / plan maintenance) is in planCycleStep under steps/planning. */ export async function runPlanningAgent( deps: PlanningDeps, @@ -128,8 +128,8 @@ export async function runPlanningAgent( language: input.language, })}\n\n---\n\n# Protocol\n\n${buildProtocol(input.summarySections ?? DEFAULT_SUMMARY_SECTIONS)}`; - // 既往多轮对话注入规划上下文(按预算裁剪),让 Agent 跨轮记住交流;仅供规划 LLM 参考, - // 绝不透传给 pr-agent 工具。 + // Inject prior multi-turn conversation into the planning context (trimmed by budget) so the agent remembers exchanges across rounds; only for the planning LLM's reference, + // never passed through to pr-agent tools. const convo = buildConversationContext(input.history ?? []); const ctx: PlanStepCtx = { deps, @@ -145,7 +145,7 @@ export async function runPlanningAgent( asksUsed: 0, }; - // 规划是单步循环:重复跑 plan-cycle 直至收尾 / 暂停 / 步数上限。 + // Planning is a single-step loop: run plan-cycle repeatedly until finalization / pause / step cap. for (let i = 0; i < maxSteps; i++) { const outcome = await planCycleStep.run(ctx); if (outcome.kind === 'aborted') { diff --git a/packages/agent/src/prompts.ts b/packages/agent/src/prompts.ts index 0f310867..8a1a90b0 100644 --- a/packages/agent/src/prompts.ts +++ b/packages/agent/src/prompts.ts @@ -7,41 +7,41 @@ import { CACHE_BREAK } from './constants.js'; import type { AgentContext } from './types.js'; /** - * 提示词组装域(见 docs/arch/02-agent/01-agent.md「提示词模版」「上下文注入」):静态 user-turn 模板(PROMPT_TEMPLATES) - * 与动态 system 上下文装配(assembleSystemContext)都属「构造发给模型的 prompt」,收口于此。占位符填充 / 截断 - * 等域无关字符串工具见 utils。 + * Prompt assembly domain (see docs/arch/02-agent/01-agent.md "prompt templates" "context injection"): both the static user-turn templates (PROMPT_TEMPLATES) + * and the dynamic system context assembly (assembleSystemContext) belong to "constructing the prompt sent to the model" and converge here. Domain-agnostic string tools such as + * placeholder filling / truncation are in utils. */ -// ── 静态 user-turn 模板 ── +// ── Static user-turn templates ── /** - * 编排器提示词模板:静态正文外置到 `resources/prompts/` 的 `.md`,构建期经 Vite `?raw` 内联。动态值用 - * `{{name}}` 占位符、由 utils 的 fillTemplate 注入;条件拼接与大块动态内容(describe/review 文本、PR 列表等) - * 仍由各调用方在 TS 侧组装。 + * Orchestrator prompt templates: static bodies are externalized into `.md` under `resources/prompts/`, inlined at build time via Vite `?raw`. Dynamic values use + * `{{name}}` placeholders, injected by utils's fillTemplate; conditional concatenation and large dynamic content (describe/review text, PR list, etc.) + * are still assembled on the TS side by each caller. */ export const PROMPT_TEMPLATES = { - /** 规划 ReAct 协议(动作格式 / 评审收尾骨架 / 记忆规则 / 计划 / 会话范围)。占位:overview/findings/suggestions。 */ + /** Planning ReAct protocol (action format / review summary skeleton / memory rules / plan / session scope). Placeholders: overview/findings/suggestions. */ protocol, - /** 追问判读 user 指令(占位:maxAsks/language);describe/review 正文由调用方追加。 */ + /** Follow-up judge user instruction (placeholders: maxAsks/language); describe/review body appended by the caller. */ judge, - /** 收尾总结 user 指令 + 三段骨架(占位:maxChars/overview/findings/suggestions);正文由调用方追加。 */ + /** Summary user instruction + three-section skeleton (placeholders: maxChars/overview/findings/suggestions); body appended by the caller. */ summary, - /** AutoPilot 批量判定 system 基底(无占位);项目规则由调用方按需追加。 */ + /** AutoPilot batch-judge system base (no placeholders); project rules appended by the caller as needed. */ autopilotJudge, } as const; -// ── 动态 system 上下文装配 ── +// ── Dynamic system context assembly ── -/** 当前 PR 的最小元数据(装配进上下文)。 */ +/** Minimal metadata for the current PR (assembled into the context). */ export interface AssemblePrMeta { title: string; description?: string; targetBranch: string; - /** 变更概况,如「12 files, +340/-58」。 */ + /** Change overview, e.g. "12 files, +340/-58". */ changeSummary?: string; } -/** 当前会话快照:让 Agent 续上未完成的规划。 */ +/** Current session snapshot: lets the agent resume unfinished planning. */ export interface AssembleSessionSnapshot { todo: AgentTodoItem[]; progressNote?: string; @@ -51,9 +51,9 @@ export interface AssembleInput { context: AgentContext; pr: AssemblePrMeta; toolCatalog: ToolCatalogEntry[]; - /** 命中规则的已拼接正文(多条经 combineRuleInstructions 拼成、含 Ruleset 分段);无命中传空 / null。 */ + /** Concatenated body of matched rules (multiple joined via combineRuleInstructions, incl. Ruleset sections); pass empty / null when nothing matched. */ matchedRuleInstructions?: string | null; - /** 输出 / 记忆写入语言(解析后的 locale code;空 = 默认 en-US,见「语言行为指令」)。 */ + /** Output / memory-write language (resolved locale code; empty = default en-US, see "language behavior instruction"). */ language?: string; session?: AssembleSessionSnapshot; } @@ -104,10 +104,10 @@ function renderLanguage(language: string | undefined): string { } /** - * 现读现装配:按「上下文注入」固定次序拼接系统上下文,并按缓存友好分两段: - * - 全局稳定前缀(跨 PR/运行一致,置最前供 1h 缓存):SOUL → AGENTS → 工具目录 → MEMORY → USER。 - * - PR/运行相关尾部(每次不同,置最后):命中规则 → PR 元数据 → 会话快照 → 语言行为指令。 - * 两段间插 CACHE_BREAK;任一段为空则不插标记。空段跳过。 + * Assemble on the fly: concatenate the system context in the fixed "context injection" order, split into two cache-friendly segments: + * - Globally stable prefix (consistent across PRs/runs, placed first for 1h caching): SOUL → AGENTS → tool catalog → MEMORY → USER. + * - PR/run-related tail (differs each time, placed last): matched rules → PR metadata → session snapshot → language behavior instruction. + * Insert CACHE_BREAK between the two segments; if either segment is empty, do not insert the marker. Skip empty segments. */ export function assembleSystemContext(input: AssembleInput): string { const { context, pr, toolCatalog, matchedRuleInstructions, language, session } = input; diff --git a/packages/agent/src/steps/context.ts b/packages/agent/src/steps/context.ts index 0fcb8af9..3e6203bc 100644 --- a/packages/agent/src/steps/context.ts +++ b/packages/agent/src/steps/context.ts @@ -1,12 +1,12 @@ import type { AgentStep, TokenUsage } from '@meebox/shared'; /** - * Agent 步骤抽象的共享基座(见 docs/arch/02-agent/01-agent.md):把此前 planner(ReAct 循环)与 orchestrator - * (评审微流程)各自重复的「记一步 + 累计用量」收口为可复用的 StepRecorder,并给出统一的 StepHandler - * 形状——每条流程即一组按序执行的步骤(评审)或单步循环(规划),新流程 = 新的步骤组合,符合复用/扩展路线。 + * Shared foundation for the agent step abstraction (see docs/arch/02-agent/01-agent.md): converges the "record one step + accumulate usage" that + * planner (ReAct loop) and orchestrator (review microflow) each previously duplicated into a reusable StepRecorder, and provides a unified StepHandler + * shape — each flow is a set of steps executed in order (review) or a single-step loop (planning); a new flow = a new combination of steps, in line with the reuse/extension path. */ -/** 累加一笔 token 用量(容缺省;calls 缺省按 1 计)。各编排 / 步骤共用。 */ +/** Accumulate one token usage entry (tolerates omission; calls defaults to 1). Shared across orchestrations / steps. */ export function addUsage(acc: TokenUsage, u?: TokenUsage): TokenUsage { if (!u) return acc; return { @@ -18,8 +18,8 @@ export function addUsage(acc: TokenUsage, u?: TokenUsage): TokenUsage { } /** - * 步骤记录器:收口「记一步」与「累计用量」。`record()` 给步骤补时间戳、入列并经 onStep 流式推送; - * `track()` 累加用量;`steps` / `usage` 为累计读取。各流程驱动与步骤共享同一实例。 + * Step recorder: converges "record one step" and "accumulate usage". `record()` stamps the step with a timestamp, enqueues it, and streams it out via onStep; + * `track()` accumulates usage; `steps` / `usage` are cumulative reads. Each flow driver and its steps share the same instance. */ export interface StepRecorder { readonly steps: AgentStep[]; @@ -48,10 +48,10 @@ export function createStepRecorder(onStep?: (step: AgentStep) => void | Promise< } /** - * 可插拔步骤的抽象基类:子类实现 `run(ctx)`,对给定运行上下文 `Ctx` 执行一段编排逻辑(记步、调工具 / LLM、 - * 写回累加器),返回 `R`。评审微流程是一组 `Step<Ctx>`(R=void)子类的有序「注册表」、顺序跑;规划是单个 - * `Step<Ctx, PlanCycleOutcome>` 子类、被驱动反复跑直至收尾。各子类无实例状态(运行态全在 ctx),故以 - * 单例入注册表。`name` 便于调试 / 注册表可读。 + * Abstract base class for pluggable steps: subclasses implement `run(ctx)`, executing a piece of orchestration logic (record step, call tool / LLM, + * write back to accumulators) against a given run context `Ctx`, returning `R`. The review microflow is an ordered "registry" of `Step<Ctx>` (R=void) subclasses, run in order; planning is a single + * `Step<Ctx, PlanCycleOutcome>` subclass, driven repeatedly until finalization. Each subclass has no instance state (all runtime state is in ctx), so they enter the registry as + * singletons. `name` aids debugging / registry readability. */ export abstract class Step<Ctx, R = void> { abstract readonly name: string; diff --git a/packages/agent/src/steps/planning/plan-cycle-step.ts b/packages/agent/src/steps/planning/plan-cycle-step.ts index bd496d81..94999583 100644 --- a/packages/agent/src/steps/planning/plan-cycle-step.ts +++ b/packages/agent/src/steps/planning/plan-cycle-step.ts @@ -12,9 +12,9 @@ import { } from './shared.js'; /** - * 自由规划(ReAct)的「单步」:拼当轮 prompt → chat → 解析动作 → 红线硬校验 → 并行派发工具 / 收尾。abort 在 - * 思考前后各检一次(思考阶段也能即时停);中途输入并入 progress、计划随轮维护与重排。驱动(planner)反复跑 - * 本步直至 final / 步数上限。无实例状态(运行态全在 ctx),以单例 planCycleStep 入驱动。 + * The "single step" of free planning (ReAct): build this round's prompt → chat → parse action → hard red-line validation → parallel tool dispatch / finalization. abort is + * checked once before and once after thinking (can stop instantly even during the thinking phase); mid-run input is merged into progress, the plan is maintained and reordered each round. The driver (planner) runs + * this step repeatedly until final / step cap. No instance state (all runtime state is in ctx); enters the driver as the singleton planCycleStep. */ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { readonly name = 'plan-cycle'; @@ -23,8 +23,8 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { const { deps, input, rec, system, convo, labels, history, memories } = ctx; if (deps.signal?.aborted) return { kind: 'aborted' }; - // 中途输入转向:把运行期间排队的用户新消息并入 progress,让本轮 ReAct 据「最新指令 + 当前进度」重排 - // 下一步。消息已由实现方在取出时持久化进会话,此处只注入提示、不再落盘。 + // Mid-run input redirection: merge new user messages queued during the run into progress so this round's ReAct reorders the + // next step per "latest instruction + current progress". Messages were already persisted into the session by the implementer when drained; here we only inject the prompt, not persist. const pending = (await deps.drainPendingInput?.()) ?? []; for (const m of pending) { history.push(`New user message (latest instruction — reconcile with the plan and progress): ${m}`); @@ -47,17 +47,17 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { .filter(Boolean) .join('\n'); - // 计本轮 LLM 推理耗时(单步思考时长,类 Claude Code 的「Thought for Ns」),系到该决策步上。 + // Measure this round's LLM reasoning time (single-step thinking duration, like Claude Code's "Thought for Ns"), attached to this decision step. const thinkStart = Date.now(); const r = await deps.chat({ system, user }); const thinkMs = Date.now() - thinkStart; - // 思考刚结束就发现已被停止 → 立即收尾,不再据此动作分发工具(停止在思考阶段也即时生效)。 + // If found stopped right after thinking → finalize immediately, not dispatching tools per this action (stop takes effect instantly even during the thinking phase). if (deps.signal?.aborted) return { kind: 'aborted' }; rec.track(r.usage); const action = extractJson<PlannerAction>(r.text); - // 累加本动作携带的记忆(任何动作都可附 remember)。 + // Accumulate the memory carried by this action (any action may attach remember). accumulateRemember(action?.remember, memories); - // 计划更新:模型给出 plan 即归一、更新当前计划并持久化 + 广播(省略 plan = 沿用上一轮)。 + // Plan update: when the model gives a plan, normalize it, update the current plan, and persist + broadcast (omitted plan = keep the previous round's). if (action?.plan !== undefined) { ctx.plan = normalizePlan(action.plan); await deps.recordPlan?.(ctx.plan); @@ -65,7 +65,7 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { const hasCalls = Boolean(action?.tool) || Boolean(action?.tools?.length); - // 无法解析 / 既无 tool(s) 又无 final → 当作收尾。兜底从原始文本打捞散文,绝不把原始 JSON 动作丢给用户。 + // Unparseable / neither tool(s) nor final → treat as finalization. Fall back to salvaging prose from the raw text, never dumping the raw JSON action to the user. if (!action || (!hasCalls && !action.final)) { const finalText = action?.final ?? salvageProse(r.text); await rec.record({ kind: 'plan', thought: action?.thought, result: finalText, thinkMs, usage: r.usage }); @@ -73,22 +73,22 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { } if (action.final && !hasCalls) { - // 剥掉模型误并入 final 末尾的判定 JSON(recommendation 走独立字段渲染为判定徽标)。 + // Strip the judge JSON the model mistakenly merged into the end of final (recommendation goes through a separate field and renders as a judge badge). const finalText = stripTrailingJson(action.final); await rec.record({ kind: 'plan', thought: action.thought, result: finalText, thinkMs, usage: r.usage }); return { kind: 'final', finalText, recommendation: parseRecommendation(action.recommendation) }; } - // 归一为待执行工具列表:tools 多选(并行、只读)优先——元素可为工具名或 {tool, question}(多个 - // 带问题的 /ask 也能一轮并行派发);否则单 tool(可带 question)。 + // Normalize into the tool list to execute: tools multi-select (parallel, read-only) takes priority — elements can be a tool name or {tool, question} (multiple + // /ask with questions can also be dispatched in parallel in one round); otherwise a single tool (may carry a question). const requested: Array<{ tool: string; question?: string }> = action.tools?.length ? action.tools .slice(0, MAX_PARALLEL_TOOLS) .map((tl) => (typeof tl === 'string' ? { tool: tl } : { tool: tl.tool ?? '', question: tl.question })) : [{ tool: action.tool ?? '', question: action.question }]; - // 红线硬校验逐个把关:未授权 / 未知即拒并回喂;/ask 另按配置的追问上限封顶(连续 agentic 探索成本高); - // 允许的留待并行执行。 + // Hard red-line validation gates each one: unauthorized / unknown is rejected and fed back; /ask is additionally capped by the configured follow-up limit (consecutive agentic exploration is costly); + // allowed ones are held for parallel execution. const isAsk = (tool: string): boolean => tool.replace(/^\//, '') === 'ask'; const allowed: Array<{ tool: string; question?: string }> = []; let asksAccepted = 0; @@ -108,7 +108,7 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { await reject(err instanceof Error ? err.message : String(err)); continue; } - // /ask 预算:达上限即拒并回喂,促使模型据现有上下文收尾或改用只读工具(本会话累计计数,跨轮生效)。 + // /ask budget: at the limit, reject and feed back, prompting the model to finalize with existing context or switch to read-only tools (cumulative count per session, effective across rounds). if (isAsk(c.tool) && ctx.asksUsed + asksAccepted >= ctx.maxAsks) { await reject( `Follow-up /ask budget exhausted (${ctx.maxAsks}). Do not call /ask again — answer with the context you already have, or use read-only file tools.`, @@ -118,10 +118,10 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { allowed.push(c); if (isAsk(c.tool)) asksAccepted++; } - if (!allowed.length) return { kind: 'continue' }; // 全被拒 → 回喂后下一轮重选 + if (!allowed.length) return { kind: 'continue' }; // all rejected → reselect next round after feedback - // 类 Claude Code:先把本轮思考与所选步骤作为一步流式出去(思考是工具选择的前因),随后才执行工具。 - // 工具执行的进度 / 计时由 run 卡片承载,这里不再为每个工具补记 tool 步,避免决策被堆到结果之后。 + // Like Claude Code: first stream out this round's thinking and the selected steps as one step (thinking is the antecedent of tool selection), only then execute tools. + // Tool execution progress / timing is carried by the run card; we no longer record an extra tool step for each tool here, avoiding decisions being piled after results. await rec.record({ kind: 'plan', thought: action.thought, @@ -130,17 +130,17 @@ export class PlanCycleStep extends Step<PlanStepCtx, PlanCycleOutcome> { usage: r.usage, }); - // 并行分发允许的工具(多选时同时跑,实际并发受运行队列约束);相互错开 100~200ms 起跑,避免同一瞬间齐发。 + // Dispatch the allowed tools in parallel (multi-select runs simultaneously, actual concurrency constrained by the run queue); stagger start by 100~200ms to avoid firing all at the same instant. const ran = await runStaggered(allowed, async (c) => ({ c, res: await deps.runTool(c) })); for (const { c, res } of ran) { rec.track(res.usage); history.push(`Called ${c.tool}${c.question ? ` ("${c.question}")` : ''} → ${clamp(res.text, 600)}`); } - // 累加本轮已发起的 /ask 计数(跨轮生效,达 maxAsks 后新的 /ask 会被上面的预算闸拒绝)。 + // Accumulate the /ask count initiated this round (effective across rounds; after reaching maxAsks, new /ask are rejected by the budget gate above). ctx.asksUsed += asksAccepted; return { kind: 'continue' }; } } -/** 规划单步循环的单例(无实例状态);驱动反复 `planCycleStep.run(ctx)`。 */ +/** Singleton for the planning single-step loop (no instance state); the driver runs `planCycleStep.run(ctx)` repeatedly. */ export const planCycleStep = new PlanCycleStep(); diff --git a/packages/agent/src/steps/planning/shared.ts b/packages/agent/src/steps/planning/shared.ts index 8bd09ce4..b3f16858 100644 --- a/packages/agent/src/steps/planning/shared.ts +++ b/packages/agent/src/steps/planning/shared.ts @@ -13,35 +13,38 @@ import { clamp, fillTemplate } from '../../utils/index.js'; import type { StepRecorder } from '../context.js'; /** - * 自由规划 plan-cycle 步骤的共享件:动作类型 + 解析(记忆 / 建议 / 计划)、会话上下文与协议拼装、运行上下文。 - * 步骤本体见 ./plan-cycle-step,驱动见 ../../planner。 + * Shared pieces for the free-form planning plan-cycle step: action types + parsing (memory / suggestion / plan), + * conversation context and protocol assembly, run context. + * See ./plan-cycle-step for the step itself, ../../planner for the driver. */ export interface PlannerAction { thought?: string; tool?: string; /** - * 一次并行多选只读工具(如 describe + review,或多个 /ask);与 tool 二选一,tools 优先。 - * 元素可为工具名字符串,或 `{tool, question}` 对象——后者让一轮里并行派发多个带问题的 /ask。 + * Select multiple read-only tools in parallel at once (e.g. describe + review, or several /ask); mutually + * exclusive with tool, tools takes precedence. + * Elements may be a tool-name string, or a `{tool, question}` object — the latter dispatches several /ask + * with questions in parallel within one round. */ tools?: Array<string | { tool?: string; question?: string }>; question?: string; final?: string; /** - * 计划(todo):模型可在任意动作里给出 / 更新一份简短步骤清单(标 done、按优先级重排、增删)。 - * 元素可为字符串或 `{id?, text, done?}`。省略 = 计划不变(沿用上一轮)。见 buildProtocol 的 plan 约定。 + * Plan (todo): the model may give / update a short step list in any action (mark done, reorder by priority, add/remove). + * Elements may be a string or `{id?, text, done?}`. Omitted = plan unchanged (carry over the previous round). See the plan convention in buildProtocol. */ plan?: Array<string | { id?: unknown; text?: unknown; done?: unknown }>; - /** 评审类收尾的非约束性判定建议(verdict + 理由);非评审请求省略。 */ + /** Non-binding judge suggestion for review-type summaries (verdict + reason); omitted for non-review requests. */ recommendation?: { verdict?: unknown; reason?: unknown }; /** - * 主动记下的**非隐私**条目,按目标可写文件分组:user→USER.md(用户信息),memory→MEMORY.md - * (长期知识),agents→AGENTS.md(工作规范,仅追加)。SOUL.md 永不写。 + * Proactively recorded **non-private** entries, grouped by the target writable file: user→USER.md (user info), + * memory→MEMORY.md (long-term knowledge), agents→AGENTS.md (working conventions, append-only). SOUL.md is never written. */ remember?: { user?: unknown; memory?: unknown; agents?: unknown }; } -/** 解析单条记忆:必须是带 `section` + `note` 的对象;无法归入专题章节的条目丢弃(返回 null)。 */ +/** Parse a single memory: must be an object with `section` + `note`; entries that can't be filed under a topic section are dropped (return null). */ function toNote(raw: unknown): MemoryNote | null { if (!raw || typeof raw !== 'object') return null; const obj = raw as { section?: unknown; note?: unknown }; @@ -51,13 +54,13 @@ function toNote(raw: unknown): MemoryNote | null { return { section, note }; } -/** 把目标文件的 remember 数组解析为 MemoryNote[](容错;丢弃无法归类的条目)。 */ +/** Parse a target file's remember array into MemoryNote[] (fault-tolerant; drop entries that can't be classified). */ function toNoteList(raw: unknown): MemoryNote[] { const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw]; return items.map(toNote).filter((n): n is MemoryNote => n !== null); } -/** 把一个动作里的 remember 累加进 acc(容错;非对象忽略)。 */ +/** Accumulate one action's remember into acc (fault-tolerant; ignore non-objects). */ export function accumulateRemember(value: PlannerAction['remember'], acc: AgentMemoryNotes): void { if (!value || typeof value !== 'object') return; acc.user.push(...toNoteList(value.user)); @@ -65,7 +68,7 @@ export function accumulateRemember(value: PlannerAction['remember'], acc: AgentM acc.agents.push(...toNoteList(value.agents)); } -/** 从收尾动作解析出合法 recommendation;verdict 非法 / 缺省 → undefined(不强加判定)。 */ +/** Parse a valid recommendation from the summary action; invalid / missing verdict → undefined (don't force a judge). */ export function parseRecommendation( rec?: PlannerAction['recommendation'], ): AgentRecommendation | undefined { @@ -80,7 +83,7 @@ export function parseRecommendation( }; } -/** 把模型给出的 plan 归一为 AgentTodoItem[]:容字符串 / 对象,丢空文本;缺 id 按序补。 */ +/** Normalize the model's plan into AgentTodoItem[]: accept string / object, drop empty text; fill missing id in order. */ export function normalizePlan(raw: PlannerAction['plan']): AgentTodoItem[] { if (!Array.isArray(raw)) return []; const out: AgentTodoItem[] = []; @@ -102,52 +105,52 @@ export function normalizePlan(raw: PlannerAction['plan']): AgentTodoItem[] { return out; } -/** 取最近若干轮、各自限长,并按总预算从新到旧裁剪(丢弃超预算的更早消息),返回时间升序文本。 */ +/** Take the most recent rounds, each length-capped, and trim newest-to-oldest by total budget (drop earlier over-budget messages), returning text in ascending time order. */ export function buildConversationContext(history: readonly AgentMessage[]): string { const lines: string[] = []; let budget = HISTORY_BUDGET_CHARS; for (let i = history.length - 1; i >= 0; i--) { const m = history[i]!; const line = `${m.role === 'user' ? 'User' : 'Assistant'}: ${clamp(m.content, HISTORY_MESSAGE_MAX)}`; - if (line.length + 1 > budget) break; // 预算耗尽:更早的对话整体裁掉 + if (line.length + 1 > budget) break; // budget exhausted: trim the earlier conversation entirely budget -= line.length + 1; lines.push(line); } return lines.reverse().join('\n'); } -/** 规划 ReAct 协议:正文外置在 resources/prompts/protocol.md,三段标题(按语言本地化)经占位符注入。 */ +/** Planning ReAct protocol: body is externalized in resources/prompts/protocol.md, the three section titles (localized per language) are injected via placeholders. */ export function buildProtocol(sections: readonly [string, string, string]): string { const [overview, findings, suggestions] = sections; return fillTemplate(PROMPT_TEMPLATES.protocol, { overview, findings, suggestions }); } -/** 规划单轮(plan-cycle 步骤)的运行上下文:依赖 + 输入 + 共享记录器 + 跨轮累加器。 */ +/** Run context for a single planning round (plan-cycle step): deps + input + shared recorder + cross-round accumulators. */ export interface PlanStepCtx { deps: PlanningDeps; input: PlanningInput; rec: StepRecorder; - /** 完整 system(含 Protocol),逐轮复用。 */ + /** Full system (incl. Protocol), reused across rounds. */ system: string; - /** 既往多轮对话(按预算裁剪后的文本),逐轮复用。 */ + /** Prior multi-round conversation (text after budget trimming), reused across rounds. */ convo: string; labels: AgentStepLabels; - /** 本轮 progress 累加(工具结果 / 红线拒绝回喂),逐轮追加。 */ + /** This round's progress accumulation (tool results / red-line rejection feedback), appended each round. */ history: string[]; - /** 本轮主动记下、待持久化的非隐私条目,逐轮累加。 */ + /** Non-private entries proactively recorded this round, pending persistence, accumulated across rounds. */ memories: AgentMemoryNotes; - /** 当前计划(todo),逐轮回喂提示;模型给出 plan 即更新(重排 / 勾选 / 增删)。 */ + /** Current plan (todo), fed back as a prompt each round; updated whenever the model gives a plan (reorder / check / add/remove). */ plan: AgentTodoItem[]; /** - * 本会话 /ask 数量上限(遵循配置的「追问数量」max_followup_asks):自由规划里连续 /ask(各为一次 - * agentic 探索)成本高,按此封顶——与「自动追问」开关无关(开关仅约束评审微流程)。 + * Upper bound on /ask count this session (follows the configured "follow-up ask count" max_followup_asks): consecutive /ask in + * free-form planning (each an agentic exploration) is expensive, so it's capped here — unrelated to the "auto follow-up ask" toggle (which only constrains the review microflow). */ maxAsks: number; - /** 本会话已发起的 /ask 计数,逐轮累加;达 maxAsks 后拒绝新的 /ask 并回喂促收尾。 */ + /** Count of /ask already issued this session, accumulated across rounds; after reaching maxAsks, reject new /ask and feed back to push toward a summary. */ asksUsed: number; } -/** plan-cycle 的产出:继续下一轮 / 收尾(带 final + 可选建议)/ 用户暂停。 */ +/** plan-cycle output: continue to the next round / summary (with final + optional suggestion) / user pause. */ export type PlanCycleOutcome = | { kind: 'continue' } | { kind: 'final'; finalText: string; recommendation?: AgentRecommendation } diff --git a/packages/agent/src/steps/review/asks-step.ts b/packages/agent/src/steps/review/asks-step.ts index c84b91f9..92ddead9 100644 --- a/packages/agent/src/steps/review/asks-step.ts +++ b/packages/agent/src/steps/review/asks-step.ts @@ -3,12 +3,12 @@ import { Step } from '../context.js'; import type { ReviewStepCtx } from './shared.js'; /** - * 多个追问同属一个阶段、彼此独立,故并行派发(runStaggered 保序、错开起跑;asks 为空不触发)。 + * Multiple follow-up asks belong to the same phase and are independent, so they're dispatched in parallel (runStaggered preserves order, staggers start; not triggered when asks is empty). * - * PR3 复评关联:judge 点名了 review finding(targetFindingId)的追问,以**复评模式**派发——携带被复评 - * finding 的正文(referencedContext)+ 结构化引用(referencedFinding),run 卡片即出「复评自」徽标 + 裁决; - * 裁决 replace/drop 时自动关闭被取代的原 review finding(closeFinding,建立 FindingClosure)。keep / 未点名 - * 不动。新评论不自动落草稿——留待用户在复评卡手动「采纳」(与手动引用路径一致)。 + * PR3 re-evaluation link: a follow-up ask that judge named a review finding for (targetFindingId) is dispatched in **re-evaluation mode** — carrying the re-evaluated + * finding's body (referencedContext) + structured reference (referencedFinding), so the run card shows a "re-evaluated from" badge + verdict; + * on verdict replace/drop it auto-closes the superseded original review finding (closeFinding, establishing a FindingClosure). keep / unnamed + * are left untouched. New comments don't auto-drop into drafts — left for the user to manually "adopt" on the re-evaluation card (consistent with the manual reference path). */ export class AsksStep extends Step<ReviewStepCtx> { readonly name = 'asks'; @@ -20,7 +20,7 @@ export class AsksStep extends Step<ReviewStepCtx> { const findings = ctx.bag.review?.findings ?? []; const results = asks.length ? await runStaggered(asks, (a) => { - // 命中点名 + 该 finding 存在且可锚定 → 复评模式(携引用上下文 + 前向链)。 + // named hit + the finding exists and is anchorable → re-evaluation mode (carries referenced context + forward link). const target = a.targetFindingId && reviewRunId ? findings.find( @@ -42,7 +42,7 @@ export class AsksStep extends Step<ReviewStepCtx> { return ctx.deps.runTool({ tool: 'ask', question: a.question }); }) : []; - // 复评裁决 replace/drop → 自动关闭被取代的原 review finding(建立 FindingClosure)。 + // re-evaluation verdict replace/drop → auto-close the superseded original review finding (establishing a FindingClosure). for (let i = 0; i < results.length; i += 1) { const ask = results[i]!; const targetId = asks[i]!.targetFindingId; @@ -61,8 +61,8 @@ export class AsksStep extends Step<ReviewStepCtx> { }); } } - // 喂给总结的只取追问的「结论」(ask-summary),不灌全文——/ask 现产出富文本分析 / 表格 / 代码块 / - // 逐条代码建议,整段灌进去会撑爆总结、诱导模型照搬单条追问的细节,背离「总结=PR 整体结论」的初衷。 + // Feed the summary only the follow-up ask's "conclusion" (ask-summary), not the full text — /ask now produces rich-text analysis / tables / code blocks / + // per-item code suggestions; dumping the whole thing in would bloat the summary and lure the model into copying a single ask's details, betraying the intent of "summary = the PR's overall conclusion". ctx.bag.askResults = results.map((ask, i) => { const conclusion = ask.findings?.find((f) => f.sectionKey === 'ask-summary')?.body?.trim() || ask.text.trim(); diff --git a/packages/agent/src/steps/review/describe-review-step.ts b/packages/agent/src/steps/review/describe-review-step.ts index 529956bc..1dcebf66 100644 --- a/packages/agent/src/steps/review/describe-review-step.ts +++ b/packages/agent/src/steps/review/describe-review-step.ts @@ -3,17 +3,17 @@ import { Step } from '../context.js'; import type { ReviewStepCtx } from './shared.js'; /** - * 并行生成 PR 描述(只读 /describe)与代码评审发现(只读 /review):二者彼此独立、都只读 PR、无先后依赖, - * 故并发分发(runStaggered 错开起跑避免抢占子进程 spawn / LLM 网络,保序、不改并发语义;实际并发仍受运行 - * 队列 max_concurrency 约束)。展示上合并为**一条**思考行(回到拆步前的单行,不再 describe / review 各占一行); - * 工具执行进度 / 计时由各自 run 卡片承载,不为工具补记 tool 步。 + * Generate the PR description (read-only /describe) and code review findings (read-only /review) in parallel: the two are independent, both read the PR read-only, with no ordering dependency, + * so they're dispatched concurrently (runStaggered staggers start to avoid contending on child-process spawn / LLM network, preserves order, doesn't change concurrency semantics; actual concurrency is still bounded by the run + * queue's max_concurrency). In the display they merge into **one** thought line (back to the single line before the step split, no longer one line each for describe / review); + * tool execution progress / timing is carried by each run card, no extra tool step is recorded for the tools. */ export class DescribeReviewStep extends Step<ReviewStepCtx> { readonly name = 'describe-review'; async run(ctx: ReviewStepCtx): Promise<void> { ctx.checkAbort(); - // 一条合并思考行(describe + review);两个只读工具随后并行执行,结果各自回填 bag 供 judge / summary 用。 + // One merged thought line (describe + review); the two read-only tools then run in parallel, each writing its result back into bag for judge / summary. await ctx.rec.record({ kind: 'plan', thought: ctx.labels.describeReview }); const [describe, review] = await runStaggered( [{ tool: 'describe' as const }, { tool: 'review' as const }], diff --git a/packages/agent/src/steps/review/improve-step.ts b/packages/agent/src/steps/review/improve-step.ts index 1b8d4f1f..37e5b4d6 100644 --- a/packages/agent/src/steps/review/improve-step.ts +++ b/packages/agent/src/steps/review/improve-step.ts @@ -2,9 +2,9 @@ import { Step } from '../context.js'; import type { ReviewStepCtx } from './shared.js'; /** - * 生成代码改进建议(只读 /improve)。先把本步思考流式出去(思考在前),再派发工具;建议以 - * code-suggestion findings 经各自 run 卡片呈现(parseReviewOutput 对 tool='improve' 走专门解析), - * 故无需回填 bag。**默认计划不含本步**——仅当规则给出的计划纳入 `improve` 时才执行。 + * Generate code improvement suggestions (read-only /improve). First stream out this step's thought (thought first), then dispatch the tool; suggestions are + * presented as code-suggestion findings via each run card (parseReviewOutput uses dedicated parsing for tool='improve'), + * so there's no need to write back into bag. **The default plan doesn't include this step** — it runs only when a rule-provided plan includes `improve`. */ export class ImproveStep extends Step<ReviewStepCtx> { readonly name = 'improve'; diff --git a/packages/agent/src/steps/review/index.ts b/packages/agent/src/steps/review/index.ts index a4d54da1..971b3cef 100644 --- a/packages/agent/src/steps/review/index.ts +++ b/packages/agent/src/steps/review/index.ts @@ -9,21 +9,21 @@ import { SummaryStep } from './summary-step.js'; export type { ReviewBag, ReviewStepCtx } from './shared.js'; /** - * 评审微流程可用步骤的稳定标识(与各 step.name 对应)。一份评审计划即由这些 kind 有序组成。 - * 新增工具步 = 在 REVIEW_STEP_REGISTRY 登记 + 在此并上 kind,驱动与默认计划无需改动。 + * Stable identifiers for the review microflow's available steps (corresponding to each step.name). A review plan is composed of these kinds in order. + * Adding a tool step = register it in REVIEW_STEP_REGISTRY + add its kind here; the driver and default plan need no changes. */ export type ReviewStepKind = 'describe-review' | 'improve' | 'judge' | 'asks' | 'summary'; /** - * 评审微流程执行计划:一组**有序**步骤 kind。AutoPilot 后续可据用户 agent 上下文规则给出自定义计划 - * (跳过 / 重排 / 增删步骤);手动评审恒用 DEFAULT_REVIEW_PLAN。计划来源(规则 → 计划)是后续工作, - * 本层只提供「按计划组装并执行」的基础能力。 + * Review microflow execution plan: a set of **ordered** step kinds. AutoPilot may later give a custom plan + * (skip / reorder / add/remove steps) based on user agent context rules; manual review always uses DEFAULT_REVIEW_PLAN. The plan source (rules → plan) is future work, + * this layer only provides the base capability of "assemble and execute by plan". */ export interface ReviewPlan { steps: ReviewStepKind[]; } -/** 步骤注册表(kind → 无状态单例)。各 step 运行态全在 ctx,故单例可复用。 */ +/** Step registry (kind → stateless singleton). Each step's runtime state lives entirely in ctx, so singletons are reusable. */ export const REVIEW_STEP_REGISTRY: Record<ReviewStepKind, Step<ReviewStepCtx>> = { 'describe-review': new DescribeReviewStep(), improve: new ImproveStep(), @@ -32,15 +32,15 @@ export const REVIEW_STEP_REGISTRY: Record<ReviewStepKind, Step<ReviewStepCtx>> = summary: new SummaryStep(), }; -/** 默认计划:与拆 plan 前的固定序列完全一致(describe-review → judge → asks → summary)。 */ +/** Default plan: exactly the same fixed sequence as before the plan split (describe-review → judge → asks → summary). */ export const DEFAULT_REVIEW_PLAN: ReviewPlan = { steps: ['describe-review', 'judge', 'asks', 'summary'], }; /** - * 计划合法性:① 非空;② 各 kind 须在注册表内;③ judge / summary 读 describe·review 的产物(bag.describe / - * bag.review),故计划含二者时必须先含 describe-review。非法计划由驱动回落 DEFAULT_REVIEW_PLAN - * (见 runReviewMicroflow),避免规则给出坏计划时崩在步骤里。 + * Plan validity: (1) non-empty; (2) each kind must be in the registry; (3) judge / summary read describe·review's outputs (bag.describe / + * bag.review), so a plan containing either must include describe-review first. Invalid plans fall back to DEFAULT_REVIEW_PLAN by the driver + * (see runReviewMicroflow), avoiding a crash inside a step when a rule gives a bad plan. */ export function isValidReviewPlan(plan: ReviewPlan): boolean { if (plan.steps.length === 0) return false; @@ -50,7 +50,7 @@ export function isValidReviewPlan(plan: ReviewPlan): boolean { return true; } -/** 把计划组装成有序步骤实例(驱动据此顺序执行)。调用方须先经 isValidReviewPlan 校验。 */ +/** Assemble the plan into ordered step instances (the driver executes in this order). Callers must validate via isValidReviewPlan first. */ export function assembleReviewSteps(plan: ReviewPlan): Step<ReviewStepCtx>[] { return plan.steps.map((k) => REVIEW_STEP_REGISTRY[k]); } diff --git a/packages/agent/src/steps/review/judge-step.ts b/packages/agent/src/steps/review/judge-step.ts index 21c2756f..bb60339b 100644 --- a/packages/agent/src/steps/review/judge-step.ts +++ b/packages/agent/src/steps/review/judge-step.ts @@ -3,7 +3,7 @@ import { extractJson } from '../../utils/index.js'; import { Step } from '../context.js'; import { judgePrompt, type ReviewStepCtx } from './shared.js'; -/** 仅严重问题条件性追问的判读(精简 system 轻量路由 + 输出封顶)。 */ +/** Judge that conditionally follows up only on severe issues (lean system, lightweight routing + output cap). */ export class JudgeStep extends Step<ReviewStepCtx> { readonly name = 'judge'; @@ -19,12 +19,12 @@ export class JudgeStep extends Step<ReviewStepCtx> { ctx.maxAsks, ctx.input.language ?? '', ), - // 判读产物只是极小 JSON(severe + 至多数条追问,可带 targetFindingId),封顶输出避免狂吐 token。 + // The judge output is tiny JSON (severe + at most a few follow-up asks, optionally with targetFindingId); cap output to avoid runaway token spend. maxOutputTokens: JUDGE_MAX_OUTPUT_TOKENS, }); const judgeMs = Date.now() - judgeStart; ctx.rec.track(judge.usage); - // 新结构:asks:[{question, targetFindingId?}];向后兼容旧 questions:string[](映射为无 target)。 + // New shape: asks:[{question, targetFindingId?}]; backward-compatible with legacy questions:string[] (mapped to no target). const verdict = extractJson<{ severe?: boolean; asks?: Array<{ question?: string; targetFindingId?: string }>; diff --git a/packages/agent/src/steps/review/shared.ts b/packages/agent/src/steps/review/shared.ts index b8022511..d3d0fdea 100644 --- a/packages/agent/src/steps/review/shared.ts +++ b/packages/agent/src/steps/review/shared.ts @@ -11,33 +11,34 @@ import { fillTemplate } from '../../utils/index.js'; import type { StepRecorder } from '../context.js'; /** - * 评审微流程各 step 的共享件:跨步骤上下文 / 累加器 + 判读 / 总结的提示词与判定解析。各 *-step.ts 引用此处, - * 注册表见 ./index。 + * Shared pieces for the review micro-flow steps: cross-step context / accumulator + judge / summary prompts and verdict parsing. Each *-step.ts references this; + * for the registry see ./index. */ -/** verdict 合法性判定(用于收尾解析;非法 / 缺省回落 manual_review)。 */ +/** Verdict validity check (used in summary parsing; invalid / missing falls back to manual_review). */ export function isVerdict(v: unknown): v is AgentRecommendationVerdict { return typeof v === 'string' && (VERDICTS as readonly string[]).includes(v); } /** - * 给 judge / summary 瘦身的 describe 正文:剥掉低信号的「File Walkthrough」(逐文件分类 / 描述大表)与 - * mermaid 图块,只留类型 / 总结 / 描述 / 评估等高价值文本——这两块对「是否有严重问题需追问」的判读、以及 - * 「PR 整体结论」的总结都无实质帮助,却占大量 token。仅用于喂 judge / summary,不影响 describe 卡片展示。 + * Slimmed-down describe body for judge / summary: strips the low-signal "File Walkthrough" (the big per-file + * classification / description table) and mermaid diagram blocks, keeping only high-value text like type / summary / description / assessment. + * These two blocks add nothing substantive to judging "are there severe issues needing follow-up" or to summarizing + * "the overall PR conclusion", yet consume a lot of tokens. Only used to feed judge / summary; does not affect the describe card display. */ export function compactDescribe(text: string): string { let out = text; - // File Walkthrough:pr-agent 追加在末尾的 <details><summary><h3>File Walkthrough...,含嵌套 details,取到结尾剥掉。 + // File Walkthrough: the <details><summary><h3>File Walkthrough... that pr-agent appends at the end, with nested details; strip from here to the end. const wt = /<details[^>]*>\s*<summary>\s*<h3>\s*File Walkthrough\s*<\/h3>\s*<\/summary>/i.exec(out); if (wt) out = out.slice(0, wt.index).trimEnd(); - // mermaid 图(Diagram Walkthrough 的架构图):判读 / 总结用不到,剥掉代码块本身。 + // mermaid diagrams (the architecture diagram in Diagram Walkthrough): unused by judge / summary, so strip the code block itself. out = out.replace(/```mermaid[\s\S]*?```/gi, '').replace(/\n{3,}/g, '\n\n'); return out.trim(); } /** - * 把 review 的代码类 findings 渲染成「可按 id 点名」的清单(供 judge 决定针对哪条出复评追问)。 - * 仅取 code-feedback / code-suggestion(可锚定、可被复评取代的代码评论),正文压一行截断控篇幅。 + * Renders the review's code findings into an "id-addressable" list (so the judge can decide which one to issue a re-review follow-up on). + * Only takes code-feedback / code-suggestion (anchorable code comments that a re-review can supersede); compress the body to one line and truncate to control length. */ function renderFindingsForJudge(findings: Finding[]): string { const code = findings.filter( @@ -54,9 +55,9 @@ function renderFindingsForJudge(findings: Finding[]): string { .join('\n'); } -/** 追问判读 user 指令外置在 resources/prompts/judge.md(占位 maxAsks/language);describe/review 正文在此追加。 - * 语言显式要求随会话语言出题(精简 system 不带 assembleSystemContext 的语言指令,否则默认英文)。 - * findings:review 解析出的结构化发现,渲染成 id 可寻址清单,供 judge 对某条出复评追问(targetFindingId)。 */ +/** The follow-up judge user instruction lives externally in resources/prompts/judge.md (placeholders maxAsks/language); describe/review bodies are appended here. + * The language is explicitly required to phrase questions in the session language (the lean system carries no assembleSystemContext language instruction, otherwise it defaults to English). + * findings: the structured findings parsed from review, rendered into an id-addressable list so the judge can issue a re-review follow-up on a given one (targetFindingId). */ export function judgePrompt( describeText: string, reviewText: string, @@ -64,7 +65,7 @@ export function judgePrompt( maxAsks: number, language: string, ): string { - // 与 renderLanguage 同策略:空 / 未知回落 en-US。 + // Same policy as renderLanguage: empty / unknown falls back to en-US. const lang = language.trim() || 'en-US'; const head = fillTemplate(PROMPT_TEMPLATES.judge, { maxAsks: String(maxAsks), language: lang }); return [ @@ -81,8 +82,8 @@ export function judgePrompt( ].join('\n'); } -/** 收尾总结 user 指令 + 三段骨架外置在 resources/prompts/summary.md(占位 maxChars/三段标题); - * 描述 / 评审发现 / 追问 Q&A 等正文在此按需追加(条件拼接仍在 TS)。 */ +/** The summary user instruction + three-section skeleton live externally in resources/prompts/summary.md (placeholders maxChars/three section titles); + * bodies like description / review findings / follow-up Q&A are appended here as needed (conditional assembly stays in TS). */ export function summaryPrompt( describeText: string, reviewText: string, @@ -109,29 +110,29 @@ export function summaryPrompt( ].join('\n'); } -/** 跨步骤传递的中间产物。 */ +/** Intermediate products passed across steps. */ export interface ReviewBag { describe?: ToolText; - /** review 工具产物(含 runId / findings,供 judge 点名 + asks 复评关联)。 */ + /** review tool output (includes runId / findings, for the judge to name + link asks re-reviews). */ review?: ToolText; - /** judge 判出的追问(asks 步消费);targetFindingId 在 = 对该条 review finding 的复评追问。 */ + /** follow-up asks decided by the judge (consumed by the asks step); when targetFindingId is present = a re-review follow-up on that review finding. */ asks: Array<{ question: string; targetFindingId?: string }>; askResults: string[]; summary?: string; recommendation?: AgentRecommendation; } -/** 评审步骤的运行上下文:依赖 + 输入 + 共享记录器 + 跨步骤累加器(bag)。 */ +/** Run context for the review steps: deps + input + shared recorder + cross-step accumulator (bag). */ export interface ReviewStepCtx { deps: ReviewOrchestratorDeps; input: ReviewOrchestratorInput; rec: StepRecorder; - /** 用户停止:每步边界检查,已 abort 即抛 `用户暂停`(思考阶段也能立即中止)。 */ + /** User stop: boundary check at each step; if already aborted, throw the stable code `aborted` (the thinking phase can be interrupted immediately too). */ checkAbort: () => void; maxAsks: number; summaryMax: number; labels: AgentStepLabels; - /** 微流程完整 system 上下文(summary 用;judge 另用精简 JUDGE_SYSTEM)。 */ + /** Full micro-flow system context (used by summary; judge uses the lean JUDGE_SYSTEM instead). */ system: string; bag: ReviewBag; } diff --git a/packages/agent/src/steps/review/summary-step.ts b/packages/agent/src/steps/review/summary-step.ts index a1df9845..d97c80c7 100644 --- a/packages/agent/src/steps/review/summary-step.ts +++ b/packages/agent/src/steps/review/summary-step.ts @@ -6,10 +6,10 @@ import { Step } from '../context.js'; import { isVerdict, summaryPrompt, type ReviewStepCtx } from './shared.js'; /** - * 收尾总结 + 建议。模型输出「纯 markdown 正文 + 末尾一行判定 JSON」(见 summary.md):正文走 - * stripTrailingJson 剥掉末尾判定(含被截断的 dangling JSON 兜底),判定走 extractTrailingJson 单独解析。 - * 不再把整段 markdown 塞进 JSON 字符串——避免正文里的引号/换行破坏 JSON 解析、并在解析失败时被腰斩。 - * 给足输出 token 上限,避免 provider 默认上限截断正文。 + * Summary + suggestion. The model outputs "plain markdown body + a one-line verdict JSON at the end" (see summary.md): the body goes through + * stripTrailingJson to strip the trailing verdict (with a fallback for truncated dangling JSON), the verdict is parsed separately via extractTrailingJson. + * No longer stuffing the whole markdown into a JSON string — avoids quotes/newlines in the body breaking JSON parsing and getting cut off on parse failure. + * Give a generous output token cap to avoid the provider's default cap truncating the body. */ export class SummaryStep extends Step<ReviewStepCtx> { readonly name = 'summary'; @@ -30,18 +30,18 @@ export class SummaryStep extends Step<ReviewStepCtx> { }); const sumMs = Date.now() - sumStart; ctx.rec.track(sum.usage); - // 兜底:模型若仍把整段包进 JSON 字符串(违背 prompt),stripTrailingJson 会把整个对象剥空 → 用 - // salvageProse 从 "summary"/"final" 字段捞回正文。 + // Fallback: if the model still wraps the whole thing in a JSON string (against the prompt), stripTrailingJson strips the whole object to empty → use + // salvageProse to recover the body from the "summary"/"final" field. const summary = stripTrailingJson(sum.text).trim() || salvageProse(sum.text).trim(); - // 末尾判定:新格式是扁平 {verdict,reason};兼容旧格式(整体 JSON 的嵌套 recommendation 字段)。 + // Trailing verdict: the new format is a flat {verdict,reason}; compatible with the legacy format (the nested recommendation field of a whole JSON object). const obj = extractTrailingJson<{ verdict?: unknown; reason?: unknown; recommendation?: { verdict?: unknown; reason?: unknown }; }>(sum.text); const rec = obj?.recommendation ?? obj; - // 判定解析失败 → 转人工复核、不带理由:该兜底对用户无价值,前端按空 reason 隐藏灰字(不再输出 - // 「无法解析建议,转人工复核」)。模型给出的合法 manual_review 理由仍照常展示。 + // Verdict parse failure → manual_review with no reason: this fallback has no value to the user, the frontend hides the grey text on an empty reason (no longer outputting + // "failed to parse suggestion, switching to manual review"). A valid manual_review reason from the model is still displayed as usual. const recommendation: AgentRecommendation = rec && isVerdict(rec.verdict) ? { verdict: rec.verdict, reason: typeof rec.reason === 'string' ? rec.reason : '' } diff --git a/packages/agent/src/tool-catalog.ts b/packages/agent/src/tool-catalog.ts index aa752a24..7a104e38 100644 --- a/packages/agent/src/tool-catalog.ts +++ b/packages/agent/src/tool-catalog.ts @@ -1,14 +1,14 @@ import { TOOLS, type ToolCatalogEntry } from '@meebox/shared'; /** - * 工具目录与修改红线(见 docs/arch/02-agent/01-agent.md「工具修改红线」)。读 / 分析类工具 Agent 始终可自主调用; - * 修改类(对远端有副作用)**默认禁止**,仅在 `grants` 显式授权时放行——以**禁用态**注入目录(Agent 知其 - * 存在但不可调用),并由 `assertToolAllowed` 在分发入口**运行时硬校验**:即便 LLM 越权产出修改类调用也被拒。 - * 工具清单(读 / 改 / grant)来自 @meebox/shared 的统一注册表 `TOOLS`(tool-registry)。 + * Tool catalog and the mutation red line (see docs/arch/02-agent/01-agent.md "工具修改红线"). Read / analysis tools can always be invoked by the Agent on its own; + * mutating ones (with side effects on the remote) are **disabled by default** and only allowed when explicitly granted in `grants` — injected into the catalog in a **disabled state** (the Agent knows they + * exist but cannot call them), and hard-validated at runtime by `assertToolAllowed` at the dispatch entry: even if the LLM produces an out-of-scope mutating call, it is rejected. + * The tool list (read / mutating / grant) comes from @meebox/shared's unified registry `TOOLS` (tool-registry). */ /** - * 构建工具目录:读类 enabled=true;修改类仅在 grants 含其授权项时 enabled,否则禁用态。从注册表派生。 + * Builds the tool catalog: read tools enabled=true; mutating tools enabled only when grants contains their grant item, otherwise disabled. Derived from the registry. */ export function buildToolCatalog(grants: ReadonlyArray<string> = []): ToolCatalogEntry[] { const granted = new Set(grants); @@ -23,8 +23,8 @@ export function buildToolCatalog(grants: ReadonlyArray<string> = []): ToolCatalo } /** - * 运行时硬校验(红线落地):分发某工具前调用。未知工具 / 修改类且未授权 → 抛错; - * 读类 / 已授权 → 放行。这是「提示词被绕过 ≠ 操作被执行」的最后一道闸。 + * Runtime hard validation (the red line enforced): called before dispatching a tool. Unknown tool / mutating and unauthorized → throw; + * read / authorized → allow. This is the last gate that makes "prompt bypassed ≠ action executed". */ export function assertToolAllowed( toolName: string, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 1ddbdf79..10368737 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,25 +1,25 @@ import type { Rule } from '@meebox/rules'; -/** Agent 目录里四个上下文文件的正文(缺失文件 → 空串)。 */ +/** Bodies of the four context files in the Agent directory (missing file → empty string). */ export interface AgentContextFiles { - /** SOUL.md:灵魂,Agent 只读、默认由模版规定。 */ + /** SOUL.md: the soul; read-only to the Agent, specified by the template by default. */ soul: string; - /** AGENTS.md:工作规范与红线。 */ + /** AGENTS.md: work conventions and red lines. */ agents: string; - /** MEMORY.md:长期记忆(可写)。 */ + /** MEMORY.md: long-term memory (writable). */ memory: string; - /** USER.md:用户画像(可写)。 */ + /** USER.md: user profile (writable). */ user: string; } -/** 一次执行装配所需的 Agent 上下文:文件正文 + 命中候选规则集。 */ +/** Agent context needed to assemble a single execution: file bodies + the matched candidate rule set. */ export interface AgentContext { files: AgentContextFiles; - /** <agentDir>/rules 下加载的规则,按 priority desc + path asc 预排序(见 @meebox/rules)。 */ + /** Rules loaded from <agentDir>/rules, pre-sorted by priority desc + path asc (see @meebox/rules). */ rules: Rule[]; } export interface LoadAgentContextOptions { - /** 单文件读取 / 规则解析失败的告警回调(不阻断装配)。 */ + /** Warning callback for single-file read / rule parse failures (does not block assembly). */ onWarn?: (msg: string, file?: string) => void; } diff --git a/packages/agent/src/utils/json.ts b/packages/agent/src/utils/json.ts index 810f232f..cdcd1bf9 100644 --- a/packages/agent/src/utils/json.ts +++ b/packages/agent/src/utils/json.ts @@ -1,10 +1,13 @@ /** - * LLM 文本里的 JSON 提取 / 修复 / 打捞工具(域无关):模型常把动作以 ```json``` 围栏或裸对象给出,且多行 - * 字符串值不转义换行、收尾误并入判定 JSON——这组函数容这些常见错误。供编排器 / 各 step 解析模型输出。 + * JSON extraction / repair / salvage utilities for LLM text (domain-agnostic): models often emit actions in + * ```json``` fences or as bare objects, leave newlines in multiline string values unescaped, and merge the + * summary's verdict JSON into the prose—this group tolerates these common errors. Used by the orchestrator / + * each step to parse model output. */ -/** 把 JSON 串字面量内部未转义的裸控制符(换行/回车/制表)补转义。LLM 常把多行 markdown 原样塞进 - * 字符串值而不转义换行,使 JSON.parse 失败——这一步修复该常见错误(不改字符串外的结构)。 */ +/** Escape unescaped bare control chars (newline/carriage-return/tab) inside JSON string literals. LLMs often + * drop multiline markdown into string values verbatim without escaping newlines, making JSON.parse fail—this + * step repairs that common error (without touching structure outside strings). */ function escapeRawControlInStrings(s: string): string { let out = ''; let inStr = false; @@ -28,8 +31,9 @@ function escapeRawControlInStrings(s: string): string { return out; } -/** 从 LLM 文本里抽第一个 JSON 对象(容 ```json``` 围栏 + 裸文本),失败返回 null。 - * 对每个候选先按原样解析,失败再补转义裸换行重试,兜住模型多行字符串不转义的常见情况。 */ +/** Extract the first JSON object from LLM text (tolerates ```json``` fences + bare text), returns null on failure. + * For each candidate, parse as-is first, then retry after escaping bare newlines, covering the common case of the + * model not escaping multiline strings. */ export function extractJson<T>(text: string): T | null { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); for (const c of [fence?.[1], text]) { @@ -42,7 +46,7 @@ export function extractJson<T>(text: string): T | null { try { return JSON.parse(candidate) as T; } catch { - /* 试下一个候选 / 下一种转义 */ + /* try next candidate / next escaping */ } } } @@ -50,8 +54,9 @@ export function extractJson<T>(text: string): T | null { } /** - * 以末尾 `}` 为锚、按花括号配平反找到匹配的起始 `{`,返回尾部对象的起始下标(找不到返回 -1)。 - * 字符串字面量内的花括号会干扰简单配平,但收尾判定 JSON 的 reason 一般不含裸 `{}`,够用。 + * Anchored on the trailing `}`, walk back by brace balancing to find the matching opening `{`, returning the + * start index of the trailing object (-1 if not found). Braces inside string literals disrupt naive balancing, + * but the summary verdict JSON's reason rarely contains bare `{}`, so this is good enough. */ function trailingObjectStart(s: string): number { if (!s.endsWith('}')) return -1; @@ -65,8 +70,9 @@ function trailingObjectStart(s: string): number { } /** - * 从文本末尾抽出收尾判定 JSON 对象(`{"verdict":...,"reason":...}`),失败返回 null。先按配平的完整尾部 - * 对象解析(容裸控制符),用于 summary 把判定与正文分离:判定走此函数、正文走 {@link stripTrailingJson}。 + * Extract the summary verdict JSON object (`{"verdict":...,"reason":...}`) from the end of the text, returns null + * on failure. Parses the balanced full trailing object (tolerating bare control chars); used by the summary to + * separate verdict from prose: the verdict goes through this function, the prose through {@link stripTrailingJson}. */ export function extractTrailingJson<T>(s: string): T | null { const text = s.trimEnd(); @@ -78,40 +84,45 @@ export function extractTrailingJson<T>(s: string): T | null { try { return JSON.parse(candidate) as T; } catch { - /* 试下一个候选 */ + /* try next candidate */ } } return null; } /** - * 去掉模型误并入 / 按约定追加在 summary / final 末尾的判定 JSON(```json {...}``` 围栏或裸对象,仅当含 - * recommendation/verdict 字样才删),避免原始 JSON 暴露给用户。recommendation 走独立字段渲染为判定徽标。 - * 末尾对象被截断(无配平闭合)时按 dangling `{"verdict"|"recommendation"` 起点兜底剥除,避免半截 JSON 残留正文。 + * Strip the verdict JSON that the model mistakenly merges into / appends by convention at the end of the summary / + * final (```json {...}``` fence or bare object, removed only when it contains recommendation/verdict), to keep raw + * JSON from leaking to the user. recommendation is rendered as a verdict badge via its own field. When the trailing + * object is truncated (no balanced close), fall back to stripping from the dangling `{"verdict"|"recommendation"` + * start, avoiding half-cut JSON left in the prose. */ export function stripTrailingJson(s: string): string { let out = s.trimEnd(); - // 末尾围栏代码块(```json {...}```) + // trailing fenced code block (```json {...}```) out = out .replace(/\s*```(?:json)?\s*\{[\s\S]*?\}\s*```\s*$/i, (m) => /"(?:recommendation|verdict)"\s*:/.test(m) ? '' : m, ) .trimEnd(); - // 末尾裸 JSON 对象:以末尾 } 为锚按花括号配平反找到匹配的起始 {,界定整个尾部对象(非最内层 {)。 + // trailing bare JSON object: anchored on the trailing }, walk back by brace balancing to the matching opening {, + // delimiting the whole trailing object (not the innermost {). const start = trailingObjectStart(out); if (start >= 0 && /"(?:recommendation|verdict)"\s*:/.test(out.slice(start))) { out = out.slice(0, start).trimEnd(); } else { - // 截断兜底:末尾有未闭合的 dangling `{"verdict"|"recommendation" …`(输出被 token 上限截断在判定 JSON 中途) - // → 从该起点剥到结尾,避免半截 JSON 残留在正文末尾。正文 markdown 不会出现该字面量起点,误删风险极低。 + // truncation fallback: an unclosed dangling `{"verdict"|"recommendation" …` at the end (output truncated by the + // token limit mid-verdict-JSON) → strip from that start to the end, avoiding half-cut JSON left at the end of the + // prose. Prose markdown won't produce that literal start, so the mis-strip risk is very low. out = out.replace(/\{\s*"(?:recommendation|verdict)"\s*:[\s\S]*$/, '').trimEnd(); } return out; } /** - * 兜底打捞人类可读散文:当 JSON 动作解析失败(截断 / 引号未转义等无法恢复时),从原始文本里用宽松 - * 正则捞出 `final` / `summary` 字段值并反转义,绝不把原始 JSON 动作丢给用户当回答。捞不到才退回原文。 + * Fallback salvage of human-readable prose: when JSON action parsing fails (truncation / unescaped quotes and other + * unrecoverable cases), pull the `final` / `summary` field value from the raw text with a lax regex and unescape it, + * never handing the raw JSON action to the user as the answer. Falls back to the raw text only when nothing is found. */ export function salvageProse(raw: string): string { const m = raw.match(/"(?:final|summary)"\s*:\s*"((?:\\.|[^"\\])*)"?/); diff --git a/packages/agent/src/utils/stagger.ts b/packages/agent/src/utils/stagger.ts index 1a92bf27..82087a88 100644 --- a/packages/agent/src/utils/stagger.ts +++ b/packages/agent/src/utils/stagger.ts @@ -1,9 +1,11 @@ import { STAGGER_MIN_MS, STAGGER_SPAN_MS } from '../constants.js'; /** - * 把并发分发的工具调用相互错开一个**累计的 100~200ms 随机延迟**:首个立即发出,其余各在前一个 - * 基础上再加 100~200ms 起跑,避免不同工具在同一瞬间齐发、抢占子进程 spawn / LLM 网络。 - * 返回顺序与入参一致(Promise.all 保序),不改变并发语义、只错开起跑时刻。延迟参数见 constants.ts。 + * Stagger concurrently dispatched tool calls by a **cumulative 100~200ms random delay**: the first fires + * immediately, each of the rest starts 100~200ms after the previous, avoiding different tools firing at the + * same instant and contending for child-process spawn / LLM network. + * Return order matches the input (Promise.all preserves order); this doesn't change concurrency semantics, only + * staggers the start times. Delay parameters see constants.ts. */ function sleep(ms: number): Promise<void> { diff --git a/packages/agent/src/utils/text.ts b/packages/agent/src/utils/text.ts index cc805dcb..c84eb821 100644 --- a/packages/agent/src/utils/text.ts +++ b/packages/agent/src/utils/text.ts @@ -1,8 +1,10 @@ -/** 字符串处理工具(域无关):模板占位符填充、按长度截断。 */ +/** String processing utilities (domain-agnostic): template placeholder filling, length-based truncation. */ /** - * 用 vars 替换模板里的 `{{name}}` 占位符(字面替换),并去掉资源文件尾换行(trimEnd)以与原内联字符串 - * 对齐。替换后若仍残留 `{{...}}` 占位符即抛错——兜住漏填(外置后没有编译期校验,运行期早失败胜过静默)。 + * Replace `{{name}}` placeholders in the template with vars (literal replacement), and trim the resource file's + * trailing newline (trimEnd) to align with the original inline string. If any `{{...}}` placeholder remains after + * replacement, throw—covering missed fills (once externalized there's no compile-time validation, failing early + * at runtime beats silence). */ export function fillTemplate(tpl: string, vars: Record<string, string>): string { let out = tpl; @@ -12,7 +14,7 @@ export function fillTemplate(tpl: string, vars: Record<string, string>): string return out.trimEnd(); } -/** 把字符串 trim 后截到至多 max 字符,超出以省略号收尾。 */ +/** Trim the string then truncate to at most max chars, ending with an ellipsis if it overflows. */ export function clamp(s: string, max: number): string { const t = s.trim(); return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`; diff --git a/packages/agent/tests/agent-files.test.ts b/packages/agent/tests/agent-files.test.ts index f5d380c4..8ff3a600 100644 --- a/packages/agent/tests/agent-files.test.ts +++ b/packages/agent/tests/agent-files.test.ts @@ -25,14 +25,14 @@ describe('scaffoldAgentDir', () => { const soul = await readFile(path.join(dir, 'SOUL.md'), 'utf8'); expect(soul.length).toBeGreaterThan(0); - // 第二次脚手架不重复创建(幂等) + // second scaffold does not re-create (idempotent) const again = await scaffoldAgentDir(dir); expect(again).toEqual([]); }); it('does not recreate the seed-once example rule after deletion', async () => { await scaffoldAgentDir(dir); - // 用户删掉示例规则——首次播种文件,删除后不应被复活。 + // user deletes the example rule—a seed-once file, it should not be revived after deletion. await unlink(path.join(dir, 'rules/example.md')); const again = await scaffoldAgentDir(dir); expect(again).not.toContain('rules/example.md'); @@ -41,7 +41,7 @@ describe('scaffoldAgentDir', () => { it('recreates a deleted user-owned README (create-if-missing)', async () => { await scaffoldAgentDir(dir); - // README 属用户所有的「缺失即创建」,删除后下次脚手架补回(与首次播种的示例规则相区别)。 + // README is user-owned "create-if-missing"; the next scaffold restores it after deletion (distinct from the seed-once example rule). await unlink(path.join(dir, 'README.md')); const again = await scaffoldAgentDir(dir); expect(again).toContain('README.md'); @@ -57,7 +57,7 @@ describe('scaffoldAgentDir', () => { it('realigns the managed SOUL.md back to the built-in template', async () => { await scaffoldAgentDir(dir); const template = await readFile(path.join(dir, 'SOUL.md'), 'utf8'); - // 用户本地改动 SOUL.md —— 不被认可,下次脚手架对齐回模版。 + // user locally edits SOUL.md—not honored, the next scaffold realigns it back to the template. await writeFile(path.join(dir, 'SOUL.md'), '# locally edited', 'utf8'); const written = await scaffoldAgentDir(dir); expect(written).toContain('SOUL.md'); @@ -77,13 +77,13 @@ describe('loadAgentContext', () => { const ctx = await loadAgentContext(dir); expect(ctx.files.soul).toContain('Soul'); expect(ctx.files.agents).toContain('Working Agreement'); - // 模版示例规则被加载(enabled: false,仅验证被解析) + // the template example rule is loaded (enabled: false, only verifying it's parsed) expect(ctx.rules.length).toBe(1); expect(ctx.rules[0]?.enabled).toBe(false); }); it('treats missing context files as empty (failure-safe)', async () => { - // 只有 rules 目录、无四个上下文文件 + // only the rules dir, none of the four context files const ctx = await loadAgentContext(dir); expect(ctx.files.soul).toBe(''); expect(ctx.files.memory).toBe(''); diff --git a/packages/agent/tests/assemble.test.ts b/packages/agent/tests/assemble.test.ts index 65857cd2..8cbcbe1c 100644 --- a/packages/agent/tests/assemble.test.ts +++ b/packages/agent/tests/assemble.test.ts @@ -61,7 +61,7 @@ describe('assembleSystemContext', () => { toolCatalog: tools, language: 'zh-CN', }); - // 标记须与 shim runtime.py 的 CACHE_BREAK 逐字一致;稳定前缀在标记前、PR/语言在标记后。 + // marker must be byte-for-byte identical to the shim runtime.py's CACHE_BREAK; stable prefix before the marker, PR/language after. const marker = '\n\n---\n\n[[MEEBOX:CACHE_BREAK]]\n\n---\n\n'; expect(out).toContain(marker); const at = out.indexOf(marker); diff --git a/packages/agent/tests/autopilot-judge.test.ts b/packages/agent/tests/autopilot-judge.test.ts index b02a8a8e..07938593 100644 --- a/packages/agent/tests/autopilot-judge.test.ts +++ b/packages/agent/tests/autopilot-judge.test.ts @@ -72,10 +72,10 @@ describe('judgeAutopilotBatch', () => { }, ], }); - // 分支合并(提交全 merge)给出明确证据;仅源为主干则只标注背景信号、不暗示跳过。 + // Branch merge (all commits are merges) gives clear evidence; a mainline-only source is just a background signal, no skip hint. expect(user).toContain('all commits are merge commits'); expect(user).toContain('source is a long-lived / integration branch'); - // 旧的硬性「prefer skip」措辞不再出现。 + // The old hard "prefer skip" wording no longer appears. expect(user).not.toContain('prefer skip'); }); @@ -98,7 +98,7 @@ describe('judgeAutopilotBatch', () => { }); const byId = Object.fromEntries(r.decisions.map((d) => [d.prLocalId, d])); expect(byId.a?.plan).toEqual({ steps: ['describe-review', 'summary'] }); - expect(byId.b?.plan).toBeUndefined(); // summary 缺前置 describe-review → 非法回落 - expect(byId.c?.plan).toBeUndefined(); // 省略 → 默认全集 + expect(byId.b?.plan).toBeUndefined(); // summary missing prerequisite describe-review → invalid fallback + expect(byId.c?.plan).toBeUndefined(); // omitted → default full set }); }); diff --git a/packages/agent/tests/branch-merge.test.ts b/packages/agent/tests/branch-merge.test.ts index ccf8aafe..6bab542b 100644 --- a/packages/agent/tests/branch-merge.test.ts +++ b/packages/agent/tests/branch-merge.test.ts @@ -52,7 +52,7 @@ describe('classifyBranchMerge', () => { }); it('does not flag a mainline source that carries original (non-merge) commits', () => { - // 复现误判修复:源为 master/dev 的 fork 原创 PR——提交含非 merge → 不是分支合并。 + // Reproduces the misjudgment fix: an original fork PR sourced from master/dev — commits include a non-merge → not a branch merge. const commits = [{ parents: ['a', 'b'] }, { parents: ['c'] }]; expect(classifyBranchMerge({ sourceBranch: 'master', targetBranch: 'master', commits })).toEqual( { isBranchMerge: false, basis: 'commits', sourceMainline: true }, diff --git a/packages/agent/tests/memory.test.ts b/packages/agent/tests/memory.test.ts index 568368f8..1dfe4f16 100644 --- a/packages/agent/tests/memory.test.ts +++ b/packages/agent/tests/memory.test.ts @@ -29,7 +29,7 @@ describe('appendAgentNotes', () => { ]); expect(added).toEqual(['skip pure dependency bumps']); const content = await readFile(file, 'utf8'); - // 插到 AutoPilot 节末尾、Grants 之前,不破坏后续章节。 + // Inserted at the end of the AutoPilot section, before Grants, without disturbing later sections. expect(content).toBe( '# Title\n\n## AutoPilot\n- skip branch merges\n- skip pure dependency bumps\n\n## Grants\n- none\n', ); @@ -51,7 +51,7 @@ describe('appendAgentNotes', () => { { section: ' ', note: 'another finding' }, ]); expect(added).toEqual([]); - // 全被丢弃 → 不建文件。 + // All dropped → no file created. await expect(readFile(path.join(dir, 'MEMORY.md'), 'utf8')).rejects.toThrow(); }); @@ -63,7 +63,7 @@ describe('appendAgentNotes', () => { { section: '项目约定', note: 'new fact' }, { section: '其他', note: 'new fact' }, ]); - expect(added).toEqual(['new fact']); // 已存在的 / 批内重复的被跳过(去重只看正文) + expect(added).toEqual(['new fact']); // existing / in-batch duplicates are skipped (dedup looks only at the body) const content = await readFile(path.join(dir, 'MEMORY.md'), 'utf8'); expect(content.match(/repo uses g- prefix/g)).toHaveLength(1); expect(content.match(/new fact/g)).toHaveLength(1); diff --git a/packages/agent/tests/orchestrator.test.ts b/packages/agent/tests/orchestrator.test.ts index cf12937e..096703aa 100644 --- a/packages/agent/tests/orchestrator.test.ts +++ b/packages/agent/tests/orchestrator.test.ts @@ -16,7 +16,7 @@ const context: AgentContext = { }; const pr = { title: 'Fix bug', targetBranch: 'main' }; -/** 可编排的 fake deps:runTool 按 tool 返回固定文本;chat 顺序返回排好的回复。 */ +/** Orchestrable fake deps: runTool returns fixed text per tool; chat returns queued replies in order. */ function makeDeps(opts: { toolText?: Partial<Record<'describe' | 'review' | 'ask' | 'improve', string>>; chatReplies: string[]; @@ -49,7 +49,7 @@ describe('extractJson', () => { }); it('recovers JSON with unescaped raw newlines inside string values', () => { - // 模型常把多行 markdown 原样塞进字符串值、不转义换行——补转义后应能解析。 + // The model often stuffs multi-line markdown into a string value without escaping newlines — should parse after we re-escape. const raw = '{"final": "## 摘要\n\n第一行\n第二行", "recommendation": {"verdict": "needs_work"}}'; const parsed = extractJson<{ final: string; recommendation: { verdict: string } }>(raw); @@ -60,7 +60,7 @@ describe('extractJson', () => { describe('salvageProse', () => { it('extracts the final/summary prose from an unparseable JSON action', () => { - // 截断(无闭合 } / 引号)时仍捞出散文,绝不把原始 JSON 丢给用户。 + // When truncated (no closing } / quote) still salvage the prose, never hand raw JSON to the user. const truncated = '{"thought":"t","final":"## 摘要\\n\\n本 PR 修复了空值崩溃'; expect(salvageProse(truncated)).toBe('## 摘要\n\n本 PR 修复了空值崩溃'); expect(salvageProse('{"summary":"all good"}')).toBe('all good'); @@ -87,7 +87,7 @@ describe('stripTrailingJson', () => { }); it('strips a truncated/unterminated trailing recommendation object', () => { - // 输出被 token 上限截断在判定 JSON 中途(无闭合 })→ 仍把半截 JSON 从正文末尾剥掉。 + // Output cut off by the token limit mid-way through the judge JSON (no closing }) → still strip the half JSON off the body end. const truncated = '## 摘要\n\n正文含"引号"也不应被腰斩。\n\n{"verdict": "needs_work", "rea'; expect(stripTrailingJson(truncated)).toBe('## 摘要\n\n正文含"引号"也不应被腰斩。'); }); @@ -116,7 +116,7 @@ describe('runReviewMicroflow', () => { const r = await runReviewMicroflow(deps, { context, pr }); expect(toolCalls.map((c) => c.tool)).toEqual(['describe', 'review']); - // describe+review 合并步(一条 plan,两工具并行)→ judge → 收尾 plan(工具执行由 run 卡片代表)。 + // describe+review merged step (one plan, two tools in parallel) → judge → summary plan (tool execution represented by run cards). expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'judge', 'plan']); expect(r.summary).toBe('all good'); expect(r.recommendation).toEqual({ verdict: 'approve', reason: 'no issues' }); @@ -135,7 +135,7 @@ describe('runReviewMicroflow', () => { const r = await runReviewMicroflow(deps, { context, pr, maxFollowupAsks: 2 }); const askCalls = toolCalls.filter((c) => c.tool === 'ask'); - expect(askCalls.map((c) => c.question)).toEqual(['q1', 'q2']); // capped at 2(执行经 runTool / run 卡片) + expect(askCalls.map((c) => c.question)).toEqual(['q1', 'q2']); // capped at 2 (executed via runTool / run cards) expect(r.recommendation.verdict).toBe('needs_work'); }); @@ -147,7 +147,7 @@ describe('runReviewMicroflow', () => { `{"summary": "${long}", "recommendation": {"verdict": "approve", "reason": "ok"}}`, ], }); - // summaryMaxChars=100 远小于 500 字符的产出:现仅作提示词软约束,不再硬截断 → 完整保留。 + // summaryMaxChars=100 is far below the 500-char output: now only a soft prompt hint, no hard truncation → fully preserved. const r = await runReviewMicroflow(deps, { context, pr, summaryMaxChars: 100 }); expect(r.summary).toBe(long); }); @@ -161,7 +161,7 @@ describe('runReviewMicroflow', () => { }); it('parses markdown summary + a flat trailing recommendation JSON (new format)', async () => { - // 新格式:纯 markdown 正文(含引号,不被腰斩)+ 末尾一行扁平判定 JSON。 + // New format: pure markdown body (with quotes, not cut off) + a flat trailing judge JSON on the last line. const md = '## 摘要\n\n本 PR 直接违反了 PR 的"单一职责"原则,需修改。'; const { deps } = makeDeps({ chatReplies: [ @@ -175,7 +175,7 @@ describe('runReviewMicroflow', () => { }); it('keeps the markdown summary intact when the trailing recommendation is truncated', async () => { - // 末尾判定被截断 → 正文完整保留(不腰斩)、判定回落 manual_review。 + // Trailing judge truncated → body fully preserved (not cut off), judge falls back to manual_review. const md = '## 摘要\n\n结论:第 2 个问题直接违反了 PR 的约定。'; const { deps } = makeDeps({ chatReplies: ['{"severe": false}', `${md}\n\n{"verdict": "needs_w`], @@ -209,7 +209,7 @@ describe('runReviewMicroflow', () => { pr, plan: { steps: ['describe-review', 'summary'] }, }); - // describe-review 步两只读工具仍并行跑;judge / asks 被跳过。 + // The two read-only tools of the describe-review step still run in parallel; judge / asks are skipped. expect(toolCalls.map((c) => c.tool)).toEqual(['describe', 'review']); expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'plan']); expect(r.summary).toBe('ok'); @@ -224,7 +224,7 @@ describe('runReviewMicroflow', () => { pr, plan: { steps: ['describe-review', 'improve', 'summary'] }, }); - // describe + review(并行)→ improve → summary(chat)。 + // describe + review (parallel) → improve → summary (chat). expect(toolCalls.map((c) => c.tool)).toEqual(['describe', 'review', 'improve']); expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'plan', 'plan']); }); @@ -282,7 +282,7 @@ describe('runReviewMicroflow', () => { }), }; await runReviewMicroflow(deps, { context, pr }); - expect(askReferenced).toEqual([true]); // 复评模式派发(带 referencedFinding) + expect(askReferenced).toEqual([true]); // dispatched in re-review mode (with referencedFinding) expect(closeCalls).toEqual([ { runId: 'rev-1', findingId: 'review-000', byAskRunId: 'ask-1', verdict: 'replace' }, ]); @@ -343,7 +343,7 @@ describe('runReviewMicroflow', () => { ], }); const r = await runReviewMicroflow(deps, { context, pr, plan: { steps: ['summary'] } }); - // 非法计划回落 DEFAULT:describe-review → judge → asks(空) → summary。 + // Invalid plan falls back to DEFAULT: describe-review → judge → asks (empty) → summary. expect(toolCalls.map((c) => c.tool)).toEqual(['describe', 'review']); expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'judge', 'plan']); }); diff --git a/packages/agent/tests/planner.test.ts b/packages/agent/tests/planner.test.ts index 8d69aeed..8015d097 100644 --- a/packages/agent/tests/planner.test.ts +++ b/packages/agent/tests/planner.test.ts @@ -8,7 +8,7 @@ const context: AgentContext = { rules: [], }; const pr = { title: 'T', targetBranch: 'main' }; -const catalog = buildToolCatalog(); // read 可用、修改类禁用 +const catalog = buildToolCatalog(); // read available, mutating tools disabled function makeDeps( chatReplies: string[], @@ -43,7 +43,7 @@ describe('runPlanningAgent', () => { }); expect(toolCalls.map((c) => c.tool)).toEqual(['/review']); expect(r.finalText).toBe('LGTM'); - // 类 Claude Code:每回合一条思考步(plan)承载本轮工具选择;工具执行由 run 卡片代表、不再补记 tool 步。 + // Claude Code style: one thinking step (plan) per turn carries this turn's tool selection; tool execution is represented by run cards, no extra tool step. expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'plan']); expect(r.steps[0]?.toolCall?.tool).toBe('/review'); expect(r.tokenUsage.totalTokens).toBe(20); // 2 chat(5) + 1 tool(10) @@ -61,7 +61,7 @@ describe('runPlanningAgent', () => { userRequest: 'summary and review', }); expect(toolCalls.map((c) => c.tool)).toEqual(['/describe', '/review']); - // 一条思考步(plan,承载并行所选工具)+ 收尾 plan;工具执行由 run 卡片代表。 + // One thinking step (plan, carrying the parallel-selected tools) + summary plan; tool execution is represented by run cards. expect(r.steps.map((s) => s.kind)).toEqual(['plan', 'plan']); expect(r.steps[0]?.toolCall?.tool).toBe('/describe + /review'); expect(r.finalText).toBe('done'); @@ -82,7 +82,7 @@ describe('runPlanningAgent', () => { userRequest: 'explore deeply', maxFollowupAsks: 2, }); - // 前两次 /ask 执行;第三次超预算被拒(不进 runTool);/review 不受 /ask 预算约束仍可执行。 + // First two /ask run; the third is over budget and refused (never reaches runTool); /review is not bound by the /ask budget and still runs. expect(toolCalls.map((c) => c.tool)).toEqual(['/ask', '/ask', '/review']); expect(r.finalText).toBe('answer'); }); @@ -95,11 +95,11 @@ describe('runPlanningAgent', () => { const r = await runPlanningAgent(deps, { context, pr, - toolCatalog: catalog, // /approve 未授权 + toolCatalog: catalog, // /approve not granted userRequest: 'x', }); - expect(toolCalls.map((c) => c.tool)).toEqual(['/review']); // 仅允许的被分发 - expect(r.steps.some((s) => s.kind === 'judge')).toBe(true); // /approve 被拒记录 + expect(toolCalls.map((c) => c.tool)).toEqual(['/review']); // only the allowed one dispatched + expect(r.steps.some((s) => s.kind === 'judge')).toBe(true); // /approve refusal recorded }); it('caps parallel tool selection at 3', async () => { @@ -108,7 +108,7 @@ describe('runPlanningAgent', () => { '{"final":"ok"}', ]); await runPlanningAgent(deps, { context, pr, toolCatalog: catalog, userRequest: 'x' }); - expect(toolCalls).toHaveLength(3); // 4 选 → 截断为 3 + expect(toolCalls).toHaveLength(3); // 4 selected → truncated to 3 }); it('refuses ungranted mutating tools (red line) and lets the agent re-plan', async () => { @@ -172,7 +172,7 @@ describe('runPlanningAgent', () => { userRequest: 'x', }); expect(r.memories.user).toEqual([{ section: '评审偏好', note: '称呼: Kyle' }]); - expect(r.memories.memory).toEqual([]); // 纯字符串无法归类 → 丢弃 + expect(r.memories.memory).toEqual([]); // a plain string can't be categorized → dropped expect(r.memories.agents).toEqual([{ section: 'AutoPilot', note: 'check tenant mapping' }]); }); diff --git a/packages/agent/tests/review-shared.test.ts b/packages/agent/tests/review-shared.test.ts index d022a875..95f4c988 100644 --- a/packages/agent/tests/review-shared.test.ts +++ b/packages/agent/tests/review-shared.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { compactDescribe } from '../src/steps/review/shared.js'; describe('compactDescribe', () => { - it('剥掉 File Walkthrough <details> 块(取到结尾)', () => { + it('strips the File Walkthrough <details> block (through to the end)', () => { const text = [ '### Title', 'Add auth guard', @@ -17,7 +17,7 @@ describe('compactDescribe', () => { expect(out).not.toContain('src/a.ts'); }); - it('剥掉 mermaid 代码块,保留其余正文', () => { + it('strips mermaid code blocks, keeps the rest of the body', () => { const text = [ '## Summary', 'Refactor cache.', @@ -36,7 +36,7 @@ describe('compactDescribe', () => { expect(out).not.toContain('graph TD'); }); - it('无可剥块时原样返回(仅去首尾空白)', () => { + it('returns as-is when there is nothing to strip (only trims leading/trailing whitespace)', () => { const text = '## Summary\nLooks good.'; expect(compactDescribe(text)).toBe('## Summary\nLooks good.'); }); diff --git a/packages/config/src/bootstrap.ts b/packages/config/src/bootstrap.ts index 7bd38531..03fc10fa 100644 --- a/packages/config/src/bootstrap.ts +++ b/packages/config/src/bootstrap.ts @@ -6,16 +6,16 @@ import type { AppPaths, Config } from '@meebox/shared'; export interface BootstrapResult { paths: AppPaths; config: Config; - /** ~/.code-meeseeks/ 本次启动时新建(首启) */ + /** ~/.code-meeseeks/ created on this launch (first run) */ firstRun: boolean; } /** - * 应用启动时调用一次: - * - 确保 `~/.code-meeseeks/` 及子目录存在 - * - 若 config.yaml 不存在,写入默认值 - * - 若已存在,读出并 schema 校验 - * - 解析 reposDir,返回 AppPaths + Config + firstRun 标志 + * Called once at application startup: + * - Ensure `~/.code-meeseeks/` and its subdirectories exist + * - If config.yaml is absent, write default values + * - If present, read it and validate against the schema + * - Resolve reposDir, return AppPaths + Config + firstRun flag */ export async function ensureWorkspace(): Promise<BootstrapResult> { const appDir = getAppDir(); @@ -27,7 +27,7 @@ export async function ensureWorkspace(): Promise<BootstrapResult> { firstRun = true; } - // 子目录创建放在配置加载之前,避免后续日志/state 写入时还需检查 + // Create subdirectories before loading config, so later log/state writes need not re-check const stubPaths = buildAppPaths('~/.code-meeseeks/repos'); for (const dir of [ stubPaths.appDir, diff --git a/packages/config/src/config-store.ts b/packages/config/src/config-store.ts index 139cec04..e1f9f72d 100644 --- a/packages/config/src/config-store.ts +++ b/packages/config/src/config-store.ts @@ -4,7 +4,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { ConfigSchema, type Config } from '@meebox/shared'; /** - * 读取 config.yaml 并校验。文件不存在时返回 null(由 bootstrap 决定是否创建)。 + * Read config.yaml and validate it. Returns null when the file is absent (bootstrap decides whether to create it). */ export async function readConfig(configFile: string): Promise<Config | null> { let text: string; @@ -19,7 +19,7 @@ export async function readConfig(configFile: string): Promise<Config | null> { } /** - * 原子写入 config.yaml:先写 tmp,fsync,再 rename。 + * Atomic write of config.yaml: write to tmp, fsync, then rename. */ export async function writeConfig(configFile: string, config: Config): Promise<void> { const yaml = stringifyYaml(config); @@ -35,7 +35,7 @@ export async function writeConfig(configFile: string, config: Config): Promise<v await fs.rename(tmp, configFile); } -/** 默认配置:所有字段走 zod schema 的 default 值。 */ +/** Default config: every field takes the zod schema's default value. */ export function defaultConfig(): Config { return ConfigSchema.parse({}); } diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index f54eca19..84bae020 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { AppPaths } from '@meebox/shared'; /** - * 展开路径里的 `~` 为用户 home 目录。仅支持开头 `~/...` 或 `~`,不处理 `~user/...`。 + * Expand a leading `~` in a path to the user's home directory. Only supports a leading `~/...` or `~`, not `~user/...`. */ export function expandHome(p: string): string { if (p === '~') return os.homedir(); @@ -13,14 +13,14 @@ export function expandHome(p: string): string { return p; } -/** 应用数据根目录(固定):`~/.code-meeseeks/`。 */ +/** Application data root directory (fixed): `~/.code-meeseeks/`. */ export function getAppDir(): string { return path.join(os.homedir(), '.code-meeseeks'); } /** - * 根据已加载的 reposDir 配置值,组装 AppPaths。 - * `reposDirRaw` 来自 config.yaml,可能含 `~`,会在此处展开为绝对路径。 + * Assemble AppPaths from the loaded reposDir config value. + * `reposDirRaw` comes from config.yaml, may contain `~`, and is expanded to an absolute path here. */ export function buildAppPaths(reposDirRaw: string): AppPaths { const appDir = getAppDir(); diff --git a/packages/ipc/package.json b/packages/ipc/package.json index 9f6d223d..868f8531 100644 --- a/packages/ipc/package.json +++ b/packages/ipc/package.json @@ -2,7 +2,7 @@ "name": "@meebox/ipc", "version": "0.0.0", "private": true, - "description": "Typed IPC 契约(renderer ↔ main):按业务领域拆分的 IpcChannels / IpcEvents / IpcBridge", + "description": "Typed IPC contract (renderer ↔ main): IpcChannels / IpcEvents / IpcBridge split by business domain", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/ipc/src/agent.ts b/packages/ipc/src/agent.ts index 568aee5c..311a31ca 100644 --- a/packages/ipc/src/agent.ts +++ b/packages/ipc/src/agent.ts @@ -9,12 +9,12 @@ import type { } from '@meebox/shared'; import type { PragentRunInfo } from './common.js'; -/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账 / pr-agent run 队列。 */ +/** Agent interaction domain: rule matching / review orchestration / free planning / session and ledger / pr-agent run queue. */ export interface AgentChannels { /** - * 给指定 PR 查 `<agent.dir>/rules` 当前命中的**全部规则**(按 priority desc + path asc,封顶前若干条, - * 与评审注入同口径)。调用方传 tool 区分 /describe / /review(规则可能只对其中一个 tool 生效)。 - * agent.dir 未配置 / 整体禁用 / 无命中 → 返回空数组。 + * For a given PR, query the **all rules** currently matched in `<agent.dir>/rules` (by priority desc + path asc, capped at the first several, + * same standard as review injection). Caller passes tool to distinguish /describe / /review (a rule may apply to only one of the tools). + * agent.dir unconfigured / globally disabled / no match → returns empty array. */ 'rules:matchForPr': { request: { localId: string; tool: ReviewRunTool }; @@ -27,75 +27,75 @@ export interface AgentChannels { }>; }; /** - * 对指定 PR 跑一次 Agent 评审微流程(describe→review→条件追问→总结)。同步等待, - * 期间经 agent:stepProgress 推送步骤;返回收尾后的 AgentSession(含 summary / - * recommendation)。pr-agent 不可用时 reject。 + * Run one Agent review micro-flow for a given PR (describe→review→conditional follow-up→summary). Waits synchronously, + * pushing steps via agent:stepProgress meanwhile; returns the finalized AgentSession (with summary / + * recommendation). Rejects when pr-agent is unavailable. */ 'agent:run': { request: { localId: string }; response: AgentSession; }; /** - * 对指定 PR 跑自由规划 Agent(自然语言入口「对话即委派」)。同步等待,步骤经 - * agent:stepProgress 推送;返回收尾会话(summary = Agent 最终回答)。 + * Run the free-planning Agent for a given PR (natural-language entry "conversation as delegation"). Waits synchronously, steps pushed via + * agent:stepProgress; returns the finalized session (summary = Agent's final answer). */ 'agent:ask': { /** - * referencedContext:用户在 Diff 里选中的代码片段(含路径 + 行范围 + 代码),作为**隐式上下文** - * 注入规划 LLM 的当轮提示,不进入持久化的用户消息正文。省略 = 本轮不带选区引用。 + * referencedContext: the code snippet the user selected in the Diff (with path + line range + code), injected as **implicit context** + * into this turn's prompt for the planning LLM, not entering the persisted user message body. Omitted = no selection reference this turn. */ request: { localId: string; question: string; referencedContext?: string }; response: AgentSession; }; - /** 暂停当前 PR 的 Agent 运行(abort);会话置 paused、保态。 */ + /** Pause the current PR's Agent run (abort); session set to paused, state preserved. */ 'agent:stop': { request: { localId: string }; response: { ok: boolean } }; /** - * 运行期间追加一条用户消息:若该 PR 正有 Agent 在跑 → 入队,下一主 Agent 周期并入、据最新指令重排 - * (queued=true);若没有在跑 → 直接起一轮自由规划(queued=false,竞态兜底,不丢消息)。 + * Append a user message during a run: if an Agent is running for this PR → enqueue, merged into the next main Agent cycle and re-ordered per the latest instruction + * (queued=true); if none is running → directly start one free-planning round (queued=false, race fallback, no message lost). */ 'agent:enqueueMessage': { request: { localId: string; message: string }; response: { queued: boolean }; }; /** - * 读取指定 PR 已落盘的 Agent 会话(含收尾 summary / recommendation);无则返回 null。 - * 供 UI 打开 PR 时恢复「评审总结」卡片——总结归属其发起 PR、跨 PR 切换不丢失、不串台。 + * Read a given PR's persisted Agent session (with final summary / recommendation); returns null if none. + * Used by the UI to restore the "review summary" card when opening a PR—the summary belongs to its originating PR, not lost across PR switches, no cross-talk. */ 'agent:getSession': { request: { localId: string }; response: AgentSession | null }; /** - * 读取指定 PR 的多轮对话消息(用户输入 + Agent 回答,按时间升序);无则空数组。 - * UI 据此渲染多轮会话;跨 PR 切换 / 重启后恢复。 + * Read a given PR's multi-turn conversation messages (user input + Agent answers, ascending by time); empty array if none. + * The UI renders the multi-turn conversation from this; restored across PR switches / restart. */ 'agent:getConversation': { request: { localId: string }; response: AgentMessage[] }; /** - * 读取指定 PR 已落盘的 Agent 过程步骤(transcript,按时间升序);无则空数组。 - * UI 据此恢复「过程化跟踪」的思考步骤——跨 PR 切换 / 重启后不丢失(步骤随产生增量落盘)。 + * Read a given PR's persisted Agent process steps (transcript, ascending by time); empty array if none. + * The UI restores the "process tracking" thinking steps from this—not lost across PR switches / restart (steps are persisted incrementally as produced). */ 'agent:getTranscript': { request: { localId: string }; response: AgentStep[] }; /** - * 批量读 AutoPilot 台账:返回各 PR 已自动评审的 recommendation(仅 decision=review 且有 - * 建议者)。PR 列表据此显示徽标,无需逐个加载会话。 + * Batch-read AutoPilot ledgers: returns each PR's auto-reviewed recommendation (only decision=review with a + * suggester). The PR list shows badges from this, without loading sessions one by one. */ 'agent:autopilotLedgers': { request: { localIds: string[] }; response: Record<string, AgentRecommendationVerdict>; }; - // ── pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列)── + // ── pr-agent run queue (review tool execution layer; agent:run / AutoPilot and user manual runs share the same queue) ── /** - * 触发一次 pr-agent /describe 或 /review。同步等待执行结束(可能数十秒到数分钟), - * 期间通过 pragent:runProgress 事件推送 stdout / stderr 行。返回最终 ReviewRun - * 状态 (succeeded / failed)。pr-agent 不可用时 reject。 + * Trigger one pr-agent /describe or /review. Waits synchronously for execution to finish (may take tens of seconds to minutes), + * pushing stdout / stderr lines via pragent:runProgress events meanwhile. Returns the final ReviewRun + * status (succeeded / failed). Rejects when pr-agent is unavailable. */ 'pragent:run': { /** - * tool='ask' 时 question 必填,作为 pr-agent CLI 的位置参数传给 ask 子命令。 - * tool='describe'/'review' 时 question 字段被忽略。 - * referencedContext:用户在 Diff 里选中的代码片段(隐式上下文),仅 tool='ask' 时生效——经 - * EXTRA_INSTRUCTIONS 注入,不进入问题位置参数(故不污染回答 echo / 会话气泡)。 - * referencedFinding:复评引用——本次 /ask 是对先前 review/improve 某条 finding 的复评(前向链, - * 落到 ReviewRun.referencedFinding),驱动复评模式提示词 + 结果卡的裁决动作。仅 tool='ask' 生效。 - * scope:单 commit 评审范围(parent..sha)——由 Diff 视图提交选择器发起,把本次 run 的 diff 限定在 - * 该 commit 自身改动而非 PR 全量。对 describe/review/ask/improve 均生效;缺省 = PR 全量范围。 + * When tool='ask', question is required, passed as the positional argument to the pr-agent CLI's ask subcommand. + * When tool='describe'/'review', the question field is ignored. + * referencedContext: the code snippet the user selected in the Diff (implicit context), effective only when tool='ask'—injected via + * EXTRA_INSTRUCTIONS, not entering the question positional argument (so it doesn't pollute the answer echo / conversation bubble). + * referencedFinding: re-review reference—this /ask is a re-review of some prior review/improve finding (forward chain, + * lands on ReviewRun.referencedFinding), driving the re-review-mode prompt + the result card's verdict actions. Effective only when tool='ask'. + * scope: single-commit review range (parent..sha)—initiated by the Diff view's commit selector, limiting this run's diff to + * that commit's own changes rather than the whole PR. Effective for describe/review/ask/improve; default = whole-PR range. */ request: { localId: string; @@ -108,45 +108,45 @@ export interface AgentChannels { response: ReviewRun; }; /** - * 列出某 PR 的历史 run,newest first。支持时间戳游标分页: - * - limit:截到 N 条;省略 = 不限(renderer 端慎用,规模大时可能慢) - * - beforeId:游标,返回 runId **严格小于** 此值的条目;省略 = 不限上界 + * List a PR's historical runs, newest first. Supports timestamp-cursor pagination: + * - limit: cap at N entries; omitted = unlimited (use with care on the renderer, may be slow at scale) + * - beforeId: cursor, returns entries with runId **strictly less than** this value; omitted = no upper bound * - * runId 是时序字典序 (`yyyymmdd-HHmmss-mmm`),"取游标后 N 条" 即"取此时刻之前的 N 条" + * runId is time-ordered lexicographically (`yyyymmdd-HHmmss-mmm`), so "take N entries after the cursor" is "take the N entries before this moment" */ 'pragent:listRuns': { request: { localId: string; limit?: number; beforeId?: string }; response: ReviewRun[]; }; - /** 单条 run 查询(用于 renderer 在事件断流后兜底刷新) */ + /** Single-run query (for the renderer to fall back and refresh after an event stream drops) */ 'pragent:getRun': { request: { localId: string; runId: string }; response: ReviewRun | null; }; - /** 清空指定 PR 的全部 run 历史记录(仅该 PR 生效)。返回删除条数。 */ + /** Clear all run history for a given PR (effective only for that PR). Returns the number deleted. */ 'pragent:clearRuns': { request: { localId: string }; response: { cleared: number }; }; - /** 删除指定 PR 的单条 run 记录(仅该 run,不动 Agent 会话 / 台账)。返回是否确有删除。 */ + /** Delete a single run record of a given PR (only that run, leaving the Agent session / ledger untouched). Returns whether something was actually deleted. */ 'pragent:deleteRun': { request: { localId: string; runId: string }; response: { ok: boolean }; }; /** - * 取消一个 run。语义跟 run 当前状态相关: - * - 跟 active 匹配 → SIGKILL 子进程,落盘 status='cancelled' - * - 在 waiting 队列里 → 从队列删除,**不**写盘 (从未真正跑过);触发 pragent:run - * 原调用方的 Promise reject 让 ChatPane handleRun 走 error 分支 - * - 都不匹配 (已结束 / 不存在) → 静默 no-op (返回 ok:false) + * Cancel a run. Semantics depend on the run's current state: + * - matches active → SIGKILL the child process, persist status='cancelled' + * - in the waiting queue → remove from the queue, do **not** write to disk (never actually ran); trigger the pragent:run + * original caller's Promise reject so ChatPane handleRun takes the error branch + * - matches neither (already finished / nonexistent) → silent no-op (returns ok:false) */ 'pragent:cancel': { request: { runId: string }; response: { ok: boolean }; }; /** - * 查询当前队列快照 (active + waiting);renderer 启动 / 重连时拉一下, - * 跟 queueChanged 事件配套兜底。 + * Query the current queue snapshot (active + waiting); the renderer pulls it on startup / reconnect, + * as a fallback paired with the queueChanged event. */ 'pragent:queue': { request: void; diff --git a/packages/ipc/src/app.ts b/packages/ipc/src/app.ts index cbb0aff3..62d0dc22 100644 --- a/packages/ipc/src/app.ts +++ b/packages/ipc/src/app.ts @@ -1,32 +1,32 @@ import type { AppInfo, AppPaths, PrAgentStatus, UpdateCheckResult } from '@meebox/shared'; import type { ConnectionSummary } from './common.js'; -/** GUI 框架交互域:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像。 */ +/** GUI framework interaction domain: app info / framework window / external open / dialogs / log relay / connections and avatars. */ export interface AppChannels { 'app:info': { request: void; response: AppInfo }; 'app:paths': { request: void; response: AppPaths }; 'app:prAgentStatus': { request: void; response: PrAgentStatus }; - /** 调 Electron shell.openPath 让 OS 默认编辑器打开 config.yaml */ + /** Call Electron shell.openPath to let the OS default editor open config.yaml */ 'app:openConfigFile': { request: void; response: void }; - /** 调 shell.openPath 在系统文件管理器打开当前生效的 Agent 目录(不存在则先建)。 */ + /** Call shell.openPath to open the currently effective Agent directory in the system file manager (create it first if it doesn't exist). */ 'app:openAgentDir': { request: void; response: void }; - /** 打开 Electron DevTools(分离窗口) */ + /** Open Electron DevTools (detached window) */ 'app:openDevTools': { request: void; response: void }; /** - * 设置应用角标计数(macOS dock)。renderer 据 PR 列表派生「@我 / 回复我」待回应总数后推送;主进程按 - * 通知配置与平台决定是否真正显示(本期仅 macOS)。count=0 清除角标。 + * Set the app badge count (macOS dock). The renderer derives the "@me / replies to me" pending-response total from the PR list and pushes it; the main process decides + * whether to actually show it per notification config and platform (macOS only this iteration). count=0 clears the badge. */ 'app:setBadgeCount': { request: { count: number }; response: void }; - /** 手动检测版本更新(设置页「检查更新」)。仅检测 + 返回结果,不下载 / 安装; - * 结果同时缓存进 main 单一真相源并在有新版时广播 app:updateAvailable,使状态栏同步。 */ + /** Manually check for a version update (Settings page "Check for updates"). Only checks + returns the result, no download / install; + * the result is also cached into main's single source of truth and, when a new version exists, broadcasts app:updateAvailable to keep the status bar in sync. */ 'app:checkUpdate': { request: void; response: UpdateCheckResult }; - /** 读取 main 缓存的最近一次成功更新检测结果(不发起网络请求)。供窗口 / 状态栏挂载时水合, - * 无缓存(尚未检测过)时返回 null。 */ + /** Read main's cached most-recent successful update-check result (does not make a network request). Used to hydrate on window / status bar mount, + * returns null when there's no cache (never checked). */ 'app:getUpdateStatus': { request: void; response: UpdateCheckResult | null }; /** - * 渲染层日志回传:把渲染进程的错误 / 未捕获异常转发到 main,落进同一份 meebox.log - * (renderer 自己的 console 不进文件)。preload 装 window.onerror / unhandledrejection - * 调用。`scope` 固定 'renderer',`meta` 任意结构化上下文(如 stack / url)。 + * Renderer log relay: forward the renderer's errors / uncaught exceptions to main, landing in the same meebox.log + * (the renderer's own console doesn't go to file). preload installs window.onerror / unhandledrejection + * to call it. `scope` is fixed 'renderer', `meta` is any structured context (e.g. stack / url). */ 'log:write': { request: { @@ -37,40 +37,40 @@ export interface AppChannels { response: void; }; /** - * 用系统默认浏览器打开 URL (shell.openExternal)。评论 markdown 内链点击 → 强制 - * 外部打开,避免 Electron 在 app window 内跳转覆盖整个界面 + * Open a URL in the system default browser (shell.openExternal). Clicking an inline link in comment markdown → force + * external open, avoiding Electron navigating within the app window and covering the whole interface */ 'app:openExternal': { request: { url: string }; response: void }; /** - * 打开 macOS「系统设置 → 通知」面板,引导用户授予 / 开启通知权限(macOS 在系统层管控通知授权, - * 应用无法代为开启)。仅 macOS 有效,其他平台为 no-op。 + * Open the macOS "System Settings → Notifications" panel to guide the user to grant / enable notification permission (macOS governs notification authorization at the system level, + * the app cannot enable it on their behalf). Effective on macOS only, a no-op on other platforms. */ 'app:openNotificationSettings': { request: void; response: void }; /** - * 调起系统原生目录选择对话框;用户取消返回 path: null。 - * defaultPath 可空,作为初始定位目录。 + * Invoke the native system directory-picker dialog; returns path: null when the user cancels. + * defaultPath is optional, used as the initial location directory. */ 'dialog:pickDirectory': { - // title 由前端按 UI 语言提供(交互领域文案统一在渲染层 i18n 维护);defaultPath 作初始定位目录。 + // title is provided by the frontend per UI language (interaction-domain text is maintained uniformly in renderer i18n); defaultPath serves as the initial location directory. request: { defaultPath?: string; title: string }; response: { path: string | null }; }; /** - * 由渲染层在主题应用后推送当前主题派生的窗控按钮配色(Windows titleBarOverlay:color=--bg-app、 - * symbolColor=--text-primary),使系统窗控按钮与具体主题的标题栏底色精确同色;null 回退通用深 / 浅色。 + * The renderer, after applying the theme, pushes the window-control button colors derived from the current theme (Windows titleBarOverlay: color=--bg-app, + * symbolColor=--text-primary), so the system window-control buttons exactly match the specific theme's title-bar background; null falls back to generic dark / light. */ 'window:setControlColors': { request: { color: string; symbolColor: string } | null; response: void; }; - /** 各连接的 ping 后缓存:当前用户 + display_name,Header 用 */ + /** Each connection's post-ping cache: current user + display_name, used by the Header */ 'app:connections': { request: void; response: ConnectionSummary[] }; /** - * 按 (connectionId, slug) 拉用户头像 data URL;主进程缓存命中直接返回。 - * 平台不支持 / 网络失败 / 用户无头像时返回 null,renderer 走 initials 回退。 + * Fetch a user avatar data URL by (connectionId, slug); returns directly on a main-process cache hit. + * Returns null when the platform is unsupported / network fails / the user has no avatar, and the renderer takes the initials fallback. */ 'app:userAvatar': { - // avatarUrl 可选:平台返回的头像直链(GitHub 机器人必须靠它);缺省时 main 按 slug 推导 + // avatarUrl is optional: the direct avatar link returned by the platform (GitHub bots must rely on it); when omitted, main derives it from slug request: { connectionId: string; slug: string; avatarUrl?: string }; response: { dataUrl: string } | null; }; diff --git a/packages/ipc/src/common.ts b/packages/ipc/src/common.ts index 31cc47cf..6fe8221d 100644 --- a/packages/ipc/src/common.ts +++ b/packages/ipc/src/common.ts @@ -6,7 +6,7 @@ import type { ReviewRunTool, } from '@meebox/shared'; -/** ChangedFile / FileContent 跨 IPC 边界用,与 @meebox/repo-mirror 类型同形。 */ +/** ChangedFile / FileContent for use across the IPC boundary, same shape as the @meebox/repo-mirror types. */ export type DiffFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; export interface DiffChangedFile { @@ -20,7 +20,7 @@ export type DiffFileContent = { binary: false; content: string } | { binary: tru export type DiffSide = 'base' | 'head'; -/** 单行 blame 信息(main 跑 git blame --porcelain,renderer 渲染左侧列)。 */ +/** Single-line blame info (main runs git blame --porcelain, the renderer renders the left column). */ export interface DiffBlameLine { line: number; commit: string; @@ -32,41 +32,41 @@ export interface DiffBlameLine { export interface ConnectionSummary { connectionId: string; - /** 来自 config 的 display_name */ + /** display_name from config */ displayName: string; - /** ping 后缓存的当前 PAT 所属用户;ping 未完成或失败时为 null */ + /** the user owning the current PAT, cached after ping; null when ping is incomplete or failed */ user: PlatformUser | null; - /** 该连接所属平台的能力描述符;渲染层据此 显/隐/灰(多平台降级,见 platform.ts) */ + /** the capability descriptor of the platform this connection belongs to; the renderer shows/hides/grays out accordingly (multi-platform degradation, see platform.ts) */ capabilities: PlatformCapabilities; } /** - * 一个 pr-agent run 的元信息,覆盖"正在跑 (active)"和"排队中 (waiting)"两种状态。 + * Metadata for one pr-agent run, covering both "running (active)" and "queued (waiting)" states. * - * - active:`startedAt` 是 ISO 启动时间,UI 计时器起点 - * - waiting:`startedAt` 为 null,UI 显示"排队中"+ enqueuedAt + * - active: `startedAt` is the ISO start time, the UI timer's origin + * - waiting: `startedAt` is null, the UI shows "queued" + enqueuedAt * - * 入队即生成 runId (跟最终落盘的 ReviewRun.id 一致;queued 状态不写盘,等真正 - * 开始时 startReviewRun 才落 disk)。这让 `pragent:cancel(runId)` 在 queued/active - * 两种状态下都能用同一个 id 引用。 + * runId is generated at enqueue (matching the eventually-persisted ReviewRun.id; the queued state is not written to disk, only when actually + * starting does startReviewRun land it on disk). This lets `pragent:cancel(runId)` reference the same id in both queued/active + * states. */ export interface PragentRunInfo { runId: string; prLocalId: string; - /** 仓库 slug 与 PR 号(队列展示用,避免只显示 localId hash)。 */ + /** Repo slug and PR number (for queue display, avoiding showing only the localId hash). */ repoSlug: string; prNumber: string; tool: ReviewRunTool; question?: string; - /** 触发来源:user(手动发起)/ agent(编排派发)。ChatPane 据此为 user 来源的运行中 run 补命令回显气泡。 */ + /** Trigger origin: user (manually initiated) / agent (dispatched by orchestration). ChatPane uses this to add a command echo bubble for running runs of user origin. */ origin: ReviewRunOrigin; - /** 单 commit 评审范围(parent..sha);限定在某 commit 时填,运行中卡片据此展示范围徽标。缺省 = PR 全量。 */ + /** Single-commit review range (parent..sha); filled when limited to a commit, and the running card shows a range badge from it. Default = whole PR. */ scope?: ReviewRunCommitScope; - /** 入队时间,ISO */ + /** Enqueue time, ISO */ enqueuedAt: string; - /** 开始执行时间,ISO;waiting 状态为 null */ + /** Execution start time, ISO; null in the waiting state */ startedAt: string | null; } -/** 兼容旧引用:active 状态本质就是 startedAt 非空的 PragentRunInfo */ +/** Compatibility for legacy references: the active state is essentially a PragentRunInfo with non-null startedAt */ export type ActiveRunInfo = PragentRunInfo; diff --git a/packages/ipc/src/config.ts b/packages/ipc/src/config.ts index 93e6ea1c..1eb65fff 100644 --- a/packages/ipc/src/config.ts +++ b/packages/ipc/src/config.ts @@ -6,70 +6,70 @@ import type { SupportedLanguage, } from '@meebox/shared'; -/** 配置操作域:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连。 */ +/** Config operations domain: read / write config.yaml (including hot-apply and draft staging) and connection / proxy test-connect. */ export interface ConfigChannels { 'config:read': { request: void; response: Config }; - /** 写入新的 repos_dir 到 config.yaml;重启生效 */ + /** Write the new repos_dir to config.yaml; effective on restart */ 'config:setReposDir': { request: { reposDir: string }; response: void }; /** - * 写入 UI 语言到 config.yaml 并**即时生效**:主进程 i18n 立刻 changeLanguage(后续 dialog/ - * 错误文案 + 下次 pragent:run 的响应语言随之),渲染层另行 i18n.changeLanguage 实时切换。 - * 与代理/连接同属热生效项,无需依赖设置页全局保存。 + * Write the UI language to config.yaml and **apply immediately**: the main process i18n calls changeLanguage right away (subsequent dialog/ + * error text + the next pragent:run's response language follow), and the renderer separately does i18n.changeLanguage to switch in real time. + * Like proxy/connection, it's a hot-apply item, not dependent on the Settings page's global save. */ 'config:setLanguage': { request: { language: SupportedLanguage }; response: void }; /** - * 写入外观(全局主题 = Monaco 配色主题 + 等宽字体族 + 字号)到 config.yaml。主题切换由 renderer 即时 - * 完成(Monaco theme + data-theme + chrome 派生 + 字体 CSS 变量);主进程据主题设原生窗口 themeSource。 + * Write appearance (global theme = Monaco color theme + monospace font family + font size) to config.yaml. Theme switching is done by the renderer + * immediately (Monaco theme + data-theme + chrome derivation + font CSS variables); the main process sets the native window themeSource per theme. */ 'config:setEditorAppearance': { request: { editor_theme: EditorTheme; editor_font_family: string; editor_font_size: number }; response: void; }; - /** 写入 LLM Provider 配置到 config.yaml;下次 pragent:run 自动用新值 */ + /** Write LLM Provider config to config.yaml; the next pragent:run automatically uses the new values */ 'config:setLlm': { request: { llm: Config['llm'] }; response: void }; - /** 写入 agent.dir 到 config.yaml;下次 pragent:run 立即生效 (现读规则) */ + /** Write agent.dir to config.yaml; effective on the next pragent:run (rules read at that time) */ 'config:setAgent': { request: { agent: Config['agent'] }; response: void }; - /** 写入消息通知配置(总开关 + 分类型系统通知 + dock 角标)到 config.yaml;内存同步,下次 poll/角标即用新值。 */ + /** Write message notification config (master switch + per-category system notifications + dock badge) to config.yaml; synced in memory, the next poll/badge uses the new values. */ 'config:setNotifications': { request: { notifications: Config['notifications'] }; response: void }; - /** 翻转 AutoPilot 开关 (agent.autopilot.enabled) 并写 config.yaml;下次 poll tick 生效。 */ + /** Flip the AutoPilot switch (agent.autopilot.enabled) and write config.yaml; effective on the next poll tick. */ 'agent:setAutopilotEnabled': { request: { enabled: boolean }; response: void }; - /** 写入轮询间隔 (秒,60~900 整数) 到 config.yaml,并热替换 poller 定时器,无需重启 */ + /** Write the poll interval (seconds, integer 60~900) to config.yaml and hot-swap the poller timer, no restart needed */ 'config:setPoller': { request: { interval_seconds: number }; response: void }; - /** 写入评审任务并发数 (1~8 整数, pr_agent.max_concurrency) 到 config.yaml,并热替换 run 队列上限,无需重启 */ + /** Write the review-task concurrency (integer 1~8, pr_agent.max_concurrency) to config.yaml and hot-swap the run queue cap, no restart needed */ 'config:setMaxConcurrency': { request: { max_concurrency: number }; response: void }; /** - * 写入网络代理配置到 config.yaml,并**热重建** adapter(REST 经代理即时生效)。 - * pr-agent / git 出口下次操作读最新配置,无需重启。 + * Write network proxy config to config.yaml and **hot-rebuild** the adapter (REST via proxy takes effect immediately). + * pr-agent / git egress reads the latest config on the next operation, no restart needed. */ 'config:setProxy': { request: { proxy: Config['proxy'] }; response: void }; - /** 用给定代理配置试连一个外部地址,验证代理是否可用;不写配置。 */ + /** Test-connect to an external address with the given proxy config to validate whether the proxy works; does not write config. */ 'config:testProxy': { request: { proxy: Config['proxy'] }; response: { ok: boolean; reason?: string }; }; /** - * 写入连接列表 + 当前启用连接到 config.yaml,并**热重建** adapter/poller 即时生效 - * (无需重启)。active 那条被轮询,其余仅保留配置。 + * Write the connection list + currently active connection to config.yaml and **hot-rebuild** the adapter/poller to take effect immediately + * (no restart needed). The active one is polled, the rest only keep their config. */ 'config:setConnections': { request: { connections: Config['connections']; active_connection_id: string }; response: void; }; - /** 用草稿 url/token 临时起 adapter ping,保存前测试连接是否可达;不写配置。 */ + /** Temporarily start an adapter ping with draft url/token to test whether the connection is reachable before saving; does not write config. */ 'config:testConnection': { request: { base_url: string; token: string; kind?: PlatformKind }; response: PingResult; }; /** - * 写入本地 API 服务监听配置(开关 / host / port / token)到 config.yaml,并**热重建**监听器 - * (开关 / 地址 / 端口变更停旧起新;token 变更下次请求即生效)。见 docs/arch/04-integration/01-service-api.md。 + * Write local API service listen config (switch / host / port / token) to config.yaml and **hot-rebuild** the listener + * (switch / address / port changes stop the old and start new; token changes take effect on the next request). See docs/arch/04-integration/01-service-api.md. */ 'config:setService': { request: { service: Config['service'] }; response: void }; - /** 重新生成 bearer token 并写盘(旧 token 即时失效),返回新 token 供设置页展示 / 复制。 */ + /** Regenerate the bearer token and write to disk (the old token is invalidated immediately), returning the new token for the Settings page to display / copy. */ 'config:generateServiceToken': { request: void; response: { token: string } }; /** - * 配置过程中自动把连接 + LLM 草稿写入 config.yaml(防丢失),但**不应用到运行时** - * (不 reconfigure adapter/poller、不更新内存 config)——重启或点底栏「保存」才生效。 + * During configuration, automatically write the connection + LLM draft to config.yaml (to prevent loss), but **do not apply to the runtime** + * (no reconfigure of adapter/poller, no update of the in-memory config)—effective only on restart or clicking the bottom-bar "Save". */ 'config:autosaveDraft': { request: { diff --git a/packages/ipc/src/events.ts b/packages/ipc/src/events.ts index d2997114..a0ff45fa 100644 --- a/packages/ipc/src/events.ts +++ b/packages/ipc/src/events.ts @@ -8,16 +8,16 @@ import type { } from '@meebox/shared'; import type { PragentRunInfo } from './common.js'; -/** Poller tick 完成后广播给 renderer 用于更新"最近一次同步"显示。 */ +/** Broadcast to renderer after a poller tick completes, to update the "last sync" display. */ export interface PollTickEvent { - /** tick 完成时间 ISO */ + /** tick completion time, ISO */ at: string; result: PollResult; } /** - * pr-agent run 期间 stdout / stderr 整行流式推送。renderer 拿来在 ChatPane - * 或日志区域实时显示。一次 run 多条;run 结束后不再发。 + * Streams whole lines of stdout / stderr during a pr-agent run. Renderer uses it to display + * in real time in ChatPane or the log area. Many per run; not sent after the run ends. */ export interface PragentRunProgressEvent { runId: string; @@ -25,60 +25,61 @@ export interface PragentRunProgressEvent { stream: 'stdout' | 'stderr'; } -/** main → renderer 推送事件。renderer 用 window.api.subscribe 监听。 */ +/** main → renderer push events. Renderer subscribes via window.api.subscribe. */ export interface IpcEvents { 'sync:progress': SyncProgressEvent; 'poll:tick': PollTickEvent; 'pragent:runProgress': PragentRunProgressEvent; /** - * 草稿变更广播:某 PR 的 drafts.json 发生增/删/改 / /review 完成时的"再摄入" - * 清理都触发。renderer 据此重拉 drafts 列表 (per localId 过滤)。 + * Draft change broadcast: triggered when a PR's drafts.json is added/deleted/modified, or by the + * "re-ingest" cleanup on /review completion. Renderer refetches the drafts list (filtered per localId). */ 'drafts:changed': { localId: string }; - /** finding 关闭关系变更广播:复评 /ask 取代/撤销原 finding(或撤销关闭)后触发,renderer 重拉关闭关系。 */ + /** finding closure relation change broadcast: triggered after a re-review /ask supersedes/revokes the original finding (or revokes a closure); renderer refetches closure relations. */ 'findingClosures:changed': { localId: string }; - /** 评论 reply / 状态变更后广播,renderer 各组件 (CommentsPanel / DiffView inline) 重拉 */ + /** Broadcast after a comment reply / status change; renderer components (CommentsPanel / DiffView inline) refetch */ 'comments:changed': { localId: string }; /** - * 队列变化广播:active 增删 / waiting 增删都触发。renderer 据此同步 chat-pane - * 运行中 UI + StatusBar 队列 chip。`active` 是当前并发运行中的 run 列表 - * (长度 ≤ max_concurrency)。 + * Queue change broadcast: triggered by active add/remove or waiting add/remove. Renderer syncs the + * chat-pane running UI + StatusBar queue chip. `active` is the list of currently concurrent running runs + * (length ≤ max_concurrency). */ 'pragent:queueChanged': { active: PragentRunInfo[]; waiting: PragentRunInfo[]; }; - /** 启动检测到新版本时推送(仅 hasUpdate=true 时发),renderer 据此提示。 */ + /** Pushed when a new version is detected at startup (only sent when hasUpdate=true); renderer prompts accordingly. */ 'app:updateAvailable': UpdateCheckResult; - /** Agent 编排步骤流式推送:每产生一个 AgentStep 即发,renderer 据此实时呈现。 */ + /** Streams agent orchestration steps: sent whenever an AgentStep is produced; renderer renders it in real time. */ 'agent:stepProgress': { sessionId: string; prLocalId: string; step: AgentStep }; /** - * 某 PR 的多轮对话有新落盘消息(如后台 AutoPilot 评审收尾追加的「评审总结」)。renderer 若正打开 - * 该 PR 则据此重载会话,让后台产生的总结卡片即时出现(手动评审走 invoke 返回后自行重载,不依赖此事件)。 + * A PR's multi-turn conversation has a new persisted message (e.g. the "review summary" appended by a background + * AutoPilot review at completion). If renderer has that PR open it reloads the conversation, so the background-produced + * summary card appears immediately (manual review reloads itself after the invoke returns, not relying on this event). */ 'agent:conversationChanged': { prLocalId: string }; /** - * 规划 Agent 的「计划(todo)」更新时推送:每当模型给出 / 更新计划即发,renderer 据此实时刷新计划面板。 - * 计划随会话持久化(session.todo),切 PR / 重启后经 agent:getSession 水合。 + * Pushed when the planning agent's "plan (todo)" updates: sent whenever the model produces / updates a plan; renderer refreshes the plan panel in real time. + * The plan is persisted with the session (session.todo), hydrated via agent:getSession after switching PR / restart. */ 'agent:planUpdated': { prLocalId: string; todo: AgentTodoItem[] }; /** - * 运行中(思考或派发工具)的编排 Agent 所属 PR 集合变化时推送:手动 `agent:run` / `agent:ask` - * 与 AutoPilot 后台评审一并计入。renderer 据此在 PR 列表项显示「执行中」指示——覆盖**纯思考阶段** - * (无活跃工具 run 时),补齐仅看运行队列时思考态缺失执行中标记的空档。 + * Pushed when the set of PRs owned by running (thinking or dispatching tools) orchestration agents changes: manual `agent:run` / `agent:ask` + * and AutoPilot background reviews are both counted. Renderer shows a "running" indicator on PR list items—covering the **pure thinking phase** + * (when there is no active tool run), filling the gap where the thinking state lacks a running marker if only the run queue is watched. */ 'agent:runningChanged': { prLocalIds: string[] }; /** - * 某 PR 的评审状态被清除(清空执行历史时一并清掉 AutoPilot 台账)。renderer 据此即时清掉 PR 列表 - * 该 PR 的评审建议 ★ 徽标,避免清空后仍残留陈旧评审状态(不必等下个 poll 重取台账)。 + * A PR's review status is cleared (clearing execution history also clears the AutoPilot ledger). Renderer immediately clears + * that PR's review-suggestion ★ badge in the PR list, avoiding stale review status lingering after clearing (no need to wait for the next poll to refetch the ledger). */ 'agent:reviewStatusCleared': { prLocalId: string }; /** - * 用户点击系统通知后 main 推送的导航意图,renderer 据此选中目标 PR 并定位: - * - `anchor` 非空(inline 评论)→ 切 Diff 标签并跳到该文件行; - * - `anchor` 为 null 且 kind 为 mention/reply(summary 评论)→ 切「活动」对话标签; - * - kind 为 new_pr → 仅选中该 PR。 - * 目标不在当前活跃列表(已归档 / 已退场)时 renderer 忽略。 + * Navigation intent pushed by main after the user clicks a system notification; renderer selects and locates the target PR: + * - `anchor` non-null (inline comment) → switch to the Diff tab and jump to that file line; + * - `anchor` is null and kind is mention/reply (summary comment) → switch to the "activity" conversation tab; + * - kind is new_pr → just select that PR. + * When the target is not in the current active list (archived / retired) renderer ignores it. */ 'notification:activate': { localId: string; diff --git a/packages/ipc/src/index.ts b/packages/ipc/src/index.ts index 74e77fa1..4f100af0 100644 --- a/packages/ipc/src/index.ts +++ b/packages/ipc/src/index.ts @@ -14,7 +14,7 @@ export * from './agent.js'; /** * Typed IPC channel contract. * - * 按业务领域拆分维护(app / pr / config / agent),在此合并为单一映射。 + * Maintained split by business domain (app / pr / config / agent), merged here into a single map. * The preload bridge and main handlers both reference this map so that * Renderer ↔ Main calls stay end-to-end type-safe. */ @@ -27,6 +27,6 @@ export interface IpcBridge { channel: K, req: IpcChannels[K]['request'], ): Promise<IpcChannels[K]['response']>; - /** 订阅 main → renderer 推送事件,返回取消订阅函数。 */ + /** Subscribe to main → renderer push events; returns an unsubscribe function. */ subscribe<E extends IpcEventName>(event: E, handler: (data: IpcEvents[E]) => void): () => void; } diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 30a12ded..33c63896 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -12,47 +12,47 @@ import type { } from '@meebox/shared'; import type { DiffBlameLine, DiffChangedFile, DiffFileContent, DiffSide } from './common.js'; -/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿。 */ +/** PR operations domain: comments / list / status / merge / mirror / diff / drafts. */ export interface PrChannels { /** - * 拉评论 body 内嵌图片 (`![alt](url)`)。url 可能是 Bitbucket attachment 绝对/相对地址, - * 私有实例需要带 PAT 才能取 → renderer `<img>` 标签无法直接 fetch,必须走 main 代理。 - * 返回 data URL 给 renderer 拼到 `<img src>`;获取失败 (404 / 跨 host / 非图片) 返回 null + * Fetch an image embedded in a comment body (`![alt](url)`). The url may be a Bitbucket attachment absolute/relative address; + * a private instance requires a PAT to fetch → the renderer `<img>` tag cannot fetch it directly, so it must go through the main proxy. + * Returns a data URL for the renderer to put in `<img src>`; returns null on fetch failure (404 / cross-host / non-image) */ 'comments:fetchAttachment': { request: { localId: string; url: string }; response: { dataUrl: string } | null; }; /** - * 对已有评论发回复。提交成功后 main 端会刷新 comments cache + broadcast - * comments:changed 事件,renderer 各组件重新拉取列表自动展示新 reply + * Reply to an existing comment. After a successful submit, the main side refreshes the comments cache + broadcasts + * the comments:changed event; renderer components refetch the list and automatically show the new reply */ 'comments:reply': { request: { localId: string; parentCommentId: string; body: string }; response: PrComment; }; /** - * 在 PR 上发一条 summary(顶层、不锚到文件)评论。成功后 main 端清评论缓存 + 广播 - * comments:changed,活动 / 评论面板自动重拉,新评论出现在时间线顶部。 + * Post a summary (top-level, not anchored to a file) comment on the PR. On success the main side clears the comment cache + broadcasts + * comments:changed; the activity / comments panel refetches automatically, and the new comment appears at the top of the timeline. */ 'comments:create': { request: { localId: string; body: string }; response: PrComment; }; /** - * 删除自己作者的远端评论。Bitbucket 要求带 version (乐观锁),调用方从已有 PrComment - * 拿;不一致 / 评论已有回复 / 自己不是作者都会失败 (Bitbucket 409/403)。成功后 main - * 端清空评论缓存 + broadcast comments:changed,UI 自动重拉刷新 + * Delete a remote comment you authored. Bitbucket requires a version (optimistic lock), which the caller takes from an existing PrComment; + * mismatch / comment already has replies / not being the author all fail (Bitbucket 409/403). On success the main + * side clears the comment cache + broadcasts comments:changed, and the UI refetches to refresh automatically */ 'comments:delete': { request: { localId: string; commentId: string; version: number }; response: void; }; /** - * 编辑自己作者评论的 body。Bitbucket PUT 同样要 version (乐观锁) — 不一致回 409, - * 上层应提示"远端已更新,请刷新后重试"并拒绝静默覆盖。Bitbucket 允许编辑带 reply - * 的评论 (跟 delete 区别)。成功后 main 端清评论缓存 + 广播 - * comments:changed,UI 自动重拉显示新文本 + * Edit the body of a comment you authored. Bitbucket PUT also requires a version (optimistic lock) — a mismatch returns 409, + * and the upper layer should prompt "remote has updated, please refresh and retry" and refuse to overwrite silently. Bitbucket allows editing a comment + * that has replies (unlike delete). On success the main side clears the comment cache + broadcasts + * comments:changed, and the UI refetches automatically to show the new text */ 'comments:edit': { request: { @@ -64,9 +64,9 @@ export interface PrChannels { response: PrComment; }; /** - * 切换当前用户对一条评论的 emoji 反应(add=true 加 / false 取下)。emoji 为规范化 Unicode 字符 - * (见 shared REACTION_PICKER);kind 区分 summary / inline(GitHub 据此选反应端点)。成功后 main - * 端清评论缓存 + 广播 comments:changed,UI 重拉刷新反应条。仅 commentReactions 能力为真的平台暴露。 + * Toggle the current user's emoji reaction on a comment (add=true to add / false to remove). emoji is a normalized Unicode character + * (see shared REACTION_PICKER); kind distinguishes summary / inline (GitHub picks the reaction endpoint accordingly). On success the main + * side clears the comment cache + broadcasts comments:changed, and the UI refetches to refresh the reaction bar. Exposed only on platforms where the commentReactions capability is true. */ 'comments:toggleReaction': { request: { @@ -79,24 +79,24 @@ export interface PrChannels { response: void; }; /** - * 上传图片作为评论附件(粘贴 / 选取触发),返回可插入正文的 markdown 片段;不支持的平台 - * (GitHub)返回 null。bytes 走 ArrayBuffer 经 IPC 传输,main 端转 Uint8Array 交 adapter 上传。 - * 仅 commentAttachments 能力为真的平台暴露入口。 + * Upload an image as a comment attachment (triggered by paste / picker), returns a markdown snippet insertable into the body; unsupported platforms + * (GitHub) return null. bytes are transferred over IPC as an ArrayBuffer, which the main side converts to Uint8Array and hands to the adapter to upload. + * The entry point is exposed only on platforms where the commentAttachments capability is true. */ 'comments:uploadAttachment': { request: { localId: string; fileName: string; contentType: string; bytes: ArrayBuffer }; response: { markdown: string } | null; }; 'prs:list': { request: void; response: StoredPullRequest[] }; - /** 列出已归档(退场)PR:从冷存储读取,供「已关闭」视图浏览(只读)。 */ + /** List archived (retired) PRs: read from cold storage, for the "closed" view to browse (read-only). */ 'prs:listArchived': { request: void; response: StoredPullRequest[] }; /** - * 按 URL 打开当前平台的 PR:解析链接 → 若本地已存在(活跃 / 归档)直接定位;否则远端拉取(鉴权)后 - * 存入归档冷存储再定位。返回其 localId 与所在范围;解析失败 / 无权限 / 不存在抛 AppError 错误码。 + * Open the current platform's PR by URL: parse the link → if it already exists locally (active / archived) locate it directly; otherwise fetch from remote (authenticated), + * store into archive cold storage, then locate it. Returns its localId and location; throws an AppError error code on parse failure / no permission / not found. */ 'prs:openByUrl': { request: { url: string }; - /** discoveryFilters:活跃 PR 所属发现分类(前端据此落到能展示它的 tab);归档 PR 为空。 */ + /** discoveryFilters: the discovery categories an active PR belongs to (the frontend uses this to land on a tab that can show it); empty for archived PRs. */ response: { localId: string; location: 'active' | 'archived'; @@ -104,131 +104,131 @@ export interface PrChannels { }; }; 'prs:refresh': { request: void; response: PollResult }; - /** Poller 最近一次完成时间(ISO 或 null);启动时初始化用 */ + /** Poller's last completion time (ISO or null); used for initialization at startup */ 'prs:lastSync': { request: void; response: { at: string | null } }; 'prs:setLocalStatus': { request: { localId: string; status: LocalPrStatus }; response: StoredPullRequest | null; }; /** - * 标记 PR 为已读:推进已读水位(当前 head sha + 时间)并清未读标记。用户打开 PR 时调用。 - * 返回带 `unread:false` 的最新 PR(找不到返回 null)。下一轮 poll 不会因旧事件再把它标回未读。 + * Mark a PR as read: advance the read watermark (current head sha + time) and clear the unread flag. Called when the user opens the PR. + * Returns the latest PR with `unread:false` (returns null if not found). The next poll round will not mark it unread again due to old events. */ 'prs:markRead': { request: { localId: string }; response: StoredPullRequest | null; }; /** - * 合并 PR 到目标分支(仅对 canMerge=true 的 PR 暴露入口)。成功后远端 PR 转 - * MERGED,调用方应自行刷新列表(下一轮 poll 会软删该 PR)。失败抛错冒泡到 renderer。 + * Merge the PR into the target branch (entry point exposed only for PRs with canMerge=true). On success the remote PR turns + * MERGED, and the caller should refresh the list itself (the next poll round will soft-delete the PR). On failure it throws, bubbling up to the renderer. */ 'prs:merge': { request: { localId: string }; response: void; }; - /** 同步 PR 所属 repo 的本地镜像(必要时 clone,否则 fetch),返回镜像绝对路径 */ + /** Sync the local mirror of the PR's repo (clone if necessary, otherwise fetch), returns the mirror's absolute path */ 'repo:sync': { request: { localId: string }; response: { mirrorPath: string; freshClone: boolean }; }; /** - * 列出变更文件(自动先 sync mirror)。默认 PR baseSha → headSha 的全部变更; - * 传 base / head(如某 commit 的 `parent..sha`)则列该范围的变更,用于「查看特定 commit」。 + * List changed files (automatically syncs the mirror first). Defaults to all changes from PR baseSha → headSha; + * passing base / head (e.g. a commit's `parent..sha`) lists the changes in that range, used for "view a specific commit". */ 'diff:listChangedFiles': { request: { localId: string; base?: string; head?: string }; response: DiffChangedFile[]; }; /** - * 列出合并到目标分支会产生冲突的文件路径(PR 目标 tip ⟂ 源 head 的 `git merge-tree` 试合并)。 - * 仅 `pr.hasConflict` 为真时才实际跑 merge-tree,否则直接返回空数组(省一次本地试合并)。 - * 试合并失败 / 无法判定时返回空数组(保守不标冲突),文件树据此在对应行标三角警示图标。 + * List file paths that would conflict when merging into the target branch (a `git merge-tree` trial merge of PR target tip ⟂ source head). + * Actually runs merge-tree only when `pr.hasConflict` is true, otherwise returns an empty array directly (saving one local trial merge). + * Returns an empty array on trial-merge failure / when undecidable (conservatively not flagging conflict); the file tree uses this to mark a triangle warning icon on the corresponding row. */ 'diff:listConflictFiles': { request: { localId: string }; response: string[]; }; /** - * 读取 base 或 head 一侧某文件的内容(二进制返回 {binary:true})。默认取 PR base / head 一侧; - * 传 base / head sha 则按指定范围取(commit 视图:base=parent、head=commit)。 + * Read the content of a file on the base or head side (binary returns {binary:true}). Defaults to the PR base / head side; + * passing base / head sha reads by the specified range (commit view: base=parent, head=commit). */ 'diff:getFileContent': { request: { localId: string; side: DiffSide; path: string; base?: string; head?: string }; response: DiffFileContent; }; /** - * 拉取 PR 上的已有评论(inline + summary 都拉,renderer 自己分)。 + * Fetch existing comments on the PR (both inline + summary are fetched, renderer splits them itself). * - * 默认走 cache + pr_updated_at stale 比对:命中回缓存,stale/miss 拉远端。 - * 但本地 PR.updatedAt 来自 poller 周期性拉,可能滞后 — 远端新增评论后, - * 本地 updatedAt 不变 → cache 误判命中 → 不刷新。打开 PR 时 renderer 应该 - * 传 force=true 跳过 stale 比对强制远端拉一次,确保 badge 计数 / inline - * 评论是最新的 + * Defaults to cache + pr_updated_at stale comparison: on a hit return the cache, on stale/miss fetch remote. + * But local PR.updatedAt comes from the poller's periodic fetch and may lag — after a remote comment is added, + * local updatedAt stays unchanged → cache falsely hits → no refresh. When opening a PR the renderer should + * pass force=true to skip the stale comparison and force one remote fetch, ensuring the badge count / inline + * comments are up to date */ 'diff:listComments': { request: { localId: string; force?: boolean }; response: PrComment[]; }; /** - * 仅读评论缓存里的总条数 (inline + summary 顶层条目数;不展开 replies),**不** - * 打远端。UI 用于 tab 角标 "评论 (N)" 的懒展示:缓存有就直接显示,缓存空就不显示。 - * 用户切到 Comments 标签时触发 `diff:listComments` 拉远端 + 写缓存,下次进 PR - * 角标就有数字了。 + * Read only the total count from the comment cache (inline + summary top-level entry count; does not expand replies), **without** + * hitting remote. The UI uses it for lazy display of the tab badge "Comments (N)": if the cache has it, show directly; if the cache is empty, do not show. + * When the user switches to the Comments tab it triggers `diff:listComments` to fetch remote + write the cache, so the next time the PR is opened + * the badge has a number. */ 'diff:commentCountCached': { request: { localId: string }; response: { count: number } | null; }; - /** 拉取 PR 包含的 commits,newest first */ + /** Fetch the commits contained in the PR, newest first */ 'diff:listCommits': { request: { localId: string }; response: PrCommit[]; }; /** - * 拉取 PR 上的评审决断活动事件(approve / needs-work / unapprove / dismiss),带时间戳。 - * 活动时间线把它与评论 / 提交按时间归并。不缓存(量小,量级同 commits);平台取不到历史 - * 决断(如 GitLab CE 无审批)时返回 [],时间线只展示评论与提交。 + * Fetch review-verdict activity events on the PR (approve / needs-work / unapprove / dismiss), with timestamps. + * The activity timeline merges them with comments / commits by time. Not cached (small volume, on par with commits); when the platform cannot retrieve historical + * verdicts (e.g. GitLab CE has no approvals) it returns [], and the timeline shows only comments and commits. */ 'diff:listActivity': { request: { localId: string }; response: PrActivityEvent[]; }; /** - * 本地 git rev-list 算 PR 引入的 commit 数 (base..head)。完全走本地 bare 镜像, - * 不打远端;任一 sha 不在镜像 (尚未 sync 到本 PR 范围) → null。 - * UI 用于 Commits 标签页角标的懒展示,跟 diff:commentCountCached 同模式 + * Local git rev-list to count the commits the PR introduces (base..head). Entirely via the local bare mirror, + * no remote hit; if any sha is not in the mirror (not yet synced to this PR range) → null. + * The UI uses it for lazy display of the Commits tab badge, same pattern as diff:commentCountCached */ 'diff:commitCount': { request: { localId: string }; response: { count: number } | null; }; /** - * 给 head 侧文件跑 git blame;同时返回 PR 引入的 head 行号集合, - * renderer 能区分"未变更行(出 blame)"vs"PR 改动行(出色带占位)"。 + * Run git blame on the head-side file; also returns the set of head line numbers the PR introduces, + * so the renderer can distinguish "unchanged lines (show blame)" vs "PR-changed lines (show a color-band placeholder)". */ 'diff:getBlame': { request: { localId: string; path: string; base?: string; head?: string }; response: { - /** 仅未变更行的 blame(已过滤掉 PR 改动行) */ + /** Blame for unchanged lines only (PR-changed lines already filtered out) */ lines: DiffBlameLine[]; - /** PR 引入的 head 行号 (added / modified),用于 blame 列画色带占位 */ + /** Head line numbers the PR introduces (added / modified), used to draw the color-band placeholder in the blame column */ changedLines: number[]; }; }; - /** 计算本地所有 repo 镜像的总占用字节数(设置页用) */ + /** Compute the total bytes used by all local repo mirrors (for the settings page) */ 'repo:getTotalSize': { request: void; response: { totalBytes: number } }; /** - * 列出指定 PR 的全部草稿 (pending / edited / posted / rejected 都返回,UI 端按 - * status 过滤显示 / 折叠)。 + * List all drafts of the given PR (pending / edited / posted / rejected are all returned; the UI filters by + * status to display / collapse). */ 'drafts:list': { request: { localId: string }; response: ReviewDraft[]; }; /** - * 创建一条草稿。id / createdAt / updatedAt 由 main 端生成,调用方传业务字段即可。 - * 调用约定:origin='finding' 时必须传 source;origin='manual' 时不要传 source。 - * 成功后 main 端广播 `drafts:changed` 事件。 + * Create a draft. id / createdAt / updatedAt are generated by the main side; the caller only passes business fields. + * Call convention: when origin='finding' source must be passed; when origin='manual' do not pass source. + * On success the main side broadcasts the `drafts:changed` event. */ 'drafts:create': { request: { @@ -238,10 +238,10 @@ export interface PrChannels { response: ReviewDraft; }; /** - * 部分更新一条草稿。规则: - * - 编辑 body 且 status='pending' → 自动转 'edited' - * - 显式传 status (e.g., 'rejected') → 按传入值覆盖 - * - 找不到 draftId 返回 null (不抛错,UI 静默兜底) + * Partially update a draft. Rules: + * - editing body while status='pending' → automatically turns 'edited' + * - explicitly passing status (e.g., 'rejected') → overwrite with the passed value + * - draftId not found returns null (no throw, UI falls back silently) */ 'drafts:update': { request: { @@ -251,14 +251,14 @@ export interface PrChannels { }; response: ReviewDraft | null; }; - /** 删除一条草稿。删 posted 草稿是允许的 (只清本地,远端 comment 不动) */ + /** Delete a draft. Deleting a posted draft is allowed (only clears local, remote comment is untouched) */ 'drafts:delete': { request: { localId: string; draftId: string }; response: void; }; /** - * finding 关闭关系(复评 /ask「取代 / 撤销」原 finding 时建立)。独立于草稿,仅作用于 ChatPane - * finding 卡片的关闭态 + 与复评卡片互链。create/delete 后 main 广播 `findingClosures:changed`。 + * finding closure relation (established when a re-review /ask "supersedes / revokes" the original finding). Independent of drafts, it only affects the closed state + * of the ChatPane finding card + its cross-link with the re-review card. After create/delete the main side broadcasts `findingClosures:changed`. */ 'findingClosures:list': { request: { localId: string }; @@ -279,17 +279,17 @@ export interface PrChannels { response: void; }; /** - * 批量发布草稿到远端:每条 draft 经 adapter.publishInlineComment 发到 Bitbucket, - * 成功 → 本地 draft status='posted' + 写 posted_remote_id;失败 → 保持原 status - * 不变并把错误收集到 results 里。**单条失败不中断后续条目** —— 跟 Bitbucket web UI - * "Start review" 行为对齐 (那边也是逐条 POST,某条 400 不影响其它)。 + * Batch-publish drafts to remote: each draft is sent to Bitbucket via adapter.publishInlineComment, + * success → local draft status='posted' + write posted_remote_id; failure → keep the original status + * unchanged and collect the error into results. **A single failure does not interrupt subsequent items** —— aligned with the Bitbucket web UI + * "Start review" behavior (which also POSTs one by one, where one 400 does not affect others). * - * 一次性发完后 main 会: - * 1. 广播 `drafts:changed` —— DiffView / FindingCard 重拉草稿换 status chip - * 2. force-refresh Bitbucket PR 评论 (跳缓存) + 广播 `comments:changed`,让 CommentsPanel - * 立即看到自己刚发布的评论,不用等下一轮 poller + * After publishing all at once, main will: + * 1. broadcast `drafts:changed` —— DiffView / FindingCard refetch drafts and swap the status chip + * 2. force-refresh Bitbucket PR comments (skip cache) + broadcast `comments:changed`, so CommentsPanel + * immediately sees the comments it just published, without waiting for the next poller round * - * 调用方 (renderer modal) 据 results 显示 "成功 N 失败 M" + 错误明细 + * The caller (renderer modal) uses results to show "N succeeded M failed" + error details */ 'drafts:publishBatch': { request: { localId: string; draftIds: string[] }; @@ -297,9 +297,9 @@ export interface PrChannels { results: Array<{ draftId: string; ok: boolean; - /** 成功时填,跟落库的 draft.posted_remote_id 同值 */ + /** Filled on success, same value as the persisted draft.posted_remote_id */ postedRemoteId?: string; - /** 失败时填,人读错因 (Bitbucket REST 4xx body 经过 PlatformError 包装) */ + /** Filled on failure, human-readable error reason (Bitbucket REST 4xx body wrapped via PlatformError) */ error?: string; }>; }; diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index e626413c..6da177e1 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -7,28 +7,28 @@ import pino, { type Logger, type StreamEntry } from 'pino'; import pinoRoll from 'pino-roll'; export interface LoggerOptions { - /** 日志目录(绝对路径),通常是 AppPaths.logsDir */ + /** Log directory (absolute path), usually AppPaths.logsDir */ logsDir: string; - /** 默认级别:dev 走 debug,prod 走 info */ + /** Default level: debug in dev, info in prod */ level?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - /** 单文件按日期切分,并保留 N 份历史 */ + /** Split a single file by date and keep N historical copies */ retentionFiles?: number; - /** 是否同时输出到 stdout(dev 默认 true) */ + /** Whether to also output to stdout (defaults to true in dev) */ alsoStdout?: boolean; /** - * dev 控制台美化(logfmt 单行:`<ISO8601> LEVEL msg k=v k=v` + 上色)。**仅非打包态开**: - * 打包态控制台保持原始 JSON。由调用方按 `!app.isPackaged` 传入——logger 包不依赖 - * Electron,无法自行判断。文件流恒为 JSON,不受此开关影响。 + * dev console prettification (single-line logfmt: `<ISO8601> LEVEL msg k=v k=v` + coloring). **Enabled only when not packaged**: + * the packaged console keeps raw JSON. Passed in by the caller based on `!app.isPackaged`—the logger package does not depend on + * Electron and cannot decide on its own. The file stream is always JSON, unaffected by this switch. */ pretty?: boolean; } /** - * 创建一个根 logger。子模块用 `root.child({ scope: 'xxx' })` 派生。 + * Create a root logger. Submodules derive via `root.child({ scope: 'xxx' })`. * - * 结构:multistream + pinoRoll(file) + 控制台流(Windows 中日韩 locale 走 - * iconv-lite 转 UTF-8 → 系统代码页,否则 PowerShell/cmd 把 UTF-8 字节当 - * GBK/SJIS 渲染会出乱码)。文件流永远是 UTF-8 原文;只有控制台经过转码。 + * Structure: multistream + pinoRoll(file) + console stream (on Windows CJK locales, + * iconv-lite converts UTF-8 → system code page, otherwise PowerShell/cmd renders UTF-8 bytes as + * GBK/SJIS and produces mojibake). The file stream is always raw UTF-8; only the console is transcoded. */ export async function createLogger(opts: LoggerOptions): Promise<Logger> { const { @@ -39,9 +39,9 @@ export async function createLogger(opts: LoggerOptions): Promise<Logger> { pretty = false, } = opts; - // pino-roll 的默认导出是个工厂函数,返回 Promise<Writable>;用它做文件流, - // 不再用 transport.targets(transport 在 worker 进程跑,控制台转码 patch - // 不到 worker 的 stdout)。 + // pino-roll's default export is a factory function returning Promise<Writable>; use it for the file stream, + // no longer using transport.targets (transport runs in a worker process, and the console-transcoding patch + // cannot reach the worker's stdout). const fileStream = await pinoRoll({ file: path.join(logsDir, 'meebox.log'), frequency: 'daily', @@ -51,7 +51,7 @@ export async function createLogger(opts: LoggerOptions): Promise<Logger> { const streams: StreamEntry[] = [{ level, stream: fileStream }]; if (alsoStdout) { - // 控制台代码页只需在 stdout 开启时探一次(async,故在此 await)。 + // The console code page only needs probing once, when stdout is enabled (async, hence awaited here). const codePage = await detectWindowsConsoleCodePage(); streams.push({ level, stream: makeConsoleStream(pretty, codePage) }); } @@ -59,17 +59,17 @@ export async function createLogger(opts: LoggerOptions): Promise<Logger> { } /** - * 控制台流。底座是「直出 / Windows CJK 转码」的 raw 流(文件流恒 UTF-8 JSON,不受影响); - * pretty=true(dev)时叠加 logfmt 美化:把每条 JSON 记录格式化成**单行** - * `<ISO8601> LEVEL msg k=v k=v`(Go 风格 kv,对象/堆栈走 JSON.stringify 把换行转义掉, - * 保证一条日志一物理行,不再被 pino-pretty 的多行对象/堆栈渲染撑断)。 + * Console stream. The base is a raw stream ("passthrough / Windows CJK transcode") (the file stream is always UTF-8 JSON, unaffected); + * when pretty=true (dev) it layers on logfmt prettification: format each JSON record into a **single line** + * `<ISO8601> LEVEL msg k=v k=v` (Go-style kv, with objects/stacks passed through JSON.stringify to escape newlines, + * guaranteeing one log entry per physical line, no longer broken up by pino-pretty's multi-line object/stack rendering). */ function makeConsoleStream(pretty: boolean, codePage: number | null): Writable { const encoding = pickWindowsConsoleEncoding(pretty, codePage); const raw: Writable = encoding ? makeTranscodingWritable(encoding) : process.stdout; if (!pretty) return raw; - // pretty 即 dev 人读模式:默认上色。不卡 isTTY——electron-vite dev 下 main 的 stdout - // 是管道(isTTY=false),但承接的终端仍能渲染 ANSI;遵循 NO_COLOR 约定可显式关闭。 + // pretty means dev human-readable mode: colored by default. Not gated on isTTY—under electron-vite dev, main's stdout + // is a pipe (isTTY=false), but the receiving terminal can still render ANSI; honoring the NO_COLOR convention allows explicit disabling. const colorize = !process.env.NO_COLOR; return new Writable({ write(chunk: Buffer | string, _enc: string, cb: (err?: Error | null) => void) { @@ -91,8 +91,8 @@ const LEVEL_LABEL: Record<number, string> = { 50: 'ERROR', 60: 'FATAL', }; -// 各级别 ANSI SGR(ASCII,经 iconv 转码不受影响):trace 灰 / debug 青 / info 绿 / -// warn 黄 / error 粗红 / fatal 白字红底徽标——越严重越醒目。 +// Per-level ANSI SGR (ASCII, unaffected by iconv transcoding): trace gray / debug cyan / info green / +// warn yellow / error bold red / fatal white-on-red badge—the more severe, the more prominent. const LEVEL_COLOR: Record<number, string> = { 10: '90', 20: '36', @@ -101,11 +101,11 @@ const LEVEL_COLOR: Record<number, string> = { 50: '1;31', 60: '1;37;41', }; -// pino 内置/无需展示为 kv 的字段 +// Fields that are pino built-ins / need not be shown as kv const CORE_KEYS = new Set(['time', 'level', 'msg', 'pid', 'hostname', 'v']); /** - * 把一条 pino JSON 记录格式化成单行 logfmt 文本(尾带 \n)。无法解析的行原样透传。 + * Format a single pino JSON record into single-line logfmt text (with trailing \n). Unparseable lines pass through verbatim. */ function formatLogfmt(rawLine: string, colorize: boolean): string { const text = rawLine.replace(/\n+$/, ''); @@ -118,10 +118,10 @@ function formatLogfmt(rawLine: string, colorize: boolean): string { } const ts = typeof rec.time === 'number' ? isoLocal(rec.time) : ''; const levelNum = typeof rec.level === 'number' ? rec.level : 30; - // 等宽对齐(5 字符),多行之间级别列对齐、msg 起始位一致,扫读更顺 + // Monospace alignment (5 chars): across lines the level column aligns and msg starts at the same position, easier to scan const label = (LEVEL_LABEL[levelNum] ?? String(levelNum)).padEnd(5); - // 全部字段(含 msg)统一走 logfmt kv:msg 自带空格时 formatValue 会加引号, - // 与后续字段边界不再有歧义(对齐 Go slog 的 `msg="..."` 风格)。msg 排首位保持显眼。 + // All fields (including msg) go through logfmt kv uniformly: when msg contains spaces formatValue adds quotes, + // so its boundary with subsequent fields is no longer ambiguous (matching Go slog's `msg="..."` style). msg goes first to stay prominent. const fields: string[] = []; if (typeof rec.msg === 'string' && rec.msg) fields.push(`msg=${formatValue(rec.msg)}`); for (const k of Object.keys(rec)) { @@ -133,7 +133,7 @@ function formatLogfmt(rawLine: string, colorize: boolean): string { return `${color('90', ts)} ${levelStr}${fields.length ? ` ${fields.join(' ')}` : ''}\n`; } -/** logfmt 值:含空白 / = / " 的字符串与对象走 JSON.stringify(换行被转义 → 不破单行)。 */ +/** logfmt value: strings containing whitespace / = / " and objects go through JSON.stringify (newlines escaped → single line preserved). */ function formatValue(v: unknown): string { if (v === null || v === undefined) return String(v); if (typeof v === 'string') return /[\s="]/.test(v) ? JSON.stringify(v) : v; @@ -145,7 +145,7 @@ function formatValue(v: unknown): string { } } -/** 本地时区 ISO8601(带偏移 + 毫秒),如 2026-06-09T12:23:45.185+08:00。 */ +/** Local-timezone ISO8601 (with offset + milliseconds), e.g. 2026-06-09T12:23:45.185+08:00. */ function isoLocal(ms: number): string { const d = new Date(ms); const p = (n: number, w = 2): string => String(n).padStart(w, '0'); @@ -160,8 +160,8 @@ function isoLocal(ms: number): string { } /** - * Windows + CJK locale 的转码 stdout 流:pino/pino-pretty 输出的 UTF-8 文本按系统 - * 代码页编码后再写 process.stdout,避免 PowerShell/cmd 把 UTF-8 当 GBK/SJIS 渲染出乱码。 + * Transcoding stdout stream for Windows + CJK locales: the UTF-8 text output by pino/pino-pretty is encoded to the system + * code page before writing to process.stdout, avoiding mojibake from PowerShell/cmd rendering UTF-8 as GBK/SJIS. */ function makeTranscodingWritable(encoding: string): Writable { return new Writable({ @@ -178,32 +178,32 @@ function makeTranscodingWritable(encoding: string): Writable { } /** - * Windows 下挑选控制台转码目标编码。优先用启动时探测到的**真实活动代码页** - * (`codePage`,来自 detectWindowsConsoleCodePage): - * - 65001 (UTF-8) → 返回 null,直出 UTF-8 不转码(控制台本就是 UTF-8); - * - 936/950/932/949 (CJK) → 转码到对应代码页; - * - 其它已知 ASCII 兼容页 → 返回 null,直出(中文字节本就无法正确显示,转码无益); - * - 探测失败(null) → 回落到按 locale 启发式猜(zh→cp936 / ja→cp932 / ko→cp949)。 + * Pick the target console transcoding encoding on Windows. Prefers the **real active code page** probed at startup + * (`codePage`, from detectWindowsConsoleCodePage): + * - 65001 (UTF-8) → return null, pass through UTF-8 without transcoding (the console is already UTF-8); + * - 936/950/932/949 (CJK) → transcode to the corresponding code page; + * - other known ASCII-compatible pages → return null, pass through (Chinese bytes cannot display correctly anyway, transcoding is no help); + * - probe failure (null) → fall back to a locale-based heuristic guess (zh→cp936 / ja→cp932 / ko→cp949). * - * 探测真实代码页避免了纯 locale 猜的脆弱点:用户若已 `chcp 65001` 切到 UTF-8 控制台, - * 再转码成 GBK 反而把正确输出搞乱——此时探测会返回 65001 → 直出 UTF-8。 + * Probing the real code page avoids the fragility of pure locale guessing: if the user has already `chcp 65001` to a UTF-8 console, + * transcoding to GBK would instead garble the correct output—here the probe returns 65001 → pass through UTF-8. * - * TTY 判定:直挂终端(isTTY=true)固然要转码;但 dev(pretty=true)下 electron-vite - * 把 main 的 stdout 接成管道(isTTY=false),下游仍是 CJK 代码页的终端——此时同样必须转码, - * 否则 UTF-8 字节被当 GBK/SJIS 渲染出乱码。故 pretty 时不卡 isTTY(与上色路径一致); - * 仅打包态原始 JSON(pretty=false)保留 isTTY 守卫,让重定向到管道 / 文件的 JSON 维持 UTF-8。 + * TTY decision: a directly attached terminal (isTTY=true) of course needs transcoding; but in dev (pretty=true) electron-vite + * pipes main's stdout (isTTY=false), and the downstream is still a CJK code-page terminal—so transcoding is equally required here, + * otherwise UTF-8 bytes get rendered as GBK/SJIS mojibake. Hence pretty does not gate on isTTY (consistent with the coloring path); + * only packaged raw JSON (pretty=false) keeps the isTTY guard, so JSON redirected to a pipe / file stays UTF-8. * - * 不区分简繁体(CP950 vs CP936):locale 回落分支按 zh-TW/zh-HK 区分,探测分支按真实页号精确命中。 - * 探测/转码错了用户也能从 logs/ 文件里拿到原始 UTF-8。 + * No distinction between simplified and traditional (CP950 vs CP936): the locale fallback branch distinguishes by zh-TW/zh-HK, the probe branch hits the exact page number. + * Even if the probe/transcoding is wrong, the user can still get the raw UTF-8 from the logs/ file. */ function pickWindowsConsoleEncoding(pretty: boolean, codePage: number | null): string | null { if (process.platform !== 'win32') return null; if (!pretty && !process.stdout.isTTY) return null; - // 真实活动代码页优先。CP_MAP 命中即用;65001/其它 ASCII 兼容页落到 map 外 → 直出 UTF-8。 + // Real active code page takes priority. Use CP_MAP on a hit; 65001 / other ASCII-compatible pages fall outside the map → pass through UTF-8. if (codePage !== null) { return CP_MAP[codePage] ?? null; } - // 探测失败:按 locale 猜。 + // Probe failed: guess by locale. const locale = Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase(); if (locale.startsWith('zh-tw') || locale.startsWith('zh-hk')) return 'cp950'; if (locale.startsWith('zh')) return 'cp936'; @@ -212,7 +212,7 @@ function pickWindowsConsoleEncoding(pretty: boolean, codePage: number | null): s return null; } -/** 需要转码的 CJK 代码页号 → iconv-lite 编码名。65001 (UTF-8) 等不在表中者直出。 */ +/** CJK code page numbers needing transcoding → iconv-lite encoding names. Those not in the table, such as 65001 (UTF-8), pass through. */ const CP_MAP: Record<number, string> = { 936: 'cp936', 950: 'cp950', @@ -223,11 +223,11 @@ const CP_MAP: Record<number, string> = { const execFileAsync = promisify(execFile); /** - * 探测当前控制台的活动输出代码页(GetConsoleOutputCP 等价物)。跑 `chcp.com`(System32 下的 - * 真实可执行文件)解析其输出里的页号——`chcp` 文本如「活动代码页: 936」/「Active code page: 65001」, - * 仅数字部分恒为 ASCII,故按当前页号无关地用正则抽数字即可。 + * Probe the current console's active output code page (equivalent to GetConsoleOutputCP). Runs `chcp.com` (the real + * executable under System32) and parses the page number from its output—`chcp` text such as "活动代码页: 936" / "Active code page: 65001", + * where only the numeric part is always ASCII, so a regex extracting the digits works regardless of the current page. * - * 非 Windows、命令缺失 / 超时 / 解析失败一律返回 null,由调用方回落 locale 启发式。 + * Non-Windows, a missing command / timeout / parse failure all return null, and the caller falls back to the locale heuristic. */ async function detectWindowsConsoleCodePage(): Promise<number | null> { if (process.platform !== 'win32') return null; diff --git a/packages/platform-bitbucket-server/src/adapter.ts b/packages/platform-bitbucket-server/src/adapter.ts index a7c3ccfb..d01ecd94 100644 --- a/packages/platform-bitbucket-server/src/adapter.ts +++ b/packages/platform-bitbucket-server/src/adapter.ts @@ -8,8 +8,8 @@ import { BitbucketMediaService } from './features/media.js'; export type { BitbucketServerAdapterOptions } from './client.js'; /** - * Bitbucket Server 适配器:领域服务容器(connection / prs / comments / media),四个领域共享一份 - * 连接上下文(统一连接封装实例 + 当前用户缓存)。 + * Bitbucket Server platform adapter: domain service container (connection / prs / comments / media); + * the four domains share one connection context (unified connection wrapper instance + current-user cache). */ export class BitbucketServerAdapter implements PlatformAdapter { readonly kind = 'bitbucket-server' as const; diff --git a/packages/platform-bitbucket-server/src/client.ts b/packages/platform-bitbucket-server/src/client.ts index 97e3e811..cf8f4050 100644 --- a/packages/platform-bitbucket-server/src/client.ts +++ b/packages/platform-bitbucket-server/src/client.ts @@ -10,13 +10,13 @@ import { type PlatformTransport, } from '@meebox/platform-core'; -/** Bitbucket 连接配置 = 统一连接配置 + clone 协议(连接层自管的连接配置,非 HTTP 传输细节)。 */ +/** Bitbucket connection config = unified connection config + clone protocol (connection-layer config managed by the connection layer, not HTTP transport details). */ export interface BitbucketClientOptions extends PlatformConnectionConfig { - /** clone 协议:'pat'(默认)走 HTTPS + 用户名:PAT;'ssh' 走系统 ssh 配置 */ + /** clone protocol: 'pat' (default) uses HTTPS + username:PAT; 'ssh' uses the system ssh config */ cloneProtocol?: 'pat' | 'ssh'; } -/** 适配器构造选项与连接配置同形。 */ +/** Adapter constructor options have the same shape as the connection config. */ export type BitbucketServerAdapterOptions = BitbucketClientOptions; export type { FetchLike } from '@meebox/platform-core'; @@ -42,10 +42,11 @@ export class BitbucketClientError extends Error { } /** - * 极薄的 Bitbucket Server REST 客户端,实现 {@link PlatformTransport}:Bearer PAT 鉴权、start/limit - * 分页迭代器、HTTP 错误抛 BitbucketClientError。通用传输样板(超时 / URL 拼接 / 有效 fetch 解析)复用 - * `@meebox/platform-core` helper;Bitbucket 特有部分(start/limit 分页、avatar 路径二进制、附件协议 - * 解析)留在本类。业务语义留给 BitbucketServerAdapter。 + * Ultra-thin Bitbucket Server REST client implementing {@link PlatformTransport}: Bearer PAT auth, + * start/limit pagination iterator, throws BitbucketClientError on HTTP errors. Generic transport + * boilerplate (timeout / URL joining / effective fetch resolution) reuses `@meebox/platform-core` + * helpers; Bitbucket-specific parts (start/limit pagination, avatar path binary, attachment protocol + * parsing) stay in this class. Business semantics are left to BitbucketServerAdapter. */ export class BitbucketClient implements PlatformTransport { private readonly baseUrl: string; @@ -57,24 +58,25 @@ export class BitbucketClient implements PlatformTransport { constructor(opts: BitbucketClientOptions) { this.baseUrl = stripTrailingSlash(opts.baseUrl); this.token = opts.token; - // 连接层统一解析有效 fetch(显式 fetch 覆盖 > 代理 > 直连)。 + // Connection layer uniformly resolves the effective fetch (explicit fetch override > proxy > direct). this.fetchFn = resolveConnectionFetch(opts); this.timeoutMs = opts.timeoutMs ?? 30_000; this.cloneProtocol = opts.cloneProtocol ?? 'pat'; } - /** 实例 web/git base(Bitbucket 的 API 与网页同 host),commit 详情页 URL 等用。 */ + /** Instance web/git base (Bitbucket's API and web share the same host); used for commit detail page URLs etc. */ get webBase(): string { return this.baseUrl; } /** - * 构造 git clone URL。 + * Construct the git clone URL. * - * ssh → `git@<host>:<proj>/<repo>.git`(端口 / 私钥 / username 交系统 ssh config,Bitbucket - * 默认 SSH 端口 7999 需自行配 Port)。pat → `https://<currentUser>:<PAT>@<host>/scm/<proj>/<repo>.git` - * (Bitbucket Server 的 PAT 鉴权要求真实用户名作 username,PAT 作 password;需 ping() 已落地当前 - * 用户,由调用方经连接上下文传入,否则抛错)。 + * ssh → `git@<host>:<proj>/<repo>.git` (port / private key / username handled by the system ssh config; + * Bitbucket's default SSH port 7999 requires configuring Port yourself). pat → + * `https://<currentUser>:<PAT>@<host>/scm/<proj>/<repo>.git` (Bitbucket Server's PAT auth requires the + * real username as username and the PAT as password; requires ping() to have already resolved the current + * user, passed in by the caller via the connection context, otherwise throws). */ getCloneUrl(repo: RepoRef, currentUserName?: string): string { const u = new URL(this.baseUrl); @@ -130,9 +132,9 @@ export class BitbucketClient implements PlatformTransport { } /** - * 拉二进制资源(avatar.png 等)。非 2xx 时抛 BitbucketClientError 携带 status / 简短 - * body,方便调用方区分 404(用户无头像)vs 401(鉴权失败)vs 其他。content-type - * 透传,方便 renderer 拼 data URL。 + * Fetch a binary resource (avatar.png etc.). On non-2xx, throws BitbucketClientError carrying status / + * short body, so the caller can distinguish 404 (user has no avatar) vs 401 (auth failure) vs other. + * content-type is passed through so the renderer can build a data URL. */ async getBinary(path: string, params?: Record<string, string>): Promise<BinaryResource> { const url = buildUrl(this.baseUrl, path, params); @@ -143,7 +145,7 @@ export class BitbucketClient implements PlatformTransport { this.timeoutMs, ); if (!res.ok) { - // 错误响应通常很短(HTML / JSON),尽量带 200 字便于诊断 + // Error responses are usually short (HTML / JSON); include up to 200 chars to aid diagnosis let body = ''; try { body = (await res.text()).slice(0, 200); @@ -164,13 +166,13 @@ export class BitbucketClient implements PlatformTransport { } /** - * 拉评论 attachment 图片:处理三种 url 形态 - * - `attachment:HASH` (Bitbucket markdown 内部协议) → 用 repo 拼成 + * Fetch a comment attachment image: handles three url shapes + * - `attachment:HASH` (Bitbucket markdown internal protocol) → join with repo into * `<baseUrl>/projects/<key>/repos/<slug>/attachments/<HASH>` - * - 绝对 url (http/https) → 校验 host 跟 baseUrl 一致才走代理 - * - 相对 url → 拼 baseUrl - * 跨 host 公网图 / 协议无法解析 / 失败 / 非 2xx → 返回 null 让上层 fallback。 - * 所有 Bitbucket-specific 解析逻辑都在 client 内部完成,adapter 不暴露细节 + * - absolute url (http/https) → validate host matches baseUrl before proxying + * - relative url → join with baseUrl + * Cross-host public image / protocol unparseable / failure / non-2xx → return null so the caller can fall back. + * All Bitbucket-specific parsing logic is done inside the client; the adapter does not expose the details */ async getAttachmentBinary( url: string, @@ -180,11 +182,11 @@ export class BitbucketClient implements PlatformTransport { try { const myHost = new URL(this.baseUrl).host; if (url.startsWith('attachment:')) { - // Bitbucket markdown 附件协议 `attachment:<repoId>/<attachmentId>` (e.g., - // `attachment:9/16854`)。Bitbucket 实际 attachment endpoint: + // Bitbucket markdown attachment protocol `attachment:<repoId>/<attachmentId>` (e.g., + // `attachment:9/16854`). Bitbucket's actual attachment endpoint: // /rest/api/1.0/projects/<key>/repos/<slug>/attachments/<attachmentId> - // (从 Bitbucket Web UI <img src> 反推;用 1.0 而非 latest,无 /contents 后缀) - // 末段才是 attachmentId,前缀 repoId 用 repo ref 取代 + // (reverse-engineered from Bitbucket Web UI <img src>; uses 1.0 not latest, no /contents suffix) + // Only the last segment is the attachmentId; the repoId prefix is replaced by the repo ref if (!repo) return null; const hash = url.slice('attachment:'.length).trim(); if (!hash) return null; @@ -195,7 +197,7 @@ export class BitbucketClient implements PlatformTransport { if (parsed.host !== myHost) return null; absoluteUrl = url; } else { - // 相对路径 (e.g., /projects/.../attachments/xxx) 拼当前 baseUrl + // Relative path (e.g., /projects/.../attachments/xxx) joined with the current baseUrl absoluteUrl = new URL(url, `https://${myHost}`).toString(); } } catch (e) { @@ -216,9 +218,10 @@ export class BitbucketClient implements PlatformTransport { return null; } if (!res.ok) { - // 非 2xx 不再静默吞掉(原先直接 return null,无任何线索)。记 status / 是否重定向 / - // 重定向后最终 URL / content-type —— 跨源重定向会丢 Authorization 头(fetch 规范)、 - // PAT 未被认可常返回 200 登录页等失败模式,都靠这行定位。 + // No longer silently swallow non-2xx (previously just returned null with no clue). Log status / + // whether redirected / final URL after redirect / content-type — cross-origin redirects drop the + // Authorization header (fetch spec), and an unrecognized PAT often returns a 200 login page; this + // line pinpoints those failure modes. console.warn( `[bb-attachment] 取附件失败 src=${url} url=${absoluteUrl} status=${String(res.status)} redirected=${String(res.redirected)} finalUrl=${res.url} contentType=${res.headers.get('content-type') ?? '(none)'}`, ); @@ -232,8 +235,8 @@ export class BitbucketClient implements PlatformTransport { } /** - * 同 get,但同时返回响应头。Bitbucket 的 `X-AUSERNAME` / `X-AUSERID` 在每个鉴权 - * 请求的响应头里,是 ping 时拿当前用户的可靠路径。 + * Same as get, but also returns the response headers. Bitbucket's `X-AUSERNAME` / `X-AUSERID` are in + * the response headers of every authenticated request, a reliable path for getting the current user during ping. */ async getWithHeaders<T>( path: string, @@ -247,8 +250,8 @@ export class BitbucketClient implements PlatformTransport { } /** - * 带 JSON body 的 POST。Bitbucket 评论 reply / 新建评论用 POST /comments。错误同 PUT - * 抛 BitbucketClientError 附 status + body + * POST with a JSON body. Bitbucket comment reply / new comment use POST /comments. On error, like PUT, + * throws BitbucketClientError with status + body */ async post<T>(path: string, body: unknown): Promise<T> { const res = await this.raw('POST', buildUrl(this.baseUrl, path), body); @@ -257,12 +260,14 @@ export class BitbucketClient implements PlatformTransport { } /** - * multipart/form-data POST(附件上传用)。不手动设 Content-Type(交给 fetch 按 FormData 加 boundary); - * 附件上传须带 `X-Atlassian-Token: no-check` 绕过 XSRF 校验(Atlassian 文件上传端点通用要求)。 + * multipart/form-data POST (for attachment upload). Do not set Content-Type manually (let fetch add the + * boundary from FormData); attachment upload must carry `X-Atlassian-Token: no-check` to bypass XSRF + * validation (a general requirement of Atlassian file-upload endpoints). * - * Accept 必须用通配(星/斜杠/星),**不能**显式写 `application/json`:附件 servlet(nginx 前置)对该 - * 端点做内容协商,显式请求 `application/json` 会被判 405(`Allow: OPTIONS`)——尽管它本就以 JSON 应答。 - * 响应仍按 JSON 解析。(curl 默认用通配 Accept 故能成功,曾掩盖此坑。) + * Accept must use the wildcard (star/slash/star), **not** an explicit `application/json`: the attachment + * servlet (behind nginx) does content negotiation on this endpoint, and an explicit `application/json` + * request gets a 405 (`Allow: OPTIONS`) — even though it responds with JSON anyway. The response is still + * parsed as JSON. (curl uses a wildcard Accept by default and thus succeeds, which once masked this pitfall.) */ async postForm<T>(path: string, form: FormData): Promise<T> { const url = buildUrl(this.baseUrl, path); @@ -285,9 +290,10 @@ export class BitbucketClient implements PlatformTransport { } /** - * 带 JSON body 的 PUT。Bitbucket 的 PR 参与者 status 用 PUT participants/{slug} 写入, - * 404 / 401 / 409 等错误抛 BitbucketClientError 并附 status + body,调用方决定降级或抛出。 - * 响应体 JSON 解析失败时返回 null(部分端点返回 204 No Content)。 + * PUT with a JSON body. Bitbucket's PR participant status is written via PUT participants/{slug}; + * errors like 404 / 401 / 409 throw BitbucketClientError with status + body, and the caller decides + * whether to degrade or rethrow. Returns null when the response body fails JSON parsing (some endpoints + * return 204 No Content). */ async put<T>(path: string, body: unknown): Promise<T | null> { const res = await this.raw('PUT', buildUrl(this.baseUrl, path), body); @@ -301,9 +307,9 @@ export class BitbucketClient implements PlatformTransport { } /** - * 无 body 的 DELETE。Bitbucket 删评论 / 删 reviewer 等 mutations 用。query 通过 path - * 直接拼 (e.g., `?version=3`),跟 GET 一致。错误抛 BitbucketClientError;成功 (204) - * 直接 return,不需要响应体 + * DELETE without a body. Used for Bitbucket mutations like deleting a comment / removing a reviewer. + * Query is joined directly onto the path (e.g., `?version=3`), consistent with GET. On error throws + * BitbucketClientError; on success (204) just returns, no response body needed */ async del(path: string): Promise<void> { const res = await this.raw('DELETE', buildUrl(this.baseUrl, path)); @@ -311,8 +317,8 @@ export class BitbucketClient implements PlatformTransport { } /** - * Bitbucket Server 标准分页:start / limit / isLastPage / nextPageStart。 - * 默认 limit=50;遍历直至 isLastPage。 + * Bitbucket Server standard pagination: start / limit / isLastPage / nextPageStart. + * Default limit=50; iterates until isLastPage. */ async *paginate<T>( path: string, diff --git a/packages/platform-bitbucket-server/src/features/comment.ts b/packages/platform-bitbucket-server/src/features/comment.ts index 75eb0a70..70f3058e 100644 --- a/packages/platform-bitbucket-server/src/features/comment.ts +++ b/packages/platform-bitbucket-server/src/features/comment.ts @@ -16,13 +16,15 @@ import type { BitbucketReactionProperty, } from '../types.js'; -// emoji ↔ Bitbucket emoticon shortcut(= gemoji shortcode)经共享 gemoji 词表换算:写入(toggle)用 -// emojiToReactionCode;读取展示优先从 twemoji url 码点解(emojiFromTwemojiUrl),shortcut 经 -// reactionCodeToEmoji 回退。实测确认形如 `eyes` 的 shortcode 可用(见 docs/arch/01-platform/04-comment-interactions)。 +// emoji ↔ Bitbucket emoticon shortcut (= gemoji shortcode) converted via the shared gemoji table: writing +// (toggle) uses emojiToReactionCode; for read/display, prefer decoding code points from the twemoji url +// (emojiFromTwemojiUrl), falling back to shortcut via reactionCodeToEmoji. Confirmed in practice that a +// shortcode like `eyes` works (see docs/arch/01-platform/04-comment-interactions). /** - * 从 Bitbucket emoticon 的 twemoji 资源 URL 解出 emoji 字符:文件名是 Unicode 码点(连字符分隔多码点, - * 如 `1f440.svg` → 👀、`2764-fe0f.svg` → ❤️)。解析不出返回 undefined(调用方回退 shortcut 映射)。 + * Decode the emoji character from a Bitbucket emoticon's twemoji resource URL: the filename is Unicode + * code points (hyphen-separated for multiple code points, e.g. `1f440.svg` → 👀, `2764-fe0f.svg` → ❤️). + * Returns undefined when it cannot be parsed (caller falls back to the shortcut mapping). */ function emojiFromTwemojiUrl(url: string | undefined): string | undefined { if (!url) return undefined; @@ -35,7 +37,7 @@ function emojiFromTwemojiUrl(url: string | undefined): string | undefined { } } -/** Bitbucket 评论领域:经 /activities 流归一评论树,发布 / 回复 / 删改走 comments 端点(带乐观锁)。 */ +/** Bitbucket comment domain: normalize the comment tree via the /activities stream; publish / reply / edit-delete go through the comments endpoint (with optimistic lock). */ export class BitbucketCommentService extends BaseCommentService { constructor( ctx: ConnectionContext, @@ -45,8 +47,9 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 经 /activities 流取全部评论:过滤 COMMENTED + ADDED 的顶层评论(跳过 DELETED/UPDATED 派生事件与 - * 带 parent 的 reply),按 id 去重,reply 跟随父评论的 .comments 一并归一。 + * Get all comments via the /activities stream: filter top-level comments that are COMMENTED + ADDED + * (skip DELETED/UPDATED derived events and replies with a parent), dedupe by id, and normalize replies + * along with their parent comment's .comments. */ async listPullRequestComments(repo: RepoRef, prId: string): Promise<PrComment[]> { const seen = new Set<string>(); @@ -68,7 +71,7 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 发表 summary 评论(仅 text,不带 anchor / parent)。 + * Post a summary comment (text only, no anchor / parent). */ async publishSummaryComment(repo: RepoRef, prId: string, body: string): Promise<PrComment> { const created = await this.client.post<BitbucketComment>( @@ -79,10 +82,11 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 发表 inline 评论:把中性锚点翻成 Bitbucket anchor 提交。 + * Post an inline comment: translate the neutral anchor into a Bitbucket anchor for submission. * - * anchor 的 line + lineType + fileType 三元组须与该行在 diff 里的真实角色一致,否则 Bitbucket 回 - * 400;diffType=EFFECTIVE 让评论锚到「当前生效 diff」,PR 后续 push 仍跟随行走。 + * The anchor's line + lineType + fileType triple must match the line's real role in the diff, otherwise + * Bitbucket returns 400; diffType=EFFECTIVE anchors the comment to the "currently effective diff", and it + * still follows the line across subsequent PR pushes. */ async publishInlineComment( repo: RepoRef, @@ -98,7 +102,7 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 回复评论:POST comments,body 带 parent.id;不带 anchor(reply 继承父评论锚点)。 + * Reply to a comment: POST comments with parent.id in the body; no anchor (a reply inherits the parent comment's anchor). */ async replyToComment( repo: RepoRef, @@ -114,9 +118,9 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 编辑评论 body:PUT comments/{cid},payload {text, version}(version 乐观锁,不一致回 409)。 + * Edit a comment body: PUT comments/{cid}, payload {text, version} (version is an optimistic lock; a mismatch returns 409). * - * 正常返回更新后的评论(version+1);上游异常回 204 时抛错(无法确认更新)。 + * Normally returns the updated comment (version+1); throws when upstream anomalously returns 204 (cannot confirm the update). */ async editComment( repo: RepoRef, @@ -138,7 +142,7 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 删除评论:DELETE comments/{cid}?version={v}(version 乐观锁必填,不一致 / 有 reply / 非作者回 409/403)。 + * Delete a comment: DELETE comments/{cid}?version={v} (version optimistic lock is required; mismatch / has replies / not the author returns 409/403). */ async deleteComment( repo: RepoRef, @@ -152,8 +156,8 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 切换当前用户对评论的 emoji 反应(comment-likes 插件):add=PUT、remove=DELETE 同一 reactions 端点。 - * 端点幂等(重复 PUT / 不存在时 DELETE 均 200),故无需先查状态。 + * Toggle the current user's emoji reaction on a comment (comment-likes plugin): add=PUT, remove=DELETE on the same reactions endpoint. + * The endpoint is idempotent (repeated PUT / DELETE when nonexistent both return 200), so there is no need to query state first. */ override async toggleReaction( repo: RepoRef, @@ -170,12 +174,12 @@ export class BitbucketCommentService extends BaseCommentService { else await this.client.del(url); } - // ---- 映射(领域私有)---- + // ---- mapping (domain-private) ---- /** - * Bitbucket 评论 → 中性 PrComment(递归归一 reply)。 + * Bitbucket comment → neutral PrComment (recursively normalize replies). * - * 透传 Bitbucket 乐观锁 version(删改时调用方须带回,否则 409);anchor 为空则为 summary 评论。 + * Passes through the Bitbucket optimistic-lock version (the caller must carry it back on edit/delete, otherwise 409); an empty anchor means a summary comment. */ private mapBitbucketComment(c: BitbucketComment, anchor?: BitbucketCommentAnchor): PrComment { return { @@ -192,11 +196,12 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * Bitbucket `properties.reactions` → 中性 PrReaction[](形状按真实实例核定)。 + * Bitbucket `properties.reactions` → neutral PrReaction[] (shape verified against a real instance). * - * 展示 emoji 优先从 `emoticon.url` 的 twemoji 文件名解码点(如 `1f440.svg` → 👀,对任意 emoji 都成立), - * 回退 shortcut 名映射;都得不到则跳过。`mine` 按 `users[]` 是否含当前用户(slug / name 任一匹配); - * 计数取 `users.length`(Bitbucket 不返回 count 字段)。 + * Display emoji is preferably decoded from the code points of `emoticon.url`'s twemoji filename (e.g. + * `1f440.svg` → 👀, valid for any emoji), falling back to the shortcut name mapping; skip if neither works. + * `mine` is based on whether `users[]` includes the current user (matching either slug or name); the count + * is taken from `users.length` (Bitbucket does not return a count field). */ private mapReactions(reactions: BitbucketReactionProperty[] | undefined): PrReaction[] { if (!reactions || reactions.length === 0) return []; @@ -214,10 +219,11 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * Bitbucket 评论 anchor → 中性锚点。 + * Bitbucket comment anchor → neutral anchor. * - * 无行号 = 文件级 / 孤儿 anchor,无法锚到具体行 → 返回 null(退化为 summary);lineType 偶有缺省时 - * 兜底 'context'(最保守值,与发布 anchor 的兜底一致)。 + * No line number = file-level / orphan anchor, cannot anchor to a specific line → return null (degrade to + * summary); when lineType is occasionally absent, fall back to 'context' (the most conservative value, + * consistent with the publish-anchor fallback). */ private mapBitbucketAnchor(a: BitbucketCommentAnchor): PrCommentAnchor | null { if (a.line == null) return null; @@ -230,9 +236,9 @@ export class BitbucketCommentService extends BaseCommentService { } /** - * 中性锚点 → Bitbucket REST anchor 字段(发布 inline 评论用,mapBitbucketAnchor 的反方向)。 + * Neutral anchor → Bitbucket REST anchor fields (for publishing inline comments, the reverse of mapBitbucketAnchor). * - * diffType 显式给 'EFFECTIVE',让评论锚到「当前生效 diff」而非某次具体 commit,PR 后续 push 仍跟随。 + * diffType is explicitly set to 'EFFECTIVE', anchoring the comment to the "currently effective diff" rather than a specific commit, so it still follows across subsequent PR pushes. */ private toBBAnchor(a: PrCommentAnchor): BitbucketCommentAnchor { return { diff --git a/packages/platform-bitbucket-server/src/features/connection.ts b/packages/platform-bitbucket-server/src/features/connection.ts index ca7e617a..2b803b1a 100644 --- a/packages/platform-bitbucket-server/src/features/connection.ts +++ b/packages/platform-bitbucket-server/src/features/connection.ts @@ -9,11 +9,11 @@ import { BaseConnection, type ConnectionContext } from '@meebox/platform-core'; import type { BitbucketClient } from '../client.js'; import type { BitbucketApplicationProperties, BitbucketUser } from '../types.js'; -/** 支持的 Bitbucket Server 最低版本(multilineMarker 等关键能力 7.0 起)。 */ +/** Minimum supported Bitbucket Server version (key capabilities like multilineMarker start at 7.0). */ const MIN_VERSION: readonly [number, number, number] = [7, 0, 0]; /** - * Bitbucket Server 连接领域:能力声明、连接探测(版本下限 + 当前用户)、PAT/SSH clone URL。 + * Bitbucket Server connection domain: capability declaration, connection probe (version floor + current user), PAT/SSH clone URL. */ export class BitbucketServerConnection extends BaseConnection { readonly kind = 'bitbucket-server' as const; @@ -26,10 +26,10 @@ export class BitbucketServerConnection extends BaseConnection { } /** - * Bitbucket Server 能力:三态审批、行内多行评论、删改乐观锁、否决项逐条(/merge vetoes)。 + * Bitbucket Server capabilities: tri-state approval, inline multiline comments, edit/delete optimistic lock, per-item vetoes (/merge vetoes). * - * 无「解决线程 / 代码 suggestion / pending-review 成组」概念;dashboard 发现不强限流; - * dashboard 支持 role=REVIEWER/AUTHOR → 提供「待我评审 / 我创建的」两类发现。 + * No concept of "resolvable thread / code suggestion / pending-review grouping"; dashboard discovery is not hard rate-limited; + * dashboard supports role=REVIEWER/AUTHOR → provides two discovery kinds: "review requested / created by me". */ capabilities(): PlatformCapabilities { return { @@ -37,7 +37,7 @@ export class BitbucketServerConnection extends BaseConnection { inlineComments: true, inlineMultiline: true, commentOptimisticLock: true, - // 评论 emoji 反应自 7.x 起(最低支持版即 7.0);emoticon 支持任意 emoji → free。 + // Comment emoji reactions since 7.x (minimum supported version is 7.0); emoticon supports any emoji → free. commentReactions: 'free', commentAttachments: true, commentHardBreaks: true, @@ -48,17 +48,17 @@ export class BitbucketServerConnection extends BaseConnection { suggestions: false, reviewGrouping: false, activityTimeline: true, - // properties.commentCount 仅数顶层评论、updatedDate 也不随评论跳变 → 无「含回复」信号,poller 兜底每轮扫待处理 PR。 + // properties.commentCount only counts top-level comments, and updatedDate does not change with comments → no "includes replies" signal, so the poller falls back to scanning pending PRs every round. commentCountIncludesReplies: false, }; } /** - * 连接探测:读 application-properties 取版本,从响应头 X-AUSERNAME 取当前用户 slug 再查 - * displayName 落地缓存。 + * Connection probe: read application-properties for the version, get the current user slug from the + * X-AUSERNAME response header, then query displayName and cache it. * - * 版本低于硬下限({@link MIN_VERSION})时 ok=false 并给出 reason;/users/{slug} 失败时退而用 - * slug 充当 displayName。 + * When the version is below the hard floor ({@link MIN_VERSION}), ok=false with a reason; when /users/{slug} + * fails, fall back to using the slug as displayName. */ async ping(): Promise<PingResult> { const { body: props, headers } = @@ -66,7 +66,7 @@ export class BitbucketServerConnection extends BaseConnection { '/rest/api/1.0/application-properties', ); - // 当前用户从响应头 X-AUSERNAME (slug) 拿,再查 /users/{slug} 拿 displayName + // Get the current user from the X-AUSERNAME (slug) response header, then query /users/{slug} for displayName const slug = headers.get('x-ausername'); if (slug) { try { @@ -75,7 +75,7 @@ export class BitbucketServerConnection extends BaseConnection { ); this.setCurrentUser({ name: u.name, displayName: u.displayName, slug: u.slug }); } catch { - // /users/{slug} 失败时退而求其次,slug 当 displayName + // When /users/{slug} fails, fall back to using the slug as displayName this.setCurrentUser({ name: slug, displayName: slug, slug }); } } @@ -89,7 +89,7 @@ export class BitbucketServerConnection extends BaseConnection { ok: false, serverVersion: props.version, user, - // 后台不拼面向用户的本地化文案:以错误码 + meta 承载,前端按码 i18n(errors.ECF0001)。 + // The backend does not assemble user-facing localized text: carry it as an error code + meta, and the frontend does i18n by code (errors.ECF0001). reason: errorCodeMessage(ERROR_CODES.CF_UNSUPPORTED_VERSION, { version: props.version, min: MIN_VERSION.join('.'), @@ -98,16 +98,16 @@ export class BitbucketServerConnection extends BaseConnection { } /** - * 构造仓库的 git clone URL(PAT 内嵌当前用户名 / ssh scp-like,按连接 clone 协议切分)。 + * Construct the repository's git clone URL (PAT embeds the current username / ssh scp-like, split by the connection's clone protocol). */ async getCloneUrl(repo: RepoRef): Promise<string> { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); } /** - * 比较版本号:逐段数值比较 `actual` 与最低要求,返回正/零/负表示 ≥ / = / <。 + * Compare version numbers: segment-by-segment numeric comparison of `actual` against the minimum requirement, returning positive/zero/negative for ≥ / = / <. * - * 非数字段按 0 处理,容错形如 `7.21.0-build` 的尾缀。 + * Non-numeric segments are treated as 0, tolerating suffixes like `7.21.0-build`. */ private compareVersion(actual: string, min: readonly [number, number, number]): number { const parts = actual.split('.').map((s) => Number.parseInt(s, 10)); diff --git a/packages/platform-bitbucket-server/src/features/media.ts b/packages/platform-bitbucket-server/src/features/media.ts index f4be91fd..4597bae7 100644 --- a/packages/platform-bitbucket-server/src/features/media.ts +++ b/packages/platform-bitbucket-server/src/features/media.ts @@ -7,7 +7,7 @@ import { import type { BitbucketClient } from '../client.js'; import type { BitbucketAttachmentUploadResponse } from '../types.js'; -/** Bitbucket 用户与媒体领域:头像(avatar.png 路径端点)与评论内嵌附件(attachment 协议解析)。 */ +/** Bitbucket user and media domain: avatars (avatar.png path endpoint) and comment inline attachments (attachment protocol parsing). */ export class BitbucketMediaService extends BaseMediaService { constructor( ctx: ConnectionContext, @@ -17,10 +17,11 @@ export class BitbucketMediaService extends BaseMediaService { } /** - * 拉用户头像(`/users/{slug}/avatar.png?s=64`)。 + * Fetch a user avatar (`/users/{slug}/avatar.png?s=64`). * - * Bitbucket user slug 总是小写,但 comments / activities 的 author 常带回大小写混合的 name 且不附 - * slug 字段;调用方退回 name 时大小写不一致会 404 —— 先按原值试,失败再小写一次。全部失败返回 null。 + * A Bitbucket user slug is always lowercase, but the author in comments / activities often carries back a + * mixed-case name without a slug field; when the caller falls back to name, a case mismatch yields 404 — + * try the original value first, then lowercase once on failure. Returns null if all fail. */ async getUserAvatar(slug: string, _avatarUrl?: string): Promise<BinaryResource | null> { const candidates = slug !== slug.toLowerCase() ? [slug, slug.toLowerCase()] : [slug]; @@ -30,28 +31,28 @@ export class BitbucketMediaService extends BaseMediaService { s: '64', }); } catch { - // 试下一个候选 + // Try the next candidate } } return null; } /** - * 代理拉取评论内嵌附件。 + * Proxy-fetch a comment inline attachment. * - * host 解析、Bitbucket `attachment:` 协议处理与 PAT 鉴权拉取均在 client 内完成,本方法仅薄封装。 + * Host resolution, Bitbucket `attachment:` protocol handling, and PAT-authenticated fetching are all done inside the client; this method is only a thin wrapper. */ async getAttachment(url: string, repo?: RepoRef): Promise<BinaryResource | null> { return this.client.getAttachmentBinary(url, repo); } /** - * 上传图片到仓库 attachments 端点(multipart 字段 `files`),返回可嵌入评论的 markdown。 - * 优先用响应 `links.attachment.href`(形如 `attachment:<repoId>/<id>`,getAttachmentBinary 据此渲染), - * 兜底用 `attachment:<id>`。attachments 是仓库级、非 PR 级,prId 忽略。 + * Upload an image to the repository attachments endpoint (multipart field `files`), returning markdown embeddable in a comment. + * Prefer the response `links.attachment.href` (shaped like `attachment:<repoId>/<id>`, which getAttachmentBinary renders from), + * falling back to `attachment:<id>`. Attachments are repository-level, not PR-level, so prId is ignored. * - * 端点是**私有 servlet**,路径在 `/projects/{key}/repos/{slug}/attachments`(**不带** `/rest/api/1.0` - * 前缀——带前缀会 405;实测该裸路径 Allow: POST)。下载仍走 `/rest/api/1.0/.../attachments/{id}`。 + * The endpoint is a **private servlet** at `/projects/{key}/repos/{slug}/attachments` (**without** the `/rest/api/1.0` + * prefix — with the prefix it returns 405; in practice this bare path has Allow: POST). Download still goes through `/rest/api/1.0/.../attachments/{id}`. */ override async uploadAttachment( repo: RepoRef, diff --git a/packages/platform-bitbucket-server/src/features/pull-request.ts b/packages/platform-bitbucket-server/src/features/pull-request.ts index aa57a7e9..2dc1d529 100644 --- a/packages/platform-bitbucket-server/src/features/pull-request.ts +++ b/packages/platform-bitbucket-server/src/features/pull-request.ts @@ -23,14 +23,14 @@ import type { BitbucketPullRequest, } from '../types.js'; -/** Bitbucket 活动 action → 评审决断事件类型(仅决断类,其余 action 不在表中 → 跳过)。 */ +/** Bitbucket activity action → review verdict event kind (verdicts only; other actions absent from the table → skipped). */ const ACTIVITY_KIND_BY_ACTION: Record<string, PrActivityKind> = { APPROVED: 'approved', UNAPPROVED: 'unapproved', REVIEWED: 'needsWork', }; -/** Bitbucket participant.status → 中性 reviewer 状态(缺省时退回 approved 布尔,见 mapReviewer)。 */ +/** Bitbucket participant.status → neutral reviewer status (falls back to the approved boolean when absent, see mapReviewer). */ const REVIEWER_STATUS_BY_STATUS: Partial< Record<NonNullable<BitbucketParticipant['status']>, ReviewerStatus> > = { @@ -39,14 +39,14 @@ const REVIEWER_STATUS_BY_STATUS: Partial< UNAPPROVED: 'unapproved', }; -/** 中性 review 状态 → Bitbucket participant status(写审批用)。 */ +/** Neutral review status → Bitbucket participant status (used to write approval). */ const BB_STATUS_BY_REVIEW: Record<ReviewerStatus, string> = { approved: 'APPROVED', needsWork: 'NEEDS_WORK', unapproved: 'UNAPPROVED', }; -/** Bitbucket PR 操作领域:dashboard 发现、提交、活动决断、审批、合并。 */ +/** Bitbucket PR operations domain: dashboard discovery, commits, activity verdicts, approval, merge. */ export class BitbucketPullRequestService extends BasePullRequestService { constructor( ctx: ConnectionContext, @@ -56,10 +56,10 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * dashboard 聚合发现待处理 PR,并并行拉每个 PR 的 /merge 状态归一可合并性。 + * Discover pending PRs via dashboard aggregation, fetching each PR's /merge status in parallel to normalize mergeability. * - * 发现分类 → dashboard role:created=我创建(AUTHOR),其余(待我评审)=REVIEWER。单个 /merge 失败 - * 降级为「无已知阻塞」(canMerge=true / 无冲突 / 无 vetoes),与原 hasConflict 失败降级语义一致。 + * Discovery category → dashboard role: created=authored by me (AUTHOR), rest (awaiting my review)=REVIEWER. A single /merge + * failure degrades to "no known blockers" (canMerge=true / no conflict / no vetoes), matching the original hasConflict failure degradation. */ async listPendingPullRequests(opts?: ListPendingOptions): Promise<PullRequest[]> { const role = opts?.filter === 'created' ? 'AUTHOR' : 'REVIEWER'; @@ -86,11 +86,11 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * 按 repo + 号从远端拉单个 PR(详情 + 可合并性,复用映射);404 / 403 由 client 抛出供上层归一。 + * Fetch a single PR from the remote by repo + number (details + mergeability, reusing the mapping); 404 / 403 thrown by the client for upstream normalization. * - * `/merge` 仅对 **OPEN** PR 有意义——对已合并 / 已拒绝的 PR 调用会回 409 - * IllegalPullRequestStateException,故非 OPEN 退化为中性合并态(不可合并 / 无冲突)。 - * 发现列表只列 OPEN PR、不经此路径,本退化只影响「按 URL 打开」已退场 PR 的场景。 + * `/merge` is only meaningful for **OPEN** PRs — calling it on a merged / declined PR returns 409 + * IllegalPullRequestStateException, so a non-OPEN PR degrades to a neutral merge state (not mergeable / no conflict). + * The discovery list only lists OPEN PRs and does not take this path; this degradation only affects the "open by URL" case for retired PRs. */ async getSinglePullRequest(repo: RepoRef, prId: string): Promise<PullRequest> { const base = `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}`; @@ -103,9 +103,9 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * 列出 PR 全部提交(newest-first,与 git log 一致)。 + * List all commits of a PR (newest-first, consistent with git log). * - * 一次性收集分页结果——PR 通常仅数十个 commit,不分页问题不大。 + * Collects paginated results all at once — a PR usually has only dozens of commits, so not paginating is fine. */ async listPullRequestCommits(repo: RepoRef, prId: string): Promise<PrCommit[]> { const out: PrCommit[] = []; @@ -118,9 +118,9 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * 从 /activities 流里挑出评审决断事件(APPROVED / UNAPPROVED / REVIEWED=标记 Needs Work)。 + * Pick review verdict events out of the /activities stream (APPROVED / UNAPPROVED / REVIEWED=marks Needs Work). * - * 评论(COMMENTED)走评论领域,这里只取决断;非决断 action 跳过。 + * Comments (COMMENTED) go through the comment domain; here only verdicts are taken; non-verdict actions are skipped. */ async listPullRequestActivity(repo: RepoRef, prId: string): Promise<PrActivityEvent[]> { const out: PrActivityEvent[] = []; @@ -140,9 +140,9 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * 把当前 PAT 用户在 PR 上的 review 状态写到远端(PUT participants/{userSlug})。 + * Write the current PAT user's review status on the PR to the remote (PUT participants/{userSlug}). * - * 需 ping() 已落地当前用户(取 slug + name 构造端点与 body),否则抛错。 + * Requires ping() to have populated the current user (takes slug + name to build the endpoint and body), otherwise throws. */ async setPullRequestReviewStatus( repo: RepoRef, @@ -163,10 +163,10 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * 合并 PR:先拉最新 PR 取 version(乐观锁)再 POST /merge?version=N。 + * Merge a PR: first fetch the latest PR to get version (optimistic lock), then POST /merge?version=N. * - * 已合并(他人已合 / 重复点击)回 409 + IllegalPullRequestStateException → 归一成 - * PR_ALREADY_MERGED 错误码供前端 i18n;其它 409(冲突 / veto / 无权限)原样冒泡。 + * Already merged (merged by someone else / double click) returns 409 + IllegalPullRequestStateException → normalized to the + * PR_ALREADY_MERGED error code for frontend i18n; other 409s (conflict / veto / no permission) bubble up as-is. */ async mergePullRequest(repo: RepoRef, prId: string): Promise<void> { const base = `/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests/${prId}`; @@ -181,9 +181,9 @@ export class BitbucketPullRequestService extends BasePullRequestService { } } - // ---- 映射(领域私有)---- + // ---- mapping (domain-private) ---- - /** 拉单个 PR 的 /merge 状态(canMerge / conflicted / vetoes 同源一次拉全)。 */ + /** Fetch a single PR's /merge status (canMerge / conflicted / vetoes fetched together from one source). */ private async fetchMergeStatus(pr: BitbucketPullRequest): Promise<BitbucketMergeStatus> { const project = pr.toRef.repository.project.key; const repo = pr.toRef.repository.slug; @@ -192,7 +192,7 @@ export class BitbucketPullRequestService extends BasePullRequestService { ); } - /** Bitbucket `/merge` 响应 → 中性 MergeStatus;vetoes 由服务端直给文案,缺省归一成空数组。 */ + /** Bitbucket `/merge` response → neutral MergeStatus; vetoes come straight from the server as text, normalized to an empty array when absent. */ private mapMergeStatus(bb: BitbucketMergeStatus): MergeStatus { return { canMerge: bb.canMerge, @@ -204,14 +204,14 @@ export class BitbucketPullRequestService extends BasePullRequestService { }; } - /** Bitbucket participant → 中性 Reviewer;status(7.x+)缺省时退回 approved 布尔。 */ + /** Bitbucket participant → neutral Reviewer; falls back to the approved boolean when status (7.x+) is absent. */ private mapReviewer(p: BitbucketParticipant): Reviewer { const mapped = p.status ? REVIEWER_STATUS_BY_STATUS[p.status] : undefined; const status: ReviewerStatus = mapped ?? (p.approved ? 'approved' : 'unapproved'); return { ...mapUser(p.user), status }; } - /** Bitbucket PR → 中性 PullRequest;hasConflict 为 mergeStatus.conflicted 的派生镜像。 */ + /** Bitbucket PR → neutral PullRequest; hasConflict is a derived mirror of mergeStatus.conflicted. */ private mapPullRequest(bb: BitbucketPullRequest, mergeStatus: MergeStatus): PullRequest { const url = bb.links.self[0]?.href ?? ''; const targetRepo = bb.toRef.repository; @@ -231,12 +231,12 @@ export class BitbucketPullRequestService extends BasePullRequestService { reviewers: bb.reviewers.map((r) => this.mapReviewer(r)), mergeStatus, hasConflict: mergeStatus.conflicted, - // 仅顶层评论数(回复不计);capabilities.commentCountIncludesReplies=false 标记其粗粒度。 + // Top-level comment count only (replies not counted); capabilities.commentCountIncludesReplies=false marks its coarse granularity. commentCount: bb.properties?.commentCount, }; } - /** Bitbucket commit → 中性 PrCommit,附 commit 详情页 URL。 */ + /** Bitbucket commit → neutral PrCommit, with the commit details page URL attached. */ private mapBitbucketCommit(c: BitbucketCommit, repo: RepoRef): PrCommit { const url = `${this.client.webBase}/projects/${repo.projectKey}/repos/${repo.repoSlug}/commits/${c.id}`; return { @@ -253,17 +253,17 @@ export class BitbucketPullRequestService extends BasePullRequestService { } /** - * Bitbucket commit 的 author/committer 只给 name(含 email),无 slug / displayName。 + * A Bitbucket commit's author/committer only gives name (including email), no slug / displayName. * - * 这里把 name 同时当 name + displayName,slug 留空(UI 头像 fallback 到 initials),email 暂丢弃。 + * Here name is used as both name + displayName, slug is left empty (UI avatar falls back to initials), email is dropped for now. */ private committerToUser(c: { name: string; emailAddress?: string }): PlatformUser { return { name: c.name, displayName: c.name }; } /** - * 判断错误是否为「PR 已被合并」:Bitbucket 对已合并 / 已关闭 PR 的合并请求回 409 + - * IllegalPullRequestStateException,错误体含「already … merged」。其它 409(冲突 / veto / 无权限)不在此列。 + * Determine whether the error is "PR already merged": Bitbucket returns 409 + + * IllegalPullRequestStateException for a merge request on a merged / closed PR, with the error body containing "already … merged". Other 409s (conflict / veto / no permission) are not included. */ private isAlreadyMergedError(err: unknown): boolean { if (!(err instanceof BitbucketClientError) || err.status !== 409) return false; diff --git a/packages/platform-bitbucket-server/src/types.ts b/packages/platform-bitbucket-server/src/types.ts index 19e46c79..ed1a034f 100644 --- a/packages/platform-bitbucket-server/src/types.ts +++ b/packages/platform-bitbucket-server/src/types.ts @@ -1,4 +1,4 @@ -// Bitbucket Server REST 响应形状(仅取用到的字段)。跨领域共享的数据类型,单独抽取于此。 +// Bitbucket Server REST response shapes (only the fields used). Data types shared across domains, extracted here separately. export interface BitbucketUser { name: string; @@ -44,8 +44,8 @@ export interface BitbucketPullRequest { reviewers: BitbucketParticipant[]; links: { self: Array<{ href: string }> }; /** - * 仪表盘 / 列表 PR 附带的统计属性。`commentCount` 仅数**顶层**评论(回复不计)—— - * 故只能作「新增顶层评论」的粗信号,无法感知回复(见 poller 评论跟踪)。字段可选容错。 + * Statistical properties attached to dashboard / list PRs. `commentCount` counts **top-level** comments only (replies not counted) — + * so it can only serve as a coarse signal for "new top-level comment", unable to sense replies (see poller comment tracking). Field optional for tolerance. */ properties?: { commentCount?: number; @@ -68,11 +68,11 @@ export interface BitbucketMergeStatus { } /** - * Bitbucket 评论上一种 emoji 反应(comment-likes 插件经评论 `properties.reactions` 注入)。 + * An emoji reaction on a Bitbucket comment (injected by the comment-likes plugin via the comment's `properties.reactions`). * - * 形状按真实实例响应核定(官方 REST 文档未明确):`emoticon` 给 `shortcut`(如 `eyes`)+ `url` - * (twemoji SVG,文件名即 Unicode 码点如 `1f440.svg`);`users[]` 为反应者列表;**无 `count` 字段** - * (计数取 `users.length`)。展示 emoji 优先从 `url` 解码点,回退 shortcut 名映射。字段仍标可选容错。 + * Shape verified against real instance responses (official REST docs unclear): `emoticon` gives `shortcut` (e.g. `eyes`) + `url` + * (twemoji SVG, the filename being the Unicode code point such as `1f440.svg`); `users[]` is the list of reactors; **no `count` field** + * (count taken from `users.length`). Displaying the emoji decodes the code point from `url` first, falling back to the shortcut name mapping. Fields still marked optional for tolerance. */ export interface BitbucketReactionProperty { emoticon?: { shortcut?: string; url?: string }; @@ -88,13 +88,13 @@ export interface BitbucketComment { updatedDate: number; comments?: BitbucketComment[]; parent?: { id: number }; - /** 反应等扩展属性(comment-likes 插件注入 `reactions`)。形状未文档化,容错读取。 */ + /** Extended properties such as reactions (comment-likes plugin injects `reactions`). Shape undocumented, read tolerantly. */ properties?: { reactions?: BitbucketReactionProperty[] }; } /** - * 附件上传响应(POST .../attachments,multipart 字段 `files`)。`links.attachment.href` 为 - * `attachment:<repoId>/<id>` 形式,可直接嵌入评论 markdown。字段按实测响应取用,容错可选。 + * Attachment upload response (POST .../attachments, multipart field `files`). `links.attachment.href` is of the + * `attachment:<repoId>/<id>` form, which can be embedded directly into comment markdown. Fields taken from observed responses, optional for tolerance. */ export interface BitbucketAttachmentUploadResponse { attachments?: Array<{ @@ -106,8 +106,8 @@ export interface BitbucketAttachmentUploadResponse { export interface BitbucketCommentAnchor { diffType?: 'EFFECTIVE' | 'COMMIT' | 'RANGE'; - // line / lineType 对文件级评论(挂在文件而非具体行)或孤儿 anchor(锚定行已不存在) - // 可能缺省 —— 标可选,mapBitbucketAnchor 据此降级,避免读 undefined.toLowerCase 崩 + // line / lineType may be absent for file-level comments (attached to the file rather than a specific line) + // or orphaned anchors (the anchored line no longer exists) — marked optional; mapBitbucketAnchor degrades accordingly, avoiding a crash from reading undefined.toLowerCase line?: number; lineType?: 'ADDED' | 'REMOVED' | 'CONTEXT'; fileType?: 'FROM' | 'TO'; @@ -117,8 +117,8 @@ export interface BitbucketCommentAnchor { export interface BitbucketCommit { id: string; // 40-char SHA - displayId: string; // 短 SHA (Bitbucket 默认 7-12 chars) - message: string; // 完整 commit message + displayId: string; // short SHA (Bitbucket default 7-12 chars) + message: string; // full commit message author: { name: string; emailAddress?: string }; authorTimestamp: number; // epoch ms committer: { name: string; emailAddress?: string }; diff --git a/packages/platform-bitbucket-server/src/utils.ts b/packages/platform-bitbucket-server/src/utils.ts index 1a4b370e..89e91d3e 100644 --- a/packages/platform-bitbucket-server/src/utils.ts +++ b/packages/platform-bitbucket-server/src/utils.ts @@ -2,9 +2,9 @@ import type { PlatformUser } from '@meebox/shared'; import type { BitbucketUser } from './types.js'; /** - * Bitbucket user → 中性 PlatformUser。 + * Bitbucket user → neutral PlatformUser. * - * 跨 PR / 评论领域共用,故留在共享模块。 + * Shared across the PR / comment domains, so it stays in the shared module. */ export function mapUser(u: BitbucketUser): PlatformUser { return { name: u.name, displayName: u.displayName, slug: u.slug }; diff --git a/packages/platform-bitbucket-server/tests/adapter.test.ts b/packages/platform-bitbucket-server/tests/adapter.test.ts index ac14c7b1..411f59c8 100644 --- a/packages/platform-bitbucket-server/tests/adapter.test.ts +++ b/packages/platform-bitbucket-server/tests/adapter.test.ts @@ -38,7 +38,7 @@ function makeAdapter(fetchFn: FetchLike): BitbucketServerAdapter { } describe('BitbucketServerAdapter capabilities contract', () => { - it('declares full Bitbucket capabilities (3 状态审批 / 乐观锁 / full veto)', () => { + it('declares full Bitbucket capabilities (3-status approval / optimistic lock / full veto)', () => { const caps = makeAdapter(mockFetch({})).connection.capabilities(); expect(caps.reviewStatuses).toEqual(['approved', 'needsWork', 'unapproved']); expect(caps.commentOptimisticLock).toBe(true); @@ -194,7 +194,7 @@ describe('BitbucketServerAdapter.getCloneUrl with cloneProtocol="ssh"', () => { expect(url).toBe('git@bb.example.com:FX/fx-help.git'); }); - it('drops baseUrl port from SSH URL (端口由 ssh config 负责)', async () => { + it('drops baseUrl port from SSH URL (port handled by ssh config)', async () => { const adapter = new BitbucketServerAdapter({ baseUrl: 'https://bb.example.com:8443', token: 'pat', @@ -317,7 +317,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { }); }); - it('按发现分类映射 dashboard role:默认 REVIEWER,created → AUTHOR', async () => { + it('maps dashboard role by discovery category: default REVIEWER, created → AUTHOR', async () => { const roles: string[] = []; const adapter = makeAdapter( mockFetch({ @@ -333,7 +333,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { expect(roles).toEqual(['REVIEWER', 'REVIEWER', 'AUTHOR']); }); - it('maps /merge vetoes into mergeStatus (canMerge=false + 逐条原因)', async () => { + it('maps /merge vetoes into mergeStatus (canMerge=false + per-item reason)', async () => { const adapter = makeAdapter( mockFetch({ '/rest/api/1.0/dashboard/pull-requests': () => ({ @@ -369,7 +369,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { { summary: 'Requires successful build', detail: undefined }, ], }); - // 无冲突但有 veto:hasConflict 仍为 false,阻塞原因只在 mergeStatus 里 + // no conflict but has veto: hasConflict is still false, the blocking reason lives only in mergeStatus expect(prs[0]!.hasConflict).toBe(false); }); @@ -394,7 +394,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { expect(prs[0]!.hasConflict).toBe(true); }); - it('treats /merge fetch failure as no conflict (保守, 不误标 ignored)', async () => { + it('treats /merge fetch failure as no conflict (conservative, does not mislabel as ignored)', async () => { const adapter = makeAdapter( mockFetch({ '/rest/api/1.0/dashboard/pull-requests': () => ({ @@ -404,7 +404,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { start: 0, values: [samplePR], }), - // /merge 端点缺失 → mockFetch 默认 404 + // /merge endpoint missing → mockFetch default 404 }), ); const prs = await adapter.prs.listPendingPullRequests(); @@ -476,7 +476,7 @@ describe('BitbucketServerAdapter.listPendingPullRequests', () => { }); }); -describe('BitbucketServerAdapter.listPullRequestComments anchor 映射', () => { +describe('BitbucketServerAdapter.listPullRequestComments anchor mapping', () => { const user = { name: 'u1', displayName: 'User One', slug: 'u1', active: true }; const mkComment = (id: number, text: string) => ({ id, @@ -496,7 +496,7 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor 映射', () => { }), }); - it('行级 anchor → 映射 path/line/side/lineType', async () => { + it('line-level anchor → maps path/line/side/lineType', async () => { const adapter = makeAdapter( mockFetch( activities([ @@ -517,7 +517,7 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor 映射', () => { expect(cs[0]!.anchor).toEqual({ path: 'src/a.ts', line: 42, side: 'new', lineType: 'added' }); }); - it('二进制 / 文件级评论 anchor 无 line/lineType → 降级 anchor=null(不崩)', async () => { + it('binary / file-level comment anchor with no line/lineType → degrades to anchor=null (no crash)', async () => { const adapter = makeAdapter( mockFetch( activities([ @@ -539,7 +539,7 @@ describe('BitbucketServerAdapter.listPullRequestComments anchor 映射', () => { expect(cs[0]!.anchor).toBeNull(); }); - it('有 line 缺 lineType → lineType 兜底 context', async () => { + it('has line but missing lineType → lineType falls back to context', async () => { const adapter = makeAdapter( mockFetch( activities([ @@ -566,9 +566,9 @@ describe('BitbucketServerAdapter.mergePullRequest', () => { let mergeVersion: string | null = 'unset'; const adapter = makeAdapter( mockFetch({ - // GET 单个 PR 拿 version + // GET single PR to get version '/rest/api/1.0/projects/FX/repos/fx-help/pull-requests/1022': () => samplePR, - // POST 合并:捕获 version query + // POST merge: capture version query '/rest/api/1.0/projects/FX/repos/fx-help/pull-requests/1022/merge': (url) => { mergeVersion = url.searchParams.get('version'); return { ...samplePR, state: 'MERGED' }; @@ -576,13 +576,13 @@ describe('BitbucketServerAdapter.mergePullRequest', () => { }), ); await adapter.prs.mergePullRequest({ projectKey: 'FX', repoSlug: 'fx-help' }, '1022'); - // 用的是 GET 回来的最新 version (samplePR.version=5),不是任何缓存值 + // uses the latest version returned by GET (samplePR.version=5), not any cached value expect(mergeVersion).toBe('5'); }); }); describe('BitbucketServerAdapter.setPullRequestReviewStatus', () => { - // approve / needs work / unapproved (撤销) 三个状态映射到 Bitbucket PUT participants 端点 + // approve / needs work / unapproved (dismiss) three statuses map to the Bitbucket PUT participants endpoint function captureFetch(): { fetchFn: FetchLike; calls: { method: string; url: string; body: string | undefined }[]; @@ -595,7 +595,7 @@ describe('BitbucketServerAdapter.setPullRequestReviewStatus', () => { url: url.pathname, body: typeof init?.body === 'string' ? init.body : undefined, }); - // ping 第一阶段:application-properties + // ping first stage: application-properties if (url.pathname === '/rest/api/1.0/application-properties') { return new Response( JSON.stringify({ version: '8.0.0', buildNumber: '8000', displayName: 'Bitbucket' }), @@ -616,7 +616,7 @@ describe('BitbucketServerAdapter.setPullRequestReviewStatus', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ); } - // PUT participants:返回 200 + 模拟 Bitbucket 响应体(实际不读,但需要解析成功) + // PUT participants: return 200 + a mock Bitbucket response body (not actually read, but must parse successfully) if (url.pathname.includes('/participants/')) { return new Response(JSON.stringify({ approved: true }), { status: 200, @@ -661,7 +661,7 @@ describe('BitbucketServerAdapter.setPullRequestReviewStatus', () => { expect(JSON.parse(put.body!).status).toBe('NEEDS_WORK'); }); - it('maps unapproved → UNAPPROVED (撤销之前的标记)', async () => { + it('maps unapproved → UNAPPROVED (dismisses the prior mark)', async () => { const { fetchFn, calls } = captureFetch(); const adapter = makeAdapter(fetchFn); await adapter.connection.ping(); diff --git a/packages/platform-core/src/adapter.ts b/packages/platform-core/src/adapter.ts index b1c10934..5cf950c1 100644 --- a/packages/platform-core/src/adapter.ts +++ b/packages/platform-core/src/adapter.ts @@ -5,8 +5,8 @@ import type { CommentService } from './features/comment.js'; import type { MediaService } from './features/media.js'; /** - * 平台适配器(根 / 总 client):领域服务容器。业务层经此按领域取所需服务(`adapter.comments.list(...)`), - * 不再面对单一巨接口。组合由 {@link composePlatformAdapter} 完成。 + * Platform adapter (root / aggregate client): a domain-service container. The business layer fetches the service it needs per domain (`adapter.comments.list(...)`), + * instead of facing one giant interface. Composition is done by {@link composePlatformAdapter}. */ export interface PlatformAdapter { readonly kind: PlatformKind; @@ -17,7 +17,7 @@ export interface PlatformAdapter { } /** - * 领域服务集合:四个领域服务的入参容器,喂给 {@link composePlatformAdapter} 组装为根适配器。 + * Domain-service set: an input container of the four domain services, fed to {@link composePlatformAdapter} to assemble the root adapter. */ export interface PlatformServices { connection: PlatformConnection; @@ -26,7 +26,7 @@ export interface PlatformServices { media: MediaService; } -/** 把四个领域服务组装成根 PlatformAdapter(领域服务容器)。根不含业务逻辑,只持有并暴露各领域。 */ +/** Assemble the four domain services into a root PlatformAdapter (domain-service container). The root holds no business logic, only holds and exposes each domain. */ export function composePlatformAdapter(services: PlatformServices): PlatformAdapter { return { kind: services.connection.kind, diff --git a/packages/platform-core/src/codes.ts b/packages/platform-core/src/codes.ts index e5994bf4..d30f60ce 100644 --- a/packages/platform-core/src/codes.ts +++ b/packages/platform-core/src/codes.ts @@ -1,30 +1,30 @@ -// 平台层统一后台状态码:后台只发**稳定中性码**、不拼面向用户的本地化文案,本地化由前端按码做 -// (见 docs/arch/01-platform/01-adapter.md §2 与 docs/arch/99-core/04-error-codes.md)。 -// 各平台适配器把自身原生状态归一到这些码;前端按码 i18n(renderer locales 的 `mergeVeto.<code>`)。 +// Platform-layer unified backend status codes: the backend only emits **stable, neutral codes**, never assembling user-facing localized text; localization is done by the frontend per code +// (see docs/arch/01-platform/01-adapter.md §2 and docs/arch/99-core/04-error-codes.md). +// Each platform adapter normalizes its own native status to these codes; the frontend does i18n per code (`mergeVeto.<code>` in renderer locales). -/** 合并否决原因码(GitHub mergeable_state / GitLab detailed_merge_status 等归一到此)。 */ +/** Merge veto reason codes (GitHub mergeable_state / GitLab detailed_merge_status etc. normalize to these). */ export const MERGE_VETO_CODES = [ - /** 存在合并冲突。 */ + /** A merge conflict exists. */ 'conflict', - /** 被分支保护阻止(必需评审 / 必需检查未通过)。 */ + /** Blocked by branch protection (required review / required checks not passed). */ 'branchProtected', - /** 落后于目标分支,需先更新 / rebase。 */ + /** Behind the target branch, needs updating / rebase first. */ 'behind', - /** 必需检查未通过 / CI 进行中。 */ + /** Required checks not passed / CI in progress. */ 'checksFailed', - /** 可合并状态计算中。 */ + /** Mergeable state being computed. */ 'checking', - /** 草稿 / WIP,需标记为可合并。 */ + /** Draft / WIP, needs to be marked as ready to merge. */ 'draft', - /** 存在未解决的讨论。 */ + /** Unresolved discussions exist. */ 'discussionsUnresolved', - /** 审批未满足要求。 */ + /** Approval requirements not met. */ 'notApproved', - /** PR / MR 非打开状态。 */ + /** PR / MR is not in open state. */ 'notOpen', - /** 被其它合并请求阻塞。 */ + /** Blocked by another merge request. */ 'blockedByDependency', - /** 远端判定当前不可合并(其它 / 未细分原因)。 */ + /** Remote deems it currently not mergeable (other / unspecified reason). */ 'notMergeable', ] as const; diff --git a/packages/platform-core/src/context.ts b/packages/platform-core/src/context.ts index e872197f..c8fb849f 100644 --- a/packages/platform-core/src/context.ts +++ b/packages/platform-core/src/context.ts @@ -2,40 +2,40 @@ import type { PlatformUser } from '@meebox/shared'; import type { PlatformTransport } from './transport.js'; /** - * 连接上下文:一个平台连接的共享态,由组合器一次构造、注入全部领域服务——统一连接封装实例(传输)+ - * 当前用户缓存。确保「一个连接 = 一个封装实例 = 一份连接态」,各领域不重复持有 transport 或 token。 + * Connection context: the shared state of one platform connection, constructed once by the composer and injected into all domain services — the unified connection wrapper instance (transport) + + * current-user cache. Ensures "one connection = one wrapper instance = one connection state", so no domain redundantly holds transport or token. */ export interface ConnectionContext { - /** 平台连接传输(统一连接封装实例)。 */ + /** Platform connection transport (unified connection wrapper instance). */ readonly transport: PlatformTransport; /** - * 读取当前 PAT 用户缓存(由 ping 落地或 setCurrentUser 预热);未就绪返回 null。 + * Read the current PAT user cache (populated by ping or pre-warmed by setCurrentUser); returns null if not ready. */ getCurrentUser(): PlatformUser | null; /** - * 写入当前 PAT 用户缓存,供各领域服务同步读取。 + * Write the current PAT user cache, for each domain service to read synchronously. */ setCurrentUser(user: PlatformUser | null): void; } /** - * 默认可变连接上下文实现:以一个内部字段缓存当前用户,供组合器一次构造后注入各领域服务。 + * Default mutable connection-context implementation: caches the current user in one internal field, for the composer to construct once and inject into each domain service. */ export class MutableConnectionContext implements ConnectionContext { private user: PlatformUser | null = null; constructor(readonly transport: PlatformTransport) {} /** - * 读取当前缓存的 PAT 用户;尚未就绪(未 ping / 未预热)时返回 null。 + * Read the currently cached PAT user; returns null when not yet ready (not pinged / not pre-warmed). */ getCurrentUser(): PlatformUser | null { return this.user; } /** - * 写入当前用户缓存,供后续同步读取(ping 落地或建连接时预热)。 + * Write the current user cache, for subsequent synchronous reads (populated by ping or pre-warmed when the connection is established). */ setCurrentUser(user: PlatformUser | null): void { this.user = user; @@ -43,15 +43,15 @@ export class MutableConnectionContext implements ConnectionContext { } /** - * 领域服务基类:持有共享连接上下文,向子类暴露 transport。 + * Domain-service base class: holds the shared connection context and exposes transport to subclasses. * - * 各领域基类(连接 / PR / 评论 / 媒体)由此派生,确保同一连接的各领域共享一份连接态。 + * Each domain base class (connection / PR / comment / media) derives from this, ensuring each domain of the same connection shares one connection state. */ export abstract class PlatformDomainService { constructor(protected readonly ctx: ConnectionContext) {} /** - * 返回共享连接上下文持有的平台连接传输(统一连接封装实例),供子类发请求。 + * Return the platform connection transport (unified connection wrapper instance) held by the shared connection context, for subclasses to make requests. */ protected get transport(): PlatformTransport { return this.ctx.transport; diff --git a/packages/platform-core/src/features/comment.ts b/packages/platform-core/src/features/comment.ts index 0052db27..36f75e76 100644 --- a/packages/platform-core/src/features/comment.ts +++ b/packages/platform-core/src/features/comment.ts @@ -1,22 +1,22 @@ import type { PrComment, PrCommentAnchor, RepoRef } from '@meebox/shared'; import { PlatformDomainService } from '../context.js'; -/** 评论:读写全闭环(summary / inline / reply / edit / delete)。 */ +/** Comments: full read-write cycle (summary / inline / reply / edit / delete). */ export interface CommentService { /** - * 列出 PR 上的全部已有评论(inline + summary)。 + * List all existing comments on a PR (inline + summary). * - * reply 经 comment.replies 嵌套返回,调用方拿到的是已成树的评论列表。 + * Replies are returned nested via comment.replies, so the caller receives an already-tree'd comment list. */ listPullRequestComments(repo: RepoRef, prId: string): Promise<PrComment[]>; /** - * 在 PR 上发一条 summary 评论(顶层、不锚到文件)。 + * Post a summary comment on a PR (top-level, not anchored to a file). */ publishSummaryComment(repo: RepoRef, prId: string, body: string): Promise<PrComment>; /** - * 在 PR diff 上发一条 inline 评论,锚到具体文件 + 行号。 + * Post an inline comment on the PR diff, anchored to a specific file + line number. */ publishInlineComment( repo: RepoRef, @@ -26,7 +26,7 @@ export interface CommentService { ): Promise<PrComment>; /** - * 在已有评论下回复。 + * Reply under an existing comment. */ replyToComment( repo: RepoRef, @@ -36,9 +36,9 @@ export interface CommentService { ): Promise<PrComment>; /** - * 编辑 PR 上的一条评论(改 body 文本)。 + * Edit a comment on a PR (change the body text). * - * version 为乐观锁,仅 Bitbucket 校验,其余平台忽略。 + * version is an optimistic lock, validated only by Bitbucket, ignored by other platforms. */ editComment( repo: RepoRef, @@ -49,17 +49,17 @@ export interface CommentService { ): Promise<PrComment>; /** - * 删除 PR 上的一条评论。 + * Delete a comment on a PR. * - * version 为乐观锁,仅 Bitbucket 校验,其余平台忽略。 + * version is an optimistic lock, validated only by Bitbucket, ignored by other platforms. */ deleteComment(repo: RepoRef, prId: string, commentId: string, version: number): Promise<void>; /** - * 切换当前用户对一条评论的某个 emoji 反应(add=true 加上、false 取下)。emoji 为规范化 - * Unicode 字符,由 adapter 翻成自家原生名。kind 区分 summary / inline(GitHub 据此选 issue / - * review 反应端点;其余平台忽略)。幂等:重复 add / 不存在时 remove 均按成功处理。 - * 仅 `commentReactions` 能力为真的平台实现;不支持的平台抛错。 + * Toggle the current user's given emoji reaction on a comment (add=true to add, false to remove). emoji is a normalized + * Unicode character, translated by the adapter to its own native name. kind distinguishes summary / inline (GitHub selects the issue / + * review reaction endpoint accordingly; other platforms ignore it). Idempotent: repeated add / remove-when-absent are both treated as success. + * Implemented only by platforms whose `commentReactions` capability is true; unsupported platforms throw. */ toggleReaction( repo: RepoRef, @@ -72,21 +72,21 @@ export interface CommentService { } /** - * 评论领域基类:读写全闭环契约方法留给平台子类按各自端点实现。 + * Comment domain base class: the full read-write contract methods are left for platform subclasses to implement against their respective endpoints. */ export abstract class BaseCommentService extends PlatformDomainService implements CommentService { /** - * 由平台子类实现:拉取 PR 的全部评论并归一为统一评论树。 + * Implemented by platform subclasses: fetch all comments of a PR and normalize into a unified comment tree. */ abstract listPullRequestComments(repo: RepoRef, prId: string): Promise<PrComment[]>; /** - * 由平台子类实现:发表一条顶层 summary 评论。 + * Implemented by platform subclasses: post a top-level summary comment. */ abstract publishSummaryComment(repo: RepoRef, prId: string, body: string): Promise<PrComment>; /** - * 由平台子类实现:在 diff 上发表锚到文件 + 行号的 inline 评论。 + * Implemented by platform subclasses: post an inline comment on the diff anchored to a file + line number. */ abstract publishInlineComment( repo: RepoRef, @@ -96,7 +96,7 @@ export abstract class BaseCommentService extends PlatformDomainService implement ): Promise<PrComment>; /** - * 由平台子类实现:在指定父评论下回复。 + * Implemented by platform subclasses: reply under a specified parent comment. */ abstract replyToComment( repo: RepoRef, @@ -106,7 +106,7 @@ export abstract class BaseCommentService extends PlatformDomainService implement ): Promise<PrComment>; /** - * 由平台子类实现:编辑评论 body(version 乐观锁按平台决定是否校验)。 + * Implemented by platform subclasses: edit a comment body (version optimistic lock validated or not per platform). */ abstract editComment( repo: RepoRef, @@ -117,7 +117,7 @@ export abstract class BaseCommentService extends PlatformDomainService implement ): Promise<PrComment>; /** - * 由平台子类实现:删除评论(version 乐观锁按平台决定是否校验)。 + * Implemented by platform subclasses: delete a comment (version optimistic lock validated or not per platform). */ abstract deleteComment( repo: RepoRef, @@ -127,7 +127,7 @@ export abstract class BaseCommentService extends PlatformDomainService implement ): Promise<void>; /** - * 由平台子类实现:切换当前用户对评论的 emoji 反应。不支持反应的平台可不覆写(默认抛错)。 + * Implemented by platform subclasses: toggle the current user's emoji reaction on a comment. Platforms not supporting reactions may leave it unoverridden (throws by default). */ toggleReaction( _repo: RepoRef, diff --git a/packages/platform-core/src/features/connection.ts b/packages/platform-core/src/features/connection.ts index 5bca7361..b49fde5d 100644 --- a/packages/platform-core/src/features/connection.ts +++ b/packages/platform-core/src/features/connection.ts @@ -7,76 +7,76 @@ import type { } from '@meebox/shared'; import { PlatformDomainService } from '../context.js'; -/** 连接 / 身份 / 克隆(根领域):连接探测、当前用户缓存、能力聚合入口、git 克隆 URL。 */ +/** Connection / identity / clone (root domain): connection probe, current-user cache, capability aggregation entry, git clone URL. */ export interface PlatformConnection { readonly kind: PlatformKind; /** - * 平台能力描述符(静态,按平台/版本/套餐固定)。 + * Platform capability descriptor (static, fixed per platform/version/plan). * - * 聚合自各领域能力声明,并由连接探测结果细化。 + * Aggregated from each domain's capability declarations, and refined by connection probe results. */ capabilities(): PlatformCapabilities; /** - * 连接探测:返回服务端版本号与当前用户。 + * Connection probe: returns the server version number and current user. * - * 版本低于硬下限时 ok=false 并给出 reason。 + * When the version is below the hard minimum, ok=false with a reason. */ ping(): Promise<PingResult>; /** - * 返回 ping 期间缓存的当前 PAT 所属用户;未就绪返回 null。 + * Return the user owning the current PAT, cached during ping; returns null if not ready. * - * 同步方法,仅读缓存、不发请求。 + * Synchronous method, only reads the cache, makes no request. */ getCurrentUser(): PlatformUser | null; /** - * 注入 / 恢复当前用户缓存。 + * Inject / restore the current-user cache. * - * main 建连接时用本地持久化身份预热,ping 完成后被远端结果覆盖。 + * main pre-warms with locally persisted identity when establishing the connection, overwritten by the remote result after ping completes. */ setCurrentUser?(user: PlatformUser | null): void; /** - * 返回 git clone URL(PAT 内嵌 user:PAT 或 ssh scp-like 形式)。 + * Return the git clone URL (with PAT embedded as user:PAT or ssh scp-like form). */ getCloneUrl(repo: RepoRef): Promise<string>; } /** - * 连接领域基类:当前用户缓存读写为跨平台共享实现;ping / capabilities / clone 由平台子类实现。 + * Connection domain base class: current-user cache read/write is a cross-platform shared implementation; ping / capabilities / clone are implemented by platform subclasses. */ export abstract class BaseConnection extends PlatformDomainService implements PlatformConnection { abstract readonly kind: PlatformKind; /** - * 由平台子类声明本平台的能力描述符(审批模型、行内评论、合并否决保真度等)。 + * Declared by platform subclasses: this platform's capability descriptor (approval model, inline comments, merge-veto fidelity, etc.). */ abstract capabilities(): PlatformCapabilities; /** - * 由平台子类实现连接探测:取服务端版本与当前用户,并落地用户缓存。 + * Connection probe implemented by platform subclasses: fetch the server version and current user, and populate the user cache. */ abstract ping(): Promise<PingResult>; /** - * 读取共享上下文缓存的当前用户;未就绪返回 null。 + * Read the current user cached in the shared context; returns null if not ready. */ getCurrentUser(): PlatformUser | null { return this.ctx.getCurrentUser(); } /** - * 写入共享上下文的当前用户缓存。 + * Write the current-user cache in the shared context. */ setCurrentUser(user: PlatformUser | null): void { this.ctx.setCurrentUser(user); } /** - * 由平台子类实现:按仓库引用构造可直接克隆的 git URL。 + * Implemented by platform subclasses: construct a directly cloneable git URL from a repo reference. */ abstract getCloneUrl(repo: RepoRef): Promise<string>; } diff --git a/packages/platform-core/src/features/media.ts b/packages/platform-core/src/features/media.ts index f9ccbfc4..7ad324bc 100644 --- a/packages/platform-core/src/features/media.ts +++ b/packages/platform-core/src/features/media.ts @@ -6,27 +6,27 @@ import type { import type { BinaryResource } from '../transport.js'; import { PlatformDomainService } from '../context.js'; -/** 用户与媒体:头像 / 评论内嵌附件代理(带凭据拉取由平台信任模型把关)。 */ +/** User and media: avatar / comment inline attachment proxy (credentialed fetch gated by the platform trust model). */ export interface MediaService { /** - * 拉取用户头像图片。 + * Fetch the user avatar image. * - * 平台不支持或失败返回 null,调用方走 initials 回退。 + * Returns null when the platform does not support it or on failure; the caller falls back to initials. */ getUserAvatar(slug: string, avatarUrl?: string): Promise<BinaryResource | null>; /** - * 代理拉取评论 body 内嵌图片。 + * Proxy-fetch an image embedded in a comment body. * - * host 不属当前平台、协议无法解析或拉取失败时返回 null。 + * Returns null when the host does not belong to the current platform, the protocol cannot be parsed, or the fetch fails. */ getAttachment(url: string, repo?: RepoRef): Promise<BinaryResource | null>; /** - * 上传一张图片作为评论附件,返回可插入评论正文的 markdown 片段。 + * Upload an image as a comment attachment and return a markdown snippet insertable into the comment body. * - * 仅 `commentAttachments` 能力为真的平台实现(GitLab /uploads、Bitbucket attachments); - * 不支持的平台返回 null(GitHub 无公开上传 API)。 + * Only implemented by platforms whose `commentAttachments` capability is true (GitLab /uploads, Bitbucket attachments); + * returns null on platforms that do not support it (GitHub has no public upload API). */ uploadAttachment( repo: RepoRef, @@ -36,21 +36,21 @@ export interface MediaService { } /** - * 用户与媒体领域基类:头像与附件拉取契约留给平台子类按各自资产域实现。 + * User and media domain base class: the avatar and attachment fetch contracts are left to platform subclasses to implement per their own asset domain. */ export abstract class BaseMediaService extends PlatformDomainService implements MediaService { /** - * 由平台子类实现:拉取用户头像,失败或不支持返回 null。 + * Implemented by platform subclasses: fetch the user avatar, returning null on failure or when unsupported. */ abstract getUserAvatar(slug: string, avatarUrl?: string): Promise<BinaryResource | null>; /** - * 由平台子类实现:代理拉取评论内嵌附件,非本平台或失败返回 null。 + * Implemented by platform subclasses: proxy-fetch a comment inline attachment, returning null when not this platform or on failure. */ abstract getAttachment(url: string, repo?: RepoRef): Promise<BinaryResource | null>; /** - * 由平台子类覆写:上传评论附件并回 markdown。默认不支持(返回 null)。 + * Overridden by platform subclasses: upload a comment attachment and return markdown. Unsupported by default (returns null). */ uploadAttachment( _repo: RepoRef, diff --git a/packages/platform-core/src/features/pull-request.ts b/packages/platform-core/src/features/pull-request.ts index 66185326..c1e2b422 100644 --- a/packages/platform-core/src/features/pull-request.ts +++ b/packages/platform-core/src/features/pull-request.ts @@ -8,73 +8,73 @@ import type { } from '@meebox/shared'; import { PlatformDomainService } from '../context.js'; -/** PR 操作:发现、提交 / 活动数据、审批决断、合并。 */ +/** PR operations: discovery, commit / activity data, review decisions, merge. */ export interface PullRequestService { /** - * 列出待处理 PR,跨项目跨仓库。 + * List pending PRs, across projects and repositories. * - * 默认按 review-requested 发现;GitHub 按 opts.filter 切换发现范围。 + * Discovers by review-requested by default; GitHub switches the discovery scope by opts.filter. */ listPendingPullRequests(opts?: ListPendingOptions): Promise<PullRequest[]>; /** - * 按 repo + id **从远端拉取单个 PR**(不经发现列表 / 缓存),用于「按 URL 打开 PR」。 - * 无权限 / 不存在时由底层 client 抛带 HTTP status 的错误(403 / 404),上层据此归一成错误码。 + * **Fetch a single PR from the remote** by repo + id (bypassing the discovery list / cache), used for "open PR by URL". + * On no permission / not found, the underlying client throws an error carrying the HTTP status (403 / 404), which the upper layer normalizes into an error code. */ getSinglePullRequest(repo: RepoRef, prId: string): Promise<PullRequest>; /** - * 列出 PR 全部提交,按 **newest first** 排序。 + * List all commits of a PR, sorted **newest first**. */ listPullRequestCommits(repo: RepoRef, prId: string): Promise<PrCommit[]>; /** - * 列出 PR 上的「评审决断」活动事件(approve / needs-work / unapprove / dismiss),带时间戳。 + * List the "review decision" activity events on a PR (approve / needs-work / unapprove / dismiss), with timestamps. */ listPullRequestActivity(repo: RepoRef, prId: string): Promise<PrActivityEvent[]>; /** - * 把当前用户在该 PR 上的 review 状态写到远端(approved / needsWork / unapproved)。 + * Write the current user's review status on this PR to the remote (approved / needsWork / unapproved). */ setPullRequestReviewStatus(repo: RepoRef, prId: string, status: ReviewerStatus): Promise<void>; /** - * 合并 PR 到目标分支。 + * Merge a PR into the target branch. * - * 仅应在 mergeStatus.canMerge=true 时调用;操作不可逆。 + * Should only be called when mergeStatus.canMerge=true; the operation is irreversible. */ mergePullRequest(repo: RepoRef, prId: string): Promise<void>; } /** - * PR 操作领域基类:契约方法全部留给平台子类实现,仅约束统一的领域接口形态。 + * PR operations domain base class: all contract methods are left to platform subclasses to implement, only constraining the unified domain interface shape. */ export abstract class BasePullRequestService extends PlatformDomainService implements PullRequestService { /** - * 由平台子类实现:跨项目发现待处理 PR 并归一为中性类型。 + * Implemented by platform subclasses: discover pending PRs across projects and normalize into neutral types. */ abstract listPendingPullRequests(opts?: ListPendingOptions): Promise<PullRequest[]>; /** - * 由平台子类实现:按 repo + id 从远端拉取单个 PR(「按 URL 打开 PR」)。 + * Implemented by platform subclasses: fetch a single PR from the remote by repo + id ("open PR by URL"). */ abstract getSinglePullRequest(repo: RepoRef, prId: string): Promise<PullRequest>; /** - * 由平台子类实现:列出 PR 提交,按 newest first 返回。 + * Implemented by platform subclasses: list PR commits, returned newest first. */ abstract listPullRequestCommits(repo: RepoRef, prId: string): Promise<PrCommit[]>; /** - * 由平台子类实现:列出 PR 的评审决断活动事件(无对应能力的平台返回空)。 + * Implemented by platform subclasses: list a PR's review decision activity events (platforms lacking the capability return empty). */ abstract listPullRequestActivity(repo: RepoRef, prId: string): Promise<PrActivityEvent[]>; /** - * 由平台子类实现:把当前用户的 review 状态写到远端。 + * Implemented by platform subclasses: write the current user's review status to the remote. */ abstract setPullRequestReviewStatus( repo: RepoRef, @@ -83,7 +83,7 @@ export abstract class BasePullRequestService ): Promise<void>; /** - * 由平台子类实现:合并 PR 到目标分支。 + * Implemented by platform subclasses: merge a PR into the target branch. */ abstract mergePullRequest(repo: RepoRef, prId: string): Promise<void>; } diff --git a/packages/platform-core/src/http.ts b/packages/platform-core/src/http.ts index e68cc424..38864d99 100644 --- a/packages/platform-core/src/http.ts +++ b/packages/platform-core/src/http.ts @@ -1,16 +1,16 @@ import type { FetchLike, PlatformConnectionConfig } from './transport.js'; -/** 连接层默认单请求超时。 */ +/** Default per-request timeout for the connection layer. */ export const DEFAULT_TIMEOUT_MS = 30_000; const globalFetch: FetchLike = (input, init) => fetch(input, init); -/** 去尾斜杠(归一 base URL 用)。 */ +/** Strip trailing slashes (for normalizing the base URL). */ export function stripTrailingSlash(s: string): string { return s.replace(/\/+$/, ''); } -/** 取 URL 的 host;解析失败返回空串。 */ +/** Get the host of a URL; returns an empty string on parse failure. */ export function hostOf(url: string): string { try { return new URL(url).host; @@ -20,8 +20,8 @@ export function hostOf(url: string): string { } /** - * 拼请求 URL:`path` 以 http(s) 开头时原样请求(分页 next / 绝对资源 URL);否则拼到 `baseUrl` 之后。 - * 可选 query 写进 searchParams。 + * Build the request URL: when `path` starts with http(s) it is requested as-is (pagination next / absolute resource URL); otherwise it is appended after `baseUrl`. + * Optional query is written into searchParams. */ export function buildUrl(baseUrl: string, path: string, params?: Record<string, string>): string { const u = /^https?:\/\//.test(path) ? new URL(path) : new URL(`${baseUrl}${path}`); @@ -29,7 +29,7 @@ export function buildUrl(baseUrl: string, path: string, params?: Record<string, return u.toString(); } -/** 带超时(AbortController)的 fetch;超时即 abort。`init.signal` 由本函数注入,调用方勿自带。 */ +/** fetch with a timeout (AbortController); aborts on timeout. `init.signal` is injected by this function, callers should not supply their own. */ export async function fetchWithTimeout( fetchFn: FetchLike, url: string, @@ -45,7 +45,7 @@ export async function fetchWithTimeout( } } -/** 从 `Link` 头解析 `rel="next"` 的 URL;无则 null(GitHub / GitLab 的 Link 头分页共用)。 */ +/** Parse the `rel="next"` URL from the `Link` header; null if absent (shared by GitHub / GitLab Link-header pagination). */ export function parseNextLink(link: string | null): string | null { if (!link) return null; for (const part of link.split(',')) { @@ -55,7 +55,7 @@ export function parseNextLink(link: string | null): string | null { return null; } -/** 收集异步迭代器为数组。 */ +/** Collect an async iterator into an array. */ export async function collect<T>(it: AsyncIterable<T>): Promise<T[]> { const out: T[] = []; for await (const v of it) out.push(v); @@ -63,8 +63,8 @@ export async function collect<T>(it: AsyncIterable<T>): Promise<T[]> { } /** - * 从错误响应体(JSON)提取 API 给的真因消息。识别 `{message}`(GitHub / Bitbucket)与 `{error}` - * (GitLab 部分端点);对象型 message 序列化为字符串。非 JSON 响应体 → 空串。 + * Extract the real-cause message the API gives from the error response body (JSON). Recognizes `{message}` (GitHub / Bitbucket) and `{error}` + * (some GitLab endpoints); an object-typed message is serialized to a string. Non-JSON response body → empty string. */ export function extractApiMessage(text: string): string { try { @@ -73,17 +73,17 @@ export function extractApiMessage(text: string): string { if (typeof m === 'string') return m; if (m && typeof m === 'object') return JSON.stringify(m); } catch { - /* 非 JSON 响应体,忽略 */ + /* non-JSON response body, ignore */ } return ''; } /** - * 解析连接层有效 fetch,把代理解析统一收口到连接层(替代各调用点手拼 `proxyFetchForHost`): - * - 显式 `config.fetch` 覆盖优先(测试桩 / 已自行解析代理); - * - 否则按统一 `config.proxy` + `baseUrl` host 经注入的 `config.proxyFetch` 工厂解析;工厂返回 undefined - * (loopback / 代理关闭)时退回直连全局 fetch; - * - 无 proxy / 无工厂 → 直连全局 fetch。 + * Resolve the connection layer's effective fetch, funneling proxy resolution into the connection layer (replacing each call site hand-assembling `proxyFetchForHost`): + * - explicit `config.fetch` override takes priority (test stub / proxy already resolved on its own); + * - otherwise resolve via the injected `config.proxyFetch` factory by the unified `config.proxy` + `baseUrl` host; when the factory returns undefined + * (loopback / proxy disabled), fall back to the direct global fetch; + * - no proxy / no factory → direct global fetch. */ export function resolveConnectionFetch(config: PlatformConnectionConfig): FetchLike { if (config.fetch) return config.fetch; diff --git a/packages/platform-core/src/transport.ts b/packages/platform-core/src/transport.ts index 8d4e0f06..37f3273e 100644 --- a/packages/platform-core/src/transport.ts +++ b/packages/platform-core/src/transport.ts @@ -1,66 +1,66 @@ import type { ProxyConfig } from '@meebox/shared'; -/** 可注入的 fetch(测试桩 / 代理包装);连接层默认用全局 fetch。 */ +/** Injectable fetch (test stub / proxy wrapper); the connection layer uses the global fetch by default. */ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>; -/** 二进制资源(头像 / 附件):原始字节 + content-type,供 main 端缓存并转 data URL。 */ +/** Binary resource (avatar / attachment): raw bytes + content-type, for the main side to cache and turn into a data URL. */ export interface BinaryResource { bytes: Uint8Array; contentType: string; } /** - * 代理 fetch 工厂(注入口)。给定统一代理配置与目标 host,产出「代理感知」的 fetch;loopback / 代理 - * 关闭时返回 undefined(连接层退回直连全局 fetch)。把 undici ProxyAgent 等具体传输实现留在注入方 - * (desktop),使 platform-core 不依赖具体代理实现。 + * Proxy fetch factory (injection point). Given the unified proxy config and the target host, produces a "proxy-aware" fetch; on loopback / proxy + * disabled returns undefined (the connection layer falls back to the direct global fetch). Concrete transport implementations such as undici ProxyAgent are left to the injector + * (desktop), so platform-core does not depend on a concrete proxy implementation. */ export type ProxyFetchFactory = (proxy: ProxyConfig, host: string) => FetchLike | undefined; /** - * 平台连接的统一配置。连接层(统一连接封装实例)据此构造——含连接参数、鉴权 token 与**统一的代理 - * 配置**。代理解析(loopback 直连 / 否则挂代理)由连接层据 `baseUrl` host 一次完成,不再由各调用点 - * 预拼 fetch(见 docs/arch/01-platform/01-adapter.md §1)。 + * Unified config for a platform connection. The connection layer (unified connection wrapper instance) is constructed from it—containing connection params, auth token, and the **unified proxy + * config**. Proxy resolution (loopback direct / otherwise attach proxy) is done once by the connection layer by `baseUrl` host, no longer pre-assembled as a fetch by each call site + * (see docs/arch/01-platform/01-adapter.md §1). */ export interface PlatformConnectionConfig { - /** 平台 REST API base,无尾斜杠。 */ + /** Platform REST API base, without trailing slash. */ baseUrl: string; - /** Personal Access Token;只进连接层、绝不进日志。 */ + /** Personal Access Token; enters only the connection layer, never the logs. */ token: string; - /** 单请求超时(默认 30s)。 */ + /** Per-request timeout (default 30s). */ timeoutMs?: number; - /** 统一代理配置;连接层据此 + `baseUrl` host 经 `proxyFetch` 解析有效 fetch。 */ + /** Unified proxy config; the connection layer resolves the effective fetch from it + `baseUrl` host via `proxyFetch`. */ proxy?: ProxyConfig; /** - * 代理 fetch 工厂(注入口)。由组合根(desktop)提供 undici 实现;连接层据 `proxy` + `baseUrl` host - * 调用它解析代理感知 fetch。未提供则不挂代理(即便 `proxy` 存在也直连)。 + * Proxy fetch factory (injection point). The undici implementation is provided by the composition root (desktop); the connection layer resolves the proxy-aware fetch by + * calling it with `proxy` + `baseUrl` host. When not provided, no proxy is attached (direct connection even if `proxy` exists). */ proxyFetch?: ProxyFetchFactory; - /** 显式 fetch 覆盖(测试桩 / 已自行解析代理);给定则优先于 `proxy` 解析。 */ + /** Explicit fetch override (test stub / proxy already resolved on its own); when given, takes priority over `proxy` resolution. */ fetch?: FetchLike; } /** - * 平台连接传输端口(port)。领域基类只依赖此接口发起调用,不感知底层 fetch / 鉴权 / 分页 / 错误解析 - * 实现。各平台包提供「统一连接封装实例」实现本端口(见 docs/arch/01-platform/01-adapter.md §1)。 + * Platform connection transport port. Domain base classes depend only on this interface to make calls, unaware of the underlying fetch / auth / pagination / error-parsing + * implementation. Each platform package provides a "unified connection wrapper instance" implementing this port (see docs/arch/01-platform/01-adapter.md §1). * - * 仅声明三平台同构的**最小连接能力**——纯 JSON 读写 + 分页。平台特有方法(GitHub PATCH / search、各平台 - * 信任模型迥异的二进制拉取等)由各自传输实现作为端口之外的扩展提供,不污染通用契约;二进制资源由 - * MediaService 领域基类按平台抽象(见 §3.2),故不进本端口。 + * Declares only the **minimal connection capability** isomorphic across the three platforms—pure JSON read/write + pagination. Platform-specific methods (GitHub PATCH / search, binary fetches whose + * trust models differ wildly across platforms, etc.) are provided by each transport implementation as extensions outside the port, not polluting the common contract; binary resources are + * abstracted per platform by the MediaService domain base class (see §3.2), so they do not enter this port. */ export interface PlatformTransport { - /** GET,返回 JSON 体。 */ + /** GET, returns the JSON body. */ get<T>(path: string, params?: Record<string, string>): Promise<T>; - /** GET,返回 JSON 体 + 响应头(读服务端版本 / 当前用户 / 分页头用)。 */ + /** GET, returns the JSON body + response headers (for reading server version / current user / pagination headers). */ getWithHeaders<T>( path: string, params?: Record<string, string>, ): Promise<{ body: T; headers: Headers }>; - /** POST JSON,返回 JSON 体。 */ + /** POST JSON, returns the JSON body. */ post<T>(path: string, body: unknown): Promise<T>; - /** PUT JSON;部分端点 204 无体,返回 null。 */ + /** PUT JSON; some endpoints return 204 with no body, returning null. */ put<T>(path: string, body: unknown): Promise<T | null>; - /** DELETE,无返回体。 */ + /** DELETE, no return body. */ del(path: string): Promise<void>; - /** 列表分页迭代器(平台各自的分页风格在实现内收口为统一异步迭代)。 */ + /** List pagination iterator (each platform's pagination style is funneled inside the implementation into a unified async iteration). */ paginate<T>(path: string, params?: Record<string, string>): AsyncIterable<T>; } diff --git a/packages/platform-core/tests/features.test.ts b/packages/platform-core/tests/features.test.ts index 89b7d44d..b5da4cc2 100644 --- a/packages/platform-core/tests/features.test.ts +++ b/packages/platform-core/tests/features.test.ts @@ -33,7 +33,7 @@ describe('MutableConnectionContext + BaseConnection', () => { const user: PlatformUser = { name: 'me', displayName: 'Me' }; conn.setCurrentUser(user); expect(conn.getCurrentUser()).toBe(user); - // 写到 connection 即写进共享 ctx —— 其它领域服务读同一份。 + // writing to the connection writes into the shared ctx — other domain services read the same copy. expect(ctx.getCurrentUser()).toBe(user); }); diff --git a/packages/platform-github/src/adapter.ts b/packages/platform-github/src/adapter.ts index c072525a..2151504d 100644 --- a/packages/platform-github/src/adapter.ts +++ b/packages/platform-github/src/adapter.ts @@ -8,8 +8,8 @@ import { GitHubMediaService } from './features/media.js'; export { normalizeGitHubApiBase, type GitHubAdapterOptions } from './client.js'; /** - * GitHub 适配器:领域服务容器(connection / prs / comments / media),四个领域共享一份连接上下文 - * (统一连接封装实例 + 当前用户缓存)。 + * GitHub adapter: domain service container (connection / prs / comments / media); the four domains share one connection context + * (unified connection wrapper instance + current user cache). */ export class GitHubAdapter implements PlatformAdapter { readonly kind = 'github' as const; diff --git a/packages/platform-github/src/client.ts b/packages/platform-github/src/client.ts index 40328b2e..650f49f6 100644 --- a/packages/platform-github/src/client.ts +++ b/packages/platform-github/src/client.ts @@ -12,13 +12,13 @@ import { type PlatformTransport, } from '@meebox/platform-core'; -/** GitHub 连接配置 = 统一连接配置 + clone 协议(连接层自管的连接配置,非 HTTP 传输细节)。 */ +/** GitHub connection config = unified connection config + clone protocol (connection-layer-managed connection config, not HTTP transport details). */ export interface GitHubClientOptions extends PlatformConnectionConfig { - /** clone 协议:'pat'(默认)走 HTTPS + 用户名:PAT;'ssh' 走系统 ssh 配置 */ + /** clone protocol: 'pat' (default) uses HTTPS + username:PAT; 'ssh' uses the system ssh config */ cloneProtocol?: 'pat' | 'ssh'; } -/** 适配器构造选项与连接配置同形。 */ +/** Adapter construction options are the same shape as the connection config. */ export type GitHubAdapterOptions = GitHubClientOptions; export class GitHubClientError extends Error { @@ -36,9 +36,9 @@ const API_VERSION = '2022-11-28'; const ACCEPT = 'application/vnd.github+json'; /** - * 容错归一 GitHub API base:用户可只填实例地址或完整 API base。 - * - `github.com` / `www.github.com`(或留空场景的官方域)→ 官方 API host `https://api.github.com`; - * - GitHub Enterprise Server 实例根 `https://ghe.example.com` → 补 `/api/v3`(已带 `/api/vN` 则原样)。 + * Fault-tolerant normalization of the GitHub API base: the user may enter only the instance address or a full API base. + * - `github.com` / `www.github.com` (or the official domain in the empty case) → official API host `https://api.github.com`; + * - GitHub Enterprise Server instance root `https://ghe.example.com` → append `/api/v3` (kept as-is if it already has `/api/vN`). */ export function normalizeGitHubApiBase(input: string): string { const trimmed = input.trim().replace(/\/+$/, ''); @@ -57,12 +57,13 @@ export function normalizeGitHubApiBase(input: string): string { } /** - * GitHub REST 客户端 = 统一连接封装实例,实现 {@link PlatformTransport}:自管连接 / 鉴权配置(base - * URL 归一、PAT、超时、代理解析)与 GitHub 连接派生态(web/git host、clone 协议、clone URL 构造)。 - * 通用传输样板复用 `@meebox/platform-core` helper;GitHub 特有部分(鉴权头 / 限流提示 / 可信资产域 / - * search / patch / clone)留在本类。业务语义留给各领域服务。 + * GitHub REST client = unified connection wrapper instance implementing {@link PlatformTransport}: self-manages connection / + * auth config (base URL normalization, PAT, timeout, proxy resolution) and GitHub connection-derived state (web/git host, + * clone protocol, clone URL construction). Generic transport boilerplate reuses `@meebox/platform-core` helpers; + * GitHub-specific parts (auth headers / rate-limit hints / trusted asset hosts / search / patch / clone) stay in this class. + * Business semantics are left to the domain services. * - * path 以 `/` 开头时拼 baseUrl;传入完整 http(s) URL 时原样请求(分页 next / 头像等用)。 + * When path starts with `/` it is joined onto baseUrl; when a full http(s) URL is passed it is requested as-is (used for pagination next / avatars etc.). */ export class GitHubClient implements PlatformTransport { private readonly baseUrl: string; @@ -70,7 +71,7 @@ export class GitHubClient implements PlatformTransport { private readonly fetchFn: FetchLike; private readonly timeoutMs: number; private readonly cloneProtocol: 'pat' | 'ssh'; - /** web / git host base(api.github.com → https://github.com;GHE → 实例 host)。 */ + /** web / git host base (api.github.com → https://github.com; GHE → instance host). */ readonly webBase: string; private readonly gitHost: string; @@ -78,12 +79,12 @@ export class GitHubClient implements PlatformTransport { const apiBase = normalizeGitHubApiBase(opts.baseUrl); this.baseUrl = stripTrailingSlash(apiBase); this.token = opts.token; - // 连接层统一解析有效 fetch(显式 fetch 覆盖 > 代理 > 直连)。 + // Connection layer uniformly resolves the effective fetch (explicit fetch override > proxy > direct). this.fetchFn = resolveConnectionFetch({ ...opts, baseUrl: apiBase }); this.timeoutMs = opts.timeoutMs ?? 30_000; this.cloneProtocol = opts.cloneProtocol ?? 'pat'; const api = new URL(apiBase); - // github.com 的 API 在 api.github.com,但 clone/web 在 github.com;GHE 同 host。 + // github.com's API is at api.github.com, but clone/web is at github.com; GHE shares the same host. this.webBase = api.hostname === 'api.github.com' ? 'https://github.com' : `${api.protocol}//${api.host}`; this.gitHost = new URL(this.webBase).host; @@ -115,10 +116,10 @@ export class GitHubClient implements PlatformTransport { private async err(res: Response, method: string, urlOrPath: string): Promise<GitHubClientError> { const txt = await res.text().catch(() => ''); - // GitHub 错误体是 JSON,message 才是真因(如合并 405「Pull Request is not mergeable」)。 + // GitHub error bodies are JSON, and message is the real cause (e.g. merge 405 "Pull Request is not mergeable"). const apiMsg = extractApiMessage(txt); const detail = apiMsg ? `:${apiMsg}` : ''; - // 限流(403/429 + X-RateLimit-Remaining: 0)给更可读的提示,便于上层节流。 + // Rate limit (403/429 + X-RateLimit-Remaining: 0) gives a more readable hint to help upper layers throttle. const remaining = res.headers.get('x-ratelimit-remaining'); const rateLimited = (res.status === 403 || res.status === 429) && remaining === '0'; const hint = rateLimited ? '(GitHub API 限流,请稍后重试)' : ''; @@ -134,7 +135,7 @@ export class GitHubClient implements PlatformTransport { return body; } - /** 同 get,但同时返回响应头(ping 读 GHE 版本 / 分页读 Link 用)。 */ + /** Same as get, but also returns the response headers (used by ping to read the GHE version / by pagination to read Link). */ async getWithHeaders<T>( path: string, params?: Record<string, string>, @@ -158,7 +159,7 @@ export class GitHubClient implements PlatformTransport { return (await res.json()) as T; } - /** PUT;部分端点(merge / dismissals)返回 JSON,留空时返回 null。 */ + /** PUT; some endpoints (merge / dismissals) return JSON, returns null when empty. */ async put<T>(path: string, body: unknown): Promise<T | null> { const res = await this.raw('PUT', buildUrl(this.baseUrl, path), body); if (!res.ok) throw await this.err(res, 'PUT', path); @@ -176,8 +177,8 @@ export class GitHubClient implements PlatformTransport { } /** - * GitHub `Link` 头分页:列表端点返回 JSON 数组,下一页地址在 `Link: <url>; rel="next"`。 - * 逐页跟 next 直到没有。per_page=100。 + * GitHub `Link` header pagination: list endpoints return a JSON array, the next-page address is in `Link: <url>; rel="next"`. + * Follow next page by page until there is none. per_page=100. */ async *paginate<T>(path: string, params: Record<string, string> = {}): AsyncIterable<T> { let url: string | null = buildUrl(this.baseUrl, path, { per_page: '100', ...params }); @@ -191,8 +192,8 @@ export class GitHubClient implements PlatformTransport { } /** - * Search 端点(`/search/issues` 等):返回 `{ items, total_count }`,分页同样走 Link 头。 - * 注意搜索 30 次/分限流;调用方应节流。 + * Search endpoints (`/search/issues` etc.): return `{ items, total_count }`, pagination also goes through the Link header. + * Note the search rate limit of 30/min; callers should throttle. */ async *searchItems<T>(path: string, params: Record<string, string>): AsyncIterable<T> { let url: string | null = buildUrl(this.baseUrl, path, { per_page: '100', ...params }); @@ -206,8 +207,8 @@ export class GitHubClient implements PlatformTransport { } /** - * 构造 git clone URL:ssh → `git@<gitHost>:<proj>/<repo>.git`;pat → 在 web host 内嵌 - * `<currentUser>:<PAT>`。pat 需 ping() 已落地当前用户(由调用方经连接上下文传入),否则抛错。 + * Construct the git clone URL: ssh → `git@<gitHost>:<proj>/<repo>.git`; pat → embed + * `<currentUser>:<PAT>` in the web host. pat requires ping() to have already landed the current user (passed in by the caller via connection context), otherwise it throws. */ getCloneUrl(repo: RepoRef, currentUserName?: string): string { if (this.cloneProtocol === 'ssh') { @@ -226,10 +227,10 @@ export class GitHubClient implements PlatformTransport { } /** - * 判断目标 host 是否为本连接可信的 GitHub/GHE 资产域 —— 只有可信域才会带 PAT。 - * github.com:api.github.com + github.com + *.githubusercontent.com(头像 / user-attachments - * 等都在此)。GHE:实例 host 及其子域(媒体资产常在同实例下)。其余(评论里攻击者放的外部 - * 图片 URL)一律不带凭据,避免 PAT 被外发泄露。 + * Determine whether the target host is a trusted GitHub/GHE asset host for this connection — only trusted hosts carry the PAT. + * github.com: api.github.com + github.com + *.githubusercontent.com (avatars / user-attachments + * etc. are all here). GHE: the instance host and its subdomains (media assets are usually under the same instance). Everything else (external + * image URLs placed by an attacker in a comment) carries no credentials, to avoid the PAT leaking outbound. */ private isTrustedAssetHost(host: string): boolean { const apiHost = new URL(this.baseUrl).host; @@ -241,15 +242,15 @@ export class GitHubClient implements PlatformTransport { host.endsWith('.githubusercontent.com') ); } - // GHE:实例同 host 或其子域 + // GHE: same host as the instance or its subdomains return host === apiHost || host.endsWith(`.${apiHost}`); } /** - * 拉二进制资源(头像 / 评论内嵌图片)。url 为完整 http(s)。**只代理可信 GitHub/GHE 资产域** - * (带 PAT 取私有资源);非可信 host(如评论里攻击者放的外部图片 URL)直接返回 null —— 既不 - * 外发 PAT(防泄露),也不让主进程去代拉任意外部 URL(防 SSRF),交渲染层退回原生 <img> 加载。 - * 非 2xx / 异常 → 同样返回 null 让上层 fallback。 + * Fetch a binary resource (avatar / comment inline image). url is a full http(s). **Only proxies trusted GitHub/GHE asset hosts** + * (carrying the PAT to fetch private resources); non-trusted hosts (e.g. external image URLs placed by an attacker in a comment) return null directly — neither + * sending out the PAT (preventing leakage) nor letting the main process proxy-fetch arbitrary external URLs (preventing SSRF), leaving the renderer to fall back to native <img> loading. + * Non-2xx / exception → also returns null to let the upper layer fall back. */ async getBinary(url: string): Promise<BinaryResource | null> { if (!/^https?:\/\//.test(url)) return null; diff --git a/packages/platform-github/src/features/comment.ts b/packages/platform-github/src/features/comment.ts index bfa26619..e6d0721a 100644 --- a/packages/platform-github/src/features/comment.ts +++ b/packages/platform-github/src/features/comment.ts @@ -10,7 +10,7 @@ import type { GhReviewComment, } from '../types.js'; -/** GitHub 反应 content ↔ 规范化 emoji 字符(固定 8 种,与 REACTION_PICKER 同序)。 */ +/** GitHub reaction content ↔ normalized emoji character (fixed 8 kinds, same order as REACTION_PICKER). */ const GH_REACTIONS: ReadonlyArray<readonly [keyof Omit<GhReactionRollup, 'total_count'>, string]> = [ ['+1', '👍'], ['-1', '👎'], @@ -23,7 +23,7 @@ const GH_REACTIONS: ReadonlyArray<readonly [keyof Omit<GhReactionRollup, 'total_ ]; const GH_CONTENT_BY_EMOJI = new Map(GH_REACTIONS.map(([content, emoji]) => [emoji, content])); -/** GitHub 评论领域:issue(summary)+ review(inline)两套端点归一为统一评论树。 */ +/** GitHub comment domain: issue (summary) + review (inline) two endpoint sets normalized into a unified comment tree. */ export class GitHubCommentService extends BaseCommentService { constructor( ctx: ConnectionContext, @@ -33,9 +33,9 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 并发拉取 issue 评论与 review 评论并归一为统一评论树。 + * Concurrently fetch issue comments and review comments and normalize into a unified comment tree. * - * issue 评论作为无线程的 summary;review 评论按 in_reply_to_id 还原为顶层 + 嵌套 replies。 + * Issue comments serve as thread-less summaries; review comments are reconstructed by in_reply_to_id into top-level + nested replies. */ async listPullRequestComments(repo: RepoRef, prId: string): Promise<PrComment[]> { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; @@ -44,14 +44,14 @@ export class GitHubCommentService extends BaseCommentService { collect(this.client.paginate<GhReviewComment>(`${prefix}/pulls/${prId}/comments`)), ]); - // 反应聚合(counts)随评论响应即得;`mine` 需另查列表端点——仅对有反应的评论拉、并行, - // 把当前用户已反应的 content 收进 Map(按 issue/review 前缀区分 id 空间)。 + // Reaction aggregation (counts) comes with the comment response; `mine` needs a separate list endpoint — fetched only for comments with reactions, in parallel, + // collecting the content the current user has reacted with into a Map (id space distinguished by issue/review prefix). const mineByKey = await this.loadMineReactions(prefix, issueComments, reviewComments); - // issue 评论 = summary(无线程) + // issue comments = summary (thread-less) const summary = issueComments.map((c) => this.mapIssueComment(c, mineByKey.get(`i:${c.id}`))); - // review 评论 = inline,按 in_reply_to_id 还原成 顶层 + 嵌套 replies + // review comments = inline, reconstructed by in_reply_to_id into top-level + nested replies const repliesByParent = new Map<number, GhReviewComment[]>(); const tops: GhReviewComment[] = []; for (const rc of reviewComments) { @@ -75,8 +75,8 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 并行拉取「有反应」评论的反应列表,返回 `Map<'i:'|'r:'+id, Set<content>>`(当前用户已反应的 content)。 - * 只对 `reactions.total_count > 0` 的评论发请求——大多数评论无反应,故额外请求数受真实反应数约束。 + * Fetch in parallel the reaction lists of comments "with reactions", returning `Map<'i:'|'r:'+id, Set<content>>` (content the current user has reacted with). + * Only sends requests for comments with `reactions.total_count > 0` — most comments have no reactions, so the extra request count is bounded by the real reaction count. */ private async loadMineReactions( prefix: string, @@ -101,7 +101,7 @@ export class GitHubCommentService extends BaseCommentService { for (const r of list) if (r.user?.login === login) mine.add(r.content); out.set(key, mine); } catch { - // 反应是增强项,单条评论的反应列表拉取失败不应拖垮整个评论列表(counts 仍按 rollup 展示)。 + // Reactions are an enhancement; a failed reaction-list fetch for a single comment should not drag down the entire comment list (counts are still shown from the rollup). } }), ); @@ -109,8 +109,8 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 切换当前用户对评论的 emoji 反应。kind=summary → issue 反应端点;inline → review 反应端点。 - * add:POST 幂等(已反应则原样返回);remove:先列表找到自己该 content 的反应 id 再 DELETE,不存在则跳过。 + * Toggle the current user's emoji reaction on a comment. kind=summary → issue reaction endpoint; inline → review reaction endpoint. + * add: POST is idempotent (returns as-is if already reacted); remove: first find own reaction id for that content in the list, then DELETE, skip if not present. */ override async toggleReaction( repo: RepoRef, @@ -138,7 +138,7 @@ export class GitHubCommentService extends BaseCommentService { await this.client.del(`${base}/${mineOne.id}`); } - /** 反应聚合(counts)+ 当前用户已反应集合 → 中性 PrReaction[](按固定 8 序、过滤 0 计数)。 */ + /** Reaction aggregation (counts) + the current user's reacted set → neutral PrReaction[] (in fixed 8 order, filtering out 0 counts). */ private buildReactions( rollup: GhReactionRollup | undefined, mine: Set<string> | undefined, @@ -153,10 +153,10 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 发表 summary 评论:经 issue 评论端点创建(无线程、无锚点)后归一返回。 + * Publish a summary comment: created via the issue comment endpoint (thread-less, anchor-less) and returned normalized. */ async publishSummaryComment(repo: RepoRef, prId: string, body: string): Promise<PrComment> { - // summary 评论 = issue 评论(无线程、无锚点) + // summary comment = issue comment (thread-less, anchor-less) const created = await this.client.post<GhIssueComment>( `/repos/${repo.projectKey}/${repo.repoSlug}/issues/${prId}/comments`, { body }, @@ -165,7 +165,7 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 发表 inline 评论:先拉 PR 取 head sha 作 commit_id,再按锚点(路径 / 行 / 侧)创建 review 评论。 + * Publish an inline comment: first fetch the PR to get head sha as commit_id, then create a review comment by the anchor (path / line / side). */ async publishInlineComment( repo: RepoRef, @@ -174,7 +174,7 @@ export class GitHubCommentService extends BaseCommentService { body: string, ): Promise<PrComment> { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; - // 行内评论需 commit_id = head sha;按 Phase 0 决策,adapter 内部拉 PR 取 head sha + // Inline comments need commit_id = head sha; per the Phase 0 decision, the adapter internally fetches the PR to get head sha const pull = await this.client.get<GhPull>(`${prefix}/pulls/${prId}`); const created = await this.client.post<GhReviewComment>(`${prefix}/pulls/${prId}/comments`, { body, @@ -187,9 +187,9 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 回复评论:优先按 inline review-comment 的 replies 端点回复。 + * Reply to a comment: prefer replying via the inline review-comment replies endpoint. * - * 父评论实为 summary(issue 评论、无线程)时端点返回 404/422,退化为新建一条 issue 评论。 + * When the parent comment is actually a summary (issue comment, thread-less) the endpoint returns 404/422, falling back to creating a new issue comment. */ async replyToComment( repo: RepoRef, @@ -199,14 +199,14 @@ export class GitHubCommentService extends BaseCommentService { ): Promise<PrComment> { const prefix = `/repos/${repo.projectKey}/${repo.repoSlug}`; try { - // 优先按 inline review-comment 回复 + // Prefer replying via the inline review-comment const created = await this.client.post<GhReviewComment>( `${prefix}/pulls/${prId}/comments/${parentCommentId}/replies`, { body }, ); return this.mapReviewComment(created); } catch (e) { - // 父评论是 summary(issue 评论,无线程)→ 退化为新建 issue 评论 + // Parent comment is a summary (issue comment, thread-less) → fall back to creating a new issue comment if (e instanceof GitHubClientError && (e.status === 404 || e.status === 422)) { const created = await this.client.post<GhIssueComment>( `${prefix}/issues/${prId}/comments`, @@ -219,9 +219,9 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 编辑评论 body:先按 review 评论端点尝试,404 时回退到 issue 评论端点。 + * Edit a comment body: try the review comment endpoint first, fall back to the issue comment endpoint on 404. * - * GitHub 无乐观锁,version 参数被忽略。 + * GitHub has no optimistic lock, the version parameter is ignored. */ async editComment( repo: RepoRef, @@ -250,9 +250,9 @@ export class GitHubCommentService extends BaseCommentService { } /** - * 删除评论:先按 review 评论端点尝试,404 时回退到 issue 评论端点。 + * Delete a comment: try the review comment endpoint first, fall back to the issue comment endpoint on 404. * - * GitHub 无乐观锁,version 参数被忽略。 + * GitHub has no optimistic lock, the version parameter is ignored. */ async deleteComment( repo: RepoRef, @@ -272,10 +272,10 @@ export class GitHubCommentService extends BaseCommentService { } } - // ---- 映射(领域私有)---- + // ---- Mapping (domain-private) ---- /** - * 把 GitHub issue 评论归一为 summary 类 PrComment;无锚点、无线程,version 置 0 作无锁哨兵。 + * Normalize a GitHub issue comment into a summary-kind PrComment; anchor-less, thread-less, version set to 0 as a lock-less sentinel. */ private mapIssueComment(c: GhIssueComment, mine?: Set<string>): PrComment { return { @@ -289,16 +289,16 @@ export class GitHubCommentService extends BaseCommentService { kind: 'summary', nativeId: String(c.id), reactions: this.buildReactions(c.reactions, mine), - // GitHub 无乐观锁:置 0 作「无需并发令牌」哨兵,让 canEdit/canDelete 判定与编辑/删除 IPC - // 的 version: number 契约统一通过(editComment/deleteComment 忽略 version)。 + // GitHub has no optimistic lock: set to 0 as a "no concurrency token needed" sentinel, so canEdit/canDelete decisions and the edit/delete IPC + // version: number contract uniformly pass (editComment/deleteComment ignore version). version: 0, }; } /** - * 把 GitHub review 评论归一为 inline 类 PrComment。 + * Normalize a GitHub review comment into an inline-kind PrComment. * - * 锚点按 line / original_line 与 side 推导;GitHub 不直接给行类型,按 side 取保守默认(仅展示用)。 + * The anchor is derived from line / original_line and side; GitHub does not directly give the line type, so a conservative default is taken by side (display-only). */ private mapReviewComment(c: GhReviewComment, mine?: Set<string>): PrComment { const line = c.line ?? c.original_line ?? null; @@ -308,7 +308,7 @@ export class GitHubCommentService extends BaseCommentService { path: c.path, line, side: c.side === 'LEFT' ? 'old' : 'new', - // GitHub 不直接给 added/removed/context;按 side 取保守默认(仅展示用) + // GitHub does not directly give added/removed/context; take a conservative default by side (display-only) lineType: c.side === 'LEFT' ? 'removed' : 'added', } : null; @@ -324,7 +324,7 @@ export class GitHubCommentService extends BaseCommentService { threadId: String(c.id), nativeId: String(c.id), reactions: this.buildReactions(c.reactions, mine), - // 无乐观锁哨兵,同 mapIssueComment。 + // Lock-less sentinel, same as mapIssueComment. version: 0, }; } diff --git a/packages/platform-github/src/features/connection.ts b/packages/platform-github/src/features/connection.ts index 0b693ebe..188ce117 100644 --- a/packages/platform-github/src/features/connection.ts +++ b/packages/platform-github/src/features/connection.ts @@ -3,7 +3,7 @@ import { BaseConnection, type ConnectionContext } from '@meebox/platform-core'; import type { GitHubClient } from '../client.js'; import type { GhUser } from '../types.js'; -/** GitHub 连接领域:能力声明、ping(含 GHE 版本)、PAT/SSH clone URL。 */ +/** GitHub connection domain: capability declaration, ping (including GHE version), PAT/SSH clone URL. */ export class GitHubConnection extends BaseConnection { readonly kind = 'github' as const; @@ -15,9 +15,9 @@ export class GitHubConnection extends BaseConnection { } /** - * GitHub 能力:三态审批(APPROVE / REQUEST_CHANGES / dismiss)、行内多行评论;无评论乐观锁; - * 合并否决项只能近似(mergeable_state,partial);发现走 search 强限流。 - * 「解决线程 / suggestion 应用 / pending-review 成组」当前未实现 → 置 false(Phase 4 再开)。 + * GitHub capabilities: three-state approval (APPROVE / REQUEST_CHANGES / dismiss), inline multi-line comments; no comment optimistic lock; + * merge vetoes can only be approximated (mergeable_state, partial); discovery goes through search with a hard rate limit. + * "resolvable threads / suggestion apply / pending-review grouping" are currently unimplemented → set to false (to be enabled in Phase 4). */ capabilities(): PlatformCapabilities { return { @@ -25,9 +25,9 @@ export class GitHubConnection extends BaseConnection { inlineComments: true, inlineMultiline: true, commentOptimisticLock: false, - // GitHub Reactions API 仅固定 8 种 → fixed。 + // GitHub Reactions API has only a fixed 8 kinds → fixed. commentReactions: 'fixed', - // GitHub 无公开评论附件上传 API(web 端走未文档化的私有端点)→ 关闭,UI 隐藏粘贴上传。 + // GitHub has no public comment attachment upload API (the web uses an undocumented private endpoint) → off, UI hides paste-upload. commentAttachments: false, commentHardBreaks: true, mergeVetoFidelity: 'partial', @@ -37,15 +37,15 @@ export class GitHubConnection extends BaseConnection { suggestions: false, reviewGrouping: false, activityTimeline: true, - // comments + review_comments 含行内回复 → 计数变化可靠反映回复,poller 仅在计数/更新时间变化时扫。 + // comments + review_comments include inline replies → count changes reliably reflect replies, the poller only scans when count/update time changes. commentCountIncludesReplies: true, }; } /** - * 探测连接:取当前用户落地缓存,并从响应头读取 GHE 版本号。 + * Probe the connection: fetch the current user to land the cache, and read the GHE version number from the response headers. * - * 公有 github.com 无版本头时 serverVersion 记为 'github.com'。 + * On public github.com with no version header, serverVersion is recorded as 'github.com'. */ async ping(): Promise<PingResult> { const { body: me, headers } = await this.client.getWithHeaders<GhUser>('/user'); @@ -60,7 +60,7 @@ export class GitHubConnection extends BaseConnection { } /** - * 构造仓库的 git clone URL,按当前用户名内嵌 PAT 凭据(无用户时退无凭据形式)。 + * Construct the repository's git clone URL, embedding PAT credentials by the current username (falls back to a credential-less form when there is no user). */ async getCloneUrl(repo: RepoRef): Promise<string> { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); diff --git a/packages/platform-github/src/features/media.ts b/packages/platform-github/src/features/media.ts index 2c43ac6b..54070f99 100644 --- a/packages/platform-github/src/features/media.ts +++ b/packages/platform-github/src/features/media.ts @@ -6,7 +6,7 @@ import { } from '@meebox/platform-core'; import type { GitHubClient } from '../client.js'; -/** GitHub 用户与媒体领域:头像与评论内嵌图片,经传输层带 PAT 拉可信资产域。 */ +/** GitHub user and media domain: avatars and comment inline images, fetched from trusted asset hosts with the PAT via the transport layer. */ export class GitHubMediaService extends BaseMediaService { constructor( ctx: ConnectionContext, @@ -16,22 +16,22 @@ export class GitHubMediaService extends BaseMediaService { } /** - * 拉取用户头像:有 avatar_url 直链优先用之(兼容普通用户与机器人);仅有 slug 时兜底拼 `<webBase>/<login>.png`。 + * Fetch the user avatar: prefer the direct avatar_url link when present (works for regular users and bots); fall back to `<webBase>/<login>.png` when only slug is available. */ async getUserAvatar(slug: string, avatarUrl?: string): Promise<BinaryResource | null> { - // 有 avatar_url 直链优先用它:普通用户走 avatars.githubusercontent.com/u/<id>, - // 机器人走 .../in/<app_id>——后者没有 <webBase>/<login>.png(login 含 [bot])。 + // Prefer the direct avatar_url link when present: regular users go through avatars.githubusercontent.com/u/<id>, + // bots go through .../in/<app_id> — the latter has no <webBase>/<login>.png (login contains [bot]). if (avatarUrl) return this.client.getBinary(avatarUrl); - // 兜底(仅有 slug 时,如 ping 缓存的当前用户):<webBase>/<login>.png?size=64 + // Fallback (when only slug is available, e.g. the current user cached by ping): <webBase>/<login>.png?size=64 return this.client.getBinary(`${this.client.webBase}/${encodeURIComponent(slug)}.png?size=64`); } /** - * 代理拉取评论内嵌图片:内嵌图为绝对 URL,经 main 端带 PAT 拉取(私有需鉴权),失败返回 null 让上层回退。 + * Proxy-fetch a comment inline image: the inline image is an absolute URL, fetched with the PAT on the main side (private needs auth), returns null on failure to let the upper layer fall back. */ async getAttachment(url: string, _repo?: RepoRef): Promise<BinaryResource | null> { - // GitHub 评论内嵌图片是绝对 URL(user-attachments / githubusercontent / GHE host); - // 经 main 端带 PAT 代理拉(私有需鉴权)。非绝对 / 失败 → null 让上层 fallback。 + // GitHub comment inline images are absolute URLs (user-attachments / githubusercontent / GHE host); + // proxy-fetched with the PAT on the main side (private needs auth). Non-absolute / failure → null to let the upper layer fall back. return this.client.getBinary(url); } } diff --git a/packages/platform-github/src/features/pull-request.ts b/packages/platform-github/src/features/pull-request.ts index e2708321..cf406199 100644 --- a/packages/platform-github/src/features/pull-request.ts +++ b/packages/platform-github/src/features/pull-request.ts @@ -20,7 +20,7 @@ import type { GitHubClient } from '../client.js'; import { mapUser } from '../utils.js'; import type { GhCommit, GhPull, GhReview, GhSearchItem } from '../types.js'; -/** 发现筛选分类 → GitHub search 主体限定词(对齐仪表盘四类)。 */ +/** Discovery filter category → GitHub search body qualifier (aligned with the dashboard's four categories). */ const FILTER_QUALIFIER: Record<PrDiscoveryFilter, string> = { 'review-requested': 'review-requested:@me', created: 'author:@me', @@ -28,21 +28,21 @@ const FILTER_QUALIFIER: Record<PrDiscoveryFilter, string> = { mentioned: 'mentions:@me', }; -/** review 决断态 → 活动事件类型(COMMENTED / PENDING 非决断,不在表中 → 跳过)。 */ +/** review decision state → activity event kind (COMMENTED / PENDING are non-decisions, not in the table → skipped). */ const ACTIVITY_KIND_BY_STATE: Partial<Record<GhReview['state'], PrActivityKind>> = { APPROVED: 'approved', CHANGES_REQUESTED: 'needsWork', DISMISSED: 'dismissed', }; -/** review 决断态 → reviewer 状态(COMMENTED / PENDING 不改变决断态,不在表中)。 */ +/** review decision state → reviewer status (COMMENTED / PENDING do not change the decision state, not in the table). */ const REVIEWER_STATUS_BY_STATE: Partial<Record<GhReview['state'], ReviewerStatus>> = { APPROVED: 'approved', CHANGES_REQUESTED: 'needsWork', DISMISSED: 'unapproved', }; -/** GitHub PR 操作领域:发现(search 两段取数)、提交、活动决断、审批、合并。 */ +/** GitHub PR operations domain: discovery (search two-stage fetch), commits, activity decisions, approval, merge. */ export class GitHubPullRequestService extends BasePullRequestService { constructor( ctx: ConnectionContext, @@ -52,9 +52,9 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 发现待处理 PR:先经 search/issues 命中候选,再逐条取详情归一。 + * Discover pending PRs: first hit candidates via search/issues, then fetch details per item and normalize. * - * 仅保留确为 PR 的命中;逐条详情请求并发执行,单条失败丢弃该条而不拖垮整体。 + * Only hits confirmed to be PRs are kept; per-item detail requests run concurrently, a single failure discards that item without dragging down the whole. */ async listPendingPullRequests(opts?: ListPendingOptions): Promise<PullRequest[]> { const items: GhSearchItem[] = []; @@ -63,7 +63,7 @@ export class GitHubPullRequestService extends BasePullRequestService { })) { if (it.pull_request) items.push(it); } - // 每条命中再取 PR 详情(sha / mergeable / draft)+ reviews(reviewer 状态)。单个失败丢弃该条。 + // For each hit, fetch PR details (sha / mergeable / draft) + reviews (reviewer status). A single failure discards that item. const results = await Promise.allSettled(items.map((it) => this.loadPull(it))); return results .filter((r): r is PromiseFulfilledResult<PullRequest> => r.status === 'fulfilled') @@ -71,7 +71,7 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 按一条 search 命中加载完整 PR:并发取 PR 详情与 reviews,组装审批人与合并状态后归一。 + * Load the full PR from a single search hit: concurrently fetch PR details and reviews, assemble reviewers and merge status, then normalize. */ private async loadPull(item: GhSearchItem): Promise<PullRequest> { const { owner, repo } = this.parseRepositoryUrl(item.repository_url); @@ -83,7 +83,7 @@ export class GitHubPullRequestService extends BasePullRequestService { return this.mapPull(pull, this.buildReviewers(pull, reviews), this.mapMergeStatus(pull)); } - /** 按 repo + 号从远端拉单个 PR(复用 loadPull 同款组装);404 / 403 由 client 抛出供上层归一。 */ + /** Fetch a single PR from the remote by repo + number (reuses the same assembly as loadPull); 404 / 403 are thrown by the client for the upper layer to normalize. */ async getSinglePullRequest(repo: RepoRef, prId: string): Promise<PullRequest> { const base = `/repos/${repo.projectKey}/${repo.repoSlug}/pulls/${prId}`; const [pull, reviews] = await Promise.all([ @@ -94,7 +94,7 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 列出 PR 提交:GitHub 端点为 oldest-first,按契约反转为 newest-first 返回。 + * List PR commits: the GitHub endpoint is oldest-first, reversed to newest-first per the contract on return. */ async listPullRequestCommits(repo: RepoRef, prId: string): Promise<PrCommit[]> { const out: PrCommit[] = []; @@ -103,14 +103,14 @@ export class GitHubPullRequestService extends BasePullRequestService { )) { out.push(this.mapCommit(c)); } - // GitHub commits 是 oldest-first;契约要求 newest-first + // GitHub commits are oldest-first; the contract requires newest-first return out.reverse(); } /** - * 把 PR 的 reviews 提炼为评审决断活动事件。 + * Distill the PR's reviews into review-decision activity events. * - * 仅保留有提交时间的决断态(APPROVED / CHANGES_REQUESTED / DISMISSED),COMMENTED / PENDING 跳过。 + * Only decision states with a submit time (APPROVED / CHANGES_REQUESTED / DISMISSED) are kept, COMMENTED / PENDING are skipped. */ async listPullRequestActivity(repo: RepoRef, prId: string): Promise<PrActivityEvent[]> { const reviews = await collect( @@ -120,7 +120,7 @@ export class GitHubPullRequestService extends BasePullRequestService { ); const out: PrActivityEvent[] = []; for (const r of reviews) { - // COMMENTED / PENDING 不是决断;submitted_at 缺失(草稿态)跳过 + // COMMENTED / PENDING are not decisions; skip when submitted_at is missing (draft state) if (!r.user || !r.submitted_at) continue; const kind = ACTIVITY_KIND_BY_STATE[r.state]; if (!kind) continue; @@ -135,10 +135,10 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 写当前用户在 PR 上的 review 状态。 + * Write the current user's review status on the PR. * - * approved / needsWork 各提交一条 review(REQUEST_CHANGES 需带 body);unapproved 则撤销本人最近一条 - * 决断性评审(dismiss)。 + * approved / needsWork each submit one review (REQUEST_CHANGES needs a body); unapproved dismisses the user's most recent + * decisive review (dismiss). */ async setPullRequestReviewStatus( repo: RepoRef, @@ -151,14 +151,14 @@ export class GitHubPullRequestService extends BasePullRequestService { return; } if (status === 'needsWork') { - // GitHub 要求 REQUEST_CHANGES 带 body(发往 GitHub 的内容,用英语中性文案) + // GitHub requires REQUEST_CHANGES to carry a body (content sent to GitHub, using neutral English text) await this.client.post(`${prefix}/pulls/${prId}/reviews`, { event: 'REQUEST_CHANGES', body: 'Changes requested', }); return; } - // unapproved:撤销当前用户最近一条 APPROVED / CHANGES_REQUESTED 评审 + // unapproved: dismiss the current user's most recent APPROVED / CHANGES_REQUESTED review const me = this.ctx.getCurrentUser()?.name; if (!me) return; const reviews = await collect( @@ -176,29 +176,29 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 合并 PR(仅用 merge commit、不回退 squash/rebase)。 + * Merge a PR (uses merge commit only, no fallback to squash/rebase). * - * 不可合并(禁用 merge commit / 冲突 / 必评必检未过 / 落后 / 无权限)时,GitHub 返回 405 或 403, - * 错误经 client 携带响应体 message 冒泡给上层。 + * When not mergeable (merge commit disabled / conflict / required reviews or checks not passed / behind / no permission), GitHub returns 405 or 403, + * and the error bubbles up to the upper layer via the client carrying the response body message. */ async mergePullRequest(repo: RepoRef, prId: string): Promise<void> { - // 仅用 merge commit(空 body = 默认 merge_method=merge),不回退 squash/rebase。 - // 失败(仓库禁用 merge commit / 冲突 / 必评未过 / 必检未过 / 分支落后 / 无权限)→ GitHub 返回 - // 405「not mergeable」或 403,client 把响应体 message 带进 GitHubClientError 冒泡给上层。 + // Uses merge commit only (empty body = default merge_method=merge), no fallback to squash/rebase. + // On failure (repo disables merge commit / conflict / required reviews not passed / required checks not passed / branch behind / no permission) → GitHub returns + // 405 "not mergeable" or 403, the client carries the response body message into GitHubClientError and bubbles it up. await this.client.put(`/repos/${repo.projectKey}/${repo.repoSlug}/pulls/${prId}/merge`, {}); } - // ---- 映射(领域私有)---- + // ---- Mapping (domain-private) ---- /** - * 按发现筛选分类拼出 search/issues 查询串(限定开放、非归档、PR 类型)。 + * Build the search/issues query string by discovery filter category (limited to open, non-archived, PR type). */ private discoveryQuery(filter: PrDiscoveryFilter): string { return `is:open is:pr ${FILTER_QUALIFIER[filter]} archived:false`; } /** - * 从 search 命中的 repository_url 解析出 owner / repo;无法解析则抛错。 + * Parse owner / repo from the search hit's repository_url; throws if unparseable. */ private parseRepositoryUrl(repositoryUrl: string): { owner: string; repo: string } { // https://api.github.com/repos/{owner}/{repo} @@ -208,10 +208,10 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 把 GitHub 的 mergeable / mergeable_state 映射为统一合并状态。 + * Map GitHub's mergeable / mergeable_state to a unified merge status. * - * GitHub 否决信息仅 partial 保真,按 state 近似归类到单一否决码(冲突 / 受保护 / 落后 / 必检失败 / - * 检测中);canMerge 仅在 mergeable=true 且 state=clean 时为真。 + * GitHub veto info is only partial fidelity, approximately classified by state into a single veto code (conflict / protected / behind / checks failed / + * checking); canMerge is true only when mergeable=true and state=clean. */ private mapMergeStatus(p: GhPull): MergeStatus { const state = p.mergeable_state ?? 'unknown'; @@ -230,15 +230,15 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 组装审批人列表:先以「已请求但未评审」者占位(unapproved),再按时间升序用每人最近一条决断态覆盖。 + * Assemble the reviewer list: first place "requested but not yet reviewed" people as placeholders (unapproved), then override with each person's most recent decision state in ascending time order. */ private buildReviewers(pull: GhPull, reviews: GhReview[]): Reviewer[] { const byLogin = new Map<string, Reviewer>(); - // 先放「已请求但未评审」的 reviewer(pending = unapproved) + // First place "requested but not yet reviewed" reviewers (pending = unapproved) for (const u of pull.requested_reviewers ?? []) { byLogin.set(u.login, { ...mapUser(u), status: 'unapproved' }); } - // reviews 按时间升序,取每人最近一条「决断性」状态覆盖 + // reviews in ascending time order, override with each person's most recent "decisive" status const sorted = [...reviews].sort((a, b) => (a.submitted_at ?? '').localeCompare(b.submitted_at ?? ''), ); @@ -251,9 +251,9 @@ export class GitHubPullRequestService extends BasePullRequestService { } /** - * 把 GitHub PR 详情(含已组装的审批人与合并状态)归一为中性 PullRequest。 + * Normalize GitHub PR details (including the assembled reviewers and merge status) into a neutral PullRequest. * - * 状态按 merged / closed / 其余映射为 merged / declined / open。 + * State is mapped by merged / closed / else to merged / declined / open. */ private mapPull(p: GhPull, reviewers: Reviewer[], mergeStatus: MergeStatus): PullRequest { const state: PullRequest['state'] = p.merged @@ -280,13 +280,13 @@ export class GitHubPullRequestService extends BasePullRequestService { reviewers, mergeStatus, hasConflict: mergeStatus.conflicted, - // 会话评论 + 行内评审评论;行内回复本身即 review_comment → 含回复(capabilities 标 true)。 + // Conversation comments + inline review comments; an inline reply is itself a review_comment → includes replies (capabilities marks true). commentCount: (p.comments ?? 0) + (p.review_comments ?? 0), }; } /** - * 把 GitHub 提交归一为中性 PrCommit;作者 / 提交者信息缺失时按 git 名→登录名→兜底逐级回退。 + * Normalize a GitHub commit into a neutral PrCommit; when author / committer info is missing, fall back level by level git name → login → fallback. */ private mapCommit(c: GhCommit): PrCommit { const authorName = c.commit.author?.name ?? c.author?.login ?? 'unknown'; diff --git a/packages/platform-github/src/types.ts b/packages/platform-github/src/types.ts index 2a2d8934..810e8029 100644 --- a/packages/platform-github/src/types.ts +++ b/packages/platform-github/src/types.ts @@ -1,4 +1,4 @@ -// GitHub REST 响应形状(仅取用到的字段)。跨领域共享的数据类型,单独抽取于此。 +// GitHub REST response shapes (only the fields we use). Cross-domain shared data types, extracted here. export interface GhUser { login: string; @@ -30,9 +30,9 @@ export interface GhPull { requested_reviewers?: GhUser[]; mergeable?: boolean | null; mergeable_state?: string; - /** 会话(issue)评论数;仅 `/pulls/{n}` 详情带 */ + /** Conversation (issue) comment count; only present on `/pulls/{n}` detail */ comments?: number; - /** 行内评审评论数(含回复,回复本身即 review comment);仅 `/pulls/{n}` 详情带 */ + /** Inline review comment count (incl. replies, a reply is itself a review comment); only present on `/pulls/{n}` detail */ review_comments?: number; } @@ -44,8 +44,8 @@ export interface GhReview { } /** - * GitHub 评论响应内嵌的反应聚合(counts-only,无 per-user)。每种 content 一个计数 + total_count。 - * `mine`(当前用户是否已反应)需另查 `.../reactions` 列表端点——见 GitHubCommentService。 + * Reaction rollup embedded in GitHub comment responses (counts-only, no per-user). One count per content + total_count. + * `mine` (whether the current user has reacted) requires querying the `.../reactions` list endpoint separately — see GitHubCommentService. */ export interface GhReactionRollup { total_count: number; @@ -59,7 +59,7 @@ export interface GhReactionRollup { eyes: number; } -/** GitHub 单条反应(列表 / 创建端点返回)。 */ +/** A single GitHub reaction (returned by list / create endpoints). */ export interface GhReaction { id: number; user: GhUser | null; @@ -105,7 +105,7 @@ export interface GhCommit { committer: GhUser | null; } -/** search/issues 命中项(PR 形态);repository_url 形如 https://api.github.com/repos/{o}/{r} */ +/** search/issues hit (PR form); repository_url looks like https://api.github.com/repos/{o}/{r} */ export interface GhSearchItem { number: number; repository_url: string; diff --git a/packages/platform-github/src/utils.ts b/packages/platform-github/src/utils.ts index 90536fa1..b3a6968e 100644 --- a/packages/platform-github/src/utils.ts +++ b/packages/platform-github/src/utils.ts @@ -1,7 +1,7 @@ import type { PlatformUser } from '@meebox/shared'; import type { GhUser } from './types.js'; -/** GitHub user → 中性 PlatformUser。跨 PR / 评论领域共用,故留在共享模块。 */ +/** GitHub user → neutral PlatformUser. Shared across the PR / comment domains, so it lives in the shared module. */ export function mapUser(u: GhUser): PlatformUser { return { name: u.login, displayName: u.name ?? u.login, slug: u.login, avatarUrl: u.avatar_url }; } diff --git a/packages/platform-github/tests/adapter.test.ts b/packages/platform-github/tests/adapter.test.ts index 1deb22ba..5a5cc50a 100644 --- a/packages/platform-github/tests/adapter.test.ts +++ b/packages/platform-github/tests/adapter.test.ts @@ -2,7 +2,7 @@ import type { PrDiscoveryFilter } from '@meebox/shared'; import { describe, expect, it } from 'vitest'; import { GitHubAdapter, normalizeGitHubApiBase } from '../src/adapter.js'; -// ---- 路由式 mock fetch:按 method + URL 子串匹配,返回 JSON Response,并记录请求 ---- +// ---- Route-based mock fetch: match by method + URL substring, return a JSON Response, and record the request ---- interface Route { method?: string; match: string; @@ -142,13 +142,13 @@ describe('GitHubAdapter listPendingPullRequests', () => { expect(pr.repo).toEqual({ projectKey: 'acme', repoSlug: 'web' }); expect(pr.sourceRef.sha).toBe('headsha'); expect(pr.mergeStatus.canMerge).toBe(true); - // reviewer:已请求未评审(pending=unapproved) + 已 APPROVED 的 rev + // reviewer: requested-but-not-reviewed (pending=unapproved) + the already-APPROVED rev const byName = Object.fromEntries(pr.reviewers.map((r) => [r.name, r.status])); expect(byName.rev).toBe('approved'); expect(byName.pending).toBe('unapproved'); }); - it('默认 filter = review-requested,查询带 is:open(排除已合并/已关闭)', async () => { + it('default filter = review-requested, query carries is:open (excludes merged/closed)', async () => { const { adapter, captured } = makeAdapter([{ match: '/search/issues', body: { items: [] } }]); await adapter.prs.listPendingPullRequests(); const q = new URL(captured[0]!.url).searchParams.get('q') ?? ''; @@ -157,7 +157,7 @@ describe('GitHubAdapter listPendingPullRequests', () => { expect(q).toContain('review-requested:@me'); }); - it('四类发现分类映射到对应 search 限定词,均含 is:open', async () => { + it('four discovery categories map to their search qualifiers, all carrying is:open', async () => { const cases: Array<[PrDiscoveryFilter, string]> = [ ['review-requested', 'review-requested:@me'], ['created', 'author:@me'], @@ -169,7 +169,7 @@ describe('GitHubAdapter listPendingPullRequests', () => { await adapter.prs.listPendingPullRequests({ filter }); const q = new URL(captured[0]!.url).searchParams.get('q') ?? ''; expect(q).toContain(qualifier); - expect(q).toContain('is:open'); // 已合并/已关闭 PR 不应出现在任一分类 + expect(q).toContain('is:open'); // merged/closed PRs should not appear in any category } }); }); @@ -349,8 +349,8 @@ describe('GitHubAdapter mergeStatus mapping', () => { }); }); -describe('GitHubAdapter getAttachment(PAT 仅发可信域)', () => { - it('外部 host 直接 null 且不发起请求;githubusercontent 资产带 PAT 代理', async () => { +describe('GitHubAdapter getAttachment (PAT only sent to trusted domains)', () => { + it('external host returns null without a request; githubusercontent assets proxied with PAT', async () => { const { adapter, captured } = makeAdapter([ { match: 'evil.example.com', body: '' }, { match: 'githubusercontent.com', body: '' }, @@ -359,10 +359,10 @@ describe('GitHubAdapter getAttachment(PAT 仅发可信域)', () => { const asset = await adapter.media.getAttachment( 'https://avatars.githubusercontent.com/u/1?v=4', ); - // 外部 host:不代理、不请求(无 captured)、返回 null → 渲染层退回原生 <img> + // external host: not proxied, not requested (no captured), returns null → renderer falls back to native <img> expect(external).toBeNull(); expect(captured.some((c) => c.url.includes('evil.example.com'))).toBe(false); - // 可信资产域:代理并带 PAT + // trusted asset domain: proxied and carries PAT expect(asset).not.toBeNull(); const gh = captured.find((c) => c.url.includes('githubusercontent.com'))!; expect(gh.headers.Authorization).toMatch(/^Bearer /); @@ -370,16 +370,16 @@ describe('GitHubAdapter getAttachment(PAT 仅发可信域)', () => { }); describe('normalizeGitHubApiBase', () => { - it('github.com SaaS:官方 API host 原样保留(不破坏公共 SaaS 对接)', () => { + it('github.com SaaS: official API host preserved as-is (does not break public SaaS integration)', () => { expect(normalizeGitHubApiBase('https://api.github.com')).toBe('https://api.github.com'); }); - it('github.com web host → 官方 API host', () => { + it('github.com web host → official API host', () => { expect(normalizeGitHubApiBase('https://github.com')).toBe('https://api.github.com'); expect(normalizeGitHubApiBase('https://www.github.com/')).toBe('https://api.github.com'); }); - it('GHE 实例根自动补 /api/v3', () => { + it('GHE instance root auto-appends /api/v3', () => { expect(normalizeGitHubApiBase('https://ghe.example.com')).toBe( 'https://ghe.example.com/api/v3', ); @@ -388,7 +388,7 @@ describe('normalizeGitHubApiBase', () => { ); }); - it('GHE 已带 /api/v3 原样(含尾斜杠归一)', () => { + it('GHE already carrying /api/v3 preserved (with trailing-slash normalization)', () => { expect(normalizeGitHubApiBase('https://ghe.example.com/api/v3/')).toBe( 'https://ghe.example.com/api/v3', ); diff --git a/packages/platform-gitlab/src/adapter.ts b/packages/platform-gitlab/src/adapter.ts index 0e97039b..ff86b4d5 100644 --- a/packages/platform-gitlab/src/adapter.ts +++ b/packages/platform-gitlab/src/adapter.ts @@ -8,8 +8,8 @@ import { GitLabMediaService } from './features/media.js'; export { normalizeGitLabApiBase, type GitLabAdapterOptions } from './client.js'; /** - * GitLab 适配器:领域服务容器(connection / prs / comments / media),四个领域共享一份连接上下文 - * (统一连接封装实例 + 当前用户缓存)。 + * GitLab adapter: domain service container (connection / prs / comments / media), where the four domains share one + * connection context (a unified connection wrapper instance + current-user cache). */ export class GitLabAdapter implements PlatformAdapter { readonly kind = 'gitlab' as const; diff --git a/packages/platform-gitlab/src/client.ts b/packages/platform-gitlab/src/client.ts index c293d2ee..db06b586 100644 --- a/packages/platform-gitlab/src/client.ts +++ b/packages/platform-gitlab/src/client.ts @@ -12,13 +12,13 @@ import { type PlatformTransport, } from '@meebox/platform-core'; -/** GitLab 连接配置 = 统一连接配置 + clone 协议(连接层自管的连接配置,非 HTTP 传输细节)。 */ +/** GitLab connection config = unified connection config + clone protocol (connection-layer-managed connection config, not HTTP transport details). */ export interface GitLabClientOptions extends PlatformConnectionConfig { - /** clone 协议:'pat'(默认)走 HTTPS + 用户名:PAT;'ssh' 走系统 ssh 配置 */ + /** clone protocol: 'pat' (default) uses HTTPS + username:PAT; 'ssh' uses the system ssh config */ cloneProtocol?: 'pat' | 'ssh'; } -/** 适配器构造选项与连接配置同形。 */ +/** Adapter constructor options share the shape of the connection config. */ export type GitLabAdapterOptions = GitLabClientOptions; export class GitLabClientError extends Error { @@ -35,8 +35,9 @@ export class GitLabClientError extends Error { const ACCEPT = 'application/json'; /** - * 容错归一 GitLab API base:用户可只填实例地址(`https://gitlab.example.com`)或完整 - * `.../api/v4`;统一补足 `/api/v4`(已带 `/api/vN` 则原样)。免去用户记忆 API 路径。 + * Fault-tolerant normalization of the GitLab API base: users may enter just the instance address + * (`https://gitlab.example.com`) or the full `.../api/v4`; uniformly append `/api/v4` (leave as-is if it + * already carries `/api/vN`). Frees users from memorizing the API path. */ export function normalizeGitLabApiBase(input: string): string { const trimmed = input.trim().replace(/\/+$/, ''); @@ -45,12 +46,13 @@ export function normalizeGitLabApiBase(input: string): string { } /** - * 极薄的 GitLab REST v4 客户端,实现 {@link PlatformTransport}:`PRIVATE-TOKEN` PAT 鉴权、`Link` 头 - * 分页迭代器、二进制拉取、错误抛 GitLabClientError。通用传输样板(超时 / URL 拼接 / 错误消息提取 / - * Link 分页 / 有效 fetch 解析)复用 `@meebox/platform-core` helper;GitLab 特有部分(PRIVATE-TOKEN / - * 资产 host 鉴权模式 / API 二进制端点)留在本类。业务语义留给 GitLabAdapter。 + * Ultra-thin GitLab REST v4 client implementing {@link PlatformTransport}: `PRIVATE-TOKEN` PAT auth, `Link`-header + * pagination iterator, binary fetch, errors thrown as GitLabClientError. Generic transport boilerplate (timeout / URL + * building / error-message extraction / Link pagination / effective fetch resolution) reuses `@meebox/platform-core` + * helpers; GitLab-specific parts (PRIVATE-TOKEN / asset-host auth mode / API binary endpoints) stay in this class. + * Business semantics are left to GitLabAdapter. * - * path 以 `/` 开头时拼 baseUrl;传入完整 http(s) URL 时原样请求(分页 next / 头像 / 附件等用)。 + * When path starts with `/`, it is joined onto baseUrl; when a full http(s) URL is passed, it is requested as-is (used for pagination next / avatars / attachments etc.). */ export class GitLabClient implements PlatformTransport { private readonly baseUrl: string; @@ -58,13 +60,14 @@ export class GitLabClient implements PlatformTransport { private readonly fetchFn: FetchLike; private readonly timeoutMs: number; private readonly cloneProtocol: 'pat' | 'ssh'; - /** 实例 web/git host(去掉 /api/v4),clone / 附件 / 网页用。 */ + /** Instance web/git host (with /api/v4 stripped), used for clone / attachments / web pages. */ private readonly webBase: string; readonly gitHost: string; /** - * MR 审批 API(approve/unapprove)是否可用:自 13.9 起为 Premium/Ultimate,CE / EE-Free 无。 - * 由连接层 ping() 经 /metadata.enterprise 探测后写入;探测前保守置 false(CE)。是该平台连接 - * 探测得到的连接态,故落在连接封装实例上,供连接(capabilities)与 PR(审批拉取)领域共读。 + * Whether the MR approval API (approve/unapprove) is available: since 13.9 it is Premium/Ultimate, absent on CE / EE-Free. + * Written by the connection layer's ping() after edition detection via /metadata.enterprise; conservatively set to false (CE) + * before detection. This is connection state obtained by this platform's connection probe, so it lives on the connection + * wrapper instance, read by both the connection (capabilities) and PR (approval fetch) domains. */ approvalsAvailable = false; @@ -72,7 +75,7 @@ export class GitLabClient implements PlatformTransport { const apiBase = normalizeGitLabApiBase(opts.baseUrl); this.baseUrl = stripTrailingSlash(apiBase); this.token = opts.token; - // 连接层统一解析有效 fetch(显式 fetch 覆盖 > 代理 > 直连)。 + // Connection layer uniformly resolves the effective fetch (explicit fetch override > proxy > direct). this.fetchFn = resolveConnectionFetch({ ...opts, baseUrl: apiBase }); this.cloneProtocol = opts.cloneProtocol ?? 'pat'; const api = new URL(apiBase); @@ -82,13 +85,14 @@ export class GitLabClient implements PlatformTransport { } private authHeaders(): Record<string, string> { - // GitLab PAT 走 PRIVATE-TOKEN 头(OAuth token 才用 Authorization: Bearer)。 + // GitLab PAT uses the PRIVATE-TOKEN header (only OAuth tokens use Authorization: Bearer). return { 'PRIVATE-TOKEN': this.token, Accept: ACCEPT }; } /** - * 构造 git clone URL:ssh → `git@<gitHost>:<group>/<repo>.git`;pat → 在 web host 内嵌 - * `<currentUser>:<PAT>`。pat 需 ping() 已落地当前用户(由调用方经连接上下文传入),否则抛错。 + * Build the git clone URL: ssh → `git@<gitHost>:<group>/<repo>.git`; pat → embed `<currentUser>:<PAT>` + * in the web host. pat requires ping() to have already landed the current user (passed in by the caller via the + * connection context), otherwise throws. */ getCloneUrl(repo: RepoRef, currentUserName?: string): string { const path = `${repo.projectKey}/${repo.repoSlug}`; @@ -125,8 +129,8 @@ export class GitLabClient implements PlatformTransport { private async err(res: Response, method: string, urlOrPath: string): Promise<GitLabClientError> { const txt = await res.text().catch(() => ''); - // GitLab 错误体是 JSON:`{message}` 或 `{error}`(部分端点)。带进错误信息便于上层定位 - // (如合并 405「Method Not Allowed」/ 审批 403「approval ... not available」)。 + // GitLab error bodies are JSON: `{message}` or `{error}` (some endpoints). Including it in the error message helps + // the upper layers localize the issue (e.g. merge 405 "Method Not Allowed" / approval 403 "approval ... not available"). const apiMsg = extractApiMessage(txt); const detail = apiMsg ? `:${apiMsg}` : ''; return new GitLabClientError( @@ -141,7 +145,7 @@ export class GitLabClient implements PlatformTransport { return body; } - /** 同 get,但同时返回响应头(分页读 Link / X-Next-Page 用)。 */ + /** Same as get, but also returns the response headers (used to read Link / X-Next-Page for pagination). */ async getWithHeaders<T>( path: string, params?: Record<string, string>, @@ -160,8 +164,8 @@ export class GitLabClient implements PlatformTransport { } /** - * multipart/form-data POST(附件上传用)。不手动设 Content-Type——交给 fetch 按 FormData 自动加 - * boundary,否则边界缺失服务端无法解析。 + * multipart/form-data POST (for attachment upload). Do not set Content-Type manually — let fetch auto-add the + * boundary from the FormData, otherwise a missing boundary leaves the server unable to parse it. */ async postForm<T>(path: string, form: FormData): Promise<T> { const url = buildUrl(this.baseUrl, path); @@ -192,8 +196,8 @@ export class GitLabClient implements PlatformTransport { } /** - * GitLab `Link` 头分页:列表端点返回 JSON 数组,下一页地址在 `Link: <url>; rel="next"` - * (keyset / offset 分页都带)。逐页跟 next 直到没有。per_page=100。 + * GitLab `Link`-header pagination: list endpoints return a JSON array, the next-page address is in `Link: <url>; rel="next"` + * (carried by both keyset / offset pagination). Follow next page by page until none remains. per_page=100. */ async *paginate<T>(path: string, params: Record<string, string> = {}): AsyncIterable<T> { let url: string | null = buildUrl(this.baseUrl, path, { per_page: '100', ...params }); @@ -207,12 +211,12 @@ export class GitLabClient implements PlatformTransport { } /** - * 资产 host 鉴权模式: - * - `'pat'`:本连接所属 GitLab 实例 host —— 带 PAT 取(私有资产需鉴权); - * - `'public'`:公共头像 CDN(gravatar)—— GitLab 用户未设自定义头像时 `avatar_url` 即指向 - * 此,是公开图片,按公网直取且**绝不带 PAT**(防令牌泄露给第三方); - * - `null`:其它外部 host —— 不代拉(防 SSRF)、不带凭据。 - * 评论里攻击者放的任意外部图片 URL 落到 `null` 分支,既不取也不带凭据。 + * Asset-host auth mode: + * - `'pat'`: the GitLab instance host this connection belongs to — fetch with PAT (private assets need auth); + * - `'public'`: public avatar CDN (gravatar) — when a GitLab user has not set a custom avatar, `avatar_url` points + * here; it is a public image, fetched directly over the public internet and **never with PAT** (prevents token leak to third parties); + * - `null`: any other external host — not proxy-fetched (prevents SSRF), no credentials. + * An arbitrary external image URL planted by an attacker in a comment falls into the `null` branch — neither fetched nor credentialed. */ private assetHostMode(host: string): 'pat' | 'public' | null { if (host === new URL(this.baseUrl).host) return 'pat'; @@ -223,9 +227,9 @@ export class GitLabClient implements PlatformTransport { } /** - * 拉二进制资源(头像 / 评论内嵌附件)。url 为完整 http(s)。**只代理本实例 host**(带 PAT 取私有 - * 资源);公共 CDN(gravatar)公网直取不带 PAT;非白名单 host 直接返回 null(不外发 PAT、不代拉 - * 任意 URL)。非 2xx / 异常 → null 让上层 fallback。 + * Fetch a binary resource (avatar / comment-embedded attachment). url is a full http(s). **Only proxy this instance's host** + * (fetch private resources with PAT); public CDN (gravatar) is fetched directly over the public internet without PAT; + * non-allowlisted hosts return null directly (do not send out PAT, do not proxy-fetch arbitrary URLs). non-2xx / exception → null to let the upper layer fall back. */ async getBinary(url: string): Promise<BinaryResource | null> { if (!/^https?:\/\//.test(url)) return null; @@ -241,16 +245,16 @@ export class GitLabClient implements PlatformTransport { } /** - * 拉 API 相对路径的二进制(始终本实例 + PAT)。用于私有项目 markdown 上传的 API 下载端点 - * `GET /projects/:id/uploads/:secret/:filename`(GitLab 17.4+;旧版无此路由 → 404 → null)。 - * 上传的 web 路由 `/<ns>/<proj>/uploads/...` 对 PAT 一律 302 到登录页,故私有上传只能走 API。 + * Fetch a binary from an API-relative path (always this instance + PAT). Used for the API download endpoint of + * private-project markdown uploads `GET /projects/:id/uploads/:secret/:filename` (GitLab 17.4+; older versions lack this route → 404 → null). + * The upload web route `/<ns>/<proj>/uploads/...` always 302s a PAT to the sign-in page, so private uploads can only go through the API. */ async getApiBinary(path: string): Promise<BinaryResource | null> { return this.fetchBinary(buildUrl(this.baseUrl, path), true); } private async fetchBinary(url: string, withPat: boolean): Promise<BinaryResource | null> { - // 本实例 / API 资产带 PAT;公共 CDN(gravatar)绝不带 PAT,避免把令牌发给第三方。 + // This instance / API assets carry PAT; public CDN (gravatar) never carries PAT, avoiding sending the token to third parties. const headers: Record<string, string> = { Accept: 'image/*,*/*;q=0.5' }; if (withPat) headers['PRIVATE-TOKEN'] = this.token; let res: Response; @@ -261,8 +265,8 @@ export class GitLabClient implements PlatformTransport { } if (!res.ok) return null; const contentType = res.headers.get('content-type') ?? 'application/octet-stream'; - // text/html = 登录重定向 / 错误页(如私有上传 web 路由 302→sign_in),不是资产 → null, - // 避免把 HTML 当图片塞进 data URL 显示成损坏图标。 + // text/html = login redirect / error page (e.g. private-upload web route 302→sign_in), not an asset → null, + // avoiding stuffing HTML into a data URL as an image that renders as a broken icon. if (contentType.toLowerCase().startsWith('text/html')) return null; const buf = await res.arrayBuffer(); return { bytes: new Uint8Array(buf), contentType }; diff --git a/packages/platform-gitlab/src/features/comment.ts b/packages/platform-gitlab/src/features/comment.ts index e8bff8ef..cdf4b602 100644 --- a/packages/platform-gitlab/src/features/comment.ts +++ b/packages/platform-gitlab/src/features/comment.ts @@ -11,7 +11,7 @@ import { GitLabClientError, type GitLabClient } from '../client.js'; import { mapUser, projectId } from '../utils.js'; import type { GlAwardEmoji, GlDiscussion, GlMr, GlNote } from '../types.js'; -/** GitLab 评论领域:discussions + notes 归一为统一评论树(首 note 顶层、其余 reply)。 */ +/** GitLab comment domain: discussions + notes normalized into a unified comment tree (first note is top-level, the rest are replies). */ export class GitLabCommentService extends BaseCommentService { constructor( ctx: ConnectionContext, @@ -21,20 +21,20 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 拉取 MR discussions 并归一为统一评论树:每个 discussion 首 note 为顶层、其余为 reply。 + * Fetch MR discussions and normalize into a unified comment tree: each discussion's first note is top-level, the rest are replies. * - * 过滤 system note(状态变更 / 指派等自动事件);全为 system note 的 discussion 跳过。 + * Filters out system notes (automatic events like status changes / assignments); discussions that are entirely system notes are skipped. */ async listPullRequestComments(repo: RepoRef, prId: string): Promise<PrComment[]> { const me = this.ctx.getCurrentUser()?.name; const base = `/projects/${projectId(repo)}/merge_requests/${prId}`; - // 先把所有非 system note 平铺收齐(保留 discussion 归属),再并行拉每条 note 的 award emoji。 + // First collect all non-system notes flat (keeping discussion ownership), then fetch each note's award emoji in parallel. const discussions: Array<{ id: string; notes: GlNote[] }> = []; for await (const d of this.client.paginate<GlDiscussion>(`${base}/discussions`)) { const notes = d.notes.filter((n) => !n.system); if (notes.length > 0) discussions.push({ id: d.id, notes }); } - // GitLab note 响应不内嵌 award emoji → 每条 note 单独查(并行)。量受评论数约束、且走评论缓存。 + // GitLab note responses don't embed award emoji → query each note separately (in parallel). Volume is bounded by comment count and goes through the comment cache. const awardsByNote = await this.loadAwards( base, discussions.flatMap((d) => d.notes.map((n) => n.id)), @@ -48,8 +48,8 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 并行拉取给定 note 列表的 award emoji,返回 `Map<noteId, GlAwardEmoji[]>`。 - * GitLab 无 note 级 award 批量端点,故逐条 GET;调用方已把 note 集合一次性传入以便并发。 + * Fetch award emoji for the given note list in parallel, returning `Map<noteId, GlAwardEmoji[]>`. + * GitLab has no note-level award batch endpoint, so GET each one; the caller passes the note set in all at once to enable concurrency. */ private async loadAwards(base: string, noteIds: number[]): Promise<Map<number, GlAwardEmoji[]>> { const out = new Map<number, GlAwardEmoji[]>(); @@ -61,7 +61,7 @@ export class GitLabCommentService extends BaseCommentService { ); if (list.length > 0) out.set(id, list); } catch { - // 反应是增强项,单条 note 的 award 拉取失败(旧版本 / 权限 / 端点缺失)不应拖垮整个评论列表。 + // Reactions are an enhancement; a single note's award fetch failing (old version / permissions / missing endpoint) shouldn't drag down the entire comment list. } }), ); @@ -69,8 +69,8 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 切换当前用户对一条 note 的 award emoji。add:POST,已存在(GitLab 回 404 "已被占用")按成功跳过; - * remove:列出找到自己该 name 的 award id 再 DELETE,不存在则跳过。 + * Toggle the current user's award emoji on a note. add: POST, if it already exists (GitLab returns 404 "already taken") skip as success; + * remove: list and find one's own award id with that name, then DELETE, skipping if it doesn't exist. */ override async toggleReaction( repo: RepoRef, @@ -87,7 +87,7 @@ export class GitLabCommentService extends BaseCommentService { try { await this.client.post<GlAwardEmoji>(awardBase, { name }); } catch (e) { - // 已反应过 → GitLab 回 404 "Name has already been taken",幂等跳过;其余错误冒泡。 + // Already reacted → GitLab returns 404 "Name has already been taken", idempotently skip; other errors bubble up. if (!(e instanceof GitLabClientError && e.status === 404)) throw e; } return; @@ -99,27 +99,27 @@ export class GitLabCommentService extends BaseCommentService { await this.client.del(`${awardBase}/${mineOne.id}`); } - /** GitLab award 列表 + 当前用户名 → 中性 PrReaction[](按首次出现序聚合;集外 emoji 名跳过显示)。 */ + /** GitLab award list + current username → neutral PrReaction[] (aggregated by first-appearance order; out-of-set emoji names are skipped from display). */ private buildReactions(awards: GlAwardEmoji[] | undefined, me: string | undefined): PrReaction[] { if (!awards || awards.length === 0) return []; const byEmoji = new Map<string, PrReaction>(); for (const a of awards) { const emoji = reactionCodeToEmoji(a.name); - if (!emoji) continue; // gemoji 词表外的 award 名 → 暂不显示(best-effort) + if (!emoji) continue; // award name outside the gemoji vocabulary → not displayed for now (best-effort) const r = byEmoji.get(emoji) ?? { emoji, count: 0, mine: false }; r.count += 1; if (me != null && a.user.username === me) r.mine = true; byEmoji.set(emoji, r); } - // Map 保留首次插入序(≈ award 出现序),直接输出。 + // Map preserves first-insertion order (≈ award appearance order), output directly. return [...byEmoji.values()]; } /** - * 发表 summary 评论:创建一个不带 position 的新 discussion(顶层 note)后归一返回。 + * Post a summary comment: create a new discussion without a position (top-level note), then normalize and return. */ async publishSummaryComment(repo: RepoRef, prId: string, body: string): Promise<PrComment> { - // summary 评论 = 不带 position 的新 discussion(顶层 note) + // summary comment = new discussion without a position (top-level note) const created = await this.client.post<GlDiscussion>( `/projects/${projectId(repo)}/merge_requests/${prId}/discussions`, { body }, @@ -128,9 +128,9 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 发表 inline 评论:创建带 position 的 discussion。 + * Post an inline comment: create a discussion with a position. * - * position 需 base/start/head 三 sha,先拉 MR 取 diff_refs(缺失则抛错);按 side 锚到 new_line / old_line。 + * position needs the three base/start/head shas, so first fetch the MR to get diff_refs (throw if missing); anchor to new_line / old_line by side. */ async publishInlineComment( repo: RepoRef, @@ -139,7 +139,7 @@ export class GitLabCommentService extends BaseCommentService { body: string, ): Promise<PrComment> { const base = `/projects/${projectId(repo)}/merge_requests/${prId}`; - // 行内评论 = 带 position 的 discussion;position 需 base/start/head 三 sha → 先拉 MR 取 diff_refs。 + // inline comment = discussion with a position; position needs the three base/start/head shas → first fetch the MR to get diff_refs. const mr = await this.client.get<GlMr>(base); const refs = mr.diff_refs; if (!refs) { @@ -155,7 +155,7 @@ export class GitLabCommentService extends BaseCommentService { new_path: anchor.path, old_path: anchor.path, }; - // side 'new'(added/context)锚到 new_line;'old'(removed)锚到 old_line。 + // side 'new' (added/context) anchors to new_line; 'old' (removed) anchors to old_line. if (anchor.side === 'new') position.new_line = anchor.line; else position.old_line = anchor.line; const created = await this.client.post<GlDiscussion>(`${base}/discussions`, { body, position }); @@ -163,9 +163,9 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 在指定 discussion 下追加一条 note 作为回复。 + * Append a note under the given discussion as a reply. * - * parentCommentId 即 discussion_id(threadId),renderer 已统一传 threadId ?? remoteId。 + * parentCommentId is the discussion_id (threadId); the renderer already consistently passes threadId ?? remoteId. */ async replyToComment( repo: RepoRef, @@ -173,7 +173,7 @@ export class GitLabCommentService extends BaseCommentService { parentCommentId: string, body: string, ): Promise<PrComment> { - // parentCommentId = discussion_id(threadId);renderer 已改为传 threadId ?? remoteId。 + // parentCommentId = discussion_id (threadId); the renderer now passes threadId ?? remoteId. const note = await this.client.post<GlNote>( `/projects/${projectId(repo)}/merge_requests/${prId}/discussions/${parentCommentId}/notes`, { body }, @@ -182,9 +182,9 @@ export class GitLabCommentService extends BaseCommentService { } /** - * 编辑评论 body:经 /notes/:id 覆盖 discussion 内 note。 + * Edit a comment body: overwrite the note within the discussion via /notes/:id. * - * GitLab 无乐观锁,version 忽略;编辑响应不带 discussion id,threadId 用 note id 兜底。 + * GitLab has no optimistic lock, version is ignored; the edit response carries no discussion id, so threadId falls back to the note id. */ async editComment( repo: RepoRef, @@ -193,18 +193,18 @@ export class GitLabCommentService extends BaseCommentService { _version: number, body: string, ): Promise<PrComment> { - // GitLab 无评论乐观锁(version 忽略);/notes/:id 覆盖 discussion 内 note。 + // GitLab has no comment optimistic lock (version ignored); /notes/:id overwrites the note within the discussion. const note = await this.client.put<GlNote>( `/projects/${projectId(repo)}/merge_requests/${prId}/notes/${commentId}`, { body }, ); if (!note) throw new Error('Failed to edit comment: empty response from remote'); - // 编辑响应不带 discussion id,threadId 用 note id 兜底(UI 删改后会 force-refresh 评论树)。 + // The edit response carries no discussion id, so threadId falls back to the note id (the UI force-refreshes the comment tree after edit/delete). return this.mapNote(note, String(note.id), this.ctx.getCurrentUser()?.name); } /** - * 删除一条评论 note。GitLab 无乐观锁,version 忽略。 + * Delete a comment note. GitLab has no optimistic lock, version is ignored. */ async deleteComment( repo: RepoRef, @@ -215,13 +215,13 @@ export class GitLabCommentService extends BaseCommentService { await this.client.del(`/projects/${projectId(repo)}/merge_requests/${prId}/notes/${commentId}`); } - // ---- 映射(领域私有)---- + // ---- Mapping (domain-private) ---- /** - * 把 GitLab note 归一为 PrComment。 + * Normalize a GitLab note into a PrComment. * - * 有 text position 时推导锚点(按 new_line / old_line 判侧与行类型)、记为 inline,否则记为 summary; - * 按作者是否为当前用户标记 canEdit / canDelete;GitLab 无乐观锁,version 置 0 作哨兵。 + * With a text position, derive the anchor (determine side and line type from new_line / old_line) and mark as inline, otherwise mark as summary; + * mark canEdit / canDelete by whether the author is the current user; GitLab has no optimistic lock, so version is set to 0 as a sentinel. */ private mapNote( n: GlNote, @@ -236,7 +236,7 @@ export class GitLabCommentService extends BaseCommentService { path: pos.new_path ?? pos.old_path ?? '', line: pos.new_line ?? pos.old_line ?? 0, side: pos.new_line != null ? 'new' : 'old', - // new_line + old_line 同在 = context;仅 new = added;仅 old = removed。 + // new_line + old_line both present = context; only new = added; only old = removed. lineType: pos.new_line != null ? (pos.old_line != null ? 'context' : 'added') : 'removed', } @@ -256,8 +256,8 @@ export class GitLabCommentService extends BaseCommentService { reactions: this.buildReactions(awards, me), canDelete: isMine, canEdit: isMine, - // GitLab 无乐观锁:置 0 作「无需并发令牌」哨兵,让 canEdit/canDelete 判定与编辑/删除 IPC - // 的 version: number 契约统一通过(editComment/deleteComment 忽略 version)。 + // GitLab has no optimistic lock: set to 0 as a "no concurrency token needed" sentinel, so the canEdit/canDelete + // decision and the edit/delete IPC's version: number contract pass uniformly (editComment/deleteComment ignore version). version: 0, }; } diff --git a/packages/platform-gitlab/src/features/connection.ts b/packages/platform-gitlab/src/features/connection.ts index ecd70f32..92463f41 100644 --- a/packages/platform-gitlab/src/features/connection.ts +++ b/packages/platform-gitlab/src/features/connection.ts @@ -4,7 +4,7 @@ import type { GitLabClient } from '../client.js'; import { mapUser } from '../utils.js'; import type { GlMetadata, GlUser, GlVersion } from '../types.js'; -/** GitLab 连接领域:能力声明(按 edition 降级审批)、ping(含 edition 探测)、PAT/SSH clone URL。 */ +/** GitLab connection domain: capability declaration (degrade approval by edition), ping (with edition detection), PAT/SSH clone URL. */ export class GitLabConnection extends BaseConnection { readonly kind = 'gitlab' as const; @@ -16,9 +16,9 @@ export class GitLabConnection extends BaseConnection { } /** - * GitLab 能力:审批二元(approve/unapprove,无 "request changes" → 不含 needsWork),且 Premium 起才有 - * API → 据 edition 降级(CE/EE-Free 空 + UI 灰显);行内单行评论;无评论乐观锁;合并否决项 full - * 保真(detailed_merge_status);发现端点不强限流。「解决线程 / suggestion / 成组提交」概念有、当前未实现。 + * GitLab capabilities: approval is binary (approve/unapprove, no "request changes" → no needsWork), and the API only exists + * from Premium up → degrade by edition (CE/EE-Free empty + UI greyed-out); single-line inline comments; no comment optimistic lock; merge vetoes full + * fidelity (detailed_merge_status); discovery endpoint not rate-limited. "Resolve thread / suggestion / grouped submission" concepts exist but are currently unimplemented. */ capabilities(): PlatformCapabilities { const reviewStatuses: ReadonlyArray<ReviewerStatus> = this.client.approvalsAvailable @@ -29,55 +29,55 @@ export class GitLabConnection extends BaseConnection { inlineComments: true, inlineMultiline: false, commentOptimisticLock: false, - // GitLab Award Emoji 支持任意 emoji → free。 + // GitLab Award Emoji supports arbitrary emoji → free. commentReactions: 'free', commentAttachments: true, - // GitLab 评论走标准 CommonMark(单 \n = 软换行/空格),不按 hard-break。 + // GitLab comments use standard CommonMark (single \n = soft wrap/space), not hard-break. commentHardBreaks: false, mergeVetoFidelity: 'full', discoveryRateLimited: false, - // GitLab MR 列表支持 reviewer_username / author_username / assignee_username 筛选 → 三类分页。 - // 没有 "mentioned" 概念,故不含 mentioned(poller 逐类轮询 + union 打标,renderer 切标签)。 + // GitLab MR list supports reviewer_username / author_username / assignee_username filters → three pagination categories. + // No "mentioned" concept, so mentioned is not included (poller polls each category + union tags, renderer switches tabs). discoveryFilters: ['review-requested', 'created', 'assigned'], resolvableThreads: false, suggestions: false, reviewGrouping: false, - // GitLab 无统一活动事件源(CE 无审批、审批系统 note 解析脆弱)→ PR 标签页退化为纯评论视图。 + // GitLab has no unified activity event source (CE has no approval, approval system note parsing is fragile) → the PR tab degrades to a pure comment view. activityTimeline: false, - // user_notes_count 含回复(回复也是 note)→ 计数变化可靠反映回复,poller 仅在计数/更新时间变化时扫。 + // user_notes_count includes replies (replies are also notes) → count changes reliably reflect replies, poller only scans when the count/update time changes. commentCountIncludesReplies: true, }; } /** - * 探测连接:取当前用户落地缓存,并经 /metadata 探测 edition 以决定审批可用性。 + * Probe the connection: fetch the current user into the cache, and detect edition via /metadata to decide approval availability. * - * /metadata 不可用(旧实例)时退 /version 并保守置为 CE(无审批)。 + * When /metadata is unavailable (old instances), fall back to /version and conservatively assume CE (no approval). */ async ping(): Promise<PingResult> { const me = await this.client.get<GlUser>('/user'); this.setCurrentUser(mapUser(me)); let serverVersion = 'gitlab'; try { - // /metadata(15.2+)带 enterprise 标志,用于 edition 探测。 + // /metadata (15.2+) carries the enterprise flag, used for edition detection. const meta = await this.client.get<GlMetadata>('/metadata'); serverVersion = meta.version; this.client.approvalsAvailable = meta.enterprise === true; } catch { - // /metadata 不可用(旧实例)→ 退 /version,保守置 CE(无审批)。 + // /metadata unavailable (old instances) → fall back to /version, conservatively assume CE (no approval). this.client.approvalsAvailable = false; try { const ver = await this.client.get<GlVersion>('/version'); serverVersion = ver.version; } catch { - /* /version 也拿不到时保留默认串 */ + /* keep the default string when /version can't be fetched either */ } } return { ok: true, serverVersion, user: this.getCurrentUser() ?? undefined }; } /** - * 构造仓库的 git clone URL,按当前用户名内嵌 PAT 凭据(无用户时退无凭据形式)。 + * Build the repo's git clone URL, embedding PAT credentials by current username (falls back to the credential-less form when there's no user). */ async getCloneUrl(repo: RepoRef): Promise<string> { return this.client.getCloneUrl(repo, this.getCurrentUser()?.name); diff --git a/packages/platform-gitlab/src/features/media.ts b/packages/platform-gitlab/src/features/media.ts index fe48e2cf..6338db6a 100644 --- a/packages/platform-gitlab/src/features/media.ts +++ b/packages/platform-gitlab/src/features/media.ts @@ -7,7 +7,7 @@ import { import type { GitLabClient } from '../client.js'; import { projectId } from '../utils.js'; -/** GitLab 用户与媒体领域:头像(avatar_url 直链)与项目上传附件(走 API 下载端点)。 */ +/** GitLab user and media domain: avatar (avatar_url direct link) and project upload attachments (via the API download endpoint). */ export class GitLabMediaService extends BaseMediaService { constructor( ctx: ConnectionContext, @@ -17,25 +17,25 @@ export class GitLabMediaService extends BaseMediaService { } /** - * 拉取用户头像:GitLab 无按用户名拼直链的形式,仅在给出 avatar_url 时拉取,否则返回 null 走 initials 回退。 + * Fetch a user avatar: GitLab has no username-based direct-link form, so only fetch when avatar_url is given, otherwise return null to fall back to initials. */ async getUserAvatar(_slug: string, avatarUrl?: string): Promise<BinaryResource | null> { - // GitLab 无 <host>/<username>.png 直链;只有 avatar_url 直链时才拉(本实例 host 才带 PAT), - // 否则退 initials。 + // GitLab has no <host>/<username>.png direct link; only fetch when there's an avatar_url direct link (only this instance's host carries the PAT), + // otherwise fall back to initials. if (avatarUrl) return this.client.getBinary(avatarUrl); return null; } /** - * 代理拉取评论内嵌附件。 + * Proxy-fetch a comment's embedded attachment. * - * 本实例的 `/uploads/<secret>/<file>` 改走带 PAT 的 API 下载端点(web 路由对 PAT 一律 302 到登录页); - * 其它本实例绝对 URL 直接代理;非本实例 / 解析不出则返回 null 让上层回退。 + * This instance's `/uploads/<secret>/<file>` is rerouted through the PAT-carrying API download endpoint (web routes always 302 a PAT to the login page); + * other absolute URLs on this instance are proxied directly; non-this-instance / unparseable returns null to let the caller fall back. */ async getAttachment(url: string, repo?: RepoRef): Promise<BinaryResource | null> { - // 项目 markdown 上传 `/uploads/<secret>/<file>`(绝对或相对皆可):其 web 路由对 PAT 一律 302 - // 到登录页(私有项目仅认浏览器 session),故改走 API 下载端点 `GET /projects/:id/uploads/ - // :secret/:filename`(GitLab 17.4+ 认 PRIVATE-TOKEN;旧版无此路由 → 404 → null)。 + // Project markdown uploads `/uploads/<secret>/<file>` (absolute or relative): their web routes always 302 + // a PAT to the login page (private projects only accept a browser session), so reroute through the API download endpoint `GET /projects/:id/uploads/ + // :secret/:filename` (GitLab 17.4+ accepts PRIVATE-TOKEN; old versions lack this route → 404 → null). const isRelative = !/^https?:\/\//.test(url); let sameHost = isRelative; if (!isRelative) { @@ -50,14 +50,14 @@ export class GitLabMediaService extends BaseMediaService { const [, secret, filename] = m; return this.client.getApiBinary(`/projects/${projectId(repo)}/uploads/${secret}/${filename}`); } - // 其它本实例绝对 URL(非 /uploads 的图)仍直接代理;非本实例 / 解析不出 → null 让上层 fallback。 + // Other absolute URLs on this instance (images that aren't /uploads) are still proxied directly; non-this-instance / unparseable → null to let the caller fall back. if (/^https?:\/\//.test(url)) return this.client.getBinary(url); return null; } /** - * 上传图片到项目 `/uploads`(项目级,非 PR 级,prId 忽略),返回 GitLab 给出的 markdown - * (`![file](/uploads/<secret>/<file>)`)。该相对 URL 经 getAttachment 走 API 下载端点渲染。 + * Upload an image to the project `/uploads` (project-level, not PR-level, prId ignored), returning the markdown GitLab provides + * (`![file](/uploads/<secret>/<file>)`). That relative URL is rendered via getAttachment through the API download endpoint. */ override async uploadAttachment( repo: RepoRef, diff --git a/packages/platform-gitlab/src/features/pull-request.ts b/packages/platform-gitlab/src/features/pull-request.ts index 237bce22..3463891a 100644 --- a/packages/platform-gitlab/src/features/pull-request.ts +++ b/packages/platform-gitlab/src/features/pull-request.ts @@ -19,7 +19,7 @@ import type { GitLabClient } from '../client.js'; import { mapUser, projectId } from '../utils.js'; import type { GlApprovals, GlCommit, GlMr, GlUser } from '../types.js'; -/** GitLab PR 操作领域:发现(三类筛选)、提交、审批、合并。GitLab 不提供活动时间线事件。 */ +/** GitLab PR operations domain: discovery (three filter categories), commits, approval, merge. GitLab provides no activity timeline events. */ export class GitLabPullRequestService extends BasePullRequestService { constructor( ctx: ConnectionContext, @@ -29,14 +29,14 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 发现待处理 MR:scope=all 全局跨项目分页命中后逐条取详情归一。 + * Discover pending MRs: scope=all paginates globally across projects, then fetch and normalize details one by one. * - * 未 ping(无当前用户)则无法构造查询,直接返回空;逐条详情并发执行,单条失败丢弃该条。 + * Without a ping (no current user) the query can't be built, so return empty directly; per-item details run concurrently, and a single failure discards that item. */ async listPendingPullRequests(opts?: ListPendingOptions): Promise<PullRequest[]> { const me = this.ctx.getCurrentUser()?.name; - // scope=all 全局跨项目;按 filter 切换 reviewer/author/assignee 限定(默认待我评审)。 - // 未 ping(无 me)则无法构造 → 空。 + // scope=all is global across projects; switch reviewer/author/assignee scoping by filter (defaults to awaiting my review). + // Without a ping (no me) it can't be built → empty. if (!me) return []; const items: GlMr[] = []; for await (const mr of this.client.paginate<GlMr>( @@ -45,7 +45,7 @@ export class GitLabPullRequestService extends BasePullRequestService { )) { items.push(mr); } - // 每条再取详情(diff_refs / detailed_merge_status)+ 审批(approved_by)。单个失败丢弃该条。 + // For each, fetch details (diff_refs / detailed_merge_status) + approval (approved_by). A single failure discards that item. const results = await Promise.allSettled(items.map((mr) => this.loadMr(mr))); return results .filter((r): r is PromiseFulfilledResult<PullRequest> => r.status === 'fulfilled') @@ -53,9 +53,9 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 按一条 MR 列表项加载完整 MR:取详情,审批可用时再取 approved_by,组装审批人后归一。 + * Load a full MR from a single MR list item: fetch details, and when approval is available fetch approved_by, assemble approvers, then normalize. * - * 审批端点因 tier / 权限不可用时按无人 approve 处理。 + * When the approval endpoint is unavailable due to tier / permissions, treat it as nobody having approved. */ private async loadMr(listItem: GlMr): Promise<PullRequest> { const repo = this.parseProjectPath(listItem.web_url); @@ -67,13 +67,13 @@ export class GitLabPullRequestService extends BasePullRequestService { const approvals = await this.client.get<GlApprovals>(`${base}/approvals`); approvedUsers = (approvals.approved_by ?? []).map((a) => a.user); } catch { - /* 审批不可用(tier/权限)→ 视作无人 approve */ + /* approval unavailable (tier/permissions) → treat as nobody approved */ } } return this.mapMr(detail, repo, this.buildReviewers(detail, approvedUsers)); } - /** 按 repo + iid 从远端拉单个 MR(复用 loadMr 同款组装);404 / 403 由 client 抛出供上层归一。 */ + /** Fetch a single MR from the remote by repo + iid (reusing loadMr's assembly); 404 / 403 are thrown by the client for the caller to normalize. */ async getSinglePullRequest(repo: RepoRef, prId: string): Promise<PullRequest> { const base = `/projects/${projectId(repo)}/merge_requests/${prId}`; const detail = await this.client.get<GlMr>(base); @@ -83,14 +83,14 @@ export class GitLabPullRequestService extends BasePullRequestService { const approvals = await this.client.get<GlApprovals>(`${base}/approvals`); approvedUsers = (approvals.approved_by ?? []).map((a) => a.user); } catch { - /* 审批不可用(tier/权限)→ 视作无人 approve */ + /* approval unavailable (tier/permissions) → treat as nobody approved */ } } return this.mapMr(detail, repo, this.buildReviewers(detail, approvedUsers)); } /** - * 列出 MR 提交:GitLab 端点已是 newest-first,与契约一致,无需反转。 + * List MR commits: the GitLab endpoint is already newest-first, consistent with the contract, no reversal needed. */ async listPullRequestCommits(repo: RepoRef, prId: string): Promise<PrCommit[]> { const out: PrCommit[] = []; @@ -99,24 +99,24 @@ export class GitLabPullRequestService extends BasePullRequestService { )) { out.push(this.mapCommit(c)); } - // GitLab MR commits 端点已是 reverse-chronological(newest-first),契约同要求,无需反转。 + // The GitLab MR commits endpoint is already reverse-chronological (newest-first), matching the contract, no reversal needed. return out; } /** - * GitLab 不参与活动时间线(capabilities.activityTimeline=false):无可靠的统一决断事件源,恒返回空。 + * GitLab does not participate in the activity timeline (capabilities.activityTimeline=false): no reliable unified decision event source, always returns empty. */ async listPullRequestActivity(_repo: RepoRef, _prId: string): Promise<PrActivityEvent[]> { - // 差异化设计:GitLab 不参与活动时间线(capabilities.activityTimeline=false,PR 标签页退化为纯 - // 评论视图),故无需提供决断事件。GitLab 也没有统一活动事件源——CE 无审批、审批仅以脆弱的英文 - // 系统 note 体现,与 Bitbucket /activities、GitHub /reviews 的可靠时间戳事件不对等——返回空。 + // Differentiated design: GitLab does not participate in the activity timeline (capabilities.activityTimeline=false, the PR tab degrades to a pure + // comment view), so no decision events are needed. GitLab also has no unified activity event source—CE has no approval, and approval is only reflected in fragile English + // system notes, not on par with the reliable timestamped events of Bitbucket /activities or GitHub /reviews—return empty. return []; } /** - * 写当前用户的 review 状态:approved / unapproved 分别打 approve / unapprove 端点。 + * Write the current user's review status: approved / unapproved hit the approve / unapprove endpoints respectively. * - * GitLab 无 "request changes" 概念,needsWork 不会被 UI 触发,防御性抛错。 + * GitLab has no "request changes" concept, needsWork won't be triggered by the UI, defensively throw. */ async setPullRequestReviewStatus( repo: RepoRef, @@ -132,27 +132,27 @@ export class GitLabPullRequestService extends BasePullRequestService { await this.client.post(`${base}/unapprove`, {}); return; } - // needsWork:GitLab 无 "request changes" 概念。capabilities.reviewStatuses 不含 needsWork, - // UI 不会触发;防御性抛错。 + // needsWork: GitLab has no "request changes" concept. capabilities.reviewStatuses doesn't include needsWork, + // the UI won't trigger it; defensively throw. throw new Error('GitLab does not support the "request changes" review status'); } /** - * 合并 MR(squash / ff 由仓库设置决定)。 + * Merge an MR (squash / ff decided by repo settings). * - * 不可合并(冲突 / 未批 / 流水线未过 / 无权限)时 GitLab 返回 405/406/409,错误携带 message 冒泡。 + * When not mergeable (conflict / unapproved / pipeline not passing / no permission), GitLab returns 405/406/409, and the error bubbles up carrying the message. */ async mergePullRequest(repo: RepoRef, prId: string): Promise<void> { - // PUT /merge:squash/ff 由仓库设置决定。失败(冲突 / 未批 / 流水线未过 / 权限)→ 405/406/409 带 message。 + // PUT /merge: squash/ff decided by repo settings. Failure (conflict / unapproved / pipeline not passing / permission) → 405/406/409 with message. await this.client.put(`/projects/${projectId(repo)}/merge_requests/${prId}/merge`, {}); } - // ---- 映射(领域私有)---- + // ---- Mapping (domain-private) ---- /** - * 把发现筛选分类映射为 /merge_requests 查询参数(scope=all 全局,按角色限定)。 + * Map a discovery filter category to /merge_requests query params (scope=all global, scoped by role). * - * GitLab 无 "mentioned" 概念,该分类退化为「待我评审」。 + * GitLab has no "mentioned" concept, so that category degrades to "awaiting my review". */ private discoveryParams(filter: PrDiscoveryFilter, me: string): Record<string, string> { const base = { scope: 'all', state: 'opened' }; @@ -169,8 +169,8 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 从 MR web_url 解析项目路径:`https://host/<group>/<sub>/<project>/-/merge_requests/<iid>` - * → projectKey=`group/sub`(嵌套 namespace),repoSlug=`project`。 + * Parse the project path from an MR web_url: `https://host/<group>/<sub>/<project>/-/merge_requests/<iid>` + * → projectKey=`group/sub` (nested namespace), repoSlug=`project`. */ private parseProjectPath(webUrl: string): RepoRef { let pathname: string; @@ -187,9 +187,9 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 把 GitLab detailed_merge_status 映射为统一否决原因码。 + * Map GitLab detailed_merge_status to a unified veto reason code. * - * 后台不拼本地化文案,由前端按码做 i18n;未识别的状态归为 notMergeable。 + * The backend doesn't assemble localized text; the frontend does i18n by code; unrecognized statuses fall under notMergeable. */ private mergeStatusCode(dms: string): MergeVetoCode { switch (dms) { @@ -222,10 +222,10 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 把 GitLab MR 的合并状态映射为统一 MergeStatus(full 保真)。 + * Map a GitLab MR's merge state to a unified MergeStatus (full fidelity). * - * 有 detailed_merge_status 时按其判定 canMerge 与否决码(未细分原因保留原始串到 detail 便于排障); - * 旧实例缺该字段时退回 merge_status 近似。 + * With detailed_merge_status, determine canMerge and veto codes from it (unsubdivided reasons keep the raw string in detail for troubleshooting); + * when old instances lack this field, fall back to the merge_status approximation. */ private mapMergeStatus(mr: GlMr): MergeStatus { const dms = mr.detailed_merge_status; @@ -236,11 +236,11 @@ export class GitLabPullRequestService extends BasePullRequestService { canMerge = dms === 'mergeable'; if (!canMerge) { const code = this.mergeStatusCode(dms); - // 未细分原因(default)保留原始 dms 到 detail,便于排障 + // Unsubdivided reason (default) keeps the raw dms in detail for troubleshooting vetoes.push(code === 'notMergeable' ? { code, detail: dms } : { code }); } } else { - // 旧实例无 detailed_merge_status:退 merge_status。 + // Old instances lack detailed_merge_status: fall back to merge_status. canMerge = mr.merge_status === 'can_be_merged' && !conflicted; if (conflicted) vetoes.push({ code: 'conflict' }); else if (mr.merge_status === 'cannot_be_merged') vetoes.push({ code: 'notMergeable' }); @@ -251,15 +251,15 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 组装审批人列表:先以指派的 reviewer 占位(unapproved),再用 approved_by 覆盖 / 补充为 approved。 + * Assemble the approver list: first placeholder the assigned reviewers (unapproved), then override / supplement to approved with approved_by. */ private buildReviewers(mr: GlMr, approvedUsers: GlUser[]): Reviewer[] { const byUser = new Map<string, Reviewer>(); - // 先放指派的 reviewer(默认未批)。 + // First add the assigned reviewers (unapproved by default). for (const u of mr.reviewers ?? []) { byUser.set(u.username, { ...mapUser(u), status: 'unapproved' }); } - // approved_by 覆盖 / 补充为 approved(含未在 reviewers 列表但已批的人)。 + // approved_by overrides / supplements to approved (including people not in the reviewers list but who have approved). for (const u of approvedUsers) { byUser.set(u.username, { ...mapUser(u), status: 'approved' }); } @@ -267,9 +267,9 @@ export class GitLabPullRequestService extends BasePullRequestService { } /** - * 把 GitLab MR 详情(含已组装的审批人)归一为中性 PullRequest。 + * Normalize GitLab MR details (with assembled approvers) into a neutral PullRequest. * - * 状态按 merged / opened / 其余映射为 merged / open / declined;source/target sha 优先取 diff_refs。 + * State maps merged / opened / others to merged / open / declined; source/target sha prefer diff_refs. */ private mapMr(mr: GlMr, repo: RepoRef, reviewers: Reviewer[]): PullRequest { const state: PullRequest['state'] = @@ -291,13 +291,13 @@ export class GitLabPullRequestService extends BasePullRequestService { reviewers, mergeStatus, hasConflict: mergeStatus.conflicted, - // 用户 note 数(系统 note 不计、回复也是 note)→ 含回复(capabilities 标 true)。 + // User note count (system notes excluded, replies are also notes) → includes replies (capabilities marks true). commentCount: mr.user_notes_count, }; } /** - * 把 GitLab 提交归一为中性 PrCommit;缺字段时按 git 名 / 短 sha / 标题等逐级回退。 + * Normalize a GitLab commit into a neutral PrCommit; when fields are missing, fall back step by step to git name / short sha / title, etc. */ private mapCommit(c: GlCommit): PrCommit { const authorName = c.author_name ?? 'unknown'; diff --git a/packages/platform-gitlab/src/types.ts b/packages/platform-gitlab/src/types.ts index eec1505c..da4065e7 100644 --- a/packages/platform-gitlab/src/types.ts +++ b/packages/platform-gitlab/src/types.ts @@ -1,4 +1,4 @@ -// GitLab REST v4 响应形状(仅取用到的字段)。跨领域共享的数据类型,单独抽取于此。 +// GitLab REST v4 response shapes (only the fields used). Data types shared across domains, extracted separately here. export interface GlUser { id: number; @@ -29,16 +29,16 @@ export interface GlMr { author: GlUser; source_branch: string; target_branch: string; - /** head(源分支最新)sha;详情与列表都带 */ + /** head (source branch latest) sha; present in both detail and list */ sha?: string; reviewers?: GlUser[]; - /** 15.6+ 的丰富可合并枚举;旧实例可能缺,退 merge_status */ + /** 15.6+ rich mergeability enum; old instances may lack it, fall back to merge_status */ detailed_merge_status?: string; merge_status?: 'can_be_merged' | 'cannot_be_merged' | 'unchecked' | 'checking'; has_conflicts?: boolean; - /** 仅单 MR 详情带;行内评论 position 与 base/head sha 需要它 */ + /** only present in single-MR detail; inline comment position and base/head sha need it */ diff_refs?: GlDiffRefs | null; - /** 用户评论(note)数,含回复、不含系统 note;列表与详情都带 */ + /** user comment (note) count, includes replies, excludes system notes; present in both list and detail */ user_notes_count?: number; } @@ -86,7 +86,7 @@ export interface GlDiscussion { notes: GlNote[]; } -/** GitLab note 上的一条 award emoji(反应)。`name` 为 GitLab emoji 名(如 `thumbsup`)。 */ +/** An award emoji (reaction) on a GitLab note. `name` is the GitLab emoji name (e.g. `thumbsup`). */ export interface GlAwardEmoji { id: number; name: string; diff --git a/packages/platform-gitlab/src/utils.ts b/packages/platform-gitlab/src/utils.ts index 63090324..60826852 100644 --- a/packages/platform-gitlab/src/utils.ts +++ b/packages/platform-gitlab/src/utils.ts @@ -1,7 +1,7 @@ import type { PlatformUser, RepoRef } from '@meebox/shared'; import type { GlUser } from './types.js'; -/** GitLab user → 中性 PlatformUser。跨 PR / 评论领域共用。 */ +/** GitLab user → neutral PlatformUser. Shared across the PR / comment domains. */ export function mapUser(u: GlUser): PlatformUser { return { name: u.username, @@ -11,7 +11,7 @@ export function mapUser(u: GlUser): PlatformUser { }; } -/** GitLab 端点 `:id`:RepoRef 的 `projectKey/repoSlug`(含嵌套 group)URL-encode 成单段路径。 */ +/** GitLab endpoint `:id`: RepoRef's `projectKey/repoSlug` (including nested group) URL-encoded into a single path segment. */ export function projectId(repo: RepoRef): string { return encodeURIComponent(`${repo.projectKey}/${repo.repoSlug}`); } diff --git a/packages/platform-gitlab/tests/adapter.test.ts b/packages/platform-gitlab/tests/adapter.test.ts index c52f19cc..24bcc617 100644 --- a/packages/platform-gitlab/tests/adapter.test.ts +++ b/packages/platform-gitlab/tests/adapter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { GitLabAdapter, normalizeGitLabApiBase } from '../src/adapter.js'; -// ---- 路由式 mock fetch:按 method + URL 子串匹配(数组序优先),返回 JSON Response,记录请求 ---- +// ---- Route-style mock fetch: match by method + URL substring (array order takes priority), return JSON Response, record requests ---- interface Route { method?: string; match: string; @@ -58,7 +58,7 @@ function makeAdapter( const ME = { id: 1, username: 'alice', name: 'Alice' }; describe('GitLabAdapter ping / edition / capabilities', () => { - it('EE 实例(metadata.enterprise=true)暴露 approve/unapprove 审批', async () => { + it('EE instance (metadata.enterprise=true) exposes approve/unapprove approval', async () => { const { adapter } = makeAdapter([ { match: '/user', body: ME }, { match: '/metadata', body: { version: '16.5.0-ee', enterprise: true } }, @@ -68,11 +68,11 @@ describe('GitLabAdapter ping / edition / capabilities', () => { expect(res.user?.name).toBe('alice'); expect(res.serverVersion).toBe('16.5.0-ee'); expect(adapter.connection.capabilities().reviewStatuses).toEqual(['approved', 'unapproved']); - // GitLab 无 needsWork + // GitLab has no needsWork expect(adapter.connection.capabilities().reviewStatuses).not.toContain('needsWork'); }); - it('CE 实例(enterprise=false)审批降级为空', async () => { + it('CE instance (enterprise=false) degrades approval to empty', async () => { const { adapter } = makeAdapter([ { match: '/user', body: ME }, { match: '/metadata', body: { version: '16.5.0', enterprise: false } }, @@ -81,7 +81,7 @@ describe('GitLabAdapter ping / edition / capabilities', () => { expect(adapter.connection.capabilities().reviewStatuses).toEqual([]); }); - it('/metadata 不可用(旧实例)退 /version,保守按 CE', async () => { + it('/metadata unavailable (old instance) falls back to /version, conservatively assumes CE', async () => { const { adapter } = makeAdapter([ { match: '/user', body: ME }, { match: '/metadata', status: 404, body: { message: '404' } }, @@ -92,12 +92,12 @@ describe('GitLabAdapter ping / edition / capabilities', () => { expect(adapter.connection.capabilities().reviewStatuses).toEqual([]); }); - it('capabilities:full 保真 / 无乐观锁 / 不限流', () => { + it('capabilities: full fidelity / no optimistic lock / not rate-limited', () => { const { adapter } = makeAdapter([]); const c = adapter.connection.capabilities(); expect(c.mergeVetoFidelity).toBe('full'); expect(c.commentOptimisticLock).toBe(false); - // GitLab 走标准 CommonMark 换行(单 \n = 空格),非 hard-break + // GitLab uses standard CommonMark line breaks (single \n = space), not hard-break expect(c.commentHardBreaks).toBe(false); expect(c.discoveryRateLimited).toBe(false); expect(c.inlineComments).toBe(true); @@ -132,8 +132,8 @@ const MR_DETAIL = { diff_refs: { base_sha: 'basesha', head_sha: 'headsha', start_sha: 'startsha' }, }; -describe('GitLabAdapter 发现', () => { - it('listPendingPullRequests:MR 映射 + 嵌套 group 路径 + 审批状态', async () => { +describe('GitLabAdapter discovery', () => { + it('listPendingPullRequests: MR mapping + nested group path + approval status', async () => { const { adapter, captured } = makeAdapter([ { match: '/user', body: ME }, { match: '/metadata', body: { version: '16.5.0', enterprise: true } }, @@ -149,17 +149,17 @@ describe('GitLabAdapter 发现', () => { expect(pr.repo).toEqual({ projectKey: 'group/sub', repoSlug: 'proj' }); expect(pr.sourceRef.sha).toBe('headsha'); expect(pr.targetRef.sha).toBe('basesha'); - // not_approved → 不可合并 + full veto + // not_approved → not mergeable + full veto expect(pr.mergeStatus.canMerge).toBe(false); expect(pr.mergeStatus.vetoes.length).toBeGreaterThan(0); - // 已批的 ME 标 approved + // the approved ME is marked approved expect(pr.reviewers.find((r) => r.name === 'alice')?.status).toBe('approved'); - // 发现请求带 reviewer_username + // the discovery request carries reviewer_username const listReq = captured.find((c) => c.url.includes('/merge_requests?')); expect(listReq?.url).toContain('reviewer_username=alice'); }); - it('discovery filter:created → author_username;assigned → assignee_username', async () => { + it('discovery filter: created → author_username; assigned → assignee_username', async () => { const mk = () => makeAdapter([ { match: '/user', body: ME }, @@ -182,8 +182,8 @@ describe('GitLabAdapter 发现', () => { }); }); -describe('GitLabAdapter 评论树(discussions/notes)', () => { - it('inline discussion → 顶层 + replies;system note 过滤;threadId=discussion id', async () => { +describe('GitLabAdapter comment tree (discussions/notes)', () => { + it('inline discussion → top-level + replies; system note filtered; threadId=discussion id', async () => { const discussions = [ { id: 'disc1', @@ -233,7 +233,7 @@ describe('GitLabAdapter 评论树(discussions/notes)', () => { { projectKey: 'group', repoSlug: 'proj' }, '3', ); - // system-only discussion 被过滤 + // system-only discussion is filtered out expect(comments).toHaveLength(1); const top = comments[0]!; expect(top.kind).toBe('inline'); @@ -241,16 +241,16 @@ describe('GitLabAdapter 评论树(discussions/notes)', () => { expect(top.threadId).toBe('disc1'); expect(top.remoteId).toBe('11'); expect(top.replies).toHaveLength(1); - expect(top.replies[0]!.canEdit).toBe(true); // reply 作者是 ME - expect(top.canEdit).toBe(false); // top 作者是 bob - // 无乐观锁哨兵:version=0,使编辑/删除 IPC 的 version:number 契约与 ownership 判定统一通过 + expect(top.replies[0]!.canEdit).toBe(true); // reply author is ME + expect(top.canEdit).toBe(false); // top author is bob + // no-optimistic-lock sentinel: version=0, making the edit/delete IPC's version:number contract and ownership decision pass uniformly expect(top.version).toBe(0); expect(top.replies[0]!.version).toBe(0); }); }); -describe('GitLabAdapter 写路径 + clone', () => { - it('clone url:pat 嵌用户:token;ssh 走 git@host', async () => { +describe('GitLabAdapter write path + clone', () => { + it('clone url: pat embeds user:token; ssh uses git@host', async () => { const pat = makeAdapter([ { match: '/user', body: ME }, { match: '/metadata', body: { version: '16', enterprise: false } }, @@ -268,7 +268,7 @@ describe('GitLabAdapter 写路径 + clone', () => { ).toBe('git@gitlab.com:group/proj.git'); }); - it('approve → POST /approve;unapprove → POST /unapprove', async () => { + it('approve → POST /approve; unapprove → POST /unapprove', async () => { const { adapter, captured } = makeAdapter([ { method: 'POST', match: '/approve', body: {} }, { method: 'POST', match: '/unapprove', body: {} }, @@ -287,14 +287,14 @@ describe('GitLabAdapter 写路径 + clone', () => { expect(captured.some((c) => c.method === 'POST' && c.url.endsWith('/unapprove'))).toBe(true); }); - it('needsWork 抛错(GitLab 无此概念)', async () => { + it('needsWork throws (GitLab has no such concept)', async () => { const { adapter } = makeAdapter([]); await expect( adapter.prs.setPullRequestReviewStatus({ projectKey: 'g', repoSlug: 'p' }, '3', 'needsWork'), ).rejects.toThrow(); }); - it('publishInlineComment:先拉 diff_refs,position 带三 sha + new_line', async () => { + it('publishInlineComment: fetch diff_refs first, position carries three shas + new_line', async () => { const { adapter, captured } = makeAdapter([ { match: '/user', body: ME }, { @@ -333,11 +333,11 @@ describe('GitLabAdapter 写路径 + clone', () => { }); describe('normalizeGitLabApiBase', () => { - it('gitlab.com SaaS:官方 API base 原样保留(不破坏公共 SaaS 对接)', () => { + it('gitlab.com SaaS: official API base kept as-is (does not break public SaaS integration)', () => { expect(normalizeGitLabApiBase('https://gitlab.com/api/v4')).toBe('https://gitlab.com/api/v4'); }); - it('实例根地址自动补 /api/v4', () => { + it('instance root address auto-appends /api/v4', () => { expect(normalizeGitLabApiBase('https://gitlab.example.com')).toBe( 'https://gitlab.example.com/api/v4', ); @@ -346,21 +346,21 @@ describe('normalizeGitLabApiBase', () => { ); }); - it('已带 /api/v4 原样(含尾斜杠归一)', () => { + it('already carries /api/v4 kept as-is (trailing slash normalized)', () => { expect(normalizeGitLabApiBase('https://gitlab.example.com/api/v4/')).toBe( 'https://gitlab.example.com/api/v4', ); }); - it('relative-url-root 子路径安装:补在子路径之后', () => { + it('relative-url-root subpath install: append after the subpath', () => { expect(normalizeGitLabApiBase('https://example.com/gitlab')).toBe( 'https://example.com/gitlab/api/v4', ); }); }); -describe('GitLabAdapter 头像代理', () => { - it('本实例头像:带 PAT 取', async () => { +describe('GitLabAdapter avatar proxy', () => { + it('this-instance avatar: fetch with PAT', async () => { const { adapter, captured } = makeAdapter([ { match: '/uploads/', body: 'PNG', headers: { 'content-type': 'image/png' } }, ]); @@ -373,7 +373,7 @@ describe('GitLabAdapter 头像代理', () => { expect(req?.headers['PRIVATE-TOKEN']).toBe('tok'); }); - it('gravatar 头像:公网直取,绝不带 PAT', async () => { + it('gravatar avatar: fetch directly over the public internet, never with PAT', async () => { const { adapter, captured } = makeAdapter([ { match: 'gravatar.com', body: 'PNG', headers: { 'content-type': 'image/png' } }, ]); @@ -387,17 +387,17 @@ describe('GitLabAdapter 头像代理', () => { expect(req?.headers['PRIVATE-TOKEN']).toBeUndefined(); }); - it('其它外部 host:不代拉(防 SSRF)', async () => { + it('other external hosts: not proxy-fetched (SSRF prevention)', async () => { const { adapter } = makeAdapter([{ match: 'evil.example.com', body: 'x' }]); const res = await adapter.media.getUserAvatar('alice', 'https://evil.example.com/x.png'); expect(res).toBeNull(); }); }); -describe('GitLabAdapter 附件代理', () => { +describe('GitLabAdapter attachment proxy', () => { const SECRET = 'f28aebc97ff910addda099ad1a4456d3'; - it('/uploads(绝对实例 URL)走 API 下载端点,带 PAT', async () => { + it('/uploads (absolute instance URL) uses the API download endpoint, with PAT', async () => { const { adapter, captured } = makeAdapter([ { match: '/api/v4/projects/', body: 'PNG', headers: { 'content-type': 'image/png' } }, ]); @@ -411,7 +411,7 @@ describe('GitLabAdapter 附件代理', () => { expect(req?.headers['PRIVATE-TOKEN']).toBe('tok'); }); - it('/uploads(相对路径)同样映射到 API 端点', async () => { + it('/uploads (relative path) also maps to the API endpoint', async () => { const { adapter, captured } = makeAdapter([ { match: '/api/v4/projects/', body: 'PNG', headers: { 'content-type': 'image/png' } }, ]); @@ -423,7 +423,7 @@ describe('GitLabAdapter 附件代理', () => { expect(req?.url).toContain(`/api/v4/projects/group%2Fsub%2Fproj/uploads/${SECRET}/image.png`); }); - it('API 返回 text/html(旧版无端点 / 登录重定向)→ null(避免 HTML 当图片)', async () => { + it('API returns text/html (old versions lack the endpoint / login redirect) → null (avoid treating HTML as an image)', async () => { const { adapter } = makeAdapter([ { match: '/api/v4/projects/', diff --git a/packages/poller/package.json b/packages/poller/package.json index 66aee001..7f004636 100644 --- a/packages/poller/package.json +++ b/packages/poller/package.json @@ -2,7 +2,7 @@ "name": "@meebox/poller", "version": "0.0.0", "private": true, - "description": "PR discovery poller: 周期性调用 PlatformAdapter 并把发现入 StateStore", + "description": "PR discovery poller: periodically calls PlatformAdapter and writes discoveries into StateStore", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/poller/src/agent-session.ts b/packages/poller/src/agent-session.ts index df8352e5..ec6a1f76 100644 --- a/packages/poller/src/agent-session.ts +++ b/packages/poller/src/agent-session.ts @@ -13,9 +13,9 @@ import type { StateStore } from '@meebox/state-store'; import { makeRunId } from './runs.js'; /** - * Agent 会话落在 `prs/<localId>/agent/`:每个 PR 一份 session + 一条 transcript, - * 与 meta / comments / runs 同处该 PR 目录,PR 退场时 deleteDir 整棵清掉(与它们同寿命)。 - * localId 是 prHashId 出来的 12 位 hex,无路径不安全字符,不需 sanitize。 + * Agent sessions live under `prs/<localId>/agent/`: one session + one transcript per PR, + * alongside meta / comments / runs in that PR directory; on PR retirement deleteDir wipes the whole tree (same lifespan as them). + * localId is the 12-hex output of prHashId, has no path-unsafe characters, and needs no sanitize. */ function sessionKey(prLocalId: string): string { return `prs/${prLocalId}/agent/session`; @@ -30,15 +30,15 @@ function conversationKey(prLocalId: string): string { export interface StartAgentSessionInput { prLocalId: string; maxSteps: number; - /** 外部预分配的 session id(与队列 id 对齐);缺省按时序生成。 */ + /** Externally pre-allocated session id (aligned with the queue id); defaults to time-ordered generation. */ id?: string; - /** 触发会话的用户自然语言请求(agent:ask);自动评审无文本则缺省。 */ + /** The user's natural-language request that triggered the session (agent:ask); defaults absent for auto reviews with no text. */ userRequest?: string; } /** - * 起一个新会话:写初始 running 状态,并把 transcript 清空(新会话覆盖旧的, - * 每个 PR 同时只有一份当前会话,见「会话隔离」)。 + * Start a new session: write the initial running status and clear the transcript (a new session overwrites the old one, + * each PR has only one current session at a time, see "session isolation"). */ export async function startAgentSession( stateStore: StateStore, @@ -78,8 +78,8 @@ export interface AgentSessionPatch { } /** - * Merge patch 到已存在的会话并重写。会话不存在返回 null(不重建空记录,避免 - * start 失败后的 update 静默成功;与 finishReviewRun 一致)。 + * Merge the patch into an existing session and rewrite it. Returns null if the session does not exist (does not rebuild an empty record, to avoid + * an update silently succeeding after a failed start; consistent with finishReviewRun). */ export async function updateAgentSession( stateStore: StateStore, @@ -113,8 +113,8 @@ export async function getAgentTranscript( } /** - * 追加一个编排步骤到 transcript,并同步会话的 stepCount(= transcript 长度)。 - * 会话不存在返回 null(必须先 start)。`at` 缺省时打当前时间。 + * Append an orchestration step to the transcript and sync the session's stepCount (= transcript length). + * Returns null if the session does not exist (must start first). `at` defaults to the current time. */ export async function appendAgentStep( stateStore: StateStore, @@ -140,7 +140,7 @@ export async function appendAgentStep( return next; } -/** 清掉某 PR 的会话 + transcript + 多轮对话(删 `prs/<localId>/agent/*`)。 */ +/** Wipe a PR's session + transcript + multi-turn conversation (delete `prs/<localId>/agent/*`). */ export async function clearAgentSession( stateStore: StateStore, prLocalId: string, @@ -151,8 +151,8 @@ export async function clearAgentSession( } /** - * 多轮对话日志(跨回合保留,独立于 per-turn 的 session / transcript 生命周期):读取本 PR - * 全部消息(用户输入 + Agent 收尾回答)。无则空数组。 + * Multi-turn conversation log (retained across turns, independent of the per-turn session / transcript lifecycle): read this PR's + * full message list (user input + Agent's summary answers). Empty array if none. */ export async function getAgentConversation( stateStore: StateStore, @@ -162,7 +162,7 @@ export async function getAgentConversation( return file?.messages ?? []; } -/** 整体重写某 PR 的多轮对话(用于压缩 / 摘要替换旧消息)。 */ +/** Rewrite a PR's multi-turn conversation as a whole (used to compact / replace old messages with a summary). */ export async function writeAgentConversation( stateStore: StateStore, prLocalId: string, @@ -174,7 +174,7 @@ export async function writeAgentConversation( }); } -/** 追加一条对话消息(用户 / 助手),返回追加后的完整消息列表。`at` 缺省打当前时间。 */ +/** Append a conversation message (user / assistant), returning the full message list after the append. `at` defaults to the current time. */ export async function appendAgentMessage( stateStore: StateStore, prLocalId: string, diff --git a/packages/poller/src/autopilot-ledger.ts b/packages/poller/src/autopilot-ledger.ts index 1674b0bb..2dd5728e 100644 --- a/packages/poller/src/autopilot-ledger.ts +++ b/packages/poller/src/autopilot-ledger.ts @@ -2,8 +2,8 @@ import type { AutopilotLedger, AutopilotLedgerFile } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * AutoPilot 台账落 `prs/<localId>/agent/autopilot.json`,与 session / transcript 同处该 - * PR 目录,PR 退场时 deleteDir 一并清掉(见 docs/arch/02-agent/03-autopilot.md「AutoPilot」)。 + * The AutoPilot ledger lives at `prs/<localId>/agent/autopilot.json`, alongside session / transcript in that + * PR directory; on PR retirement deleteDir wipes it too (see docs/arch/02-agent/03-autopilot.md "AutoPilot"). */ function ledgerKey(prLocalId: string): string { return `prs/${prLocalId}/agent/autopilot`; @@ -28,8 +28,8 @@ export async function writeAutopilotLedger( } /** - * 清掉该 PR 的 AutoPilot 台账(清空执行历史时一并删)。台账存的是评审建议 verdict,PR 列表 ★ 徽标 - * 据此显示;删掉后 ★ 随之消失,避免清空结果后仍残留陈旧评审状态。 + * Wipe this PR's AutoPilot ledger (deleted together when clearing execution history). The ledger holds the review verdict, on which the PR list ★ badge + * is displayed; once deleted the ★ disappears, avoiding a stale review status lingering after clearing results. */ export async function clearAutopilotLedger( stateStore: StateStore, @@ -39,8 +39,8 @@ export async function clearAutopilotLedger( } /** - * 该 PR 是否需要自动评审:无台账(从未跑过)或台账记录的 updatedAt 与当前不一致 - * (PR 已变更)即为 true。内容未变则 false(去重,不重复跑)。 + * Whether this PR needs an auto review: true if there is no ledger (never run) or the ledger's recorded updatedAt does not match the current one + * (the PR has changed). Returns false if the content is unchanged (dedup, no repeat run). */ export async function needsAutoReview( stateStore: StateStore, diff --git a/packages/poller/src/comments-cache.ts b/packages/poller/src/comments-cache.ts index c9852a2a..b9d73f63 100644 --- a/packages/poller/src/comments-cache.ts +++ b/packages/poller/src/comments-cache.ts @@ -2,19 +2,19 @@ import type { PrComment } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * PR 评论快照文件。落在 `prs/<localId>/comments.json`。 + * PR comments snapshot file. Lives at `prs/<localId>/comments.json`. * - * 失效判定:`pr_updated_at` 跟当前 PR meta 的 updatedAt 不一致即视为 stale, - * 需要重新从远端拉 + 覆写本文件。**注意 updatedAt 并非对所有平台都随评论跳**——含回复的平台 - * (GitHub 等)新评论会跳,但 Bitbucket 的 updatedDate 不随评论 / 回复变化(见 poller.ts 的 - * `commentCountIncludesReplies` 逻辑)。故轮询发现评论变更时由主进程显式失效本缓存 + 广播 - * comments:changed(见 apps/desktop main 的 invalidateCommentsCache),不依赖 updatedAt 自失效。 + * Staleness check: if `pr_updated_at` does not match the current PR meta's updatedAt it is considered stale, + * requiring a re-fetch from remote + overwrite of this file. **Note that updatedAt does not bump with comments on all platforms**—on platforms with replies + * (GitHub etc.) a new comment bumps it, but Bitbucket's updatedDate does not change with comments / replies (see poller.ts's + * `commentCountIncludesReplies` logic). So when polling discovers a comment change the main process explicitly invalidates this cache + broadcasts + * comments:changed (see apps/desktop main's invalidateCommentsCache), rather than relying on updatedAt to self-invalidate. */ export interface CommentsCacheFile { schema_version: 1; - /** 写入本缓存时 PR meta 的 updatedAt 值;stale 判定的对比目标 */ + /** The PR meta's updatedAt value when this cache was written; the comparison target for the stale check */ pr_updated_at: string; - /** 本次远端拉取完成的 ISO 时间,便于排障 */ + /** ISO time when this remote fetch completed, for troubleshooting */ fetched_at: string; comments: PrComment[]; } @@ -42,7 +42,7 @@ export async function writeCommentsCache( } /** - * 缓存是否需要重拉。null / pr_updated_at 跟当前不一致都算 stale。 + * Whether the cache needs a re-fetch. null / pr_updated_at not matching the current one both count as stale. */ export function isCommentsCacheStale( cache: CommentsCacheFile | null, diff --git a/packages/poller/src/diff-base-cache.ts b/packages/poller/src/diff-base-cache.ts index 17cf43be..342d4fad 100644 --- a/packages/poller/src/diff-base-cache.ts +++ b/packages/poller/src/diff-base-cache.ts @@ -1,28 +1,28 @@ import type { StateStore } from '@meebox/state-store'; /** - * PR diff 基准(merge-base)固化文件。落在 `prs/<localId>/diff-base.json`。 + * PR diff base (merge-base) pinned file. Lives at `prs/<localId>/diff-base.json`. * - * **为什么固化**:PR diff 的语义基准应是「源分支自目标分支分叉处」= `merge-base(target, head)`, - * 而非目标分支当前 tip(`targetRef.sha`,会随别的 PR 合入而前移)。 - * - 变更文件列表 / 改动行用三点 diff(`base...head`),已隐式按 merge-base 算,对目标前移稳定; - * - 但**文件内容**(Monaco 左栏)若按 `targetRef.sha` 读,编辑器实际是两点对比,目标漂移后别的 PR - * 的改动会以「倒挂/撤回」形式串进来。固化 merge-base 后,内容 / 列表 / 计数 / blame / pr-agent - * 一律以它为 base,编辑器即真三点、对目标漂移稳定,评论 / finding 行锚点也有了固定参照。 + * **Why pin it**: the semantic base of a PR diff should be "where the source branch diverged from the target branch" = `merge-base(target, head)`, + * not the target branch's current tip (`targetRef.sha`, which moves forward as other PRs merge in). + * - The changed-files list / changed lines use three-dot diff (`base...head`), already implicitly computed against merge-base, stable against target advancing; + * - but **file content** (Monaco's left pane), if read against `targetRef.sha`, makes the editor an actual two-dot comparison, and after target drifts other PRs' + * changes leak in as "inversions/reverts". Once merge-base is pinned, content / list / counts / blame / pr-agent + * all use it as base, the editor is a true three-dot and stable against target drift, and comment / finding line anchors gain a fixed reference. * - * **失效**:`head`(`sourceRef.sha`)被 rebase 致固化 base 不再是其祖先时重算;源分支把 - * 当前目标分支 merge 进来时也重算,避免旧分叉点把 merge 带来的目标分支改动算进 PR diff。 - * 源分支正常 push(head 仅前进)不失效,base 仍锚在分叉点。 + * **Invalidation**: recomputed when `head` (`sourceRef.sha`) is rebased so the pinned base is no longer its ancestor; also recomputed when the source branch + * merges the current target branch in, to avoid the old divergence point counting the merge's target-branch changes into the PR diff. + * A normal source-branch push (head only advances) does not invalidate; the base stays anchored at the divergence point. * - * 它是**本地派生缓存**、非平台元数据,独立成文件,poller 重写 meta.json 时不触碰。 + * It is a **local derived cache**, not platform metadata, kept as its own file and untouched when poller rewrites meta.json. */ export interface DiffBaseCacheFile { schema_version: 1; - /** 固化的 merge-base sha,作为 diff/内容/计数/blame/pr-agent 的统一 base */ + /** The pinned merge-base sha, used as the unified base for diff/content/counts/blame/pr-agent */ base_sha: string; - /** 算这个 base 时对应的 head(sourceRef.sha),便于排障与人工核对 */ + /** The head (sourceRef.sha) corresponding to when this base was computed, for troubleshooting and manual verification */ head_sha: string; - /** 计算完成的 ISO 时间 */ + /** ISO time when the computation completed */ computed_at: string; } @@ -36,8 +36,8 @@ export interface DiffBaseCacheReuseInput { export async function isDiffBaseCacheReusable(input: DiffBaseCacheReuseInput): Promise<boolean> { const { cachedBaseSha, targetSha, headSha, isAncestor } = input; if (!(await isAncestor(cachedBaseSha, headSha))) return false; - // 源分支 merge 目标分支后,target 会成为 head 的祖先;继续用旧分叉点会把这次 merge - // 带入的目标分支改动也算进 PR diff,必须重算到新的 merge-base(通常就是 target)。 + // After the source branch merges the target branch, target becomes an ancestor of head; keeping the old divergence point + // would count this merge's target-branch changes into the PR diff too, so it must be recomputed to the new merge-base (usually target itself). if (targetSha !== cachedBaseSha && (await isAncestor(targetSha, headSha))) return false; return true; } diff --git a/packages/poller/src/drafts.ts b/packages/poller/src/drafts.ts index a5cae8da..813543f0 100644 --- a/packages/poller/src/drafts.ts +++ b/packages/poller/src/drafts.ts @@ -3,19 +3,19 @@ import type { DraftsFile, ReviewDraft } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * `state/prs/<localId>/drafts.json` 的 KV key。跟 per-PR 目录布局一致, - * PR 退场时 `deleteDir` 整树清掉,不需要单独 evict 草稿。 + * KV key for `state/prs/<localId>/drafts.json`. Consistent with the per-PR directory layout, + * on PR retirement `deleteDir` wipes the whole tree, so drafts need no separate evict. */ function draftsKey(prLocalId: string): string { return `prs/${prLocalId}/drafts`; } -/** 12 hex chars 短 id:跟 PR localId 同套,避免在 UI / 日志里出现两种 id 形态 */ +/** 12-hex-char short id: same scheme as PR localId, to avoid two id forms in the UI / logs */ function makeDraftId(): string { return crypto.randomBytes(8).toString('hex').slice(0, 12); } -/** 读取 drafts;文件缺失 (PR 第一次进入草稿流) 返回空数组 */ +/** Read drafts; returns an empty array if the file is missing (PR entering the draft flow for the first time) */ export async function listDrafts( store: StateStore, prLocalId: string, @@ -34,9 +34,9 @@ export async function getDraft( } /** - * 写整个 drafts 数组回盘。所有 mutator (create / update / delete / - * dropPendingFindingDrafts) 都走这条路径,让"读-改-写"原子性靠 StateStore 的 - * tmp + rename 兜底。 + * Write the entire drafts array back to disk. All mutators (create / update / delete / + * dropPendingFindingDrafts) go through this path, so the "read-modify-write" atomicity falls back on StateStore's + * tmp + rename. */ async function writeDrafts( store: StateStore, @@ -50,9 +50,9 @@ async function writeDrafts( } /** - * 创建一条草稿。id / createdAt / updatedAt 由本函数生成;调用方传业务字段。 - * 约定:origin='finding' 时必须带 source;origin='manual' 时不要 source (上层 IPC - * 已校验)。 + * Create a draft. id / createdAt / updatedAt are generated by this function; the caller passes the business fields. + * Convention: origin='finding' must carry source; origin='manual' must not have source (the upper IPC + * layer already validates). */ export async function createDraft( store: StateStore, @@ -75,12 +75,12 @@ export async function createDraft( } /** - * 部分更新。任何字段 patch 都 bump updatedAt。 + * Partial update. Any field patch bumps updatedAt. * - * 状态自动跃迁:patch 中含 `body` 且草稿当前 status='pending' → 自动转 'edited' - * (用户动了原 AI 建议)。显式 patch.status 优先于自动规则。 + * Automatic status transition: if the patch contains `body` and the draft's current status='pending' → automatically becomes 'edited' + * (the user touched the original AI suggestion). An explicit patch.status takes precedence over the automatic rule. * - * 找不到 draftId 返回 null (调用方按需兜底,不抛错)。 + * Returns null if draftId is not found (the caller falls back as needed, does not throw). */ export async function updateDraft( store: StateStore, @@ -93,7 +93,7 @@ export async function updateDraft( const idx = all.findIndex((d) => d.id === draftId); if (idx < 0) return null; const cur = all[idx]!; - // body 改动 + pending → 自动 'edited';显式 patch.status 优先 + // body change + pending → automatically 'edited'; explicit patch.status takes precedence const autoStatus = patch.body !== undefined && patch.body !== cur.body && cur.status === 'pending' ? 'edited' @@ -116,18 +116,18 @@ export async function deleteDraft( ): Promise<void> { const all = await listDrafts(store, prLocalId); const filtered = all.filter((d) => d.id !== draftId); - if (filtered.length === all.length) return; // no-op,不存在的 id 不抛错 + if (filtered.length === all.length) return; // no-op, a nonexistent id does not throw await writeDrafts(store, prLocalId, filtered); } /** - * /review 完成时的"再摄入"规则:丢弃所有 `pending+finding` 草稿。 + * The "re-ingest" rule when /review completes: discard all `pending+finding` drafts. * - * 保留:status ∈ {edited, posted, rejected} 或 origin='manual' —— 用户已投入的 - * 决断永不被覆盖。 + * Kept: status ∈ {edited, posted, rejected} or origin='manual' —— decisions the user has already invested in + * are never overwritten. * - * 返回被丢弃的草稿数,main 端可以用来给 RunResultView 加 "清理 N 条旧待处理" - * 反馈 chip。 + * Returns the number of drafts discarded, which the main side can use to add a "cleaned up N old pending" + * feedback chip to RunResultView. */ export async function dropPendingFindingDrafts( store: StateStore, diff --git a/packages/poller/src/finding-closures.ts b/packages/poller/src/finding-closures.ts index 32512486..19f18e17 100644 --- a/packages/poller/src/finding-closures.ts +++ b/packages/poller/src/finding-closures.ts @@ -2,15 +2,16 @@ import type { FindingClosure, FindingClosuresFile } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * `state/prs/<localId>/findingClosures.json` 的 KV key。与 per-PR 目录布局一致,PR 退场时整树清掉, - * 无需单独 evict。复评 /ask「取代 / 撤销」原 finding 时记一条关闭关系——独立于本地草稿语义,仅作用于 - * ChatPane finding 卡片的关闭态 + 与复评卡片的双向互链。 + * KV key for `state/prs/<localId>/findingClosures.json`. Aligned with the per-PR directory layout; + * the whole tree is cleared when a PR leaves, so no separate evict is needed. When a re-review /ask + * "supersedes / revokes" an original finding, a closure is recorded here — independent of local draft + * semantics, affecting only the closed state of ChatPane finding cards + the two-way link to the re-review card. */ function closuresKey(prLocalId: string): string { return `prs/${prLocalId}/findingClosures`; } -/** 读取关闭关系;文件缺失返回空数组。 */ +/** Read closures; returns an empty array when the file is missing. */ export async function listFindingClosures( store: StateStore, prLocalId: string, @@ -31,8 +32,9 @@ async function writeFindingClosures( } /** - * 记一条关闭关系(按 (runId, findingId) 标识源 finding)。同一源 finding 已有关闭关系时**覆盖** - * (以最近一次复评为准),避免重复堆积。createdAt 由本函数生成。 + * Record a closure (identifying the source finding by (runId, findingId)). When the same source finding + * already has a closure it is **overwritten** (the most recent re-review wins), avoiding duplicate buildup. + * createdAt is generated by this function. */ export async function addFindingClosure( store: StateStore, @@ -48,7 +50,7 @@ export async function addFindingClosure( return closure; } -/** 撤销一条关闭关系(按源 finding 标识)。不存在则 no-op、不抛错。 */ +/** Revoke a closure (identified by source finding). No-op if it does not exist; does not throw. */ export async function removeFindingClosure( store: StateStore, prLocalId: string, diff --git a/packages/poller/src/parse-output.ts b/packages/poller/src/parse-output.ts index 864befc8..ef681798 100644 --- a/packages/poller/src/parse-output.ts +++ b/packages/poller/src/parse-output.ts @@ -8,32 +8,32 @@ import type { } from '@meebox/shared'; export interface ParsedReviewOutput { - /** 取首个非空 section 标题 / 描述首行作为 PR 摘要 */ + /** Take the first non-empty section title / first line of the description as the PR summary */ summary?: string; findings: Finding[]; - /** 复评 /ask 的裁决(解析自 `<verdict>` 段);非复评 / 未给则不填 */ + /** The re-review /ask verdict (parsed from the `<verdict>` block); left unset for non-re-review / when not given */ askVerdict?: AskVerdict; /** - * pr-agent CLI 看起来"完成"了 (exit 0) 但 stdout 里有 LLM 调用失败的 marker - * (litellm AuthenticationError / "Failed to generate prediction" 等)。命中时 - * 调用方应把 run.status 升级为 'failed' + errorReason='llm-error',UI 显示 - * 红色失败 chip 而非"完成" + * The pr-agent CLI appears to "complete" (exit 0) but stdout has a marker of a failed LLM call + * (litellm AuthenticationError / "Failed to generate prediction" etc.). On a hit, the caller should + * upgrade run.status to 'failed' + errorReason='llm-error', and the UI shows a red failure chip + * rather than "complete" */ llmFailure?: { message: string }; } /** - * 扫 stdout 找 LLM 调用全失败的 marker。pr-agent 的 fallback retry 跑完所有备选 - * 模型仍失败时只 logger.error 一行 "Failed to <tool> PR: Failed to generate - * prediction with any model of [...]",CLI 自身 exit 0 不会主动失败。 + * Scan stdout for a marker of all LLM calls failing. When pr-agent's fallback retry exhausts all alternate + * models and still fails, it only logger.error's one line "Failed to <tool> PR: Failed to generate + * prediction with any model of [...]", and the CLI itself exits 0 without actively failing. * - * 抽取的 message 尽量精炼可读: - * - 优先取 "Error during LLM inference: <一行错因>" 最后一次出现 (一般是真错因) - * - 否则取 "Failed to <tool> PR: <reason>" 那行 - * - 都没有但有 "Failed to generate prediction with any model" → 通用兜底 + * The extracted message is kept as concise and readable as possible: + * - Prefer the last occurrence of "Error during LLM inference: <one-line cause>" (usually the real cause) + * - Otherwise take the "Failed to <tool> PR: <reason>" line + * - Neither present but "Failed to generate prediction with any model" is → generic fallback * - * 调用方拿到 message 后跟 `[详见原始输出]` 提示一起渲染,让用户能展开 raw stdout - * 自行排查 + * After getting the message, the caller renders it alongside a `[see raw output]` hint, letting the user + * expand the raw stdout to investigate themselves */ export function detectLlmFailure(stdout: string): { message: string } | null { const text = stripAnsi(stdout); @@ -43,25 +43,25 @@ export function detectLlmFailure(stdout: string): { message: string } | null { /Error during LLM inference/i.test(text); if (!hasFailMarker) return null; - // 优先抽 "Error during LLM inference: <一行内容>" 中最实质的错因 + // Prefer extracting the most substantive cause from "Error during LLM inference: <one-line content>" const inferenceMatches = [...text.matchAll(/Error during LLM inference:\s*([^\n]+)/gi)]; if (inferenceMatches.length > 0) { const last = inferenceMatches[inferenceMatches.length - 1]![1]!.trim(); return { message: last }; } - // 退到 "Failed to <tool> PR: ..." 那行 + // Fall back to the "Failed to <tool> PR: ..." line const toolMatch = /Failed to (?:review|describe|ask|improve) PR:\s*([^\n]+)/i.exec(text); if (toolMatch) return { message: toolMatch[1]!.trim() }; - // 兜底通用 + // Generic fallback return { message: '所有备选模型均调用失败 (Failed to generate prediction with any model)' }; } /** - * 剥掉文本里的 ANSI 转义码。pr-agent 在容器里跑时 stdout 也带颜色 (logger 配置使然), - * 解析 / 落到 finding body / 走 react-markdown 渲染都不该带 `\x1b[...m`。 - * 实时流走 ChatPane 的 AnsiPre 解析,那条路径保留 ANSI;这里只处理"持久化 / 解析"。 + * Strip ANSI escape codes from text. When pr-agent runs in a container, stdout also carries color (due to logger + * config), and parsing / landing in a finding body / rendering via react-markdown should not carry `\x1b[...m`. + * The live stream goes through ChatPane's AnsiPre parsing, which preserves ANSI; this only handles "persistence / parsing". * - * 同时剥 CSI (`ESC [ ... letter`) 和 OSC (`ESC ] ... BEL/ST`) 等常见控制序列。 + * Also strips common control sequences like CSI (`ESC [ ... letter`) and OSC (`ESC ] ... BEL/ST`). */ // eslint-disable-next-line no-control-regex const ANSI_ESCAPE_RE = /\x1b\[[\d;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; @@ -71,17 +71,17 @@ export function stripAnsi(s: string): string { } interface Section { - /** Markdown header 级别 1-6 */ + /** Markdown header level 1-6 */ level: number; title: string; body: string; } /** - * 把 pr-agent 0.36.0 的 markdown 输出按 H1-H6 切片为 sections。 - * 每个 section 含 level / title / body(body 去掉前后空白)。 - * 顶部无 header 的前导内容也合成一个 level=0 / title='' 的 section,便于 /describe - * 整段拿出来。 + * Slice pr-agent 0.36.0's markdown output into sections by H1-H6. + * Each section has level / title / body (body has leading/trailing whitespace stripped). + * Leading content at the top with no header is also synthesized into a level=0 / title='' section, so /describe + * can be pulled out as a whole segment. */ export function splitMarkdownSections(md: string): Section[] { const lines = md.replace(/\r\n/g, '\n').split('\n'); @@ -91,7 +91,7 @@ export function splitMarkdownSections(md: string): Section[] { for (const line of lines) { const m = HEADER_RE.exec(line); if (m) { - // 先把 prev section 收尾(去掉空段) + // First finalize the prev section (drop empty segments) if (cur && (cur.title || cur.body.trim())) { sections.push({ ...cur, body: cur.body.trim() }); } @@ -106,27 +106,27 @@ export function splitMarkdownSections(md: string): Section[] { return sections; } -/** 剥 markdown 强调符号 (`**Foo**` → `Foo`),用作 title 显示 + 归一比对 */ +/** Strip markdown emphasis marks (`**Foo**` → `Foo`), used for title display + normalized comparison */ function normalizeTitle(t: string): string { return t.replace(/[*_]+/g, '').trim(); } -/** 我们 materializeWorktree 临时建的内部分支名(`pr-<localId>/head|base`),pr-agent 把它当 PR 标识漏出来 */ +/** The internal branch name our materializeWorktree temporarily creates (`pr-<localId>/head|base`), which pr-agent leaks as a PR identifier */ const INTERNAL_BRANCH_RE = /pr-[\w-]+\/(head|base)\b/i; /** - * 剥 body 首尾的"噪音行":连续 markdown HR (`---` / `***` / `___`)、空行、 - * 整行就是 `pr-<localId>/head|base` 的内部分支名 leak。pr-agent 在段落间用 `---` - * 分隔,splitMarkdownSections 切完后这条 HR 会黏在上一个 section 的 body 末尾; - * 类似地 pr-<localId>/head 这种 PR identifier leak 也可能停在 body 首或尾。 - * 全部在 parser 层清掉,下游 / 渲染 / 胶囊拆分都不用关心。 + * Strip "noise lines" from the head and tail of body: consecutive markdown HR (`---` / `***` / `___`), blank lines, + * and whole lines that are just the `pr-<localId>/head|base` internal branch name leak. pr-agent separates paragraphs + * with `---`, and after splitMarkdownSections this HR sticks to the end of the previous section's body; similarly a + * PR identifier leak like pr-<localId>/head may also land at the head or tail of body. + * All cleaned at the parser layer, so downstream / rendering / capsule splitting need not care. */ function trimNoise(body: string): string { const isNoise = (l: string): boolean => { const trimmed = l.trim(); if (trimmed === '') return true; if (/^(?:[-*_]\s*){3,}$/.test(trimmed)) return true; // markdown HR - if (INTERNAL_BRANCH_RE.test(trimmed) && trimmed.length < 40) return true; // 短行 + 含分支名 + if (INTERNAL_BRANCH_RE.test(trimmed) && trimmed.length < 40) return true; // short line + contains branch name return false; }; const lines = body.split('\n'); @@ -136,10 +136,10 @@ function trimNoise(body: string): string { } /** - * 把规整化后的 title 映射到稳定 sectionKey。匹配采用 lower-case + 正则,覆盖 - * pr-agent 不同版本 / /describe vs /review / 中英变体的常见拼写。 + * Map a normalized title to a stable sectionKey. Matching uses lower-case + regex, covering the common spellings + * across pr-agent versions / /describe vs /review / Chinese-English variants. * - * 维护时新增 key:在 PrDocSectionKey 类型加,在此表加一条 [regex, key]。 + * When maintaining and adding a key: add it to the PrDocSectionKey type, and add a [regex, key] entry to this table. */ const SECTION_KEY_PATTERNS: ReadonlyArray<readonly [RegExp, PrDocSectionKey]> = [ [/^(?:suggested[\s_-]+)?title$/i, 'title'], @@ -147,15 +147,15 @@ const SECTION_KEY_PATTERNS: ReadonlyArray<readonly [RegExp, PrDocSectionKey]> = [/^type$/i, 'pr-type'], [/^(?:pr[\s_-]+reviewer[\s_-]+guide|review[\s_-]+summary|summary)$/i, 'summary'], [/^description$/i, 'description'], - // "Diagram Walkthrough" → diagram(含 walkthrough 子串,故须排在 walkthrough 之前) + // "Diagram Walkthrough" → diagram (contains the walkthrough substring, so must precede walkthrough) [/diagram/i, 'diagram'], - // 注入的高层评估段(shim 给 describe schema 加 assessment 字段 → 渲染为 `### **Assessment**`) + // Injected high-level assessment section (the shim adds an assessment field to the describe schema → rendered as `### **Assessment**`) [/^(?:high[\s_-]+level[\s_-]+)?assessment$/i, 'assessment'], [/^walkthrough$/i, 'walkthrough'], - // 测试/安全段的 <strong> 文案随结论变化(pr-agent 模板硬编码,恒英文): - // 测试:Relevant tests / PR contains tests / No relevant tests[ found] - // 安全:Security concerns / No security concerns[ identified] - // 只匹配 "Relevant tests"/"Security concerns" 会漏掉「有测试」「无安全风险」等常见结论 → 退化成 general。 + // The <strong> text of the tests/security sections varies with the conclusion (hardcoded in pr-agent templates, always English): + // tests: Relevant tests / PR contains tests / No relevant tests[ found] + // security: Security concerns / No security concerns[ identified] + // Matching only "Relevant tests"/"Security concerns" would miss common conclusions like "has tests" / "no security risk" → degrading to general. [ /^(?:relevant[\s_-]+tests?|pr[\s_-]+contains[\s_-]+tests?|no[\s_-]+relevant[\s_-]+tests?(?:[\s_-]+found)?)$/i, 'relevant-tests', @@ -166,8 +166,8 @@ const SECTION_KEY_PATTERNS: ReadonlyArray<readonly [RegExp, PrDocSectionKey]> = ]; function mapSectionKey(displayTitle: string): PrDocSectionKey | undefined { - // 剥首尾的 emoji / 标点 / 空白,让 `⏱️ Estimated effort to review: 3 🔵🔵` - // 这种带装饰的标题也能命中 SECTION_KEY_PATTERNS 里的英文锚词 + // Strip leading/trailing emoji / punctuation / whitespace, so a decorated title like `⏱️ Estimated effort to review: 3 🔵🔵` + // can also hit the English anchor words in SECTION_KEY_PATTERNS const cleaned = displayTitle.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').trim(); for (const [re, key] of SECTION_KEY_PATTERNS) { if (re.test(cleaned)) return key; @@ -176,21 +176,22 @@ function mapSectionKey(displayTitle: string): PrDocSectionKey | undefined { } /** - * 噪音段落,直接从 findings 里剔除: - * - `user description`:纯粹回显用户已写的 PR 描述,UI 上已有 PrInfoView 显示 - * - title 含 `pr-<localId>/head|base`:我们临时建的分支名,pr-agent 把它当 PR 标识 - * 作为各级 heading leak 出来(含 emoji / 修饰也照样匹配,子串就行) - * - 空 title + 经 trimNoise 后空 body:纯分支名 leak 的独立 section - * - /ask 工具下的 `question` / `questions` 段:UI 上方 chat-user-msg 已展示用户提问, - * pr-agent 把问题回显在答案文本里是冗余的 + * Noise sections, removed directly from findings: + * - `user description`: purely echoes the PR description the user already wrote, which PrInfoView already displays in the UI + * - title containing `pr-<localId>/head|base`: the branch name we temporarily create, which pr-agent leaks as a PR identifier + * at heading levels (matches even with emoji / decoration — a substring is enough) + * - empty title + empty body after trimNoise: a standalone section that is a pure branch-name leak + * - the `question` / `questions` section under the /ask tool: the chat-user-msg above in the UI already shows the user's question, + * so pr-agent echoing the question in the answer text is redundant */ const SKIP_TITLES = new Set(['user description']); const ASK_QUESTION_HEADERS = new Set(['ask', 'question', 'questions', '问题', '提问']); const ASK_ANSWER_HEADERS = new Set(['answer', 'answers', '回答', '答案', '解答']); /** - * /ask 输出里的结构性表头判别:pr-agent 把「Ask ❓」「回答:」这类标题段回显出来,对 UI 是冗余的 - * (提问已在上方气泡展示、答案紧跟其下)。先剥首尾的 emoji / 标点 / 空格再按集合匹配。 + * Detect structural headers in /ask output: pr-agent echoes title segments like "Ask ❓" / "Answer:", which are redundant + * for the UI (the question is already shown in the bubble above, and the answer follows right below). Strip leading/trailing + * emoji / punctuation / whitespace first, then match against the sets. */ function askHeaderKind(title: string): 'question' | 'answer' | null { const t = normalizeTitle(title) @@ -206,32 +207,32 @@ function shouldSkipSection(sec: Section, tool: ReviewRunTool): boolean { if (SKIP_TITLES.has(t)) return true; if (tool === 'ask') { const kind = askHeaderKind(sec.title); - // 「Ask ❓」等问题回显段整段剔除;空的「回答」表头段(仅标题无正文)同样剔除。 + // Question-echo segments like "Ask ❓" are removed entirely; an empty "Answer" header segment (title only, no body) is also removed. if (kind === 'question') return true; if (kind === 'answer' && !trimNoise(sec.body).trim()) return true; } - // title 含内部分支名 (e.g., "pr-<id>/head" / "pr-<id>/head 🔍" / "## pr-<id>/base") + // title contains an internal branch name (e.g., "pr-<id>/head" / "pr-<id>/head 🔍" / "## pr-<id>/base") if (INTERNAL_BRANCH_RE.test(t)) return true; - // trimNoise 把首尾的 HR / 分支名 leak 剥掉后,body 空 = 整段都是噪音 + // After trimNoise strips the leading/trailing HR / branch-name leak, an empty body = the whole segment is noise const cleanedBody = trimNoise(sec.body).trim(); if (!t && !cleanedBody) return true; return false; } /** - * 判断一个 section 是否是 pr-agent `/review` 的 key_issues_to_review 段。 + * Determine whether a section is pr-agent `/review`'s key_issues_to_review segment. * - * pr-agent v0.35+ LocalGitProvider 跑 /review 时该段渲染为: + * When pr-agent v0.35+ LocalGitProvider runs /review, this segment renders as: * ### ⚡ Recommended focus areas for review - * #### <- 单独空 H4 行作为 issue 间分隔符 - * **潜在空引用** <- issue_header (bold) + * #### <- a standalone empty H4 line as an inter-issue separator + * **Potential null reference** <- issue_header (bold) * - * <issue_content 多行文本> + * <issue_content multi-line text> * #### - * **<下一条 header>** + * **<next header>** * ... * - * 这里只识别 section title。展开成多条 finding 走 expandKeyIssuesSection。 + * This only recognizes the section title. Expanding into multiple findings goes through expandKeyIssuesSection. */ function isKeyIssuesSection(title: string): boolean { return /key\s+issues\s+to\s+review|recommended\s+focus\s+areas\s+for\s+review|关键问题|关注焦点/i.test( @@ -240,32 +241,32 @@ function isKeyIssuesSection(title: string): boolean { } /** - * 把 "Recommended focus areas for review" 段 body 按 issue 拆成多条 finding。 + * Split the "Recommended focus areas for review" segment's body into multiple findings by issue. * - * 切分锚点:**单独一行 + bold 包裹的 issue header**(如 `**潜在空引用**`)。每条 - * issue 的 content 是从它的 bold header 行下一行到下一条 bold header 行之间。 - * `####` 空标题分隔符跳过(splitMarkdownSections 不会切空标题);首条 header - * 之前的内容(一般只有 `####`)丢弃。 + * Split anchor: **a standalone line + a bold-wrapped issue header** (e.g. `**Potential null reference**`). Each issue's + * content spans from the line after its bold header to the next bold header line. + * The `####` empty-title separator is skipped (splitMarkdownSections does not split empty titles); content + * before the first header (usually just `####`) is discarded. * - * anchor 抽取:嵌入式运行时的 sitecustomize 已补 LocalGitProvider.get_line_link - * (返回 `meebox:///<file>#L<s>-L<e>`),所以 header 渲染成 `[**header**](meebox://…)`, - * 可直接取出结构化 anchor(与真实 provider 同源,逐条基本全覆盖)。链接缺失时(旧运行时 - * / 真无 anchor)退回从 issue 文本 best-effort 推断:旧 marker `[file:…, lines:…]`, - * 或 `path/to/file.ext` + `第 N 行 / lines N-M` 关键词。都抽不到则 anchor 留空, - * UI 端把"跳转编辑"按钮 disable。 + * anchor extraction: the embedded runtime's sitecustomize already patches LocalGitProvider.get_line_link + * (returning `meebox:///<file>#L<s>-L<e>`), so the header renders as `[**header**](meebox://…)`, from which + * a structured anchor can be extracted directly (same source as the real provider, near-full per-issue coverage). + * When the link is missing (old runtime / truly no anchor), fall back to best-effort inference from the issue text: + * the old marker `[file:…, lines:…]`, or `path/to/file.ext` + `第 N 行 / lines N-M` keywords. When nothing can be + * extracted the anchor is left empty, and the UI disables the "jump to edit" button. */ function expandKeyIssuesSection(sec: Section, baseIndex: number, tool: ReviewRunTool): Finding[] { const body = trimNoise(sec.body); const lines = body.split('\n'); - // issue header 行两种形态: - // - 无 link(旧版 / 真无 anchor):整行 `**header**` - // - 有 link(sitecustomize 补的 get_line_link):`[**header**](meebox:///file#Ls-Le)` + // Two forms of the issue header line: + // - no link (old version / truly no anchor): the whole line is `**header**` + // - with link (get_line_link patched by sitecustomize): `[**header**](meebox:///file#Ls-Le)` const HEADER_LINE_RE = /^\s*\*\*\s*([^*\n][^*\n]*?)\s*\*\*\s*$/; const LINKED_HEADER_RE = /^\s*\[\s*\*\*\s*([^*\n][^*\n]*?)\s*\*\*\s*\]\(\s*([^)\s]+)\s*\)\s*$/; interface IssueBlock { title: string; body: string; - /** header 行带的链接(如 meebox://…);用于取结构化 anchor */ + /** The link carried by the header line (e.g. meebox://…); used to extract a structured anchor */ link?: string; } const blocks: IssueBlock[] = []; @@ -281,8 +282,8 @@ function expandKeyIssuesSection(sec: Section, baseIndex: number, tool: ReviewRun continue; } if (cur) { - // 跳过 issue 块之间的空 H4 分隔符(splitMarkdownSections 不会切 `#### ` 空标题, - // 整行就是 `#`+ 空白时直接丢;正文里残留 `#` 不影响) + // Skip the empty H4 separator between issue blocks (splitMarkdownSections does not split a `#### ` empty title; + // drop directly when the whole line is `#` + whitespace; a residual `#` in the body has no effect) if (/^#{2,}\s*$/.test(line.trim())) continue; cur.body += `${line}\n`; } @@ -290,13 +291,13 @@ function expandKeyIssuesSection(sec: Section, baseIndex: number, tool: ReviewRun if (cur) blocks.push(cur); if (blocks.length === 0) { - // body 完全找不到 bold header(旧版 / prompt 漂移) → 退回整段当一条 finding + // No bold header found at all in body (old version / prompt drift) → fall back to the whole segment as one finding return [sectionToFinding(sec, baseIndex, tool)]; } return blocks.map((b, i) => { const raw = b.body.trim(); - // 先用含 marker 的原文解析 anchor(marker 是行号兜底),再 strip 用于展示 + // First parse the anchor from the raw text containing the marker (the marker is a line-number fallback), then strip for display const anchor = resolveIssueAnchor(b.link, raw); const issueBody = stripAnchorMarker(raw); const id = `${tool}-${String(baseIndex + i).padStart(3, '0')}`; @@ -311,22 +312,22 @@ function expandKeyIssuesSection(sec: Section, baseIndex: number, tool: ReviewRun }); } -// ===== GFM 输出解析(gfm_markdown=True:shim 让 LocalGitProvider 支持 GFM,使 /describe -// 出 mermaid 图、/review 走 GFM 富 markdown)。GFM 下 /review 整体是 <table>,每段一个 -// <tr><td>…<strong>标题</strong>…</td></tr>;key_issues 段内 finding 是 -// <details><summary><a href='meebox://…'><strong>标题</strong></a>\n\n内容\n</summary>\n\n代码片段\n\n</details> -// 或 <a href='meebox://…'><strong>标题</strong></a><br>内容。markdown H1-H6 切片对其失配, -// 故另走这条 HTML 解析路径(仅 review + 检测到 GFM 时)。 +// ===== GFM output parsing (gfm_markdown=True: the shim makes LocalGitProvider support GFM, so /describe +// emits mermaid diagrams and /review uses GFM rich markdown). Under GFM /review is a whole <table>, with each +// segment being one <tr><td>…<strong>title</strong>…</td></tr>; a finding inside the key_issues segment is +// <details><summary><a href='meebox://…'><strong>title</strong></a>\n\ncontent\n</summary>\n\ncode snippet\n\n</details> +// or <a href='meebox://…'><strong>title</strong></a><br>content. markdown H1-H6 slicing mismatches it, +// so this HTML parsing path is used instead (only for review + when GFM is detected). /** - * 是否是 GFM 表格形态的 /review 输出(决定走 HTML 还是 markdown 解析路径)。 + * Whether the output is GFM table-form /review (decides the HTML vs markdown parsing path). * - * 判定:含闭合 `<table>…</table>` 且至少有一个单元格 `<td>`/`<th>`。判定前先剥掉代码围栏 - * (```…```),避免「markdown 正文在代码块里提到 `<table>`」被误判进 HTML 路径。 + * Decision: contains a closed `<table>…</table>` and at least one `<td>`/`<th>` cell. Before deciding, strip code + * fences (```…```), to avoid "markdown body mentioning `<table>` inside a code block" being misjudged into the HTML path. * - * 注意:不能要求「以 <table> 开头」—— 真实 GFM /review 常在表格前带一句前导说明 - * (如「以下是辅助评审的关键观察:」),强行锚定开头会漏判、导致整表退化成单条总结、 - * key_issues 也不再拆成独立 code-feedback。 + * Note: must not require "starts with <table>" — real GFM /review often carries a leading sentence before the table + * (e.g. "The following are key observations to aid the review:"); forcibly anchoring the start would misjudge, causing + * the whole table to degrade into a single summary and key_issues to no longer split into standalone code-feedback. */ function isGfmReviewOutput(text: string): boolean { const withoutFences = text.replace(/```[\s\S]*?```/g, ''); @@ -337,8 +338,8 @@ function isGfmReviewOutput(text: string): boolean { ); } -/** GFM finding 片段 → 可渲染文本:<br>/<summary>/<details> → 换行,其余标签剥掉, - * 常见实体解码;代码围栏(```)是字面文本,原样保留。 */ +/** GFM finding fragment → renderable text: <br>/<summary>/<details> → newline, other tags stripped, + * common entities decoded; code fences (```) are literal text, preserved as-is. */ function gfmInlineToText(s: string): string { return s .replace(/<br\s*\/?>/gi, '\n') @@ -352,15 +353,15 @@ function gfmInlineToText(s: string): string { .trim(); } -/** 把 GFM <table> 拆成行 section:每个 <tr> 内**所有**单元格(<td>/<th>)内容,行首 <strong> - * 作 title,其余作 body(key_issues 行的 body 保留原始 HTML 供 expandGfmKeyIssues 抽 finding)。 */ +/** Split a GFM <table> into row sections: the content of **all** cells (<td>/<th>) within each <tr>, the leading <strong> + * as title and the rest as body (the body of a key_issues row keeps the raw HTML for expandGfmKeyIssues to extract findings). */ function splitGfmTableSections(html: string): Section[] { const sections: Section[] = []; const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi; let rm: RegExpExecArray | null; while ((rm = rowRe.exec(html)) !== null) { const row = rm[1]!; - // 收集行内所有单元格并以 \n\n 连接:多列表格不丢后续单元格内容(只取首个 <td> 会漏掉评审条目)。 + // Collect all cells in the row and join with \n\n: a multi-column table does not drop subsequent cell content (taking only the first <td> would miss review items). const cellRe = /<(?:td|th)\b[^>]*>([\s\S]*?)<\/(?:td|th)>/gi; const cells: string[] = []; let cm: RegExpExecArray | null; @@ -372,7 +373,7 @@ function splitGfmTableSections(html: string): Section[] { .replace(/[::]\s*$/, '') .trim() : ''; - // `<strong>标题</strong>: 值` 形态:去掉标题后残留的前导分隔符(: :  空白) + // `<strong>title</strong>: value` form: the leading separator (: :  whitespace) left after removing the title const body = (titleMatch ? cellText.slice(titleMatch.index + titleMatch[0].length) : cellText) .replace(/^(?: |\s|[::])+/gi, '') .trim(); @@ -381,8 +382,8 @@ function splitGfmTableSections(html: string): Section[] { return sections; } -/** 从 GFM key_issues 段 body(原始 HTML)抽多条 finding:以 <a href><strong>标题</strong></a> - * 为锚,相邻两条之间为该条正文。link 取结构化 anchor,与 markdown 路径同源。 */ +/** Extract multiple findings from the GFM key_issues segment body (raw HTML): anchoring on <a href><strong>title</strong></a>, + * the text between two adjacent ones is that finding's body. The link yields the structured anchor, same source as the markdown path. */ function expandGfmKeyIssues(html: string, baseIndex: number, tool: ReviewRunTool): Finding[] { const FIND_RE = /<a\s+href=['"]([^'"]+)['"]\s*>\s*<strong>([\s\S]*?)<\/strong>\s*<\/a>/gi; const matches = [...html.matchAll(FIND_RE)]; @@ -392,12 +393,12 @@ function expandGfmKeyIssues(html: string, baseIndex: number, tool: ReviewRunTool const start = m.index + m[0].length; const end = i + 1 < matches.length ? matches[i + 1]!.index : html.length; let chunk = html.slice(start, end); - // <details> 形态:issue_content 在 </summary> 前;其后是 relevant_lines 代码片段, - // 不塞进 body(代码由 anchor → DiffView 展示,body 重复贴大段代码很吵)。 + // <details> form: issue_content is before </summary>; after it is the relevant_lines code snippet, + // which is not put into body (the code is shown via anchor → DiffView; re-pasting a large code block in body is noisy). const sumIdx = chunk.search(/<\/summary>/i); if (sumIdx >= 0) chunk = chunk.slice(0, sumIdx); const raw = gfmInlineToText(chunk); - // 先用含 marker 的原文解析 anchor(行号兜底),再 strip 用于展示 + // First parse the anchor from the raw text containing the marker (line-number fallback), then strip for display const anchor = resolveIssueAnchor(link, raw); const issueBody = stripAnchorMarker(raw); return { @@ -412,18 +413,18 @@ function expandGfmKeyIssues(html: string, baseIndex: number, tool: ReviewRunTool } /** - * 从 issue 文本里 best-effort 抽 file path + 行号。pr-agent 渲染丢字段后这是唯一 - * 兜底途径:扫一遍 content,找 (1) 含 `/` 或 `\` 或 `.<ext>` 的路径 token, - * (2) `第 N 行 / 行 N-M / line(s) N-M / Lines N-M` 形式的行号。抽不到返回 undefined。 + * Best-effort extract a file path + line number from the issue text. After pr-agent rendering loses the fields this is + * the only fallback route: scan the content once, finding (1) a path token containing `/` or `\` or `.<ext>`, + * (2) a line number in the form `第 N 行 / 行 N-M / line(s) N-M / Lines N-M`. Returns undefined when nothing is extracted. * - * 我们也认 prompt extra-instructions 里我们自己请求 model 显式输出的 marker: + * We also recognize the marker we ourselves ask the model to output explicitly in the prompt extra-instructions: * [file: <path>, lines: <start>-<end>] - * 用作 anchor 强信号 (优先采用) + * used as a strong anchor signal (preferentially adopted) */ /** - * 解析 sitecustomize 注入的 anchor 链接 `meebox:///<url-encoded-file>#L<s>-L<e>` - * (行号段可选;end 可省)。非 meebox 链接(真实 provider 的 http 链接)返回 undefined, - * 交回文本推断。path 做 URL 解码还原空格 / 非 ASCII。 + * Parse the anchor link injected by sitecustomize `meebox:///<url-encoded-file>#L<s>-L<e>` + * (the line-number part is optional; end may be omitted). A non-meebox link (a real provider's http link) returns undefined, + * handing back to text inference. path is URL-decoded to restore spaces / non-ASCII. */ function parseMeeboxAnchor(url: string): FindingAnchor | undefined { const m = /^meebox:\/{0,3}([^#?]+)(?:#L(\d+)(?:-L(\d+))?)?\s*$/i.exec(url.trim()); @@ -442,13 +443,14 @@ function parseMeeboxAnchor(url: string): FindingAnchor | undefined { } /** - * 合并两路 anchor 信号,得到最完整的定位: - * - meebox 链接(sitecustomize 注入,path 来自 provider 同源、最可靠) - * - 文本推断(原始 `[file:…, lines:…]` marker 协议 / 路径+行号兜底) + * Merge the two anchor signals to get the most complete location: + * - meebox link (injected by sitecustomize, path from the same source as the provider, most reliable) + * - text inference (the original `[file:…, lines:…]` marker protocol / path+line-number fallback) * - * 规则:链接的 path 权威;行号链接优先(模型填了结构化 start/end → 链接自带 #L), - * 链接缺行号时回退用文本协议的行号补全——但仅当文本指向同一文件,避免跨文件错配。 - * 这样既拿到可靠 path,又不丢模型只写进 marker、没填结构化字段时的行号。 + * Rules: the link's path is authoritative; a line-number-bearing link takes priority (the model filled structured start/end + * → the link carries #L), and when the link lacks line numbers, fall back to completing with the text protocol's line numbers — + * but only when the text points at the same file, to avoid cross-file mismatch. This gets a reliable path while not losing + * the line numbers from when the model only wrote them into the marker without filling structured fields. */ function resolveIssueAnchor(link: string | undefined, body: string): FindingAnchor | undefined { const linkAnchor = link ? parseMeeboxAnchor(link) : undefined; @@ -465,14 +467,14 @@ function resolveIssueAnchor(link: string | undefined, body: string): FindingAnch return linkAnchor; } -/** 锚点 marker `[file: <path>, lines: <s>-<e>]`(我们 prompt 注入的)。抽成 anchor 后 - * 应从展示 body 删除,否则会作为多余文字泄漏到 finding 正文。 - * 路径本身可能含 `[]`(如 `a/[m-123]/x.ts`):带 lines 时用惰性 `.+?` + 必现的 `, lines:` - * 后缀界定(`.` 可匹配 `]`,故路径里的 `]` 不再误截);无 lines 时回退到不含 `]` 的旧式。 */ +/** Anchor marker `[file: <path>, lines: <s>-<e>]` (injected by our prompt). After being extracted into an anchor it + * should be removed from the display body, otherwise it leaks into the finding text as stray text. + * The path itself may contain `[]` (e.g. `a/[m-123]/x.ts`): when lines are present, delimit with a lazy `.+?` + the + * mandatory `, lines:` suffix (`.` can match `]`, so the `]` in the path is no longer wrongly cut); without lines, fall back to the old form excluding `]`. */ const ANCHOR_MARKER_RE = /\[\s*file\s*:\s*(?:.+?\s*,\s*lines?\s*:\s*\d+(?:\s*[-–—]\s*\d+)?|[^,\]\n]+?)\s*\]/gi; -/** 从 finding body 删掉锚点 marker,并收敛多余空白。 */ +/** Remove the anchor marker from the finding body and collapse excess whitespace. */ export function stripAnchorMarker(body: string): string { return body .replace(ANCHOR_MARKER_RE, '') @@ -482,8 +484,8 @@ export function stripAnchorMarker(body: string): string { } function inferAnchorFromIssueText(text: string): FindingAnchor | undefined { - // 显式 marker (我们 prompt 注入的)。带 lines 时路径用惰性 `.+?` + 必现 `, lines:` 后缀界定, - // 允许路径含 `[]`(`.` 匹配 `]`,不被路径里的 `]` 误截);无 lines 时回退到不含 `]` 的旧式。 + // Explicit marker (injected by our prompt). With lines, delimit the path with a lazy `.+?` + the mandatory `, lines:` suffix, + // allowing the path to contain `[]` (`.` matches `]`, not wrongly cut by the `]` in the path); without lines, fall back to the old form excluding `]`. const markerWithLines = /\[\s*file\s*:\s*(.+?)\s*,\s*lines?\s*:\s*(\d+)(?:\s*[-–—]\s*(\d+))?\s*\]/i; const markerNoLines = /\[\s*file\s*:\s*([^,\]\s][^,\]]*?)\s*\]/i; @@ -495,7 +497,7 @@ function inferAnchorFromIssueText(text: string): FindingAnchor | undefined { if (mm[3]) anchor.endLine = Number.parseInt(mm[3], 10); return anchor; } - // 兜底 1:含 `/` 的路径 token (优先匹配 `path/to/file.ext`) + // Fallback 1: a path token containing `/` (preferentially matching `path/to/file.ext`) const pathRe = /(?:^|[\s(`'"])([A-Za-z0-9_./\\-]+\/[A-Za-z0-9_./\\-]*\.[A-Za-z0-9]{1,8})(?=[\s)`'":.,!?]|$)/m; const pm = pathRe.exec(text); @@ -517,23 +519,23 @@ function inferAnchorFromIssueText(text: string): FindingAnchor | undefined { } /** - * 解析单段 markdown 为 Finding。识别 pr-agent 常见的 - * `**File:** path` + `**Lines:** N-M` 模式 → code-feedback;其它返回 general / description。 + * Parse a single markdown segment into a Finding. Recognizes pr-agent's common + * `**File:** path` + `**Lines:** N-M` pattern → code-feedback; otherwise returns general / description. */ export function sectionToFinding(sec: Section, index: number, tool: ReviewRunTool): Finding { const id = `${tool}-${String(index).padStart(3, '0')}`; const body = trimNoise(sec.body); const rawTitle = normalizeTitle(sec.title) || undefined; const mappedKey = rawTitle ? mapSectionKey(rawTitle) : undefined; - // /ask:带正文的「回答 / Answer」表头是冗余的(其下就是答案正文)→ 清掉标题只留正文。 + // /ask: an "Answer" header with body is redundant (the answer text follows right below) → clear the title, keep only the body. const displayTitle = tool === 'ask' && askHeaderKind(sec.title) === 'answer' ? undefined : rawTitle; - // pr-agent 0.36.0 review 输出形如 (pr-agent 自定义 prompt 或非 LocalGitProvider 时): + // pr-agent 0.36.0 review output looks like (with a pr-agent custom prompt or a non-LocalGitProvider): // **File:** src/foo.ts // **Lines:** 42-50 // **Issue:** ... - // 兼容 file_path / Line / 行号 等中英变体 + // Compatible with Chinese-English variants like file_path / Line / 行号 const fileMatch = /^\s*\*\*\s*(?:file(?:[_\s]?path)?|路径|文件)\s*:?\s*\*\*\s*(.+?)\s*$/im.exec( body, ); @@ -560,13 +562,13 @@ export function sectionToFinding(sec: Section, index: number, tool: ReviewRunToo }; } - // /ask 兜底:pr-agent /ask 自由回答不会按 `**File:** xxx` 这种结构化格式输出, - // 但我们 prompt 注入了 `[file: <path>, lines: <s>-<e>]` marker 要求 model 在 - // 答案涉及代码位置时显式标注。命中 marker 则升格成 code-feedback —— UI 会显示 - // "→ 编辑" 按钮直跳 DiffView 行内评论草稿,让 /ask 的提问回答也能转化为可发布 - // 的 inline comment (跟 /review 路径一致)。 - // 仅 /ask 启用:/describe 的 description 段如果偶然提到一个路径不应被识别成 - // code-feedback;/review 的常规段也不该被这条兜底覆盖 + // /ask fallback: pr-agent /ask free-form answers do not output in a structured format like `**File:** xxx`, + // but our prompt injects a `[file: <path>, lines: <s>-<e>]` marker requiring the model to annotate explicitly + // when the answer involves a code location. On a marker hit it is upgraded to code-feedback — the UI shows a + // "→ edit" button jumping straight to a DiffView inline-comment draft, so /ask question answers can also convert + // into a publishable inline comment (consistent with the /review path). + // Enabled only for /ask: if /describe's description segment happens to mention a path it should not be recognized as + // code-feedback; /review's regular segments should also not be covered by this fallback if (tool === 'ask') { const anchor = inferAnchorFromIssueText(body); if (anchor && typeof anchor.startLine === 'number') { @@ -590,15 +592,15 @@ export function sectionToFinding(sec: Section, index: number, tool: ReviewRunToo }; } -// ===== /describe 的 File Walkthrough 处理 ===== -// pr-agent 把 File Walkthrough 作为 HTML <details><table> 追加在 describe 末尾(line 131), -// 没有 markdown header,会黏进上一段(通常是 ### Diagram Walkthrough)的 body。这里把它 -// 单独抽出,并把嵌套表格转成「按分组折叠的无序列表」(聊天面板里表格体验差),同时丢掉 -// 无实际意义的 +1/-1 统计列。 +// ===== /describe's File Walkthrough handling ===== +// pr-agent appends File Walkthrough as an HTML <details><table> at the end of describe (line 131), +// with no markdown header, so it sticks into the previous segment's body (usually ### Diagram Walkthrough). Here it is +// extracted separately, and the nested table is converted into a "grouped-collapsible unordered list" (tables are a poor +// experience in the chat panel), while dropping the meaningless +1/-1 stats column. /** - * 从 describe 输出抽出 File Walkthrough 块(追加在末尾,含嵌套 details,故从起点取到结尾)。 - * 返回 { rest: 去掉该块的正文, block: 该块原文 };没有则 null。 + * Extract the File Walkthrough block from describe output (appended at the end, containing nested details, so taken from start to end). + * Returns { rest: the body with the block removed, block: the block's raw text }; null if absent. */ function extractFileWalkthrough(md: string): { rest: string; block: string } | null { const startRe = /<details[^>]*>\s*<summary>\s*<h3>\s*File Walkthrough\s*<\/h3>\s*<\/summary>/i; @@ -607,25 +609,26 @@ function extractFileWalkthrough(md: string): { rest: string; block: string } | n return { rest: md.slice(0, m.index).trimEnd(), block: md.slice(m.index) }; } -/** HTML 文本节点转义:desc/文件名经 gfmInlineToText 已把实体解码成裸 < > &, - * 再放进 <li> 文本上下文须重新转义,避免破坏结构 / 被下游清洗器误判。 */ +/** HTML text-node escaping: after gfmInlineToText decodes entities into bare < > & in desc/filenames, + * putting them into an <li> text context requires re-escaping, to avoid breaking the structure / being misjudged by the downstream sanitizer. */ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } /** - * 把 File Walkthrough 的嵌套 HTML 表格转成「按分类折叠的无序列表」纯 HTML: - * <details open><summary>分类名(N)</summary> - * <ul><li><strong>文件名</strong> — 描述</li>…</ul> + * Convert File Walkthrough's nested HTML table into pure HTML as a "by-category collapsible unordered list": + * <details open><summary>category name(N)</summary> + * <ul><li><strong>filename</strong> — description</li>…</ul> * </details> - * 保留 pr-agent 的多级分类(每个分类各自独立成可收起/展开的 <details>),丢掉每行后面 - * 无意义的 +1/-1 链接列。分类靠「<strong>X</strong></td><td><details|table>」识别 —— pr-agent - * 仅在文件数超阈值时给分类包一层 <details>(collapsible_file_list=adaptive),小 PR 则是 - * 裸 <td><table>,两种都要认,否则小 PR 会识别不到分类、退化成平铺列表。文件靠 - * 「<strong>X</strong><dd><code>desc</code>」识别,按出现位置归到所属分类。 + * Preserves pr-agent's multi-level categories (each category is its own collapsible/expandable <details>), dropping the + * meaningless +1/-1 link column after each row. Categories are recognized by "<strong>X</strong></td><td><details|table>" — + * pr-agent only wraps a category in a <details> when the file count exceeds a threshold (collapsible_file_list=adaptive), + * while a small PR is a bare <td><table>; both must be recognized, otherwise a small PR would fail to recognize categories + * and degrade into a flat list. Files are recognized by "<strong>X</strong><dd><code>desc</code>", assigned to the owning + * category by position of appearance. * - * 注:产出纯 HTML(非 markdown `- ` 列表)——「markdown 列表嵌在 <details> 原始 HTML 块内」 - * 在 react-markdown(rehype-raw) 下并不稳定渲染成折叠区,纯 HTML 才能确保各级可靠折叠。 + * Note: produces pure HTML (not a markdown `- ` list) — "a markdown list nested inside a <details> raw HTML block" does not + * reliably render as a collapsible region under react-markdown(rehype-raw); only pure HTML ensures reliable collapse at each level. */ function walkthroughToList(block: string): string { const GROUP_RE = /<strong>([^<]+?)<\/strong>\s*<\/td>\s*<td>\s*<(?:details|table)\b/gi; @@ -642,7 +645,7 @@ function walkthroughToList(block: string): string { `<ul>\n${items.map(fmtItem).join('\n')}\n</ul>`; if (groups.length === 0) { - // 无分类:直接平铺列表 + // No categories: a flat list directly return files.length ? fmtList(files) : '(无文件变更明细)'; } const parts: string[] = []; @@ -660,15 +663,15 @@ function walkthroughToList(block: string): string { return parts.join('\n') || (files.length ? fmtList(files) : '(无文件变更明细)'); } -/** /ask 结构化分段标签 → sectionKey(固定渲染顺序:summary → analysis → suggestions)。 */ +/** /ask structured-segment tags → sectionKey (fixed render order: summary → analysis → suggestions). */ const ASK_STRUCTURED_SECTIONS: ReadonlyArray<{ tag: string; key: PrDocSectionKey }> = [ { tag: 'summary', key: 'ask-summary' }, { tag: 'analysis', key: 'ask-analysis' }, { tag: 'suggestions', key: 'ask-suggestions' }, ]; -/** 取一个 `<tag>…</tag>` 块的正文(去 anchor marker + 噪音),空则 undefined。大小写不敏感、跨行。 - * summary / analysis 段是纯文本展示,marker 是阅读噪音先剥掉。 */ +/** Take the body of a `<tag>…</tag>` block (removing the anchor marker + noise); undefined if empty. Case-insensitive, multi-line. + * The summary / analysis segments are plain-text display, so the marker is reading noise and is stripped first. */ function extractAskSection(md: string, tag: string): string | undefined { const m = extractAskSectionRaw(md, tag); if (!m) return undefined; @@ -676,8 +679,8 @@ function extractAskSection(md: string, tag: string): string | undefined { return body || undefined; } -/** 取一个 `<tag>…</tag>` 块的原始正文(仅去首尾噪音,**保留 anchor marker**),空则 undefined。 - * suggestions 段要据 marker 拆条 / 定位,故走原始正文。 */ +/** Take the raw body of a `<tag>…</tag>` block (removing only leading/trailing noise, **keeping the anchor marker**); undefined if empty. + * The suggestions segment needs the marker to split entries / locate, so it uses the raw body. */ function extractAskSectionRaw(md: string, tag: string): string | undefined { const re = new RegExp(`<${tag}\\s*>([\\s\\S]*?)<\\/${tag}\\s*>`, 'i'); const m = re.exec(md); @@ -687,9 +690,10 @@ function extractAskSectionRaw(md: string, tag: string): string | undefined { } /** - * 解析 `<suggestions>` 段为 finding 列表:以 anchor marker 为锚把建议拆成逐条——带行号 marker 的条目 - * 升为 `code-suggestion`(带 anchor,UI 出代码定位 + 编辑 / 拒绝 / 引用,可采纳为行内评论),其余文本归并为 - * 普通 `ask-suggestions`。无任何 marker → 整段一条 `ask-suggestions`(同旧行为)。idx 从 baseIndex 起编号。 + * Parse the `<suggestions>` segment into a finding list: anchoring on the anchor marker, split suggestions entry by entry — + * entries with a line-number marker are upgraded to `code-suggestion` (with an anchor; the UI shows code location + edit / + * reject / quote, and it can be adopted as an inline comment), and the remaining text is merged into a plain `ask-suggestions`. + * No marker at all → the whole segment as one `ask-suggestions` (same as the old behavior). idx is numbered from baseIndex. */ function parseAskSuggestions(body: string, baseIndex: number): Finding[] { const pad = (n: number): string => String(n).padStart(3, '0'); @@ -719,7 +723,7 @@ function parseAskSuggestions(body: string, baseIndex: number): Finding[] { }); idx += 1; } - // 末个 marker 之后的尾部(无 marker)文本归并为一条普通建议。 + // The tail text after the last marker (no marker) is merged into one plain suggestion. const tail = trimNoise(stripAnchorMarker(body.slice(cursor))); if (tail) { findings.push({ id: `ask-${pad(idx)}`, category: 'general', sectionKey: 'ask-suggestions', body: tail }); @@ -727,7 +731,7 @@ function parseAskSuggestions(body: string, baseIndex: number): Finding[] { return findings; } -/** 抽复评 /ask 的 `<verdict>replace|keep|drop</verdict>`(大小写 / 空白容错)。无 / 不认得则 undefined。 */ +/** Extract the re-review /ask's `<verdict>replace|keep|drop</verdict>` (case / whitespace tolerant). undefined if absent / unrecognized. */ function extractAskVerdict(md: string): AskVerdict | undefined { const m = /<verdict\s*>([\s\S]*?)<\/verdict\s*>/i.exec(md); const v = m?.[1]?.trim().toLowerCase(); @@ -735,22 +739,22 @@ function extractAskVerdict(md: string): AskVerdict | undefined { } /** - * /ask 结构化分段解析:把 prompt 注入要求模型输出的 `<summary>` / `<analysis>` / `<suggestions>` - * 三段切成独立 finding(各带 ask-* sectionKey,UI 据此着色 / 折叠 / 排序)。summary 正文首行 - * 兼作 ParsedReviewOutput.summary。 + * /ask structured-segment parsing: split the three segments `<summary>` / `<analysis>` / `<suggestions>` that the + * prompt injection requires the model to output into standalone findings (each with an ask-* sectionKey, by which the UI + * colors / collapses / sorts). The first line of the summary body doubles as ParsedReviewOutput.summary. * - * 回退:未出现任一配对标签、或标签都为空 → 返回 null,调用方走普通 /ask markdown 解析(模型没遵循 - * 结构化指令时不破坏既有行为)。 + * Fallback: no paired tag appears, or all tags are empty → return null, and the caller goes through ordinary /ask markdown + * parsing (not breaking existing behavior when the model does not follow the structured instruction). */ export function parseStructuredAsk(stdout: string): ParsedReviewOutput | null { const md = stripAnsi(stdout); - // 至少要有一对识别的开合标签,否则视作非结构化输出、回退。 + // There must be at least one recognized pair of open/close tags, otherwise treat as unstructured output and fall back. if (!/<(summary|analysis|suggestions)\s*>[\s\S]*?<\/\1\s*>/i.test(md)) return null; const findings: Finding[] = []; let summary: string | undefined; let idx = 0; for (const { tag, key } of ASK_STRUCTURED_SECTIONS) { - // suggestions 段特殊处理:按 anchor marker 拆成逐条(带行号的升为可定位 code-suggestion)。 + // suggestions segment special handling: split entry by entry by the anchor marker (those with line numbers upgraded to a locatable code-suggestion). if (tag === 'suggestions') { const raw = extractAskSectionRaw(md, tag); if (!raw) continue; @@ -774,42 +778,42 @@ export function parseStructuredAsk(stdout: string): ParsedReviewOutput | null { .map((l) => l.trim()) .find(Boolean); } - if (findings.length === 0) return null; // 有标签但全空 → 回退 + if (findings.length === 0) return null; // tags present but all empty → fall back const askVerdict = extractAskVerdict(md); return { findings, ...(summary ? { summary } : {}), ...(askVerdict ? { askVerdict } : {}) }; } /** - * 解析 pr-agent stdout 为 findings 列表。M3-B2 是 best-effort: - * - 切 markdown sections - * - 跳过噪音段落 (临时分支名 leak / 用户描述回显) - * - 识别 file + lines 模式标 code-feedback - * - 已知 section title 映射到 sectionKey,UI 用于排序 / 着色 + * Parse pr-agent stdout into a findings list. M3-B2 is best-effort: + * - split markdown sections + * - skip noise sections (temporary branch-name leak / user description echo) + * - recognize the file + lines pattern and mark code-feedback + * - map known section titles to sectionKey, used by the UI for sorting / coloring * - * /improve 走专门解析路径:pr-agent local provider 输出是 HTML <details> 嵌套结构 - * 而非纯 markdown sections,splitMarkdownSections 切不出来。 + * /improve goes through a dedicated parsing path: the pr-agent local provider outputs a nested HTML <details> structure + * rather than pure markdown sections, which splitMarkdownSections cannot split. * - * 失败 / 空输出 / 完全不规则的格式 → findings 为空数组,调用方可以回退到展示原始 - * stdout。不在这里抛错。 + * Failure / empty output / a completely irregular format → findings is an empty array, and the caller can fall back to + * showing the raw stdout. No error is thrown here. */ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedReviewOutput { - // LLM 失败检测先做:失败时仍可能有部分 sections (e.g., 之前轮次的 logger marker), - // 让 findings 解析继续走完,但 llmFailure 字段标记让上层判定 status='failed' + // LLM failure detection first: on failure there may still be partial sections (e.g., a logger marker from a previous round), + // so let findings parsing run to completion, but the llmFailure field marks it so the upper layer decides status='failed' const llmFailure = detectLlmFailure(stdout) ?? undefined; if (tool === 'improve') { const out = parseImproveOutput(stdout); return llmFailure ? { ...out, llmFailure } : out; } - // /ask 结构化分段:prompt 注入 <summary>/<analysis>/<suggestions> 标签(见 pr-agent-bridge - // prompts.ts),命中则按段产出彩色 / 可折叠 finding;模型未遵循(无配对标签)则回退到下方普通解析。 + // /ask structured segments: the prompt injects <summary>/<analysis>/<suggestions> tags (see pr-agent-bridge + // prompts.ts); on a hit it produces colored / collapsible findings per segment; if the model does not follow (no paired tags) fall back to the ordinary parsing below. if (tool === 'ask') { const structured = parseStructuredAsk(stdout); if (structured) return llmFailure ? { ...structured, llmFailure } : structured; } const cleanStdout = stripAnsi(stdout); - // describe:先把追加在末尾的 File Walkthrough <details> 块抽出(否则黏进 ### Diagram - // Walkthrough 段),单独成一条「文件变更」finding,并把嵌套表格转成折叠无序列表。 + // describe: first extract the File Walkthrough <details> block appended at the end (otherwise it sticks into the ### Diagram + // Walkthrough segment), making it a standalone "file changes" finding, and convert the nested table into a collapsible unordered list. let walkthroughFinding: Finding | undefined; let baseMd = cleanStdout; if (tool === 'describe') { @@ -824,8 +828,8 @@ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedRe }; } } - // GFM 路径仅用于 /review(gfm_markdown 下整体是 <table>);describe/ask 仍走 markdown - // 切片(其 HTML/表格/mermaid 由下游 react-markdown 渲染,section 结构不受影响)。 + // The GFM path is only for /review (under gfm_markdown the whole thing is a <table>); describe/ask still go through markdown + // slicing (their HTML/table/mermaid is rendered downstream by react-markdown, the section structure is unaffected). const gfm = tool === 'review' && isGfmReviewOutput(baseMd); const allSections = gfm ? splitGfmTableSections(baseMd) : splitMarkdownSections(baseMd); const sections = allSections.filter((s) => !shouldSkipSection(s, tool)); @@ -833,18 +837,18 @@ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedRe const fs = walkthroughFinding ? [walkthroughFinding] : []; return llmFailure ? { findings: fs, llmFailure } : { findings: fs }; } - // 单 section 可能展开成多个 findings (key_issues_to_review 段)。用游标 idx 维持 - // 全局 finding 编号稳定,UI list-key 不冲突 + // A single section may expand into multiple findings (the key_issues_to_review segment). The cursor idx keeps + // the global finding numbering stable so the UI list-key does not collide const findings: Finding[] = []; let idx = 0; for (const sec of sections) { if (tool === 'review' && isKeyIssuesSection(normalizeTitle(sec.title))) { - // GFM:sec.body 是原始 HTML,按 <a href><strong> 抽 finding;非 GFM 走 markdown 展开 + // GFM: sec.body is raw HTML, extract findings by <a href><strong>; non-GFM goes through markdown expansion const expanded = gfm ? expandGfmKeyIssues(sec.body, idx, tool) : expandKeyIssuesSection(sec, idx, tool); if (expanded.length === 0) { - // 抽不到(格式漂移)→ 退回整段一条 finding,body 清成可读文本 + // Nothing extracted (format drift) → fall back to the whole segment as one finding, body cleaned into readable text findings.push( sectionToFinding(gfm ? { ...sec, body: gfmInlineToText(sec.body) } : sec, idx, tool), ); @@ -854,16 +858,16 @@ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedRe idx += expanded.length; } } else { - // GFM 非 key-issues 段:body 是 HTML,清成文本再交 sectionToFinding(其 **File:** 等 - // 锚点匹配按 markdown 文本设计;这些段一般无行级 anchor,清理后展示即可) + // GFM non-key-issues segment: body is HTML, cleaned into text then handed to sectionToFinding (whose **File:** etc. + // anchor matching is designed for markdown text; these segments generally have no line-level anchor, so just display after cleaning) const s = gfm ? { ...sec, body: gfmInlineToText(sec.body) } : sec; findings.push(sectionToFinding(s, idx, tool)); idx += 1; } } - // describe 的 File Walkthrough 单独成段(渲染顺序由 SECTION_ORDER 的 walkthrough 决定) + // describe's File Walkthrough as a standalone segment (render order determined by walkthrough in SECTION_ORDER) if (walkthroughFinding) findings.push(walkthroughFinding); - // summary:优先取首个有 title 的 section;都没有 title 取首个 body 首行 + // summary: prefer the first section with a title; if none have a title, take the first line of the first body let summary: string | undefined; const titled = sections.find((s) => s.title); if (titled) summary = normalizeTitle(titled.title); @@ -874,20 +878,20 @@ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedRe ?.trim(); if (firstNonEmpty) summary = firstNonEmpty; } - // /ask 复评裁决兜底:结构化解析失败回退到这条普通路径时,仍从答案文本抽 <verdict>, - // 不丢复评的取代 / 关闭信号(run-executor 的自动关闭依赖它)。 + // /ask re-review verdict fallback: when structured parsing fails and falls back to this ordinary path, still extract + // <verdict> from the answer text, not losing the re-review's supersede / closure signal (run-executor's auto-closure depends on it). const askVerdict = tool === 'ask' ? extractAskVerdict(cleanStdout) : undefined; const base = askVerdict ? { findings, summary, askVerdict } : { findings, summary }; return llmFailure ? { ...base, llmFailure } : base; } /** - * 解析 pr-agent `/improve` 工具的输出。 + * Parse the output of pr-agent's `/improve` tool. * - * pr-agent local provider 不实现 `publish_code_suggestions`,所以 `/improve` 走 - * `publish_comment` 把汇总 markdown 写到 `review.md` (跟 /review、/ask 共用)。 + * The pr-agent local provider does not implement `publish_code_suggestions`, so `/improve` goes through + * `publish_comment` writing the aggregated markdown to `review.md` (shared with /review, /ask). * - * 每条建议的模板 (摘自 pr-agent `pr_code_suggestions.py` 的 generate_summarized_suggestions): + * The template for each suggestion (from pr-agent `pr_code_suggestions.py`'s generate_summarized_suggestions): * ``` * <details><summary>{one_sentence_summary}</summary> * @@ -912,19 +916,19 @@ export function parseReviewOutput(stdout: string, tool: ReviewRunTool): ParsedRe * </details> * ``` * - * 反解策略:以**file marker 行** `[<file> [<start>-<end>]](<url>)` 为切分点。 - * 每两个相邻 marker 之间是一条建议的范围,向前找 `<summary>`,向后找 - * ` ```diff ` 块 + `importance[1-10]:` 评分。pr-agent 版本间细节会变,按 marker - * 切片比硬解 HTML 嵌套更稳。 + * Reverse-parse strategy: use the **file marker line** `[<file> [<start>-<end>]](<url>)` as the split point. + * The range between two adjacent markers is one suggestion; look backward for `<summary>`, and forward for + * the ` ```diff ` block + `importance[1-10]:` score. Details change across pr-agent versions, so slicing by marker + * is more robust than hard-parsing the nested HTML. * - * 没有 marker → 输出形态不识别(旧版 / 配置变化),返回空 findings + summary 提示。 + * No marker → the output form is unrecognized (old version / config change), returns empty findings + a summary hint. */ export function parseImproveOutput(stdout: string): ParsedReviewOutput { const cleaned = stripAnsi(stdout).replace(/\r\n/g, '\n'); const lines = cleaned.split('\n'); - // file marker 行:`[<path> [<start>-<end>]](<url>)`,path 内不含空白(但可含 `[]`,如 - // `a/[m-123]/x.ts`);range 可能 `[42-45]` 或 `[42]` (单行)。path 用惰性非空白 `[^\s]+?` + - // 必现的 ` [<range>]](` 后缀界定,路径里的 `]` 不再误截。 + // file marker line: `[<path> [<start>-<end>]](<url>)`, path contains no whitespace (but may contain `[]`, e.g. + // `a/[m-123]/x.ts`); range may be `[42-45]` or `[42]` (single line). path uses a lazy non-whitespace `[^\s]+?` + + // the mandatory ` [<range>]](` suffix to delimit, so the `]` in the path is no longer wrongly cut. const markerRe = /^\[([^\s]+?)\s+\[(\d+)(?:-(\d+))?\]\]\(/; interface Marker { idx: number; @@ -954,7 +958,7 @@ export function parseImproveOutput(stdout: string): ParsedReviewOutput { const prevIdx = i > 0 ? markers[i - 1]!.idx : 0; const blockText = lines.slice(m.idx, nextIdx).join('\n'); - // suggestion_content: marker 上面最近的非空非 HTML 行 (通常 **...** 加粗) + // suggestion_content: the nearest non-empty non-HTML line above the marker (usually **...** bold) let content = ''; for (let j = m.idx - 1; j > prevIdx; j--) { const l = lines[j]!.trim(); @@ -964,7 +968,7 @@ export function parseImproveOutput(stdout: string): ParsedReviewOutput { break; } - // one_sentence_summary: marker 上面最近的 <summary>...</summary> (不含 importance 那个) + // one_sentence_summary: the nearest <summary>...</summary> above the marker (not the importance one) let summaryText = ''; for (let j = m.idx - 1; j > prevIdx; j--) { const sm = /<summary[^>]*>([\s\S]*?)<\/summary>/i.exec(lines[j]!); @@ -974,7 +978,7 @@ export function parseImproveOutput(stdout: string): ParsedReviewOutput { } } - // diff block + 拆 -/+ 行 + // diff block + split -/+ lines let codeChange: FindingCodeChange | undefined; const diffStart = blockText.indexOf('```diff'); if (diffStart >= 0) { @@ -987,7 +991,7 @@ export function parseImproveOutput(stdout: string): ParsedReviewOutput { for (const dl of patch.split('\n')) { if (dl.startsWith('-')) existingLines.push(dl.slice(1).replace(/^ /, '')); else if (dl.startsWith('+')) improvedLines.push(dl.slice(1).replace(/^ /, '')); - // 普通 context 行 (空格起手) 在 pr-agent improve diff 里少见,忽略 + // Plain context lines (leading space) are rare in pr-agent improve diffs, ignored } if (existingLines.length > 0 || improvedLines.length > 0) { codeChange = { diff --git a/packages/poller/src/poller.ts b/packages/poller/src/poller.ts index 74c5afaa..dc582bd7 100644 --- a/packages/poller/src/poller.ts +++ b/packages/poller/src/poller.ts @@ -24,7 +24,7 @@ import { type PrIndexFile, } from './pr-state.js'; -/** Bitbucket reviewer.status → 本地 LocalPrStatus 单向映射(poll 时把远端权威态拉下来)。 */ +/** One-way mapping Bitbucket reviewer.status → local LocalPrStatus (poll pulls down the remote authoritative state). */ function statusFromReviewer(s: ReviewerStatus | undefined): LocalPrStatus { if (s === 'approved') return 'approved'; if (s === 'needsWork') return 'needs_work'; @@ -38,38 +38,40 @@ export interface PollerConnection { export interface PollerOptions { connections: ReadonlyArray<PollerConnection>; - /** 活跃 PR 存储(`state/` 根):索引 + 在场 PR 的 meta / 评论 / runs 等。 */ + /** Active PR store (`state/` root): index + meta / comments / runs etc. for present PRs. */ stateStore: StateStore; /** - * 归档 PR 冷存储(`archived/` 根,与 state/ 平级)。PR 退场(软删)时其 `prs/<hash>/` 整树从 - * stateStore 搬入此处、复活时搬回;硬清按同一 grace 策略从此处删除。索引仍只在 stateStore 维护。 + * Archived PR cold storage (`archived/` root, a sibling of state/). When a PR departs (soft-delete) its + * `prs/<hash>/` whole tree is moved from stateStore into here, and moved back on revival; hard purge deletes + * from here under the same grace policy. The index is still maintained only in stateStore. */ archiveStore: StateStore; intervalSeconds: number; logger: Logger; - /** 用于测试注入;默认 Date.now() */ + /** For test injection; defaults to Date.now() */ now?: () => Date; - /** 每次 tick 完成(含 errors=N 但未抛出)后回调;用于 main → renderer 推送 */ + /** Callback after each tick completes (including errors=N but not thrown); used for main → renderer push */ onTick?: (info: { at: string; result: PollResult }) => void; /** - * 本轮 poll 发现"有新增 / 内容变更的 PR"的 repo 集合(去重)。main 拿到后可以 - * 顺手 `repoMirror.syncMirror(...)` 把本地镜像跟上,让用户随后点开 PR 时省一 - * 趟 fetch。失败 / 无 PR 变化的连接不会出现在集合中。 + * The set of repos where this poll round found "PRs newly added / content-changed" (deduped). After receiving it, + * main can conveniently `repoMirror.syncMirror(...)` to catch the local mirror up, saving the user a fetch when they + * later open the PR. Connections that failed / had no PR changes do not appear in the set. * - * 仅触发条件:该 repo 至少有一个 PR 在本轮被识别为 added 或 changed - * (updatedAt 跳变)。removed 不算 (PR 关单一般不影响 commit 范围)。 + * Trigger condition only: that repo has at least one PR recognized this round as added or changed (updatedAt jumped). + * removed does not count (closing a PR generally does not affect the commit range). */ onPrsChanged?: (repos: ReadonlyArray<ChangedRepo>) => void; /** - * 本轮 poll 新发生的「值得提醒」事件(新 PR / 被 @ / 被回复)。main 据通知配置弹系统通知。仅在**已有基线** - * (索引此前非空)时产出,避免首启 / 批量涌入时通知风暴;空数组不回调。详见 PollNotificationEvent。 + * "Notification-worthy" events newly occurring this poll round (new PR / @mentioned / replied-to). main pops system + * notifications per notification config. Produced only when **a baseline already exists** (the index was previously + * non-empty), avoiding a notification storm on first launch / bulk influx; an empty array does not call back. See PollNotificationEvent. */ onNotify?: (events: ReadonlyArray<PollNotificationEvent>) => void; } /** - * Poll 时通知 main 哪些 repo 有 PR 变更。字段是 PrIdentity 的 repo 投影 (去掉 - * remoteId / url),足够 main 拼 RepoIdentity 并触发 syncMirror。 + * Notify main during poll which repos have PR changes. The fields are the repo projection of PrIdentity (dropping + * remoteId / url), enough for main to assemble RepoIdentity and trigger syncMirror. */ export interface ChangedRepo { platform: PlatformKind; @@ -81,22 +83,22 @@ export interface ChangedRepo { const EMPTY: PollResult = { fetched: 0, changed: 0, added: 0, removed: 0, errors: 0 }; /** - * 周期性 poll,把跨连接发现的 PR 汇入 `state/pull-requests.json`。 + * Periodic poll, merging PRs discovered across connections into `state/pull-requests.json`. * - * 写入策略:保留旧 PR 的 localStatus 与 discoveredAt;每轮重写整文件 - * (单写者 + 原子写,规模小时简单胜过 diff 合并)。 + * Write strategy: preserve old PRs' localStatus and discoveredAt; rewrite the whole file each round + * (single writer + atomic write — at small scale, simplicity beats diff merging). * - * 并发:同一 tick 不重入。 + * Concurrency: no re-entry within the same tick. */ export class Poller { private interval?: ReturnType<typeof setInterval>; private inFlight = false; - /** tick 在 inFlight 期间又被请求 → 标记,当前轮结束后紧接着补跑一轮(不丢请求)。 */ + /** tick requested again while inFlight → mark it, and immediately run one more round after the current one (no request dropped). */ private rerunRequested = false; private _lastPollAt: string | null = null; - /** 可热替换的连接集合(设置页改连接 / 切换启用时换)。初值取自构造 opts */ + /** Hot-swappable connection set (swapped when the settings page changes connections / toggles enablement). Initial value from constructor opts */ private connections: ReadonlyArray<PollerConnection>; - /** 可热替换的轮询间隔(秒)。初值取自构造 opts */ + /** Hot-swappable poll interval (seconds). Initial value from constructor opts */ private intervalSeconds: number; constructor(private readonly opts: PollerOptions) { @@ -105,22 +107,24 @@ export class Poller { } /** - * 热替换轮询的连接集合(设置页改连接 / 切换启用后调用)。下一轮 poll 生效; - * 不在此处主动 tick,调用方决定是否立即触发一次。 + * Hot-swap the poll's connection set (called after the settings page changes connections / toggles enablement). + * Takes effect next poll round; does not actively tick here — the caller decides whether to trigger one immediately. */ setConnections(connections: ReadonlyArray<PollerConnection>): void { this.connections = connections; } /** - * 归档所有「不属于 activeIds」连接的 PR,使其进入 purge 路径。 + * Archive all PRs of connections "not in activeIds", putting them on the purge path. * - * 背景:单活动连接模型下 poller 只喂活动连接,软删只处理本轮 poll 到的连接 - * (seenByConnection)。切换/禁用连接后,旧连接的 PR 永远不会被 poll 到 → 永不 - * archived → 永不 purge,磁盘上累积陈旧状态。本方法在**用户显式切换/禁用连接**时由 - * main 调用,把这些 PR 标 archivedAt;后续任意一轮 poll 的 purge 段(grace 期满)会清掉。 + * Background: under the single-active-connection model the poller only feeds the active connection, and soft-delete + * only handles connections polled this round (seenByConnection). After switching/disabling a connection, the old + * connection's PRs are never polled → never archived → never purged, accumulating stale state on disk. This method is + * called by main on **the user explicitly switching/disabling a connection**, marking these PRs' archivedAt; the purge + * segment of any later poll round (grace expired) will clean them up. * - * 仅由显式动作触发(非网络故障),故不违反「一次网络抖动不误删整库」的不变式。 + * Triggered only by an explicit action (not a network failure), so it does not violate the "one network blip must not + * wrongly delete the whole store" invariant. */ async archiveConnectionsExcept(activeIds: readonly string[]): Promise<void> { const active = new Set(activeIds); @@ -131,7 +135,7 @@ export class Poller { let dirty = false; for (const [localId, entry] of Object.entries(prs)) { if (!active.has(entry.identity.connectionId) && !entry.archivedAt) { - // 整树搬入归档冷存储后再标 archivedAt(搬迁先于索引落盘,崩溃可幂等重来)。 + // Move the whole tree into archive cold storage, then mark archivedAt (migration precedes index persistence; a crash can idempotently retry). await relocateTree(this.opts.stateStore, this.opts.archiveStore, prDirKey(localId)); prs[localId] = { ...entry, archivedAt: now }; dirty = true; @@ -143,8 +147,8 @@ export class Poller { } /** - * 热替换轮询间隔(秒)。运行中则按新周期重建定时器(不立即 tick);下一次触发 - * 起用新间隔。设置页改轮询间隔后调用,无需重启。 + * Hot-swap the poll interval (seconds). While running, rebuild the timer on the new period (does not tick immediately); + * the new interval takes effect from the next trigger. Called after the settings page changes the poll interval, no restart needed. */ setIntervalSeconds(seconds: number): void { this.intervalSeconds = seconds; @@ -154,15 +158,16 @@ export class Poller { } } - /** 最近一次成功 pollOnce 完成的时间(ISO);从未跑过返回 null */ + /** Time (ISO) the most recent successful pollOnce completed; returns null if never run */ getLastPollAt(): string | null { return this._lastPollAt; } /** - * 启动常驻轮询。`immediate=true`(默认)立刻先跑一轮;`immediate=false` 只装定时器、 - * 不跑首轮——用于「活动连接无缓存身份」场景:避免用 me=null 跑出半成品首轮,改由调用方 - * 在 ping 确认身份后再触发首次 tick(见 index.ts pingConnections)。 + * Start the resident poll. `immediate=true` (default) runs one round right away; `immediate=false` only installs the + * timer and skips the first round — for the "active connection has no cached identity" scenario: avoids running a + * half-baked first round with me=null, letting the caller instead trigger the first tick after ping confirms the + * identity (see index.ts pingConnections). */ start(immediate = true): void { if (this.interval) return; @@ -178,9 +183,10 @@ export class Poller { } /** - * 立刻发起一次 poll。若上一次还在跑,则不并发,而是登记「补跑」:当前轮结束后紧接着再跑 - * 一轮。这样在「ping 异步补到 currentUser 后请求重新分类」等场景下,请求不会因恰好撞上 - * 进行中的 poll 而被丢弃。 + * Trigger a poll immediately. If the previous one is still running, do not run concurrently but register a "rerun": + * run one more round right after the current one ends. This way, in scenarios like "requesting re-classification after + * ping asynchronously fills in currentUser", the request is not dropped just because it happened to collide with an + * in-progress poll. */ async tick(): Promise<PollResult> { if (this.inFlight) { @@ -201,31 +207,31 @@ export class Poller { } /** - * 单轮 poll 的安全 invariants(用户硬要求 / 设计文档化): + * Safety invariants for a single poll round (hard user requirements / documented in design): * - * 1. **拉取失败 → 不动本地** :单个连接 listPendingPullRequests 抛错时,**只算** - * `errors++`,**不**: - * - 写其名下任何 PR 的 meta - * - 把名下既有 PR 软删 (archive) - * - 从索引中剔除任何条目 - * 实现靠 seenByConnection 只装成功连接的 hash 集合;soft archive 循环只迭代 - * seenByConnection 里的连接。 + * 1. **Fetch failure → do not touch local**: when a single connection's listPendingPullRequests throws, **only** + * count `errors++`, and do **not**: + * - write meta for any of its PRs + * - soft-delete (archive) any of its existing PRs + * - remove any entry from the index + * Implemented via seenByConnection holding only successful connections' hash sets; the soft-archive loop iterates + * only connections in seenByConnection. * - * 2. **所有连接全失败 → 索引文件 0 写** :dirty flag 控制;磁盘 mtime 不变, - * 避免上层 file watcher 误触发 / 备份工具误以为有改动。 + * 2. **All connections fail → 0 writes to the index file**: controlled by the dirty flag; disk mtime unchanged, + * avoiding false triggers of the upper file watcher / a backup tool mistakenly thinking there was a change. * - * 3. **硬清 (grace 期满 archive 条目)** 跟当轮 poll 成败无关:archivedAt 是过去 - * 某次成功 poll 决定的事实,时间到了该清就清。 + * 3. **Hard purge (archive entries past grace)** is unrelated to this round's poll success/failure: archivedAt is a + * fact decided by some past successful poll; once the time is up, purge as due. */ private async pollOnce(): Promise<PollResult> { const now = (this.opts.now?.() ?? new Date()).toISOString(); const nowMs = Date.parse(now); const indexFile = await readPrIndex(this.opts.stateStore); - // 索引拷一份到 mutable Map 方便增删;条目缺失时退回到空 Map (首次 poll) + // Copy the index into a mutable Map for easy add/remove; fall back to an empty Map when the entry is missing (first poll) const indexByLocalId = new Map<string, PrIndexEntry>(Object.entries(indexFile?.prs ?? {})); - // 已有基线 = 本轮之前索引已非空。首轮 / 清库后的首 poll 不产出通知事件(仅建基线),避免涌入风暴。 + // Baseline exists = the index was already non-empty before this round. The first round / first poll after clearing the store produces no notification events (only builds the baseline), avoiding an influx storm. const hadBaseline = indexByLocalId.size > 0; - // 本轮新发生的通知事件(新 PR / 被 @ / 被回复);poll 末投影给 main 弹系统通知。 + // Notification events newly occurring this round (new PR / @mentioned / replied-to); projected to main at poll end to pop system notifications. const notifyEvents: PollNotificationEvent[] = []; let fetched = 0; @@ -233,25 +239,27 @@ export class Poller { let added = 0; let removed = 0; let errors = 0; - // dirty 跟踪本轮是否有任何状态变化 (meta 写入 / 软删 / 硬清)。全无变化时 - // 跳过索引文件 rewrite,磁盘 mtime 不动 (invariant #2) + // dirty tracks whether this round had any state change (meta write / soft-delete / hard purge). When there is + // no change at all, skip the index file rewrite and leave disk mtime untouched (invariant #2) let dirty = false; - // 本轮发现"有新增 / 内容变更 PR"的 repo 集合 (去重);用于 onPrsChanged - // 通知 main 触发 syncMirror。key = `${connectionId}|${group}|${repo}` + // The set of repos where this round found "PRs newly added / content-changed" (deduped); used by onPrsChanged + // to notify main to trigger syncMirror. key = `${connectionId}|${group}|${repo}` const changedReposByKey = new Map<string, ChangedRepo>(); - // 每个**成功** poll 的连接看到的 localId 集合。失败的连接不进入此 map (invariant #1) + // The localId set seen by each **successful** poll connection. Failed connections do not enter this map (invariant #1) const seenByConnection = new Map<string, Set<string>>(); for (const { connectionId, adapter } of this.connections) { const me = adapter.connection.getCurrentUser(); try { const caps = adapter.connection.capabilities(); - // 评论计数是否「含回复」:true(GitHub/GitLab)→ 计数/updatedAt 变化才扫;false(Bitbucket, - // 计数仅顶层、updatedDate 也不随评论跳变)→ 对待处理 PR 每轮兜底扫,否则漏「回复」类通知。 + // Whether the comment count "includes replies": true (GitHub/GitLab) → scan only when the count/updatedAt changes; + // false (Bitbucket — the count is top-level only and updatedDate does not jump with comments) → fallback-scan + // pending PRs every round, otherwise "reply"-type notifications are missed. const commentCountIncludesReplies = caps.commentCountIncludesReplies; - // 发现分类:平台提供多类(GitHub 四类)→ 逐类轮询并 union 打标,让 renderer 切标签 - // 走本地缓存而非每次拉远端;无分类的平台(Bitbucket)单轮询、标记为空数组。 + // Discovery categories: a platform providing multiple categories (GitHub's four) → poll each and union-tag, so the + // renderer switches tabs via local cache instead of fetching remote each time; a platform without categories + // (Bitbucket) is polled once and tagged with an empty array. const filters = caps.discoveryFilters ?? []; const merged = new Map<string, { pr: PullRequest; matched: PrDiscoveryFilter[] }>(); const collect = async (filter?: PrDiscoveryFilter): Promise<void> => { @@ -274,16 +282,16 @@ export class Poller { seenByConnection.set(connectionId, seen); for (const { pr, matched } of merged.values()) { - // hash localId:platform + 连接 + group + repo + remoteId 一锅哈希。 - // 同一 connection 下不同 repo 同 PR id 也能区分开 (Bitbucket 的 PR id 是 per-repo - // 递增的);platform 字段让多平台扩展时 schema 不必改 + // hash localId: platform + connection + group + repo + remoteId hashed all together. + // Different repos under the same connection with the same PR id can also be distinguished (Bitbucket's PR id is + // per-repo incrementing); the platform field means the schema need not change when expanding to multiple platforms const identity = { platform: adapter.kind, connectionId, group: pr.repo.projectKey, repo: pr.repo.repoSlug, remoteId: pr.remoteId, - url: pr.url, // 仅快照,不进 hash + url: pr.url, // snapshot only, not part of the hash }; const localId = prHashId(identity); seen.add(localId); @@ -304,11 +312,11 @@ export class Poller { } } - // localStatus 直接镜像远端当前用户的 reviewer.status(远端为权威态)。 - // UI 上点 approve / needs work 时会先 PUT 到远端,再下一轮 poll 时此处取回。 - // currentUser 未知时(ping 未完成/失败)无法可靠判定本人评审态:此时**保留已记录的 - // 状态**而非覆盖成 pending,避免「已评审」被误降级(首轮 poll 已由 main 确保 me 就绪, - // 此分支仅作 ping 异常时的兜底)。 + // localStatus directly mirrors the remote current user's reviewer.status (remote is authoritative). + // Clicking approve / needs work in the UI first PUTs to remote, and this fetches it back on the next poll round. + // When currentUser is unknown (ping incomplete/failed) one's own review state cannot be reliably determined: + // in that case **keep the recorded status** rather than overwriting to pending, avoiding "reviewed" being wrongly + // downgraded (the first poll round already has main ensure me is ready; this branch is only a fallback for ping errors). let localStatus: LocalPrStatus; if (me) { const mine = pr.reviewers.find((r) => r.name === me.name); @@ -318,8 +326,8 @@ export class Poller { localStatus = prevMeta?.pr.localStatus ?? 'pending'; } - // 通知仅针对「待处理」(localStatus==='pending') 的 PR:已 approve / 标记 needs_work 的不再打扰。 - // 新 PR(仅已有基线时,避免首启涌入风暴)。mention/reply 事件在下方评论扫描处投影(同样受 pending 门控)。 + // Notifications only target "pending" (localStatus==='pending') PRs: already-approved / marked-needs_work ones are no longer disturbed. + // New PRs (only when a baseline exists, to avoid a first-launch influx storm). mention/reply events are projected at the comment scan below (also gated by pending). const notifiable = hadBaseline && localStatus === 'pending'; if (isAdded && notifiable) { notifyEvents.push({ @@ -333,14 +341,14 @@ export class Poller { }); } - // 「我创建的」PR(作者为本人)通知:被标记需修改 / 出现冲突。仅在已有基线 + 已知 PR(prev)时探测; - // 各自的上一轮快照字段缺失(升级前旧索引)时按「基线」处理——只在下方索引写入处播种、不补发历史事件。 + // "PRs I authored" (author is yourself) notifications: marked needs-work / a conflict appeared. Detected only when a baseline exists + PR is known (prev); + // when the respective prior-round snapshot fields are missing (old index from before the upgrade), treated as "baseline" — only seeded at the index write below, not backfilled with historical events. const authoredByMe = !!me && pr.author.name === me.name; const needsWorkReviewers = pr.reviewers .filter((r) => r.status === 'needsWork') .map((r) => r.name); if (authoredByMe && hadBaseline && prev) { - // 新出现的「需修改」评审人(本轮在 needsWork、上一轮不在)→ authored_needs_work。 + // Newly appearing "needs work" reviewers (in needsWork this round, not last round) → authored_needs_work. const prevNW = prev.needsWorkReviewers; if (prevNW !== undefined) { const fresh = needsWorkReviewers.filter((n) => !prevNW.includes(n)); @@ -357,7 +365,7 @@ export class Poller { }); } } - // 合并冲突 false→true → authored_conflict(无具体发起人,actor 取 PR 作者本人)。 + // Merge conflict false→true → authored_conflict (no specific initiator; actor is the PR author themselves). if (prev.hasConflict === false && pr.hasConflict === true) { notifyEvents.push({ kind: 'authored_conflict', @@ -371,13 +379,14 @@ export class Poller { } } - // 复活:上一轮处于归档态(数据已搬入 archived/)→ 先把整树搬回活跃存储,再写 meta, - // 让 runs / 评论 / 已读水位等历史与新 meta 同处活跃目录(搬回先于 writePrMeta,避免 split)。 + // Revival: last round was in archived state (data already moved into archived/) → first move the whole tree back + // to active storage, then write meta, so runs / comments / read watermark history sits in the active directory + // together with the new meta (moving back precedes writePrMeta, to avoid a split). if (prev?.archivedAt) { await relocateTree(this.opts.archiveStore, this.opts.stateStore, prDirKey(localId)); } - // 完整 PR 元数据落到 per-PR meta.json。platform 字段让 meta 自描述 + // Full PR metadata written to per-PR meta.json. The platform field makes meta self-describing await writePrMeta(this.opts.stateStore, localId, { ...pr, localId, @@ -390,17 +399,18 @@ export class Poller { }); dirty = true; - // 未读 mention(见 pr-state computeUnread / computeUnreadMentionCount):拉评论扫「@我 / 回复我」。 - // 游标 lastMentionAt 取较大值(驱动未读点);mentionAts 与历史并集去重、按时间降序留最近 MENTION_ATS_CAP - // 条(驱动未读点旁的计数)。新到达 / 新 commit 未读无需在此处理(读取时分别按发现时间 vs 未读纪元、head sha - // 比对派生)。read-state 仅由 markRead 写,poll 不碰。 + // Unread mentions (see pr-state computeUnread / computeUnreadMentionCount): fetch comments and scan "@me / reply-to-me". + // The cursor lastMentionAt takes the larger value (drives the unread dot); mentionAts dedupes against the historical + // union and keeps the most recent MENTION_ATS_CAP entries in descending time order (drives the count next to the + // unread dot). New-arrival / new-commit unread need not be handled here (on read they are derived by discovery time + // vs unread epoch, and head sha comparison respectively). read-state is written only by markRead; poll does not touch it. // - // 评论跟踪**仅针对「待处理」(notifiable=pending) PR**(含「待我评审」与「我创建的」),且需 me 已知。 - // 是否拉评论: - // - 含回复的平台(commentCountIncludesReplies):仅当 updatedAt 跳变或 commentCount 变化(可能有新评论) - // 才扫——省请求。 - // - 不含回复的平台(Bitbucket:updatedDate 不随评论跳、commentCount 仅顶层不含回复):无任何免费的 - // 「含回复」信号 → 对待处理 PR 每轮兜底扫一次,否则漏「回复」类通知。 + // Comment tracking is **only for "pending" (notifiable=pending) PRs** (including "awaiting my review" and "I authored"), and requires me to be known. + // Whether to fetch comments: + // - Platforms including replies (commentCountIncludesReplies): scan only when updatedAt jumps or commentCount changes + // (there may be new comments) — saves requests. + // - Platforms not including replies (Bitbucket: updatedDate does not jump with comments, commentCount is top-level only, + // excluding replies): no free "includes replies" signal → fallback-scan pending PRs once per round, otherwise "reply"-type notifications are missed. const commentCountChanged = prev?.commentCount !== undefined && pr.commentCount !== undefined && @@ -428,12 +438,12 @@ export class Poller { if (!lastMentionAt || Date.parse(latest) > Date.parse(lastMentionAt)) { lastMentionAt = latest; } - // 通知:仅对**已知 PR**(prev 存在)投影(外层已保证 notifiable=已有基线 + 待处理);取晚于历史游标 - // 的命中按类型聚合条数。新 PR 此前历史评论不计(prev 不存在则跳过),避免新发现 PR 触发其旧评论的提醒风暴。 + // Notifications: projected only for **known PRs** (prev exists) (the outer layer already guarantees notifiable=baseline exists + pending); + // take hits later than the historical cursor and aggregate counts by type. A new PR's prior historical comments do not count (skipped when prev does not exist), avoiding a newly discovered PR triggering a notification storm from its old comments. if (prev) { const sinceMs = prevCursor ? Date.parse(prevCursor) : 0; const fresh = hits.filter((h) => Date.parse(h.at) > sinceMs); - // 按类型聚合本轮新增条数;发起人与点击定位取该类最新一条命中(通知头像 + 跳转目标)。 + // Aggregate this round's new counts by type; the initiator and click target take that type's latest hit (notification avatar + jump target). const project = (kind: 'reply' | 'mention'): void => { const subset = fresh.filter((h) => h.kind === kind); if (subset.length === 0) return; @@ -456,8 +466,8 @@ export class Poller { project('mention'); } } - // 「我创建的」PR:他人新评论(不限是否 @我 / 回复我,自己的评论不计)→ authored_comment。 - // 独立游标 lastCommentAt:晚于它的他人评论计为新;游标缺失(升级前)时仅播种、不补发历史评论。 + // "PRs I authored": others' new comments (regardless of whether @me / reply-to-me; one's own comments do not count) → authored_comment. + // Independent cursor lastCommentAt: others' comments later than it count as new; when the cursor is missing (before the upgrade) only seed, do not backfill historical comments. if (authoredByMe) { const others = collectCommentsFromOthers(comments, me); if (others.length) { @@ -498,7 +508,7 @@ export class Poller { } } - // 索引条目:仅 lookup/退场判定需要的字段;archivedAt 反向恢复 (远端回来了) + // Index entry: only the fields needed for lookup/departure decisions; archivedAt reverse recovery (remote came back) indexByLocalId.set(localId, { identity, updatedAt: pr.updatedAt, @@ -519,9 +529,9 @@ export class Poller { } } - // 软删:每个成功 poll 的连接,"本地有 + 本轮没看到 + 还没 archived"的 PR - // 标 archivedAt = now。失败的连接 (不在 seenByConnection) 不参与,避免一次 - // 网络故障误删整库 + // Soft-delete: for each successful poll connection, PRs that are "present locally + not seen this round + not yet + // archived" are marked archivedAt = now. Failed connections (not in seenByConnection) do not participate, avoiding + // one network failure wrongly deleting the whole store for (const [connectionId, seen] of seenByConnection) { for (const [localId, entry] of indexByLocalId) { if ( @@ -529,7 +539,7 @@ export class Poller { !seen.has(localId) && !entry.archivedAt ) { - // 整树搬入归档冷存储后再标 archivedAt(搬迁先于索引落盘,崩溃可幂等重来)。 + // Move the whole tree into archive cold storage, then mark archivedAt (migration precedes index persistence; a crash can idempotently retry). await relocateTree(this.opts.stateStore, this.opts.archiveStore, prDirKey(localId)); indexByLocalId.set(localId, { ...entry, archivedAt: now }); removed++; @@ -538,22 +548,23 @@ export class Poller { } } - // 硬清:archived 超过 grace 期 (默认 1 周) → rm -r 整个 PR 目录 + 索引删除 + // Hard purge: archived past the grace period (default 1 week) → rm -r the whole PR directory + remove from index let purged = 0; let reconciled = 0; for (const [localId, entry] of [...indexByLocalId.entries()]) { if (!entry.archivedAt) continue; if (nowMs - Date.parse(entry.archivedAt) > PURGE_GRACE_MS) { - // 硬清:grace 期满 → 两端整目录清(archiveStore 主存 + stateStore 兜旧布局 / split-brain 残留)。 + // Hard purge: grace expired → clear the whole directory on both ends (archiveStore primary + stateStore backstops old layout / split-brain residue). await this.opts.archiveStore.deleteDir(prDirKey(localId)); await this.opts.stateStore.deleteDir(prDirKey(localId)); indexByLocalId.delete(localId); purged++; dirty = true; } else { - // 对账(最终一致):凡 archived 条目其数据都应在 archiveStore。把仍滞留活跃存储的整树搬入归档—— - // 涵盖旧布局存量、异常 split-brain 残留、中断的搬迁。已就位者源缺失即 no-op、近零成本。 - // 仅搬数据、不改索引(archivedAt 不变),故不置 dirty——保持「全失败 poll 零索引写」不变式。 + // Reconcile (eventual consistency): any archived entry's data should be in archiveStore. Move any whole tree still + // lingering in active storage into the archive — covering old-layout backlog, abnormal split-brain residue, and + // interrupted migrations. For those already in place, a missing source is a no-op at near-zero cost. + // Only moves data, does not change the index (archivedAt unchanged), so does not set dirty — preserving the "all-failed poll writes zero index" invariant. const moved = await relocateTree( this.opts.stateStore, this.opts.archiveStore, @@ -563,8 +574,8 @@ export class Poller { } } - // 索引文件仅在本轮有实际变化时重写 (invariant #2)。全失败 / 全无变化的 poll - // 不触磁盘 mtime + // The index file is rewritten only when this round had actual changes (invariant #2). An all-failed / no-change poll + // does not touch disk mtime if (dirty) { const next: PrIndexFile = { schema_version: 1, @@ -576,11 +587,11 @@ export class Poller { const result: PollResult = { fetched, changed, added, removed, errors }; this._lastPollAt = now; this.opts.logger.info({ ...result, purged, reconciled, dirty }, 'poll complete'); - // 通知调用方有哪些 repo 需要 sync mirror。空集合不调,避免无谓 noop + // Notify the caller which repos need a mirror sync. An empty set is not called, avoiding a pointless noop if (changedReposByKey.size > 0) { this.opts.onPrsChanged?.(Array.from(changedReposByKey.values())); } - // 本轮通知事件投影给 main(弹系统通知)。空数组不调。 + // This round's notification events projected to main (pop system notifications). An empty array is not called. if (notifyEvents.length > 0) { this.opts.onNotify?.(notifyEvents); } @@ -589,4 +600,4 @@ export class Poller { } } -// listStoredPullRequests / setLocalStatus 移到 pr-state.ts,跟新 schema 一起维护 +// listStoredPullRequests / setLocalStatus moved to pr-state.ts, maintained together with the new schema diff --git a/packages/poller/src/pr-hash-id.ts b/packages/poller/src/pr-hash-id.ts index b64fd5fa..f955e234 100644 --- a/packages/poller/src/pr-hash-id.ts +++ b/packages/poller/src/pr-hash-id.ts @@ -2,51 +2,53 @@ import crypto from 'node:crypto'; import type { PlatformKind } from '@meebox/shared'; /** - * PR 在 meebox 状态体系里的稳定身份。多平台中性化字段,方便 M5 接入 GitHub / - * GitLab 时直接复用同一份 schema,不必各家自己造名: + * A PR's stable identity within meebox's state system. Platform-neutral fields, so that when M5 adds + * GitHub / GitLab the same schema is reused directly, without each platform inventing its own names: * * platform × connection × group × repo × remoteId * - * 字段语义映射 (各平台对齐到同一抽象): - * | 抽象 | Bitbucket Server | GitHub | GitLab | + * Field semantics mapping (each platform aligned to the same abstraction): + * | abstract | Bitbucket Server | GitHub | GitLab | * |-----------|------------------|--------------------|-----------------| * | platform | bitbucket-server | github | gitlab | * | group | projectKey | owner (org/user) | namespace | * | repo | repoSlug | name | name | - * | remoteId | PR id (数字) | PR number | MR iid | + * | remoteId | PR id (numeric) | PR number | MR iid | * - * `connectionId` 是 meebox 本地标识,跟用户在 config.yaml 里给某个连接起的 id - * 一致;它的角色是"分账户/分凭据" (用户可能有两个 Bitbucket 内网账号),跟 platform 维度 - * 互补 (Bitbucket 跨账户的同 host 不撞 id 也是靠 connectionId 区分)。 + * `connectionId` is a meebox-local identifier, matching the id the user gave a connection in config.yaml; + * its role is "per-account/per-credential" (a user may have two internal Bitbucket accounts), complementing + * the platform dimension (Bitbucket's same host across accounts also avoids id collisions via connectionId). * - * 仅 `<connectionId>:<remoteId>` 不够 —— Bitbucket PR id 在仓库维度递增,同一 connection - * 下两个不同 repo 完全可能撞 id (例如 proj-A/repo-x#42 和 proj-A/repo-y#42)。 + * `<connectionId>:<remoteId>` alone is not enough — Bitbucket PR ids increment per repository, so two + * different repos under the same connection can readily collide on id (e.g. proj-A/repo-x#42 and proj-A/repo-y#42). * - * `url` 是远端 PR 完整 URL 快照 (可选),便于离线场景仍能直接跳转 / 调试;不参与哈希。 + * `url` is a snapshot of the remote PR's full URL (optional), so offline scenarios can still jump / debug directly; + * it does not participate in the hash. */ export interface PrIdentity { platform: PlatformKind; connectionId: string; group: string; repo: string; - /** 字符串形态,跟 remote API 取回的形状一致 (Bitbucket 是数字 PR id 字符串化) */ + /** String form, matching the shape returned by the remote API (Bitbucket is a numeric PR id stringified) */ remoteId: string; - /** 远端 PR URL 快照;仅作信息字段,不参与 hash */ + /** Remote PR URL snapshot; informational field only, does not participate in the hash */ url?: string; } /** - * 把 PR 身份信息哈希为定长 12 位 hex 字符串,用作 localId / state 目录名。 + * Hash the PR identity into a fixed-length 12-char hex string, used as the localId / state directory name. * - * 选择 12 hex (~48 bit):单用户使用量远低于 2^24,碰撞概率仍可忽略;又比 - * 完整 sha1 (40 chars) 短得多,目录列表 / 日志可读。 + * Choosing 12 hex (~48 bit): a single user's usage is far below 2^24, so collision probability is still + * negligible; yet much shorter than a full sha1 (40 chars), keeping directory listings / logs readable. * - * 输入规范化:用 `|` 当分隔符 (URL-safe + 不会出现在 connection id / group / repo - * / remote id 里)。任何字段含 `|` 视为输入异常 (上层应该挡),这里不做兜底替换以免 - * 引入碰撞。`url` 不进哈希源 (URL 在不同 Bitbucket 路径下可能变化但 PR 还是同一个)。 + * Input normalization: use `|` as the separator (URL-safe + never appears in connection id / group / repo + * / remote id). Any field containing `|` is treated as invalid input (the upper layer should block it); no + * fallback substitution is done here, to avoid introducing collisions. `url` is not part of the hash source + * (the URL may vary across different Bitbucket paths while the PR is still the same one). * - * 哈希源顺序:platform / connection / group / repo / remoteId —— 最稳定字段在前 - * 让前缀有判别力 (debug 时 prefix-match 也能命中)。 + * Hash source order: platform / connection / group / repo / remoteId — most stable fields first so the + * prefix has discriminating power (prefix-match can still hit when debugging). */ export function prHashId(identity: PrIdentity): string { const canonical = [ diff --git a/packages/poller/src/pr-state.ts b/packages/poller/src/pr-state.ts index 382d2925..a68b58e2 100644 --- a/packages/poller/src/pr-state.ts +++ b/packages/poller/src/pr-state.ts @@ -3,75 +3,82 @@ import type { StateStore } from '@meebox/state-store'; import type { PrIdentity } from './pr-hash-id.js'; /** - * `state/prs/index.json` 唯一负责"哪些 hash localId 当前已知 + 状态字段", - * 用作快速列表 / 退场判定 / 软删跟踪。完整 PR 元数据 (title / refs / reviewers - * 等) 落在 `prs/<localId>/meta.json`。 + * `state/prs/index.json` is solely responsible for "which hash localIds are currently known + status fields", + * used for fast listing / departure decisions / soft-delete tracking. Full PR metadata (title / refs / reviewers + * etc.) lives in `prs/<localId>/meta.json`. */ export interface PrIndexEntry { identity: PrIdentity; - /** 远端 PR.updatedAt 镜像,poll 比对用 */ + /** Mirror of remote PR.updatedAt, used for poll comparison */ updatedAt: string; /** - * 远端评论计数镜像({@link PullRequest.commentCount})。poll 与上轮比对:含回复的平台(GitHub/GitLab) - * 据此判定「可能有新评论」以决定是否扫描;Bitbucket(仅顶层、不含回复)此值仅作辅助、不足以判定回复。 - * 平台不提供时为 undefined(poll 退回仅按 `updatedAt` 判定)。 + * Mirror of the remote comment count ({@link PullRequest.commentCount}). poll compares against the prior round: + * platforms whose count includes replies (GitHub/GitLab) use it to decide "there may be new comments" and thus + * whether to scan; on Bitbucket (top-level only, excludes replies) this value is only auxiliary and insufficient + * to determine replies. undefined when the platform does not provide it (poll falls back to judging by `updatedAt` alone). */ commentCount?: number; - /** 首次被本机 poll 发现时间 */ + /** Time first discovered by this machine's poll */ discoveredAt: string; - /** 最近一次仍在远端列表里出现的时间 */ + /** Time it most recently still appeared in the remote list */ lastSeenAt: string; /** - * 软删时间戳:PR 在远端从 reviewer pending 列表消失 (merged / declined / 自己 - * 不再是 reviewer) → 设为本次 poll 的 now。重新出现时清回 null (反向恢复)。 - * 距 archivedAt 超过 PURGE_GRACE_MS 后才真正 rm -r 目录。 + * Soft-delete timestamp: when a PR disappears from the remote reviewer pending list (merged / declined / you + * are no longer a reviewer) → set to this poll's now. Cleared back to null when it reappears (reverse recovery). + * The directory is only actually rm -r'd once archivedAt is older than PURGE_GRACE_MS. * - * 软删窗口期内 UI 不展示 (listStoredPullRequests 过滤掉),但 runs 历史 / 缓存 - * 都保留 —— 用户万一回头查可以恢复。 + * During the soft-delete window the UI does not display it (listStoredPullRequests filters it out), but runs + * history / cache are all kept — in case the user looks back and wants to recover it. */ archivedAt: string | null; /** - * 「@我 / 回复我」最新评论时间的单调游标(ISO)。poll 在 PR 内容变更(updatedAt 跳变)时拉评论扫描后 - * 取较大值更新;读取时与已读水位 `lastReadAt` 比较得出 mention 未读。由 poll 独占维护(poll 整体重写索引), - * 与用户的已读水位(另存 read-state.json)解耦,避免 poll 重写索引时把用户操作覆盖掉。 + * Monotonic cursor (ISO) of the latest "@me / reply-to-me" comment time. poll updates it to the larger value + * after scanning comments when a PR's content changes (updatedAt jumps); on read it is compared against the read + * watermark `lastReadAt` to derive mention unread. Maintained exclusively by poll (poll rewrites the whole index), + * decoupled from the user's read watermark (stored separately in read-state.json), so poll's index rewrite does not + * overwrite user actions. */ lastMentionAt?: string; /** - * 「@我 / 回复我」评论的 createdAt 列表(ISO),保留最近 {@link MENTION_ATS_CAP} 条(按时间降序截断)。 - * poll 扫评论时与历史并集去重;读取时按已读水位计未读条数(见 computeUnreadMentionCount)—— 与布尔未读点 - * 并存、互不替代。同 `lastMentionAt` 由 poll 独占维护,与已读水位解耦。 + * List of createdAt (ISO) of "@me / reply-to-me" comments, keeping the most recent {@link MENTION_ATS_CAP} entries + * (truncated in descending time order). poll dedupes against the historical union when scanning comments; on read + * the unread count is computed by the read watermark (see computeUnreadMentionCount) — coexisting with the boolean + * unread dot, not replacing it. Like `lastMentionAt`, maintained exclusively by poll, decoupled from the read watermark. */ mentionAts?: string[]; /** - * 「我创建的」PR 通知用的上一轮快照(poll 独占维护)。仅当 PR 作者为本人时才据此产出 authored_* 通知; - * 字段缺失(升级前的旧索引)时对应事件按「基线」处理——只播种、不补发,避免升级后一次性涌入历史事件。 + * Prior-round snapshots used for "PRs I authored" notifications (maintained exclusively by poll). Only when the PR + * author is yourself are authored_* notifications produced from these; when the fields are missing (old index from + * before the upgrade) the corresponding event is treated as "baseline" — only seeded, not backfilled, avoiding a + * one-time flood of historical events after upgrade. */ - /** 上一轮的合并冲突态(== PullRequest.hasConflict);用于探测 false→true 的新增冲突。 */ + /** Prior round's merge-conflict state (== PullRequest.hasConflict); used to detect a false→true new conflict. */ hasConflict?: boolean; - /** 上一轮处于「需修改」状态的评审人 name 列表;用于探测新出现的 needs-work 评审人。 */ + /** Prior round's list of reviewer names in "needs work" state; used to detect newly appearing needs-work reviewers. */ needsWorkReviewers?: string[]; - /** 上一轮已知的最新「他人评论」createdAt(ISO)游标;晚于它的他人评论计为新评论。 */ + /** Prior round's known latest "others' comment" createdAt (ISO) cursor; others' comments later than it count as new. */ lastCommentAt?: string; } -/** mentionAts 保留上限:仅留最近 10 条。未读计数据此封顶,UI 满额显示「10+」。 */ +/** mentionAts retention cap: keep only the most recent 10. The unread count is capped by this; UI shows "10+" when full. */ export const MENTION_ATS_CAP = 10; /** - * 用户对单个 PR 的「已读水位」。独立成 `prs/<localId>/read-state.json` —— **仅** markRead(用户打开 PR)写; - * poll 周期性重写 index.json 时完全不碰它,从而不会把用户刚推进的水位覆盖回去。未写过 = 用户从未打开该 PR。 + * The user's "read watermark" for a single PR. Kept separately as `prs/<localId>/read-state.json` — written **only** by + * markRead (the user opening the PR); poll's periodic rewrite of index.json never touches it, so the watermark the user + * just advanced is not overwritten. Never written = the user has never opened that PR. */ export interface PrReadStateFile { schema_version: 1; - /** 用户上次查看时的源分支 head sha;当前 head 与之不同 = 有新 commit = 未读 */ + /** Source branch head sha at the user's last view; a current head differing from it = new commit = unread */ lastReadHeadSha: string; - /** 用户上次查看时间(ISO);晚于此的 @我 / 回复我评论 = 未读 */ + /** The user's last view time (ISO); @me / reply-to-me comments later than this = unread */ lastReadAt: string; } export interface PrIndexFile { schema_version: 1; - /** hash localId → entry。Object 而非 Array:lookup O(1) + JSON 体积更小 */ + /** hash localId → entry. Object rather than Array: O(1) lookup + smaller JSON size */ prs: Record<string, PrIndexEntry>; } @@ -80,7 +87,7 @@ export interface PrMetaFile { pr: StoredPullRequest; } -/** 软删保留期:1 周。超过此时长的 archived 条目下一次 poll 时被 hard purge */ +/** Soft-delete retention period: 1 week. archived entries older than this are hard purged on the next poll */ export const PURGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000; export const PR_INDEX_KEY = 'prs/index'; @@ -136,11 +143,11 @@ export async function writePrMeta( } /** - * 计算 PR 的「未读」标记(派生,不持久化)。规则: - * - **从未打开过**(无 read-state)→ 未读:覆盖「新分配 / 请求评审给你」的新到达,以及清空目录 / 全新安装后涌入的 PR。 - * - 打开过之后:源 head 又变(新 commit),或已读时间之后出现「@我 / 回复我」评论(`lastMentionAt > lastReadAt`)→ 未读。 + * Compute a PR's "unread" mark (derived, not persisted). Rules: + * - **Never opened** (no read-state) → unread: covers new arrivals of "newly assigned / review requested of you", as well as PRs flooding in after clearing the directory / a fresh install. + * - After being opened: the source head changed again (new commit), or an "@me / reply-to-me" comment appeared after the read time (`lastMentionAt > lastReadAt`) → unread. * - * 已读水位(read-state)由用户打开 PR 写入。早期开发版不做升级兼容——不抑制旧存量泛红(清库 / 重装即可)。 + * The read watermark (read-state) is written when the user opens the PR. Early dev builds do no upgrade compatibility — old backlog turning red is not suppressed (just clear the store / reinstall). */ export function computeUnread( entry: PrIndexEntry, @@ -155,11 +162,13 @@ export function computeUnread( } /** - * 计算「@我 / 回复我」未读条数(派生,不持久化)。与布尔未读点(computeUnread)**并存**:未读点照常按 - * 新到达 / 新 commit / 点名回复亮,本计数仅在此之上**额外**给出点名/回复你的未读条数。 + * Compute the "@me / reply-to-me" unread count (derived, not persisted). **Coexists** with the boolean unread dot + * (computeUnread): the unread dot still lights by new arrival / new commit / mention-reply, and this count only adds, + * on top of that, the count of mentions/replies to you that are unread. * - * 规则:取索引里累积的 mention 时间戳,数其中晚于已读水位 `lastReadAt` 的条数;从未打开过(无 read-state)→ - * 全部计入。条数已在 poll 端按 {@link MENTION_ATS_CAP} 封顶(最多 10),故返回值天然 ≤ 10,UI 满额显示「10+」。 + * Rules: take the mention timestamps accumulated in the index and count those later than the read watermark `lastReadAt`; + * never opened (no read-state) → all are counted. The count is already capped on the poll side by {@link MENTION_ATS_CAP} + * (at most 10), so the return value is naturally ≤ 10, and the UI shows "10+" when full. */ export function computeUnreadMentionCount( entry: PrIndexEntry, @@ -176,12 +185,13 @@ export function computeUnreadMentionCount( } /** - * 列出当前**活跃** (非软删) 的 PR。 + * List currently **active** (non-soft-deleted) PRs. * - * 实现:先读索引 → 过滤掉 archivedAt 非空的 → 逐个读 meta.json + read-state.json。索引里没有但目录 - * 还在的 meta 视为孤儿,跳过 (poll 阶段会清掉)。 + * Implementation: read the index first → filter out those with a non-null archivedAt → read meta.json + read-state.json + * one by one. meta not in the index but whose directory still exists is treated as an orphan and skipped (the poll + * phase will clean it up). * - * 返回时据已读水位派生 `unread` 标记叠加到每条 PR 上(meta.json 本身不存此字段)。 + * On return, an `unread` mark derived from the read watermark is layered onto each PR (meta.json itself does not store this field). */ export async function listStoredPullRequests( store: StateStore, @@ -204,11 +214,12 @@ export async function listStoredPullRequests( } /** - * 列出**已归档**(退场 / 软删)的 PR,供「已关闭」视图浏览。 + * List **archived** (departed / soft-deleted) PRs, for browsing in the "Closed" view. * - * 索引仍只在 `stateStore` 维护(archivedAt 非空即归档);PR 实体目录在退场时整树搬入 `archiveStore` - * 冷存储,故逐个 meta 从 archiveStore 读。索引有条目但 archiveStore 无 meta(搬迁中途 / 旧布局)即跳过。 - * 归档 PR 一律视为已读(不参与未读派生)。 + * The index is still maintained only in `stateStore` (a non-null archivedAt means archived); the PR entity directory is + * moved as a whole tree into `archiveStore` cold storage on departure, so each meta is read from archiveStore. Entries + * present in the index but with no meta in archiveStore (mid-migration / old layout) are skipped. Archived PRs are + * always treated as read (not participating in unread derivation). */ export async function listArchivedPullRequests( stateStore: StateStore, @@ -227,8 +238,9 @@ export async function listArchivedPullRequests( } /** - * 标记 PR 为已读:把已读水位推进到当前 head sha + now。用户打开 PR 时由 IPC 调用。仅写 read-state.json - * (不碰 index.json),故与周期性 poll 的索引重写互不干扰。找不到 meta 返回 null;否则返回带 `unread:false` 的最新 PR。 + * Mark a PR as read: advance the read watermark to the current head sha + now. Called via IPC when the user opens a PR. + * Writes only read-state.json (not index.json), so it does not interfere with periodic poll's index rewrite. Returns null + * when meta is not found; otherwise returns the latest PR with `unread:false`. */ export async function markPrRead( store: StateStore, @@ -245,10 +257,11 @@ export async function markPrRead( } /** - * 覆写指定 PR 的 localStatus。调用方 (IPC) 通常先 PUT 到 Bitbucket 成功后再调本函数, - * 让本地立即反映新状态;下一轮 poll 会从 Bitbucket 拿到同样的值,不会产生抖动。 + * Overwrite the localStatus of the given PR. The caller (IPC) usually PUTs to Bitbucket successfully first, then calls + * this function so the local state reflects the new status immediately; the next poll will fetch the same value from + * Bitbucket, producing no flicker. * - * 找不到 meta 返回 null (PR 已退场 / 从未存在)。 + * Returns null when meta is not found (the PR has departed / never existed). */ export async function setLocalStatus( store: StateStore, diff --git a/packages/poller/src/runs.ts b/packages/poller/src/runs.ts index eaa81bfc..6faf6311 100644 --- a/packages/poller/src/runs.ts +++ b/packages/poller/src/runs.ts @@ -11,19 +11,21 @@ import type { PrAgentStrategy } from '@meebox/shared'; import type { StateStore } from '@meebox/state-store'; /** - * runs 落在 `prs/<localId>/runs/<runId>.json`:跟 meta.json / comments.json 一起 - * 在同一个 PR 目录下,PR 退场时 deleteDir 整棵清掉。 + * runs land at `prs/<localId>/runs/<runId>.json`: together with meta.json / + * comments.json under the same PR directory; when the PR leaves, deleteDir wipes + * the whole tree. * - * localId 现是 prHashId 出来的 12 位 hex (`pr-hash-id.ts`),无路径不安全字符, - * 不需要再 sanitize。 + * localId is now the 12-hex from prHashId (`pr-hash-id.ts`), with no path-unsafe + * characters, so no further sanitize is needed. */ function runKey(prLocalId: string, runId: string): string { return `prs/${prLocalId}/runs/${runId}`; } /** - * 时序 id,格式 `yyyymmdd-HHmmss-mmm`。按文件名字典序即时间序,列出时直接 - * 倒序排即可拿到 newest first,无需读所有文件内容。 + * Chronological id, format `yyyymmdd-HHmmss-mmm`. Lexicographic filename order is + * time order, so listing just reverses to get newest first without reading any + * file content. */ export function makeRunId(now: Date = new Date()): string { const pad = (n: number, w = 2): string => String(n).padStart(w, '0'); @@ -39,28 +41,29 @@ export interface StartReviewRunInput { tool: ReviewRunTool; prAgentVersion: string; strategy: PrAgentStrategy; - /** /ask 工具的问题;其他 tool 留空 */ + /** The question for the /ask tool; leave empty for other tools */ question?: string; /** - * 外部预分配的 runId (可选)。pr-agent run queue 在入队时就分配 id (用于 - * cancel(runId) 引用),到真正 start 时把同一 id 沿用下来,避免入队 id 跟 - * 落盘 id 不一致。 + * Externally pre-allocated runId (optional). The pr-agent run queue assigns the + * id at enqueue time (used for cancel(runId) references), and carries the same id + * through to the actual start, avoiding a mismatch between the enqueue id and the + * persisted id. */ id?: string; /** - * 本次 run 使用的 LLM model id (含 provider 前缀,如 `openai/qwen-plus`)。 - * 未指定时 run.model 留空,UI 自然不展示模型 chip + * The LLM model id used by this run (with provider prefix, e.g. `openai/qwen-plus`). + * When unspecified, run.model is left empty and the UI naturally shows no model chip */ model?: string; - /** 复评引用:本次 /ask 是对某条 finding 的复评时,记下被引用的源 finding(前向链)。 */ + /** Re-review reference: when this /ask is a re-review of a finding, record the referenced source finding (forward link). */ referencedFinding?: ReviewRun['referencedFinding']; - /** 触发来源:user(手动)/ agent(编排派发)。用于 ChatPane 命令回显气泡;缺省不回显。 */ + /** Trigger origin: user (manual) / agent (orchestration dispatch). Used for the ChatPane command echo bubble; omitted means no echo. */ origin?: ReviewRun['origin']; - /** 单 commit 评审范围(parent..sha);缺省 = PR 全量范围。落盘供结果卡展示范围徽标。 */ + /** Single-commit review scope (parent..sha); omitted = full PR scope. Persisted for the result card's scope badge. */ scope?: ReviewRun['scope']; } -/** 写入初始 running 状态;调用方在 pr-agent 调用前必须先 start。 */ +/** Write the initial running state; callers must start before invoking pr-agent. */ export async function startReviewRun( stateStore: StateStore, input: StartReviewRunInput, @@ -97,19 +100,20 @@ export interface FinishReviewRunPatch { errorMessage?: string; stdout?: string; stderr?: string; - /** M3-B2 解析得到的结构化 findings */ + /** Structured findings parsed in M3-B2 */ findings?: Finding[]; - /** UI 列表展示用的概要 */ + /** Summary for the UI list display */ summary?: string; - /** 本次 run 的真实 LLM token 用量(累加,来自 litellm callback) */ + /** Actual LLM token usage for this run (accumulated, from litellm callback) */ tokenUsage?: TokenUsage; - /** 复评裁决(解析自复评 /ask 输出的 `<verdict>`);非复评 / 未给则不填 */ + /** Re-review verdict (parsed from the `<verdict>` in re-review /ask output); left empty when not a re-review / not given */ askVerdict?: ReviewRun['askVerdict']; } /** - * Merge patch 到已存在的 run,重写文件。文件不存在返回 null(不重建空记录, - * 避免 startReviewRun 失败后 finishReviewRun 静默成功)。 + * Merge the patch into an existing run and rewrite the file. Returns null when the + * file does not exist (does not rebuild an empty record, to avoid finishReviewRun + * silently succeeding after startReviewRun failed). */ export async function finishReviewRun( stateStore: StateStore, @@ -137,20 +141,21 @@ export async function getReviewRun( } /** - * 列出一个 PR 的 run 历史。按 startedAt **倒序** (newest first) 返回。 + * List a PR's run history. Returns in **reverse** startedAt order (newest first). * - * runId 本身是时序字典序 (`yyyymmdd-HHmmss-mmm`),所以文件名升序排 = 时间升序排, - * 不必读 file body 拿 startedAt 字段。 + * runId is itself a chronological lexicographic id (`yyyymmdd-HHmmss-mmm`), so + * ascending filename order = ascending time order, no need to read the file body + * for the startedAt field. * - * 分页(用于 ChatPane 向上滚动懒加载): - * - `opts.beforeId` 仅返回**更早**于此 runId 的条目(严格小于);省略 = 不限上界 - * - `opts.limit` 截到 N 条;省略 = 不限 - * - 不读文件就能定位 page:先按 key 字典序排序 + 过滤 + 切片,再批量读,避免大库 - * 全表扫描 + * Pagination (for ChatPane scroll-up lazy loading): + * - `opts.beforeId` returns only entries **earlier** than this runId (strictly less than); omitted = no upper bound + * - `opts.limit` truncates to N entries; omitted = no limit + * - locate the page without reading files: first sort keys lexicographically + + * filter + slice, then batch read, avoiding a full-table scan on a large store */ /** - * 清空某 PR 的全部 run 历史记录(仅该 PR;删 `prs/<localId>/runs/*`)。返回删除条数。 - * 正在跑的 run 其落盘记录也会被删,但跑完时 finishReviewRun 会重新落盘 → 不影响进行中的 run。 + * Clear all run history records for a PR (only that PR; deletes `prs/<localId>/runs/*`). Returns the number deleted. + * A running run's persisted record is deleted too, but finishReviewRun rewrites it on completion → does not affect the in-progress run. */ export async function clearReviewRunsForPr( stateStore: StateStore, @@ -186,7 +191,7 @@ export async function listReviewRunsForPr( keys.sort().reverse(); // newest first by runId let filtered = keys; if (opts.beforeId) { - // key 形如 `prs/<localId>/runs/<runId>`,取末段比较 + // key looks like `prs/<localId>/runs/<runId>`, compare the last segment const before = opts.beforeId; filtered = keys.filter((k) => { const last = k.slice(k.lastIndexOf('/') + 1); @@ -203,9 +208,9 @@ export async function listReviewRunsForPr( } /** - * 该 PR 是否已有 /describe 或 /review 的有效产出(已成功,或正在跑)。用于 AutoPilot 准入: - * 会话中一旦有 describe/review 输出(手动或自动)即判定已评审过,不再自动触发,避免重复评审。 - * 失败 / 取消的 run 不算(未产出有效结果),仍可触发。 + * Whether the PR already has valid /describe or /review output (succeeded, or running). Used for AutoPilot admission: + * once a session has describe/review output (manual or automatic), it is deemed already reviewed and not auto-triggered again, avoiding duplicate review. + * Failed / cancelled runs do not count (no valid result produced), and can still trigger. */ export async function hasReviewOutput(stateStore: StateStore, prLocalId: string): Promise<boolean> { const runs = await listReviewRunsForPr(stateStore, prLocalId); diff --git a/packages/poller/src/types.ts b/packages/poller/src/types.ts index 67b4f442..7abed3f4 100644 --- a/packages/poller/src/types.ts +++ b/packages/poller/src/types.ts @@ -1,4 +1,4 @@ -// 复用 shared 中的契约类型(renderer 也会经 IPC 看到)。 +// Reuse the contract types from shared (the renderer also sees them via IPC). export type { LocalPrStatus, PollResult, StoredPullRequest } from '@meebox/shared'; -// PR state schema (索引 + meta) 现位于 pr-state.ts,从那里 re-export +// PR state schema (index + meta) now lives in pr-state.ts, re-exported from there diff --git a/packages/poller/src/unread.ts b/packages/poller/src/unread.ts index de1fded2..f254c065 100644 --- a/packages/poller/src/unread.ts +++ b/packages/poller/src/unread.ts @@ -1,13 +1,13 @@ import type { PlatformUser, PrComment, PrCommentAnchor } from '@meebox/shared'; /** - * 「未读」检测的纯逻辑:在 PR 评论树里找出**与当前用户相关**的最新一条他人评论的时间戳。 - * 相关 = ① 正文 @我(按 name / slug 任一 handle 匹配),或 ② 回复我(父评论作者是我)。自己写的评论不计。 + * Pure logic for "unread" detection: find, in the PR comment tree, the timestamp of the latest comment by others that is **relevant to the current user**. + * Relevant = ① the body @mentions me (matched by name / slug, either handle), or ② replies to me (the parent comment's author is me). Comments I wrote do not count. * - * 返回最新相关评论的 createdAt(ISO);无则 null。调用方(poll)把它与历史 `lastMentionAt` 取较大值维护成 - * 单调游标;是否「未读」由读取时与已读水位 `lastReadAt` 比较决定(见 pr-state.computeUnread)——故此处不关心水位。 + * Returns the createdAt (ISO) of the latest relevant comment; null if none. The caller (poll) takes the max of it and the historical `lastMentionAt` to maintain a + * monotonic cursor; whether it is "unread" is decided at read time by comparing against the read watermark `lastReadAt` (see pr-state.computeUnread) — so the watermark is not the concern here. * - * 仅在 poll 识别到 PR 内容变更(updatedAt 跳变)时调用——避免对每个跟踪 PR 每轮都拉评论,成本与活动量成正比。 + * Only called when poll detects a PR content change (updatedAt jumps) — avoids pulling comments every round for every tracked PR, keeping cost proportional to activity. */ function escapeRegExp(s: string): string { @@ -15,8 +15,8 @@ function escapeRegExp(s: string): string { } /** - * 正文是否 @ 了任一 handle。要求 `@` 前不是单词字符(排除邮箱 `a@h` 之类),`@handle` 后不接单词字符 / `.` / `-` - * (排除 `@handle2` 误命中 `@handle`)。大小写不敏感。 + * Whether the body @mentions any handle. Requires that the char before `@` is not a word char (excludes emails like `a@h`), and `@handle` is not followed by a word char / `.` / `-` + * (excludes `@handle2` falsely matching `@handle`). Case-insensitive. */ function mentionsAnyHandle(body: string, handles: readonly string[]): boolean { for (const h of handles) { @@ -27,12 +27,12 @@ function mentionsAnyHandle(body: string, handles: readonly string[]): boolean { return false; } -/** 与我相关的评论命中:被回复(父评论作者是我)优先于被 @(reply 是更强的相关关系)。 */ +/** A comment hit relevant to me: being replied to (the parent comment's author is me) takes priority over being @mentioned (reply is a stronger relevance). */ export type MentionKind = 'mention' | 'reply'; /** - * 评论树里一条「@我 / 回复我」他人评论的命中:时间 + 类型 + 作者(系统通知头像 / 发起人)+ 评论定位 - * (`commentRemoteId` 与 `anchor`:通知点击跳转用——inline 评论 anchor 非空可跳 diff 行,summary 评论 anchor 为 null)。 + * A hit in the comment tree for a "@me / reply to me" comment by others: time + kind + author (system notification avatar / originator) + comment locator + * (`commentRemoteId` and `anchor`: for notification click-through — a non-null inline comment anchor can jump to the diff line, a summary comment anchor is null). */ export interface MentionHit { at: string; @@ -43,13 +43,13 @@ export interface MentionHit { } /** - * 评论树里所有「@我 / 回复我」他人评论的命中(时间 + 类型),深度优先、自然到达顺序(未排序)。 - * 相关判定:① 父评论作者是我(reply),或 ② 正文 @我(mention);自己写的不计。两者都满足时记为 reply。 + * All hits in the comment tree for "@me / reply to me" comments by others (time + kind), depth-first, in natural arrival order (unsorted). + * Relevance: ① the parent comment's author is me (reply), or ② the body @mentions me (mention); comments I wrote do not count. When both hold, recorded as reply. * - * - `me`:当前用户(poll 时从 adapter 缓存身份取)。handle 取 name + slug(去重、非空)。 + * - `me`: the current user (taken from the adapter's cached identity during poll). handle is name + slug (deduplicated, non-empty). * - * 调用方(poll)据此取最新游标、据已读水位计未读条数(见 pr-state.computeUnreadMentionCount), - * 并按类型投影系统通知事件。 + * The caller (poll) uses this to take the latest cursor, count unread against the read watermark (see pr-state.computeUnreadMentionCount), + * and project system notification events by kind. */ export function collectMentionsToMe( comments: readonly PrComment[], @@ -76,7 +76,7 @@ export function collectMentionsToMe( return hits; } -/** 评论树里一条**他人**评论(不限是否 @我 / 回复我):时间 + 作者 + 定位。用于「我创建的」PR 的新评论通知。 */ +/** A comment by **others** in the comment tree (regardless of @me / reply to me): time + author + locator. Used for new-comment notifications on "my authored" PRs. */ export interface CommentHit { at: string; author: PlatformUser; @@ -85,9 +85,9 @@ export interface CommentHit { } /** - * 评论树里**所有他人评论**(作者非当前用户)的命中,深度优先、自然到达顺序(未排序)。与 - * {@link collectMentionsToMe} 不同:不筛 @我 / 回复我,收全部他人评论——供「我创建的」PR 的「收到新评论」通知 - * 用(作者本人的评论不计,故不会因自己评论而误报)。 + * Hits for **all comments by others** in the comment tree (author is not the current user), depth-first, in natural arrival order (unsorted). Unlike + * {@link collectMentionsToMe}: does not filter by @me / reply to me, collecting all comments by others — for the "received new comment" notification on "my authored" PRs + * (comments by the author themselves do not count, so it will not false-alarm on one's own comments). */ export function collectCommentsFromOthers( comments: readonly PrComment[], @@ -117,7 +117,7 @@ export function collectCommentsFromOthers( } /** - * 评论树里所有「@我 / 回复我」他人评论的 createdAt(ISO)列表。基于 {@link collectMentionsToMe}。 + * List of createdAt (ISO) for all "@me / reply to me" comments by others in the comment tree. Based on {@link collectMentionsToMe}. */ export function collectCommentsToMeAt( comments: readonly PrComment[], @@ -127,7 +127,7 @@ export function collectCommentsToMeAt( } /** - * 评论树里「@我 / 回复我」的最新他人评论的 createdAt(ISO);无则 null。基于 {@link collectCommentsToMeAt}。 + * The createdAt (ISO) of the latest "@me / reply to me" comment by others in the comment tree; null if none. Based on {@link collectCommentsToMeAt}. */ export function latestCommentToMeAt( comments: readonly PrComment[], diff --git a/packages/poller/tests/agent-session.test.ts b/packages/poller/tests/agent-session.test.ts index f3dfa81b..63ff8b63 100644 --- a/packages/poller/tests/agent-session.test.ts +++ b/packages/poller/tests/agent-session.test.ts @@ -9,7 +9,7 @@ import { updateAgentSession, } from '../src/agent-session.js'; -/** 内存版 StateStore:覆盖 read/write/delete/list/deleteDir,供持久化逻辑单测。 */ +/** In-memory StateStore: covers read/write/delete/list/deleteDir, for unit-testing persistence logic. */ class MemStore implements StateStore { private m = new Map<string, unknown>(); async read<T>(key: string): Promise<T | null> { diff --git a/packages/poller/tests/archive-housekeeping.test.ts b/packages/poller/tests/archive-housekeeping.test.ts index e76b4495..fd253221 100644 --- a/packages/poller/tests/archive-housekeeping.test.ts +++ b/packages/poller/tests/archive-housekeeping.test.ts @@ -23,7 +23,7 @@ afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); -// 把归档目录 mtime 回拨到超期 +// Backdate the archive directory's mtime to past the grace period const backdateArchive = async (hash: string, ageMs: number): Promise<void> => { const t = new Date(NOW.getTime() - ageMs); await fs.utimes(path.join(tmpDir, 'archived', 'prs', hash), t, t); @@ -51,12 +51,12 @@ const seedIndex = async (hashes: string[]): Promise<void> => { }; describe('sweepOrphanedArchivedPrs', () => { - it('删除「索引无条目 + 超 grace」的归档孤儿,保留仍被索引登记的', async () => { + it('deletes archived orphans "no index entry + past grace", keeps those still registered in the index', async () => { await archiveStore.write('prs/known/meta', { v: 1 }); await archiveStore.write('prs/orphan/meta', { v: 1 }); await backdateArchive('known', GRACE + 60_000); await backdateArchive('orphan', GRACE + 60_000); - await seedIndex(['known']); // 只有 known 在索引里 + await seedIndex(['known']); // only known is in the index const removed = await sweepOrphanedArchivedPrs({ stateStore: store, archiveStore, now: () => NOW }); expect(removed).toBe(1); @@ -64,16 +64,16 @@ describe('sweepOrphanedArchivedPrs', () => { expect(await archiveStore.read('prs/orphan/meta')).toBeNull(); }); - it('索引整个丢失时也只清超 grace 的孤儿、不动仍年轻的', async () => { + it('even when the whole index is lost, only sweeps orphans past grace, leaving still-young ones alone', async () => { await archiveStore.write('prs/old/meta', { v: 1 }); await archiveStore.write('prs/recent/meta', { v: 1 }); await backdateArchive('old', GRACE + 60_000); await backdateArchive('recent', GRACE - 60_000); - // 不写索引(模拟索引丢失)→ keep 为空 + // do not write the index (simulate index loss) → keep is empty const removed = await sweepOrphanedArchivedPrs({ stateStore: store, archiveStore, now: () => NOW }); expect(removed).toBe(1); - expect(await archiveStore.read('prs/old/meta')).toBeNull(); // 超期 → 清 - expect(await archiveStore.read('prs/recent/meta')).not.toBeNull(); // 年轻 → 保留(保守) + expect(await archiveStore.read('prs/old/meta')).toBeNull(); // past grace → swept + expect(await archiveStore.read('prs/recent/meta')).not.toBeNull(); // young → kept (conservative) }); }); diff --git a/packages/poller/tests/autopilot-ledger.test.ts b/packages/poller/tests/autopilot-ledger.test.ts index 32d55f3f..83c93541 100644 --- a/packages/poller/tests/autopilot-ledger.test.ts +++ b/packages/poller/tests/autopilot-ledger.test.ts @@ -47,14 +47,14 @@ describe('autopilot ledger', () => { }); it('needsAutoReview: true when no ledger or version changed; false when same', async () => { - expect(await needsAutoReview(store, PR, 't1')).toBe(true); // 无台账 + expect(await needsAutoReview(store, PR, 't1')).toBe(true); // no ledger await writeAutopilotLedger(store, { prLocalId: PR, autoReviewedUpdatedAt: 't1', decision: 'review', at: '2026-06-15T10:00:00.000Z', }); - expect(await needsAutoReview(store, PR, 't1')).toBe(false); // 同版本 - expect(await needsAutoReview(store, PR, 't2')).toBe(true); // 内容已变 + expect(await needsAutoReview(store, PR, 't1')).toBe(false); // same version + expect(await needsAutoReview(store, PR, 't2')).toBe(true); // content changed }); }); diff --git a/packages/poller/tests/diff-base-cache.test.ts b/packages/poller/tests/diff-base-cache.test.ts index 226f04df..6caa5aa5 100644 --- a/packages/poller/tests/diff-base-cache.test.ts +++ b/packages/poller/tests/diff-base-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { isDiffBaseCacheReusable } from '../src/diff-base-cache.js'; describe('isDiffBaseCacheReusable', () => { - it('目标分支已被 merge 到源分支时失效旧 base', async () => { + it('invalidates stale base when target branch has been merged into source branch', async () => { const ancestors = new Set(['old-base..head', 'target..head', 'old-base..target']); const reusable = await isDiffBaseCacheReusable({ @@ -15,7 +15,7 @@ describe('isDiffBaseCacheReusable', () => { expect(reusable).toBe(false); }); - it('目标分支前移但尚未进入源分支时复用旧 base', async () => { + it('reuses stale base when target branch advances but has not yet landed in source branch', async () => { const ancestors = new Set(['old-base..head', 'old-base..target']); const reusable = await isDiffBaseCacheReusable({ diff --git a/packages/poller/tests/parse-output.test.ts b/packages/poller/tests/parse-output.test.ts index cb51638f..e2bc0c97 100644 --- a/packages/poller/tests/parse-output.test.ts +++ b/packages/poller/tests/parse-output.test.ts @@ -8,7 +8,7 @@ import { } from '../src/parse-output.js'; describe('splitMarkdownSections', () => { - it('按 H1-H6 切片,body 去前后空白', () => { + it('slices by H1-H6, body trimmed of leading/trailing whitespace', () => { const md = '# Title\n\nlead body\n\n## Sub\nsub body\n'; const out = splitMarkdownSections(md); expect(out.map((s) => ({ level: s.level, title: s.title }))).toEqual([ @@ -19,26 +19,26 @@ describe('splitMarkdownSections', () => { expect(out[1]!.body).toBe('sub body'); }); - it('顶部无 header 的前导内容合成 level=0 section', () => { + it('leading content with no header at the top becomes a level=0 section', () => { const out = splitMarkdownSections('intro line\nmore intro\n\n## Real\nbody'); expect(out).toHaveLength(2); expect(out[0]!.level).toBe(0); expect(out[0]!.body).toBe('intro line\nmore intro'); }); - it('\\r\\n 行尾兼容', () => { + it('\\r\\n line-ending compatibility', () => { const out = splitMarkdownSections('# A\r\nbody\r\n## B\r\nb2\r\n'); expect(out.map((s) => s.title)).toEqual(['A', 'B']); }); - it('空输入返回空数组', () => { + it('empty input returns an empty array', () => { expect(splitMarkdownSections('')).toEqual([]); expect(splitMarkdownSections(' \n ')).toEqual([]); }); }); describe('sectionToFinding', () => { - it('识别 **File:** + **Lines:** 模式 → code-feedback + anchor', () => { + it('recognizes **File:** + **Lines:** pattern → code-feedback + anchor', () => { const f = sectionToFinding( { level: 3, @@ -53,7 +53,7 @@ describe('sectionToFinding', () => { expect(f.title).toBe('Possible bug'); }); - it('单行 lines 字段也能解析 startLine', () => { + it('single-line lines field also parses startLine', () => { const f = sectionToFinding( { level: 3, title: 't', body: '**File:** a.ts\n**Line:** 7' }, 0, @@ -62,7 +62,7 @@ describe('sectionToFinding', () => { expect(f.anchor).toEqual({ path: 'a.ts', startLine: 7 }); }); - it('file_path / file path 变体也吃', () => { + it('file_path / file path variants also accepted', () => { const f = sectionToFinding( { level: 3, title: 't', body: '**file_path:** x.ts\n**line_numbers:** 1-3' }, 0, @@ -71,7 +71,7 @@ describe('sectionToFinding', () => { expect(f.anchor).toEqual({ path: 'x.ts', startLine: 1, endLine: 3 }); }); - it('反引号包裹的路径剥掉', () => { + it('backtick-wrapped path stripped', () => { const f = sectionToFinding( { level: 3, title: 't', body: '**File:** `src/a.ts`\n**Line:** 1' }, 0, @@ -80,7 +80,7 @@ describe('sectionToFinding', () => { expect(f.anchor?.path).toBe('src/a.ts'); }); - it('review tool 无 file 信息 → general', () => { + it('review tool with no file info → general', () => { const f = sectionToFinding( { level: 2, title: 'Estimated effort to review [1-5]', body: '3' }, 0, @@ -90,17 +90,17 @@ describe('sectionToFinding', () => { expect(f.title).toBe('Estimated effort to review [1-5]'); }); - it('describe tool 无 file → description', () => { + it('describe tool with no file → description', () => { const f = sectionToFinding({ level: 2, title: 'PR Type', body: 'feature' }, 0, 'describe'); expect(f.category).toBe('description'); }); - it('id 形如 <tool>-NNN', () => { + it('id shaped like <tool>-NNN', () => { const f = sectionToFinding({ level: 2, title: 't', body: 'x' }, 5, 'review'); expect(f.id).toBe('review-005'); }); - it('/ask 命中 [file:..., lines:...] marker → 升格 code-feedback + anchor', () => { + it('/ask matches [file:..., lines:...] marker → promoted to code-feedback + anchor', () => { const f = sectionToFinding( { level: 2, @@ -114,7 +114,7 @@ describe('sectionToFinding', () => { expect(f.anchor).toEqual({ path: 'src/auth/login.ts', startLine: 42, endLine: 50 }); }); - it('/ask 单行 marker (无 endLine) 也能升格', () => { + it('/ask single-line marker (no endLine) can also be promoted', () => { const f = sectionToFinding( { level: 2, title: 'Answer', body: '说明。\n[file: pkg/cache.go, lines: 17]' }, 0, @@ -124,7 +124,7 @@ describe('sectionToFinding', () => { expect(f.anchor).toEqual({ path: 'pkg/cache.go', startLine: 17 }); }); - it('marker 路径含 [] 仍能抽出 anchor(path 不被路径里的 ] 误截)', () => { + it('marker path containing [] still extracts anchor (path not truncated by the ] inside the path)', () => { const f = sectionToFinding( { level: 2, @@ -142,9 +142,9 @@ describe('sectionToFinding', () => { }); }); - it('/ask 答案不涉及具体位置 (无 marker) → 留 general,不强行兜底路径 token', () => { + it('/ask answer with no specific location (no marker) → stays general, does not force-fallback a path token', () => { const f = sectionToFinding( - // 故意含 src/foo.ts 路径 token,但没显式 marker 也没行号 → 应保持 general + // intentionally contains a src/foo.ts path token, but no explicit marker and no line numbers → should stay general { level: 2, title: 'Answer', body: '这个 PR 整体重构了 src/foo.ts 的导出口。' }, 0, 'ask', @@ -153,7 +153,7 @@ describe('sectionToFinding', () => { expect(f.anchor).toBeUndefined(); }); - it('/describe 即使内容含 marker 也不升格 (兜底仅 ask 启用)', () => { + it('/describe does not promote even when content contains a marker (fallback only enabled for ask)', () => { const f = sectionToFinding( { level: 2, title: 'Description', body: 'something\n[file: a.ts, lines: 1-3]' }, 0, @@ -164,7 +164,7 @@ describe('sectionToFinding', () => { }); describe('stripAnchorMarker', () => { - it('清掉 [file:…, lines:…] marker(含路径里的 [],不被 ] 误截)', () => { + it('strips [file:…, lines:…] marker (including [] inside the path, not truncated by ])', () => { const body = '租户错位风险。\n[file: 2026/11.1.x/[m-6837803244].迁移/src/context.ts, lines: 92-101]'; const out = stripAnchorMarker(body); @@ -173,14 +173,14 @@ describe('stripAnchorMarker', () => { expect(out).not.toContain('lines: 92-101'); }); - it('清掉无 [] 的普通 marker,并兼容无 lines 的旧式', () => { + it('strips a plain marker without [], and tolerates the old form without lines', () => { expect(stripAnchorMarker('问题。\n[file: src/a.ts, lines: 5-9]')).toBe('问题。'); expect(stripAnchorMarker('问题。\n[file: src/a.ts]')).toBe('问题。'); }); }); describe('parseReviewOutput', () => { - it('混合 sections:description + code-feedback + general 并存', () => { + it('mixed sections: description + code-feedback + general coexist', () => { const md = [ '## PR Review', '', @@ -206,27 +206,27 @@ describe('parseReviewOutput', () => { expect(code.anchor?.endLine).toBe(20); }); - it('空 stdout → 空 findings 不抛', () => { + it('empty stdout → empty findings, does not throw', () => { expect(parseReviewOutput('', 'review')).toEqual({ findings: [] }); }); - it('无 markdown header 的纯文本 → 1 个 general finding,summary 取首行', () => { + it('plain text with no markdown header → 1 general finding, summary takes the first line', () => { const { findings, summary } = parseReviewOutput('plain stdout line\nmore', 'review'); expect(findings).toHaveLength(1); expect(findings[0]!.category).toBe('general'); expect(summary).toBe('plain stdout line'); }); - it('describe tool 全部段落落 description category', () => { + it('describe tool: all sections land in the description category', () => { const md = '## PR Description\nimproves foo\n\n## PR Type\nfeature'; const { findings } = parseReviewOutput(md, 'describe'); expect(findings.every((f) => f.category === 'description')).toBe(true); }); - // pr-agent v0.35+ LocalGitProvider /review 真实输出:每条 issue 渲染成 - // `**header**\n\ncontent`,没有 File/Lines 字段(pr-agent 渲染时丢字段)。 - // 我们要把这种段拆成多条独立 finding,UI 端按 code-feedback 卡片渲染 - it('展开 "Recommended focus areas for review" 段为多条 code-feedback finding', () => { + // pr-agent v0.35+ LocalGitProvider /review real output: each issue renders as + // `**header**\n\ncontent`, with no File/Lines fields (pr-agent drops the fields when rendering). + // We split such a section into multiple independent findings, rendered as code-feedback cards on the UI side + it('expands the "Recommended focus areas for review" section into multiple code-feedback findings', () => { const md = [ '## PR Reviewer Guide', '', @@ -248,22 +248,22 @@ describe('parseReviewOutput', () => { expect(codeFb).toHaveLength(2); expect(codeFb[0]!.title).toBe('潜在空引用'); expect(codeFb[0]!.body).toMatch(/goTenantLoginView/); - expect(codeFb[0]!.anchor).toBeUndefined(); // 内容没提到 path → 抽不到 + expect(codeFb[0]!.anchor).toBeUndefined(); // content does not mention path → cannot extract expect(codeFb[1]!.title).toBe('异常处理缺失'); - // 第二条 content 里提到了路径 + 行号 → best-effort 抽到 + // the second content mentions path + line numbers → best-effort extracted expect(codeFb[1]!.anchor?.path).toBe('src/foo.ts'); expect(codeFb[1]!.anchor?.startLine).toBe(42); expect(codeFb[1]!.anchor?.endLine).toBe(50); }); - it('header 带 meebox:// 链接(get_line_link 注入)→ 取结构化 anchor', () => { + it('header with meebox:// link (injected by get_line_link) → takes structured anchor', () => { const md = [ '### ⚡ Recommended focus areas for review', '', '#### ', '[**潜在空引用**](meebox:///src/auth/login.ts#L42-L50)', '', - 'tenant 可能为 null,未判空直接 getId()。', // 正文不含 path,仍能从链接拿 anchor + 'tenant 可能为 null,未判空直接 getId()。', // body contains no path, still gets anchor from the link '', '#### ', '[**单行定位**](meebox:///pkg/cache.go#L17)', @@ -279,9 +279,9 @@ describe('parseReviewOutput', () => { expect(code[1]!.anchor).toEqual({ path: 'pkg/cache.go', startLine: 17 }); }); - it('链接只有 path(模型没填结构化行号)→ 用正文 marker 的行号补全', () => { - // 真实样本:get_line_link 拿到 start_line=0 → 链接无 #L,但模型按我们的指令 - // 在正文 marker 里写了 lines: 244-255。合并后应拿到完整 anchor。 + it('link has only path (model did not fill structured line numbers) → completed with line numbers from the body marker', () => { + // real sample: get_line_link got start_line=0 → link has no #L, but the model, per our instructions, + // wrote lines: 244-255 in the body marker. After merging, should get the complete anchor. const md = [ '### ⚡ Recommended focus areas for review', '', @@ -300,7 +300,7 @@ describe('parseReviewOutput', () => { }); }); - it('链接 path 与正文 marker 指向不同文件 → 不借行号(避免错配)', () => { + it('link path and body marker point to different files → does not borrow line numbers (avoids mismatch)', () => { const md = [ '### Key Issues to Review', '', @@ -310,11 +310,11 @@ describe('parseReviewOutput', () => { ].join('\n'); const { findings } = parseReviewOutput(md, 'review'); const c = findings.find((f) => f.category === 'code-feedback')!; - // path 取链接的 a.ts;行号不借 b.ts 的 → 只有 path + // path takes the link's a.ts; line numbers not borrowed from b.ts → only path expect(c.anchor).toEqual({ path: 'src/a.ts' }); }); - it('meebox:// 链接 URL 解码(路径含空格)', () => { + it('meebox:// link URL decoding (path contains spaces)', () => { const md = [ '### Key Issues to Review', '', @@ -327,7 +327,7 @@ describe('parseReviewOutput', () => { expect(c.anchor).toEqual({ path: 'src/my dir/a.ts', startLine: 3, endLine: 4 }); }); - it('英文 "Key Issues to Review" 标题也走展开路径', () => { + it('English "Key Issues to Review" heading also goes through the expand path', () => { const md = [ '### 🔍 Key Issues to Review', '', @@ -346,7 +346,7 @@ describe('parseReviewOutput', () => { expect(code[0]!.anchor?.startLine).toBe(17); }); - it('显式 [file: ..., lines: ..] marker 是 anchor 强信号', () => { + it('explicit [file: ..., lines: ..] marker is a strong anchor signal', () => { const md = [ '### Recommended focus areas for review', '', @@ -360,7 +360,7 @@ describe('parseReviewOutput', () => { expect(c.anchor).toEqual({ path: 'src/cache.ts', startLine: 88, endLine: 93 }); }); - it('key-issues 段无 bold header → 退回单 finding(不丢内容)', () => { + it('key-issues section with no bold header → falls back to a single finding (no content lost)', () => { const md = [ '### Recommended focus areas for review', '', @@ -371,7 +371,7 @@ describe('parseReviewOutput', () => { expect(findings[0]!.body).toMatch(/说明文字/); }); - it('GFM 表格输出:key_issues 的 <details>/<a href> finding 抽取 + anchor', () => { + it('GFM table output: <details>/<a href> finding extraction + anchor for key_issues', () => { const md = [ '<table>', '<tr><td>⏱️ <strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr>', @@ -399,11 +399,11 @@ describe('parseReviewOutput', () => { expect(code[0]!.anchor).toEqual({ path: 'src/auth/login.ts', startLine: 42, endLine: 50 }); expect(code[0]!.body).toMatch(/未判空/); expect(code[1]!.anchor).toEqual({ path: 'pkg/cache.go', startLine: 17 }); - // effort 行也切成 section finding(标题映射到 effort) + // effort line also sliced into a section finding (title maps to effort) expect(findings.some((f) => f.sectionKey === 'effort')).toBe(true); }); - it('GFM key_issues 抽不到 finding → 退回单条(不丢内容)', () => { + it('GFM key_issues cannot extract a finding → falls back to a single one (no content lost)', () => { const md = [ '<table>', '<tr><td>⚡ <strong>Recommended focus areas for review</strong><br><br>', @@ -415,21 +415,21 @@ describe('parseReviewOutput', () => { expect(findings.some((f) => /格式漂移/.test(f.body))).toBe(true); }); - it('GFM 多列行:保留每个 <td> 单元格内容,不丢后续列', () => { + it('GFM multi-column row: preserves every <td> cell content, does not drop later columns', () => { const md = [ '<table>', '<tr><td><strong>第一列标题</strong>: 左侧内容</td><td>右侧第二列内容 keep-me</td></tr>', '</table>', ].join('\n'); const { findings } = parseReviewOutput(md, 'review'); - // 第二个单元格的内容必须保留在 section body 里 + // the second cell's content must be preserved in the section body expect(findings.some((f) => /keep-me/.test(f.body))).toBe(true); expect(findings.some((f) => /左侧内容/.test(f.body))).toBe(true); }); - it('GFM /review:表格前有前导说明文字仍走 HTML 路径,逐段拆分 + key_issues 抽 finding', () => { + it('GFM /review: leading explanatory text before the table still goes through the HTML path, split section by section + key_issues extracts findings', () => { const md = [ - '以下是辅助评审的关键观察:', // 真实输出常见的前导句,不应导致漏判 + '以下是辅助评审的关键观察:', // a common leading sentence in real output, should not cause a misjudgment '', '<table>', '<tr><td>⏱️ <strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr>', @@ -442,7 +442,7 @@ describe('parseReviewOutput', () => { '</table>', ].join('\n'); const { findings } = parseReviewOutput(md, 'review'); - // 逐段独立:工作量 / 安全 各自成段;key_issues 抽成 code-feedback finding(不再挤进单条总结) + // each section independent: effort / security each their own section; key_issues extracted into code-feedback findings (no longer crammed into a single summary) expect(findings.some((f) => f.sectionKey === 'effort')).toBe(true); expect(findings.some((f) => f.sectionKey === 'security')).toBe(true); const code = findings.filter((f) => f.category === 'code-feedback'); @@ -453,7 +453,7 @@ describe('parseReviewOutput', () => { }); }); - it('非 GFM /review:代码围栏里提到 <table>/<tr> 仍走 markdown 路径(不丢正文)', () => { + it('non-GFM /review: <table>/<tr> mentioned inside a code fence still goes through the markdown path (no body lost)', () => { const md = [ '### PR 分析', '', @@ -464,12 +464,12 @@ describe('parseReviewOutput', () => { '```', ].join('\n'); const { findings } = parseReviewOutput(md, 'review'); - // 若误判进 GFM 表格路径,splitGfmTableSections 只会按 <tr> 行切片、丢弃 markdown 正文。 - // 走 markdown 路径则正文(含说明文字)完整保留 —— 以此判定未走偏。 + // if misjudged into the GFM table path, splitGfmTableSections would only slice by <tr> rows and discard the markdown body. + // going through the markdown path preserves the body (including explanatory text) intact — used to determine it did not go astray. expect(findings.some((f) => /仅作说明/.test(f.body))).toBe(true); }); - it('GFM /review:测试/安全段按结论变体文案(PR contains tests / No security concerns)正确归类', () => { + it('GFM /review: test/security sections correctly classified by conclusion-variant wording (PR contains tests / No security concerns)', () => { const md = [ '<table>', '<tr><td>🧪 <strong>PR contains tests</strong></td></tr>', @@ -477,13 +477,13 @@ describe('parseReviewOutput', () => { '</table>', ].join('\n'); const { findings } = parseReviewOutput(md, 'review'); - // 旧实现只认 "Relevant tests"/"Security concerns",这两种常见结论会退化成 general(无 chip/配色) + // the old implementation only recognized "Relevant tests"/"Security concerns"; these two common conclusions would degrade to general (no chip/coloring) expect(findings.find((f) => /tests/i.test(f.title ?? ''))?.sectionKey).toBe('relevant-tests'); expect(findings.find((f) => /security/i.test(f.title ?? ''))?.sectionKey).toBe('security'); }); }); -describe('parseReviewOutput · describe 架构图 / 文件走查', () => { +describe('parseReviewOutput · describe diagram / file walkthrough', () => { const md = [ '### **PR Type**', 'Enhancement', @@ -515,32 +515,32 @@ describe('parseReviewOutput · describe 架构图 / 文件走查', () => { '___', ].join('\n'); - it('Diagram Walkthrough → diagram 段,body 含 mermaid,不含走查表格', () => { + it('Diagram Walkthrough → diagram section, body contains mermaid, no walkthrough table', () => { const { findings } = parseReviewOutput(md, 'describe'); const diagram = findings.find((f) => f.sectionKey === 'diagram'); expect(diagram).toBeDefined(); expect(diagram!.body).toMatch(/```mermaid/); expect(diagram!.body).toMatch(/flowchart LR/); - // 走查块已被抽走,不应黏在 diagram body 里 + // the walkthrough block has been extracted, should not stick in the diagram body expect(diagram!.body).not.toMatch(/File Walkthrough|<table/); }); - it('File Walkthrough → walkthrough 段,保留多级分类折叠列表,去掉 +1/-1', () => { + it('File Walkthrough → walkthrough section, preserves multi-level category collapsible list, removes +1/-1', () => { const { findings } = parseReviewOutput(md, 'describe'); const wt = findings.find((f) => f.sectionKey === 'walkthrough'); expect(wt).toBeDefined(); - // 每个分类各自独立成可折叠 <details>,内部纯 HTML 无序列表 + 描述 + // each category becomes its own collapsible <details>, with a pure HTML unordered list + description inside expect(wt!.body).toMatch(/<details open><summary>功能增强(2)<\/summary>/); expect(wt!.body).toMatch( /<li><strong>CacheValueProvider\.ts<\/strong> — 增加缓存合并配置透传<\/li>/, ); expect(wt!.body).toMatch(/<details open><summary>测试(1)<\/summary>/); - // 不保留原始表格 / +1/-1 统计 + // does not preserve the original table / +1/-1 stats expect(wt!.body).not.toMatch(/\+-1|\/--1|Relevant files/); }); - it('File Walkthrough 非折叠形态(小 PR,<td><table> 无 <details>)仍识别出分类', () => { - // pr-agent 在文件数低于阈值时不给分类包 <details>,分类单元格直接是 <td><table>。 + it('File Walkthrough non-collapsible form (small PR, <td><table> without <details>) still recognizes categories', () => { + // pr-agent does not wrap categories in <details> when the file count is below the threshold; the category cell is directly <td><table>. const small = [ '### Diagram Walkthrough', '', @@ -565,13 +565,13 @@ describe('parseReviewOutput · describe 架构图 / 文件走查', () => { expect(wt!.body).toMatch( /<li><strong>Message\.ts<\/strong> — 新增飞书 Markdown 长度限制常量<\/li>/, ); - // 不应退化成无分类的平铺列表 + // should not degrade into a flat list with no categories expect(wt!.body).toContain('<details open>'); }); }); describe('parseStructuredAsk', () => { - it('命中三标签 → 三段 finding(ask-summary/analysis/suggestions),顺序固定、summary 入字段', () => { + it('matches three tags → three-section finding (ask-summary/analysis/suggestions), fixed order, summary into field', () => { const md = [ '<summary>', 'It is safe to merge.', @@ -595,14 +595,14 @@ describe('parseStructuredAsk', () => { expect(findings[0]!.body).toBe('It is safe to merge.'); }); - it('只有 summary 段 → 单 finding,analysis/suggestions 省略', () => { + it('only a summary section → single finding, analysis/suggestions omitted', () => { const md = '<summary>\nLooks good.\n</summary>'; const { findings } = parseReviewOutput(md, 'ask'); expect(findings).toHaveLength(1); expect(findings[0]!.sectionKey).toBe('ask-summary'); }); - it('suggestions 段带行号 marker → 升为可定位 code-suggestion(anchor 提取、marker 剥除)', () => { + it('suggestions section with line-number marker → promoted to a locatable code-suggestion (anchor extracted, marker stripped)', () => { const md = [ '<suggestions>', 'Guard the null case here.', @@ -616,7 +616,7 @@ describe('parseStructuredAsk', () => { expect(findings[0]!.anchor).toEqual({ path: 'src/a.ts', startLine: 10, endLine: 12 }); }); - it('suggestions 段多条 marker → 逐条拆成 code-suggestion;无 marker 尾部归并为 ask-suggestions', () => { + it('suggestions section with multiple markers → split into code-suggestions one by one; marker-less tail merged into ask-suggestions', () => { const md = [ '<suggestions>', '- Add a null guard.', @@ -637,7 +637,7 @@ describe('parseStructuredAsk', () => { expect(findings[2]!.anchor).toBeUndefined(); }); - it('suggestions 段无 marker → 整段一条 ask-suggestions(同旧行为)', () => { + it('suggestions section with no marker → the whole section as one ask-suggestions (same as old behavior)', () => { const md = ['<suggestions>', 'General advice with no specific code location.', '</suggestions>'].join( '\n', ); @@ -646,25 +646,25 @@ describe('parseStructuredAsk', () => { expect(findings[0]!.body).toBe('General advice with no specific code location.'); }); - it('无标签 → 回退普通 /ask 解析(不产出 ask-* 段)', () => { + it('no tags → falls back to plain /ask parsing (produces no ask-* sections)', () => { const md = 'Just a plain free-form answer with no tags.'; expect(parseStructuredAsk(md)).toBeNull(); const { findings } = parseReviewOutput(md, 'ask'); expect(findings.every((f) => !String(f.sectionKey).startsWith('ask-'))).toBe(true); }); - it('标签存在但内容全空 → 回退(返回 null)', () => { + it('tags present but content all empty → fallback (returns null)', () => { expect(parseStructuredAsk('<summary>\n\n</summary>')).toBeNull(); }); - it('复评 <verdict> 抽取(replace / keep / drop)', () => { + it('re-review <verdict> extraction (replace / keep / drop)', () => { const mk = (v: string): string => `<summary>x</summary>\n<verdict>${v}</verdict>`; expect(parseReviewOutput(mk('replace'), 'ask').askVerdict).toBe('replace'); expect(parseReviewOutput(mk('Keep'), 'ask').askVerdict).toBe('keep'); expect(parseReviewOutput(mk('drop'), 'ask').askVerdict).toBe('drop'); }); - it('未知 verdict / 无 verdict → askVerdict undefined', () => { + it('unknown verdict / no verdict → askVerdict undefined', () => { expect( parseReviewOutput('<summary>x</summary>\n<verdict>maybe</verdict>', 'ask').askVerdict, ).toBeUndefined(); diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 73204470..49655bea 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -21,42 +21,42 @@ import { type PrIndexFile, } from '../src/pr-state.js'; -// 仅在 IPC 层被调用、poller 不触发的领域方法补桩;满足容器接口契约。 +// stubs for domain methods called only at the IPC layer and not triggered by the poller; satisfies the container interface contract. const unusedComments: CommentService = { listPullRequestComments: async () => [], publishSummaryComment: () => - Promise.reject(new Error('FakeAdapter.publishSummaryComment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.publishSummaryComment not implemented (unused by poller tests)')), publishInlineComment: () => - Promise.reject(new Error('FakeAdapter.publishInlineComment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.publishInlineComment not implemented (unused by poller tests)')), replyToComment: () => - Promise.reject(new Error('FakeAdapter.replyToComment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.replyToComment not implemented (unused by poller tests)')), editComment: () => - Promise.reject(new Error('FakeAdapter.editComment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.editComment not implemented (unused by poller tests)')), deleteComment: () => - Promise.reject(new Error('FakeAdapter.deleteComment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.deleteComment not implemented (unused by poller tests)')), toggleReaction: () => - Promise.reject(new Error('FakeAdapter.toggleReaction 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.toggleReaction not implemented (unused by poller tests)')), }; const unusedMedia: MediaService = { getUserAvatar: async () => null, getAttachment: () => - Promise.reject(new Error('FakeAdapter.getAttachment 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.getAttachment not implemented (unused by poller tests)')), uploadAttachment: async () => null, }; /** - * 容器形状的测试替身:poller 只读 kind / connection.getCurrentUser / - * connection.capabilities / prs.listPendingPullRequests,其余领域补最小桩。 - * 真测试逻辑(用户、能力、待处理 PR 行为)落在对应子对象里;测试辅助(setPrs / - * failNextList / seedUser)仍挂在 adapter 上,子对象闭包读同一实例状态。 + * Container-shaped test double: the poller only reads kind / connection.getCurrentUser / + * connection.capabilities / prs.listPendingPullRequests; other domains get minimal stubs. + * The real test logic (user, capabilities, pending-PR behavior) lives in the corresponding sub-objects; + * test helpers (setPrs / failNextList / seedUser) still hang on the adapter, and the sub-object closures read the same instance state. */ class FakeAdapter implements PlatformAdapter { readonly kind = 'bitbucket-server' as const; private currentUser: { name: string; displayName: string } | null = null; private commentList: PrComment[] = []; - // 能力开关:模拟「含回复的计数信号」平台(GitHub/GitLab);默认 false(Bitbucket 粗信号、每轮兜底扫)。 + // capability switch: simulates a "reply-inclusive count signal" platform (GitHub/GitLab); defaults to false (Bitbucket coarse signal, fallback scan every round). private replyAware = false; - // listPullRequestComments 调用计数:验证 reliable 平台未变化时不扫 / coarse 平台每轮扫。 + // listPullRequestComments call count: verifies a reliable platform does not scan when unchanged / a coarse platform scans every round. commentCalls = 0; readonly connection: PlatformConnection; readonly prs: PullRequestService; @@ -108,14 +108,14 @@ class FakeAdapter implements PlatformAdapter { return this.prList; }, getSinglePullRequest: () => - Promise.reject(new Error('FakeAdapter.getSinglePullRequest 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.getSinglePullRequest not implemented (unused by poller tests)')), listPullRequestCommits: async () => [], listPullRequestActivity: async () => [], setPullRequestReviewStatus: async () => { - // 测试只关心 poller 自身行为;setReviewStatus 在 IPC 层调用,poller 不触发 + // the test only cares about the poller's own behavior; setReviewStatus is called at the IPC layer, not triggered by the poller }, mergePullRequest: () => - Promise.reject(new Error('FakeAdapter.mergePullRequest 未实现(poller 测试不使用)')), + Promise.reject(new Error('FakeAdapter.mergePullRequest not implemented (unused by poller tests)')), }; } setPrs(prs: PullRequest[]): void { @@ -124,15 +124,15 @@ class FakeAdapter implements PlatformAdapter { failNextList(): void { this.failList = true; } - // 测试辅助:直接灌入当前用户(区别于 PlatformConnection 的 setCurrentUser(user) 契约方法)。 + // test helper: directly seed the current user (distinct from PlatformConnection's setCurrentUser(user) contract method). seedUser(name: string, displayName = name): void { this.currentUser = { name, displayName }; } - // 测试辅助:灌入 listPullRequestComments 返回的评论(未读 mention / 通知投影用)。 + // test helper: seed the comments returned by listPullRequestComments (for unread mention / notification projection). seedComments(list: PrComment[]): void { this.commentList = list; } - // 测试辅助:切到「含回复计数信号」平台语义(GitHub/GitLab);默认 false 模拟 Bitbucket 粗信号。 + // test helper: switch to "reply-inclusive count signal" platform semantics (GitHub/GitLab); defaults to false to simulate Bitbucket coarse signal. setReplyAware(v: boolean): void { this.replyAware = v; } @@ -170,7 +170,7 @@ function makePr(id: string, updatedAt: string, title = `PR ${id}`): PullRequest }; } -/** 「我创建的」PR:作者即当前用户(默认 alice)。用于 authored_* 通知测试。 */ +/** "authored by me" PR: author is the current user (defaults to alice). Used for authored_* notification tests. */ function makeAuthoredPr(id: string, updatedAt: string, author = 'alice'): PullRequest { const pr = makePr(id, updatedAt); pr.author = { name: author, displayName: author }; @@ -179,7 +179,7 @@ function makeAuthoredPr(id: string, updatedAt: string, author = 'alice'): PullRe let tmpDir: string; let store: JsonFileStateStore; -// 归档冷存储:与 store 物理分离(store 根 = tmpDir,archived 根 = tmpDir/archived)。 +// archive cold storage: physically separate from store (store root = tmpDir, archived root = tmpDir/archived). let archiveStore: JsonFileStateStore; beforeEach(async () => { @@ -293,7 +293,7 @@ describe('Poller.tick', () => { expect(stored[0]!.connectionId).toBe('good'); }); - it('tick re-entrancy: a second tick while one is in flight returns immediately (并登记补跑)', async () => { + it('tick re-entrancy: a second tick while one is in flight returns immediately (and registers a catch-up run)', async () => { let resolveList: ((v: PullRequest[]) => void) | undefined; let listCalls = 0; const slowConnection: PlatformConnection = { @@ -326,9 +326,10 @@ describe('Poller.tick', () => { // unused in this test }, mergePullRequest: () => Promise.reject(new Error('unused')), - // 首次返回受 resolveList 控制的挂起 promise;后续(in-flight 期间第二次 tick 登记的 - // 「补跑」)立即返回,避免测试悬挂。补跑是新语义:第二次 tick 虽即时返回 EMPTY,但当前轮 - // 结束后会紧接着再 poll 一轮(保证 ping 异步补到 currentUser 后的重分类请求不丢)。 + // the first call returns a pending promise controlled by resolveList; subsequent ones (the + // "catch-up run" registered by the second tick during in-flight) return immediately to avoid a hung test. + // the catch-up run is new semantics: although the second tick returns EMPTY immediately, right after the current + // round ends it polls one more round (ensures the reclassification request after ping asynchronously fills currentUser is not lost). listPendingPullRequests: () => { listCalls += 1; return listCalls === 1 @@ -340,7 +341,7 @@ describe('Poller.tick', () => { kind: 'bitbucket-server', connection: slowConnection, prs: slowPulls, - // 以下领域本测试不触发;补最小桩满足容器接口契约。 + // the following domains are not triggered by this test; minimal stubs satisfy the container interface contract. comments: { listPullRequestComments: async () => [], publishSummaryComment: () => Promise.reject(new Error('unused')), @@ -364,9 +365,9 @@ describe('Poller.tick', () => { logger: noopLogger, }); const firstTick = poller.tick(); - // 让 firstTick 推进到 adapter.listPendingPullRequests 调用(穿过 stateStore.read 的真实 fs 读) + // let firstTick advance to the adapter.listPendingPullRequests call (through the real fs read of stateStore.read) await new Promise<void>((r) => setTimeout(r, 50)); - const secondTick = await poller.tick(); // 立即返回 EMPTY + const secondTick = await poller.tick(); // returns EMPTY immediately expect(secondTick).toEqual({ fetched: 0, changed: 0, added: 0, removed: 0, errors: 0 }); resolveList!([makePr('1', '2026-05-28T01:00:00.000Z')]); await firstTick; @@ -388,7 +389,7 @@ describe('Poller.tick', () => { await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(2); - // PR #2 在远端 merged → 不再出现在 dashboard + // PR #2 merged remotely → no longer appears in the dashboard adapter.setPrs([makePr('1', '2026-05-28T01:00:00.000Z')]); const r = await poller.tick(); expect(r.removed).toBe(1); @@ -398,7 +399,7 @@ describe('Poller.tick', () => { }); it('all connections fail in one tick: index file mtime untouched + state intact', async () => { - // 先一次成功 poll,落地基线 + // first a successful poll to lay down the baseline const ok1 = makePr('1', '2026-05-28T01:00:00.000Z'); const ok2 = makePr('2', '2026-05-28T02:00:00.000Z'); const adapter = new FakeAdapter([ok1, ok2]); @@ -414,9 +415,9 @@ describe('Poller.tick', () => { const mtimeBefore = (await fs.stat(indexPath)).mtimeMs; const storedBefore = await listStoredPullRequests(store); - // 下一轮:远端整体失败 (网络断 / 5xx) → 本地一行不动 (invariant #1+#2) + // next round: remote fails as a whole (network down / 5xx) → not a single local row moves (invariant #1+#2) adapter.failNextList(); - // 至少加 5ms 时间窗,避免 mtime 分辨率 (Windows NTFS 100ns 都行,保险起见) + // add at least a 5ms time window to avoid mtime resolution (Windows NTFS 100ns would do, just to be safe) await new Promise<void>((r) => setTimeout(r, 5)); const r = await poller.tick(); expect(r.errors).toBe(1); @@ -424,7 +425,7 @@ describe('Poller.tick', () => { expect(r.changed).toBe(0); expect(r.added).toBe(0); const mtimeAfter = (await fs.stat(indexPath)).mtimeMs; - expect(mtimeAfter).toBe(mtimeBefore); // 文件没被重写 + expect(mtimeAfter).toBe(mtimeBefore); // file was not rewritten expect(await listStoredPullRequests(store)).toEqual(storedBefore); }); @@ -443,7 +444,7 @@ describe('Poller.tick', () => { await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(2); - // 下一次 poll 失败(网络抖动 / 远端 5xx)→ 本地状态库不动 + // next poll fails (network jitter / remote 5xx) → local state store untouched adapter.failNextList(); const r = await poller.tick(); expect(r.errors).toBe(1); @@ -467,19 +468,19 @@ describe('Poller.tick', () => { await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(2); - // ok 连接成功但其 PR 远端关单;broken 连接 fail + // ok connection succeeds but its PR is closed remotely; broken connection fails ok.setPrs([]); broken.failNextList(); const r = await poller.tick(); - expect(r.removed).toBe(1); // 只剪了 ok 的 + expect(r.removed).toBe(1); // only ok's was pruned expect(r.errors).toBe(1); const stored = await listStoredPullRequests(store); expect(stored).toHaveLength(1); expect(stored[0]!.connectionId).toBe('broken'); }); - // localStatus 直接镜像 Bitbucket reviewer.status,是远端权威态的本地缓存。 - // hasConflict 不影响 localStatus(仅作为独立维度,UI 通过 hasConflict 单独筛选)。 + // localStatus directly mirrors Bitbucket reviewer.status; it is a local cache of the remote authoritative state. + // hasConflict does not affect localStatus (it is only an independent dimension; the UI filters by hasConflict separately). it('preserves hasConflict=true on new PR without changing localStatus', async () => { const pr = makePr('1', '2026-05-28T01:00:00.000Z'); @@ -548,14 +549,14 @@ describe('Poller.tick', () => { await poller.tick(); expect((await listStoredPullRequests(store))[0]!.localStatus).toBe('pending'); - // Bitbucket 上 kyle 点了 approve + // kyle clicked approve on Bitbucket adapter.setPrs([ { ...pr, reviewers: [{ name: 'kyle', displayName: 'Kyle', status: 'approved' as const }] }, ]); await poller.tick(); expect((await listStoredPullRequests(store))[0]!.localStatus).toBe('approved'); - // Bitbucket 上 kyle 撤销,回到 pending + // kyle revoked on Bitbucket, back to pending adapter.setPrs([ { ...pr, reviewers: [{ name: 'kyle', displayName: 'Kyle', status: 'unapproved' as const }] }, ]); @@ -576,7 +577,7 @@ describe('Poller.tick', () => { const file = await store.read<PrIndexFile>(PR_INDEX_KEY); expect(file?.schema_version).toBe(1); expect(Object.keys(file!.prs)).toHaveLength(1); - // 每个 PR 的 meta.json 落在 prs/<hash>/meta.json + // each PR's meta.json lands at prs/<hash>/meta.json const hash = Object.keys(file!.prs)[0]!; const meta = await store.read<{ schema_version: 1; pr: { localId: string } }>( `prs/${hash}/meta`, @@ -619,19 +620,19 @@ describe('Poller.tick', () => { expect(await listStoredPullRequests(store)).toHaveLength(2); const goneHash = (await listStoredPullRequests(store)).find((p) => p.remoteId === '2')!.localId; - // PR #2 关单 → soft archive (archivedAt set in index),list 自动过滤掉 + // PR #2 closed → soft archive (archivedAt set in index), list filters it out automatically adapter.setPrs([makePr('1', '2026-05-28T01:00:00.000Z')]); await poller.tick(); const visible = await listStoredPullRequests(store); expect(visible).toHaveLength(1); expect(visible[0]!.remoteId).toBe('1'); - // 索引条目仍在 (待 grace 期满才硬删);数据已从活跃存储搬入归档冷存储 + // index entry still present (hard-deleted only after grace period expires); data already moved from active storage into archive cold storage const index = await store.read<PrIndexFile>(PR_INDEX_KEY); const archivedEntries = Object.values(index!.prs).filter((e) => e.archivedAt); expect(archivedEntries).toHaveLength(1); - expect(await store.read(`prs/${goneHash}/meta`)).toBeNull(); // 活跃存储已搬空 - expect(await archiveStore.read(`prs/${goneHash}/meta`)).not.toBeNull(); // 落到归档存储 + expect(await store.read(`prs/${goneHash}/meta`)).toBeNull(); // active storage already emptied + expect(await archiveStore.read(`prs/${goneHash}/meta`)).not.toBeNull(); // landed in archive storage }); it('archived PR re-appearing on remote becomes active again', async () => { @@ -646,21 +647,21 @@ describe('Poller.tick', () => { await poller.tick(); const hash = (await listStoredPullRequests(store))[0]!.localId; - // 远端关单 → soft archive:数据搬入归档存储 + // closed remotely → soft archive: data moved into archive storage adapter.setPrs([]); await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(0); expect(await archiveStore.read(`prs/${hash}/meta`)).not.toBeNull(); - // 复活:远端又出现 (例如 reviewer 被重新加回) → archivedAt 清零、整树搬回活跃存储 + // revival: reappears remotely (e.g. reviewer re-added) → archivedAt cleared, whole tree moved back to active storage adapter.setPrs([makePr('1', '2026-05-28T01:00:00.000Z')]); await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(1); - expect(await store.read(`prs/${hash}/meta`)).not.toBeNull(); // 搬回活跃存储 - expect(await archiveStore.read(`prs/${hash}/meta`)).toBeNull(); // 归档存储已腾空 + expect(await store.read(`prs/${hash}/meta`)).not.toBeNull(); // moved back to active storage + expect(await archiveStore.read(`prs/${hash}/meta`)).toBeNull(); // archive storage now vacated }); - it('外部删除 prs/index.json: 下一轮 poll 自动重建', async () => { + it('external deletion of prs/index.json: next poll rebuilds automatically', async () => { const adapter = new FakeAdapter([makePr('1', '2026-05-28T01:00:00.000Z')]); const poller = new Poller({ connections: [{ connectionId: 'bb1', adapter }], @@ -672,16 +673,16 @@ describe('Poller.tick', () => { await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(1); - // 模拟外部 (用户 / 清理工具) 直接 rm 掉索引文件 + // simulate an external party (user / cleanup tool) directly rm-ing the index file await fs.rm(path.join(tmpDir, 'prs', 'index.json')); expect(await listStoredPullRequests(store)).toHaveLength(0); - // 下一轮 poll 重建索引 + // next poll rebuilds the index await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(1); }); - it('外部删除 meta.json 但索引尚存:list 跳过;下一轮 poll 重写 meta', async () => { + it('external deletion of meta.json but index still present: list skips; next poll rewrites meta', async () => { const adapter = new FakeAdapter([makePr('1', '2026-05-28T01:00:00.000Z')]); const poller = new Poller({ connections: [{ connectionId: 'bb1', adapter }], @@ -694,11 +695,11 @@ describe('Poller.tick', () => { const hash = (await listStoredPullRequests(store))[0]!.localId; const metaPath = path.join(tmpDir, 'prs', hash, 'meta.json'); - // 外部清掉 meta;索引 entry 仍在 + // external party clears meta; the index entry remains await fs.rm(metaPath); - expect(await listStoredPullRequests(store)).toHaveLength(0); // list 跳过 + expect(await listStoredPullRequests(store)).toHaveLength(0); // list skips - // 下一轮 poll:PR 还在远端 → 写回 meta + // next poll: the PR is still on remote → writes meta back await poller.tick(); expect(await listStoredPullRequests(store)).toHaveLength(1); await expect(fs.access(metaPath)).resolves.toBeUndefined(); @@ -718,12 +719,12 @@ describe('Poller.tick', () => { await poller.tick(); const hash = (await listStoredPullRequests(store))[0]!.localId; - // T+0: 关单 → soft archive:数据搬入归档存储(仍在 grace 期内保留) + // T+0: closed → soft archive: data moved into archive storage (still retained within grace period) adapter.setPrs([]); await poller.tick(); expect(await archiveStore.read(`prs/${hash}/meta`)).not.toBeNull(); - // T+8 天: 超过 1 周 grace → 硬清掉整目录(归档存储 + 活跃存储两端都清) + // T+8 days: past the 1-week grace → hard-purge the whole directory (cleared on both archive storage + active storage) now = new Date('2026-06-09T00:00:00.000Z'); await poller.tick(); expect(await archiveStore.read(`prs/${hash}/meta`)).toBeNull(); @@ -732,7 +733,7 @@ describe('Poller.tick', () => { expect(Object.keys(index!.prs)).toHaveLength(0); }); - it('对账:把滞留活跃存储的归档数据搬入归档存储(旧布局 / split-brain 最终一致)', async () => { + it('reconciliation: move archived data stuck in active storage into archive storage (old layout / split-brain eventual consistency)', async () => { const adapter = new FakeAdapter([makePr('1', '2026-05-28T01:00:00.000Z')]); const now = new Date('2026-06-01T00:00:00.000Z'); const poller = new Poller({ @@ -746,19 +747,19 @@ describe('Poller.tick', () => { await poller.tick(); const hash = (await listStoredPullRequests(store))[0]!.localId; - // 模拟旧布局存量:手工把索引条目标 archived,但数据**仍留在活跃存储**(未搬迁) + // simulate old-layout leftovers: manually mark the index entry archived, but the data **still stays in active storage** (not migrated) const index = await store.read<PrIndexFile>(PR_INDEX_KEY); index!.prs[hash]!.archivedAt = now.toISOString(); await store.write(PR_INDEX_KEY, index!); expect(await store.read(`prs/${hash}/meta`)).not.toBeNull(); expect(await archiveStore.read(`prs/${hash}/meta`)).toBeNull(); - // 远端仍无该 PR → 下一轮 poll 的对账步(未到 grace、不清)把整树搬入归档存储 + // the PR still absent from remote → the reconciliation step of the next poll (before grace, no purge) moves the whole tree into archive storage adapter.setPrs([]); await poller.tick(); - expect(await store.read(`prs/${hash}/meta`)).toBeNull(); // 搬出活跃存储 - expect(await archiveStore.read(`prs/${hash}/meta`)).not.toBeNull(); // 落到归档存储 - // 仍在索引、仍 archived(对账只搬数据、不动索引) + expect(await store.read(`prs/${hash}/meta`)).toBeNull(); // moved out of active storage + expect(await archiveStore.read(`prs/${hash}/meta`)).not.toBeNull(); // landed in archive storage + // still in the index, still archived (reconciliation only moves data, does not touch the index) const after = await store.read<PrIndexFile>(PR_INDEX_KEY); expect(after!.prs[hash]!.archivedAt).toBe(now.toISOString()); }); @@ -802,7 +803,7 @@ describe('setLocalStatus', () => { }); describe('Poller.archiveConnectionsExcept', () => { - it('归档非活动连接的 PR、保留活动连接(进入 purge 路径)', async () => { + it('archives PRs of inactive connections, keeps the active connection (enters the purge path)', async () => { const a1 = new FakeAdapter([makePr('1', '2026-05-28T01:00:00.000Z')]); const a2 = new FakeAdapter([makePr('2', '2026-05-28T01:00:00.000Z')]); const now = new Date('2026-06-01T00:00:00.000Z'); @@ -817,25 +818,25 @@ describe('Poller.archiveConnectionsExcept', () => { logger: noopLogger, now: () => now, }); - await poller.tick(); // 两个连接的 PR 都入库,archivedAt=null + await poller.tick(); // PRs from both connections are stored, archivedAt=null - // 用户切换:只剩 bb1 活动 + // user switches: only bb1 remains active await poller.archiveConnectionsExcept(['bb1']); const index = await store.read<PrIndexFile>(PR_INDEX_KEY); const entries = Object.values(index!.prs); const bb1 = entries.find((e) => e.identity.connectionId === 'bb1')!; const bb2 = entries.find((e) => e.identity.connectionId === 'bb2')!; - expect(bb1.archivedAt).toBeNull(); // 活动连接不动 - expect(bb2.archivedAt).toBe(now.toISOString()); // 非活动连接被归档 - // bb1 数据留在活跃存储;bb2 数据搬入归档存储 + expect(bb1.archivedAt).toBeNull(); // active connection untouched + expect(bb2.archivedAt).toBe(now.toISOString()); // inactive connection archived + // bb1 data stays in active storage; bb2 data moved into archive storage const bb1Hash = Object.entries(index!.prs).find(([, e]) => e === bb1)![0]; const bb2Hash = Object.entries(index!.prs).find(([, e]) => e === bb2)![0]; expect(await store.read(`prs/${bb1Hash}/meta`)).not.toBeNull(); expect(await store.read(`prs/${bb2Hash}/meta`)).toBeNull(); expect(await archiveStore.read(`prs/${bb2Hash}/meta`)).not.toBeNull(); - // 幂等:再调一次不改已归档的时间戳 + // idempotent: calling again does not change the already-archived timestamp const later = new Date('2026-06-02T00:00:00.000Z'); const poller2 = new Poller({ connections: [{ connectionId: 'bb1', adapter: a1 }], @@ -848,7 +849,7 @@ describe('Poller.archiveConnectionsExcept', () => { await poller2.archiveConnectionsExcept(['bb1']); const index2 = await store.read<PrIndexFile>(PR_INDEX_KEY); const bb2After = Object.values(index2!.prs).find((e) => e.identity.connectionId === 'bb2')!; - expect(bb2After.archivedAt).toBe(now.toISOString()); // 仍是首次归档时间 + expect(bb2After.archivedAt).toBe(now.toISOString()); // still the first archive time }); }); @@ -872,11 +873,11 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - // 首轮:建基线,不产出任何事件(避免首启涌入风暴)。 + // first round: build the baseline, produce no events (avoids a first-launch flood). await poller.tick(); expect(events).toEqual([]); - // 次轮:PR1 内容变更 + 一条 @alice 的新评论;同时来一个全新 PR2。 + // second round: PR1 content changed + one new @alice comment; a brand-new PR2 also arrives. now = new Date('2026-06-02T00:00:00.000Z'); adapter.setPrs([ makePr('1', '2026-05-29T01:00:00.000Z'), @@ -894,21 +895,21 @@ describe('Poller onNotify (system notification projection)', () => { const kinds = events.map((e) => ({ kind: e.kind, remoteId: e.remoteId, count: e.count })); expect(kinds).toContainEqual({ kind: 'new_pr', remoteId: '2', count: undefined }); expect(kinds).toContainEqual({ kind: 'mention', remoteId: '1', count: 1 }); - // 仅这两条(PR2 是新发现、其自身评论不投影 mention;PR1 仅 mention 一条)。 + // only these two (PR2 is a new discovery, its own comments do not project a mention; PR1 has just one mention). expect(events).toHaveLength(2); - // 富字段:仓库 + 连接 + 发起人(new_pr=PR 作者;mention=评论作者)一并投影。 + // rich fields: repo + connection + actor (new_pr=PR author; mention=comment author) all projected together. const newPr = events.find((e) => e.kind === 'new_pr')!; expect(newPr.repo).toEqual({ projectKey: 'P', repoSlug: 'r' }); expect(newPr.connectionId).toBe('bb1'); - expect(newPr.actor.name).toBe('u'); // makePr 的作者 + expect(newPr.actor.name).toBe('u'); // makePr's author const mention = events.find((e) => e.kind === 'mention')!; - expect(mention.actor.name).toBe('bob'); // 评论作者 - expect(mention.comment?.anchor).toBeNull(); // summary 评论 → 点击打开活动标签 + expect(mention.actor.name).toBe('bob'); // comment author + expect(mention.comment?.anchor).toBeNull(); // summary comment → click opens the activity tab }); it('suppresses notifications for non-pending PRs (already approved / needs_work)', async () => { - // PR1 当前用户已 approve → localStatus 非 pending;即便有新 @ 评论也不弹通知。 + // PR1 already approved by the current user → localStatus is not pending; even a new @ comment does not pop a notification. const approved = makePr('1', '2026-05-28T01:00:00.000Z'); approved.reviewers = [{ name: 'alice', displayName: 'Alice', status: 'approved' as const }]; const adapter = new FakeAdapter([approved]); @@ -925,7 +926,7 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线 + await poller.tick(); // baseline now = new Date('2026-06-02T00:00:00.000Z'); const changed = makePr('1', '2026-05-29T01:00:00.000Z'); changed.reviewers = [{ name: 'alice', displayName: 'Alice', status: 'approved' as const }]; @@ -938,14 +939,14 @@ describe('Poller onNotify (system notification projection)', () => { }), ]); await poller.tick(); - expect(events).toEqual([]); // 非 pending → 不投影 + expect(events).toEqual([]); // not pending → not projected }); it('coarse-signal platform (Bitbucket) catches a reply with no updatedAt / commentCount change', async () => { - // 核心修复:Bitbucket 回复既不顶 updatedDate、也不计入顶层 commentCount → 唯有对待处理 PR 每轮兜底扫才不漏。 + // core fix: a Bitbucket reply neither bumps updatedDate nor counts toward the top-level commentCount → only a fallback scan of pending PRs every round avoids missing it. const pr1 = makePr('1', '2026-05-28T01:00:00.000Z'); - pr1.commentCount = 5; // 顶层评论数;新增回复不会改变它 - const adapter = new FakeAdapter([pr1]); // FakeAdapter 默认 commentCountIncludesReplies=false(粗信号) + pr1.commentCount = 5; // top-level comment count; adding a reply does not change it + const adapter = new FakeAdapter([pr1]); // FakeAdapter defaults to commentCountIncludesReplies=false (coarse signal) adapter.seedUser('alice'); const events: PollNotificationEvent[] = []; let now = new Date('2026-06-01T00:00:00.000Z'); @@ -959,15 +960,15 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线 + await poller.tick(); // baseline expect(events).toEqual([]); - // 次轮:updatedAt 与 commentCount 都不变,仅在 alice 的评论下新增一条 bob 的回复。 + // second round: updatedAt and commentCount both unchanged, only a bob reply added under alice's comment. now = new Date('2026-06-02T00:00:00.000Z'); - adapter.setPrs([pr1]); // 同一 PR,updatedAt / commentCount 原样 + adapter.setPrs([pr1]); // same PR, updatedAt / commentCount as-is adapter.seedComments([ makeComment({ - author: { name: 'alice', displayName: 'Alice' }, // 我的顶层评论 + author: { name: 'alice', displayName: 'Alice' }, // my top-level comment body: 'my comment', createdAt: '2026-05-28T02:00:00.000Z', replies: [ @@ -991,7 +992,7 @@ describe('Poller onNotify (system notification projection)', () => { const pr1 = makePr('1', '2026-05-28T01:00:00.000Z'); pr1.commentCount = 0; const adapter = new FakeAdapter([pr1]); - adapter.setReplyAware(true); // 模拟 GitHub/GitLab:含回复的计数信号 + adapter.setReplyAware(true); // simulate GitHub/GitLab: reply-inclusive count signal adapter.seedUser('alice'); const events: PollNotificationEvent[] = []; let now = new Date('2026-06-01T00:00:00.000Z'); @@ -1005,10 +1006,10 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线:reliable 平台基线轮不扫 + await poller.tick(); // baseline: reliable platform does not scan on the baseline round expect(adapter.commentCalls).toBe(0); - // 次轮:commentCount 0→1(含回复信号变化)→ 扫一次并投影 mention(updatedAt 故意不变,证明靠计数触发)。 + // second round: commentCount 0→1 (reply-inclusive signal changes) → scan once and project a mention (updatedAt intentionally unchanged, proving it triggers on the count). now = new Date('2026-06-02T00:00:00.000Z'); const pr1b = makePr('1', '2026-05-28T01:00:00.000Z'); pr1b.commentCount = 1; @@ -1021,18 +1022,18 @@ describe('Poller onNotify (system notification projection)', () => { }), ]); await poller.tick(); - expect(adapter.commentCalls).toBe(1); // 计数变化 → 扫了一次 + expect(adapter.commentCalls).toBe(1); // count changed → scanned once expect(events.some((e) => e.kind === 'mention')).toBe(true); - // 第三轮:updatedAt 与 commentCount 都不变 → 不再扫,无新事件。 + // third round: updatedAt and commentCount both unchanged → no more scans, no new events. now = new Date('2026-06-03T00:00:00.000Z'); const pr1c = makePr('1', '2026-05-28T01:00:00.000Z'); pr1c.commentCount = 1; adapter.setPrs([pr1c]); const eventsLen = events.length; await poller.tick(); - expect(adapter.commentCalls).toBe(1); // 未变化 → 未扫 - expect(events.length).toBe(eventsLen); // 无新事件 + expect(adapter.commentCalls).toBe(1); // unchanged → not scanned + expect(events.length).toBe(eventsLen); // no new events }); it('authored PR: fires authored_needs_work when a reviewer newly marks needs-work', async () => { @@ -1050,7 +1051,7 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线:无 needsWork 评审人 + await poller.tick(); // baseline: no needsWork reviewer expect(events).toEqual([]); now = new Date('2026-06-02T00:00:00.000Z'); @@ -1062,7 +1063,7 @@ describe('Poller onNotify (system notification projection)', () => { const e = events.find((x) => x.kind === 'authored_needs_work'); expect(e).toBeDefined(); expect(e!.remoteId).toBe('1'); - expect(e!.actor.name).toBe('bob'); // 标记需修改的评审人 + expect(e!.actor.name).toBe('bob'); // the reviewer who marked needs-work }); it('authored PR: fires authored_conflict on a false→true merge-conflict transition', async () => { @@ -1080,7 +1081,7 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线:无冲突 + await poller.tick(); // baseline: no conflict expect(events).toEqual([]); now = new Date('2026-06-02T00:00:00.000Z'); @@ -1110,9 +1111,9 @@ describe('Poller onNotify (system notification projection)', () => { onNotify: (e) => events.push(...e), }); - await poller.tick(); // 基线:首轮不 notifiable、不扫评论 + await poller.tick(); // baseline: first round is not notifiable, does not scan comments - // 第二轮:出现一条他人评论 → 仅播种游标、不补发历史评论。 + // second round: a comment from someone else appears → only seed the cursor, do not backfill historical comments. now = new Date('2026-06-02T00:00:00.000Z'); adapter.setPrs([makeAuthoredPr('1', '2026-05-29T01:00:00.000Z')]); adapter.seedComments([ @@ -1125,7 +1126,7 @@ describe('Poller onNotify (system notification projection)', () => { await poller.tick(); expect(events.some((e) => e.kind === 'authored_comment')).toBe(false); - // 第三轮:又来一条更晚的他人评论(晚于游标)→ 触发 authored_comment。 + // third round: another later comment from someone else (after the cursor) → triggers authored_comment. now = new Date('2026-06-03T00:00:00.000Z'); adapter.setPrs([makeAuthoredPr('1', '2026-05-30T01:00:00.000Z')]); adapter.seedComments([ diff --git a/packages/poller/tests/runs.test.ts b/packages/poller/tests/runs.test.ts index 7dcc2ab0..6be09404 100644 --- a/packages/poller/tests/runs.test.ts +++ b/packages/poller/tests/runs.test.ts @@ -25,12 +25,12 @@ afterEach(async () => { }); describe('makeRunId', () => { - it('格式 yyyymmdd-HHmmss-mmm', () => { + it('format yyyymmdd-HHmmss-mmm', () => { const id = makeRunId(new Date('2026-05-29T10:20:30.045Z')); - // 转换到本地时区会影响 HH,但是字符长度和分隔符稳定 + // converting to local timezone affects HH, but character length and separators stay stable expect(id).toMatch(/^\d{8}-\d{6}-\d{3}$/); }); - it('字典序 = 时间序,便于列名倒排出最新', () => { + it('lexical order = time order, so reverse-sorting names surfaces the latest', () => { const a = makeRunId(new Date('2026-05-29T10:00:00.000Z')); const b = makeRunId(new Date('2026-05-29T11:00:00.000Z')); expect(a < b).toBe(true); @@ -38,7 +38,7 @@ describe('makeRunId', () => { }); describe('startReviewRun', () => { - it('落地 running 状态 + 必备字段', async () => { + it('persists running status + required fields', async () => { const now = new Date('2026-05-29T10:00:00.000Z'); const run = await startReviewRun( store, @@ -57,7 +57,7 @@ describe('startReviewRun', () => { expect(run.strategy).toBe('local-cli'); expect(run.finishedAt).toBeUndefined(); - // 文件确实落到 prs/<localId>/runs/<runId>.json + // file actually lands at prs/<localId>/runs/<runId>.json const fp = path.join(tmpRoot, 'prs', 'abc123def456', 'runs', `${run.id}.json`); const txt = await fs.readFile(fp, 'utf8'); expect(JSON.parse(txt).run.id).toBe(run.id); @@ -65,7 +65,7 @@ describe('startReviewRun', () => { }); describe('finishReviewRun', () => { - it('merge patch 到已有 run,保留 startedAt 等字段', async () => { + it('merge patches into an existing run, preserving startedAt and other fields', async () => { const start = new Date('2026-05-29T10:00:00.000Z'); const run = await startReviewRun( store, @@ -90,7 +90,7 @@ describe('finishReviewRun', () => { expect(finished?.stdout).toContain('Review'); }); - it('文件不存在返回 null(不会静默重建)', async () => { + it('returns null when the file does not exist (no silent recreate)', async () => { const r = await finishReviewRun(store, 'abc123def456', 'nonexistent', { status: 'succeeded', finishedAt: 'x', @@ -99,7 +99,7 @@ describe('finishReviewRun', () => { expect(r).toBeNull(); }); - it('失败原因 + exitCode + stderr 都能写入', async () => { + it('writes error reason + exitCode + stderr', async () => { const run = await startReviewRun(store, { prLocalId: 'abc123def456', tool: 'review', @@ -121,7 +121,7 @@ describe('finishReviewRun', () => { }); describe('getReviewRun', () => { - it('找不到时返回 null', async () => { + it('returns null when not found', async () => { const r = await getReviewRun(store, 'abc123def456', 'nope'); expect(r).toBeNull(); }); @@ -132,22 +132,22 @@ describe('hasReviewOutput', () => { const startWith = (tool: 'describe' | 'review' | 'ask', at: Date) => startReviewRun(store, { prLocalId: pr, tool, prAgentVersion: 'v', strategy: 'embedded' }, () => at); - it('无 run → false', async () => { + it('no run → false', async () => { expect(await hasReviewOutput(store, pr)).toBe(false); }); - it('describe / review 成功 → true', async () => { + it('describe / review succeeded → true', async () => { const r = await startWith('describe', new Date('2026-05-29T10:00:00.000Z')); await finishReviewRun(store, pr, r.id, { status: 'succeeded', finishedAt: 'x', durationMs: 1 }); expect(await hasReviewOutput(store, pr)).toBe(true); }); - it('review 正在跑(running)→ true', async () => { + it('review in progress (running) → true', async () => { await startWith('review', new Date('2026-05-29T10:00:00.000Z')); expect(await hasReviewOutput(store, pr)).toBe(true); }); - it('describe/review 失败 / 取消不算,仍可触发 → false', async () => { + it('describe/review failed / cancelled does not count, still triggerable → false', async () => { const a = await startWith('describe', new Date('2026-05-29T10:00:00.000Z')); await finishReviewRun(store, pr, a.id, { status: 'failed', finishedAt: 'x', durationMs: 1 }); const b = await startWith('review', new Date('2026-05-29T10:01:00.000Z')); @@ -155,7 +155,7 @@ describe('hasReviewOutput', () => { expect(await hasReviewOutput(store, pr)).toBe(false); }); - it('仅 /ask 成功不算「已评审」→ false', async () => { + it('/ask succeeding alone does not count as "reviewed" → false', async () => { const r = await startWith('ask', new Date('2026-05-29T10:00:00.000Z')); await finishReviewRun(store, pr, r.id, { status: 'succeeded', finishedAt: 'x', durationMs: 1 }); expect(await hasReviewOutput(store, pr)).toBe(false); @@ -163,7 +163,7 @@ describe('hasReviewOutput', () => { }); describe('listReviewRunsForPr', () => { - it('newest first,跨 PR 不串扰', async () => { + it('newest first, no cross-PR bleed', async () => { const oldA = await startReviewRun( store, { prLocalId: 'abc123def456', tool: 'review', prAgentVersion: 'v', strategy: 'embedded' }, @@ -174,7 +174,7 @@ describe('listReviewRunsForPr', () => { { prLocalId: 'abc123def456', tool: 'describe', prAgentVersion: 'v', strategy: 'embedded' }, () => new Date('2026-05-29T11:00:00.000Z'), ); - // 另一个 PR 的 run 不应出现在 42 的列表里 + // another PR's run should not appear in 42's list await startReviewRun( store, { prLocalId: 'def789abc012', tool: 'review', prAgentVersion: 'v', strategy: 'embedded' }, @@ -185,7 +185,7 @@ describe('listReviewRunsForPr', () => { expect(list.map((r) => r.tool)).toEqual(['describe', 'review']); }); - it('无 run 时返回空数组', async () => { + it('returns an empty array when there is no run', async () => { const list = await listReviewRunsForPr(store, 'abc123def456'); expect(list).toEqual([]); }); diff --git a/packages/pr-agent-bridge/src/bridge.ts b/packages/pr-agent-bridge/src/bridge.ts index df4bc78a..a6d7a0d9 100644 --- a/packages/pr-agent-bridge/src/bridge.ts +++ b/packages/pr-agent-bridge/src/bridge.ts @@ -10,7 +10,7 @@ import type { PrAgentRunResult, } from './types.js'; -/** 各策略共享的骨架:把 RunOptions 翻成 (cmd, args, env) 后委派给 ExecFn */ +/** Skeleton shared by all strategies: translate RunOptions into (cmd, args, env) then delegate to ExecFn */ abstract class BaseBridge implements PrAgentBridge { abstract readonly strategy: PrAgentStrategy; @@ -62,7 +62,7 @@ abstract class BaseBridge implements PrAgentBridge { cwd?: string; }; - /** chat 通道的 (cmd, args, env, cwd);仅嵌入式支持,其余策略抛错。 */ + /** (cmd, args, env, cwd) for the chat channel; only the embedded strategy supports it, others throw. */ protected abstract buildChatInvocation(opts: ChatRunOptions): { cmd: string; args: string[]; @@ -72,11 +72,11 @@ abstract class BaseBridge implements PrAgentBridge { } /** - * 走系统 PATH 的 pr-agent CLI(pipx / pip / brew 安装)。 + * pr-agent CLI on the system PATH (installed via pipx / pip / brew). * - * 远端模式 (opts.cwd 未配置):`pr-agent --pr_url <url> <tool>` - * 本地模式 (opts.cwd 已配置):子进程 cwd 落到 worktree;env 注入 - * `CONFIG__GIT_PROVIDER=local`,命令变为 + * Remote mode (opts.cwd not set): `pr-agent --pr_url <url> <tool>` + * Local mode (opts.cwd set): subprocess cwd points at the worktree; env injects + * `CONFIG__GIT_PROVIDER=local`, the command becomes * `pr-agent --pr_url <cwd> [--target_branch <base>] <tool>` */ export class LocalCliBridge extends BaseBridge { @@ -89,13 +89,13 @@ export class LocalCliBridge extends BaseBridge { cwd?: string; } { if (opts.cwd) { - // 反直觉但是 pr-agent 社区版 LocalGitProvider 的真实行为: - // get_git_provider_with_context 把 --pr_url 的值作为第一个位置参数传给 - // LocalGitProvider(target_branch_name),**--pr_url 在 local 模式下就是 - // target branch 的名字**,不是 PR URL 或路径。仓库根靠容器 cwd 自己走 .git - // 父目录查找定位,跟 --pr_url 无关。 - // 所以这里把 opts.targetBranch (= materializeWorktree 建好的 pr-<localId>/base) - // 直接填到 --pr_url 槽位。 + // Counterintuitive but the real behavior of the pr-agent community edition LocalGitProvider: + // get_git_provider_with_context passes the --pr_url value as the first positional argument to + // LocalGitProvider(target_branch_name), so **in local mode --pr_url is the name of the + // target branch**, not a PR URL or path. The repo root is located by the container cwd itself + // walking up parent directories looking for .git, unrelated to --pr_url. + // So here we put opts.targetBranch (= the pr-<localId>/base built by materializeWorktree) + // directly into the --pr_url slot. return { cmd: 'pr-agent', args: [ @@ -129,13 +129,14 @@ export class LocalCliBridge extends BaseBridge { } /** - * 走随 app 打包的嵌入式 Python 运行时:用 `<vendor>/python -m - * pr_agent.cli` 跑 pr-agent,免除用户预装 Python / Docker。 + * Uses the embedded Python runtime bundled with the app: runs pr-agent via + * `<vendor>/python -m pr_agent.cli`, sparing the user from pre-installing Python / Docker. * - * 形态与 LocalCli 的 local 模式一致(local provider,cwd=worktree, - * CONFIG__GIT_PROVIDER=local),区别仅在 cmd 指向嵌入式解释器绝对路径 + - * `-m pr_agent.cli`。嵌入式运行时只用于本地 worktree,所以 cwd 恒被设置; - * 万一未设也兜底走远端 `--pr_url <prUrl>`(与 LocalCli 对齐)。 + * Shape is identical to LocalCli's local mode (local provider, cwd=worktree, + * CONFIG__GIT_PROVIDER=local); the only difference is cmd points at the embedded + * interpreter's absolute path + `-m pr_agent.cli`. The embedded runtime is only used for + * local worktrees, so cwd is always set; should it be unset, it falls back to remote + * `--pr_url <prUrl>` (aligned with LocalCli). */ export class EmbeddedRuntimeBridge extends BaseBridge { readonly strategy = 'embedded' as const; @@ -155,7 +156,7 @@ export class EmbeddedRuntimeBridge extends BaseBridge { cwd?: string; } { const cli = ['-m', 'pr_agent.cli']; - // 强制 UTF-8(UTF8_ENV):嵌入式 Python 在中文 Windows 上默认系统码页会让含 emoji 的输出崩,见 constants。 + // Force UTF-8 (UTF8_ENV): on Chinese Windows the embedded Python defaults to the system code page, which crashes output containing emoji, see constants. if (opts.cwd) { return { cmd: this.pythonPath, @@ -177,9 +178,10 @@ export class EmbeddedRuntimeBridge extends BaseBridge { env?: Record<string, string>; cwd?: string; } { - // 跑随运行时打包的 chat helper:API 模式复用 pr-agent 已被 shim 补丁的 LiteLLMAIHandler - // (provider 路由 / 去 temperature / 提示缓存 / usage 哨兵全继承);CLI 模式(MEEBOX_CLI_MODE) - // 在 helper 内直接调本机 CLI、不 import pr_agent / litellm,省每次启动的 import 开销。 + // Runs the chat helper bundled with the runtime: in API mode it reuses pr-agent's shim-patched + // LiteLLMAIHandler (inheriting provider routing / temperature removal / prompt cache / usage sentinel); + // in CLI mode (MEEBOX_CLI_MODE) the helper calls the local CLI directly, without importing + // pr_agent / litellm, saving the import cost on every startup. return { cmd: this.pythonPath, args: ['-m', 'meebox_pragent_shim.chat'], diff --git a/packages/pr-agent-bridge/src/constants.ts b/packages/pr-agent-bridge/src/constants.ts index 0ce44b28..35e2bb19 100644 --- a/packages/pr-agent-bridge/src/constants.ts +++ b/packages/pr-agent-bridge/src/constants.ts @@ -1,33 +1,33 @@ import type { ReviewRunTool } from '@meebox/shared'; /** - * 包内共享常量统一收口:子进程超时兜底、强制 UTF-8 的 spawn env、pr-agent local provider 产出文件名。 - * 散落各处的同类常量集中于此,便于统一复用与调参。 + * Single collection point for constants shared within the package: subprocess timeout fallbacks, the force-UTF-8 spawn env, pr-agent local provider output file names. + * Same-kind constants scattered elsewhere are consolidated here for uniform reuse and tuning. */ -// /review 在长 PR + 推理型模型 (DeepSeek-v4 / Claude thinking) 下常跑 3-8 min;5 min 经常打 timeout。 -// 设到 10 min 让绝大多数真实 PR 能跑完,仍能兜住卡死的子进程不让它无限挂着。需要更长的话调用方可在 -// opts.timeoutMs 显式覆盖。 +// /review on a long PR + reasoning model (DeepSeek-v4 / Claude thinking) often runs 3-8 min; 5 min frequently hits timeout. +// Set to 10 min so the vast majority of real PRs finish, while still catching a stuck subprocess so it doesn't hang forever. If more is needed the caller can +// override explicitly via opts.timeoutMs. export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -// chat 通道单次默认 5 min:编排 / 判定调用通常远快于 /review,但推理型模型仍可能慢。 +// chat channel per-call default 5 min: orchestration / judgment calls are usually far faster than /review, but reasoning models can still be slow. export const DEFAULT_CHAT_TIMEOUT_MS = 5 * 60 * 1000; -// 运行时探测(spawn `--version` 量级)默认 5s 超时兜底:足够且不拖慢启动。 +// Runtime detect (spawn `--version` scale) default 5s timeout fallback: enough and doesn't slow startup. export const DEFAULT_PROBE_TIMEOUT_MS = 5000; /** - * 强制 UTF-8 的 spawn env:嵌入式 Python 在中文 Windows 上默认用系统码页 (GBK/cp936) 做 stdio / 文件 - * 编码,pr-agent 输出含 emoji (如 🔍 section 标题) 时会 'gbk' codec can't encode 崩掉。PYTHONUTF8=1 - * 覆盖 stdio + fs + 默认 open() 编码,PYTHONIOENCODING 兜底。所有 spawn 的 python 子进程统一带上。 + * Force-UTF-8 spawn env: on Chinese Windows the embedded Python defaults to the system code page (GBK/cp936) for stdio / file + * encoding, so when pr-agent output contains emoji (e.g. the 🔍 section title) it crashes with 'gbk' codec can't encode. PYTHONUTF8=1 + * overrides stdio + fs + default open() encoding, with PYTHONIOENCODING as fallback. Applied uniformly to every spawned python subprocess. */ export const UTF8_ENV = { PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8' } as const; /** - * pr-agent local provider 各 tool 的产出落盘文件名(worktree 根的相对路径): - * /describe → description.md(publish_description) - * /review → review.md (publish_comment) - * /ask → review.md (共用同一文件,publish_comment 覆盖) - * /improve → improve.md (汇总建议走 publish_comment,经 LOCAL__REVIEW_PATH 重定向与 review.md 分流) - * 既供 buildToolEnv 设 LOCAL__REVIEW_PATH,也供调用方 run 结束后读取产出文件——两处共用此表保持同步。 + * The on-disk output file name for each pr-agent local provider tool (path relative to the worktree root): + * /describe → description.md (publish_description) + * /review → review.md (publish_comment) + * /ask → review.md (shares the same file, publish_comment overwrites) + * /improve → improve.md (aggregated suggestions go through publish_comment, split from review.md via LOCAL__REVIEW_PATH redirect) + * Used both by buildToolEnv to set LOCAL__REVIEW_PATH and by the caller to read the output file after run finishes — both sides share this table to stay in sync. */ export const PRAGENT_LOCAL_OUTPUT: Record<ReviewRunTool, string> = { describe: 'description.md', diff --git a/packages/pr-agent-bridge/src/detect.ts b/packages/pr-agent-bridge/src/detect.ts index 5314eba8..6f17815c 100644 --- a/packages/pr-agent-bridge/src/detect.ts +++ b/packages/pr-agent-bridge/src/detect.ts @@ -6,17 +6,17 @@ import { DEFAULT_PROBE_TIMEOUT_MS } from './constants.js'; import { defaultExec } from './exec.js'; import type { ExecFn, PrAgentBridge } from './types.js'; -/** 策略选择:'auto' 按优先级探测;显式值强制该策略。 */ +/** Strategy choice: 'auto' detects by priority; an explicit value forces that strategy. */ export type StrategyChoice = 'auto' | PrAgentStrategy; export interface DetectOptions { /** - * 嵌入式运行时解释器绝对路径(main 进程按 dev/打包解析后传入)。给定且文件存在 - * 时,'embedded' 策略参与探测并排在最前。bridge 包自身拿不到 Electron app, - * 所以路径必须由调用方注入。 + * Absolute path to the embedded runtime interpreter (passed in by the main process after resolving + * dev/packaged). When given and the file exists, the 'embedded' strategy joins detection and ranks + * first. The bridge package itself can't reach the Electron app, so the path must be injected by the caller. */ embeddedPythonPath?: string; - /** 强制策略;'auto'(默认)按 embedded → local-cli 顺序探测。 */ + /** Force strategy; 'auto' (default) detects in embedded → local-cli order. */ forceStrategy?: StrategyChoice; } @@ -36,7 +36,7 @@ type ProbeResult = ProbeOk | ProbeFail; /** * Spawn a command, treat ANY output (even non-zero exit) as "binary exists", - * spawn ENOENT as "binary missing". 默认超时兜底见 DEFAULT_PROBE_TIMEOUT_MS。 + * spawn ENOENT as "binary missing". For the default timeout fallback see DEFAULT_PROBE_TIMEOUT_MS. */ async function exec( cmd: string, @@ -92,8 +92,8 @@ async function exec( clearTimeout(timer); const combined = (stdout || stderr).trim(); const firstLine = combined.split('\n')[0]?.slice(0, 200) ?? ''; - // requireZeroExit:需要拿可信输出(如 pr-agent 版本号)时,exit!=0 视为失败, - // 避免把报错首行(traceback)当成结果。普通"二进制是否存在"探测仍宽容(任意输出即可)。 + // requireZeroExit: when a trustworthy output is needed (e.g. the pr-agent version number), exit!=0 counts as failure, + // to avoid treating the first error line (traceback) as the result. The plain "does the binary exist" detect stays lenient (any output will do). if (requireZeroExit && code !== 0) { settle({ ok: false, error: firstLine || `exit ${String(code)}`, probeMs: elapsed() }); } else if (combined) { @@ -110,8 +110,8 @@ async function probeLocalCli(): Promise<ProbeResult> { } /** - * 查嵌入式运行时里实际安装的 pr-agent 版本(importlib.metadata,不 import pr_agent, - * 快)。失败(未装 / 解释器异常)返回 null,调用方兜底。 + * Look up the actually installed pr-agent version in the embedded runtime (importlib.metadata, does not + * import pr_agent, fast). On failure (not installed / interpreter error) returns null for the caller to fall back. */ async function embeddedPrAgentVersion(pythonPath: string): Promise<string | null> { const r = await exec( @@ -124,15 +124,15 @@ async function embeddedPrAgentVersion(pythonPath: string): Promise<string | null } /** - * 按 forceStrategy / 可用性构造探测序列。embedded 仅在 pythonPath 给定且文件存在时 - * 参与,并排最前;forceStrategy 非 'auto' 时只保留该策略。 + * Build the detect sequence by forceStrategy / availability. embedded only joins when pythonPath is + * given and the file exists, and ranks first; when forceStrategy is not 'auto' only that strategy is kept. */ function buildStrategies( opts: DetectOptions, ): Array<{ name: PrAgentStrategy; probe: () => Promise<ProbeResult> }> { const pyPath = opts.embeddedPythonPath; const all: Array<{ name: PrAgentStrategy; probe: () => Promise<ProbeResult> }> = []; - // 嵌入式解释器跑 `python --version`:快,且文件不存在时直接判不可用(不阻塞) + // Run `python --version` on the embedded interpreter: fast, and when the file doesn't exist it's judged unavailable outright (non-blocking) if (pyPath && existsSync(pyPath)) { all.push({ name: 'embedded', probe: () => exec(pyPath, ['--version']) }); } @@ -142,8 +142,8 @@ function buildStrategies( } /** - * 按优先级探测 pr-agent 可用性:embedded(随 app 打包)→ local CLI(pipx)。 - * 返回首个成功的策略;全部失败则报告所有尝试结果。 + * Detect pr-agent availability by priority: embedded (bundled with the app) → local CLI (pipx). + * Returns the first successful strategy; if all fail, reports all attempt results. */ export async function detectPrAgent(opts: DetectOptions = {}): Promise<PrAgentStatus> { const strategies = buildStrategies(opts); @@ -151,10 +151,10 @@ export async function detectPrAgent(opts: DetectOptions = {}): Promise<PrAgentSt for (const { name, probe } of strategies) { const r = await probe(); if (r.ok) { - // version 字段语义:用户在 UI 看到的"pr-agent 版本"。 - // - embedded:另查 importlib.metadata 拿实际安装的 pr-agent 版本 (`pr-agent 0.36.0`), - // 而非解释器的 Python 版本(用户关心的是 pr-agent 版本) - // - local-cli:`pr-agent --help` 首行,保持原值 + // Semantics of the version field: the "pr-agent version" the user sees in the UI. + // - embedded: separately queries importlib.metadata to get the actually installed pr-agent version (`pr-agent 0.36.0`), + // rather than the interpreter's Python version (what the user cares about is the pr-agent version) + // - local-cli: the first line of `pr-agent --help`, kept as-is let version: string; if (name === 'embedded') { const pv = opts.embeddedPythonPath @@ -172,9 +172,9 @@ export async function detectPrAgent(opts: DetectOptions = {}): Promise<PrAgentSt } /** - * 探测并构造一个可调用的 PrAgentBridge。embedded 优先(需传 embeddedPythonPath)→ - * LocalCli;都不可用时返回 null + status(UI 走 unavailable 占位)。 - * 可注入 ExecFn 便于单测 / mock。 + * Detect and construct a callable PrAgentBridge. embedded takes priority (requires passing embeddedPythonPath) → + * LocalCli; when neither is available returns null + status (the UI shows the unavailable placeholder). + * ExecFn can be injected for unit tests / mocking. */ export async function createPrAgentBridge( opts: DetectOptions & { exec?: ExecFn } = {}, @@ -182,7 +182,7 @@ export async function createPrAgentBridge( const status = await detectPrAgent(opts); if (!status.available) return { bridge: null, status }; const exec = opts.exec ?? defaultExec; - // detectPrAgent 选中 embedded 时 embeddedPythonPath 必定存在(buildStrategies 已校验) + // When detectPrAgent picks embedded, embeddedPythonPath is guaranteed to exist (buildStrategies already validated it) const bridge: PrAgentBridge = status.strategy === 'embedded' ? new EmbeddedRuntimeBridge(status.version, opts.embeddedPythonPath!, exec) diff --git a/packages/pr-agent-bridge/src/env.ts b/packages/pr-agent-bridge/src/env.ts index 474920ba..ed637cbf 100644 --- a/packages/pr-agent-bridge/src/env.ts +++ b/packages/pr-agent-bridge/src/env.ts @@ -3,21 +3,22 @@ import { LLM_CONTEXT_TOKENS_DEFAULT } from '@meebox/shared'; import { PRAGENT_LOCAL_OUTPUT } from './constants.js'; /** - * pr-agent 环境变量构造:把一条 LLM Profile 翻成 pr-agent / 嵌入式 shim 认的 env(provider 凭据 / - * 模型 / litellm 路由前缀 / 编排 chat 的推理档与缓存)。这层是 pr-agent 运行时契约(双下划线 env key、 - * litellm 前缀路由、shim 哨兵 env),归属 pr-agent 适配包;主服务只传 LlmProfile + 高层意图,不直接 - * 拼 CONFIG__* / MEEBOX_* key。纯函数、无 I/O。 + * pr-agent environment variable construction: translate one LLM Profile into the env pr-agent / the embedded shim + * recognize (provider credentials / model / litellm routing prefix / reasoning profile and cache for orchestration chat). + * This layer is the pr-agent runtime contract (double-underscore env keys, litellm prefix routing, shim sentinel env), + * belonging to the pr-agent adapter package; the main service only passes LlmProfile + high-level intent, not directly + * assembling CONFIG__* / MEEBOX_* keys. Pure function, no I/O. */ /** - * 把 provider + 用户输入的 model 字符串规整成 litellm 期望的形式。 + * Normalize the provider + user-input model string into the form litellm expects. * - * litellm 通过 model 字符串的前缀路由到对应 provider(`deepseek/...` → DeepSeek - * SDK,`anthropic/...` / `claude-*` → Anthropic,`openai/...` → OpenAI 兼容客户端, - * 无前缀 → 默认走 OpenAI)。用户在 LLM Profile 里只填模型名(如 `deepseek-v4-pro`), - * 这里按 provider 自动补前缀,避免 litellm 路由错到 OpenAI 用 `dummy_key` 报错。 + * litellm routes to the corresponding provider by the model string's prefix (`deepseek/...` → DeepSeek + * SDK, `anthropic/...` / `claude-*` → Anthropic, `openai/...` → OpenAI-compatible client, + * no prefix → defaults to OpenAI). In an LLM Profile the user fills in only the model name (e.g. `deepseek-v4-pro`), + * and here we auto-add the prefix by provider, to avoid litellm misrouting to OpenAI and erroring with `dummy_key`. * - * 用户若手动写了带前缀的形式(兼容多 provider 用户 / 高级用户),不重复加。 + * If the user manually wrote the prefixed form (for multi-provider / advanced users), we don't add it again. */ function normalizeModel(provider: LlmProfile['provider'], model: string): string { if (!model) return model; @@ -26,30 +27,31 @@ function normalizeModel(provider: LlmProfile['provider'], model: string): string case 'deepseek': return m.startsWith('deepseek/') ? m : `deepseek/${m}`; case 'anthropic': - // 一律补 `anthropic/` 前缀让 litellm 按前缀直接路由到 Anthropic。 - // 不能靠裸 `claude-*` 名字——litellm 只对**内置 model_cost 表里**的 claude - // 型号才能从名字反推 provider;新型号 (如 claude-opus-4-8) 不在表里,裸名传 - // 过去第一道 provider 路由就抛 "LLM Provider NOT provided"。带前缀则无需查表, - // 厂商原厂模型只填型号名即可直接用。用户手写带前缀的不重复加。 + // Always add the `anthropic/` prefix so litellm routes directly to Anthropic by prefix. + // Can't rely on a bare `claude-*` name — litellm can only infer the provider from the name for + // claude models **in the built-in model_cost table**; new models (e.g. claude-opus-4-8) aren't in + // the table, and a bare name throws "LLM Provider NOT provided" at the first provider routing step. + // With the prefix no table lookup is needed, and a vendor's first-party models work by filling in + // just the model name. If the user hand-wrote the prefix, we don't add it again. return m.startsWith('anthropic/') ? m : `anthropic/${m}`; case 'openai': - // 真 OpenAI:litellm 认 gpt-* / o1-* 等内置模型名;带 openai/ 前缀也直认。 - // 用户写的就是 litellm 内置表里的名字,不主动加前缀避免重复 (`openai/openai/...`) + // Real OpenAI: litellm recognizes built-in model names like gpt-* / o1-*; the openai/ prefix is also accepted directly. + // What the user writes is a name in litellm's built-in table, so we don't proactively add a prefix to avoid duplication (`openai/openai/...`) return m; case 'openai-compatible': case 'dashscope': case 'volcengine-ark': - // OpenAI 兼容协议(DashScope / 火山方舟 / 自部署 vLLM / 中转)— 模型 ID - // 是平台特定 (qwen-plus / doubao-pro-32k / ep-xxx endpoint id 等),**不在 - // litellm 内置 MAX_TOKENS 表里**,裸名传过去 litellm 第一道 provider 路由 - // 就报 "LLM Provider NOT provided"。 - // 必须显式 `openai/` 前缀让 litellm 走 "custom OpenAI client + 用 OPENAI_API_BASE - // 作为 endpoint" 分支,model 字段去前缀后透传给平台 + // OpenAI-compatible protocol (DashScope / Volcengine Ark / self-hosted vLLM / relay) — the model ID + // is platform-specific (qwen-plus / doubao-pro-32k / ep-xxx endpoint id, etc.), **not in + // litellm's built-in MAX_TOKENS table**, so a bare name throws "LLM Provider NOT provided" at + // litellm's first provider routing step. + // The explicit `openai/` prefix is required so litellm takes the "custom OpenAI client + use OPENAI_API_BASE + // as endpoint" branch; the model field is passed through to the platform after the prefix is stripped return m.startsWith('openai/') ? m : `openai/${m}`; case 'cli': - // cli 模式完全绕过 litellm(shim 替换 chat_completion 直接调本机 CLI),model - // 字段是命令名 (claude) 不是 litellm 模型名,原样透传。CONFIG__MODEL 仅供 - // pr-agent 内部 token 估算用(未知名 → 走 custom_model_max_tokens 兜底)。 + // cli mode fully bypasses litellm (the shim replaces chat_completion to call the local CLI directly), the model + // field is a command name (claude) not a litellm model name, passed through as-is. CONFIG__MODEL is only for + // pr-agent's internal token estimation (unknown name → falls back to custom_model_max_tokens). return m; default: return m; @@ -57,70 +59,70 @@ function normalizeModel(provider: LlmProfile['provider'], model: string): string } /** - * 把单条 LLM Profile 翻成 pr-agent 认的环境变量。pr-agent 内部 TOML 配置 + - * 双下划线 env var 覆盖:`[openai] key = ...` ↔ `OPENAI__KEY=...`。 + * Translate a single LLM Profile into the environment variables pr-agent recognizes. pr-agent uses internal TOML config + + * double-underscore env var overrides: `[openai] key = ...` ↔ `OPENAI__KEY=...`. * - * 走 env 而不是 `--openai.key=` CLI flag:避免密钥出现在 `ps` 进程列表 / - * git reflog;env 仅同用户在 /proc/<pid>/environ 可见,相对安全。 + * Uses env rather than the `--openai.key=` CLI flag: to keep the secret out of the `ps` process list / + * git reflog; env is only visible to the same user at /proc/<pid>/environ, relatively safe. * - * 空字符串字段一律跳过——别覆盖 pr-agent 默认值或用户 shell 里已有的 env。 + * Empty-string fields are always skipped — don't override pr-agent's default or the env already present in the user's shell. * - * 此外三条防御性默认: - * - `CONFIG__MAX_MODEL_TOKENS`:pr-agent **全局 input 上限**,默认 32000;日志里 - * "tokens under limit: 32000" 来自这条。DeepSeek-v4 / 现代 Claude / GPT-4 都是 128k+ - * 上下文,没必要被 pr-agent 强行截到 32k。由 `maxModelTokens`(用户「上下文长度」设置)控制、 - * 默认 128000,让长 PR 能完整入 prompt。**CLI 模式忽略该设置**(CLI 工具自管上下文)、固定默认值。 - * - `CONFIG__CUSTOM_MODEL_MAX_TOKENS`(同上取值):pr-agent 的 MAX_TOKENS 内置表只覆盖少 - * 数主流模型,DeepSeek / 新 Claude / 自部署 / openai-compatible 都不在表里,跑起来 - * 报 "model not defined in MAX_TOKENS"。这条是 unknown 模型的兜底 - * - `CONFIG__FALLBACK_MODELS=[]`:pr-agent 默认配了 fallback (一般指向 OpenAI 系列), - * 主模型失败后会自动用 dummy key 试 OpenAI,污染日志且容易被误读成"配错了 OpenAI"。 - * 我们已经显式指定 provider,没有 fallback 的必要 + * Plus three defensive defaults: + * - `CONFIG__MAX_MODEL_TOKENS`: pr-agent's **global input limit**, default 32000; the log's + * "tokens under limit: 32000" comes from this. DeepSeek-v4 / modern Claude / GPT-4 all have 128k+ + * context, no need for pr-agent to forcibly truncate to 32k. Controlled by `maxModelTokens` (the user's "context length" setting), + * default 128000, so a long PR fits fully into the prompt. **CLI mode ignores this setting** (the CLI tool manages its own context), fixed at the default. + * - `CONFIG__CUSTOM_MODEL_MAX_TOKENS` (same value as above): pr-agent's built-in MAX_TOKENS table only covers a + * few mainstream models; DeepSeek / new Claude / self-hosted / openai-compatible are all absent from the table, and running + * errors with "model not defined in MAX_TOKENS". This is the fallback for unknown models + * - `CONFIG__FALLBACK_MODELS=[]`: pr-agent configures a fallback by default (usually pointing at the OpenAI family), + * and after the main model fails it automatically tries OpenAI with a dummy key, polluting the log and easily misread as "OpenAI misconfigured". + * We already specify the provider explicitly, so a fallback is unnecessary */ export function buildPragentEnv(profile: LlmProfile, maxModelTokens?: number): Record<string, string> { const env: Record<string, string> = {}; if (profile.model) env['CONFIG__MODEL'] = normalizeModel(profile.provider, profile.model); - // 上下文长度:用户「上下文长度」设置控制 input 裁剪上限;CLI 模式忽略(工具自管上下文)→ 固定默认值。 + // Context length: the user's "context length" setting controls the input truncation limit; CLI mode ignores it (the tool manages its own context) → fixed at the default. const contextTokens = profile.provider === 'cli' ? LLM_CONTEXT_TOKENS_DEFAULT : (maxModelTokens ?? LLM_CONTEXT_TOKENS_DEFAULT); env['CONFIG__MAX_MODEL_TOKENS'] = String(contextTokens); env['CONFIG__CUSTOM_MODEL_MAX_TOKENS'] = String(contextTokens); env['CONFIG__FALLBACK_MODELS'] = '[]'; - // litellm import 时会联网拉远端模型价格表(raw.githubusercontent.com),内网/弱网 - // 下 SSL 超时拖慢启动且刷警告。我们只取真实 token 数(来自 API response.usage), - // 不需要价格表 → 强制只用包内本地备份、彻底不联网。见 sitecustomize 的 usage callback。 + // On import litellm fetches the remote model price table over the network (raw.githubusercontent.com); on an intranet/weak network + // the SSL timeout slows startup and floods warnings. We only take the real token count (from API response.usage), + // don't need the price table → force using only the in-package local backup, no network at all. See sitecustomize's usage callback. env['LITELLM_LOCAL_MODEL_COST_MAP'] = 'True'; - // 注:没接 LITELLM_LOG / CONFIG__VERBOSITY_LEVEL 因为 pr-agent 0.35 社区版上 - // 都不让 completion tokens 落到 stdout —— pr-agent 把它扔进 logger.debug 的 - // 'artifact' 字段,loguru 默认 INFO 级别滤掉。要拿到 completion tokens 需要走 - // sitecustomize / launcher monkey-patch litellm,独立于 env 实现 (留到后续) + // Note: LITELLM_LOG / CONFIG__VERBOSITY_LEVEL aren't wired in because on the pr-agent 0.35 community edition + // neither lets completion tokens reach stdout — pr-agent dumps it into logger.debug's + // 'artifact' field, which loguru's default INFO level filters out. Getting completion tokens requires + // sitecustomize / launcher monkey-patching litellm, implemented independently of env (left for later) switch (profile.provider) { case 'openai': case 'openai-compatible': case 'dashscope': case 'volcengine-ark': { - // 阿里百炼 / 火山方舟 / 自部署 vLLM 都暴露 OpenAI 兼容 endpoint。 + // Alibaba DashScope / Volcengine Ark / self-hosted vLLM all expose an OpenAI-compatible endpoint. // - // 严格按 pr-agent 官方推荐 (docs/usage-guide/changing_a_model),只设双下划 - // 线 env: `OPENAI__KEY` / `OPENAI__API_BASE`。pr-agent 内部 - // (litellm_ai_handler.py) 会: + // Strictly following pr-agent's official recommendation (docs/usage-guide/changing_a_model), only set the double-underscore + // env: `OPENAI__KEY` / `OPENAI__API_BASE`. Internally pr-agent + // (litellm_ai_handler.py) does: // litellm.openai_key = settings.openai.key // litellm.api_base = settings.openai.api_base // self.api_base = settings.openai.api_base - // 并在 `await acompletion(...)` 调用时无条件传 `api_base=self.api_base`。 + // and on the `await acompletion(...)` call unconditionally passes `api_base=self.api_base`. // - // 不要同时设单下划线 `OPENAI_API_KEY` / `OPENAI_BASE_URL` — OpenAI SDK 实例 - // 化时优先读这些环境变量,会把 pr-agent 注入的 `litellm.api_base` 覆盖掉, - // OpenAI client 改走 SDK 默认 endpoint,请求被打到 https://api.openai.com, - // DashScope key 必 401 (实测路径)。 + // Don't also set the single-underscore `OPENAI_API_KEY` / `OPENAI_BASE_URL` — the OpenAI SDK reads these + // environment variables first when instantiating, which overrides the `litellm.api_base` pr-agent injected, + // making the OpenAI client take the SDK's default endpoint, sending requests to https://api.openai.com, + // and the DashScope key inevitably 401s (a tested path). // - // model 仍需 `openai/<...>` 前缀 (normalizeModel 已加) — litellm 第一道 - // provider 路由按前缀认作 OpenAI-compatible client。裸 model 名 (qwen-plus) - // 不在 litellm.model_cost 表里,会抛 "LLM Provider NOT provided"。 + // model still needs the `openai/<...>` prefix (normalizeModel already adds it) — litellm's first + // provider routing recognizes it as an OpenAI-compatible client by prefix. A bare model name (qwen-plus) + // isn't in the litellm.model_cost table and throws "LLM Provider NOT provided". // - // dashscope / volcengine-ark 用 LLM_PROVIDERS 预设兜底 (跟 SettingsModal - // placeholder 同一份默认 endpoint),让历史 profile 留空时也能 work。 - // openai-compatible 不兜底 — 它是"自部署/中转代理"语义,endpoint 因人而异 + // dashscope / volcengine-ark fall back to the LLM_PROVIDERS profile (the same default endpoint as the SettingsModal + // placeholder), so a legacy profile still works when left blank. + // openai-compatible has no fallback — it carries "self-hosted/relay proxy" semantics, and the endpoint varies per user const baseUrlFallback: Record<string, string> = { dashscope: 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'volcengine-ark': 'https://ark.cn-beijing.volces.com/api/v3', @@ -132,29 +134,29 @@ export function buildPragentEnv(profile: LlmProfile, maxModelTokens?: number): R break; } case 'deepseek': - // litellm 走 deepseek/<model> 路径;env 用 DEEPSEEK__KEY。base_url 一般无需填 + // litellm takes the deepseek/<model> path; env uses DEEPSEEK__KEY. base_url generally doesn't need to be filled in if (profile.api_key) env['DEEPSEEK__KEY'] = profile.api_key; if (profile.base_url) env['DEEPSEEK__API_BASE'] = profile.base_url; break; case 'anthropic': if (profile.api_key) env['ANTHROPIC__KEY'] = profile.api_key; - // base_url 必须走 litellm 原生 env `ANTHROPIC_API_BASE`(单下划线),**不能**用 - // pr-agent 风格的双下划线 `ANTHROPIC__API_BASE`:pr-agent 0.36 的 litellm_ai_handler - // 只读 settings.anthropic.key、不读 anthropic.api_base,对 anthropic 把 api_base=None - // 透传给 litellm.acompletion;litellm 的 get_api_base 仅在 api_base 为空时才回落到 - // ANTHROPIC_API_BASE / ANTHROPIC_BASE_URL(都没有才用官方 https://api.anthropic.com)。 - // litellm 默认会给 base 自动补 `/v1/messages`,故填到根域名即可、勿自带该后缀(中转端点 - // 本身已是完整路径时,另设 LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX=true 关掉自动补全)。 + // base_url must go through litellm's native env `ANTHROPIC_API_BASE` (single underscore), and **cannot** use + // the pr-agent-style double-underscore `ANTHROPIC__API_BASE`: pr-agent 0.36's litellm_ai_handler + // only reads settings.anthropic.key, not anthropic.api_base, passing api_base=None through to + // litellm.acompletion for anthropic; litellm's get_api_base only falls back to + // ANTHROPIC_API_BASE / ANTHROPIC_BASE_URL when api_base is empty (and uses the official https://api.anthropic.com only if neither is set). + // litellm by default auto-appends `/v1/messages` to the base, so fill in the root domain and don't include that suffix yourself (when a relay endpoint + // is itself already a full path, additionally set LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX=true to turn off the auto-completion). if (profile.base_url) env['ANTHROPIC_API_BASE'] = profile.base_url; break; case 'cli': { - // 本地 CLI 模式:不直连任何 API,也不下发任何密钥。仅打两个哨兵 env 让 - // sitecustomize shim 在 pr-agent 进程内把 LiteLLMAIHandler.chat_completion - // 整体换成「调本机 CLI 子进程」版本(见 scripts/pragent-shim/meebox_pragent_shim/cli/)。 - // MEEBOX_CLI_MODE=1 —— 开关;非空即启用 CLI 接管 - // MEEBOX_CLI_BIN=claude —— 要调用的命令名(一期仅 claude;shim 用 which 解析真实路径) - // CLI 进程经子进程继承父 env(含 PATH / HOME),故能找到 claude 二进制并读到 - // ~/.claude 登录态。CONFIG__MODEL 已在上面置为命令名 (claude),仅用于 token 估算。 + // Local CLI mode: doesn't connect to any API directly, nor hand down any secret. Only sets two sentinel env vars so the + // sitecustomize shim, inside the pr-agent process, swaps LiteLLMAIHandler.chat_completion + // wholesale for a "call the local CLI subprocess" version (see scripts/pragent-shim/meebox_pragent_shim/cli/). + // MEEBOX_CLI_MODE=1 —— switch; any non-empty value enables the CLI takeover + // MEEBOX_CLI_BIN=claude —— the command name to invoke (phase one is claude only; the shim resolves the real path with which) + // The CLI process inherits the parent env (including PATH / HOME) via the subprocess, so it can find the claude binary and read the + // ~/.claude login state. CONFIG__MODEL was set to the command name (claude) above, used only for token estimation. const bin = (profile.model || 'claude').trim() || 'claude'; env['MEEBOX_CLI_MODE'] = '1'; env['MEEBOX_CLI_BIN'] = bin; @@ -164,31 +166,31 @@ export function buildPragentEnv(profile: LlmProfile, maxModelTokens?: number): R return env; } -/** 编排 chat 通道的 env 高层选项:调用方只表达意图,key 名由 bridge 持有。 */ +/** High-level env options for the orchestration chat channel: the caller only expresses intent, the key names are held by the bridge. */ export interface ChatEnvOptions { - /** pr-agent 响应语言(CONFIG__RESPONSE_LANGUAGE);空则不设。 */ + /** pr-agent response language (CONFIG__RESPONSE_LANGUAGE); if empty, not set. */ responseLanguage?: string; /** - * 调低推理档(编排 chat 是路由 + 轻量综合,非深度代码分析,那在 pr-agent /review 里)。两条路径都降档提速: - * - 本机 CLI 模式:MEEBOX_CLI_REASONING=low(codex → model_reasoning_effort=low、claude → haiku;见 cli/specs)。 - * - API / litellm 模式:CONFIG__REASONING_EFFORT=low(pr-agent 仅对 support_reasoning_models 应用, - * 非 reasoning 模型该项无副作用),避免一个 yes/no 路由判读也吐大量思考 token、拖慢响应。 + * Lower the reasoning profile (orchestration chat is routing + light synthesis, not deep code analysis — that's in pr-agent /review). Both paths lower the profile to speed up: + * - local CLI mode: MEEBOX_CLI_REASONING=low (codex → model_reasoning_effort=low, claude → haiku; see cli/specs). + * - API / litellm mode: CONFIG__REASONING_EFFORT=low (pr-agent only applies it to support_reasoning_models, + * with no side effect on non-reasoning models), to avoid a single yes/no routing decision also spewing lots of thinking tokens and slowing the response. */ lowReasoning?: boolean; /** - * 服务端提示缓存(MEEBOX_CHAT_CACHE,5min TTL):为编排 chat 的大块 system 前缀打 cache_control。多轮规划逐轮 - * 共享同一 system → 第 2 轮起命中、降延迟/成本(仅 Anthropic 需显式标;OpenAI/DeepSeek 自动前缀缓存)。判读 system - * 过小不达缓存粒度自动跳过(见 litellm_handler)。 + * Server-side prompt cache (MEEBOX_CHAT_CACHE, 5min TTL): applies cache_control to the orchestration chat's large system prefix. Multi-round planning shares the same + * system round over round → hits from the 2nd round onward, lowering latency/cost (only Anthropic needs an explicit marker; OpenAI/DeepSeek cache the prefix automatically). It automatically skips when + * the system is too small to reach the cache granularity (see litellm_handler). */ promptCache?: boolean; - /** 裁剪输入内容的上下文长度上限(token,CONFIG__MAX_MODEL_TOKENS);空则用默认 128000。CLI 模式忽略。 */ + /** Context length limit for truncating input content (tokens, CONFIG__MAX_MODEL_TOKENS); if empty, uses the default 128000. Ignored in CLI mode. */ maxModelTokens?: number; } /** - * 组装编排 chat 通道的 pr-agent env:在 LLM Profile 基础 env(provider 凭据 / 模型)之上叠加 chat 专属的 - * 响应语言 / 推理档 / 提示缓存契约 key。调用方传 LlmProfile + 高层意图(不直接写 CONFIG__* / MEEBOX_* key)。 - * profile 为 null(未配置 active profile)时仅返回意图相关的 key。代理 env 由调用方另铺(非 pr-agent 范畴)。 + * Assemble the pr-agent env for the orchestration chat channel: on top of the LLM Profile's base env (provider credentials / model), layer the chat-specific + * response language / reasoning profile / prompt cache contract keys. The caller passes LlmProfile + high-level intent (not directly writing CONFIG__* / MEEBOX_* keys). + * When profile is null (no active profile configured) only the intent-related keys are returned. The proxy env is laid out separately by the caller (outside the pr-agent scope). */ export function buildChatEnv( profile: LlmProfile | null, @@ -204,35 +206,35 @@ export function buildChatEnv( return env; } -/** pr-agent tool run 的 env 高层选项:调用方只表达意图(tool + 响应语言),契约 key 由 bridge 持有。 */ +/** High-level env options for a pr-agent tool run: the caller only expresses intent (tool + response language), the contract keys are held by the bridge. */ export interface ToolEnvOptions { tool: ReviewRunTool; - /** pr-agent 响应语言(CONFIG__RESPONSE_LANGUAGE);空则不设。 */ + /** pr-agent response language (CONFIG__RESPONSE_LANGUAGE); if empty, not set. */ responseLanguage?: string; - /** 裁剪输入内容的上下文长度上限(token,CONFIG__MAX_MODEL_TOKENS);空则用默认 128000。CLI 模式忽略。 */ + /** Context length limit for truncating input content (tokens, CONFIG__MAX_MODEL_TOKENS); if empty, uses the default 128000. Ignored in CLI mode. */ maxModelTokens?: number; /** - * 代码建议 / 评审发现数量上限(2~8):/review → PR_REVIEWER__NUM_MAX_FINDINGS、 - * /improve → PR_CODE_SUGGESTIONS__NUM_CODE_SUGGESTIONS(均硬上限)。空则用 pr-agent 默认。 - * /ask 的软约束走提示词(见 buildExtraInstructions),不经此。 + * Upper limit on the number of code suggestions / review findings (2~8): /review → PR_REVIEWER__NUM_MAX_FINDINGS, + * /improve → PR_CODE_SUGGESTIONS__NUM_CODE_SUGGESTIONS (both hard limits). If empty, uses pr-agent's default. + * /ask's soft constraint goes through the prompt (see buildExtraInstructions), not this. */ maxCodeSuggestions?: number; } /** - * 组装一次 pr-agent tool run 的 env:在 LLM Profile 基础 env 之上叠加响应语言与 per-tool pr-agent 配置 key。 - * 调用方传 LlmProfile + 意图(不直接写 CONFIG__* / PR_CODE_SUGGESTIONS__* / LOCAL__* key)。代理 env 由调用方 - * 另铺(非 pr-agent 范畴)。 + * Assemble the env for a single pr-agent tool run: on top of the LLM Profile's base env, layer the response language and per-tool pr-agent config keys. + * The caller passes LlmProfile + intent (not directly writing CONFIG__* / PR_CODE_SUGGESTIONS__* / LOCAL__* keys). The proxy env is laid out + * separately by the caller (outside the pr-agent scope). * - * /improve 在 local provider 下只有「汇总建议 → publish_comment」一条可用路径(shim 已强制 gfm_markdown=True), - * 故显式关死两项默认、并把产出重定向到 improve.md: - * - PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS=false:committable/inline 会走 publish_code_suggestions → - * local provider 直接 NotImplementedError(pr-agent 默认即 false,此处防上游翻默认值)。 - * - PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT=false:persistent_comment(默认 true)会翻历史评论做增量更新 → - * local provider 不实现、每次刷一段 NotImplementedError traceback(被兜底捕获,正文不丢但日志吵);local 每次 - * 全新 worktree、无历史可翻,直接关掉走 publish_comment。 - * - LOCAL__REVIEW_PATH=improve.md:与 /review /ask 的 review.md 分流(pr-agent 原生 local.review_path 覆盖 - * publish_comment 落盘路径,相对路径按子进程 cwd = worktree 根解析)。 + * Under the local provider /improve has only one usable path, "aggregated suggestions → publish_comment" (the shim already forces gfm_markdown=True), + * so explicitly kill two defaults and redirect the output to improve.md: + * - PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS=false: committable/inline would go through publish_code_suggestions → + * local provider throws NotImplementedError outright (pr-agent's default is already false, this guards against upstream flipping the default). + * - PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT=false: persistent_comment (default true) would look through history comments for incremental updates → + * local provider doesn't implement it and spews a NotImplementedError traceback each time (caught by a fallback, the body isn't lost but the log is noisy); local always + * uses a brand-new worktree with no history to look through, so turn it off outright and go through publish_comment. + * - LOCAL__REVIEW_PATH=improve.md: split from /review /ask's review.md (pr-agent's native local.review_path overrides + * the publish_comment on-disk path, with the relative path resolved against the subprocess cwd = worktree root). */ export function buildToolEnv( profile: LlmProfile | null, @@ -244,12 +246,12 @@ export function buildToolEnv( env['PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS'] = 'false'; env['PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT'] = 'false'; env['LOCAL__REVIEW_PATH'] = PRAGENT_LOCAL_OUTPUT.improve; - // 代码建议数量上限(用户「代码建议数量」设置);空则用 pr-agent 默认(num_code_suggestions=4)。 + // Upper limit on the number of code suggestions (the user's "code suggestion count" setting); if empty, uses pr-agent's default (num_code_suggestions=4). if (opts.maxCodeSuggestions !== undefined) { env['PR_CODE_SUGGESTIONS__NUM_CODE_SUGGESTIONS'] = String(opts.maxCodeSuggestions); } } - // 评审发现数量上限(与 /improve 共用同一设置);空则用 pr-agent 默认(num_max_findings=3)。 + // Upper limit on the number of review findings (shares the same setting as /improve); if empty, uses pr-agent's default (num_max_findings=3). if (opts.tool === 'review' && opts.maxCodeSuggestions !== undefined) { env['PR_REVIEWER__NUM_MAX_FINDINGS'] = String(opts.maxCodeSuggestions); } diff --git a/packages/pr-agent-bridge/src/exec.ts b/packages/pr-agent-bridge/src/exec.ts index dbe7bb50..a4721a74 100644 --- a/packages/pr-agent-bridge/src/exec.ts +++ b/packages/pr-agent-bridge/src/exec.ts @@ -2,7 +2,7 @@ import { spawn, type SpawnOptions } from 'node:child_process'; import treeKill from 'tree-kill'; import { PrAgentRunError, type ExecFn, type PrAgentRunResult } from './types.js'; -/** spawn 函数的最小依赖切片,便于测试注入 fake */ +/** Minimal dependency slice of the spawn function, to ease injecting a fake in tests */ export type SpawnFn = (cmd: string, args: readonly string[], opts: SpawnOptions) => SpawnedChild; interface DataEmitter { @@ -15,11 +15,11 @@ interface WritableLike { } interface SpawnedChild { - /** 子进程 pid;spawn 失败时为 undefined。用于调用方做进程树级清理。 */ + /** Subprocess pid; undefined when spawn fails. Used by the caller for process-tree-level cleanup. */ pid?: number; stdout: DataEmitter | null; stderr: DataEmitter | null; - /** 子进程 stdin;opts.input 时写入后 end。spawn 失败 / fake child 可为空。 */ + /** Subprocess stdin; written then ended when opts.input is set. May be null on spawn failure / fake child. */ stdin?: WritableLike | null; on(event: 'error', cb: (err: Error) => void): void; on(event: 'close', cb: (code: number | null, signal: NodeJS.Signals | null) => void): void; @@ -27,11 +27,12 @@ interface SpawnedChild { } /** - * 杀掉子进程的**整棵进程树**。pr-agent 的 python 会再 spawn litellm/网络库等孙进程, - * Windows 下 `child.kill` 不级联——只杀 python 主进程,孙进程变孤儿继续占着 vendor/python - * 等文件句柄,导致升级时 NSIS 安装器报「应用无法关闭」。tree-kill 在 win32 即原生 - * `taskkill /pid X /T /F`(级联、不依赖已被 Win11 移除的 wmic);posix 走 ps 遍历进程树。 - * 无 pid(spawn 失败 / 测试 fake child)时回退直接 kill。 + * Kill the **entire process tree** of the subprocess. pr-agent's python further spawns grandchildren + * like litellm / network libraries; on Windows `child.kill` does not cascade — it only kills the python + * main process, leaving grandchildren orphaned and still holding file handles on vendor/python etc., + * causing the NSIS installer to report "application cannot close" during upgrade. tree-kill on win32 is + * native `taskkill /pid X /T /F` (cascades, no dependency on wmic which Win11 removed); posix walks the + * process tree via ps. When there is no pid (spawn failure / test fake child), fall back to a direct kill. */ function killTree(child: SpawnedChild): void { const pid = child.pid; @@ -44,7 +45,7 @@ function killTree(child: SpawnedChild): void { return; } treeKill(pid, 'SIGKILL', () => { - // tree-kill 失败兜底:至少杀掉直接子进程 + // tree-kill failure fallback: at least kill the direct subprocess try { child.kill('SIGKILL'); } catch { @@ -54,14 +55,14 @@ function killTree(child: SpawnedChild): void { } /** - * 构造一个 ExecFn。spawnFn 默认 node:child_process.spawn;测试时注入 fake。 + * Construct an ExecFn. spawnFn defaults to node:child_process.spawn; inject a fake in tests. * - * 行为: - * - 启动失败 (ENOENT 等) → reject(PrAgentRunError 'spawn-failed') - * - 超时 → SIGKILL 子进程 + reject(PrAgentRunError 'timeout'),已收集的 stdout / stderr - * 随 result 一起返回 - * - 退出码非 0 / 被信号杀 → reject(PrAgentRunError 'non-zero-exit' / 'killed') - * - onLine 按 \n 切片实时回调;进程结束时残留 partial line 也补一次 + * Behavior: + * - Launch failure (ENOENT etc.) → reject(PrAgentRunError 'spawn-failed') + * - Timeout → SIGKILL subprocess + reject(PrAgentRunError 'timeout'); the collected stdout / stderr + * are returned along with the result + * - Non-zero exit code / killed by signal → reject(PrAgentRunError 'non-zero-exit' / 'killed') + * - onLine fires in real time split by \n; any leftover partial line is also emitted once at process end */ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): ExecFn { return (cmd, args, opts) => { @@ -96,13 +97,14 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec return; } - // 把 prompt 等输入写进子进程 stdin(chat 通道用),写完即 end 触发 EOF。 + // Write input such as the prompt into the subprocess stdin (used by the chat channel); end after + // writing to trigger EOF. if (opts.input != null) { try { child.stdin?.write(opts.input); child.stdin?.end(); } catch { - /* stdin 已关闭 / 子进程已退出,忽略 */ + /* stdin already closed / subprocess already exited, ignore */ } } @@ -111,7 +113,7 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec killTree(child); }, opts.timeoutMs); - // 用户取消:监听 AbortSignal,触发 SIGKILL;signal 在我们入参前就 aborted 也兜住 + // User cancellation: listen on AbortSignal, trigger SIGKILL; also handle a signal already aborted before we received it const onAbort = (): void => { if (settled) return; cancelled = true; @@ -119,7 +121,7 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec }; if (opts.signal) { if (opts.signal.aborted) { - // 防御:调用方传进来已经 abort 的 signal → 立即杀 + // Defensive: caller passed in an already-aborted signal → kill immediately queueMicrotask(onAbort); } else { opts.signal.addEventListener('abort', onAbort, { once: true }); @@ -134,7 +136,7 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec let buf = stream === 'stdout' ? stdoutBuf + s : stderrBuf + s; let nl: number; while ((nl = buf.indexOf('\n')) >= 0) { - // 去掉行尾可能的 \r + // Strip a possible trailing \r let line = buf.slice(0, nl); if (line.endsWith('\r')) line = line.slice(0, -1); opts.onLine(line, stream); @@ -166,7 +168,7 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec settled = true; clearTimeout(timer); opts.signal?.removeEventListener('abort', onAbort); - // 把残留的 partial line 也吐出来 + // Emit any leftover partial line as well if (opts.onLine) { if (stdoutBuf) opts.onLine(stdoutBuf, 'stdout'); if (stderrBuf) opts.onLine(stderrBuf, 'stderr'); @@ -177,7 +179,7 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec exitCode: code ?? -1, durationMs: elapsed(), }; - // 取消优先级最高:取消导致的信号杀掉,不算 timeout / killed / non-zero-exit + // Cancellation has highest priority: a signal kill caused by cancellation is not counted as timeout / killed / non-zero-exit if (cancelled) { reject(new PrAgentRunError('pr-agent cancelled by user', 'cancelled', result)); return; @@ -214,5 +216,5 @@ export function createExec(spawnFn: SpawnFn = spawn as unknown as SpawnFn): Exec }; } -/** 默认 ExecFn:走 node:child_process.spawn */ +/** Default ExecFn: goes through node:child_process.spawn */ export const defaultExec: ExecFn = createExec(); diff --git a/packages/pr-agent-bridge/src/prompts.ts b/packages/pr-agent-bridge/src/prompts.ts index 73dacfe4..d382a3b6 100644 --- a/packages/pr-agent-bridge/src/prompts.ts +++ b/packages/pr-agent-bridge/src/prompts.ts @@ -1,16 +1,19 @@ import type { ReviewRunTool } from '@meebox/shared'; /** - * pr-agent 提示词组装:把注入各 tool 的 EXTRA_INSTRUCTIONS、/ask 语言后缀、以及输出回显去重 - * 收口到本模块,避免散落在 run 队列执行逻辑里。纯字符串构造,不含 I/O / 运行时依赖。 + * pr-agent prompt assembly: funnels the EXTRA_INSTRUCTIONS injected per tool, the /ask language suffix, + * and output-echo deduplication into this module, avoiding their scattering across the run-queue exec logic. + * Pure string construction, with no I/O / runtime dependencies. */ /** - * 把 config.language (ISO locale) 翻成自然语言 prompt directive。 + * Translate config.language (ISO locale) into a natural-language prompt directive. * - * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt template),但 - * /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格类输出的标题 / 列名 / 段落标记。 - * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 pr-agent 原行为。 + * CONFIG__RESPONSE_LANGUAGE already suffices for /describe /review (it is embedded in their prompt + * template), but /ask does not strictly obey it; the explicit prompt reinforces all tools, especially + * covering the headings / column names / section markers of /ask + table-style output. English (en-US) + * returns an empty string to avoid adding an unnecessary hint to the LLM. Other unknown locales return + * empty to preserve pr-agent's original behavior. */ function languageDirectiveFor(lang: string): string { const norm = lang.toLowerCase(); @@ -30,20 +33,25 @@ function languageDirectiveFor(lang: string): string { } /** - * anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 + * anchor marker directive: make the model explicitly append, at the end of content involving a code location, * [file: <path>, lines: <start_line>-<end_line>] * - * 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 - * `[**header**](meebox:///<file>#L<s>-L<e>)`,parse-output 取结构化 anchor(path 来自 - * provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/end_line YAML - * 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。故这条 marker 作为**行号 - * 兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker 的行号补(resolveIssueAnchor)。 + * The main path has switched to sitecustomize injecting LocalGitProvider.get_line_link → key_issues rendered + * as `[**header**](meebox:///<file>#L<s>-L<e>)`, and parse-output takes the structured anchor (path comes from + * the provider, same source, most reliable). But the #L line numbers still depend on the model filling in + * pr-agent's native start_line/end_line YAML fields; in practice some models fill only this marker and leave the + * structured fields empty → the link has only a path. So this marker is kept as a **line-number fallback**: when + * parse-output merges, it gives the path to the link and fills missing line numbers from the marker's line numbers + * (resolveIssueAnchor). * - * - /review: 每条 key_issue 末尾 **必加** marker - * - /ask: **不注入**——/ask 走结构化分段(见 structuredAskDirective),其建议定位走「被引用 finding 的 - * anchor」(复评取代场景),无需逐段 marker;而强制逐段 marker 会把回答压成纯文本逐段、回避表格 / 代码块, - * 削弱 pr-agent 原生 /ask 的富文本表现(实测)。故对 /ask 取消该指令,保留 pr-agent 原生作答风格。 - * - /describe / /improve 不注入:前者不出 issue,后者走 marker 行 `[file [start-end]](url)` 自带 anchor + * - /review: **always append** a marker at the end of each key_issue + * - /ask: **not injected** — /ask uses structured sectioning (see structuredAskDirective), and its suggestion + * locations use "the anchor of the referenced finding" (re-evaluation/replacement scenario), so no per-section + * marker is needed; forcing per-section markers would flatten the answer into plain-text sections and avoid + * tables / code blocks, weakening pr-agent's native /ask rich-text presentation (observed). So this directive + * is dropped for /ask, preserving pr-agent's native answering style. + * - /describe / /improve: not injected — the former produces no issues, the latter uses the marker line + * `[file [start-end]](url)` which carries its own anchor */ function anchorMarkerDirective(tool: ReviewRunTool): string { if (tool === 'review') { @@ -67,11 +75,13 @@ function anchorMarkerDirective(tool: ReviewRunTool): string { } /** - * 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 - * pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文;渲染层 - * (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立 <p>。关键是「保持简洁」——只在 - * 现象/影响/建议的语义边界换行,不得借分段扩写内容。须与 anchor marker 协同:分段在正文内部, - * marker 仍独占最末行。 + * Layout directive: only changes the line-break layout of each /review key_issue to improve GUI readability, + * without adding length. pr-agent's original prompt asks for a "short and concise summary", and the model + * defaults to piling it into a single long run-on paragraph; the render layer (ReactMarkdown + remarkBreaks) + * faithfully presents it, and a blank-line-separated section becomes an independent <p>. The key is "stay + * concise" — break only at the semantic boundaries of symptom/impact/suggestion, and do not use sectioning as + * a pretext to expand the content. Must cooperate with the anchor marker: sectioning stays inside the body, + * and the marker still occupies the very last line alone. */ function reviewLayoutDirective(tool: ReviewRunTool): string { if (tool !== 'review') return ''; @@ -89,15 +99,18 @@ function reviewLayoutDirective(tool: ReviewRunTool): string { } /** - * /ask 结构化分段指令:结构化只是**在 pr-agent 原生 /ask 富文本回答之上加一层轻包装**——把回答包进 - * 字面 `<summary>` / `<analysis>` / `<suggestions>` 三段,便于 GUI 归纳;段内内容保持 pr-agent 原生 - * 表现(表格 / 代码块 / 子标题 / 列表、深度照常),不削减。summary 必填(结论,GUI 高亮展开)、analysis - * 可省(完整过程分析,GUI 默认收起)、suggestions 可省(可执行建议,**逐条带代码定位标记** → GUI 解析成 - * 可采纳的「代码建议」卡)。仅 /ask 注入;模型未遵循时 parse-output 整体回退普通解析(见 packages/poller)。 + * /ask structured-sectioning directive: structuring is just **a light wrapper on top of pr-agent's native + * /ask rich-text answer** — wrapping the answer into the literal three sections `<summary>` / `<analysis>` / + * `<suggestions>` to help the GUI summarize; the content within each section keeps pr-agent's native + * presentation (tables / code blocks / sub-headings / lists, depth as usual), without cutting anything down. + * summary is required (the conclusion, highlighted and expanded in the GUI), analysis is optional (the full + * process analysis, collapsed by default in the GUI), suggestions is optional (actionable suggestions, **each + * with a code-location marker** → parsed by the GUI into adoptable "code suggestion" cards). Injected only for + * /ask; when the model does not comply, parse-output falls back entirely to ordinary parsing (see packages/poller). */ function structuredAskDirective(tool: ReviewRunTool, maxCodeSuggestions?: number): string { if (tool !== 'ask') return ''; - // 代码建议数量软约束:/ask 无 pr-agent 原生上限,仅能在此提示层封顶(与 /improve /review 共用同一设置)。 + // Soft constraint on the number of code suggestions: /ask has no pr-agent native cap, so it can only be capped at this prompt layer (shares the same setting with /improve /review). const capRule = maxCodeSuggestions !== undefined ? [ @@ -148,9 +161,11 @@ function structuredAskDirective(tool: ReviewRunTool, maxCodeSuggestions?: number } /** - * /ask 复评模式指令:本次 /ask 是对一条既有评审评论(正文随 referencedContext 给出)的复评时注入。 - * 在结构化三段基础上,要求模型额外给出 `<verdict>` 裁决——replace(取代:<suggestions> 写一条可直接发布的 - * 替代评论本身)/ keep(原评论成立)/ drop(原评论不成立、无需评论)。驱动结果卡的采纳 / 关闭动作。 + * /ask re-evaluation-mode directive: injected when this /ask re-evaluates an existing review comment (its body + * is given via referencedContext). On top of the structured three sections, the model is additionally required + * to give a `<verdict>` decision — replace (replace: <suggestions> writes a directly publishable replacement + * comment itself) / keep (the original comment stands) / drop (the original comment does not hold, no comment + * needed). Drives the adopt / dismiss actions of the result card. */ function referencedAskDirective(tool: ReviewRunTool, hasReferencedFinding: boolean): string { if (tool !== 'ask' || !hasReferencedFinding) return ''; @@ -180,11 +195,14 @@ function referencedAskDirective(tool: ReviewRunTool, hasReferencedFinding: boole } /** - * /ask 代码检索指引(仅 CLI 提供方:子进程 cwd 落在完整 worktree、具备文件工具时注入)。引导 agentic CLI 以 - * **定向检索**(内置只读搜索 / `grep` 查符号 · 只读所需行段)替代整文件通读与全仓扫描,压掉冷启动探索的浪费性 - * token,同时保留读真实文件的深度。刻意只用**只读**工具集:headless(无 TTY)下 default 权限模式对非只读工具 - * (写 / `rg` 等不在内置只读白名单的命令)不是拒绝而是**直接中止会话**,故不得诱导 `rg` 等命令。API 提供方无 - * 文件访问、不注入(enabled=false)。 + * /ask code-retrieval guidance (CLI provider only: injected when the subprocess cwd lands in the full worktree + * and file tools are available). Guides the agentic CLI to use **targeted retrieval** (built-in read-only search + * / `grep` for symbols · read-only of the needed line ranges) instead of reading whole files and scanning the + * whole repo, cutting the wasteful cold-start exploration tokens while keeping the depth of reading real files. + * Deliberately uses a **read-only** tool set only: in headless (no TTY) mode, the default permission mode does + * not reject non-read-only tools (writes / commands like `rg` not on the built-in read-only allowlist) but + * **directly aborts the session**, so commands like `rg` must not be induced. The API provider has no file + * access and is not injected (enabled=false). */ function worktreeRetrievalDirective(tool: ReviewRunTool, enabled: boolean): string { if (tool !== 'ask' || !enabled) return ''; @@ -202,24 +220,40 @@ function worktreeRetrievalDirective(tool: ReviewRunTool, enabled: boolean): stri } /** - * 组装注入 pr-agent 的 EXTRA_INSTRUCTIONS:按序拼接 语言指示 / anchor marker / 结构化分段 / 复评裁决 / - * 排版 / PR 上下文 / 命中规则,空段跳过;全空返回 undefined(调用方据此决定是否设 env)。 - * - 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 /ask 走 [pr_questions] 不严格 - * 遵守,必须显式强化 - * - PR 上下文 / 规则由调用方现读传入(local provider 不自己去远端拉这些) + * Assemble the EXTRA_INSTRUCTIONS injected into pr-agent: concatenate in order the language directive / anchor + * marker / structured sectioning / re-evaluation verdict / layout / PR context / matched rules, skipping empty + * sections; return undefined when all empty (the caller decides whether to set the env accordingly). + * - Language directive: CONFIG__RESPONSE_LANGUAGE suffices for /describe /review, but /ask goes through + * [pr_questions] and does not strictly obey it, so it must be explicitly reinforced + * - PR context / rules are read and passed in by the caller (the local provider does not fetch these from the + * remote itself) */ +/** + * User-defined code-suggestion spec (free text from settings): injected verbatim for the tools that produce code + * suggestions (/improve, /review, /ask) to shape how the model structures each suggestion (e.g. Problem / Analysis / + * Suggestion sectioning). A soft constraint. /describe is excluded (it writes the PR description, not code suggestions). + * Empty / whitespace → no injection. + */ +function codeSuggestionSpecDirective(tool: ReviewRunTool, spec?: string): string { + if (!spec?.trim()) return ''; + if (tool !== 'improve' && tool !== 'review' && tool !== 'ask') return ''; + return spec.trim(); +} + export function buildExtraInstructions(input: { tool: ReviewRunTool; language: string; prContext: string; matchedRuleInstructions: string; - /** 用户在 Diff 里选中的代码片段(自描述引用块,渲染层已拼好),仅 /ask 注入。 */ + /** User-defined code-suggestion spec (verbatim, soft constraint): injected for /improve, /review, /ask; empty means none. */ + codeSuggestionSpec?: string; + /** Code snippet the user selected in the Diff (self-describing quote block, already assembled by the render layer), injected only for /ask. */ referencedContext?: string; - /** 本次 /ask 是否为对某条既有评论的「复评」;为真则注入复评裁决指示(仅 /ask)。 */ + /** Whether this /ask is a "re-evaluation" of an existing comment; when true, inject the re-evaluation verdict directive (/ask only). */ referencedFinding?: boolean; - /** 代码建议数量上限(2~8):/ask 的 <suggestions> 软约束(仅 /ask 用到)。空则不封顶。 */ + /** Upper bound on the number of code suggestions (2~8): soft constraint for /ask's <suggestions> (used by /ask only). Empty means no cap. */ maxCodeSuggestions?: number; - /** 是否注入 /ask 代码检索指引(仅 CLI 提供方:子进程可在完整 worktree 里用 shell/文件工具时为真)。 */ + /** Whether to inject the /ask code-retrieval guidance (CLI provider only: true when the subprocess can use shell/file tools in the full worktree). */ worktreeRetrieval?: boolean; }): string | undefined { const parts = [ @@ -229,6 +263,7 @@ export function buildExtraInstructions(input: { referencedAskDirective(input.tool, !!input.referencedFinding), worktreeRetrievalDirective(input.tool, !!input.worktreeRetrieval), reviewLayoutDirective(input.tool), + codeSuggestionSpecDirective(input.tool, input.codeSuggestionSpec), input.prContext, input.referencedContext ?? '', input.matchedRuleInstructions, @@ -236,7 +271,7 @@ export function buildExtraInstructions(input: { return parts.length > 0 ? parts.join('\n\n---\n\n') : undefined; } -/** EXTRA_INSTRUCTIONS 对应的 pr-agent env key(按 tool)。 */ +/** The pr-agent env key corresponding to EXTRA_INSTRUCTIONS (by tool). */ export function extraInstructionsEnvKey(tool: ReviewRunTool): string { switch (tool) { case 'describe': @@ -251,9 +286,11 @@ export function extraInstructionsEnvKey(tool: ReviewRunTool): string { } /** - * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 - * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff - * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 + * /ask-specific: put the language requirement as a hard directive at the "end of the question", **written in the + * target language itself** (which best prompts the model to switch to answering in that language). On the system + * side, CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS are often drowned out by large amounts of English diff for + * free-form Q&A, so require it once more at the end of the user turn (the recency position). en-US / unknown + * locale returns an empty string (English by default). */ export function askLanguageSuffixFor(lang: string): string { const norm = lang.toLowerCase(); @@ -273,22 +310,24 @@ export function askLanguageSuffixFor(lang: string): string { } /** - * /ask 输出去问题回显。pr-agent 把答案产物写成 - * `### **Ask**❓\n<question>\n\n### **Answer:**\n<answer>`, - * 其中 `<question>` 含我们追加到问题末尾的格式指令(结构化分段 / anchor / 复评裁决——内含字面 - * `<summary>` / `<verdict>` 等**示例标签**),若不剔除会污染下游结构化解析(parseStructuredAsk 会误把 - * 示例标签当答案)。 + * Strip the question echo from /ask output. pr-agent writes the answer artifact as + * `### **Ask**❓\n<question>\n\n### **Answer:**\n<answer>`, + * where `<question>` contains the formatting directives we appended to the end of the question (structured + * sectioning / anchor / re-evaluation verdict — containing literal **example tags** such as `<summary>` / + * `<verdict>`); if not stripped, they would pollute downstream structured parsing (parseStructuredAsk would + * mistake the example tags for the answer). * - * 策略:优先按 pr-agent 固定的英文「Answer」表头切,只取其后的答案(连同问题回显 + 注入指令一并丢弃); - * 表头缺失(版本漂移)时回退到逐行精确匹配删掉回显的问题 / 语言后缀行。 + * Strategy: preferentially cut at pr-agent's fixed English "Answer" header, taking only the answer after it + * (discarding the question echo + injected directives together); when the header is missing (version drift), + * fall back to per-line exact matching to delete the echoed question / language suffix lines. */ export function stripAskQuestionEcho(md: string, ...echoed: string[]): string { if (!md) return md; - // pr_questions.py `_prepare_pr_answer` 硬编码英文 `### **Answer:**`(不随响应语言本地化)。 + // pr_questions.py `_prepare_pr_answer` hardcodes the English `### **Answer:**` (not localized by response language). const answerRe = /^#{1,6}\s*\*\*\s*Answer\s*:?\s*\*\*\s*$/im; const m = answerRe.exec(md); if (m) return md.slice(m.index + m[0].length).replace(/^\s+/, ''); - // 回退:逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 + // Fallback: per-line exact matching (whole line after trim == any given string) to delete, keeping the rest of the body. const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); if (!qs.size) return md; return md diff --git a/packages/pr-agent-bridge/src/types.ts b/packages/pr-agent-bridge/src/types.ts index 3cbde28e..4e927b8a 100644 --- a/packages/pr-agent-bridge/src/types.ts +++ b/packages/pr-agent-bridge/src/types.ts @@ -1,67 +1,68 @@ import type { PrAgentStrategy } from '@meebox/shared'; -/** pr-agent 子命令枚举;跟 @meebox/shared 的 ReviewRunTool 同集合 */ +/** pr-agent subcommand enum; same set as @meebox/shared's ReviewRunTool */ export type PrAgentTool = 'describe' | 'review' | 'ask' | 'improve'; export interface PrAgentRunOptions { /** - * pr-agent 入口 `--pr_url`。 - * - 远端模式:Bitbucket PR URL,如 https://host/projects/.../pull-requests/<id> - * - 本地模式 (cwd 已配置):直接传 cwd 路径(容器里固定为 /repo),pr-agent 拿来 - * 定位本地仓库目录,不会走任何远端 API + * pr-agent entry `--pr_url`. + * - Remote mode: Bitbucket PR URL, e.g. https://host/projects/.../pull-requests/<id> + * - Local mode (cwd configured): pass the cwd path directly (fixed to /repo in the container), which + * pr-agent uses to locate the local repository directory, without hitting any remote API */ prUrl: string; tool: PrAgentTool; - /** 注入到子进程的环境变量(LLM key / platform token / config 覆盖) */ + /** Environment variables injected into the subprocess (LLM key / platform token / config overrides) */ env?: Record<string, string>; - /** 追加到 CLI 末尾的参数(如 --extra_instructions / --config_file) */ + /** Arguments appended at the end of the CLI (e.g. --extra_instructions / --config_file) */ extraArgs?: string[]; - /** 单次调用超时,默认 5 min */ + /** Timeout for a single call, default 5 min */ timeoutMs?: number; - /** stdout / stderr 整行流式推送(M3-B UI 进度提示用) */ + /** Per-line streaming push of stdout / stderr (for M3-B UI progress hints) */ onLine?: (line: string, stream: 'stdout' | 'stderr') => void; /** - * 用户主动取消信号。abort 后 SIGKILL 子进程,reject 为 PrAgentRunError reason='cancelled'。 - * 不传则永不取消,仅 timeoutMs 兜底。 + * User-initiated cancellation signal. After abort, SIGKILL the subprocess and reject with + * PrAgentRunError reason='cancelled'. If not passed, never cancels, with only timeoutMs as a fallback. */ signal?: AbortSignal; /** - * 本地工作树绝对路径。配置后切到 `git_provider=local` 模式:把 cwd 作为子进程 - * 工作目录,pr-agent 自己跑 `git diff <targetBranch>...HEAD`,完全不出网到代码托管。 - * 不设置则走原远端 provider 模式(默认 prUrl 远端拉 PR)。 + * Absolute path of the local worktree. When configured, switches to `git_provider=local` mode: uses cwd + * as the subprocess working directory, and pr-agent runs `git diff <targetBranch>...HEAD` itself, never + * going out to the code host. If not set, uses the original remote provider mode (default: pull the PR + * from the remote via prUrl). */ cwd?: string; /** - * 本地 diff 起点。仅 `cwd` 设置时生效;典型值是 PR base sha 或 ref 名。 - * 传给 pr-agent 的 `--target_branch`;缺省时 pr-agent 自己 fallback 到默认分支。 + * Local diff starting point. Effective only when `cwd` is set; typical value is the PR base sha or ref name. + * Passed to pr-agent's `--target_branch`; when omitted, pr-agent falls back to the default branch itself. */ targetBranch?: string; } export interface PrAgentRunResult { - /** 子进程完整 stdout 文本 */ + /** Full stdout text of the subprocess */ stdout: string; - /** 子进程完整 stderr 文本 */ + /** Full stderr text of the subprocess */ stderr: string; - /** 退出码;信号被杀 / 启动失败时为 -1 */ + /** Exit code; -1 when killed by signal / launch failed */ exitCode: number; - /** 运行墙钟时间 (ms) */ + /** Wall-clock run time (ms) */ durationMs: number; } export type PrAgentRunFailureReason = - /** 超时被 SIGKILL */ + /** SIGKILLed on timeout */ | 'timeout' - /** 子进程未能启动(ENOENT / 权限 / fork 失败) */ + /** Subprocess failed to launch (ENOENT / permission / fork failure) */ | 'spawn-failed' - /** 正常退出但 exit code != 0 */ + /** Exited normally but exit code != 0 */ | 'non-zero-exit' - /** 被外部信号杀死(非超时) */ + /** Killed by an external signal (not a timeout) */ | 'killed' - /** 用户主动取消 (AbortSignal abort) */ + /** User-initiated cancellation (AbortSignal abort) */ | 'cancelled'; -/** pr-agent 跑失败时抛出;携带原因 + 已收集的 stdout / stderr / exitCode 供 UI 展示 */ +/** Thrown when pr-agent run fails; carries the reason + collected stdout / stderr / exitCode for UI display */ export class PrAgentRunError extends Error { constructor( message: string, @@ -77,62 +78,65 @@ export interface ExecOptions { timeoutMs: number; env?: Record<string, string>; onLine?: (line: string, stream: 'stdout' | 'stderr') => void; - /** 子进程工作目录;local 模式用 */ + /** Subprocess working directory; used in local mode */ cwd?: string; - /** 用户主动取消信号;abort 后 SIGKILL 子进程并 reject reason='cancelled' */ + /** User-initiated cancellation signal; after abort, SIGKILL the subprocess and reject reason='cancelled' */ signal?: AbortSignal; - /** 写入子进程 stdin 的内容(写完即 end);chat 通道传 prompt 用。 */ + /** Content written to the subprocess stdin (ended after writing); used by the chat channel to pass the prompt. */ input?: string; } /** - * 编排器「独立 LLM 通道」的一次原始对话调用(见 docs/arch/02-agent/02-session.md「会话 Agent 化」)。 - * 复用嵌入式运行时的 litellm(provider 路由 / 代理 / token 采集已解决): - * 子进程跑 `meebox_pragent_shim.chat`,prompt 经 stdin 传入,结果走 stdout, - * token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(与 pr-agent run 同一套)。 + * A single raw dialogue call of the orchestrator's "standalone LLM channel" (see + * docs/arch/02-agent/02-session.md "Session Agentification"). Reuses the embedded runtime's litellm + * (provider routing / proxy / token collection already solved): the subprocess runs + * `meebox_pragent_shim.chat`, the prompt is passed via stdin, the result goes through stdout, and token + * usage is printed to stderr via the `@@MEEBOX_USAGE@@` sentinel (the same setup as pr-agent run). */ export interface ChatRunOptions { - /** system 段(可空)。 */ + /** system section (nullable). */ system?: string; - /** user 段(必填)。 */ + /** user section (required). */ user: string; - /** 采样温度;anthropic 经 shim 自动剔除。 */ + /** Sampling temperature; automatically stripped for anthropic via the shim. */ temperature?: number; /** - * 输出 token 上限(litellm max_tokens)。用于轻量路由判读(如追问判读)封顶输出、避免模型 - * 对一个 yes/no 决策吐大量 token 拖慢响应。仅嵌入式 litellm 路径生效;CLI provider 忽略。 - * 省略 = 不封顶(总结 / 规划收尾等需完整篇幅者不传)。 + * Output token cap (litellm max_tokens). Used to cap output for lightweight routing judgments (e.g. + * follow-up judgment), avoiding the model spewing large amounts of tokens for a yes/no decision and + * slowing the response. Effective only on the embedded litellm path; ignored by the CLI provider. + * Omitted = no cap (not passed for those needing full length, e.g. summarization / planning wrap-up). */ maxOutputTokens?: number; - /** 注入子进程的 env(LLM key / model / 代理,复用 buildPragentEnv)。 */ + /** env injected into the subprocess (LLM key / model / proxy, reuses buildPragentEnv). */ env?: Record<string, string>; - /** 子进程工作目录;建议传中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 */ + /** Subprocess working directory; recommended to pass a neutral temp directory (in cli mode, to avoid picking up the reviewed repo's CLAUDE.md). */ cwd?: string; timeoutMs?: number; signal?: AbortSignal; } /** - * 注入式子进程执行接口:默认实现走 node:child_process.spawn,单测可注入 fake 版本 - * 不依赖真实 Node 子进程。Bridge 层不直接 import spawn,只依赖此接口。 + * Injectable subprocess exec interface: the default implementation goes through node:child_process.spawn, + * and unit tests can inject a fake version without depending on a real Node subprocess. The Bridge layer + * does not import spawn directly, only depending on this interface. */ export type ExecFn = (cmd: string, args: string[], opts: ExecOptions) => Promise<PrAgentRunResult>; export interface PrAgentBridge { - /** 解析后选中的策略名 */ + /** The selected strategy name after resolution */ readonly strategy: PrAgentStrategy; - /** 探测时拿到的版本字符串(CLI --help / --version 首行,或嵌入式查出的 pr-agent 版本) */ + /** Version string obtained during detection (CLI --help / --version first line, or the pr-agent version discovered by the embedded runtime) */ readonly version: string; - /** 跑 /describe;等价 run({ ...opts, tool: 'describe' }) */ + /** Run /describe; equivalent to run({ ...opts, tool: 'describe' }) */ describe(opts: Omit<PrAgentRunOptions, 'tool'>): Promise<PrAgentRunResult>; - /** 跑 /review;等价 run({ ...opts, tool: 'review' }) */ + /** Run /review; equivalent to run({ ...opts, tool: 'review' }) */ review(opts: Omit<PrAgentRunOptions, 'tool'>): Promise<PrAgentRunResult>; - /** 通用入口;后续加 /ask /improve 时直接用 run */ + /** Generic entry; use run directly when adding /ask /improve later */ run(opts: PrAgentRunOptions): Promise<PrAgentRunResult>; /** - * 编排器的独立 LLM 对话通道(复用嵌入式 litellm,见 ChatRunOptions)。 - * 仅嵌入式策略支持;local-cli 策略调用即抛错。结果 stdout = 回复文本, - * stderr 含 `@@MEEBOX_USAGE@@` token 哨兵(调用方按既有解析器累加)。 + * The orchestrator's standalone LLM dialogue channel (reuses the embedded litellm, see ChatRunOptions). + * Supported only by the embedded strategy; the local-cli strategy throws on call. Result stdout = reply + * text, stderr contains the `@@MEEBOX_USAGE@@` token sentinel (accumulated by the caller via the existing parser). */ chat(opts: ChatRunOptions): Promise<PrAgentRunResult>; } diff --git a/packages/pr-agent-bridge/tests/bridge.test.ts b/packages/pr-agent-bridge/tests/bridge.test.ts index 52faedf0..709e45bb 100644 --- a/packages/pr-agent-bridge/tests/bridge.test.ts +++ b/packages/pr-agent-bridge/tests/bridge.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { EmbeddedRuntimeBridge, LocalCliBridge } from '../src/bridge.js'; import type { ExecFn, ExecOptions, PrAgentRunResult } from '../src/types.js'; -/** 收集所有 exec 调用便于断言 cmd / args / env / timeoutMs */ +/** Collect all exec calls to ease asserting cmd / args / env / timeoutMs */ function makeRecordingExec(returnValue?: Partial<PrAgentRunResult>): { exec: ExecFn; calls: Array<{ cmd: string; args: string[]; opts: ExecOptions }>; @@ -22,7 +22,7 @@ function makeRecordingExec(returnValue?: Partial<PrAgentRunResult>): { } describe('LocalCliBridge', () => { - it('describe 走 pr-agent --pr_url <url> describe', async () => { + it('describe goes through pr-agent --pr_url <url> describe', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('pr-agent 0.36.0', exec); await bridge.describe({ prUrl: 'https://bb/projects/X/repos/y/pull-requests/1' }); @@ -35,7 +35,7 @@ describe('LocalCliBridge', () => { ]); }); - it('review 末尾追加 extraArgs', async () => { + it('review appends extraArgs at the end', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.review({ @@ -51,28 +51,28 @@ describe('LocalCliBridge', () => { ]); }); - it('env 通过 exec opts.env 透传(LocalCli 由 exec 层 merge process.env)', async () => { + it('env is passed through via exec opts.env (LocalCli merges process.env at the exec layer)', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.review({ prUrl: 'https://x/pr/1', env: { OPENAI_KEY: 'sk-test' } }); expect(calls[0]!.opts.env).toEqual({ OPENAI_KEY: 'sk-test' }); }); - it('未给 timeoutMs 时落到默认 10 min', async () => { + it('falls to the default 10 min when timeoutMs is not given', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.describe({ prUrl: 'https://x/pr/1' }); expect(calls[0]!.opts.timeoutMs).toBe(10 * 60 * 1000); }); - it('显式 timeoutMs 覆盖默认值', async () => { + it('explicit timeoutMs overrides the default', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.describe({ prUrl: 'https://x/pr/1', timeoutMs: 30_000 }); expect(calls[0]!.opts.timeoutMs).toBe(30_000); }); - it('onLine 透传给 exec', async () => { + it('onLine is passed through to exec', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); const onLine = vi.fn(); @@ -80,17 +80,17 @@ describe('LocalCliBridge', () => { expect(calls[0]!.opts.onLine).toBe(onLine); }); - it('strategy + version 暴露', () => { + it('strategy + version are exposed', () => { const bridge = new LocalCliBridge('pr-agent 0.36.0', makeRecordingExec().exec); expect(bridge.strategy).toBe('local-cli'); expect(bridge.version).toBe('pr-agent 0.36.0'); }); - it('cwd 配置后切到 local-mode: --pr_url 的值是 target branch 名 (pr-agent local provider 约定)', async () => { + it('after cwd is configured, switches to local-mode: --pr_url value is the target branch name (pr-agent local provider convention)', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.review({ - prUrl: 'https://x/pr/1', // 本地模式下 prUrl 不会被用到 + prUrl: 'https://x/pr/1', // in local mode prUrl is not used cwd: '/tmp/wt/abc', targetBranch: 'pr-abc123/base', env: { OPENAI_KEY: 'sk' }, @@ -103,7 +103,7 @@ describe('LocalCliBridge', () => { }); }); - it('local-mode 无 targetBranch: --pr_url 留空 (调用方应保证传)', async () => { + it('local-mode without targetBranch: --pr_url left empty (caller should guarantee passing it)', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new LocalCliBridge('v', exec); await bridge.review({ prUrl: 'unused', cwd: '/tmp/wt' }); @@ -111,7 +111,7 @@ describe('LocalCliBridge', () => { expect(calls[0]!.opts.env).toEqual({ CONFIG__GIT_PROVIDER: 'local' }); }); - it('chat: local-cli 不支持,调用即抛错(无嵌入式运行时)', async () => { + it('chat: local-cli is not supported, throws on call (no embedded runtime)', async () => { const bridge = new LocalCliBridge('v', makeRecordingExec().exec); await expect(bridge.chat({ user: 'hi' })).rejects.toThrow(/嵌入式/); }); @@ -120,7 +120,7 @@ describe('LocalCliBridge', () => { describe('EmbeddedRuntimeBridge', () => { const PY = '/app/vendor/pragent/python/bin/python3'; - it('local-mode: 用嵌入式解释器 -m pr_agent.cli + target branch + local provider', async () => { + it('local-mode: uses the embedded interpreter -m pr_agent.cli + target branch + local provider', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new EmbeddedRuntimeBridge('embedded Python 3.12.13', PY, exec); await bridge.review({ @@ -146,7 +146,7 @@ describe('EmbeddedRuntimeBridge', () => { }); }); - it('extraArgs 追加在末尾', async () => { + it('extraArgs are appended at the end', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new EmbeddedRuntimeBridge('v', PY, exec); await bridge.review({ @@ -166,13 +166,13 @@ describe('EmbeddedRuntimeBridge', () => { ]); }); - it('strategy + version 暴露', () => { + it('strategy + version are exposed', () => { const bridge = new EmbeddedRuntimeBridge('embedded Python 3.12.13', PY, makeRecordingExec().exec); expect(bridge.strategy).toBe('embedded'); expect(bridge.version).toBe('embedded Python 3.12.13'); }); - it('chat: 跑 meebox_pragent_shim.chat,prompt 走 stdin,UTF-8 + 中性 cwd', async () => { + it('chat: runs meebox_pragent_shim.chat, prompt goes through stdin, UTF-8 + neutral cwd', async () => { const { exec, calls } = makeRecordingExec({ stdout: 'reply' }); const bridge = new EmbeddedRuntimeBridge('v', PY, exec); const res = await bridge.chat({ @@ -194,7 +194,7 @@ describe('EmbeddedRuntimeBridge', () => { expect(calls[0]!.opts.timeoutMs).toBe(5 * 60 * 1000); }); - it('chat: temperature 仅在显式传入时进 payload', async () => { + it('chat: temperature enters the payload only when explicitly passed', async () => { const { exec, calls } = makeRecordingExec(); const bridge = new EmbeddedRuntimeBridge('v', PY, exec); await bridge.chat({ user: 'hi', temperature: 0.7 }); diff --git a/packages/pr-agent-bridge/tests/detect.test.ts b/packages/pr-agent-bridge/tests/detect.test.ts index 53cff737..db701f48 100644 --- a/packages/pr-agent-bridge/tests/detect.test.ts +++ b/packages/pr-agent-bridge/tests/detect.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; import { createPrAgentBridge, detectPrAgent } from '../src/detect.js'; -// 用 process.execPath(node 本身,响应 `--version`)冒充嵌入式解释器,确定性、离线地 -// 验证 embedded 探测/选择/构造,不依赖真实 vendor 运行时或网络。 +// Use process.execPath (node itself, responds to `--version`) to impersonate the embedded interpreter, +// deterministically and offline verifying embedded detect/select/construct, without the real vendor runtime or network. const FAKE_PY = process.execPath; -describe('detect: embedded strategy 选择', () => { - it('embeddedPythonPath 存在 + forceStrategy=embedded → 选中 embedded 并构造对应 bridge', async () => { +describe('detect: embedded strategy selection', () => { + it('embeddedPythonPath exists + forceStrategy=embedded → selects embedded and constructs the matching bridge', async () => { const { bridge, status } = await createPrAgentBridge({ embeddedPythonPath: FAKE_PY, forceStrategy: 'embedded', @@ -14,19 +14,19 @@ describe('detect: embedded strategy 选择', () => { expect(status.available).toBe(true); if (status.available) { expect(status.strategy).toBe('embedded'); - // version 形如 `pr-agent <ver>`;FAKE_PY 非真 python 拿不到版本 → `pr-agent unknown` + // version looks like `pr-agent <ver>`; FAKE_PY is not real python so no version → `pr-agent unknown` expect(status.version.startsWith('pr-agent ')).toBe(true); } expect(bridge?.strategy).toBe('embedded'); }); - it('auto 模式下 embedded 排在最前(路径存在即优先选中)', async () => { + it('in auto mode embedded ranks first (selected as soon as the path exists)', async () => { const status = await detectPrAgent({ embeddedPythonPath: FAKE_PY, forceStrategy: 'auto' }); expect(status.available).toBe(true); if (status.available) expect(status.strategy).toBe('embedded'); }); - it('forceStrategy=embedded 但路径不存在 → 不回退、报告 unavailable', async () => { + it('forceStrategy=embedded but path does not exist → no fallback, reports unavailable', async () => { const status = await detectPrAgent({ embeddedPythonPath: '/no/such/python-xyz', forceStrategy: 'embedded', @@ -34,8 +34,8 @@ describe('detect: embedded strategy 选择', () => { expect(status.available).toBe(false); }); - it('未传 embeddedPythonPath → embedded 不参与(回退探测 local-cli)', async () => { - // 这里只断言不会因 embedded 缺失而抛错;local-cli 是否可用取决于环境 + it('embeddedPythonPath not passed → embedded is excluded (falls back to detecting local-cli)', async () => { + // Only asserts it does not throw due to missing embedded; whether local-cli is available depends on the environment const status = await detectPrAgent({}); expect(status.available === true || status.available === false).toBe(true); if (status.available) expect(status.strategy).not.toBe('embedded'); diff --git a/packages/pr-agent-bridge/tests/exec.test.ts b/packages/pr-agent-bridge/tests/exec.test.ts index 787e895d..db8099de 100644 --- a/packages/pr-agent-bridge/tests/exec.test.ts +++ b/packages/pr-agent-bridge/tests/exec.test.ts @@ -4,8 +4,8 @@ import { createExec, type SpawnFn } from '../src/exec.js'; import { PrAgentRunError } from '../src/types.js'; /** - * 极薄 fake spawn:返回一个 EventEmitter-like child,测试主动 emit data/close/error。 - * stdout/stderr 是分开的 emitter,子进程层只听 'data'。 + * Ultra-thin fake spawn: returns an EventEmitter-like child; the test actively emits data/close/error. + * stdout/stderr are separate emitters, the subprocess layer only listens on 'data'. */ function fakeChild(): { stdout: EventEmitter; @@ -48,7 +48,7 @@ function buildSpawn(child: ReturnType<typeof fakeChild>): SpawnFn { } describe('createExec', () => { - it('正常 exit 0 → resolve 带 stdout / stderr / exitCode / durationMs', async () => { + it('normal exit 0 → resolve with stdout / stderr / exitCode / durationMs', async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const promise = exec('pr-agent', ['--help'], { timeoutMs: 1000 }); @@ -62,7 +62,7 @@ describe('createExec', () => { expect(r.durationMs).toBeGreaterThanOrEqual(0); }); - it('非零 exit → reject(PrAgentRunError "non-zero-exit") 携带已收集输出', async () => { + it('non-zero exit → reject(PrAgentRunError "non-zero-exit") carrying collected output', async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const promise = exec('pr-agent', [], { timeoutMs: 1000 }); @@ -75,7 +75,7 @@ describe('createExec', () => { }); }); - it('信号杀(非超时)→ reason "killed"', async () => { + it('killed by signal (not a timeout) → reason "killed"', async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const promise = exec('pr-agent', [], { timeoutMs: 5000 }); @@ -86,14 +86,14 @@ describe('createExec', () => { }); }); - it('超时触发 SIGKILL 并 reject reason "timeout"', async () => { + it('timeout triggers SIGKILL and rejects with reason "timeout"', async () => { vi.useFakeTimers(); const child = fakeChild(); const exec = createExec(buildSpawn(child)); const promise = exec('pr-agent', [], { timeoutMs: 100 }); vi.advanceTimersByTime(101); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - // exec 在 close 之后才 settle;模拟内核响应 SIGKILL 后 close + // exec only settles after close; simulate the kernel responding to SIGKILL then closing child.emitClose(null, 'SIGKILL'); await expect(promise).rejects.toMatchObject({ name: 'PrAgentRunError', @@ -102,7 +102,7 @@ describe('createExec', () => { vi.useRealTimers(); }); - it("'error' 事件 (spawn ENOENT) → reason \"spawn-failed\"", async () => { + it("'error' event (spawn ENOENT) → reason \"spawn-failed\"", async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const promise = exec('not-exists', [], { timeoutMs: 1000 }); @@ -113,7 +113,7 @@ describe('createExec', () => { }); }); - it('spawnFn throw → 立即 reject reason "spawn-failed"', async () => { + it('spawnFn throw → immediately reject with reason "spawn-failed"', async () => { const exec = createExec(() => { throw new Error('boom'); }); @@ -122,7 +122,7 @@ describe('createExec', () => { }); }); - it('onLine 按 \\n 切片实时回调;尾部 partial 在 close 时补一次', async () => { + it('onLine slices on \\n with realtime callbacks; trailing partial flushed once at close', async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const lines: Array<[string, string]> = []; @@ -142,7 +142,7 @@ describe('createExec', () => { ]); }); - it('onLine 兼容 \\r\\n 行尾(Windows 输出)', async () => { + it('onLine handles \\r\\n line endings (Windows output)', async () => { const child = fakeChild(); const exec = createExec(buildSpawn(child)); const lines: string[] = []; @@ -156,7 +156,7 @@ describe('createExec', () => { expect(lines).toEqual(['win line', 'next']); }); - it('PrAgentRunError 是 Error 子类', () => { + it('PrAgentRunError is an Error subclass', () => { const e = new PrAgentRunError('x', 'timeout'); expect(e).toBeInstanceOf(Error); expect(e.name).toBe('PrAgentRunError'); diff --git a/packages/pr-agent-bridge/tests/prompts.test.ts b/packages/pr-agent-bridge/tests/prompts.test.ts index a62ab9fe..68656d74 100644 --- a/packages/pr-agent-bridge/tests/prompts.test.ts +++ b/packages/pr-agent-bridge/tests/prompts.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { stripAskQuestionEcho } from '../src/prompts.js'; describe('stripAskQuestionEcho', () => { - it('按 pr-agent 的 Answer 表头切,丢弃问题回显 + 注入的格式指令(含字面示例标签)', () => { - // pr-agent `_prepare_pr_answer` 产物形态:Ask 段回显问题 + 我们拼进问题的结构化指令(含示例 - // <summary> 标签),Answer 段才是真答案。结构化解析须只见到答案,否则会误把示例标签当答案。 + it('splits on pr-agent Answer header, dropping question echo + injected format instructions (incl. literal example tags)', () => { + // pr-agent `_prepare_pr_answer` output shape: the Ask section echoes the question + the structured instructions + // we appended to it (incl. example <summary> tags), and only the Answer section is the real answer. Structured + // parsing must see only the answer, otherwise it mistakes the example tags for the answer. const md = [ '### **Ask**❓', '这个函数有什么问题?', @@ -22,17 +23,17 @@ describe('stripAskQuestionEcho', () => { expect(out).toContain('<summary>真正的结论</summary>'); expect(out).not.toContain('A direct answer (example)'); expect(out).not.toContain('### **Ask**'); - // 示例 <summary> 标签(问题回显里的)已被切掉,只剩答案里的那一个。 + // The example <summary> tag (from the question echo) has been cut, leaving only the one in the answer. expect(out.match(/<summary>/g)?.length).toBe(1); }); - it('无 Answer 表头(版本漂移)→ 回退逐行精确删问题 / 语言后缀', () => { + it('no Answer header (version drift) → falls back to line-by-line exact removal of question / language suffix', () => { const md = ['这个函数有什么问题?', '答案正文。', '请用简体中文回答。'].join('\n'); const out = stripAskQuestionEcho(md, '这个函数有什么问题?', '请用简体中文回答。'); expect(out).toBe('答案正文。'); }); - it('空输入安全返回', () => { + it('empty input returns safely', () => { expect(stripAskQuestionEcho('', 'q')).toBe(''); }); }); diff --git a/packages/repo-mirror/src/repo-mirror-manager.ts b/packages/repo-mirror/src/repo-mirror-manager.ts index c252b53c..782e9c97 100644 --- a/packages/repo-mirror/src/repo-mirror-manager.ts +++ b/packages/repo-mirror/src/repo-mirror-manager.ts @@ -16,13 +16,13 @@ import type { } from './types.js'; /** - * simple-git 的 blockUnsafeOperationsPlugin 会扫描传给 `.env()` 的 env 对象,命中这批 - * "危险" key(小写匹配)就抛 `Use of "X" is not permitted without enabling allowUnsafeXxx`。 - * 列表对齐 simple-git v3 env policy。宿主常见的 EDITOR / PAGER / SSH_ASKPASS / PREFIX 都在内。 + * simple-git's blockUnsafeOperationsPlugin scans the env object passed to `.env()`; hitting one of these + * "dangerous" keys (matched lowercase) throws `Use of "X" is not permitted without enabling allowUnsafeXxx`. + * The list aligns with simple-git v3 env policy. Common host vars EDITOR / PAGER / SSH_ASKPASS / PREFIX are included. * - * 我们给远端 git 挂代理时必须 merge process.env(否则 PATH/HOME 全丢),但 merge 会把这些 - * 宿主变量带进 .env() 触发校验。对无人值守的 clone/fetch 这些(编辑器/pager/askpass/外部 - * config 路径等)一律用不到,merge 时统一剔除,比逐个开 allowUnsafe 标志更稳更全。 + * When attaching a proxy to remote git we must merge process.env (otherwise PATH/HOME are all lost), but the merge + * carries these host vars into .env() and triggers the check. For unattended clone/fetch these (editor/pager/askpass/external + * config paths etc.) are never used, so strip them uniformly at merge time — more robust and complete than enabling allowUnsafe flags one by one. */ const GIT_UNSAFE_ENV_KEYS = new Set([ 'editor', @@ -45,10 +45,10 @@ const GIT_UNSAFE_ENV_KEYS = new Set([ 'prefix', ]); -/** Promise 版 execFile:用于 simple-git 会因非零退出吞掉 stdout 的命令(如 merge-tree 冲突时退出码 1)。 */ +/** Promise version of execFile: for commands where simple-git swallows stdout on non-zero exit (e.g. merge-tree returns exit code 1 on conflict). */ const execFileAsync = promisify(execFile); -/** 从 env 里剔除 simple-git 会拦的危险 key(大小写不敏感)。 */ +/** Strip from env the dangerous keys simple-git blocks (case-insensitive). */ function stripGitUnsafeEnv(env: Record<string, string>): Record<string, string> { const out: Record<string, string> = {}; for (const [k, v] of Object.entries(env)) { @@ -58,76 +58,77 @@ function stripGitUnsafeEnv(env: Record<string, string>): Record<string, string> } export interface RepoMirrorOptions { - /** repos_dir 根(来自 config.workspace.repos_dir,已展开 ~) */ + /** repos_dir root (from config.workspace.repos_dir, ~ already expanded) */ reposDir: string; - /** 由 PlatformAdapter 提供:给一个 repo 返回带认证的 clone URL */ + /** Provided by PlatformAdapter: returns an authenticated clone URL for a repo */ getCloneUrl: (repo: RepoIdentity) => Promise<string>; logger?: Logger; - /** 可选 sync 进度回调;clone/fetch 期间分阶段发出 start/progress/done/error */ + /** Optional sync progress callback; emits start/progress/done/error in phases during clone/fetch */ onProgress?: (event: SyncProgressEvent) => void; /** - * 可选出站代理 env。getter 形式,每次远端 clone/fetch 前求值, - * 让设置页改代理后下次操作即生效。返回 HTTP(S)_PROXY/NO_PROXY 等;关闭时返回 {}。 - * 仅作用于打远端的 clone/fetch;本地只读 git 操作不注入。 + * Optional outbound proxy env. Getter form, evaluated before each remote clone/fetch, + * so a proxy change in the settings page takes effect on the next operation. Returns HTTP(S)_PROXY/NO_PROXY etc.; returns {} when disabled. + * Applies only to clone/fetch that hit the remote; local read-only git operations are not injected. */ proxyEnv?: () => Record<string, string>; } /** - * 本地 git 镜像管理。**全局** sync 队列:任意时刻只有 1 个 repo 在 clone/fetch。 - * 多个调用方 (UI 切 PR / 主进程 schedule) 都共用此队列,不并发打 Bitbucket、不抢 - * git 进程带宽,用户感知到的进度更稳。 + * Local git mirror management. **Global** sync queue: at most 1 repo is cloning/fetching at any time. + * Multiple callers (UI switching PRs / main-process schedule) share this queue, so Bitbucket is not hit + * concurrently and git-process bandwidth is not contended — the progress the user perceives is steadier. * - * 读操作 (listChangedFiles / getFileContent / getSize) 不走队列,对本地 bare - * 镜像并发只读安全。 + * Read operations (listChangedFiles / getFileContent / getSize) skip the queue; concurrent read-only + * access to the local bare mirror is safe. * - * 策略:`git clone --bare`(完整 bare 镜像,含全部 blobs)。早期用过 - * `--filter=blob:none` partial clone 省盘,但 `git blame --porcelain` 会触发 - * 全量历史 blob 的按需拉取;M2-D 实测下来要么慢、要么远端不全时直接 fatal - * 退出。为换取 blame / pr-agent 这类需要历史 blob 的工具能稳定跑,改回完整 - * bare clone,磁盘占用代价交给用户在设置页可见的总占用 + 可换 `repos_dir`。 + * Strategy: `git clone --bare` (full bare mirror, including all blobs). Early on we used + * `--filter=blob:none` partial clone to save disk, but `git blame --porcelain` triggers + * on-demand fetching of the full history's blobs; in M2-D testing it was either slow or, when the remote + * was incomplete, exited fatal outright. To let blame / pr-agent and similar tools that need history blobs run + * stably, we reverted to a full bare clone, leaving the disk-usage cost to the user's visible total in the + * settings page + a switchable `repos_dir`. * - * 后续 fetch 走 `git fetch`,增量。 + * Subsequent fetches go through `git fetch`, incremental. * - * 不做 worktree。M2 范围内 diff 计算走 `git show <sha>:<path>`,不需要把 - * 文件 checkout 到磁盘。M3 接 pr-agent 时再看是否需要 worktree。 + * No worktree. Within the M2 scope, diff computation uses `git show <sha>:<path>`, with no need to + * check out files to disk. When wiring up pr-agent in M3 we will revisit whether a worktree is needed. */ export class RepoMirrorManager { - /** 全局单队列指针;每次新 syncMirror 都接到它的尾部,串行执行。 */ + /** Global single-queue pointer; each new syncMirror chains onto its tail and runs serially. */ private syncQueue: Promise<unknown> = Promise.resolve(); - /** 按 repoKey 索引正在跑(或排队)的 sync Promise;同 repo 并发调用复用。 */ + /** Indexes running (or queued) sync Promises by repoKey; concurrent calls for the same repo reuse it. */ private readonly inFlight = new Map<string, Promise<MirrorResult>>(); constructor(private readonly opts: RepoMirrorOptions) {} /** - * 给打远端的 simple-git 实例挂代理 env。代理关闭 / 未配置时原样返回 - * (git 子进程继承 process.env)。注意 simple-git 的 .env() 整体替换子进程 env, - * 故必须 merge process.env,否则 PATH / HOME 等全丢。 + * Attach proxy env to a simple-git instance that hits the remote. Returns as-is when the proxy is + * disabled / unconfigured (the git subprocess inherits process.env). Note simple-git's .env() replaces the + * subprocess env wholesale, so process.env must be merged, otherwise PATH / HOME etc. are all lost. */ private withProxyEnv(git: SimpleGit): SimpleGit { const px = this.opts.proxyEnv?.() ?? {}; if (Object.keys(px).length === 0) return git; - // 剔除宿主 EDITOR/PAGER/SSH_ASKPASS 等:simple-git 的安全插件会拦截传给 .env() 的 - // 这些 key 并抛 allowUnsafeEditor 等错误(见 GIT_UNSAFE_ENV_KEYS 注释)。 + // Strip host EDITOR/PAGER/SSH_ASKPASS etc.: simple-git's safety plugin intercepts these keys passed to + // .env() and throws errors like allowUnsafeEditor (see the GIT_UNSAFE_ENV_KEYS comment). const merged = stripGitUnsafeEnv({ ...process.env, ...px } as Record<string, string>); return git.env(merged); } - /** 计算 bare 镜像应当落在哪里(不保证存在)。 */ + /** Compute where the bare mirror should live (existence not guaranteed). */ mirrorPath(repo: RepoIdentity): string { return path.join(this.opts.reposDir, repo.host, repo.projectKey, repo.repoSlug, 'bare'); } /** - * 检查指定 commit sha 在本地 bare 镜像里是否可达。用于"打开 PR 时若本地已经 - * 包含 head + base sha 就跳过 fetch"的预检 (省一趟网络往返)。 + * Check whether the given commit sha is reachable in the local bare mirror. Used as a precheck for + * "when opening a PR, skip the fetch if the local mirror already contains head + base sha" (saving a network round-trip). * - * 实现:`git cat-file -e <sha>^{commit}` —— 只验证存在性且确实是 commit - * 类型 (不是 tree/blob),命中 exit 0、缺失 exit 非 0。比 `rev-parse` 更轻 - * (不解析 reflog / refs),比 `log -1` 更精确 (后者部分远端不全的对象也能"看见")。 + * Implementation: `git cat-file -e <sha>^{commit}` — verifies only existence and that it really is a commit + * type (not tree/blob); hit is exit 0, missing is exit non-0. Lighter than `rev-parse` + * (does not resolve reflog / refs) and more precise than `log -1` (the latter can "see" some objects even when the remote is incomplete). * - * 镜像目录不存在 → 直接 false (尚未 clone)。git 错误 → 视为 false (保守)。 + * Mirror directory missing → false directly (not yet cloned). git error → treated as false (conservative). */ async hasCommit(repo: RepoIdentity, sha: string): Promise<boolean> { if (!sha) return false; @@ -146,10 +147,10 @@ export class RepoMirrorManager { } /** - * 按给定 refspec best-effort fetch 进 bare 镜像(典型用法:补一道平台 PR 头引用 - * `refs/pull/<n>/head` 等,把被删 / 强推源分支的 PR head sha 钉回本地——`refs/heads/*` 已看不到它)。 - * 镜像不存在 / fetch 失败均**不抛**(网络 / 远端拒绝 / 该 PR 引用不存在都可能),调用方随后自行复验 - * hasCommit。空 refspec 直接返回。前置:调用方已 await 过 syncMirror,故与全局 sync 队列无并发。 + * Best-effort fetch of the given refspec into the bare mirror (typical use: pull in a platform PR head ref + * `refs/pull/<n>/head` etc., pinning back the PR head sha of a deleted / force-pushed source branch — `refs/heads/*` no longer sees it). + * Neither a missing mirror nor a fetch failure **throws** (network / remote rejection / that PR ref not existing are all possible); the caller re-verifies + * with hasCommit afterward. Empty refspec returns directly. Precondition: the caller has already awaited syncMirror, so there is no concurrency with the global sync queue. */ async fetchRefspecs(repo: RepoIdentity, refspecs: string[]): Promise<void> { if (refspecs.length === 0) return; @@ -174,11 +175,11 @@ export class RepoMirrorManager { } /** - * 镜像是否「健康」:是有效 git 目录且 origin remote 已配置。clone/fetch 中途被打断会留下「HEAD 在、 - * 但 git 元数据残缺(常缺 origin remote)」的目录,仅判 HEAD 存在会误以为可 fetch → `git fetch origin` - * 直接 fatal(`'origin' does not appear to be a git repository`)。用 `git config --get - * remote.origin.url` 同时验证两点:命令在非 git 目录会失败,origin 未配置则无输出 → 任一不满足即不健康, - * 调用方据此删库重建(见 doSyncMirror 的主动自愈)。 + * Whether the mirror is "healthy": a valid git directory with origin remote configured. A clone/fetch interrupted midway leaves a directory where "HEAD exists + * but git metadata is incomplete (origin remote often missing)"; checking only that HEAD exists wrongly assumes it can fetch → `git fetch origin` + * goes fatal outright (`'origin' does not appear to be a git repository`). Using `git config --get + * remote.origin.url` verifies both at once: the command fails in a non-git directory, and with origin unconfigured there is no output → failing either means unhealthy, + * on which the caller deletes and rebuilds the repo (see doSyncMirror's proactive self-heal). */ private async isHealthyMirror(mirrorPath: string): Promise<boolean> { try { @@ -190,16 +191,16 @@ export class RepoMirrorManager { } /** - * 计算 base..head 之间「源分支主干自产」的 commit 数 (PR 引入的提交数)。完全走本地 bare 镜像 - * `git rev-list --count --first-parent --no-merges <base>..<head>` —— 不打远端,毫秒级返回。 + * Count the "source-branch mainline self-produced" commits between base..head (the number of commits the PR introduces). Runs entirely on the local bare mirror + * `git rev-list --count --first-parent --no-merges <base>..<head>` — no remote, returns in milliseconds. * - * 用途:UI 在 PR 标签页上展示 commits 数角标,不必为了一个数字去拉远端。base 传**分叉点 sha** - * (merge-base)时 base..head = 源分支自分叉后引入的提交;`--first-parent` 只沿源分支主干,把 - * 历史上 merge 其它分支带进来的他人提交一并排除,`--no-merges` 再略去 merge 提交本身——口径 - * 与 {@link listIntroducedCommitShas}(commit 列表 / 活动时间线的过滤集)一致,避免角标与列表对不上。 + * Use: the UI shows a commits-count badge on the PR tab without hitting the remote just for a number. When base is the **fork-point sha** + * (merge-base), base..head = the commits the source branch introduces since forking; `--first-parent` follows only the source-branch mainline, excluding + * others' commits brought in by historical merges of other branches, and `--no-merges` further omits the merge commits themselves — the same criterion + * as {@link listIntroducedCommitShas} (the filter set for the commit list / activity timeline), avoiding a mismatch between the badge and the list. * - * 任一 sha 不在本地镜像 (尚未 sync 到本 PR 范围) → 返回 null,调用方把它 - * 当 "暂时未知" 处理 (不显示角标 / 显示加载占位)。 + * If either sha is not in the local mirror (not yet synced to this PR's range) → returns null, and the caller treats it + * as "temporarily unknown" (no badge / shows a loading placeholder). */ async countCommits(repo: RepoIdentity, baseSha: string, headSha: string): Promise<number | null> { const shas = await this.listIntroducedCommitShas(repo, baseSha, headSha); @@ -207,17 +208,17 @@ export class RepoMirrorManager { } /** - * 列出 base..head 之间「源分支主干自产」的提交 SHA(40-char),newest-first。完全走本地 bare - * 镜像 `git rev-list --first-parent --no-merges <base>..<head>` —— 不打远端。 + * List the "source-branch mainline self-produced" commit SHAs (40-char) between base..head, newest-first. Runs entirely on the local bare + * mirror `git rev-list --first-parent --no-merges <base>..<head>` — no remote. * - * `--first-parent` 只沿源分支主干遍历:历史上把别的分支 merge 进源分支带来的**他人提交**(落在 - * merge 提交的第二父侧)不会进入结果;`--no-merges` 再剔除 merge 提交本身。最终只剩源分支上直接 - * 产出的提交。 + * `--first-parent` traverses only the source-branch mainline: **others' commits** brought in by historically merging other branches into the source branch (landing on + * the merge commit's second-parent side) do not enter the result; `--no-merges` further removes the merge commits themselves. What remains is only the commits produced directly + * on the source branch. * - * 用途:把平台 `/commits` 端点返回的完整列表(`target..source` 全集,含 merge 及合入的他人提交) - * 过滤为「本 PR 真正引入的提交」,消除长期分支 / fork 同步分支反复 merge 造成的列表噪声。 + * Use: filter the full list returned by the platform `/commits` endpoint (the whole `target..source` set, including merges and merged-in others' commits) + * down to "the commits this PR truly introduces", eliminating the list noise caused by long-lived branches / fork-sync branches repeatedly merging. * - * 任一 sha 不在本地镜像(尚未 sync 到本 PR 范围)→ 返回 null,调用方退回未过滤的平台列表。 + * If either sha is not in the local mirror (not yet synced to this PR's range) → returns null, and the caller falls back to the unfiltered platform list. */ async listIntroducedCommitShas( repo: RepoIdentity, @@ -248,9 +249,9 @@ export class RepoMirrorManager { } /** - * `git merge-base a b` —— 两 sha 的最近共同祖先(PR 源分支自目标分叉处)。 - * 用于把 PR diff 的 base 锚到分叉点(而非随别的 PR 合入而前移的目标分支 tip)。 - * 任一 sha 缺失 / 无共同祖先 / 缺对象 → 返回 null,调用方兜底(不固化、下次再试)。 + * `git merge-base a b` — the nearest common ancestor of two shas (where the PR source branch forked from the target). + * Used to anchor the PR diff's base to the fork point (rather than the target-branch tip that moves forward as other PRs merge). + * If either sha is missing / there is no common ancestor / an object is missing → returns null, and the caller falls back (does not persist it, retries next time). */ async mergeBase(repo: RepoIdentity, a: string, b: string): Promise<string | null> { if (!a || !b) return null; @@ -265,9 +266,9 @@ export class RepoMirrorManager { } /** - * `git merge-base --is-ancestor anc desc` —— anc 是否为 desc 的祖先。 - * 用于校验固化的 base 对当前 head 仍有效(head 正常 push 仍成立;被 rebase 则不成立 → 触发重算)。 - * exit 0 → true;exit 1(非祖先)/ 缺对象 → false。 + * `git merge-base --is-ancestor anc desc` — whether anc is an ancestor of desc. + * Used to validate that a persisted base is still valid for the current head (still holds under normal head push; broken after a rebase → triggers recomputation). + * exit 0 → true; exit 1 (not an ancestor) / missing object → false. */ async isAncestor(repo: RepoIdentity, anc: string, desc: string): Promise<boolean> { if (!anc || !desc) return false; @@ -281,14 +282,14 @@ export class RepoMirrorManager { } /** - * 同步镜像:首次 clone bare partial,后续 fetch。 + * Sync the mirror: bare partial clone on first run, fetch thereafter. * - * 调度规则: - * - 同 repo 并发调用 → 复用同一 in-flight Promise(不重复 sync,进度共享) - * - 不同 repo 串行:所有新 sync 都接到全局队列尾部,任意时刻最多 1 个在跑 + * Scheduling rules: + * - Concurrent calls for the same repo → reuse the same in-flight Promise (no duplicate sync, shared progress) + * - Different repos serialize: every new sync chains onto the global queue's tail, at most 1 running at any time * - * 进度通过 onProgress 回调对外发出;多个调用方共用同一 sync 时各自的 - * 订阅者都会收到同一组事件。 + * Progress is emitted via the onProgress callback; when multiple callers share the same sync, each of their + * subscribers receives the same set of events. */ async syncMirror(repo: RepoIdentity): Promise<MirrorResult> { const key = this.repoKey(repo); @@ -296,16 +297,16 @@ export class RepoMirrorManager { if (existing) return existing; const run = (): Promise<MirrorResult> => this.doSyncMirror(repo); - // 不论 prev 成败都执行 run,避免某次失败堵塞后续整条队列 + // Run regardless of prev's success/failure, to avoid one failure blocking the rest of the queue const promise = this.syncQueue.then(run, run); this.inFlight.set(key, promise); - // 队列尾指针推到 next(用 catch 把失败摊平为 undefined,确保下一个 .then - // 的 onFulfilled 被调用);await 的仍然是原 promise,拿到真实结果/异常 + // Push the queue-tail pointer to next (use catch to flatten failure to undefined, ensuring the next .then's + // onFulfilled is called); what is awaited is still the original promise, getting the real result/exception this.syncQueue = promise.catch(() => undefined); - // sync 完成 / 失败后从 in-flight 移除,下次调用可启新的 sync。 - // 先 catch 吃掉 rejection(避免 finally 链上抛 unhandled rejection), - // 调用方 await 原 promise 仍能拿到真实的 reject。 + // After sync completes / fails, remove it from in-flight so the next call can start a new sync. + // catch the rejection first (to avoid throwing an unhandled rejection on the finally chain); + // the caller awaiting the original promise still gets the real reject. promise .catch(() => undefined) .finally(() => { @@ -318,35 +319,35 @@ export class RepoMirrorManager { } /** - * 从 bare mirror 派生一个**自含**的临时工作树,HEAD 在命名分支 `pr-<localId>/head` - * 上指向 headSha;可选再建个 `pr-<localId>/base` 指向 baseSha。 + * Derive a **self-contained** temporary worktree from the bare mirror, with HEAD on the named branch `pr-<localId>/head` + * pointing at headSha; optionally also create `pr-<localId>/base` pointing at baseSha. * - * 为什么需要这样:pr-agent 社区版 `LocalGitProvider.__init__` 做两件强约束的事: - * - `self.head_branch_name = self.repo.head.ref.name` —— HEAD 必须在命名分支 - * 上(不能 detached),否则 GitPython 抛 TypeError - * - `LOCAL__TARGET_BRANCH` 必须是 `self.repo.heads` 里存在的**分支名** - * (不接受 sha),否则 `branches[target_name]` KeyError - * 分支名用 `pr-<localId>/<head|base>`(localId = 每-PR 稳定主键):与 PR 关联便于追溯, - * 又避开跟仓库真实分支的冲突;不带工具品牌前缀,不留固定可辨识特征。未提供 localId - * (包级直调)时回退到本次随机 nonce,保持每次不同。 + * Why this is needed: pr-agent community edition's `LocalGitProvider.__init__` does two strongly-constrained things: + * - `self.head_branch_name = self.repo.head.ref.name` — HEAD must be on a named branch + * (cannot be detached), otherwise GitPython throws TypeError + * - `LOCAL__TARGET_BRANCH` must be a **branch name** that exists in `self.repo.heads` + * (does not accept a sha), otherwise `branches[target_name]` KeyError + * The branch name uses `pr-<localId>/<head|base>` (localId = per-PR stable primary key): associated with the PR for traceability, + * while avoiding collisions with the repo's real branches; no tool brand prefix, leaving no fixed identifiable trait. When localId + * is not provided (direct package-level calls) it falls back to this run's random nonce, staying different each time. * - * 为什么不用 `git worktree add`:worktree 的 `.git` 是个 file,内容是 + * Why not `git worktree add`: a worktree's `.git` is a file whose content is * `gitdir: <bare-host-path>/worktrees/<name>` - * 依赖一个外部 host 绝对路径,bare 仓库移动 / 清理后顺链断裂,GitPython 找不到 - * git dir → 抛 `Could not find repository root`。 + * depending on an external host absolute path; once the bare repo is moved / cleaned up the chain breaks, GitPython cannot find the + * git dir → throws `Could not find repository root`. * - * 实现:`git clone --local --no-checkout` 从 bare 派生独立 repo —— 同盘时 - * objects 走 hardlinks,磁盘 ~0;.git 自含、不依赖外部路径,更稳。再 fetch - * 一道 Bitbucket 专有的 refspec `refs/pull-requests/<id>/from` 把 PR 源 sha 拉齐 (默认 - * refspec 不拉它,否则 PR 源分支被删 / 强推后 checkout 会失败)。 + * Implementation: `git clone --local --no-checkout` derives an independent repo from the bare one — on the same disk + * objects go through hardlinks, disk ~0; the .git is self-contained, does not depend on external paths, more robust. Then fetch + * Bitbucket's dedicated refspec `refs/pull-requests/<id>/from` to bring the PR source sha in sync (the default + * refspec does not fetch it, otherwise checkout fails after the PR source branch is deleted / force-pushed). * - * 返回 `{ path, headBranchName, targetBranchName?, cleanup }`: - * - `headBranchName`:HEAD 当前在的分支名(`pr-<localId>/head`),调用方一般 - * 用不上但留作接口对称 - * - `targetBranchName`:baseSha 传了才有,pr-agent `LOCAL__TARGET_BRANCH` 填它 - * - `cleanup()`:清理临时目录 + * Returns `{ path, headBranchName, targetBranchName?, cleanup }`: + * - `headBranchName`: the branch name HEAD is currently on (`pr-<localId>/head`), which the caller usually + * does not need but is kept for interface symmetry + * - `targetBranchName`: only present when baseSha is passed, filled into pr-agent `LOCAL__TARGET_BRANCH` + * - `cleanup()`: clean up the temporary directory * - * 命名:`<reposDir>/<repo>/wt/<sha12>-<ts>-<rand>`,并发安全靠时间戳 + 随机后缀。 + * Naming: `<reposDir>/<repo>/wt/<sha12>-<ts>-<rand>`, concurrency-safe via timestamp + random suffix. */ async materializeWorktree( repo: RepoIdentity, @@ -365,35 +366,35 @@ export class RepoMirrorManager { const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const wtPath = path.join(wtRoot, `${headSha.slice(0, 12)}-${nonce}`); - // 分支名与 PR 关联(localId = 每-PR 稳定主键)便于追溯,不带工具品牌前缀;无 localId - // (包级直调)时回退到本次随机 nonce。用 `<ns>/head`、`<ns>/base` 分层命名(贴合平台 - // PR ref 惯例如 refs/pull/N/head);不单独建裸 `<ns>` 分支,故无 git dir/file ref 冲突。 + // Branch name associated with the PR (localId = per-PR stable primary key) for traceability, no tool brand prefix; when localId + // is absent (direct package-level calls) fall back to this run's random nonce. Use `<ns>/head`, `<ns>/base` hierarchical naming (matching platform + // PR ref conventions like refs/pull/N/head); no bare `<ns>` branch is created separately, so there is no git dir/file ref collision. const branchNs = prLocalId ? `pr-${prLocalId}` : `pr-${nonce}`; const HEAD_BRANCH = `${branchNs}/head`; const BASE_BRANCH = `${branchNs}/base`; - // --local + 默认 hardlinks:同盘 inode 共享,无 alternates 文件 (避免跨 mount 断链) - // --no-checkout:先不落文件,等分支建好再 checkout + // --local + default hardlinks: same-disk inode sharing, no alternates file (avoids cross-mount chain breakage) + // --no-checkout: do not lay down files yet, checkout after the branch is created await simpleGit(wtRoot).clone(mirrorPath, wtPath, ['--local', '--no-checkout']); - // 禁用 LFS smudge filter:bare mirror 默认不拉 LFS 对象 (--mirror 仅拉 git refs), - // 容器也通常打不到企业内网的 LFS server。让 LFS pointer 保持原样 (几百字节的 - // pointer 文本),否则 `git checkout` 会调 git-lfs 去远端拉真实 blob,smudge 失败 - // → exit。pr-agent review 二进制文件无意义,看到 pointer 文本只是小段元数据。 - // - filter.lfs.smudge=cat:smudge 时直接 cat 文件 (不调 git-lfs) - // - filter.lfs.process=空:清空 long-lived filter process (默认 git-lfs filter-process) - // - filter.lfs.required=false:filter 不存在 / 失败不当致命错误 - // 配置在 .git/config 持久化,pr-agent 容器内继承同一 config,自然也不触发 LFS。 + // Disable the LFS smudge filter: the bare mirror does not fetch LFS objects by default (--mirror fetches only git refs), + // and containers usually cannot reach the enterprise intranet LFS server either. Keep the LFS pointer as-is (a few-hundred-byte + // pointer text), otherwise `git checkout` calls git-lfs to fetch the real blob from the remote, smudge fails + // → exit. pr-agent reviewing a binary file is meaningless; seeing the pointer text is just a small bit of metadata. + // - filter.lfs.smudge=cat: cat the file directly on smudge (does not call git-lfs) + // - filter.lfs.process=empty: clear the long-lived filter process (default git-lfs filter-process) + // - filter.lfs.required=false: a missing / failing filter is not treated as a fatal error + // The config persists in .git/config; the pr-agent container inherits the same config, so it naturally does not trigger LFS either. // - // simple-git 默认禁止设 filter.* (担心被注入任意命令);显式 allowUnsafeFilter - // 仅在这个 simpleGit 实例上 opt-in,其他读操作走默认严格模式 + // simple-git forbids setting filter.* by default (fear of arbitrary command injection); explicit allowUnsafeFilter + // opts in only on this simpleGit instance, while other read operations use the default strict mode const lfsCfg = simpleGit({ baseDir: wtPath, unsafe: { allowUnsafeFilter: true } }); await lfsCfg.raw(['config', '--local', 'filter.lfs.smudge', 'cat']); await lfsCfg.raw(['config', '--local', 'filter.lfs.process', '']); await lfsCfg.raw(['config', '--local', 'filter.lfs.required', 'false']); - // 补 Bitbucket 的 PR 源 sha:`git clone` 默认只拉 refs/heads/*。失败不阻断 - // (heads 里能找到 headSha 也行,例如 GitHub fork) + // Pull in Bitbucket's PR source sha: `git clone` fetches only refs/heads/* by default. Failure does not block + // (finding headSha in heads is also fine, e.g. a GitHub fork) try { await simpleGit(wtPath).raw([ 'fetch', @@ -407,10 +408,10 @@ export class RepoMirrorManager { ); } - // 建命名分支 meebox/head 指向 headSha 并 checkout (pr-agent 要求 HEAD 在命名分支上) + // Create named branch meebox/head pointing at headSha and checkout (pr-agent requires HEAD on a named branch) await simpleGit(wtPath).raw(['checkout', '-b', HEAD_BRANCH, headSha]); - // baseSha 提供时建 meebox/base 指向它 (pr-agent LOCAL__TARGET_BRANCH 只认分支名) + // When baseSha is provided, create meebox/base pointing at it (pr-agent LOCAL__TARGET_BRANCH only accepts a branch name) let targetBranchName: string | undefined; if (baseSha) { await simpleGit(wtPath).raw(['branch', '-f', BASE_BRANCH, baseSha]); @@ -426,7 +427,7 @@ export class RepoMirrorManager { headBranchName: HEAD_BRANCH, targetBranchName, cleanup: async () => { - // 自含 clone,没有 bare 端登记需要清理,直接 rm -rf 即可 + // Self-contained clone, no bare-side registration to clean up, just rm -rf await fs.rm(wtPath, { recursive: true, force: true }).catch((err) => { this.opts.logger?.warn({ err, wtPath }, 'worktree cleanup failed'); }); @@ -434,7 +435,7 @@ export class RepoMirrorManager { }; } - /** 镜像大小(字节)。不存在返回 0。 */ + /** Mirror size (bytes). Returns 0 if it does not exist. */ async getSize(repo: RepoIdentity): Promise<RepoSize> { const dir = this.mirrorPath(repo); if (!(await this.exists(dir))) return { totalBytes: 0 }; @@ -442,10 +443,10 @@ export class RepoMirrorManager { } /** - * 列出 PR 范围内变更文件(baseSha 与 headSha 的三点 diff,与 Bitbucket/GitHub - * 的 PR diff 一致:head 自分叉后引入的变化)。 + * List files changed within the PR range (three-dot diff of baseSha and headSha, consistent with Bitbucket/GitHub + * PR diffs: the changes head introduces since forking). * - * 用 -z 把状态 + path NUL 分隔,路径含空格/中文 / 引号都不会破。 + * Use -z to NUL-separate status + path, so paths with spaces/Chinese / quotes do not break. */ async listChangedFiles( repo: RepoIdentity, @@ -453,9 +454,9 @@ export class RepoMirrorManager { headSha: string, ): Promise<ChangedFile[]> { const mirrorPath = this.mirrorPath(repo); - // 刚 clone / fetch 完,包文件可能还在 FS flush;某些 sha 第一次 cat 时 - // git 可能短暂报 'Invalid symmetric difference' / 'bad revision'。简单重试 - // 两次,间隔 200ms / 400ms 后通常就稳了。失败再向上抛由 renderer 走 banner。 + // Right after clone / fetch, pack files may still be flushing to the FS; on the first cat of some shas + // git may briefly report 'Invalid symmetric difference' / 'bad revision'. A simple retry + // twice, at 200ms / 400ms intervals, usually stabilizes it. Failing again propagates up for the renderer to show a banner. const out = await retryTransientGit( () => simpleGit(mirrorPath).raw(['diff', '-z', '--name-status', `${baseSha}...${headSha}`]), this.opts.logger, @@ -465,12 +466,12 @@ export class RepoMirrorManager { } /** - * 列出把源 head 合并进目标 tip 会冲突的文件路径(`git merge-tree --write-tree` 的试合并,git ≥ 2.38)。 - * 无冲突(退出码 0)/ 无法判定(退出码 < 0 或 git 过旧)→ 返回空数组,由调用方保守不标记。 + * List the file paths that would conflict when merging source head into target tip (a trial merge via `git merge-tree --write-tree`, git ≥ 2.38). + * No conflict (exit code 0) / undeterminable (exit code < 0 or git too old) → returns an empty array, and the caller conservatively does not mark. * - * merge-tree 冲突时退出码为 1 且把结果写到 stdout,simple-git 会因非零退出吞掉 stdout,故直接走 - * execFile 自行捕获 stdout。`-z` 让输出 NUL 分隔(路径含空格/中文/引号都不破),`--name-only` 只出冲突 - * 文件名:首字段是结果 tree OID,随后是各冲突文件名,遇空字段(段分隔的双 NUL)即冲突文件段结束。 + * On conflict merge-tree returns exit code 1 and writes the result to stdout; simple-git swallows stdout on non-zero exit, so go straight through + * execFile to capture stdout ourselves. `-z` makes the output NUL-separated (paths with spaces/Chinese/quotes do not break), `--name-only` outputs only the conflicting + * file names: the first field is the result tree OID, followed by each conflicting file name; hitting an empty field (the double NUL between segments) ends the conflict-file segment. */ async listConflictFiles( repo: RepoIdentity, @@ -480,7 +481,7 @@ export class RepoMirrorManager { if (!targetSha || !sourceSha) return []; const mirrorPath = this.mirrorPath(repo); try { - // 退出码 0 = 干净可合并,无冲突。 + // Exit code 0 = clean, mergeable, no conflict. await execFileAsync( 'git', ['merge-tree', '--write-tree', '--name-only', '-z', targetSha, sourceSha], @@ -489,7 +490,7 @@ export class RepoMirrorManager { return []; } catch (err) { const e = err as { code?: number | string; stdout?: string | Buffer }; - // 退出码 1 = 存在冲突,stdout 携带冲突文件段;其余(无法完成试合并 / git 过旧)保守返回空。 + // Exit code 1 = conflict exists, stdout carries the conflict-file segment; otherwise (trial merge could not complete / git too old) conservatively return empty. if (e.code === 1 && e.stdout != null) { return parseMergeTreeConflictsZ(e.stdout.toString()); } @@ -502,9 +503,9 @@ export class RepoMirrorManager { } /** - * 读取某文件在某 commit 的内容。完整 bare clone 下 blob 都在本地,直接 git show。 - * 文件不在该 commit (新增/删除场景) 返回空 content。 - * 简单 null-byte 启发判定二进制(前 8000 字符)。 + * Read a file's content at a given commit. Under a full bare clone all blobs are local, so git show directly. + * If the file is not in that commit (add/delete scenarios) returns empty content. + * Simple null-byte heuristic to detect binary (first 8000 characters). */ async getFileContent(repo: RepoIdentity, sha: string, filePath: string): Promise<FileContent> { const mirrorPath = this.mirrorPath(repo); @@ -512,7 +513,7 @@ export class RepoMirrorManager { try { content = await simpleGit(mirrorPath).raw(['show', `${sha}:${filePath}`]); } catch { - // 文件在该 commit 不存在(新增前 / 删除后),返回空 + // File does not exist at that commit (before an add / after a delete), return empty return { binary: false, content: '' }; } if (content.slice(0, 8000).includes('�')) { @@ -522,12 +523,12 @@ export class RepoMirrorManager { } /** - * 列出 PR (`baseSha...headSha`) 中 head 一侧被新增 / 修改的行号集合。 - * 用于 blame 过滤:PR 自己引入的行不展示历史 blame(语义上没意义,只会指向 - * PR 自己的 commit),仅对 base 已有部分展示原始归属。 + * List the set of line numbers added / modified on the head side within the PR (`baseSha...headSha`). + * Used for blame filtering: lines the PR itself introduces do not show historical blame (semantically meaningless, would only point to + * the PR's own commit); original attribution is shown only for the part already present in base. * - * 走 `git diff -U0 base...head -- path`,解析 hunk 头 `@@ -A,B +C,D @@` 的 - * 右侧 C..C+D-1 段。D=0(纯删除)不贡献任何 head 行。 + * Uses `git diff -U0 base...head -- path`, parsing the right side C..C+D-1 segment of the hunk header + * `@@ -A,B +C,D @@`. D=0 (pure deletion) contributes no head lines. */ async listChangedHeadLines( repo: RepoIdentity, @@ -551,21 +552,21 @@ export class RepoMirrorManager { { err, repo: this.repoKey(repo), baseSha, headSha, filePath }, 'git diff for changed lines failed', ); - // 失败时保守返回空集 → blame 全部展示;至少不丢信息 + // On failure conservatively return an empty set → blame shows everything; at least no info is lost return new Set(); } return parseHunkAddedLines(out); } /** - * 跑 `git blame --porcelain <sha> -- <path>` 并解析逐行的归属 commit + 作者 + 时间。 - * 完整 bare clone 下历史 blob 都在本地,正常情况下应该秒出。 + * Run `git blame --porcelain <sha> -- <path>` and parse each line's attribution commit + author + time. + * Under a full bare clone all history blobs are local, so it should normally return in seconds. * - * 错误处理分两档: - * - **`fatal: no such path X in <sha>`**:文件在该 sha 不存在(PR 把它删了 / - * 重命名 / 后续 commit 又改回原状)。这是合法状态,blame 自然不存在,返 - * 回空数组让 renderer "无 blame 留空" 而不是弹错误 banner。 - * - 其它错误:抛出让 renderer 走 BackendErrorBanner,附原始 git stderr。 + * Error handling has two tiers: + * - **`fatal: no such path X in <sha>`**: the file does not exist at that sha (the PR deleted it / + * renamed it / a later commit reverted it to its original state). This is a legal state, blame naturally does not exist, so + * return an empty array to let the renderer "leave blame empty" rather than pop an error banner. + * - Other errors: thrown so the renderer shows a BackendErrorBanner, with the raw git stderr attached. */ async getBlame(repo: RepoIdentity, sha: string, filePath: string): Promise<BlameLine[]> { const mirrorPath = this.mirrorPath(repo); @@ -590,9 +591,9 @@ export class RepoMirrorManager { const mirrorPath = this.mirrorPath(repo); const key = this.repoKey(repo); let hasMirror = await this.exists(path.join(mirrorPath, 'HEAD')); - // 自愈(主动):目录存在但不是**健康**镜像 —— clone/fetch 中途被打断会留下「HEAD 在、但缺 origin - // remote / 非有效 git 目录」的残缺镜像,后续 `git fetch origin` 直接 fatal - // (`'origin' does not appear to be a git repository`)。检出即删掉,按首次 clone 走完整重建。 + // Self-heal (proactive): directory exists but is not a **healthy** mirror — a clone/fetch interrupted midway leaves an incomplete mirror where "HEAD exists but origin + // remote is missing / not a valid git directory", after which `git fetch origin` goes fatal outright + // (`'origin' does not appear to be a git repository`). Detect and delete it, then do a full rebuild as a first-time clone. if (hasMirror && !(await this.isHealthyMirror(mirrorPath))) { this.opts.logger?.warn( { repo: key, mirrorPath }, @@ -605,7 +606,7 @@ export class RepoMirrorManager { const emit = (e: Omit<SyncProgressEvent, 'repo'>): void => { this.opts.onProgress?.({ repo: key, ...e }); }; - // simple-git 把 git --progress 的 stderr 解析成 { method, stage, progress } 推给我们 + // simple-git parses git --progress's stderr into { method, stage, progress } and pushes it to us const gitProgressOpt = { progress: ({ method, @@ -626,12 +627,12 @@ export class RepoMirrorManager { if (hasMirror) { try { this.opts.logger?.debug({ repo: key }, 'mirror exists, fetching'); - // 显式 refspec,覆盖式拉: - // - refs/heads/*:所有分支(含 PR target 与 source 分支) - // - refs/pull-requests/*/from:Bitbucket 把 PR 源头 sha 单独保存在这里。 - // 当源分支已被删除 / 强推后,refs/heads 看不到,但 from ref 仍指向 - // PR 开启时的 sha;没有它 `git diff base...head` 会 "Invalid - // symmetric difference" 因为 head 不可达。 + // Explicit refspec, force-overwrite fetch: + // - refs/heads/*: all branches (including the PR target and source branches) + // - refs/pull-requests/*/from: Bitbucket stores the PR source sha separately here. + // After the source branch is deleted / force-pushed, refs/heads no longer sees it, but the from ref still points at + // the sha at PR-open time; without it `git diff base...head` gives "Invalid + // symmetric difference" because head is unreachable. await this.withProxyEnv(simpleGit({ baseDir: mirrorPath, ...gitProgressOpt })).raw([ 'fetch', '--progress', @@ -642,41 +643,41 @@ export class RepoMirrorManager { emit({ phase: 'done' }); return { mirrorPath, freshClone: false }; } catch (fetchErr) { - // 自愈(被动):fetch 撞上**本地损坏 / 残缺**镜像(缺 origin、坏对象等)→ 删掉,落到下方完整 - // clone 重建。其它错误(网络 / 认证 / 远端拒绝)原样抛出,不误删健康镜像、不把临时网络问题当损坏。 + // Self-heal (reactive): fetch hits a **locally corrupt / incomplete** mirror (missing origin, bad objects, etc.) → delete it, falling to the full + // clone rebuild below. Other errors (network / auth / remote rejection) are thrown as-is, not mistakenly deleting a healthy mirror, not treating a transient network issue as corruption. if (!isLocalMirrorCorruption(fetchErr)) throw fetchErr; this.opts.logger?.warn( { repo: key, err: fetchErr instanceof Error ? fetchErr.message : String(fetchErr) }, 'fetch failed on corrupt mirror; removing for full re-clone', ); await fs.rm(mirrorPath, { recursive: true, force: true }).catch(() => undefined); - // 不 return,落到下方完整 clone 自愈重建。 + // Do not return; fall to the full clone self-heal rebuild below. } } this.opts.logger?.info({ repo: key }, 'cloning bare mirror (full + all refs)'); const url = await this.opts.getCloneUrl(repo); await fs.mkdir(path.dirname(mirrorPath), { recursive: true }); - // --mirror 而不是 --bare:默认 --bare 只拉 refs/heads + refs/tags, - // Bitbucket 的 PR source sha 落在 refs/pull-requests/<id>/from 命名空间, - // 没被拉下来就 `git diff base...head` 找不到 head sha → "Invalid - // symmetric difference"。--mirror 隐含 --bare 并把所有 refs 都拉下来 - // (heads/tags/pull-requests/notes/...),后续 fetch 也自动同步全部。 - // --no-hardlinks: 本地 upstream 别复用 hardlinks,避免 fetch 时跟 upstream - // 状态串扰;远端 HTTPS clone 不受影响。 - // --progress: 强制 git 输出进度,否则非 TTY 模式默认静默 + // --mirror instead of --bare: default --bare fetches only refs/heads + refs/tags, + // but Bitbucket's PR source sha lands in the refs/pull-requests/<id>/from namespace, + // and if not fetched `git diff base...head` cannot find the head sha → "Invalid + // symmetric difference". --mirror implies --bare and fetches all refs + // (heads/tags/pull-requests/notes/...), and subsequent fetches also sync everything automatically. + // --no-hardlinks: the local upstream should not reuse hardlinks, avoiding state crosstalk with upstream + // during fetch; remote HTTPS clone is unaffected. + // --progress: force git to output progress, otherwise non-TTY mode is silent by default // - // 不用 --filter=blob:none:blame / pr-agent 等需要历史 blob 的工具会触发 - // 按需拉取,远端不全或 partial clone 协议未支持时直接 fatal。完整 clone - // 一次性付清磁盘代价,运行期稳定。 + // No --filter=blob:none: tools that need history blobs like blame / pr-agent would trigger + // on-demand fetching, going fatal outright when the remote is incomplete or the partial-clone protocol is unsupported. A full clone + // pays the disk cost once and is stable at runtime. await this.withProxyEnv(simpleGit(gitProgressOpt)).clone(url, mirrorPath, [ '--mirror', '--no-hardlinks', '--progress', ]); - // Windows 等系统 fresh clone 后 FS 可能还在 flush,紧接着的 git diff - // 有概率撞上"refs/packs 状态不一致"报错。等 git 自己能 rev-parse HEAD - // 几次稳定后再返回,调用方拿到的 mirror 一定可用。 + // On systems like Windows the FS may still be flushing after a fresh clone, and the immediately following git diff + // can hit a "refs/packs state inconsistent" error. Wait until git itself can rev-parse HEAD + // stably a few times before returning, so the mirror the caller receives is guaranteed usable. await waitMirrorReady(mirrorPath, this.opts.logger); emit({ phase: 'done' }); return { mirrorPath, freshClone: true }; @@ -717,14 +718,14 @@ export class RepoMirrorManager { } /** - * 错误消息是否指向**本地镜像损坏 / 残缺**(而非网络 / 认证 / 远端拒绝等可重试的远端问题)。fetch 失败 - * 后据此判断是否「删库重 clone」自愈——只对本地损坏自愈,避免把临时网络问题误判为损坏而无谓全量重建。 - * 不匹配 "could not read from remote repository":它对网络 / 认证失败也会出现,不能据它判定本地损坏。 + * Whether the error message points to a **locally corrupt / incomplete mirror** (rather than a retryable remote issue like network / auth / remote rejection). After a fetch failure + * this decides whether to "delete and re-clone" as a self-heal — self-heal only on local corruption, avoiding a pointless full rebuild from misjudging a transient network issue as corruption. + * Does not match "could not read from remote repository": it also appears on network / auth failures, so it cannot be used to judge local corruption. */ function isLocalMirrorCorruption(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); return ( - msg.includes('does not appear to be a git repository') || // 缺 origin remote(中断的 clone) + msg.includes('does not appear to be a git repository') || // missing origin remote (interrupted clone) msg.includes('not a git repository') || msg.includes('bad object') || msg.includes('object file is empty') || @@ -735,9 +736,9 @@ function isLocalMirrorCorruption(err: unknown): boolean { } /** - * Fresh clone 后稳定性兜底:轮询 `git rev-parse HEAD` 直到成功,最多 ~5 次共 - * 500ms。Windows 文件系统 / 杀毒软件可能让 pack 写入有短暂延迟,此函数把这段 - * 等待从调用方挪到 mirror 自身。 + * Stability fallback after a fresh clone: poll `git rev-parse HEAD` until it succeeds, up to ~5 times totaling + * 500ms. The Windows filesystem / antivirus may cause a brief delay in pack writes; this function moves that + * wait from the caller into the mirror itself. */ async function waitMirrorReady(mirrorPath: string, logger?: Logger): Promise<void> { const tries = [40, 80, 120, 200, 320]; @@ -753,10 +754,10 @@ async function waitMirrorReady(mirrorPath: string, logger?: Logger): Promise<voi } /** - * 把 git 调用包一层重试:错误消息匹配"刚 clone 完镜像还没就绪"族(Invalid - * symmetric difference / bad revision / unknown revision),就 sleep 一会儿再 - * 试一次,最多 3 次(含首次)。其它错误 (network / no such path / 真实 sha - * 不存在) 立刻抛,不浪费时间。 + * Wrap a git call in a retry layer: if the error message matches the "mirror just cloned but not yet ready" family (Invalid + * symmetric difference / bad revision / unknown revision), sleep a bit and try + * again, up to 3 times (including the first). Other errors (network / no such path / the real sha + * not existing) are thrown immediately, wasting no time. */ const TRANSIENT_GIT_RE = /Invalid symmetric difference expression|bad revision|unknown revision or path not in the working tree/i; @@ -788,8 +789,8 @@ async function retryTransientGit<T>( } /** - * 解析 `git diff -z --name-status A...B` 的输出。 - * 格式(NUL 分隔): + * Parse the output of `git diff -z --name-status A...B`. + * Format (NUL-separated): * M\0path\0M\0path\0 * A\0newpath\0 * D\0oldpath\0 @@ -822,14 +823,14 @@ function parseNameStatusZ(raw: string): ChangedFile[] { } /** - * 解析 `git merge-tree --write-tree --name-only -z` 在冲突时的 stdout。 - * 格式(NUL 分隔):`<结果 tree OID>\0<冲突文件名>\0...\0\0<提示信息段...>`——首字段是 tree OID,随后 - * 是各冲突文件名,遇空字段(段间双 NUL)即冲突文件段结束,后续提示信息段忽略。同名去重。 + * Parse the stdout of `git merge-tree --write-tree --name-only -z` on conflict. + * Format (NUL-separated): `<result tree OID>\0<conflict file name>\0...\0\0<info message segment...>` — the first field is the tree OID, followed + * by each conflict file name; hitting an empty field (the double NUL between segments) ends the conflict-file segment, and the subsequent info-message segment is ignored. Deduplicate identical names. */ export function parseMergeTreeConflictsZ(raw: string): string[] { const parts = raw.split('\0'); const files: string[] = []; - // parts[0] = 结果 tree OID;从下一字段起收集冲突文件名,遇空字段(段分隔)停止。 + // parts[0] = result tree OID; collect conflict file names starting from the next field, stopping at an empty field (segment separator). for (let i = 1; i < parts.length; i++) { if (parts[i] === '') break; files.push(parts[i]!); @@ -838,16 +839,16 @@ export function parseMergeTreeConflictsZ(raw: string): string[] { } /** - * 解析 `git blame --porcelain` 输出。每个 hunk 头形如: + * Parse `git blame --porcelain` output. Each hunk header looks like: * `<sha> <origLine> <finalLine> [<numLines>]` - * 接着是 `key value` 元信息(author / author-mail / author-time / summary 等), - * 最后是制表符开头的源码行 `\t<line>`。同一个 commit 的后续 hunk 头只带 sha - * 那一行,元信息要从首次出现的 hunk 头继承。 + * followed by `key value` metadata (author / author-mail / author-time / summary etc.), + * and finally the tab-prefixed source line `\t<line>`. Subsequent hunk headers for the same commit carry only the sha + * line, and the metadata must be inherited from the hunk header where it first appeared. */ /** - * 解析 `git diff -U0` 的 hunk 头,收集 head 一侧的"被修改"行号集合。 - * Hunk 头形如 `@@ -A,B +C,D @@`:右侧 C..C+D-1 段是 head 引入/修改的行。 - * 缺省 count 视为 1;count=0 是纯删除位,head 侧 0 行,跳过。 + * Parse the hunk headers of `git diff -U0`, collecting the set of "modified" line numbers on the head side. + * A hunk header looks like `@@ -A,B +C,D @@`: the right-side C..C+D-1 segment is the lines head introduces/modifies. + * A missing count is treated as 1; count=0 is a pure-deletion position with 0 head-side lines, skip it. */ export function parseHunkAddedLines(raw: string): Set<number> { const out = new Set<number>(); @@ -862,8 +863,8 @@ export function parseHunkAddedLines(raw: string): Set<number> { } export function parseBlamePorcelain(raw: string): BlameLine[] { - // Windows 上 git 输出常带 \r\n;用兼容 split 切,否则 `^...(\d+)$` 正则末尾 - // 会留下 \r 导致全部 hunk 头匹配失败 → 解析出空数组 → blame 不显示也不报错 + // On Windows git output often carries \r\n; split compatibly, otherwise the `^...(\d+)$` regex end + // leaves a trailing \r causing all hunk headers to fail matching → parses to an empty array → blame neither shows nor errors const lines = raw.split(/\r?\n/); const commitMeta = new Map< string, @@ -900,7 +901,7 @@ export function parseBlamePorcelain(raw: string): BlameLine[] { else if (key === 'summary') summary = value; i++; } - // 跳过源码行(\t 开头那一行不算 metadata) + // Skip the source line (the \t-prefixed line is not metadata) if (i < lines.length && lines[i]!.startsWith('\t')) i++; let meta = commitMeta.get(sha); diff --git a/packages/repo-mirror/src/types.ts b/packages/repo-mirror/src/types.ts index e38d4e14..fa1e8f96 100644 --- a/packages/repo-mirror/src/types.ts +++ b/packages/repo-mirror/src/types.ts @@ -1,19 +1,19 @@ export interface RepoIdentity { - /** 来自 connection.base_url 的 host(不含 scheme),用于按主机分目录 */ + /** host from connection.base_url (no scheme), used to split directories by host */ host: string; projectKey: string; repoSlug: string; } export interface MirrorResult { - /** bare 镜像的绝对路径,<reposDir>/<host>/<projectKey>/<repoSlug>/bare */ + /** absolute path of the bare mirror, <reposDir>/<host>/<projectKey>/<repoSlug>/bare */ mirrorPath: string; - /** 本次是否做了首次 clone(vs 后续 fetch) */ + /** whether this call did a first-time clone (vs a subsequent fetch) */ freshClone: boolean; } export interface RepoSize { - /** 字节,包含 bare repo 全部内容 */ + /** bytes, covering the entire contents of the bare repo */ totalBytes: number; } @@ -26,26 +26,26 @@ export type ChangedFileStatus = | 'typechange'; export interface ChangedFile { - /** 新路径(A/M/T);R/C 时为目标 path */ + /** new path (A/M/T); the target path for R/C */ path: string; - /** 仅 R/C 时存在,源 path */ + /** present only for R/C, the source path */ oldPath?: string; status: ChangedFileStatus; - /** R/C 时的相似度百分比 (0-100) */ + /** similarity percentage for R/C (0-100) */ similarity?: number; } export type FileContent = { binary: false; content: string } | { binary: true }; -/** 单行 blame 信息。从 `git blame --porcelain <sha> -- <path>` 解析。 */ +/** Single-line blame info. Parsed from `git blame --porcelain <sha> -- <path>`. */ export interface BlameLine { - /** 该行在 head 侧的行号(1-based) */ + /** line number on the head side (1-based) */ line: number; - /** 该行所属 commit 的完整 sha */ + /** full sha of the commit this line belongs to */ commit: string; author: string; authorEmail: string; - /** 作者时间 ISO */ + /** author time, ISO */ authorDate: string; summary: string; } diff --git a/packages/repo-mirror/tests/pull-ref.test.ts b/packages/repo-mirror/tests/pull-ref.test.ts index 978fea0d..df09efad 100644 --- a/packages/repo-mirror/tests/pull-ref.test.ts +++ b/packages/repo-mirror/tests/pull-ref.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { pullRequestHeadRefspec } from '@meebox/shared'; describe('pullRequestHeadRefspec', () => { - it('按平台构造 PR 头引用 refspec(精确编号,非通配)', () => { + it('builds the PR head ref refspec per platform (exact number, not a wildcard)', () => { expect(pullRequestHeadRefspec('github', '108')).toBe('+refs/pull/108/head:refs/pull/108/head'); expect(pullRequestHeadRefspec('gitlab', '42')).toBe( '+refs/merge-requests/42/head:refs/merge-requests/42/head', @@ -12,14 +12,14 @@ describe('pullRequestHeadRefspec', () => { ); }); - it('remoteId 非纯数字 → null(不构造可疑 ref)', () => { + it('remoteId not purely numeric → null (do not build a suspicious ref)', () => { expect(pullRequestHeadRefspec('github', '')).toBeNull(); expect(pullRequestHeadRefspec('github', 'abc')).toBeNull(); expect(pullRequestHeadRefspec('github', '1; rm -rf')).toBeNull(); expect(pullRequestHeadRefspec('github', '12/head')).toBeNull(); }); - it('两端空白容错(trim 后纯数字)', () => { + it('tolerates surrounding whitespace (purely numeric after trim)', () => { expect(pullRequestHeadRefspec('github', ' 9 ')).toBe('+refs/pull/9/head:refs/pull/9/head'); }); }); diff --git a/packages/repo-mirror/tests/repo-mirror-manager.test.ts b/packages/repo-mirror/tests/repo-mirror-manager.test.ts index 554fab1c..25721b5d 100644 --- a/packages/repo-mirror/tests/repo-mirror-manager.test.ts +++ b/packages/repo-mirror/tests/repo-mirror-manager.test.ts @@ -21,12 +21,12 @@ const repo: RepoIdentity = { repoSlug: 'fx-help', }; -/** 创建一个 fake upstream git 仓库,能让 syncMirror clone 自它。 */ +/** Create a fake upstream git repo that syncMirror can clone from. */ async function makeUpstream(dir: string): Promise<void> { await fs.mkdir(dir, { recursive: true }); const git = simpleGit(dir); await git.init(); - // 容错:CI 环境可能没有 user 配置 + // fallback: CI environment may not have user config await git.addConfig('user.email', 'test@example.com', false, 'local'); await git.addConfig('user.name', 'Test', false, 'local'); await git.addConfig('commit.gpgsign', 'false', false, 'local'); @@ -61,7 +61,7 @@ describe('RepoMirrorManager.syncMirror', () => { expect(r.mirrorPath).toBe( path.join(reposDir, 'bb.example.com', 'FX', 'fx-help', 'bare'), ); - // bare repo 标志:HEAD 文件 + config 文件存在 + // bare repo markers: HEAD file + config file exist await expect(fs.access(path.join(r.mirrorPath, 'HEAD'))).resolves.toBeUndefined(); await expect(fs.access(path.join(r.mirrorPath, 'config'))).resolves.toBeUndefined(); }); @@ -70,7 +70,7 @@ describe('RepoMirrorManager.syncMirror', () => { const mgr = makeManager(); await mgr.syncMirror(repo); - // 在 upstream 里加新 commit + // add a new commit in upstream const upstreamGit = simpleGit(upstreamPath); await fs.writeFile(path.join(upstreamPath, 'NEW.md'), 'new'); await upstreamGit.add('.'); @@ -79,7 +79,7 @@ describe('RepoMirrorManager.syncMirror', () => { const r = await mgr.syncMirror(repo); expect(r.freshClone).toBe(false); - // 镜像应包含新 commit + // mirror should contain the new commit const mirrorGit = simpleGit(r.mirrorPath); const log = await mirrorGit.log(); expect(log.total).toBeGreaterThanOrEqual(2); @@ -94,14 +94,14 @@ describe('RepoMirrorManager.syncMirror', () => { return upstreamPath; }, }); - // 并发 3 次 → 三个调用复用同一 in-flight Promise,只触发 1 次实际 clone + // 3 concurrent calls → all three reuse the same in-flight Promise, triggering only 1 actual clone const results = await Promise.all([ mgr.syncMirror(repo), mgr.syncMirror(repo), mgr.syncMirror(repo), ]); expect(urlCalls).toBe(1); - // 三个调用拿到的应当是同一个 MirrorResult 引用(来自同一 Promise) + // all three calls should get the same MirrorResult reference (from the same Promise) expect(results[0]).toBe(results[1]); expect(results[0]).toBe(results[2]); expect(results[0]!.freshClone).toBe(true); @@ -117,13 +117,13 @@ describe('RepoMirrorManager.syncMirror', () => { }, }); await mgr.syncMirror(repo); - // 一次同步完成后再调一次,应触发新的 sync(这里是 fetch,因为镜像已存在) + // after one sync completes, call again; should trigger a new sync (a fetch here, since the mirror already exists) await mgr.syncMirror(repo); - expect(urlCalls).toBe(1); // clone 仅 1 次;fetch 不走 getCloneUrl + expect(urlCalls).toBe(1); // clone only once; fetch does not go through getCloneUrl }); it('serializes syncMirror across different repos via global queue', async () => { - // 全局单队列:不同 repo 也串行执行,但都能成功完成 + // single global queue: different repos also run serially, but all complete successfully const otherRepo: RepoIdentity = { ...repo, repoSlug: 'fx-code' }; const otherUpstream = path.join(tmpRoot, 'upstream-other'); await makeUpstream(otherUpstream); @@ -133,14 +133,14 @@ describe('RepoMirrorManager.syncMirror', () => { getCloneUrl: async (r) => (r.repoSlug === 'fx-help' ? upstreamPath : otherUpstream), }); - // 跟踪 doSyncMirror 同时运行的最大并发数 + // track the max concurrency of doSyncMirror running at the same time let inFlight = 0; let maxInFlight = 0; const origGetCloneUrl = mgr['opts'].getCloneUrl; mgr['opts'].getCloneUrl = async (r) => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); - // 让 clone 步骤稍稍延后,给同时性留窗口 + // delay the clone step slightly to leave a window for concurrency await new Promise((res) => setTimeout(res, 10)); const url = await origGetCloneUrl(r); inFlight--; @@ -151,7 +151,7 @@ describe('RepoMirrorManager.syncMirror', () => { expect(a.freshClone).toBe(true); expect(b.freshClone).toBe(true); expect(a.mirrorPath).not.toBe(b.mirrorPath); - // 关键:全局队列保证任何时刻最多 1 个 clone 在跑 + // key: the global queue guarantees at most 1 clone running at any moment expect(maxInFlight).toBe(1); }); @@ -166,7 +166,7 @@ describe('RepoMirrorManager.syncMirror', () => { }, }); await expect(mgr.syncMirror(repo)).rejects.toBeDefined(); - // 第二次走真正的 upstream,应该成功 + // the second call uses the real upstream and should succeed const r2 = await mgr.syncMirror(repo); expect(r2.freshClone).toBe(true); }); @@ -197,11 +197,11 @@ describe('RepoMirrorManager.mirrorPath', () => { }); describe('RepoMirrorManager diff/content', () => { - /** 在 upstream 准备 2 个 commit,返回 base / head sha。 */ + /** Prepare 2 commits in upstream, return base / head sha. */ async function prepareTwoCommits(): Promise<{ baseSha: string; headSha: string }> { const upstream = simpleGit(upstreamPath); - // 重置:上层 beforeEach 已经 init + commit README,删了重来更可控 + // reset: the outer beforeEach already init + commit README; deleting and redoing is more controllable await fs.rm(upstreamPath, { recursive: true, force: true }); await fs.mkdir(upstreamPath, { recursive: true }); await upstream.init(); @@ -268,38 +268,38 @@ describe('RepoMirrorManager diff/content', () => { const mgr = makeManager(); await mgr.syncMirror(repo); - // c.txt 在 base 不存在 + // c.txt does not exist at base expect(await mgr.getFileContent(repo, baseSha, 'c.txt')).toEqual({ binary: false, content: '', }); - // b.txt 在 head 不存在 + // b.txt does not exist at head expect(await mgr.getFileContent(repo, headSha, 'b.txt')).toEqual({ binary: false, content: '', }); }); - it('parseHunkAddedLines 收集 head 侧添加/修改行号集合', () => { + it('parseHunkAddedLines collects the set of added/modified line numbers on the head side', () => { const diff = [ 'diff --git a/x.ts b/x.ts', 'index aaaa..bbbb 100644', '--- a/x.ts', '+++ b/x.ts', - // 改 1 行 @ head:5 + // modify 1 line @ head:5 '@@ -5,1 +5,1 @@', '-old', '+new', - // 加 3 行 @ head:10..12 (count 省略形式 + 多行) + // add 3 lines @ head:10..12 (count-omitted form + multiple lines) '@@ -10,0 +10,3 @@', '+line a', '+line b', '+line c', - // 纯删除:head 侧 0 行 + // pure deletion: 0 lines on the head side '@@ -20,2 +21,0 @@', '-del a', '-del b', - // 无 count 视为 1 + // no count is treated as 1 '@@ -30 +31 @@', '-zz', '+yy', @@ -309,15 +309,15 @@ describe('RepoMirrorManager diff/content', () => { expect([...set].sort((a, b) => a - b)).toEqual([5, 10, 11, 12, 31]); }); - it('parseHunkAddedLines 兼容 CRLF', () => { + it('parseHunkAddedLines handles CRLF', () => { const lf = '@@ -1,1 +1,1 @@\n-a\n+b\n@@ -5,0 +5,2 @@\n+c\n+d\n'; const crlf = lf.replace(/\n/g, '\r\n'); expect([...parseHunkAddedLines(crlf)].sort((a, b) => a - b)).toEqual([1, 5, 6]); }); - it('parseBlamePorcelain 兼容 LF / CRLF 行尾', () => { + it('parseBlamePorcelain handles LF / CRLF line endings', () => { const sha = 'a'.repeat(40); - // Windows 上 git 输出经常带 \r\n,要保证仍能匹配 hunk 头 + // git output on Windows often carries \r\n; ensure hunk headers still match const lf = [ `${sha} 1 1 2`, 'author Kyle', @@ -341,14 +341,14 @@ describe('RepoMirrorManager diff/content', () => { expect(fromCrlf[0]!.authorEmail).toBe('kyle@example.com'); expect(fromCrlf[1]!.commit).toBe(sha); expect(fromCrlf[1]!.line).toBe(2); - // 同 commit 的后续 hunk 元信息应继承自首次出现 + // metadata for later hunks of the same commit should be inherited from its first occurrence expect(fromCrlf[1]!.author).toBe('Kyle'); }); it('getFileContent flags binary on null-byte presence', async () => { - // 自定义 upstream with a binary file + // custom upstream with a binary file const upstream = simpleGit(upstreamPath); - const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]); // PNG header含 NUL + const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]); // PNG header contains NUL await fs.writeFile(path.join(upstreamPath, 'icon.png'), buf); await upstream.add('.'); await upstream.commit('add binary'); @@ -361,31 +361,31 @@ describe('RepoMirrorManager diff/content', () => { expect(r.binary).toBe(true); }); - it('parseMergeTreeConflictsZ 取首 OID 后到段分隔双 NUL 间的冲突文件名(去重)', () => { - // `git merge-tree --write-tree --name-only -z` 冲突时的 stdout:OID\0 file\0 \0(段分隔) 提示... + it('parseMergeTreeConflictsZ takes conflict file names between the first OID and the section-separating double NUL (deduped)', () => { + // stdout of `git merge-tree --write-tree --name-only -z` on conflict: OID\0 file\0 \0(section separator) message... const raw = '4530c9c9c26e09ddc2340fd825c09a190039d7d2\0f.txt\0src/x y.ts\0\0' + '1\0f.txt\0CONFLICT (content): Merge conflict in f.txt\0'; expect(parseMergeTreeConflictsZ(raw)).toEqual(['f.txt', 'src/x y.ts']); }); - it('parseMergeTreeConflictsZ 无冲突文件(首字段后即段分隔)返回空数组', () => { + it('parseMergeTreeConflictsZ returns an empty array when there are no conflict files (section separator right after the first field)', () => { expect(parseMergeTreeConflictsZ('4530c9c9\0\0info')).toEqual([]); }); - it('listConflictFiles 列出试合并到目标分支会冲突的文件', async () => { + it('listConflictFiles lists files that would conflict when trial-merging into the target branch', async () => { const upstream = simpleGit(upstreamPath); await upstream.addConfig('commit.gpgsign', 'false', false, 'local'); - // 目标分支名(init 默认 master / main,环境而定),后续 checkout 回它。 + // target branch name (init defaults to master / main, depending on environment); checked out again later. const main = (await upstream.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); - // base:两个文件 + // base: two files await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb\nc\n'); await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x\n'); await upstream.add('.'); await upstream.commit('conflict base'); const baseSha = (await upstream.revparse(['HEAD'])).trim(); - // feature 分支:改 shared.txt 第二行 + 改 solo.txt + // feature branch: modify the second line of shared.txt + modify solo.txt await upstream.checkoutLocalBranch('feature'); await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-feature\nc\n'); await fs.writeFile(path.join(upstreamPath, 'solo.txt'), 'x-feature\n'); @@ -393,7 +393,7 @@ describe('RepoMirrorManager diff/content', () => { await upstream.commit('feature edit'); const featureSha = (await upstream.revparse(['HEAD'])).trim(); - // 回目标分支并对 shared.txt 同一行做冲突改动;solo.txt 不动 → 仅 shared.txt 冲突 + // back to the target branch and make a conflicting change to the same line of shared.txt; leave solo.txt untouched → only shared.txt conflicts await upstream.checkout([main]); await fs.writeFile(path.join(upstreamPath, 'shared.txt'), 'a\nb-main\nc\n'); await upstream.add('.'); @@ -408,49 +408,49 @@ describe('RepoMirrorManager diff/content', () => { expect(conflicts).toContain('shared.txt'); expect(conflicts).not.toContain('solo.txt'); - // 无冲突方向(同一分支与自身)→ 空 + // non-conflicting direction (same branch against itself) → empty expect(await mgr.listConflictFiles(repo, targetSha, targetSha)).toEqual([]); }); }); describe('RepoMirrorManager.materializeWorktree', () => { - it('从 bare mirror 派生 self-contained worktree, HEAD 在 pr-<localId>/head 命名分支上', async () => { + it('derives a self-contained worktree from the bare mirror, with HEAD on the pr-<localId>/head named branch', async () => { const mgr = makeManager(); await mgr.syncMirror(repo); const headSha = (await simpleGit(upstreamPath).revparse(['HEAD'])).trim(); const wt = await mgr.materializeWorktree(repo, headSha, undefined, 'pr01hash'); try { - // worktree 路径在 <reposDir>/<host>/<project>/<repo>/wt/ 下 + // the worktree path is under <reposDir>/<host>/<project>/<repo>/wt/ expect(wt.path.startsWith(path.join(reposDir, 'bb.example.com', 'FX', 'fx-help', 'wt'))).toBe( true, ); - // .git 必须是目录 (self-contained clone),不是 file (worktree-style 链) + // .git must be a directory (self-contained clone), not a file (worktree-style link) const gitStat = await fs.stat(path.join(wt.path, '.git')); expect(gitStat.isDirectory()).toBe(true); - // README.md (upstream 初始 commit 的文件) 应该被 checkout 到工作树 + // README.md (file from upstream's initial commit) should be checked out to the worktree const readme = await fs.readFile(path.join(wt.path, 'README.md'), 'utf8'); expect(readme).toBe('hello'); - // HEAD 必须在命名分支 pr-<localId>/head 上 (pr-agent 要求,不能 detached) + // HEAD must be on the named branch pr-<localId>/head (pr-agent requires it, cannot be detached) const headRef = (await simpleGit(wt.path).raw(['symbolic-ref', 'HEAD'])).trim(); expect(headRef).toBe('refs/heads/pr-pr01hash/head'); expect(wt.headBranchName).toBe('pr-pr01hash/head'); - // 该分支应该指向 headSha + // the branch should point at headSha const branchSha = ( await simpleGit(wt.path).revparse(['refs/heads/pr-pr01hash/head']) ).trim(); expect(branchSha).toBe(headSha); - // 没传 baseSha → 没有 target branch + // no baseSha passed → no target branch expect(wt.targetBranchName).toBeUndefined(); } finally { await wt.cleanup(); } }); - it('baseSha 传入后建 pr-<localId>/base 分支,targetBranchName 返回该名字', async () => { + it('when baseSha is passed, creates the pr-<localId>/base branch and targetBranchName returns that name', async () => { const mgr = makeManager(); await mgr.syncMirror(repo); - // upstream 加一个新 commit;用初始 commit 当 base,新 commit 当 head + // add a new commit in upstream; use the initial commit as base and the new commit as head const baseSha = (await simpleGit(upstreamPath).revparse(['HEAD'])).trim(); const upstreamGit = simpleGit(upstreamPath); await fs.writeFile(path.join(upstreamPath, 'NEW.md'), 'feature'); @@ -466,7 +466,7 @@ describe('RepoMirrorManager.materializeWorktree', () => { await simpleGit(wt.path).revparse(['refs/heads/pr-pr01hash/base']) ).trim(); expect(baseBranchSha).toBe(baseSha); - // head 仍在 pr-<localId>/head + // head is still on pr-<localId>/head const headBranchSha = ( await simpleGit(wt.path).revparse(['refs/heads/pr-pr01hash/head']) ).trim(); @@ -476,7 +476,7 @@ describe('RepoMirrorManager.materializeWorktree', () => { } }); - it('cleanup 后 worktree 目录消失', async () => { + it('the worktree directory is gone after cleanup', async () => { const mgr = makeManager(); await mgr.syncMirror(repo); const headSha = (await simpleGit(upstreamPath).revparse(['HEAD'])).trim(); @@ -485,7 +485,7 @@ describe('RepoMirrorManager.materializeWorktree', () => { await expect(fs.access(wt.path)).rejects.toThrow(); }); - it('并发派生多个 worktree 不会撞名 (Date.now + 随机后缀)', async () => { + it('deriving multiple worktrees concurrently does not collide on names (Date.now + random suffix)', async () => { const mgr = makeManager(); await mgr.syncMirror(repo); const headSha = (await simpleGit(upstreamPath).revparse(['HEAD'])).trim(); diff --git a/packages/rules/package.json b/packages/rules/package.json index d4816689..30a31487 100644 --- a/packages/rules/package.json +++ b/packages/rules/package.json @@ -2,7 +2,7 @@ "name": "@meebox/rules", "version": "0.0.0", "private": true, - "description": "本地 rules.dir 下 markdown 规则文件的加载 + 匹配;产物供 ipc/pr-agent 注入 extra_instructions", + "description": "Loads + matches markdown rule files under the local rules.dir; output feeds ipc/pr-agent to inject extra_instructions", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/rules/src/load.ts b/packages/rules/src/load.ts index 25b4d4c6..730382fc 100644 --- a/packages/rules/src/load.ts +++ b/packages/rules/src/load.ts @@ -6,12 +6,12 @@ import { MAX_RULE_FILES, type Rule, type RuleTool } from './types.js'; const VALID_TOOLS: ReadonlyArray<RuleTool> = ['describe', 'review']; /** - * 递归扫 dir 下所有 .md,gray-matter 解析 frontmatter + body。 - * - 单个文件解析失败(frontmatter yaml 烂 / 必填字段类型错)→ 跳过该文件,throw-safe - * - dir 不存在 / 不可读 → 返回空数组,由调用方决定是否提示 - * - 文件名 / 路径不限制,但建议小写 + 短横线 (UI 展示 id 用相对路径) + * Recursively scan all .md under dir, parse frontmatter + body via gray-matter. + * - Single file parse failure (broken frontmatter yaml / required field type error) → skip that file, throw-safe + * - dir missing / unreadable → return empty array, caller decides whether to prompt + * - No restriction on file name / path, but lowercase + hyphen recommended (UI displays id as relative path) * - * 返回的 Rule 按 priority desc + filePath asc 预排序,调用方可以直接遍历找首条。 + * Returned Rule list is pre-sorted by priority desc + filePath asc, so callers can iterate directly to find the first. */ export async function loadRules( dir: string, @@ -51,10 +51,11 @@ async function dirExists(p: string): Promise<boolean> { } /** - * 递归收集 dir 下所有 .md(跳过隐藏目录)。收集数到达 {@link MAX_RULE_FILES} 即停止遍历并告警—— - * 规则目录被误指向超大目录树时的性能兜底拦截,避免一次加载扫穿海量文件。返回顺序为目录遍历序, - * 优先级排序在 loadRules 内统一做(sortRules),故此处截断丢弃的是「遍历靠后」的文件,与优先级无关 - * (命中上限另由 pickMatchingRules 把关)。 + * Recursively collect all .md under dir (skipping hidden directories). Stop traversal and warn once the count + * reaches {@link MAX_RULE_FILES} — a performance fallback interception for when the rules dir is mistakenly pointed at + * a huge directory tree, avoiding scanning through a massive number of files in one load. Return order is the directory + * traversal order; priority sorting is done uniformly inside loadRules (sortRules), so what truncation drops here are + * the files "later in traversal", unrelated to priority (the match cap is separately gated by pickMatchingRules). */ async function listMdFiles( dir: string, @@ -72,7 +73,7 @@ async function listMdFiles( } const full = path.join(d, e.name); if (e.isDirectory()) { - // 跳过隐藏目录 (.git / .vscode 等) + // skip hidden directories (.git / .vscode etc.) if (e.name.startsWith('.')) continue; await walk(full); } else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) { @@ -87,7 +88,7 @@ async function listMdFiles( return out; } -/** frontmatter 解析失败 / 字段类型不对时返回 null,由调用方记 warn 跳过 */ +/** Return null on frontmatter parse failure / wrong field type; caller logs warn and skips */ function buildRule( filePath: string, baseDir: string, @@ -96,7 +97,7 @@ function buildRule( const data = parsed.data as Record<string, unknown>; const body = parsed.content.trim(); - // applies_to: 各字段都可省,省 = match anything (undefined regex) + // applies_to: every field is optional, omitted = match anything (undefined regex) const appliesRaw = (data.applies_to as Record<string, unknown> | undefined) ?? {}; const applies = { @@ -105,9 +106,9 @@ function buildRule( target_branch: compileRegex(appliesRaw.target_branch), }; - // tools: 默认只给 /review。规则的语义本来就是"代码评审规约",给 /describe - // (PR 描述生成) 注入约束会让描述偏题;想要规则同时影响 /describe 的用户显式 - // 写 tools: [describe, review] + // tools: defaults to only /review. A rule's semantics are inherently a "code review spec"; injecting + // constraints into /describe (PR description generation) would derail the description; users who want a + // rule to also affect /describe explicitly write tools: [describe, review] const toolsRaw = data.tools; let tools: ReadonlyArray<RuleTool> = ['review']; if (Array.isArray(toolsRaw)) { @@ -117,7 +118,7 @@ function buildRule( if (filtered.length > 0) tools = filtered; } - // custom_labels: 容错成空数组 + // custom_labels: fall back to empty array on error const labelsRaw = data.custom_labels; const customLabels: string[] = Array.isArray(labelsRaw) ? labelsRaw.filter((x): x is string => typeof x === 'string') @@ -141,10 +142,10 @@ function buildRule( function compileRegex(v: unknown): RegExp | undefined { if (typeof v !== 'string' || v === '') return undefined; try { - // 用户写的是正则源串。不强制加锚 (^/$),由规则文件作者自行决定 + // user writes a regex source string. No forced anchors (^/$); left to the rule file author to decide return new RegExp(v); } catch { - // 非法正则:当作未配置该字段,规则匹配时跳过;buildRule 不抛 + // invalid regex: treat as if the field is not configured, skip during rule matching; buildRule does not throw return undefined; } } diff --git a/packages/rules/src/match.ts b/packages/rules/src/match.ts index 8ad7a3fe..c8ace2b3 100644 --- a/packages/rules/src/match.ts +++ b/packages/rules/src/match.ts @@ -1,12 +1,12 @@ import { DEFAULT_MAX_MATCHED_RULES, type Rule, type RuleMatchContext } from './types.js'; /** - * 判断单条规则是否匹配 PR 上下文。语义: - * - enabled=false → 不匹配 - * - tools 不含当前 tool → 不匹配 - * - applies_to.<field> 缺省 → 视为匹配任意;存在 → 字段值需通过 regex.test() + * Determine whether a single rule matches the PR context. Semantics: + * - enabled=false → no match + * - tools does not include the current tool → no match + * - applies_to.<field> omitted → treated as matching anything; present → field value must pass regex.test() * - * 任一字段不匹配立刻返回 false (短路)。 + * Return false immediately if any field fails to match (short-circuit). */ export function ruleMatches(rule: Rule, ctx: RuleMatchContext): boolean { if (!rule.enabled) return false; @@ -19,9 +19,10 @@ export function ruleMatches(rule: Rule, ctx: RuleMatchContext): boolean { } /** - * 取同一 PR 命中的全部规则,按 priority desc + filePath asc(loadRules 已预排序,故保序), - * 封顶 `limit` 条(默认 {@link DEFAULT_MAX_MATCHED_RULES})作安全兜底,超出按排序丢弃靠后者。 - * 多条规则的正文由调用方经 {@link combineRuleInstructions} 拼接注入。 + * Take all rules matched for the same PR, by priority desc + filePath asc (loadRules already pre-sorted, so order is + * preserved), capped at `limit` rules (default {@link DEFAULT_MAX_MATCHED_RULES}) as a safety fallback; those beyond + * the cap are dropped from the end per the sort order. The bodies of multiple rules are concatenated and injected by + * the caller via {@link combineRuleInstructions}. */ export function pickMatchingRules( rules: ReadonlyArray<Rule>, @@ -33,17 +34,18 @@ export function pickMatchingRules( } /** - * 取命中规则的**首条**(priority desc + filePath asc)。UI 单条预览等仍用得到;评审注入走 - * {@link pickMatchingRules} 取多条。 + * Take the **first** matched rule (priority desc + filePath asc). Still used for UI single-rule preview etc.; review + * injection uses {@link pickMatchingRules} to take multiple. */ export function pickMatchingRule(rules: ReadonlyArray<Rule>, ctx: RuleMatchContext): Rule | null { return rules.find((r) => ruleMatches(r, ctx)) ?? null; } /** - * 把多条命中规则的正文拼成单段注入文本。各规则只取 body(frontmatter 在加载期已被 gray-matter 剥离, - * 不会泄漏进 instructions);规则间以 `## Ruleset N` 分段标题分隔,便于模型区分不同规约、互不串味。 - * 单条时也加 `## Ruleset 1` 头以保持一致。空输入 → 空串。 + * Concatenate the bodies of multiple matched rules into a single injection text. Each rule contributes only its body + * (frontmatter was already stripped by gray-matter at load time, so it won't leak into instructions); rules are + * separated by `## Ruleset N` section headings, so the model can distinguish different specs without cross-contamination. + * A single rule also gets a `## Ruleset 1` header for consistency. Empty input → empty string. */ export function combineRuleInstructions(rules: ReadonlyArray<Rule>): string { return rules diff --git a/packages/rules/src/types.ts b/packages/rules/src/types.ts index 043cf2c5..2874902c 100644 --- a/packages/rules/src/types.ts +++ b/packages/rules/src/types.ts @@ -1,53 +1,55 @@ /** - * 单条规则,从 rules.dir 下的某个 .md 文件加载得到。 - * frontmatter 是 yaml,body 是 markdown 正文(最终作为 extra_instructions 注给 pr-agent)。 + * A single rule, loaded from a .md file under rules.dir. + * frontmatter is yaml, body is the markdown content (ultimately injected as extra_instructions to pr-agent). * - * 设计原则: - * - 每个字段都给宽松默认(缺省 = 匹配任意 / 启用 / 不打标签)。规则文件可以只写 body - * 不写 frontmatter,效果是全局生效的"基础规约"。 - * - applies_to.* 的值是**正则源串**,加载时转 RegExp,匹配时 .test() 字段值。 - * 字段缺省 → 匹配任意。 - * - 同一次 pragent:run 可能有多条规则命中;调用方按 priority desc + filePath asc - * 排序后**只取第一条**(pickMatchingRule)。多规则拼接的复杂语义留到后续按需扩展。 + * Design principles: + * - Every field has a lenient default (omitted = match anything / enabled / no labels). A rule file can write only + * the body without frontmatter, producing a globally-effective "base spec". + * - applies_to.* values are **regex source strings**, converted to RegExp at load time, .test() against the field + * value at match time. Field omitted → match anything. + * - A single pragent:run may have multiple rules matched; the caller sorts by priority desc + filePath asc and + * **takes only the first** (pickMatchingRule). The complex semantics of concatenating multiple rules are left for + * later on-demand extension. */ export type RuleTool = 'describe' | 'review' | 'improve'; /** - * 单次评审注入的命中规则上限:超出按排序(priority desc + id asc)取前 N、其余丢弃,避免 prompt 膨胀。 - * 安全兜底,非硬约束语义。 + * Cap on matched rules injected per review: beyond this, take the top N by sort order (priority desc + id asc) and + * drop the rest, avoiding prompt bloat. A safety fallback, not hard-constraint semantics. */ export const DEFAULT_MAX_MATCHED_RULES = 20; /** - * 单个 rules 目录递归收集的 .md 文件数上限:遍历到达即停并告警,避免规则目录被误指向超大目录树时 - * 拖垮加载(性能兜底拦截)。设得远高于命中上限,给非命中规则留足余量。 + * Cap on .md files recursively collected from a single rules directory: stop and warn once reached, avoiding dragging + * down loading when the rules dir is mistakenly pointed at a huge directory tree (performance fallback interception). + * Set far above the match cap, leaving ample room for non-matched rules. */ export const MAX_RULE_FILES = 200; export interface RuleApplies { - /** Bitbucket projectKey 正则,例如 "^FX$" 或 "^FX-.*" */ + /** Bitbucket projectKey regex, e.g. "^FX$" or "^FX-.*" */ project?: RegExp; - /** Bitbucket repoSlug 正则,例如 "^fx-.*" */ + /** Bitbucket repoSlug regex, e.g. "^fx-.*" */ repo?: RegExp; - /** PR base 分支显示名正则,例如 "^(master|main)$" */ + /** PR base branch display name regex, e.g. "^(master|main)$" */ target_branch?: RegExp; } export interface Rule { - /** 文件相对 rules.dir 的路径 (含 .md),作 id + UI 展示 + 排序 tie-break */ + /** File path relative to rules.dir (including .md), serving as id + UI display + sort tie-break */ id: string; - /** 文件绝对路径,调用方需要展示 / 跳转时用 */ + /** File absolute path, used when the caller needs to display / navigate to it */ filePath: string; applies_to: RuleApplies; - /** 缺省值是 `['review']`:规则语义偏代码评审,给 /describe 注入约束容易让描述偏题 */ + /** Defaults to `['review']`: rule semantics lean toward code review, injecting constraints into /describe easily derails the description */ tools: ReadonlyArray<RuleTool>; - /** 命中后给 pr-agent custom_labels (P0 暂不接入,先解析存储) */ + /** custom_labels passed to pr-agent on match (not wired in for P0, just parsed and stored for now) */ custom_labels: ReadonlyArray<string>; - /** 同时命中多条时按 priority desc 排序;默认 0 */ + /** When multiple match simultaneously, sort by priority desc; default 0 */ priority: number; - /** 是否启用,false 时 loadRules 仍会读入但 pickMatchingRule 会跳过 */ + /** Whether enabled; when false loadRules still reads it in but pickMatchingRule skips it */ enabled: boolean; - /** markdown body,作为 extra_instructions 注给 pr-agent */ + /** markdown body, injected as extra_instructions to pr-agent */ instructions: string; } diff --git a/packages/rules/tests/load.test.ts b/packages/rules/tests/load.test.ts index bc099796..59350c8e 100644 --- a/packages/rules/tests/load.test.ts +++ b/packages/rules/tests/load.test.ts @@ -21,12 +21,12 @@ async function write(rel: string, body: string): Promise<void> { } describe('loadRules', () => { - it('空 dir 返回空数组', async () => { + it('empty dir returns empty array', async () => { const rules = await loadRules(''); expect(rules).toEqual([]); }); - it('dir 不存在调 onWarn 并返回空', async () => { + it('nonexistent dir calls onWarn and returns empty', async () => { const warns: string[] = []; const rules = await loadRules(path.join(tmp, 'no-such-dir'), { onWarn: (msg) => warns.push(msg), @@ -35,7 +35,7 @@ describe('loadRules', () => { expect(warns[0]).toMatch(/not found/); }); - it('解析 frontmatter + body;缺省字段走默认', async () => { + it('parses frontmatter + body; missing fields use defaults', async () => { await write( 'simple.md', `--- @@ -65,7 +65,7 @@ priority: 10 expect(r.instructions).toContain('正文示例'); }); - it('递归扫描子目录,id 用相对路径', async () => { + it('scans subdirectories recursively, id uses relative path', async () => { await write('a.md', '---\n---\n\nA'); await write('sub/b.md', '---\n---\n\nB'); await write('sub/nested/c.md', '---\n---\n\nC'); @@ -74,7 +74,7 @@ priority: 10 expect(ids).toEqual(['a.md', 'sub/b.md', 'sub/nested/c.md']); }); - it('跳过隐藏目录 (.git / .vscode)', async () => { + it('skips hidden directories (.git / .vscode)', async () => { await write('keep.md', '---\n---\n\nkeep'); await write('.git/internal.md', '---\n---\n\nshouldnt-load'); await write('.vscode/settings.md', '---\n---\n\nshouldnt-load'); @@ -82,7 +82,7 @@ priority: 10 expect(rules.map((r) => r.id)).toEqual(['keep.md']); }); - it('非 .md 文件被忽略', async () => { + it('non-.md files are ignored', async () => { await write('rule.md', '---\n---\n\nyes'); await write('readme.txt', 'no'); await write('notes.markdown', 'no'); @@ -90,7 +90,7 @@ priority: 10 expect(rules.map((r) => r.id)).toEqual(['rule.md']); }); - it('frontmatter yaml 烂掉时调 onWarn 并跳过该文件,其他规则继续加载', async () => { + it('calls onWarn and skips the file when frontmatter yaml is broken, other rules keep loading', async () => { await write('good.md', '---\napplies_to:\n project: FX\n---\n\nok'); await write('bad.md', '---\napplies_to: {invalid yaml [\n---\n\nbroken'); const warns: string[] = []; @@ -99,7 +99,7 @@ priority: 10 expect(warns.length).toBe(1); }); - it('非法正则源串视为该字段未配置,不抛', async () => { + it('treats an invalid regex source string as the field being unset, does not throw', async () => { await write( 'bad-regex.md', `--- @@ -115,7 +115,7 @@ body expect(rules[0]!.applies_to.project).toBeUndefined(); }); - it('排序:priority desc 优先,tie-break 用 id asc', async () => { + it('sorting: priority desc first, tie-break by id asc', async () => { await write('aaa.md', '---\npriority: 5\n---\n\nA'); await write('bbb.md', '---\npriority: 10\n---\n\nB'); await write('ccc.md', '---\npriority: 5\n---\n\nC'); @@ -123,7 +123,7 @@ body expect(rules.map((r) => r.id)).toEqual(['bbb.md', 'aaa.md', 'ccc.md']); }); - it('frontmatter 完全缺省时也能加载,所有字段走默认 (tools 默认只 review)', async () => { + it('loads even when frontmatter is entirely absent, all fields use defaults (tools defaults to review only)', async () => { await write('bare.md', '# only body\n\n纯 markdown,没 frontmatter'); const rules = await loadRules(tmp); expect(rules).toHaveLength(1); @@ -136,14 +136,14 @@ body expect(r.instructions).toContain('# only body'); }); - it('enabled=false 仍加载到列表 (由 match 阶段过滤)', async () => { + it('enabled=false is still loaded into the list (filtered at the match stage)', async () => { await write('off.md', '---\nenabled: false\n---\n\nbody'); const rules = await loadRules(tmp); expect(rules).toHaveLength(1); expect(rules[0]!.enabled).toBe(false); }); - it('custom_labels 类型容错', async () => { + it('custom_labels type tolerance', async () => { await write( 'labels.md', `--- @@ -154,7 +154,7 @@ body `, ); const rules: Rule[] = await loadRules(tmp); - // 数字 / null 被过滤,只保留 string + // numbers / null are filtered out, only strings kept expect(rules[0]!.custom_labels).toEqual(['tech-debt', 'needs-tests']); }); }); diff --git a/packages/rules/tests/match.test.ts b/packages/rules/tests/match.test.ts index f7ffbbff..79318122 100644 --- a/packages/rules/tests/match.test.ts +++ b/packages/rules/tests/match.test.ts @@ -23,44 +23,44 @@ const ctx: RuleMatchContext = { }; describe('ruleMatches', () => { - it('frontmatter 全省 + enabled=true → 匹配任意', () => { + it('frontmatter all omitted + enabled=true → matches anything', () => { expect(ruleMatches(mkRule(), ctx)).toBe(true); }); - it('enabled=false → 永远不匹配', () => { + it('enabled=false → never matches', () => { expect(ruleMatches(mkRule({ enabled: false }), ctx)).toBe(false); }); - it('tools 不含当前 tool → 不匹配', () => { + it('tools does not include the current tool → no match', () => { expect(ruleMatches(mkRule({ tools: ['describe'] }), ctx)).toBe(false); expect(ruleMatches(mkRule({ tools: ['review'] }), ctx)).toBe(true); }); - it('project 正则精确锚 (^FX$) 命中', () => { + it('project regex with exact anchors (^FX$) matches', () => { const r = mkRule({ applies_to: { project: /^FX$/ } }); expect(ruleMatches(r, ctx)).toBe(true); expect(ruleMatches(r, { ...ctx, projectKey: 'FX-OTHER' })).toBe(false); }); - it('project 正则模糊 (FX) 不锚时会子串命中', () => { + it('fuzzy project regex (FX) without anchors matches on substring', () => { const r = mkRule({ applies_to: { project: /FX/ } }); expect(ruleMatches(r, { ...ctx, projectKey: 'PRE-FX-SUF' })).toBe(true); }); - it('repo 正则命中', () => { + it('repo regex matches', () => { const r = mkRule({ applies_to: { repo: /^fx-.*/ } }); expect(ruleMatches(r, ctx)).toBe(true); expect(ruleMatches(r, { ...ctx, repoSlug: 'other-help' })).toBe(false); }); - it('target_branch 多选正则命中', () => { + it('target_branch multi-choice regex matches', () => { const r = mkRule({ applies_to: { target_branch: /^(master|main)$/ } }); expect(ruleMatches(r, ctx)).toBe(true); expect(ruleMatches(r, { ...ctx, targetBranch: 'main' })).toBe(true); expect(ruleMatches(r, { ...ctx, targetBranch: 'develop' })).toBe(false); }); - it('多字段同时设:任一不匹配整体 false', () => { + it('multiple fields set at once: any one not matching makes the whole thing false', () => { const r = mkRule({ applies_to: { project: /^FX$/, repo: /^fx-other$/ }, }); @@ -69,7 +69,7 @@ describe('ruleMatches', () => { }); describe('pickMatchingRule', () => { - it('多条命中按列表顺序取第一条 (调用方传入时已经预排序)', () => { + it('with multiple matches takes the first by list order (caller pre-sorts before passing in)', () => { const rules = [ mkRule({ id: '01.md' }), mkRule({ id: '02.md' }), @@ -77,12 +77,12 @@ describe('pickMatchingRule', () => { expect(pickMatchingRule(rules, ctx)?.id).toBe('01.md'); }); - it('全不命中返回 null', () => { + it('returns null when nothing matches', () => { const rules = [mkRule({ applies_to: { project: /^OTHER$/ } })]; expect(pickMatchingRule(rules, ctx)).toBeNull(); }); - it('enabled=false 的规则会被跳过,下一条继续', () => { + it('enabled=false rules are skipped, continuing to the next', () => { const rules = [ mkRule({ id: 'a.md', enabled: false }), mkRule({ id: 'b.md' }), @@ -90,7 +90,7 @@ describe('pickMatchingRule', () => { expect(pickMatchingRule(rules, ctx)?.id).toBe('b.md'); }); - it('tool 不匹配的规则会被跳过', () => { + it('rules whose tool does not match are skipped', () => { const rules = [ mkRule({ id: 'desc-only.md', tools: ['describe'] }), mkRule({ id: 'review-ok.md', tools: ['review'] }), @@ -100,7 +100,7 @@ describe('pickMatchingRule', () => { }); describe('pickMatchingRules', () => { - it('返回全部命中(保序),过滤掉不匹配 / 禁用的', () => { + it('returns all matches (order preserved), filtering out non-matching / disabled ones', () => { const rules = [ mkRule({ id: 'a.md' }), mkRule({ id: 'b.md', enabled: false }), @@ -110,18 +110,18 @@ describe('pickMatchingRules', () => { expect(pickMatchingRules(rules, ctx).map((r) => r.id)).toEqual(['a.md', 'd.md']); }); - it('封顶 limit 条(按列表顺序取前 N)', () => { + it('caps at limit entries (takes the first N by list order)', () => { const rules = [mkRule({ id: '1.md' }), mkRule({ id: '2.md' }), mkRule({ id: '3.md' })]; expect(pickMatchingRules(rules, ctx, 2).map((r) => r.id)).toEqual(['1.md', '2.md']); }); - it('全不命中返回空数组', () => { + it('returns an empty array when nothing matches', () => { expect(pickMatchingRules([mkRule({ applies_to: { project: /^OTHER$/ } })], ctx)).toEqual([]); }); }); describe('combineRuleInstructions', () => { - it('按 Ruleset N 分段拼接各规则正文(frontmatter 已在加载期剥离)', () => { + it('concatenates each rule body in Ruleset N sections (frontmatter already stripped at load time)', () => { const out = combineRuleInstructions([ mkRule({ instructions: 'first body' }), mkRule({ instructions: 'second body' }), @@ -129,7 +129,7 @@ describe('combineRuleInstructions', () => { expect(out).toBe('## Ruleset 1\n\nfirst body\n\n## Ruleset 2\n\nsecond body'); }); - it('空输入 → 空串', () => { + it('empty input → empty string', () => { expect(combineRuleInstructions([])).toBe(''); }); }); diff --git a/packages/shared/src/agent-contract.ts b/packages/shared/src/agent-contract.ts index 4af18dde..1a6465f8 100644 --- a/packages/shared/src/agent-contract.ts +++ b/packages/shared/src/agent-contract.ts @@ -1,26 +1,26 @@ /** - * 高阶 Agent 的会话与工具契约类型(见 docs/arch/02-agent/02-session.md「会话 Agent 化」与「数据契约」)。 - * 这些类型被**持久化**(@meebox/poller)、**经 IPC 传输**(ipc.ts)、并在渲染层呈现, - * 故置于 shared(与 ReviewRun / Finding 同处)。@meebox/agent 的纯逻辑从此引用。 + * Session and tool contract types for the high-level Agent (see docs/arch/02-agent/02-session.md "Agentifying the session" and "Data contract"). + * These types are **persisted** (@meebox/poller), **transported over IPC** (ipc.ts), and rendered in the renderer, + * so they live in shared (alongside ReviewRun / Finding). @meebox/agent's pure logic references them from here. */ import type { TokenUsage } from './poller-contract.js'; -/** 工具的副作用分类与可用性(红线落地的依据,见「工具修改红线」)。 */ +/** A tool's side-effect classification and availability (the basis for enforcing the red lines, see "Tool mutation red lines"). */ export interface ToolCatalogEntry { - /** 工具指令名,如 `/describe`。 */ + /** Tool command name, e.g. `/describe`. */ name: string; - /** 语义说明,注入提示词供 Agent 理解何时调用。 */ + /** Semantic description, injected into the prompt so the Agent understands when to call it. */ summary: string; - /** 是否修改类(对远端有副作用)。读/分析类 = false。 */ + /** Whether it is mutating (has side effects on the remote). Read/analysis tools = false. */ mutating: boolean; - /** 是否可被 Agent 自主调用:修改类在未授权时为 false(禁用态注入)。 */ + /** Whether the Agent may call it autonomously: mutating tools are false when unauthorized (injected in a disabled state). */ enabled: boolean; } export type AgentSessionStatus = 'running' | 'paused' | 'done' | 'failed' | 'cancelled'; -/** 编排步骤的种类:规划 / 工具分发 / 判读(见「步与子任务的计量边界」)。 */ +/** The kind of orchestration step: planning / tool dispatch / judging (see "Metering boundary between steps and sub-tasks"). */ export type AgentStepKind = 'plan' | 'tool' | 'judge'; export interface AgentTodoItem { @@ -30,35 +30,35 @@ export interface AgentTodoItem { } export interface AgentToolCall { - /** 被分发的工具名(如 `/review`)。 */ + /** The dispatched tool name (e.g. `/review`). */ tool: string; args?: Record<string, unknown>; } -/** 一个编排级步骤(编排 agent 的一次决策回合,不含 pr-agent run 内部开销)。 */ +/** An orchestration-level step (one decision round of the orchestration agent, excluding the internal cost of a pr-agent run). */ export interface AgentStep { kind: AgentStepKind; - /** 思考摘要(留档 + 流式推送)。 */ + /** Thought summary (archived + streamed). */ thought?: string; - /** kind='tool' 时的工具调用。 */ + /** The tool call when kind='tool'. */ toolCall?: AgentToolCall; - /** 工具结果 / 判读结论的摘要。 */ + /** Summary of the tool result / judging conclusion. */ result?: string; - /** 本步**单独**的 LLM token 用量(不累计、不含其它步):judge / 总结 / 规划等经独立通道的推理步在此带值; - * describe/review/ask 的工具开销由其各自 run 卡片承载、不在步骤上重复计。UI 在步骤行展示,使每步成本可见。 */ + /** This step's **own** LLM token usage (not cumulative, not including other steps): reasoning steps that go through a dedicated channel such as judge / summary / planning carry a value here; + * the tool cost of describe/review/ask is borne by their respective run cards and is not counted again on the step. The UI shows it on the step row so each step's cost is visible. */ usage?: TokenUsage; - /** 步骤产生时间(ISO)。 */ + /** Step creation time (ISO). */ at?: string; - /** 本步思考(产生该决策的单次 LLM 推理)耗时(毫秒);类 Claude Code 的「Thought for Ns」单步计时。 - * 仅推理类步骤(plan/judge)有值;固定派发(如微流程的 describe/review 选择)无 LLM 思考则缺省。 */ + /** This step's thinking (the single LLM inference that produced the decision) duration (ms); a per-step timing like Claude Code's "Thought for Ns". + * Only reasoning steps (plan/judge) have a value; fixed dispatch (such as the micro-flow's describe/review choice) has no LLM thinking and is left unset. */ thinkMs?: number; - /** 是否 AutoPilot 后台评审触发:仅标在该次评审的首步上,UI 据此在步骤行打机器人 chip。 */ + /** Whether triggered by an AutoPilot background review: marked only on the first step of that review, so the UI shows a robot chip on the step row. */ autopilot?: boolean; } export type AgentRecommendationVerdict = 'approve' | 'needs_work' | 'manual_review'; -/** 收尾建议(非约束性,不触发任何写操作,见「AutoPilot」)。 */ +/** Summary recommendation (non-binding, triggers no write operations, see "AutoPilot"). */ export interface AgentRecommendation { verdict: AgentRecommendationVerdict; reason: string; @@ -67,32 +67,32 @@ export interface AgentRecommendation { export type AgentMessageRole = 'user' | 'assistant'; /** - * 一条对话消息(回合级,区别于回合内的 AgentStep)。多轮对话的持久化单元:用户输入与 Agent - * 收尾回答各一条,按时间追加。Agent 自身上下文(规划)会读取历史消息,但**绝不**注入 pr-agent - * 工具调用(工具只看 PR + 当轮问题)。 + * A single conversation message (turn-level, distinct from the AgentStep within a turn). The persistence unit for multi-turn conversations: + * one each for the user input and the Agent's summary answer, appended by time. The Agent's own context (planning) reads historical messages, + * but **never** injects them into pr-agent tool calls (tools only see the PR + the current turn's question). */ export interface AgentMessage { role: AgentMessageRole; content: string; - /** assistant 评审类回合的非约束性判定;对话 / 用户消息不填。 */ + /** Non-binding verdict for an assistant review-type turn; not set for conversation / user messages. */ recommendation?: AgentRecommendation; /** - * 用户消息携带的引用上下文(自描述 markdown:路径 + 行范围 + 代码围栏,见 - * renderer formatReferencedContext)。发起提问时若带 Diff 选区则填,UI 在气泡下方折叠展示; - * 无选区 / 助手消息不填。(finding 引用走 /ask run 卡片,不经本字段。) + * Referenced context carried by a user message (self-describing markdown: path + line range + code fence, see + * renderer formatReferencedContext). Set when a question is asked with a Diff selection, and the UI shows it collapsed below the bubble; + * not set when there is no selection / for assistant messages. (Finding references go through the /ask run card, not this field.) */ referencedContext?: string; - /** 产生时间(ISO),用于时间线排序。 */ + /** Creation time (ISO), used for timeline ordering. */ at: string; } -/** 持久化包装:`prs/<localId>/agent/conversation.json`(多轮消息流式追加,跨回合保留)。 */ +/** Persistence wrapper: `prs/<localId>/agent/conversation.json` (multi-turn messages appended streaming, retained across turns). */ export interface AgentConversationFile { schema_version: 1; messages: AgentMessage[]; } -/** 每个 PR 一份、由子 agent 所有的会话记录(见数据契约)。 */ +/** One per PR, the session record owned by the sub-agent (see the data contract). */ export interface AgentSession { id: string; prLocalId: string; @@ -101,27 +101,27 @@ export interface AgentSession { stepCount: number; maxSteps: number; /** - * 触发本会话的用户自然语言请求(「对话即委派」入口 agent:ask)。自动评审(agent:run) - * 无文本输入 → 不填。UI 据此把用户输入回显为右对齐气泡、归属其发起 PR、持久化恢复。 + * The user's natural-language request that triggered this session ("conversation as delegation" entry agent:ask). An automatic review (agent:run) + * has no text input → not set. The UI uses it to echo the user input as a right-aligned bubble, attribute it to its originating PR, and restore it on persistence. */ userRequest?: string; - /** 本 PR 收尾总结正文(summary_max_chars 仅作提示词软约束引导篇幅,不对正文硬截断)。 */ + /** This PR's summary body (summary_max_chars is only a soft prompt constraint to guide length, not a hard truncation of the body). */ summary?: string; - /** 收尾建议(非约束性)。 */ + /** Summary recommendation (non-binding). */ recommendation?: AgentRecommendation; startedAt: string; finishedAt?: string; - /** 终止原因(如「步数上限中止」「用户暂停」)。 */ + /** Termination reason (e.g. "aborted at step limit", "user paused"). */ terminationReason?: string; } -/** 持久化包装:`prs/<localId>/agent/session.json`。 */ +/** Persistence wrapper: `prs/<localId>/agent/session.json`. */ export interface AgentSessionFile { schema_version: 1; session: AgentSession; } -/** 持久化包装:`prs/<localId>/agent/transcript.json`(步骤流式追加)。 */ +/** Persistence wrapper: `prs/<localId>/agent/transcript.json` (steps appended streaming). */ export interface AgentTranscriptFile { schema_version: 1; steps: AgentStep[]; @@ -130,24 +130,24 @@ export interface AgentTranscriptFile { export type AutopilotDecision = 'review' | 'skipped'; /** - * AutoPilot 每 PR 一条台账:去重 + 审计(见 docs/arch/02-agent/03-autopilot.md「AutoPilot」)。 - * 是否「未自动评审过当前版本」据 `autoReviewedUpdatedAt` 与当前 PR `updatedAt` 是否一致判定, - * 故 PR 推新 commit 后能再次进入候选、内容未变则不重复跑。 + * One AutoPilot ledger entry per PR: dedup + audit (see docs/arch/02-agent/03-autopilot.md "AutoPilot"). + * Whether "the current version has not been auto-reviewed" is decided by whether `autoReviewedUpdatedAt` matches the current PR's `updatedAt`, + * so a PR re-enters candidacy after pushing a new commit, and does not re-run when the content is unchanged. */ export interface AutopilotLedger { prLocalId: string; - /** 评审 / 判定时所对应的 PR updatedAt 快照。 */ + /** Snapshot of the PR's updatedAt at the time of the review / decision. */ autoReviewedUpdatedAt: string; decision: AutopilotDecision; - /** 判定原因(skipped 时尤其有用,便于审计 / UI 展示)。 */ + /** Decision reason (especially useful when skipped, for audit / UI display). */ reason?: string; - /** 若评审,子 agent 给出的建议倾向(供 PR 列表徽标直接读、无需加载会话)。 */ + /** If reviewed, the recommendation leaning given by the sub-agent (read directly by the PR list badge, no need to load the session). */ recommendation?: AgentRecommendationVerdict; - /** 写入时间(ISO)。 */ + /** Write time (ISO). */ at: string; } -/** 持久化包装:`prs/<localId>/agent/autopilot.json`。 */ +/** Persistence wrapper: `prs/<localId>/agent/autopilot.json`. */ export interface AutopilotLedgerFile { schema_version: 1; ledger: AutopilotLedger; diff --git a/packages/shared/src/app-info.ts b/packages/shared/src/app-info.ts index ae285712..1ee71757 100644 --- a/packages/shared/src/app-info.ts +++ b/packages/shared/src/app-info.ts @@ -9,16 +9,16 @@ export interface AppPaths { archivedDir: string; /** logs/ subdir */ logsDir: string; - /** agent/ subdir — 默认 Agent 目录位置(SOUL/AGENTS/MEMORY/USER + rules/) */ + /** agent/ subdir — default Agent directory location (SOUL/AGENTS/MEMORY/USER + rules/) */ agentDir: string; - /** cache/ subdir — 临时性可重建数据 (avatars 等),可被外部清空 */ + /** cache/ subdir — transient rebuildable data (avatars etc.), may be cleared externally */ cacheDir: string; /** repos_dir resolved from config (may differ from default) */ reposDir: string; } -/** 与 Node.js 的 process.platform 字面量完全匹配,但不引入 NodeJS 命名空间, - * 这样 renderer (不挂 @types/node) 也能消费 shared 类型。 */ +/** Matches Node.js's process.platform literals exactly, but without pulling in the NodeJS namespace, + * so the renderer (which does not include @types/node) can also consume the shared type. */ export type Platform = | 'aix' | 'android' @@ -46,20 +46,20 @@ export interface AppInfo { } /** - * 版本更新检测结果。仅检测 + 提示,不自动下载 / 安装。 - * - ok=false:检测未完成(网络 / 解析失败),error 给原因;hasUpdate 恒 false。 - * - ok=true:检测完成;hasUpdate 表示是否有更新版本。 + * Version update check result. Detects + notifies only, does not auto download / install. + * - ok=false: the check did not complete (network / parse failure), error gives the reason; hasUpdate is always false. + * - ok=true: the check completed; hasUpdate indicates whether a newer version exists. */ export interface UpdateCheckResult { ok: boolean; hasUpdate: boolean; currentVersion: string; - /** 最新稳定版版本号(ok=true 时给出) */ + /** Latest stable version number (given when ok=true) */ latestVersion?: string; - /** 最新版 Release 页 URL(hasUpdate=true 时给出,供用户手动下载) */ + /** Release page URL of the latest version (given when hasUpdate=true, for the user to download manually) */ url?: string; - /** 最新版发布时间 ISO(可选) */ + /** Release time of the latest version, ISO (optional) */ publishedAt?: string; - /** ok=false 时的失败原因 */ + /** Failure reason when ok=false */ error?: string; } diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 2bb368e9..0b665f27 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -9,9 +9,9 @@ import { export const CloneSettingsSchema = z .object({ /** - * git clone 协议。 - * - pat (默认): HTTPS,URL 里嵌 `<当前用户名>:<PAT>` (Bitbucket Server 约定) - * - ssh: scp-like `git@<host>:<project>/<repo>.git`,端口/密钥走系统 ssh config + * git clone protocol. + * - pat (default): HTTPS, embeds `<current username>:<PAT>` in the URL (Bitbucket Server convention) + * - ssh: scp-like `git@<host>:<project>/<repo>.git`, port/key go through the system ssh config */ protocol: z.enum(['pat', 'ssh']).default('pat'), }) @@ -29,16 +29,16 @@ export const BitbucketServerConnectionSchema = z.object({ clone: CloneSettingsSchema, }); -/** github.com 官方 REST API base。GitHub 连接的 base_url 留空时默认走这里。 */ +/** github.com official REST API base. Used by default when a GitHub connection's base_url is left empty. */ export const GITHUB_DOTCOM_API_BASE = 'https://api.github.com'; export const GitHubConnectionSchema = z.object({ id: z.string().min(1), kind: z.literal('github'), /** - * GitHub REST API base。**可选**:留空 / 缺省时默认 `https://api.github.com`(github.com); - * GitHub Enterprise Server 填 `https://<ghe-host>/api/v3`。clone / web host 由 adapter - * 推导(api.github.com → github.com;GHE → 同 host)。 + * GitHub REST API base. **Optional**: when empty / omitted, defaults to `https://api.github.com` (github.com); + * for GitHub Enterprise Server, use `https://<ghe-host>/api/v3`. clone / web host are derived by the adapter + * (api.github.com → github.com; GHE → same host). */ base_url: z.preprocess( (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v), @@ -52,16 +52,16 @@ export const GitHubConnectionSchema = z.object({ clone: CloneSettingsSchema, }); -/** gitlab.com 官方 REST API v4 base。GitLab 连接的 base_url 留空时默认走这里。 */ +/** gitlab.com official REST API v4 base. Used by default when a GitLab connection's base_url is left empty. */ export const GITLAB_DOTCOM_API_BASE = 'https://gitlab.com/api/v4'; export const GitLabConnectionSchema = z.object({ id: z.string().min(1), kind: z.literal('gitlab'), /** - * GitLab REST API v4 base。**可选**:留空 / 缺省时默认 `https://gitlab.com/api/v4`(gitlab.com); - * 自建 / GitLab Self-Managed 填 `https://<host>/api/v4`。clone / web host 由 adapter 推导 - * (去掉 `/api/v4` 取实例 host),CE 与 EE 经 edition 探测在能力位上降级(审批)。 + * GitLab REST API v4 base. **Optional**: when empty / omitted, defaults to `https://gitlab.com/api/v4` (gitlab.com); + * for self-hosted / GitLab Self-Managed, use `https://<host>/api/v4`. clone / web host are derived by the adapter + * (strip `/api/v4` to get the instance host), and CE vs EE is degraded on capability bits via edition detection (approvals). */ base_url: z.preprocess( (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v), @@ -82,15 +82,15 @@ export const ConnectionSchema = z.discriminatedUnion('kind', [ ]); /** - * 单条 LLM 预设。多条 profile 共存,由 `llm.active_id` 切换当前生效。 - * pr-agent 内部用 litellm;provider 决定走 OPENAI__* / ANTHROPIC__* 等哪族环境变量。 - * `openai-compatible` 覆盖 vLLM / DeepSeek / 中转 / 本地 Ollama 的 OpenAI 兼容端点(/v1) - * 等所有 OpenAI API 协议兼容的方案。 + * A single LLM profile. Multiple profiles coexist, and `llm.active_id` switches which one is currently in effect. + * pr-agent uses litellm internally; the provider decides which family of env vars (OPENAI__* / ANTHROPIC__* etc.) is used. + * `openai-compatible` covers vLLM / DeepSeek / relay / local Ollama OpenAI-compatible endpoints (/v1) + * and all other schemes compatible with the OpenAI API protocol. */ /** - * 兼容迁移:已废弃的 `ollama` provider → `openai-compatible`。Ollama 自带 OpenAI 兼容端点 - * `/v1`,统一走更标准、更稳的 OpenAI 路径(litellm `openai/` + OPENAI__API_BASE)。base_url - * 补足 `/v1`(旧 ollama 默认是原生 API 根 `http://localhost:11434`,无 `/v1`)。 + * Compatibility migration: the deprecated `ollama` provider → `openai-compatible`. Ollama ships an OpenAI-compatible endpoint + * `/v1`, so route uniformly through the more standard, more stable OpenAI path (litellm `openai/` + OPENAI__API_BASE). base_url + * gets `/v1` appended (the old ollama default was the native API root `http://localhost:11434`, without `/v1`). */ function migrateLegacyLlmProvider(val: unknown): unknown { if (val !== null && typeof val === 'object' && !Array.isArray(val)) { @@ -109,26 +109,26 @@ function migrateLegacyLlmProvider(val: unknown): unknown { } const LlmProfileObject = z.object({ - /** 稳定 id,UI 选中 / 引用用;新建时 renderer 生成 (uuid 或 timestamp) */ + /** Stable id, used for UI selection / reference; generated by the renderer on creation (uuid or timestamp) */ id: z.string().min(1), - /** 给人看的名字,可空,UI 会拿 provider+model 做后备显示 */ + /** Human-readable name, may be empty; the UI falls back to provider+model for display */ label: z.string().default(''), provider: z .enum(['openai', 'openai-compatible', 'deepseek', 'anthropic', 'dashscope', 'volcengine-ark', 'cli']) .default('openai-compatible'), - /** OpenAI 系 / 本地服务: api_base。非必填留空 */ + /** OpenAI-family / local service: api_base. Optional, leave empty */ base_url: z.string().default(''), /** - * pr-agent 的 `config.model`,litellm 接受 `<provider>/<name>` 前缀 - * (如 `openai/qwen2.5`、`anthropic/claude-3-5-sonnet`),也接受裸名 - * (`gpt-4o`) 走 OpenAI。 + * pr-agent's `config.model`; litellm accepts a `<provider>/<name>` prefix + * (e.g. `openai/qwen2.5`, `anthropic/claude-3-5-sonnet`), and also accepts a bare name + * (`gpt-4o`) which goes to OpenAI. */ model: z.string().default(''), - /** 主密钥;本地 / 不需要鉴权的服务留空 */ + /** Primary key; leave empty for local / no-auth services */ api_key: z.string().default(''), }); -/** 旧 `ollama` profile 在校验前迁移为 `openai-compatible`(见 migrateLegacyLlmProvider)。 */ +/** An old `ollama` profile is migrated to `openai-compatible` before validation (see migrateLegacyLlmProvider). */ export const LlmProfileSchema = z.preprocess(migrateLegacyLlmProvider, LlmProfileObject); export type LlmProvider = @@ -136,16 +136,16 @@ export type LlmProvider = | 'openai-compatible' | 'deepseek' | 'anthropic' - | 'dashscope' // 阿里百炼(DashScope,OpenAI 兼容入口,含千问 / Qwen / DeepSeek-on-DashScope) - | 'volcengine-ark' // 火山方舟(Volcengine Ark,OpenAI 兼容入口,含豆包 / Doubao 等) - | 'cli'; // 本地命令行:由本机已安装的 agentic CLI(一期 claude code)执行评审,不直连 API + | 'dashscope' // Alibaba Bailian (DashScope, OpenAI-compatible entry, includes Qwen / DeepSeek-on-DashScope) + | 'volcengine-ark' // Volcengine Ark (OpenAI-compatible entry, includes Doubao etc.) + | 'cli'; // Local command line: a locally installed agentic CLI (phase 1: claude code) runs the review, without directly connecting to the API export type LlmProfile = z.infer<typeof LlmProfileSchema>; /** - * 出站网络代理。一期仅 HTTP 代理:开关打开后 LLM / Bitbucket Server - * REST / git HTTPS 统一走代理,仅 loopback/本地(含本地 Ollama)自动直连;SSH 走用户 - * 自配 ~/.ssh/config。配置面只暴露 地址/端口/Basic Auth。 - * `protocol` 为枚举预留扩展位(一期仅 'http';追加 socks5 等对存量配置非破坏性)。 + * Outbound network proxy. Phase 1 supports HTTP proxy only: once enabled, LLM / Bitbucket Server + * REST / git HTTPS all go through the proxy, only loopback/local (including local Ollama) connect directly automatically; SSH goes through the user's + * own ~/.ssh/config. The config surface only exposes host/port/Basic Auth. + * `protocol` is an enum reserved as an extension point (phase 1 only 'http'; adding socks5 etc. is non-breaking for existing configs). */ export const ProxySchema = z.object({ enabled: z.boolean().default(false), @@ -158,10 +158,10 @@ export const ProxySchema = z.object({ export type ProxyConfig = z.infer<typeof ProxySchema>; /** - * 本地 API 服务监听(见 docs/arch/04-integration/01-service-api.md)。默认关闭、零暴露面;开启即**强制** - * bearer token 鉴权(token 为空时由主进程在启用时自动生成)。`host` 默认仅 loopback(127.0.0.1),可设 - * `0.0.0.0` 暴露到局域网——高风险、需安全警示。`port` 固定安全默认 18765(10000+,避开常见开发端口且低于 - * 临时端口范围)。token 明文落盘(同既有凭据策略,经 SecretStore 抽象、绝不进日志)。 + * Local API service listener (see docs/arch/04-integration/01-service-api.md). Disabled by default, zero exposure surface; enabling **enforces** + * bearer token authentication (the token is auto-generated by the main process on enable when empty). `host` defaults to loopback only (127.0.0.1), and can be set to + * `0.0.0.0` to expose it to the LAN — high risk, needs a security warning. `port` has a fixed safe default of 18765 (10000+, avoiding common dev ports and below + * the ephemeral port range). The token is stored in plaintext on disk (same as the existing credential policy, abstracted via SecretStore, never entering logs). */ export const ServiceSchema = z.object({ enabled: z.boolean().default(false), @@ -172,9 +172,9 @@ export const ServiceSchema = z.object({ export type ServiceConfig = z.infer<typeof ServiceSchema>; /** - * LLM 上下文长度(token):裁剪输入内容的全局上限,透传 pr-agent `CONFIG__MAX_MODEL_TOKENS` / - * `CONFIG__CUSTOM_MODEL_MAX_TOKENS`。默认 128000(与现代主流模型上下文匹配);**对本地 CLI 模式 - * 不生效**(CLI 工具自管上下文,见 @meebox/pr-agent-bridge)。范围 32k~1M。 + * LLM context length (token): the global cap for trimming input content, passed through to pr-agent `CONFIG__MAX_MODEL_TOKENS` / + * `CONFIG__CUSTOM_MODEL_MAX_TOKENS`. Default 128000 (matching the context of modern mainstream models); **does not apply to local CLI mode** + * (the CLI tool manages its own context, see @meebox/pr-agent-bridge). Range 32k~1M. */ export const LLM_CONTEXT_TOKENS_MIN = 32000; export const LLM_CONTEXT_TOKENS_MAX = 1000000; @@ -182,28 +182,28 @@ export const LLM_CONTEXT_TOKENS_DEFAULT = 128000; export const ConfigSchema = z.object({ /** - * UI 与 pr-agent 输出使用的语言 (ISO locale,如 'zh-CN' / 'en-US' / 'ja-JP' / 'de-DE')。 - * **默认空串 = 自动**:由 `resolveLanguage` 按操作系统偏好语言匹配,无合适项回落英语。 - * 非空则按显式选择。透传到容器 `CONFIG__RESPONSE_LANGUAGE`(经解析后的有效值)。 + * The language used by the UI and pr-agent output (ISO locale, e.g. 'zh-CN' / 'en-US' / 'ja-JP' / 'de-DE'). + * **Empty string by default = automatic**: matched by `resolveLanguage` against the OS preferred language, falling back to English when there is no suitable match. + * When non-empty, follows the explicit choice. Passed through to the container `CONFIG__RESPONSE_LANGUAGE` (the resolved effective value). */ language: z.string().default(''), /** - * 外观偏好(全局主题 / 编辑器字体等纯前端展示项;主进程仅据主题设原生窗口 themeSource)。 + * Appearance preferences (pure frontend display items such as global theme / editor font; the main process only sets the native window themeSource based on the theme). */ appearance: z .object({ /** - * 全局主题(Monaco 编辑器 + 整个 GUI chrome 共用):'auto' 跟随系统深 / 浅色(默认,深 → Dark - * Modern、浅 → Light Modern),其余为内置 / 第三方主题 id(见 @meebox/shared EDITOR_THEME_OPTIONS)。 - * 主题反推浅 / 深写入 data-theme 驱动语义色板,并派生 chrome 结构色;解析见 renderer/src/theme。 + * Global theme (shared by the Monaco editor + the entire GUI chrome): 'auto' follows the system dark / light (default, dark → Dark + * Modern, light → Light Modern), the rest are built-in / third-party theme ids (see @meebox/shared EDITOR_THEME_OPTIONS). + * The theme's inferred light / dark is written to data-theme to drive the semantic color palette and derive the chrome structural colors; resolution see renderer/src/theme. */ editor_theme: z.enum(EDITOR_THEME_IDS).default('auto'), /** - * 编辑器等宽字体族(CSS font-family,可逗号分隔多候选)。**默认空** = 用内置 mono 字体栈; - * 非空则覆盖编辑器与全应用等宽文本字体。 + * Editor monospace font family (CSS font-family, comma-separated candidates allowed). **Empty by default** = use the built-in mono font stack; + * when non-empty, overrides the monospace text font for the editor and the whole app. */ editor_font_family: z.string().default(''), - /** 编辑器字号(px)。限合理范围(见 EDITOR_FONT_SIZE_MIN/MAX),默认 14;按平台再做微调。 */ + /** Editor font size (px). Bounded to a reasonable range (see EDITOR_FONT_SIZE_MIN/MAX), default 14; further fine-tuned per platform. */ editor_font_size: z .number() .int() @@ -218,61 +218,78 @@ export const ConfigSchema = z.object({ }) .default({}), /** - * 高阶 Agent(见 docs/arch/02-agent/01-agent.md)。Agent 目录是 Agent 的完整人格与知识来源: - * `<agent.dir>/` 下含 SOUL.md / AGENTS.md / MEMORY.md / USER.md 与 rules/ 子目录 - * (规则正文,匹配语义见 @meebox/rules)。 + * The high-level Agent (see docs/arch/02-agent/01-agent.md). The Agent directory is the Agent's complete personality and knowledge source: + * `<agent.dir>/` contains SOUL.md / AGENTS.md / MEMORY.md / USER.md and the rules/ subdirectory + * (rule bodies, match semantics see @meebox/rules). * - * Agent 无独立启用开关——只要配置了 LLM 且 pr-agent 就绪即可用。dir 留空(默认)时回落 - * 工作目录下的默认位置(`~/.code-meeseeks/agent`,启动期幂等脚手架);配自定义路径可指向一个 - * git repo 让团队共享上下文。 + * The Agent has no separate enable switch — it is usable as long as an LLM is configured and pr-agent is ready. When dir is empty (default) it falls back to + * the default location under the working directory (`~/.code-meeseeks/agent`, idempotently scaffolded at startup); a custom path can point to a + * git repo so a team can share context. */ agent: z .object({ dir: z.string().default(''), - /** 单会话步数上限(默认取小值;见 docs/arch/02-agent/02-session.md「会话 Agent 化」)。 */ + /** Per-session step cap (defaults to a small value; see docs/arch/02-agent/02-session.md "Agentifying the session"). */ max_steps: z.number().int().min(1).max(50).default(8), - /** 收尾总结严格篇幅上限(字符)。 */ + /** Strict length cap for the summary (characters). */ summary_max_chars: z.number().int().min(100).max(4000).default(800), /** - * AutoPilot 预评审(见 docs/arch/02-agent/03-autopilot.md「AutoPilot」)。默认关闭,状态栏可启用。 - * enabled=false 时调度逻辑完全不跑。 + * AutoPilot pre-review (see docs/arch/02-agent/03-autopilot.md "AutoPilot"). Disabled by default, can be enabled from the status bar. + * When enabled=false the scheduling logic does not run at all. */ autopilot: z .object({ enabled: z.boolean().default(false), - // 评估节奏对齐轮询(每个 poller tick 评估一遍),不再单设最小间隔;准入门控 + 台账去重防重复。 - /** 单批 LLM 判定的 PR 上限。 */ + // The evaluation cadence is aligned with polling (evaluated once per poller tick), no separate minimum interval; admission gating + ledger dedup prevent repeats. + /** Per-batch cap on the number of PRs for LLM decisions. */ batch_size: z.number().int().min(1).max(50).default(10), /** - * 逐项写权限授权(默认空 = 全拒)。如 'approve' / 'needs_work' / - * 'publish_comment';运行期按红线硬校验放行(见「工具修改红线」)。 + * Per-item write permission grants (empty by default = deny all). E.g. 'approve' / 'needs_work' / + * 'publish_comment'; released at runtime after hard validation against the red lines (see "Tool mutation red lines"). */ grants: z.array(z.string()).default([]), }) .default({}), /** - * Agent 行为策略(扩展位,后续行为开关并入此处)。作用于自动评审微流程(手动自动评审 + - * AutoPilot 共用),非 AutoPilot 专属。当前各项: - * - auto_followup:评审运行阶段是否启用**自动追问**(条件性 /ask)。关闭则评审微流程跳过 - * judge + asks 两步、直接总结,省一次 judge LLM 调用与潜在追问开销(省 token)。默认开, - * 与历史行为一致。 - * - max_followup_asks:自动追问数量上限(条件性 /ask 的硬上限)。 + * Agent behavior strategy (an extension point; subsequent behavior switches are merged here). Applies to the automatic review micro-flow (shared by manual auto-review + + * AutoPilot), not exclusive to AutoPilot. Current items: + * - auto_followup: whether to enable **automatic follow-up questions** (conditional /ask) during the review run phase. When off, the review micro-flow skips + * the judge + asks two steps and summarizes directly, saving one judge LLM call and potential follow-up cost (saves token). On by default, + * consistent with historical behavior. + * - max_followup_asks: cap on the number of automatic follow-up questions (hard cap on conditional /ask). */ strategy: z .object({ auto_followup: z.boolean().default(true), /** - * 自动追问数量上限(条件性 /ask 的硬上限,0~5,默认 2)。仅 auto_followup 开启时生效; - * 0 等同关闭(开关已独立控制启停,故 UI 下拉只提供 1~5、不给 0 以免歧义)。 + * Cap on the number of automatic follow-up questions (hard cap on conditional /ask, 0~5, default 2). Effective only when auto_followup is on; + * 0 is equivalent to off (the switch already controls enable/disable independently, so the UI dropdown offers only 1~5, not 0, to avoid ambiguity). */ max_followup_asks: z.number().int().min(0).max(5).default(2), /** - * 单次任务生成的代码建议 / 评审发现数量上限(2~8,默认 4)。统一约束三处: - * - /review:pr-agent `pr_reviewer.num_max_findings`(硬上限); - * - /improve:pr-agent `pr_code_suggestions.num_code_suggestions`(硬上限); - * - /ask:结构化分段 `<suggestions>` 的提示层软约束(模型一般遵守,极少数可能超)。 + * Cap on the number of code suggestions / review findings generated per task (2~8, default 4). Uniformly constrains three places: + * - /review: pr-agent `pr_reviewer.num_max_findings` (hard cap); + * - /improve: pr-agent `pr_code_suggestions.num_code_suggestions` (hard cap); + * - /ask: a prompt-layer soft constraint on the structured `<suggestions>` section (the model generally complies, a rare few may exceed). */ max_code_suggestions: z.number().int().min(2).max(8).default(4), + /** + * User-defined code-suggestion spec (free text): injected verbatim as extra_instructions for the tools + * that produce code suggestions (/improve, /review, /ask), shaping how the model structures each + * suggestion (e.g. a Problem / Analysis / Suggestion sectioning). A **soft constraint** — the model + * generally complies but is not guaranteed to. Empty by default (nothing injected). /describe is excluded. + */ + code_suggestion_spec: z.string().default(''), + /** + * User-defined markdown layout applied **deterministically** when turning an AI code-suggestion finding into + * a review draft body — the author controls the whole comment layout (line breaks, sections, extra content). + * Placeholders (uppercase, angle-bracketed): `<TITLE>` localized "AI suggestion" label, `<SUGGESTIONS>` the + * suggestion body, `<HOME>` project site url, `<PR>` current PR url, `<MODEL>` current active model name (for + * an attribution suffix). If it contains `<SUGGESTIONS>` it is the full body template; otherwise the whole + * string is prepended as a prefix before the body. Empty falls back to `DEFAULT_CODE_SUGGESTION_LAYOUT` + * (an AI-suggestion badge + model name, then the body). + */ + code_suggestion_layout: z.string().default(''), }) .default({}), }) @@ -282,70 +299,70 @@ export const ConfigSchema = z.object({ interval_seconds: z.number().int().min(30).default(300), }) .default({}), - /** 出站网络代理。默认关闭 = 全部直连,等同历史行为。 */ + /** Outbound network proxy. Disabled by default = all direct connections, equivalent to historical behavior. */ proxy: ProxySchema.default({}), /** - * 版本更新检测。启动时(及设置页手动)查 GitHub Releases 最新稳定版与当前版本比对, - * 有新版仅**提示**用户去下载(不自动下载 / 安装)。check_enabled=false 关闭检测。 + * Version update check. At startup (and manually from the settings page) queries the latest stable GitHub Releases version and compares it against the current version, + * only **notifying** the user to download when a newer version exists (no auto download / install). check_enabled=false disables the check. */ update: z .object({ check_enabled: z.boolean().default(true), }) .default({}), - /** 本地 API 服务监听(默认关闭)。见上 {@link ServiceSchema}。 */ + /** Local API service listener (disabled by default). See {@link ServiceSchema} above. */ service: ServiceSchema.default({}), /** - * 消息通知(见 docs/arch/03-gui/03-notifications.md)。enabled 为总开关;关闭后既不弹系统通知也不亮 dock 角标。 - * 其余各项按事件类型分别控制系统通知(toast)是否弹出:new_pr / reply / mention 面向「待我评审」等场景; - * authored_* 面向「我创建的」PR(作者为本人)——新评论 / 被标记需修改 / 出现冲突。macOS dock「待回应」计数 - * 角标无独立开关——随总开关默认启用。系统通知受 OS 权限约束——用户在系统设置关闭后应用静默降级。 + * Notifications (see docs/arch/03-gui/03-notifications.md). enabled is the master switch; when off, neither system notifications pop nor does the dock badge light up. + * The other items control per event type whether a system notification (toast) pops: new_pr / reply / mention target scenarios like "awaiting my review"; + * authored_* target PRs "I authored" (I am the author) — new comment / marked needs work / merge conflict appeared. The macOS dock "awaiting response" count + * badge has no separate switch — enabled by default along with the master switch. System notifications are subject to OS permission — when the user disables them in system settings, the app silently degrades. */ notifications: z .object({ enabled: z.boolean().default(true), - /** 收到新的 PR 时弹系统通知 */ + /** Pop a system notification when a new PR arrives */ new_pr: z.boolean().default(true), - /** 收到评论回复时弹系统通知 */ + /** Pop a system notification when a comment reply is received */ reply: z.boolean().default(true), - /** 评论中被 @ 提及时弹系统通知 */ + /** Pop a system notification when @-mentioned in a comment */ mention: z.boolean().default(true), - /** 我创建的 PR 收到他人新评论时弹系统通知 */ + /** Pop a system notification when a PR I authored receives a new comment from someone else */ authored_comment: z.boolean().default(true), - /** 我创建的 PR 被评审标记「需修改」时弹系统通知 */ + /** Pop a system notification when a PR I authored is marked "needs work" by a review */ authored_needs_work: z.boolean().default(true), - /** 我创建的 PR 出现合并冲突时弹系统通知 */ + /** Pop a system notification when a PR I authored has a merge conflict */ authored_conflict: z.boolean().default(true), }) .default({}), /** - * pr-agent 运行时策略选择。 - * - 'auto'(默认):优先嵌入式运行时(随 app 打包,正常安装恒可用),缺失则 - * 回退探测系统 local-cli; - * - 显式 'embedded' / 'local-cli':强制该策略,便于高级用户切到自有系统 CLI。 + * pr-agent runtime strategy selection. + * - 'auto' (default): prefer the embedded runtime (bundled with the app, always available on a normal install), and if missing + * fall back to probing the system local-cli; + * - explicit 'embedded' / 'local-cli': force that strategy, convenient for advanced users to switch to their own system CLI. */ pr_agent: z .object({ strategy: z.enum(['auto', 'embedded', 'local-cli']).default('auto'), /** - * 评审任务并发数(1~8,默认 2)。嵌入式 / local-cli 下每个 run 独立 worktree + - * 独立子进程,并发安全;上限节流 LLM 限流 / 本机资源。设置页可调(config:setMaxConcurrency - * 热替换队列上限),亦可 config.yaml 手改。 + * Review task concurrency (1~8, default 2). Under embedded / local-cli, each run has an independent worktree + + * an independent child process, concurrency-safe; the cap throttles LLM rate limits / local machine resources. Adjustable from the settings page (config:setMaxConcurrency + * hot-swaps the queue cap), or editable by hand in config.yaml. */ max_concurrency: z.number().int().min(1).max(8).default(2), }) .default({}), connections: z.array(ConnectionSchema).default([]), /** - * 当前**启用**的唯一连接 id(同时只启用一条,见设置页)。空串 / 找不到对应连接时 - * 不轮询任何连接(UI 引导用户启用一条)。connections 数组保留全部配置,仅 active - * 这条被建 adapter 轮询;按 id 查连接的地方仍读全量,历史 PR 不受影响。 + * The single currently **active** connection id (only one is active at a time, see the settings page). When empty / no matching connection is found, + * no connection is polled (the UI guides the user to enable one). The connections array retains all configs, only the active + * one gets an adapter built and polled; places that look up a connection by id still read the full set, so historical PRs are unaffected. */ active_connection_id: z.string().default(''), llm: z.preprocess( - // 兼容旧 single-config 形态:M3-C 初版用过 { provider, base_url, model, api_key } - // 直接作为 llm 字段;现在改成 { profiles: [...], active_id }。检测旧 shape - // 自动塞成一个 id='default' 的 profile。 + // Compatibility with the old single-config shape: the initial M3-C version used { provider, base_url, model, api_key } + // directly as the llm field; it is now { profiles: [...], active_id }. Detecting the old shape + // automatically wraps it into a single profile with id='default'. (val) => { if ( val !== null && @@ -359,7 +376,7 @@ export const ConfigSchema = z.object({ ) { const o = val as Record<string, unknown>; const oldProvider = typeof o.provider === 'string' ? o.provider : ''; - // azure 已废,转成 openai-compatible (Azure 本质就是 OpenAI API + 自定义 base_url) + // azure is deprecated, convert to openai-compatible (Azure is essentially the OpenAI API + a custom base_url) const provider = ( [ 'openai', @@ -392,17 +409,17 @@ export const ConfigSchema = z.object({ }, z .object({ - /** 用户保存的多套 LLM 预设(每条独立 provider/model/base_url/key) */ + /** The multiple LLM profiles the user has saved (each with its own provider/model/base_url/key) */ profiles: z.array(LlmProfileSchema).default([]), /** - * 当前选中的 profile id。空字符串 或 找不到对应 profile 时 pragent:run - * 不注入任何 LLM env,pr-agent 退到读 shell 环境变量。 + * The currently selected profile id. When it is an empty string or no matching profile is found, pragent:run + * injects no LLM env and pr-agent falls back to reading the shell environment variables. */ active_id: z.string().default(''), /** - * 裁剪输入内容的上下文长度上限(token)。透传 pr-agent CONFIG__MAX_MODEL_TOKENS / - * CONFIG__CUSTOM_MODEL_MAX_TOKENS;超长改动按此截断以适配模型。**本地 CLI 模式不生效** - * (CLI 工具自管上下文)。默认 128000,范围 32k~1M(见 @meebox/pr-agent-bridge)。 + * The context length cap (token) for trimming input content. Passed through to pr-agent CONFIG__MAX_MODEL_TOKENS / + * CONFIG__CUSTOM_MODEL_MAX_TOKENS; oversized changes are truncated by this to fit the model. **Does not apply to local CLI mode** + * (the CLI tool manages its own context). Default 128000, range 32k~1M (see @meebox/pr-agent-bridge). */ context_tokens: z .number() diff --git a/packages/shared/src/error-code.ts b/packages/shared/src/error-code.ts index 4496a9c9..4da88989 100644 --- a/packages/shared/src/error-code.ts +++ b/packages/shared/src/error-code.ts @@ -1,86 +1,86 @@ /** - * 错误码与统一错误对象。面向用户、跨 IPC 的后端错误一律抛 AppError,以错误码承载,本地化由前端按码做。 - * 设计见 docs/arch/99-core/04-error-codes.md。技术异常 / 后台日志仍用英语(边界:是否跨 IPC 展示给用户)。 + * Error codes and the unified error object. User-facing, cross-IPC backend errors all throw AppError, carried by an error code, with localization done by the frontend per code. + * Design see docs/arch/99-core/04-error-codes.md. Technical exceptions / background logs still use English (boundary: whether shown to the user across IPC). */ -/** 领域标签(两字母大写)。新增领域追加在末尾。 */ +/** Domain tag (two uppercase letters). New domains are appended at the end. */ export type ErrorDomain = 'AG' | 'UI' | 'CF' | 'NT' | 'PR' | 'SV'; /** - * 错误码注册表(唯一真相源):`E` + 两字母领域 + 四位数字。新增码在此登记,并在渲染层各 locale 补 - * `errors.<CODE>`。每领域保留 `0000` 作未分类兜底。 + * Error code registry (single source of truth): `E` + two-letter domain + four digits. New codes are registered here, and `errors.<CODE>` is added + * to each locale in the renderer. Each domain reserves `0000` as the unclassified fallback. */ export const ERROR_CODES = { - /** 未分类 Agent 错误(兜底)。 */ + /** Unclassified Agent error (fallback). */ AG_UNCLASSIFIED: 'EAG0000', - /** `/ask` 缺少 question。 */ + /** `/ask` is missing question. */ AG_ASK_NEEDS_QUESTION: 'EAG0001', - /** 该 PR 的 `/{tool}` 任务已在执行或排队中(meta.tool)。 */ + /** A `/{tool}` task for this PR is already running or queued (meta.tool). */ AG_DUPLICATE_TASK: 'EAG0002', - /** pr-agent 未就绪(嵌入式运行时与本机 CLI 都未探测到)。 */ + /** pr-agent is not ready (neither the embedded runtime nor a local CLI was detected). */ AG_PR_AGENT_NOT_READY: 'EAG0003', - /** 未分类配置错误(兜底)。 */ + /** Unclassified config error (fallback). */ CF_UNCLASSIFIED: 'ECF0000', - /** 平台版本低于支持下限(meta.version 实际版本、meta.min 最低要求)。 */ + /** Platform version below the supported minimum (meta.version actual version, meta.min minimum requirement). */ CF_UNSUPPORTED_VERSION: 'ECF0001', - /** 未分类网络错误(兜底)。 */ + /** Unclassified network error (fallback). */ NT_UNCLASSIFIED: 'ENT0000', - /** 代理未启用或地址为空。 */ + /** Proxy not enabled or host is empty. */ NT_PROXY_DISABLED: 'ENT0001', - /** 代理认证失败(407)。 */ + /** Proxy authentication failed (407). */ NT_PROXY_AUTH_FAILED: 'ENT0407', - /** 未分类 PR / 草稿错误(兜底)。 */ + /** Unclassified PR / draft error (fallback). */ PR_UNCLASSIFIED: 'EPR0000', - /** 草稿不存在(可能已被删除)。 */ + /** Draft does not exist (may have been deleted). */ PR_DRAFT_NOT_FOUND: 'EPR0001', - /** 草稿已被拒绝、跳过。 */ + /** Draft has been rejected, skipped. */ PR_DRAFT_REJECTED: 'EPR0002', - /** PR 已被合并(本地状态滞后,合并时远端已是 merged)。 */ + /** PR has already been merged (local state lags, the remote was already merged at merge time). */ PR_ALREADY_MERGED: 'EPR0003', - /** 提供的链接不是当前平台的 PR / MR 链接(无法解析)。 */ + /** The provided link is not a PR / MR link for the current platform (cannot be parsed). */ PR_URL_INVALID: 'EPR0004', - /** 远端找不到该 PR(不存在,或无权限因而对你不可见)。 */ + /** The PR cannot be found on the remote (does not exist, or is not visible to you due to lack of permission). */ PR_NOT_FOUND: 'EPR0005', - /** 无权限访问该 PR / 仓库(403)。 */ + /** No permission to access this PR / repository (403). */ PR_FORBIDDEN: 'EPR0006', - /** 没有活动连接,无法按链接打开 PR。 */ + /** No active connection, cannot open a PR by link. */ PR_NO_ACTIVE_CONNECTION: 'EPR0007', /** - * 本地 API 服务(service listener)域错误码。**经 HTTP 返回给外部 CLI / 客户端**,不经渲染层 i18n - * (故暂不在 renderer locale 登记;如未来在 GUI 展示再补 `errors.<CODE>`,formatBackendError 已有兜底)。 - * 见 docs/arch/04-integration/01-service-api.md。 + * Local API service (service listener) domain error codes. **Returned to the external CLI / client over HTTP**, not through renderer i18n + * (so not registered in the renderer locale for now; if displayed in the GUI in the future, add `errors.<CODE>`, formatBackendError already has a fallback). + * See docs/arch/04-integration/01-service-api.md. */ - /** 未分类服务错误(兜底)。 */ + /** Unclassified service error (fallback). */ SV_UNCLASSIFIED: 'ESV0000', - /** 鉴权失败:缺失 / 不匹配 bearer token(→ HTTP 401)。 */ + /** Authentication failed: missing / mismatched bearer token (→ HTTP 401). */ SV_UNAUTHORIZED: 'ESV0001', - /** 写操作不经本地 API 开放(→ HTTP 403)。 */ + /** The write operation is not exposed through the local API (→ HTTP 403). */ SV_WRITE_NOT_ALLOWED: 'ESV0002', - /** 路由 / 资源不存在(→ HTTP 404)。 */ + /** Route / resource does not exist (→ HTTP 404). */ SV_NOT_FOUND: 'ESV0003', - /** 请求体校验失败(→ HTTP 400)。 */ + /** Request body validation failed (→ HTTP 400). */ SV_BAD_REQUEST: 'ESV0004', - /** CLI 版本低于服务端可兼容下限(→ HTTP 426;meta.minVersion 最低要求、meta.clientVersion 实际版本)。 */ + /** CLI version below the server's compatible minimum (→ HTTP 426; meta.minVersion minimum requirement, meta.clientVersion actual version). */ SV_CLIENT_TOO_OLD: 'ESV0005', } as const; -/** 已登记的错误码字面量联合(抛错时只能用注册过的码,防笔误)。 */ +/** Union of registered error code literals (only registered codes may be thrown, to prevent typos). */ export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; -/** AppError.meta:仅可序列化标量,供前端 i18n 插值 + 诊断;勿放大对象 / 凭据。 */ +/** AppError.meta: only serializable scalars, for frontend i18n interpolation + diagnostics; do not put large objects / credentials in. */ export type AppErrorMeta = Record<string, string | number | boolean>; const SENTINEL = '@meebox/err'; const CODE_RE = /^E[A-Z]{2}\d{4}$/; -/** 把 {code, meta} 编码进 message:Electron IPC 仅可靠保留 Error.message,自定义属性会丢。 */ +/** Encode {code, meta} into the message: Electron IPC reliably preserves only Error.message, custom properties are lost. */ function encode(code: ErrorCode, meta: AppErrorMeta | undefined, msg: string): string { return `${SENTINEL} ${JSON.stringify({ code, msg, ...(meta ? { meta } : {}) })}`; } /** - * 统一业务错误:面向用户、跨 IPC 的错误一律抛它。`code` 决定语义 / 前端 i18n;`meta` 携带插值 + 诊断。 - * `message` 为编码串(含人读 `msg` 便于日志);前端经 decodeAppError 还原 {code, meta}。 + * Unified business error: user-facing, cross-IPC errors all throw it. `code` decides the semantics / frontend i18n; `meta` carries interpolation + diagnostics. + * `message` is the encoded string (including the human-readable `msg` for logging); the frontend restores {code, meta} via decodeAppError. */ export class AppError extends Error { readonly code: ErrorCode; @@ -95,22 +95,22 @@ export class AppError extends Error { } /** - * 取错误码的 wire 编码串(与 AppError.message 同形)。供「结果信封」式错误使用:返回到前端的结果对象里 - * 把已本地化字符串字段换成此编码串,前端经 decodeAppError / formatBackendError 走同一条解码 + i18n 路径。 + * Get the wire encoding string of an error code (same shape as AppError.message). For "result envelope"-style errors: in the result object returned to the frontend, + * replace the localized string field with this encoded string, and the frontend goes through the same decode + i18n path via decodeAppError / formatBackendError. */ export function errorCodeMessage(code: ErrorCode, meta?: AppErrorMeta): string { return encode(code, meta, code); } export interface DecodedAppError { - /** 错误码(已校验格式;可能是未登记的新码,前端按 i18n 兜底处理)。 */ + /** Error code (format validated; may be an unregistered new code, handled by the frontend's i18n fallback). */ code: string; meta?: AppErrorMeta; } /** - * 从(可能被 Electron 加过 `Error invoking remote method …:` 前缀的)错误 message 中解码 AppError 信封。 - * 非本信封 / 解析失败 → null(调用方走兜底)。 + * Decode the AppError envelope from an error message (which may have an `Error invoking remote method …:` prefix added by Electron). + * Not this envelope / parse failure → null (the caller falls back). */ export function decodeAppError(message: string): DecodedAppError | null { const at = message.indexOf(SENTINEL); @@ -128,12 +128,12 @@ export function decodeAppError(message: string): DecodedAppError | null { }; } } catch { - /* 非本信封 */ + /* not this envelope */ } return null; } -/** 取错误码的领域标签。 */ +/** Get the domain tag of an error code. */ export function errorDomain(code: string): ErrorDomain { return code.slice(1, 3) as ErrorDomain; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 794b0664..5aba15aa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './platform.js'; export * from './poller-contract.js'; export * from './pr-agent-status.js'; export * from './pr-filter.js'; +export * from './product.js'; export * from './sync-progress.js'; export * from './theme.js'; export * from './tool-registry.js'; diff --git a/packages/shared/src/inline-comment-policy.ts b/packages/shared/src/inline-comment-policy.ts index cfcee8e4..271c5fcf 100644 --- a/packages/shared/src/inline-comment-policy.ts +++ b/packages/shared/src/inline-comment-policy.ts @@ -1,25 +1,25 @@ /** - * 不同代码托管平台对 inline comment 允许的"锚定行范围"差异较大: + * Different code platforms vary widely in the "anchor line range" they allow for inline comments: * - * - Bitbucket Server / Data Center: 严格 — `/comments` 接口要求 anchor.line - * 落在 diff hunk 范围内(含 context 行)。锚到 hunk 之外的行 Bitbucket 直接 400。 - * - GitHub / GitLab: 宽松 — diff 视图内任意行都能起评论 - * (GitHub 多文件 review comment 也是按 file:line 锚定但范围更宽松)。 + * - Bitbucket Server / Data Center: strict — the `/comments` endpoint requires anchor.line + * to fall within the diff hunk range (including context lines). Anchoring to a line outside the hunk gets a 400 from Bitbucket. + * - GitHub / GitLab: permissive — a comment can start on any line within the diff view + * (GitHub's multi-file review comment also anchors by file:line but with a looser range). * - * 把"哪一行能新增内联评论"抽象成 platform-specific policy,让 DiffView 渲染行 - * hover '+' glyph 时按当前 PR 的 platform 选 profile 过滤;后续 Bitbucket publishInline - * 提交时也复用同一份规则做前置校验,避免 400。 + * Abstract "which line can take a new inline comment" into a platform-specific policy, so DiffView + * filters by the profile chosen from the current PR's platform when rendering the hover '+' glyph; the same ruleset is later reused + * for pre-validation at Bitbucket publishInline submit time to avoid a 400. * - * Hunk 信息来自 monaco DiffEditor 的 getLineChanges(),不依赖额外 IPC: + * Hunk info comes from monaco DiffEditor's getLineChanges(), without extra IPC: * - ILineChange.{original,modified}{Start,End}LineNumber → DiffHunkRange - * - 转换在 DiffView 里就近做,policy 只接受归一化结构 + * - the conversion is done locally in DiffView; policy only accepts the normalized structure */ import type { PlatformKind } from './platform.js'; /** - * 单个 diff hunk 在 original / modified 两侧的行范围。inclusive。 - * - modified=null:纯删除(modified 侧没有对应行) - * - original=null:纯新增(original 侧没有对应行) + * Line range of a single diff hunk on the original / modified sides. inclusive. + * - modified=null: pure deletion (no corresponding line on the modified side) + * - original=null: pure addition (no corresponding line on the original side) */ export interface DiffHunkRange { modified: { start: number; end: number } | null; @@ -27,12 +27,12 @@ export interface DiffHunkRange { } export interface InlineCommentPolicy { - /** Profile 显示名,hover '+' 被禁用时 tooltip 可以引用 */ + /** Profile display name; the tooltip can reference it when hover '+' is disabled */ label: string; /** - * 判断 (side, line) 是否允许新增 inline comment。hunks 是全文件的变更范围列表。 - * 注意:策略只看 anchor 行能否落点,不管"已有评论 / 草稿占用"那一档;占用判断 - * 仍在 DiffView 的 occupied set 里做 + * Decide whether (side, line) allows a new inline comment. hunks is the list of change ranges for the whole file. + * Note: the policy only checks whether the anchor line is a valid landing spot, not the "existing comment / draft occupied" case; occupancy + * is still decided in DiffView's occupied set */ isLineAllowed( hunks: ReadonlyArray<DiffHunkRange>, @@ -42,8 +42,8 @@ export interface InlineCommentPolicy { } /** - * 工厂:以"hunk 范围 ± context 行"为允许行的 policy。context=0 时严格 hunk 内; - * Bitbucket 实测允许变更上下 10 行(含 context 行)可加评论 + * Factory: a policy whose allowed lines are "hunk range ± context lines". context=0 means strictly inside the hunk; + * Bitbucket in practice allows comments within 10 lines above/below the change (including context lines) */ function makeContextRangePolicy(label: string, context: number): InlineCommentPolicy { return { @@ -61,17 +61,17 @@ function makeContextRangePolicy(label: string, context: number): InlineCommentPo } /** - * Bitbucket profile:允许变更区域**上下 10 行**内的行 (跟 Bitbucket Web UI 行为对齐 — 离 hunk - * 太远的行 /comments 接口直接 400)。新增行 (modified 侧) 锚到 modified range, - * 删除行 (original 侧) 锚到 original range。Bitbucket 的 fileType=FROM/TO 字段后续在 - * publishInline 时根据 side 翻译 + * Bitbucket profile: allows lines within **10 lines above/below** the change area (aligned with Bitbucket Web UI behavior — a line + * too far from the hunk gets a 400 from the /comments endpoint). Added lines (modified side) anchor to the modified range, + * deleted lines (original side) anchor to the original range. Bitbucket's fileType=FROM/TO field is later translated by side at + * publishInline time */ const bitbucketPolicy = makeContextRangePolicy( 'Bitbucket Server: 变更上下 10 行内可加评论', 10, ); -/** 宽松 profile:任意行允许(GitHub / GitLab) */ +/** Permissive profile: any line allowed (GitHub / GitLab) */ const permissivePolicy: InlineCommentPolicy = { label: '任意行可加评论', isLineAllowed: () => true, @@ -83,7 +83,7 @@ export const INLINE_COMMENT_POLICIES: Readonly<Record<PlatformKind, InlineCommen gitlab: permissivePolicy, }; -/** 平台值未知时回退宽松 policy,避免新平台接入时把 + 全屏蔽 */ +/** Fall back to the permissive policy when the platform value is unknown, to avoid fully hiding + when a new platform is integrated */ export function policyForPlatform(platform: PlatformKind): InlineCommentPolicy { return INLINE_COMMENT_POLICIES[platform] ?? permissivePolicy; } diff --git a/packages/shared/src/language.ts b/packages/shared/src/language.ts index f6158dcc..01462d08 100644 --- a/packages/shared/src/language.ts +++ b/packages/shared/src/language.ts @@ -1,24 +1,24 @@ /** - * 语言解析(main / renderer 共用)。 + * Language resolution (shared by main / renderer). * - * config.language 为「期望语言」:非空且受支持则用它;为空(未设置 = 自动)时按操作系统 - * 偏好语言逐个匹配;都不中则回落英语(en-US),而非源语言中文——多语言产品不应默认强制中文。 + * config.language is the "desired language": if non-empty and supported, use it; if empty (unset = auto), match against the OS + * preferred languages one by one; if none match, fall back to English (en-US) rather than the source language Chinese — a multilingual product should not force Chinese by default. */ export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ja-JP', 'de-DE'] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; /** - * UI 语言选择项:每个语言以**自身的本地化名称(endonym)**展示,不随当前 UI 语言翻译 - * (English 永远是 "English"、中文永远是 "中文 (简体)")。 + * UI language options: each language is shown with **its own localized name (endonym)**, not translated by the current UI language + * (English is always "English", Chinese is always "中文 (简体)"). * - * 顺序:English 作为国际化通用语 + 当前默认/兜底语言**优先置顶**;其余按 endonym 字母序 - * (拉丁文名在前,CJK 在后):English → Deutsch → 中文 (简体) → 日本語。设置页下拉框与 - * 首启向导共用此列表,保持一致。 + * Order: English, as the international lingua franca + current default/fallback language, is **pinned to the top**; the rest follow endonym alphabetical order + * (Latin names first, CJK after): English → Deutsch → 中文 (简体) → 日本語. The settings page dropdown and + * the first-launch wizard share this list, staying consistent. */ export interface LanguageOption { code: SupportedLanguage; - /** 该语言的本地化名称(endonym),直接用作下拉项文案,不经 i18n 翻译。 */ + /** The language's localized name (endonym), used directly as the dropdown item text, not translated via i18n. */ endonym: string; } @@ -29,7 +29,7 @@ export const LANGUAGE_OPTIONS: readonly LanguageOption[] = [ { code: 'ja-JP', endonym: '日本語' }, ]; -/** 把任意 locale 串(zh-Hans-CN / en / ja-JP / de-DE…)匹配到受支持语言,无匹配返回 null。 */ +/** Match any locale string (zh-Hans-CN / en / ja-JP / de-DE…) to a supported language; return null when no match. */ export function matchSupportedLanguage(lang: string | null | undefined): SupportedLanguage | null { const n = (lang ?? '').toLowerCase(); if (!n) return null; @@ -41,12 +41,12 @@ export function matchSupportedLanguage(lang: string | null | undefined): Support } /** - * 解析有效 UI 语言:优先 config.language;为空 / 不识别则按 OS 偏好语言列表逐个匹配; - * 仍无合适项则回落英语(en-US)。 + * Resolve the effective UI language: prefer config.language; if empty / unrecognized, match against the OS preferred language list one by one; + * if still no suitable option, fall back to English (en-US). * - * @param configLang config.language(可能为空字符串 = 自动) - * @param osLocales OS 偏好语言列表(renderer 传 navigator.languages,main 传 - * app.getPreferredSystemLanguages()),按优先级排列 + * @param configLang config.language (may be an empty string = auto) + * @param osLocales OS preferred language list (renderer passes navigator.languages, main passes + * app.getPreferredSystemLanguages()), ordered by priority */ export function resolveLanguage( configLang: string | null | undefined, diff --git a/packages/shared/src/platform.ts b/packages/shared/src/platform.ts index 0d334131..2602c337 100644 --- a/packages/shared/src/platform.ts +++ b/packages/shared/src/platform.ts @@ -1,14 +1,14 @@ -// 顺序即各处平台展示准绳:GitHub → Bitbucket → GitLab,新平台追加末尾(见 PlatformIcon.PLATFORM_META)。 +// The order is the platform display standard everywhere: GitHub → Bitbucket → GitLab, new platforms appended at the end (see PlatformIcon.PLATFORM_META). export type PlatformKind = 'github' | 'bitbucket-server' | 'gitlab'; /** - * 各平台「PR 头」的 git 引用 refspec(fetch 进本地镜像,把 PR 源 sha 钉牢)。源分支被删 / 强推后, - * `refs/heads/*` 已看不到 head sha,但平台保留了 PR 专属引用——据此 fetch 才能让 `git diff base...head` - * 不报 "Invalid symmetric difference"。 + * The git ref refspec for each platform's "PR head" (fetch into the local mirror, pinning the PR source sha). After the source branch is deleted / force-pushed, + * `refs/heads/*` no longer shows the head sha, but the platform keeps a PR-specific ref — fetching by it is what lets `git diff base...head` + * not report "Invalid symmetric difference". * - * **必须按 PR 号精确取**:GitHub 的 pull 引用 / GitLab 的 merge-requests 引用默认不在 ref 广播里, - * 通配 fetch 匹配不到(Bitbucket 的 pull-requests 引用会广播、通配可取,二者不同);按确切编号 fetch - * 平台才返回。remoteId 非纯数字(异常)→ 返回 null(不构造可疑 ref)。 + * **Must fetch precisely by PR number**: GitHub's pull refs / GitLab's merge-requests refs are not in the ref advertisement by default, + * so a wildcard fetch can't match them (Bitbucket's pull-requests refs are advertised and reachable by wildcard; the two differ); the platform only returns them when fetched + * by exact number. remoteId not being pure digits (abnormal) → return null (do not construct a suspicious ref). */ export function pullRequestHeadRefspec(platform: PlatformKind, remoteId: string): string | null { const n = remoteId.trim(); @@ -24,14 +24,14 @@ export function pullRequestHeadRefspec(platform: PlatformKind, remoteId: string) } /** - * 从一条 PR / MR 网页链接解析出 `{ group, repo, remoteId }`(用于「按 URL 打开当前平台 PR」)。 - * 仅按 **path 形态** 判定(忽略 host / query / hash / 尾缀如 `/files`、`/commits`),从而兼容自建实例、 - * 企业版与带上下文路径的部署;解析不出对应平台的 PR 形态返回 null(调用方据此报「不是该平台的 PR 链接」)。 + * Parse `{ group, repo, remoteId }` from a PR / MR web link (used for "open the current platform's PR by URL"). + * Judged only by the **path shape** (ignoring host / query / hash / trailing segments like `/files`, `/commits`), for compatibility with self-hosted instances, + * enterprise editions, and deployments with a context path; return null when the corresponding platform's PR shape can't be parsed (the caller reports "not a PR link for this platform" accordingly). * - * 各平台 path 形态: - * - GitHub:`/{owner}/{repo}/pull/{n}` - * - Bitbucket Server:`/projects/{KEY}/repos/{slug}/pull-requests/{n}`,个人仓库 `/users/{user}/repos/{slug}/pull-requests/{n}`(group=`~user`) - * - GitLab:`/{namespace…}/{project}/-/merge_requests/{n}`(namespace 可多级) + * Path shapes per platform: + * - GitHub: `/{owner}/{repo}/pull/{n}` + * - Bitbucket Server: `/projects/{KEY}/repos/{slug}/pull-requests/{n}`, personal repo `/users/{user}/repos/{slug}/pull-requests/{n}` (group=`~user`) + * - GitLab: `/{namespace…}/{project}/-/merge_requests/{n}` (namespace may be multi-level) */ export function parsePullRequestUrl( platform: PlatformKind, @@ -76,23 +76,23 @@ export interface RepoRef { } export interface PlatformUser { - /** 后端 ID(用于 API/匹配) */ + /** Backend ID (for API/matching) */ name: string; - /** 给人看的展示名 */ + /** Human-facing display name */ displayName: string; /** - * URL 友好的 slug,平台特定。Bitbucket 里 user.slug 可能与 user.name 大小写不同, - * 走 avatar 等 URL 路径的接口必须用 slug;缺失时调用方走 name 兜底。 + * URL-friendly slug, platform-specific. In Bitbucket, user.slug may differ in case from user.name; + * endpoints that use URL paths such as avatar must use slug; when missing, the caller falls back to name. */ slug?: string; /** - * 头像直链(平台返回的 avatar_url)。有则优先按此 URL 拉头像——GitHub 机器人 - * (login 形如 `foo[bot]`)没有 `github.com/<login>.png`,必须用 avatar_url 才取得到。 + * Direct avatar link (the avatar_url returned by the platform). If present, prefer fetching the avatar by this URL — GitHub bots + * (login shaped like `foo[bot]`) have no `github.com/<login>.png`, so avatar_url is required to fetch it. */ avatarUrl?: string; } -/** Reviewer 在 PR 上的当前判定。Bitbucket: APPROVED / NEEDS_WORK / UNAPPROVED */ +/** The reviewer's current verdict on the PR. Bitbucket: APPROVED / NEEDS_WORK / UNAPPROVED */ export type ReviewerStatus = 'approved' | 'needsWork' | 'unapproved'; export interface Reviewer extends PlatformUser { @@ -100,38 +100,38 @@ export interface Reviewer extends PlatformUser { } /** - * 一条阻止合并的原因(merge check 否决项)。跨平台中性形状: - * - Bitbucket: `/merge` 端点 vetoes[],summary=summaryMessage,detail=detailedMessage - * - GitHub: required status / required reviews 未满足项 - * - GitLab: detailed_merge_status 的具体阻塞原因 + * A reason that blocks merging (a merge check veto item). Cross-platform neutral shape: + * - Bitbucket: `/merge` endpoint vetoes[], summary=summaryMessage, detail=detailedMessage + * - GitHub: unmet required status / required reviews items + * - GitLab: the specific blocking reason from detailed_merge_status */ export interface MergeVeto { /** - * 稳定否决原因码(中性、不本地化)。GitHub / GitLab 等把派生原因归一到 `@meebox/platform-core` - * 的 `MergeVetoCode`,前端按码 i18n(`mergeVeto.<code>`)。后台不拼面向用户的中文/本地化文案。 - * 服务端直给人读文案(如 Bitbucket)时可不带 code、改用 `summary`。 + * Stable veto reason code (neutral, not localized). GitHub / GitLab etc. normalize derived reasons to `@meebox/platform-core`'s + * `MergeVetoCode`, and the frontend does i18n by code (`mergeVeto.<code>`). The backend does not assemble user-facing Chinese/localized text. + * When the server directly provides human-readable text (such as Bitbucket), code may be omitted in favor of `summary`. */ code?: string; - /** 服务端直给的人读原因(如 Bitbucket summaryMessage);无 `code` 时展示用。 */ + /** Human-readable reason provided directly by the server (such as Bitbucket summaryMessage); used for display when there is no `code`. */ summary?: string; - /** 详细原因,hover / 展开展示,可能缺省(Bitbucket detailedMessage) */ + /** Detailed reason, shown on hover / expand, may be absent (Bitbucket detailedMessage) */ detail?: string; } /** - * 远端对 PR 的"可合并状态"判定。冲突在这里收敛成一种维度,PR.hasConflict - * 只是 `conflicted` 的派生镜像(保留兼容现有冲突角标)。 + * The remote's "mergeable status" verdict on the PR. Conflict is converged here into one dimension; PR.hasConflict + * is just a derived mirror of `conflicted` (kept for compatibility with the existing conflict badge). * - * Bitbucket 一次 `/merge` 请求即可拿全:canMerge / conflicted / vetoes 同源,无额外开销。 + * Bitbucket gets it all in one `/merge` request: canMerge / conflicted / vetoes share the same source, no extra cost. */ export interface MergeStatus { - /** 远端判定当前是否可直接合并(Bitbucket canMerge)。false 时 vetoes 给出逐条原因 */ + /** Whether the remote judges it currently directly mergeable (Bitbucket canMerge). When false, vetoes give the itemized reasons */ canMerge: boolean; - /** 是否存在 merge conflict(Bitbucket conflicted / outcome=CONFLICTED*) */ + /** Whether a merge conflict exists (Bitbucket conflicted / outcome=CONFLICTED*) */ conflicted: boolean; /** - * 阻止合并的逐条原因(Bitbucket vetoes)。canMerge=true 时通常为空。 - * 例:必填 reviewer 未全部 approve、未通过的 build、分支保护规则等。 + * The itemized reasons blocking merge (Bitbucket vetoes). Usually empty when canMerge=true. + * E.g.: required reviewers not all approved, failing builds, branch protection rules, etc. */ vetoes: MergeVeto[]; } @@ -152,22 +152,22 @@ export interface PullRequest { updatedAt: string; reviewers: Reviewer[]; /** - * 远端可合并状态:能否合并 + 逐条阻塞原因(含冲突)。 - * Bitbucket 走 `/merge` 端点,canMerge / conflicted / vetoes 同源一次拉全。 + * Remote mergeable status: whether it can merge + itemized blocking reasons (including conflict). + * Bitbucket uses the `/merge` endpoint; canMerge / conflicted / vetoes share the same source, fetched all at once. */ mergeStatus: MergeStatus; /** - * 远端是否存在 merge conflict。**派生镜像** = `mergeStatus.conflicted`, - * 保留独立字段是为了兼容现有冲突角标 (PrItem) 的直接读取;新代码优先读 - * `mergeStatus`。adapter 写入时两者必须保持一致。 + * Whether a merge conflict exists on the remote. **Derived mirror** = `mergeStatus.conflicted`; + * the standalone field is kept for compatibility with the existing conflict badge (PrItem) reading it directly; new code should prefer reading + * `mergeStatus`. The adapter must keep the two consistent when writing. */ hasConflict: boolean; /** - * 远端评论总数(含 / 不含回复视平台而定,见 {@link PlatformCapabilities.commentCountIncludesReplies})。 - * 随 PR 发现列表免费返回(无额外请求):Bitbucket = `properties.commentCount`(仅顶层)、 - * GitHub = `comments + review_comments`(含行内回复)、GitLab = `user_notes_count`(含回复)。 - * poller 据此(与 `updatedAt` 并用)判定 PR 是否可能有新评论 → 决定是否拉评论扫描未读 / 通知。 - * 平台不提供时省略(poller 退回仅按 `updatedAt` 判定)。 + * Total remote comment count (with / without replies depending on platform, see {@link PlatformCapabilities.commentCountIncludesReplies}). + * Returned for free with the PR discovery list (no extra request): Bitbucket = `properties.commentCount` (top-level only), + * GitHub = `comments + review_comments` (includes inline replies), GitLab = `user_notes_count` (includes replies). + * poller uses this (together with `updatedAt`) to judge whether a PR may have new comments → deciding whether to fetch comments to scan unread / notify. + * Omitted when the platform doesn't provide it (poller falls back to judging by `updatedAt` alone). */ commentCount?: number; } @@ -176,86 +176,86 @@ export interface PingResult { ok: boolean; serverVersion?: string; user?: PlatformUser; - /** 当 ok=false 时给出的人读原因(设置页显示) */ + /** Human-readable reason given when ok=false (shown on the settings page) */ reason?: string; } export interface PrCommentAnchor { - /** 当前路径(renamed 文件给 dst 端) */ + /** Current path (for a renamed file, the dst side) */ path: string; - /** 锚定行号 */ + /** Anchor line number */ line: number; - /** 'old' = 锚到 base / FROM;'new' = 锚到 head / TO */ + /** 'old' = anchor to base / FROM; 'new' = anchor to head / TO */ side: 'old' | 'new'; - /** 锚点对应行的 diff 角色 */ + /** The diff role of the anchored line */ lineType: 'added' | 'removed' | 'context'; } /** - * PR 上的单条提交。跨平台中性形状;Bitbucket / GitHub / GitLab 都映射到这一份。 + * A single commit on the PR. Cross-platform neutral shape; Bitbucket / GitHub / GitLab all map to this one. * - * `parents` 长度可判定是否 merge commit (>1 = merge)。`url` 给 UI 跳转用。 + * `parents` length tells whether it's a merge commit (>1 = merge). `url` is for UI navigation. */ export interface PrCommit { - /** 完整 40-char SHA-1 */ + /** Full 40-char SHA-1 */ sha: string; - /** 短 SHA (Bitbucket displayId / GitHub sha[:7]),UI 默认展示 */ + /** Short SHA (Bitbucket displayId / GitHub sha[:7]), shown by default in UI */ abbreviatedSha: string; - /** 完整 commit message (含正文)。UI 展示首行作为 subject,hover/展开看 body */ + /** Full commit message (including body). UI shows the first line as the subject, hover/expand for the body */ message: string; author: PlatformUser; - /** ISO;author = 写代码的人 */ + /** ISO; author = the person who wrote the code */ authoredAt: string; - /** 通常 = author 但 rebase / amend 等场景会变;可选 */ + /** Usually = author but changes in rebase / amend etc. scenarios; optional */ committer?: PlatformUser; - /** ISO;committer = 实际落库的人 */ + /** ISO; committer = the person who actually committed it */ committedAt: string; - /** 父提交 SHA 列表;长度 >1 表示 merge commit */ + /** Parent commit SHA list; length >1 means a merge commit */ parents: string[]; - /** 平台侧 commit 详情页 URL,可选 */ + /** Platform-side commit detail page URL, optional */ url?: string; } /** - * 评论上一种 emoji 反应的聚合(跨平台中性)。各平台原生反应标识不一(GitHub 固定 8 种 - * content、GitLab award_emoji 名、Bitbucket emoticon shortname),统一归一为 **Unicode emoji - * 字符**作 key —— UI 直接渲染、跨平台一致。原生名 ↔ emoji 的映射由各平台 adapter 私有持有 - * (它最了解自己的 API),shared 只认 emoji 字符 + {@link REACTION_PICKER} 候选集。 + * The aggregation of one emoji reaction on a comment (cross-platform neutral). Native reaction identifiers vary by platform (GitHub's fixed 8 + * content values, GitLab award_emoji names, Bitbucket emoticon shortnames), all normalized to a **Unicode emoji + * character** as the key — UI renders it directly, consistent across platforms. The native name ↔ emoji mapping is held privately by each platform adapter + * (it knows its own API best); shared only recognizes the emoji character + {@link REACTION_PICKER} candidate set. */ export interface PrReaction { - /** 规范化 Unicode emoji 字符(如 `👍`)。 */ + /** Normalized Unicode emoji character (such as `👍`). */ emoji: string; - /** 该 emoji 的反应总数。 */ + /** Total reaction count for this emoji. */ count: number; - /** 当前 PAT 用户是否已用该 emoji 反应(决定 UI 高亮 + 点击切换方向)。 */ + /** Whether the current PAT user has already reacted with this emoji (determines UI highlight + click toggle direction). */ mine: boolean; } /** - * GitHub 固定支持的 8 种反应 emoji(展示顺序即此序)。GitHub Reactions API 仅这 8 种,故 `fixed` - * 模式的选择器用它。各 adapter 负责把字符翻成自家原生名。 + * The 8 reaction emojis GitHub fixedly supports (display order is this order). GitHub Reactions API has only these 8, so the `fixed` + * mode's picker uses it. Each adapter is responsible for translating the character into its own native name. */ export const REACTION_PICKER = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'] as const; -/** 反应选择器一项:emoji 字符 + 平台原生 shortcode(gemoji 风格名,GitLab award 名 / Bitbucket - * emoticon shortname 通用)+ 检索关键词(小写英文,空格分隔)。 */ +/** A reaction picker item: emoji character + platform native shortcode (gemoji-style name, shared by GitLab award name / Bitbucket + * emoticon shortname) + search keywords (lowercase English, space-separated). */ export interface ReactionEmoji { emoji: string; - /** gemoji 风格 shortcode(无冒号);free 模式下 adapter 据此映射 char→原生名。 */ + /** gemoji-style shortcode (no colons); in free mode the adapter maps char→native name by it. */ code: string; - /** 检索关键词(小写英文,空格分隔;含 code 同义词)。 */ + /** Search keywords (lowercase English, space-separated; includes code synonyms). */ keywords: string; } /** - * free 模式(GitLab / Bitbucket)反应的**内置精选大集**:~150 个高频 emoji,code 用 gemoji 风格 - * shortcode(GitLab award 名 / Bitbucket emoticon shortname 同源),写入可靠。刻意为内置精选而非全量 - * Unicode —— 既避免第三方大词表的打包冗余与「比实例 Twemoji 版本新的 emoji 写入失败」问题,又覆盖常用 - * (含 alien 等)。读取归一不依赖本表(Bitbucket 走 twemoji url 码点、GitLab 走 award 名回查)。 - * 前 {@link REACTION_POPULAR} 个为默认展示集;其余靠搜索命中。扩展:在对应分组追加一行即可。 + * The **built-in curated large set** for free-mode (GitLab / Bitbucket) reactions: ~150 high-frequency emojis, code uses gemoji-style + * shortcode (same source as GitLab award name / Bitbucket emoticon shortname), reliable to write. Deliberately a built-in curated set rather than full + * Unicode — this avoids both the bundle bloat of a third-party large lexicon and the "emoji newer than the instance's Twemoji version fails to write" problem, while covering common ones + * (including alien etc.). Read normalization does not depend on this table (Bitbucket uses twemoji url codepoints, GitLab looks up by award name). + * The first {@link REACTION_POPULAR} are the default display set; the rest are reached via search. To extend: append a row to the corresponding group. */ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ - // —— 高频反应(默认展示)—— + // —— High-frequency reactions (shown by default) —— { emoji: '👍', code: 'thumbsup', keywords: 'thumbsup +1 yes like approve good up' }, { emoji: '👎', code: 'thumbsdown', keywords: 'thumbsdown -1 no dislike bad down' }, { emoji: '😄', code: 'smile', keywords: 'smile happy joy' }, @@ -300,7 +300,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '✅', code: 'white_check_mark', keywords: 'check done yes complete approve' }, { emoji: '❌', code: 'x', keywords: 'x no cross wrong fail' }, { emoji: '⚠️', code: 'warning', keywords: 'warning caution alert' }, - // —— 更多表情 —— + // —— More faces —— { emoji: '😀', code: 'grinning', keywords: 'grinning happy smile' }, { emoji: '😅', code: 'sweat_smile', keywords: 'sweat smile relief nervous' }, { emoji: '😊', code: 'blush', keywords: 'blush shy happy' }, @@ -330,7 +330,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '💀', code: 'skull', keywords: 'skull dead' }, { emoji: '💩', code: 'hankey', keywords: 'poop crap shit' }, { emoji: '🎃', code: 'jack_o_lantern', keywords: 'pumpkin halloween' }, - // —— 手势 —— + // —— Gestures —— { emoji: '🤞', code: 'crossed_fingers', keywords: 'crossed fingers luck hope' }, { emoji: '✌️', code: 'v', keywords: 'victory peace v' }, { emoji: '🤟', code: 'love_you_gesture', keywords: 'love you ily' }, @@ -346,7 +346,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '✊', code: 'fist_raised', keywords: 'fist raised power' }, { emoji: '🤲', code: 'palms_up_together', keywords: 'palms up beg pray' }, { emoji: '🤳', code: 'selfie', keywords: 'selfie photo' }, - // —— 动物 —— + // —— Animals —— { emoji: '🐶', code: 'dog', keywords: 'dog puppy' }, { emoji: '🐱', code: 'cat', keywords: 'cat kitten' }, { emoji: '🐭', code: 'mouse', keywords: 'mouse' }, @@ -368,7 +368,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '🐙', code: 'octopus', keywords: 'octopus' }, { emoji: '🐳', code: 'whale', keywords: 'whale' }, { emoji: '🐟', code: 'fish', keywords: 'fish' }, - // —— 食物 —— + // —— Food —— { emoji: '🍎', code: 'apple', keywords: 'apple fruit' }, { emoji: '🍌', code: 'banana', keywords: 'banana fruit' }, { emoji: '🍉', code: 'watermelon', keywords: 'watermelon fruit' }, @@ -389,7 +389,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '🍺', code: 'beer', keywords: 'beer drink' }, { emoji: '🍻', code: 'beers', keywords: 'beers cheers drink' }, { emoji: '🍷', code: 'wine_glass', keywords: 'wine drink' }, - // —— 自然 —— + // —— Nature —— { emoji: '☀️', code: 'sunny', keywords: 'sun sunny weather' }, { emoji: '🌙', code: 'crescent_moon', keywords: 'moon night' }, { emoji: '🌈', code: 'rainbow', keywords: 'rainbow' }, @@ -403,7 +403,7 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '🌻', code: 'sunflower', keywords: 'sunflower flower' }, { emoji: '🌸', code: 'cherry_blossom', keywords: 'blossom flower sakura' }, { emoji: '🍀', code: 'four_leaf_clover', keywords: 'clover luck' }, - // —— 活动 / 物品 —— + // —— Activities / objects —— { emoji: '⚽', code: 'soccer', keywords: 'soccer football ball' }, { emoji: '🏀', code: 'basketball', keywords: 'basketball ball' }, { emoji: '🎯', code: 'dart', keywords: 'dart target bullseye' }, @@ -448,34 +448,34 @@ export const REACTION_EMOJIS: readonly ReactionEmoji[] = [ { emoji: '🤷', code: 'shrug', keywords: 'shrug whatever idk' }, ]; -/** 默认展示集大小(REACTION_EMOJIS 前 N 个,按高频排序在前)。 */ +/** Default display set size (the first N of REACTION_EMOJIS, high-frequency ones ordered first). */ const REACTION_POPULAR_COUNT = 44; const _codeByEmoji = new Map(REACTION_EMOJIS.map((e) => [e.emoji, e.code])); const _emojiByCode = new Map(REACTION_EMOJIS.map((e) => [e.code, e.emoji])); -// 读取兼容别名:GitLab/Bitbucket 偶用 +1 / -1 作 award/emoticon 名。 +// Read-compatibility aliases: GitLab/Bitbucket occasionally use +1 / -1 as award/emoticon names. _emojiByCode.set('+1', '👍'); _emojiByCode.set('-1', '👎'); -/** emoji 字符 → 平台写入用 shortcode(GitLab award 名 / Bitbucket emoticon),未知返回 undefined。 */ +/** emoji character → platform write shortcode (GitLab award name / Bitbucket emoticon); returns undefined when unknown. */ export function emojiToReactionCode(emoji: string): string | undefined { return _codeByEmoji.get(emoji); } -/** shortcode(含 +1/-1 别名)→ emoji 字符,未知返回 undefined(读取归一回退用)。 */ +/** shortcode (including +1/-1 aliases) → emoji character; returns undefined when unknown (used for read normalization fallback). */ export function reactionCodeToEmoji(code: string): string | undefined { return _emojiByCode.get(code); } -/** free 模式选择器默认展示的高频反应(无搜索词时)。 */ +/** The high-frequency reactions shown by default in the free-mode picker (when there's no search term). */ export const REACTION_POPULAR: readonly ReactionEmoji[] = REACTION_EMOJIS.slice( 0, REACTION_POPULAR_COUNT, ); /** - * free 模式选择器的搜索:空查询回 {@link REACTION_POPULAR};否则在内置精选集里按关键词子串匹配, - * 截断到 limit(默认 60)。 + * Free-mode picker search: an empty query returns {@link REACTION_POPULAR}; otherwise match by keyword substring within the built-in curated set, + * truncated to limit (default 60). */ export function searchReactionEmojis(query: string, limit = 60): ReactionEmoji[] { const q = query.trim().toLowerCase(); @@ -490,14 +490,14 @@ export function searchReactionEmojis(query: string, limit = 60): ReactionEmoji[] return out; } -/** 评论图片附件上传的输入(main 端从 IPC 的 ArrayBuffer 转 Uint8Array 后传给 adapter)。 */ +/** Input for a comment image attachment upload (main converts the IPC ArrayBuffer to Uint8Array before passing to the adapter). */ export interface CommentAttachmentUpload { fileName: string; contentType: string; bytes: Uint8Array; } -/** 评论附件上传结果:可直接插入评论正文的 markdown 片段(如 `![name](url)` / `attachment:` 形式)。 */ +/** Comment attachment upload result: a markdown snippet that can be inserted directly into the comment body (such as `![name](url)` / `attachment:` form). */ export interface CommentAttachmentResult { markdown: string; } @@ -510,173 +510,173 @@ export interface PrComment { createdAt: string; /** ISO */ updatedAt: string; - /** null = PR 顶层 summary 评论;set = inline 评论锚到具体文件行 */ + /** null = PR top-level summary comment; set = inline comment anchored to a specific file line */ anchor: PrCommentAnchor | null; - /** 嵌套 replies (Bitbucket 走 comment.comments[]) */ + /** Nested replies (Bitbucket uses comment.comments[]) */ replies: PrComment[]; /** - * 远端版本号 (乐观锁)。Bitbucket 走 0/1/2... 单调递增;DELETE / PUT 时必须在 query - * 里带当前 version,否则 409 conflict。GitHub / GitLab 无此语义,置 `0` 作「无需并发令牌」 - * 哨兵——让 canEdit/canDelete 判定与编辑/删除 IPC 的 `version: number` 契约统一通过, - * 其编辑/删除 API 忽略该值。 + * Remote version number (optimistic lock). Bitbucket uses 0/1/2... monotonically increasing; DELETE / PUT must carry the current version in the query, + * otherwise 409 conflict. GitHub / GitLab have no such semantics; set to `0` as a "no concurrency token needed" + * sentinel — so canEdit/canDelete decisions and the edit/delete IPC's `version: number` contract pass uniformly, + * while their edit/delete APIs ignore the value. */ version?: number; /** - * main 端预判的"是否可由当前 PAT 用户删除"。综合: - * - author.name === currentUser.name (PAT 缓存) - * - replies.length === 0 (Bitbucket 拒删有 reply 的) - * - version 字段存在 (DELETE 必备乐观锁) + * The main-side prejudgment of "whether the current PAT user can delete it". Combines: + * - author.name === currentUser.name (PAT cache) + * - replies.length === 0 (Bitbucket refuses to delete ones with a reply) + * - version field present (optimistic lock required for DELETE) * - * renderer 端不再自己比对作者名 / 检查 reply / 检查 version — 直接读这个 flag。 - * 跨 PR / 跨 connection 时,main 端用 PR 所属 adapter 的 cachedUser 判断, - * renderer 不需要透传 currentUserName + * The renderer no longer compares author names / checks replies / checks version itself — it reads this flag directly. + * Across PRs / connections, main decides using the cachedUser of the PR's owning adapter, + * so the renderer doesn't need to pass through currentUserName */ canDelete?: boolean; /** - * main 端预判的"是否可编辑"。Bitbucket 跟 canDelete 区别在不要求 reply.length===0 - * (带 reply 的评论也允许改 body);其它同源:作者匹配 + 有 version + * The main-side prejudgment of "whether it can be edited". Bitbucket differs from canDelete in not requiring reply.length===0 + * (comments with a reply may still change their body); otherwise same source: author match + has version */ canEdit?: boolean; /** - * 评论种类(多平台抽象):'summary' = PR 级讨论;'inline' = 锚到文件行。 - * 现状 anchor 是否为 null 已能区分;本字段是 GitHub(issue/review 评论分两套 API)/ - * GitLab(note/discussion)归一时的显式标注,便于 UI 与回写。可选,旧数据不填。 + * Comment kind (multi-platform abstraction): 'summary' = PR-level discussion; 'inline' = anchored to a file line. + * Currently whether anchor is null already distinguishes them; this field is the explicit label during normalization for GitHub (issue/review comments split across two APIs) / + * GitLab (note/discussion), convenient for UI and write-back. Optional, not filled for old data. */ kind?: 'summary' | 'inline'; /** - * 线程标识(回复目标的抽象)。Bitbucket=父评论 id、GitHub=review-comment id、 - * GitLab=discussion id。reply 时透传给 adapter;Bitbucket 现走 remoteId 即可。 + * Thread identifier (abstraction of the reply target). Bitbucket=parent comment id, GitHub=review-comment id, + * GitLab=discussion id. Passed through to the adapter on reply; Bitbucket currently just uses remoteId. */ threadId?: string; - /** 平台原生 id(回写 / 幂等用,与 remoteId 同源但语义独立保留扩展空间)。 */ + /** Platform native id (for write-back / idempotency; same source as remoteId but kept semantically independent for extension room). */ nativeId?: string; /** - * 评论上的 emoji 反应聚合(见 {@link PrReaction})。平台不支持 / 该评论无反应时省略或为空数组。 - * 仅平台 `commentReactions` 能力为真时填充;renderer 据此在评论气泡下渲染反应条。 + * The emoji reaction aggregation on the comment (see {@link PrReaction}). Omitted or an empty array when the platform doesn't support it / the comment has no reactions. + * Filled only when the platform's `commentReactions` capability is truthy; the renderer renders the reaction bar under the comment bubble accordingly. */ reactions?: PrReaction[]; } /** - * PR 评审决断事件的判定类型。`dismissed` = 决断被撤销/作废(GitHub DISMISSED), - * 与主动 `unapproved`(撤回赞成)语义相近但来源不同,保留区分供 UI 文案。 + * The verdict type of a PR review decision event. `dismissed` = the decision was revoked/invalidated (GitHub DISMISSED), + * semantically close to an active `unapproved` (withdrawing approval) but from a different source; the distinction is kept for UI text. */ export type PrActivityKind = 'approved' | 'needsWork' | 'unapproved' | 'dismissed'; /** - * PR 活动时间线上的「评审决断」事件(带时间戳)。跨平台中性形状,由各 adapter 从原生活动流 - * 映射:GitHub `/pulls/{n}/reviews`(state + submitted_at);Bitbucket `/activities` - * (action=APPROVED/REVIEWED/UNAPPROVED + createdDate);GitLab 系统 note(approved/ - * unapproved,CE 无审批则取不到)。 + * A "review decision" event on the PR activity timeline (with timestamp). Cross-platform neutral shape, mapped by each adapter from the native activity stream: + * GitHub `/pulls/{n}/reviews` (state + submitted_at); Bitbucket `/activities` + * (action=APPROVED/REVIEWED/UNAPPROVED + createdDate); GitLab system note (approved/ + * unapproved; unavailable when CE has no approval). * - * 仅承载评论 / 提交之外的「决断类」事件——评论走 {@link PrComment}、提交走 {@link PrCommit}, - * 渲染层把三路按时间归并成一条时间线。平台拿不到历史事件时该方法返回空数组。 + * Carries only "decision-type" events beyond comments / commits — comments go through {@link PrComment}, commits through {@link PrCommit}, + * and the renderer merges the three streams by time into one timeline. Returns an empty array when the platform can't get historical events. */ export interface PrActivityEvent { - /** 平台侧事件 id(去重 / React key 用) */ + /** Platform-side event id (for dedup / React key) */ remoteId: string; kind: PrActivityKind; - /** 触发该决断的用户 */ + /** The user who triggered this decision */ actor: PlatformUser; /** ISO */ createdAt: string; - /** 决断附带正文(GitHub review body 可能带说明);无则省略 */ + /** Body accompanying the decision (GitHub review body may carry an explanation); omitted when absent */ body?: string; } /** - * PR 的 diff 基准 sha(行内评论发布锚点用)。GitHub 用 `headSha` 作 commit_id; - * GitLab 用三者拼 position;Bitbucket 不需要(忽略)。adapter 可按 prId 内部拉取, - * 也可由调用方(已持 PR meta + 本地镜像 sha)传入,避免每次发布多打一次 API。 + * The PR's diff base shas (used as the inline comment publish anchor). GitHub uses `headSha` as commit_id; + * GitLab assembles position from all three; Bitbucket doesn't need it (ignored). The adapter can fetch it internally by prId, + * or the caller (already holding PR meta + local mirror sha) can pass it in, avoiding an extra API call per publish. */ export interface PrDiffRefs { - /** head(源分支最新)sha */ + /** head (latest of the source branch) sha */ headSha: string; - /** base(目标分支 / merge-base)sha */ + /** base (target branch / merge-base) sha */ baseSha: string; - /** GitLab position 需要的 start sha;其它平台可空 */ + /** The start sha needed by GitLab position; may be empty on other platforms */ startSha?: string; } /** - * 平台能力描述符(多平台适配,见 docs/arch/01-platform/01-adapter.md §2 / §3)。 - * 把无法在所有平台等价实现的能力显式声明,UI 据此 显/隐/灰(降级规则见 §2),业务层据此调策略, - * 避免在调用处 try/catch 猜或写 `if (platform === ...)`。 + * Platform capability descriptor (multi-platform adaptation, see docs/arch/01-platform/01-adapter.md §2 / §3). + * Explicitly declares capabilities that can't be equivalently implemented across all platforms; the UI shows/hides/greys-out accordingly (degradation rules see §2), and the business layer tunes strategy accordingly, + * avoiding try/catch guessing or `if (platform === ...)` at call sites. */ export interface PlatformCapabilities { - /** 支持的 review 决断(GitLab CE 可能为 [] 或 ['approved','unapproved']) */ + /** Supported review verdicts (GitLab CE may be [] or ['approved','unapproved']) */ reviewStatuses: ReadonlyArray<ReviewerStatus>; - /** 是否支持行内评论 */ + /** Whether inline comments are supported */ inlineComments: boolean; - /** 是否支持多行行内评论 */ + /** Whether multi-line inline comments are supported */ inlineMultiline: boolean; - /** 评论删改是否需要 version 乐观锁(仅 Bitbucket) */ + /** Whether comment edit/delete requires a version optimistic lock (Bitbucket only) */ commentOptimisticLock: boolean; /** - * 评论是否支持上传图片附件(粘贴 / 选取)。GitLab(/uploads)、Bitbucket(attachments)为真; - * GitHub 无公开附件上传 API → 为假(UI 隐藏粘贴上传入口、提示不支持)。为真时渲染层拦截图片粘贴 - * → 经 adapter 上传 → 回填 markdown 到正文。 + * Whether comments support uploading image attachments (paste / pick). GitLab (/uploads), Bitbucket (attachments) are true; + * GitHub has no public attachment upload API → false (UI hides the paste-upload entry, notes it's unsupported). When true, the renderer intercepts image paste + * → uploads via the adapter → backfills markdown into the body. */ commentAttachments: boolean; /** - * 评论 emoji 反应支持模式: - * - `false`:不支持,UI 整块隐藏。 - * - `'fixed'`:仅固定集(GitHub Reactions 的 8 种)→ 选择器用 {@link REACTION_PICKER},无搜索。 - * - `'free'`:支持任意 emoji(GitLab Award Emoji / Bitbucket emoticon)→ 选择器用内置精选集 - * ({@link searchReactionEmojis} 搜索 + {@link REACTION_POPULAR} 默认)。 - * 各 adapter 据自家 API 把 emoji 字符翻成原生名(fixed 内置 8 映射;free 用 {@link emojiToReactionCode})。 + * Comment emoji reaction support mode: + * - `false`: unsupported, the UI hides the whole block. + * - `'fixed'`: fixed set only (GitHub Reactions' 8) → the picker uses {@link REACTION_PICKER}, no search. + * - `'free'`: any emoji supported (GitLab Award Emoji / Bitbucket emoticon) → the picker uses the built-in curated set + * ({@link searchReactionEmojis} search + {@link REACTION_POPULAR} default). + * Each adapter translates the emoji character into a native name per its own API (fixed uses a built-in 8-mapping; free uses {@link emojiToReactionCode}). */ commentReactions: false | 'fixed' | 'free'; /** - * 评论正文单换行是否按 hard-break 渲染(单 `\n` → `<br>`)。GitHub / Bitbucket 评论上下文 - * 是(`true`);GitLab 走标准 CommonMark(单 `\n` 作软换行 = 空格,`false`)。renderer 据此 - * 决定是否启用 remark-breaks,使本地渲染与各平台 web 一致。 + * Whether a single newline in a comment body renders as a hard-break (single `\n` → `<br>`). GitHub / Bitbucket comment contexts + * do (`true`); GitLab uses standard CommonMark (single `\n` as a soft break = space, `false`). The renderer + * decides whether to enable remark-breaks accordingly, so local rendering matches each platform's web. */ commentHardBreaks: boolean; - /** 合并否决项保真度:'full' 逐条可得(Bitbucket/GitLab);'partial' 只能近似(GitHub) */ + /** Merge veto item fidelity: 'full' itemized available (Bitbucket/GitLab); 'partial' only approximate (GitHub) */ mergeVetoFidelity: 'full' | 'partial'; - /** 发现端点是否强限流(GitHub search 30/分)→ 该平台轮询间隔单独拉长 */ + /** Whether the discovery endpoint is heavily rate-limited (GitHub search 30/min) → this platform's polling interval is lengthened separately */ discoveryRateLimited: boolean; /** - * 平台提供的 PR 发现分类(GitHub 仪表盘四类)。poller 一轮把这些分类都抓回来、给 PR 打标, - * renderer 据此本地过滤标签页。为空 / 省略 = 平台只有单一「待我评审」发现,无分类标签。 + * PR discovery categories the platform provides (GitHub dashboard's four). In one round the poller fetches all these categories and tags PRs, + * and the renderer filters tabs locally accordingly. Empty / omitted = the platform has only a single "review-requested" discovery, no category tabs. */ discoveryFilters?: ReadonlyArray<PrDiscoveryFilter>; - /** 评论线程是否可「解决 / Resolve」+ 折叠(GitHub/GitLab 有,Bitbucket 无) */ + /** Whether comment threads can be "resolved / Resolve" + collapsed (GitHub/GitLab have it, Bitbucket doesn't) */ resolvableThreads: boolean; - /** 是否支持行内代码 suggestion「一键应用」(GitHub/GitLab 有,Bitbucket 无) */ + /** Whether inline code suggestion "one-click apply" is supported (GitHub/GitLab have it, Bitbucket doesn't) */ suggestions: boolean; - /** 决断 + 行内评论是否可成组提交(pending review);映射到本地草稿池→批量发布 */ + /** Whether decisions + inline comments can be submitted as a group (pending review); maps to the local draft pool → batch publish */ reviewGrouping: boolean; /** - * 是否提供「带时间戳的评审决断活动事件流」({@link PrActivityEvent})以支撑活动时间线。 - * GitHub(/reviews)/ Bitbucket(/activities)为 `true`:该 PR 标签页渲染评论 + 提交 + 决断 - * 归并的「活动」时间线。GitLab 为 `false`:无统一活动事件源(CE 无审批、系统 note 解析脆弱), - * 标签页退化为纯「评论」视图(沿用原行为与文案),不混入提交 / 决断。 + * Whether a "timestamped review-decision activity event stream" ({@link PrActivityEvent}) is provided to support the activity timeline. + * GitHub (/reviews) / Bitbucket (/activities) are `true`: that PR tab renders the "activity" timeline merging comments + commits + decisions. + * GitLab is `false`: no unified activity event source (CE has no approval, system note parsing is fragile), + * so the tab degrades to a pure "comments" view (retaining the original behavior and text), not mixing in commits / decisions. */ activityTimeline: boolean; /** - * {@link PullRequest.commentCount} 是否「含回复」——即新增一条回复是否会让该计数变化。决定 poller 评论 - * 跟踪策略: - * - `true`(GitHub `comments + review_comments`、GitLab `user_notes_count`):计数是可靠的「含回复」增量 - * 信号;poller 仅在 `commentCount` 或 `updatedAt` 变化时才拉评论扫描——省请求。 - * - `false`(Bitbucket `properties.commentCount` 仅数顶层评论,回复不计、且 `updatedDate` 也不随评论跳变): - * 无任何免费的「含回复」信号;poller 对**待处理 PR** 每轮兜底拉一次评论扫描,否则会漏掉「回复」类通知。 + * Whether {@link PullRequest.commentCount} "includes replies" — i.e. whether adding a reply changes the count. Determines the poller's comment + * tracking strategy: + * - `true` (GitHub `comments + review_comments`, GitLab `user_notes_count`): the count is a reliable "includes replies" delta + * signal; the poller fetches comments to scan only when `commentCount` or `updatedAt` changes — saving requests. + * - `false` (Bitbucket `properties.commentCount` counts only top-level comments, replies don't count, and `updatedDate` also doesn't jump on comments): + * no free "includes replies" signal at all; the poller fetches a comment scan as a fallback every round for **pending PRs**, otherwise it would miss "reply"-type notifications. */ commentCountIncludesReplies: boolean; } /** - * PR 发现筛选分类(运行时筛选,不持久化)。目前仅 GitHub 适配器据此切换 search 限定词, - * 对齐 GitHub 仪表盘的四类;其他平台忽略此参数、维持各自的「待我评审」语义。 - * - `review-requested`(默认):请求当前用户评审的 PR。 - * - `created`:当前用户创建的 PR。 - * - `assigned`:指派给当前用户的 PR。 - * - `mentioned`:提及当前用户的 PR。 + * PR discovery filter category (runtime filter, not persisted). Currently only the GitHub adapter uses it to switch search qualifiers, + * aligned with GitHub dashboard's four; other platforms ignore this parameter and keep their own "review-requested" semantics. + * - `review-requested` (default): PRs requesting the current user's review. + * - `created`: PRs created by the current user. + * - `assigned`: PRs assigned to the current user. + * - `mentioned`: PRs mentioning the current user. */ export type PrDiscoveryFilter = 'review-requested' | 'created' | 'assigned' | 'mentioned'; -/** 发现 PR 时的可选项;filter 缺省按 review-requested。 */ +/** Options when discovering PRs; filter defaults to review-requested. */ export interface ListPendingOptions { filter?: PrDiscoveryFilter; } diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index 3b66d8ea..ee7f91fb 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -10,29 +10,29 @@ import type { PrAgentStrategy } from './pr-agent-status.js'; import type { ReviewRunTool } from './tool-registry.js'; /** - * 本地 review 判定。和 Bitbucket reviewer.status 一一对应,UI 由它驱动两个 toggle 按钮: - * - pending: 默认(UNAPPROVED),尚未给出 review 判定 - * - approved: 已 approve - * - needs_work: 已标记 NEEDS_WORK + * Local review verdict. One-to-one with Bitbucket reviewer.status; the UI drives two toggle buttons from it: + * - pending: default (UNAPPROVED), no review verdict given yet + * - approved: approved + * - needs_work: marked NEEDS_WORK * - * 用户在 UI 上点击会同步到远端 Bitbucket(参与者 status),下一轮 poll 再次取回保持一致。 + * Clicking in the UI syncs to remote Bitbucket (participant status), and the next poll round fetches it back to stay consistent. */ export type LocalPrStatus = 'pending' | 'approved' | 'needs_work'; -// 工具枚举 ReviewRunTool 见统一注册表 tool-registry(新增工具改那里)。注:improve 的 pr-agent local -// provider 不实现 `publish_code_suggestions`,输出走 review.md(与 review / ask 共用);parseReviewOutput -// 对 tool='improve' 走专门解析路径,把每条 <details> 建议拆成带 anchor 的 code-feedback finding。 +// The tool enum ReviewRunTool is in the unified registry tool-registry (add new tools there). Note: improve's pr-agent local +// provider doesn't implement `publish_code_suggestions`, so its output goes through review.md (shared with review / ask); parseReviewOutput +// takes a dedicated parse path for tool='improve', splitting each <details> suggestion into an anchored code-feedback finding. export type ReviewRunStatus = 'running' | 'succeeded' | 'failed' | 'cancelled'; /** - * pr-agent 单次调用失败时的归类。 + * The failure classification for a single pr-agent invocation. * - * 'llm-error' 跟其他 reason 不同 —— pr-agent CLI 本身可能 exit 0 (它内部 catch - * 了 LLM 错误只 logger.warning 一下),但 stdout 里能看到 "Failed to generate - * prediction with any model" / "Error during LLM inference" 之类 marker。 - * parseReviewOutput 检测到这种 marker 时把 status 升格为 'failed' + - * reason='llm-error',避免 UI 把 LLM 调用全失败的 run 当"成功完成"展示 + * 'llm-error' differs from other reasons — the pr-agent CLI itself may exit 0 (it internally catches + * LLM errors and only logger.warning's about them), but stdout shows a marker like "Failed to generate + * prediction with any model" / "Error during LLM inference". + * When parseReviewOutput detects such a marker it upgrades status to 'failed' + + * reason='llm-error', so the UI doesn't present a run where all LLM calls failed as "successfully completed" */ export type ReviewRunFailureReason = | 'timeout' @@ -43,36 +43,36 @@ export type ReviewRunFailureReason = | 'llm-error'; /** - * 解析 pr-agent stdout 后得到的单条 finding。category 反映来源: - * - description: /describe 输出的描述段 - * - code-feedback: 锚到具体文件 / 行的代码建议(有 anchor) - * - general: 其它 markdown 段(如 estimated effort / score / relevant tests) + * A single finding obtained after parsing pr-agent stdout. category reflects the source: + * - description: the description section from /describe output + * - code-feedback: a code suggestion anchored to a specific file / line (has anchor) + * - general: other markdown sections (such as estimated effort / score / relevant tests) */ export type FindingCategory = 'description' | 'general' | 'code-feedback'; /** - * 标准化的 pr-agent 输出段落键名。把不同版本 pr-agent 的 section title (可能带 - * **bold** / 大小写不同 / 中英变体) 归一到稳定标识,UI 按 key 决定排序 / 着色 / - * 是否隐藏 / 后续做特化卡片。 + * Standardized pr-agent output section key. Normalizes section titles across pr-agent versions (which may carry + * **bold** / differ in case / have Chinese-English variants) to a stable identifier; the UI decides sorting / coloring / + * whether to hide / later specialized cards by key. */ export type PrDocSectionKey = - | 'title' // 建议的 PR 标题 - | 'pr-type' // 类型标签 (Bug fix / Enhancement / Tests / ...) - | 'summary' // /review 顶部总结 - | 'description' // 主描述段 - | 'diagram' // 架构图(changes_diagram,mermaid) - | 'assessment' // 思路建议(注入字段:替代方案 + 倾向性建议,对齐 Qodo High-Level Assessment) - | 'walkthrough' // 文件级走查 - | 'relevant-tests' // 相关测试 - | 'security' // 安全发现 - | 'code-feedback' // /review 单条 finding (带 file:line anchor) - | 'code-suggestion' // /improve 单条改进建议 (带 file:line anchor + existing/improved diff) - | 'ask-summary' // /ask 结构化分段:结论 / 直接回答(高亮、展开) - | 'ask-analysis' // /ask 结构化分段:过程性分析 / 讨论(默认收起) - | 'ask-suggestions' // /ask 结构化分段:可执行建议(高亮) - | 'effort' // 评估工作量 1-5 - | 'score' // 质量分 - | 'general'; // 兜底,未识别 + | 'title' // Suggested PR title + | 'pr-type' // Type label (Bug fix / Enhancement / Tests / ...) + | 'summary' // /review top summary + | 'description' // Main description section + | 'diagram' // Architecture diagram (changes_diagram, mermaid) + | 'assessment' // Approach suggestions (injected field: alternatives + preference recommendation, aligned with Qodo High-Level Assessment) + | 'walkthrough' // File-level walkthrough + | 'relevant-tests' // Relevant tests + | 'security' // Security findings + | 'code-feedback' // /review single finding (with file:line anchor) + | 'code-suggestion' // /improve single improvement suggestion (with file:line anchor + existing/improved diff) + | 'ask-summary' // /ask structured section: conclusion / direct answer (highlighted, expanded) + | 'ask-analysis' // /ask structured section: procedural analysis / discussion (collapsed by default) + | 'ask-suggestions' // /ask structured section: actionable suggestions (highlighted) + | 'effort' // Estimated effort 1-5 + | 'score' // Quality score + | 'general'; // Fallback, unrecognized export interface FindingAnchor { path: string; @@ -80,23 +80,23 @@ export interface FindingAnchor { endLine?: number; } -/** Finding 严重度:M4 评审发布闭环用,UI 决定 chip 着色 / 排序优先级 */ +/** Finding severity: used by the M4 review publish loop; the UI decides chip coloring / sort priority */ export type FindingSeverity = 'info' | 'warning' | 'error'; /** - * Finding 在评审 → 发布闭环中的状态机: - * pending : 默认值,待用户决断 - * accepted : 用户勾选采纳 (将作为 inline / summary 评论发布) - * edited : 用户改写了内容 (draft_body 含编辑后版本) - * rejected : 用户拒绝;不发布 - * posted : 已发布到远端 (posted_remote_id 含远端评论 id 用作幂等) + * The Finding's state machine in the review → publish loop: + * pending : default, awaiting the user's decision + * accepted : user checked to accept (will be published as an inline / summary comment) + * edited : user rewrote the content (draft_body holds the edited version) + * rejected : user rejected; not published + * posted : published to the remote (posted_remote_id holds the remote comment id, used for idempotency) */ export type FindingStatus = 'pending' | 'accepted' | 'edited' | 'rejected' | 'posted'; /** - * /improve 单条建议的"前后代码"对比。pr-agent 在 markdown 里用 `diff` 代码块同时 - * 给出 existing + improved 两段内容;解析后我们拆成两份字符串,UI 用单语言 syntax - * highlight 渲染 (anchor.path 给文件类型)。两边都是片段,不一定能独立运行/编译。 + * The "before/after code" comparison of a single /improve suggestion. pr-agent gives both existing + improved + * content in a `diff` code block in the markdown; after parsing we split into two strings, and the UI renders + * with single-language syntax highlight (anchor.path gives the file type). Both sides are fragments, not necessarily independently runnable/compilable. */ export interface FindingCodeChange { existing: string; @@ -104,104 +104,104 @@ export interface FindingCodeChange { } export interface Finding { - /** 同一 run 内稳定的 id,便于 UI list-key + 后续 "改为评论草稿" 引用 */ + /** Id stable within the same run, convenient for UI list-key + later "turn into comment draft" references */ id: string; category: FindingCategory; /** - * 段落归一键。新解析的 finding 都会带;旧持久化的 run 没有此字段 (回退到 category)。 - * UI 按 sectionKey 决定排序 + 视觉分层 + * Section normalization key. Every newly parsed finding carries it; old persisted runs lack this field (fall back to category). + * The UI decides sorting + visual layering by sectionKey */ sectionKey?: PrDocSectionKey; - /** 来自 markdown header (已剥除 **__ 强调符号);可能为空 */ + /** From the markdown header (with **__ emphasis symbols stripped); may be empty */ title?: string; - /** 原始 markdown body(含格式),UI 用 react-markdown 渲染 */ + /** Raw markdown body (with formatting), rendered by the UI with react-markdown */ body: string; - /** category='code-feedback' / 'code-suggestion' 时有值 */ + /** Present when category='code-feedback' / 'code-suggestion' */ anchor?: FindingAnchor; /** - * /improve 建议带的"原代码 → 改进代码"对比。仅 sectionKey='code-suggestion' 时填。 - * UI 用单语言 syntax highlight 渲染前后两个片段 + * The "original code → improved code" comparison carried by an /improve suggestion. Filled only when sectionKey='code-suggestion'. + * The UI renders the before/after fragments with single-language syntax highlight */ codeChange?: FindingCodeChange; /** - * /improve 给的重要度评分 1-10。仅 sectionKey='code-suggestion' 时填。 - * 配合 severity (M4 评审决断) 做排序 / 着色:分数 ≥ 8 默认 'warning',< 5 默认 'info' + * The importance score 1-10 given by /improve. Filled only when sectionKey='code-suggestion'. + * Combined with severity (M4 review decision) for sorting / coloring: score ≥ 8 defaults to 'warning', < 5 defaults to 'info' */ score?: number; /** - * 严重度 (M4);当前 parser 不填,M4 接 /improve 时按 pr-agent 输出 / rules 补 - * 推断逻辑。UI 默认按 'info' 渲染 + * Severity (M4); the current parser doesn't fill it; M4 will add the inference logic per pr-agent output / rules when wiring up /improve. + * The UI renders as 'info' by default */ severity?: FindingSeverity; /** - * 发布闭环状态 (M4);缺省视为 'pending'。所有 finding 默认是 pending,用户在 - * Findings Drawer 上勾选后转 accepted / edited / rejected;发布成功转 posted + * Publish loop status (M4); defaults to 'pending'. All findings default to pending; after the user checks them in + * the Findings Drawer they become accepted / edited / rejected; a successful publish turns them to posted */ status?: FindingStatus; /** - * 用户编辑后的评论正文。仅 status='edited' 时填;其他状态 UI 直接读 body + * The comment body after user editing. Filled only when status='edited'; for other states the UI reads body directly */ draft_body?: string; /** - * 发布成功后远端评论 id (e.g., Bitbucket comment id)。用作幂等 key,防止同一 finding - * 被重复发布;跟 state/posted-comments.json 互为冗余但前者按 finding 维度, - * 后者按 (finding_id, remote_id) 维度全局索引,用途互补 + * The remote comment id after a successful publish (e.g., Bitbucket comment id). Used as an idempotency key to prevent the same finding + * from being published twice; redundant with state/posted-comments.json, but the former is by finding dimension and + * the latter is a global index by (finding_id, remote_id) dimension — complementary uses */ posted_remote_id?: string; } /** - * M4 评审 → 发布闭环的"草稿"。 + * The "draft" of the M4 review → publish loop. * - * 草稿的生命周期跟 Finding 解耦: - * - Finding 是 /review 的不可变快照 (跑过什么 AI 说了什么) - * - Draft 是用户工作中的可变态 (用户编辑 / 拒绝 / 发布的对象) + * A draft's lifecycle is decoupled from Finding: + * - Finding is the immutable snapshot of /review (what ran, what the AI said) + * - Draft is the mutable state in the user's work (the object the user edits / rejects / publishes) * - * 落盘到 `state/prs/<localId>/drafts.json`,per-PR 目录;PR 退场 - * 时 deleteDir 整树清掉。 + * Persisted to `state/prs/<localId>/drafts.json`, a per-PR directory; when the PR leaves, + * deleteDir clears the whole tree. * - * 状态机: - * pending ──(用户编辑 body)──► edited - * pending ──(用户拒绝)──────► rejected - * edited ──(用户拒绝)──────► rejected - * pending / edited ──(批量发布成功)──► posted - * posted ──► (终态,本地不变;要改远端走 Bitbucket API) + * State machine: + * pending ──(user edits body)──► edited + * pending ──(user rejects)──────► rejected + * edited ──(user rejects)──────► rejected + * pending / edited ──(batch publish succeeds)──► posted + * posted ──► (terminal, unchanged locally; to change the remote use the Bitbucket API) */ export interface ReviewDraft { - /** 唯一稳定 id (uuid 或 runId+findingId 派生),UI list-key + 持久化引用 */ + /** Unique stable id (uuid or derived from runId+findingId), for UI list-key + persistence references */ id: string; - /** PR hash localId,跟父目录一致 */ + /** PR hash localId, consistent with the parent directory */ prLocalId: string; - /** 锚点:跟 FindingAnchor 一致但 startLine/endLine 必填 (草稿必须 anchor 到具体行) */ + /** Anchor: same as FindingAnchor but startLine/endLine required (a draft must anchor to a specific line) */ anchor: ReviewDraftAnchor; - /** 当前评论正文。pending 时 = AI 建议原文;edited 时 = 用户编辑后 */ + /** Current comment body. When pending = the AI suggestion's original text; when edited = after user editing */ body: string; /** - * 来源:AI 建议 (`finding`) vs 用户手动添加 (`manual`)。 - * 用户从 DiffView 行 hover '+' 创建的草稿是 manual;从 ChatPane 跳转的是 finding。 + * Origin: AI suggestion (`finding`) vs user-added manually (`manual`). + * A draft created by the user from a DiffView line hover '+' is manual; one navigated from ChatPane is finding. */ origin: 'finding' | 'manual'; /** - * 仅 origin='finding' 时填,指回源 finding。UI 用它在 ChatPane finding card - * 上反查关联 Draft 的 status chip 显示 + * Filled only when origin='finding', pointing back to the source finding. The UI uses it on the ChatPane finding card + * to look up the associated Draft's status chip display */ source?: { runId: string; findingId: string }; status: 'pending' | 'edited' | 'posted' | 'rejected'; - /** 发布成功后远端 comment id,幂等 key + 跳转链接 */ + /** The remote comment id after a successful publish, idempotency key + navigation link */ posted_remote_id?: string; /** ISO */ createdAt: string; - /** ISO,每次 update 都刷新 */ + /** ISO, refreshed on every update */ updatedAt: string; } export interface ReviewDraftAnchor { path: string; - /** 锚点起始行 (从 1 开始) */ + /** Anchor start line (1-based) */ startLine: number; - /** 锚点结束行;单行评论 = startLine */ + /** Anchor end line; single-line comment = startLine */ endLine: number; - /** 锚到 base (old) 还是 head (new) 侧 */ + /** Anchor to the base (old) or head (new) side */ side: 'old' | 'new'; } @@ -216,11 +216,11 @@ export interface FindingClosuresFile { } /** - * PR identity 快照:嵌进 ReviewRun (可选) 让 run 文件自描述,不依赖 `prs/index.json` - * 也能反查所属 PR。M5 归档场景 (PR 已硬清但 run 单独导出) 会需要。 + * PR identity snapshot: embedded into ReviewRun (optional) so the run file is self-describing, able to look up its owning PR + * without depending on `prs/index.json`. Needed by the M5 archive scenario (PR already hard-cleared but the run exported separately). * - * 这里复制 `@meebox/poller` 的 PrIdentity 形状到 shared,避免 shared 反向依赖 - * poller (循环依赖)。两边字段一一对应。 + * This copies `@meebox/poller`'s PrIdentity shape into shared, to avoid shared reverse-depending on + * poller (circular dependency). Fields correspond one-to-one on both sides. */ export interface PrIdentitySnapshot { platform: PlatformKind; @@ -232,137 +232,137 @@ export interface PrIdentitySnapshot { } /** - * 一次 pr-agent 调用的完整记录。落地为 `state/prs/<localId>/runs/<runId>.json`, - * 与 PR 的 meta.json / comments.json 同目录,PR 退场时一并清理。 + * The complete record of one pr-agent invocation. Persisted as `state/prs/<localId>/runs/<runId>.json`, + * in the same directory as the PR's meta.json / comments.json, cleaned up together when the PR leaves. */ /** - * 本次 run 的 LLM token 用量(真实值,来自 API response.usage,经 litellm callback - * 捕获,见 sitecustomize.py)。一次 run 可能多次调用 LLM(retry / 多 tool),这里是 - * **累加**值,calls 记录调用次数。历史 run / 非 embedded / 流式模型可能缺失 → 全可选。 + * This run's LLM token usage (real values, from API response.usage, captured via a litellm callback, + * see sitecustomize.py). One run may call the LLM multiple times (retry / multiple tools), so this is the + * **cumulative** value, with calls recording the number of invocations. May be missing for historical runs / non-embedded / streaming models → all optional. */ export interface TokenUsage { promptTokens?: number; completionTokens?: number; totalTokens?: number; - /** 本次 run 捕获到的 LLM 调用次数(累加来源) */ + /** The number of LLM invocations captured in this run (the cumulative source) */ calls?: number; - /** 提示缓存读取(cache_read)token 数:promptTokens 的一部分,供 UI 拆分展示「↑总量 (cache N)」。 - * CLI 路径取自 claude/codex usage,API 路径取自 litellm(Anthropic cache_read / OpenAI cached_tokens)。 - * 缺失或为 0 = 无缓存命中信息(UI 不展示该括号)。 */ + /** Prompt cache read (cache_read) token count: part of promptTokens, for the UI to split-display "↑total (cache N)". + * The CLI path takes it from claude/codex usage, the API path from litellm (Anthropic cache_read / OpenAI cached_tokens). + * Missing or 0 = no cache-hit info (the UI doesn't show that parenthetical). */ cacheReadTokens?: number; - /** 模型实际交互轮次:CLI agentic 模式为本次 run 内部累计的 num_turns(可远大于 calls); - * 其它情况回退为 LLM 调用次数(calls)。≤1 时 UI 不单独展示。 */ + /** The model's actual interaction turns: in CLI agentic mode, the num_turns accumulated inside this run (may be far greater than calls); + * otherwise falls back to the LLM invocation count (calls). ≤1 the UI doesn't show separately. */ turns?: number; } -/** pr-agent run 触发来源:user(用户手动发起)/ agent(编排 / AutoPilot 派发)。 */ +/** pr-agent run trigger origin: user (manually initiated by the user) / agent (dispatched by orchestration / AutoPilot). */ export type ReviewRunOrigin = 'user' | 'agent'; /** - * 单 commit 评审范围:把一次 run 的 diff 限定在某个 commit 自身的改动(`parent..sha`), - * 而非 PR 全量。由 Diff 视图的提交选择器发起,落盘到 ReviewRun 供结果卡展示范围徽标。 - * 无父 commit(root)无法单 commit 定界,不提供该范围。 + * Single-commit review scope: limiting a run's diff to a specific commit's own changes (`parent..sha`), + * rather than the whole PR. Initiated from the Diff view's commit selector, persisted to ReviewRun for the result card to show a scope badge. + * A commit with no parent (root) can't be single-commit-bounded, so this scope is not provided. */ export interface ReviewRunCommitScope { - /** 目标 commit 完整 SHA(worktree head)。 */ + /** Target commit full SHA (worktree head). */ sha: string; - /** 目标 commit 首个父 commit SHA(worktree base;单 commit diff = parent..sha)。 */ + /** Target commit's first parent commit SHA (worktree base; single-commit diff = parent..sha). */ parent: string; - /** 展示用短 SHA。 */ + /** Short SHA for display. */ abbreviatedSha: string; - /** 展示用 commit 主题(首行 message)。 */ + /** Commit subject for display (first line of message). */ subject: string; } export interface ReviewRun { - /** yyyymmdd-HHmmss-ms 时序 id,便于按文件名倒序列出 */ + /** yyyymmdd-HHmmss-ms sequential id, convenient for listing in reverse filename order */ id: string; - /** PR hash localId (12 hex chars),跟 StoredPullRequest.localId 对齐 */ + /** PR hash localId (12 hex chars), aligned with StoredPullRequest.localId */ prLocalId: string; /** - * PR identity 快照 (可选);目前 M3 默认不填,UI 始终从 meta.json 读 PR 信息。 - * 留 schema 位给 M5 归档:导出单个 run 文件时能凭此快照反查远端 PR / 跳转 URL, - * 即使本地 `prs/<hash>/` 已经被硬清 + * PR identity snapshot (optional); currently M3 doesn't fill it by default, the UI always reads PR info from meta.json. + * A schema slot reserved for M5 archive: when exporting a single run file, this snapshot lets it look up the remote PR / navigation URL, + * even after the local `prs/<hash>/` has been hard-cleared */ prIdentitySnapshot?: PrIdentitySnapshot; tool: ReviewRunTool; - /** /ask 工具的问题内容;其他 tool 不填。UI 把它当用户发言渲染在 run 卡片之上 */ + /** The question content for the /ask tool; other tools don't fill it. The UI renders it as user speech above the run card */ question?: string; /** - * 触发来源:user(用户在 ChatPane 直接发起的斜杠命令)/ agent(编排 / AutoPilot 派发的子 run)。 - * ChatPane 据此为 user 来源的 run 在其卡片之上补一条命令回显气泡(对话习惯);agent 子 run 不回显 - * (其用户输入已由编排会话的用户消息承载,避免重复冒泡)。历史 run 无此字段(undefined),不回显。 + * Trigger origin: user (a slash command initiated directly by the user in ChatPane) / agent (a sub-run dispatched by orchestration / AutoPilot). + * ChatPane accordingly adds a command echo bubble above the card for user-origin runs (conversational habit); agent sub-runs are not echoed + * (their user input is already carried by the orchestration session's user message, avoiding duplicate bubbling). Historical runs lack this field (undefined), not echoed. */ origin?: ReviewRunOrigin; /** - * 单 commit 评审范围:本次 run 限定在该 commit 自身改动(`parent..sha`)而非 PR 全量时填。 - * 缺省 = PR 全量范围。结果卡据此展示范围徽标。 + * Single-commit review scope: filled when this run is limited to that commit's own changes (`parent..sha`) rather than the whole PR. + * Default = whole-PR scope. The result card shows a scope badge accordingly. */ scope?: ReviewRunCommitScope; - /** 探测时拿到的 pr-agent 版本(CLI 首行 / 嵌入式查出的 pr-agent 版本) */ + /** The pr-agent version obtained at probe time (CLI first line / the pr-agent version found by the embedded runtime) */ prAgentVersion: string; strategy: PrAgentStrategy; /** - * 本次 run 使用的 LLM 模型 ID — 取自启动时 active LlmProfile.model (经过 - * normalizeModel 加 provider 前缀的形态,e.g., `openai/qwen-plus` / - * `deepseek/deepseek-chat`)。 + * The LLM model ID used by this run — taken from the active LlmProfile.model at startup (in the form after + * normalizeModel adds the provider prefix, e.g., `openai/qwen-plus` / + * `deepseek/deepseek-chat`). * - * 历史 run 没存这字段 (undefined),UI 应能 graceful 处理。新 run 在 startReviewRun - * 入口填上,让 ChatPane 在 meta 行展示"哪一次 review 用的哪个模型",方便回看 - * 不同 profile 出的结果差异 + * Historical runs didn't store this field (undefined), so the UI should handle it gracefully. New runs fill it at the startReviewRun + * entry, so ChatPane shows in the meta row "which model each review used", making it convenient to review + * result differences across profiles */ model?: string; status: ReviewRunStatus; - /** ISO 启动时间 */ + /** ISO start time */ startedAt: string; - /** ISO 结束时间,running 状态下为 undefined */ + /** ISO finish time, undefined in the running state */ finishedAt?: string; - /** 运行墙钟 (ms) */ + /** Wall-clock runtime (ms) */ durationMs?: number; - /** 进程退出码;超时 / 信号杀 / 启动失败时可能为 -1 或 undefined */ + /** Process exit code; may be -1 or undefined on timeout / signal kill / spawn failure */ exitCode?: number; errorReason?: ReviewRunFailureReason; errorMessage?: string; - /** 原始 stdout 文本;M3-B2 解析成 findings 后仍保留供"看原文"调试 */ + /** Raw stdout text; still kept after M3-B2 parses it into findings, for "see original" debugging */ stdout?: string; - /** 原始 stderr 文本 */ + /** Raw stderr text */ stderr?: string; - /** 解析后的 findings;succeeded run 才填,failed 也可能部分有 */ + /** Parsed findings; filled only for a succeeded run, a failed one may also have some partially */ findings?: Finding[]; - /** 概要 (取首个 ## section 标题 / 描述首行),UI list 上显示 */ + /** Summary (takes the first ## section title / first description line), shown in the UI list */ summary?: string; - /** 本次 run 的真实 LLM token 用量(累加);缺失 = 未捕获到(见 TokenUsage) */ + /** This run's real LLM token usage (cumulative); missing = not captured (see TokenUsage) */ tokenUsage?: TokenUsage; /** - * 复评引用:本次 /ask 是对先前 review/improve run 某条 finding 的「复评」时,记下被引用的源 - * finding(前向链)。UI 据此在 /ask 卡片上展示「复评自 <file:line>」徽标 + 裁决动作。 - * 仅 tool='ask' 且经「引用」触发时填。 + * Re-review reference: when this /ask is a "re-review" of a finding from a prior review/improve run, record the referenced source + * finding (forward link). The UI shows a "re-reviewed from <file:line>" badge + verdict actions on the /ask card accordingly. + * Filled only when tool='ask' and triggered via "reference". */ referencedFinding?: { runId: string; findingId: string; anchor?: FindingAnchor }; /** - * 复评裁决:解析自复评 /ask 输出的 `<verdict>` 段——replace=给取代性新评论 / keep=原评论成立 / - * drop=原评论不成立。驱动 UI 的采纳 / 关闭动作。模型未给则 undefined(UI 仅展示、不出裁决动作)。 + * Re-review verdict: parsed from the `<verdict>` section of the re-review /ask output — replace=give a superseding new comment / keep=the original comment holds / + * drop=the original comment doesn't hold. Drives the UI's accept / close actions. undefined when the model doesn't give one (the UI only displays, no verdict action). */ askVerdict?: AskVerdict; } -/** 复评裁决:取代原评论 / 保留原评论 / 撤销原评论。 */ +/** Re-review verdict: supersede the original comment / keep the original comment / revoke the original comment. */ export type AskVerdict = 'replace' | 'keep' | 'drop'; /** - * finding 关闭关系:一条被复评 /ask「取代 / 撤销」而关闭的源 finding(独立于本地草稿语义,仅作用于 - * ChatPane finding 卡片的关闭态 + 双向互链)。按 (runId, findingId) 标识源 finding。 + * Finding closure relation: a source finding closed by a re-review /ask "supersede / revoke" (independent of local draft semantics, affecting only + * the closed state + bidirectional cross-linking of the ChatPane finding card). Identifies the source finding by (runId, findingId). */ export interface FindingClosure { - /** 源 finding 所在的 review/improve run id */ + /** The review/improve run id where the source finding resides */ runId: string; - /** 源 finding id */ + /** The source finding id */ findingId: string; - /** 关闭它的复评 /ask run id(用于卡片互链) */ + /** The re-review /ask run id that closed it (for card cross-linking) */ byAskRunId: string; - /** 触发关闭的裁决(replace=被取代 / drop=被撤销) */ + /** The verdict that triggered closure (replace=superseded / drop=revoked) */ verdict: AskVerdict; - /** ISO 关闭时间 */ + /** ISO closure time */ createdAt: string; } @@ -372,70 +372,70 @@ export interface ReviewRunFile { } /** - * 状态库里存的 PR:在远端字段之上叠加本地维度(归属连接、本地状态、发现/最后看到时间)。 - * 既在主进程持久化用,也是 renderer 经由 IPC 拿到的形状。 + * The PR as stored in the state store: local dimensions (owning connection, local status, discovery/last-seen time) layered on top of the remote fields. + * Used both for main-process persistence and as the shape the renderer receives via IPC. */ export interface StoredPullRequest extends PullRequest { /** - * PR 在本地状态体系的唯一标识:sha1(platform|connectionId|group|repo|remoteId) - * 取前 12 hex chars。详见 `@meebox/poller` 的 `prHashId`。 + * The PR's unique identifier in the local state system: sha1(platform|connectionId|group|repo|remoteId) + * taking the first 12 hex chars. See `@meebox/poller`'s `prHashId` for details. * - * 用 hash 而不是拼字符串: - * - 路径友好 (无 `:` `/` 需要转义,跨平台一致) - * - 定长 (12 chars) - * - 不同 platform / repo 同 PR id 不会撞 (platform + group + repo + remote 都纳入哈希源) + * Using a hash instead of concatenating strings: + * - path-friendly (no `:` `/` needing escaping, consistent across platforms) + * - fixed length (12 chars) + * - the same PR id across different platform / repo won't collide (platform + group + repo + remote are all in the hash source) */ localId: string; /** - * 远端平台类型。让单个 meta.json 自描述,不依赖 prs/index.json 也能知道这条 PR - * 来自什么平台 —— 跨存储迁移 / 备份 / 离线分析时友好。M3 起 Bitbucket only;M5 接入 - * GitHub / GitLab 时无需改 schema + * The remote platform type. Makes a single meta.json self-describing, able to know which platform this PR + * comes from without depending on prs/index.json — friendly for cross-storage migration / backup / offline analysis. Bitbucket only since M3; no schema change needed when integrating + * GitHub / GitLab in M5 */ platform: PlatformKind; connectionId: string; localStatus: LocalPrStatus; /** - * 该 PR 命中的发现分类(GitHub:review-requested/created/assigned/mentioned 的子集)。 - * poller 一轮把各分类都抓回来并 union 打标;renderer 据此本地过滤标签页,切换不再拉远端。 - * 不支持分类的平台(Bitbucket)为空数组。 + * The discovery categories this PR matched (GitHub: a subset of review-requested/created/assigned/mentioned). + * In one round the poller fetches all categories and union-tags them; the renderer filters tabs locally accordingly, and switching no longer hits the remote. + * An empty array for platforms that don't support categories (Bitbucket). */ discoveryFilters: PrDiscoveryFilter[]; - /** 首次被 poll 发现的时间,ISO */ + /** Time first discovered by poll, ISO */ discoveredAt: string; - /** 最近一次 poll 仍能看到的时间,ISO */ + /** Time most recently still seen by a poll, ISO */ lastSeenAt: string; /** - * 「未读」标记(派生值,由 `listStoredPullRequests` 据索引里的已读水位计算后填上;持久化的 meta.json - * 不含此字段)。为真表示自用户上次查看该 PR 后发生了**与我相关**的新事件:源分支推了新 commit、或出现 - * 了 @我 / 回复我的新评论。UI 据此在列表项上点一个未读圆点。用户打开 PR 即清除(推进已读水位)。 + * "Unread" marker (derived value, filled by `listStoredPullRequests` after computing against the read watermark in the index; the persisted meta.json + * doesn't contain this field). True means a new event **relevant to me** has occurred since the user last viewed this PR: the source branch pushed a new commit, or + * new comments @-ing me / replying to me appeared. The UI shows an unread dot on the list item accordingly. Opening the PR clears it (advancing the read watermark). */ unread?: boolean; /** - * 「@我 / 回复我」未读评论条数(派生值,同 `unread` 由 `listStoredPullRequests` 据已读水位计算填上,meta.json - * 不含)。与未读圆点**并存、互不替代**:圆点照常按新到达 / 新 commit / 点名回复亮,本计数仅额外给出点名/回复你 - * 的未读条数。已在 poll 端封顶 10,故 ≤ 10;UI 满额显示「10+」。0 表示无此类未读(不渲染计数)。 + * "@me / reply to me" unread comment count (derived value, like `unread` filled by `listStoredPullRequests` computing against the read watermark, not in meta.json). + * **Coexists with, does not replace** the unread dot: the dot still lights up on new arrival / new commit / named reply as usual; this count only additionally gives the count of unread comments naming/replying to you. + * Already capped at 10 on the poll side, so ≤ 10; the UI shows "10+" when full. 0 means no such unread (the count isn't rendered). */ unreadMentionCount?: number; } export interface PollResult { - /** 本轮所有连接合并返回的 PR 总数 */ + /** Total number of PRs returned across all connections in this round */ fetched: number; - /** 比上次 updatedAt 有变化的 PR 数 */ + /** Number of PRs changed vs the last updatedAt */ changed: number; - /** 本轮新增的 PR 数 */ + /** Number of PRs added this round */ added: number; - /** 本轮被剪除的 PR 数(远端已 merge/decline,或当前用户不再是 reviewer) */ + /** Number of PRs pruned this round (already merged/declined on the remote, or the current user is no longer a reviewer) */ removed: number; - /** poll 失败的连接数 */ + /** Number of connections that failed to poll */ errors: number; } /** - * 系统通知事件类型(与设置页开关一一对应): - * - `new_pr` / `mention` / `reply`:面向「待我评审」等——新 PR / 被 @ / 被回复。 - * - `authored_comment` / `authored_needs_work` / `authored_conflict`:面向「我创建的」PR(作者为本人)—— - * 收到他人新评论 / 被评审标记需修改 / 出现合并冲突。 + * System notification event types (one-to-one with the settings page toggles): + * - `new_pr` / `mention` / `reply`: for "review-requested" etc. — new PR / being @-ed / being replied to. + * - `authored_comment` / `authored_needs_work` / `authored_conflict`: for "authored by me" PRs (self is the author) — + * receiving a new comment from others / being marked needs-work by a reviewer / a merge conflict appearing. */ export type PollNotificationKind = | 'new_pr' @@ -446,32 +446,32 @@ export type PollNotificationKind = | 'authored_conflict'; /** - * Poll 本轮新发生的「值得提醒」事件,由 poller 经 onNotify 投影给主进程(用于弹系统通知)。仅在**已有基线** - * (非首轮 / PR 此前已知)时产出,避免首启 / 批量涌入时通知风暴;带游标的事件(mention/reply/authored_comment) - * 仅当评论时间晚于历史游标才计;authored_needs_work / authored_conflict 仅在对应状态发生新迁移时才产出。 + * A "worth notifying" event newly occurring in this poll round, projected by the poller to the main process via onNotify (for popping system notifications). Produced only when there is **an existing baseline** + * (not the first round / the PR was previously known), to avoid a notification storm on first launch / batch influx; cursor-bearing events (mention/reply/authored_comment) + * count only when the comment time is later than the historical cursor; authored_needs_work / authored_conflict are produced only on a new transition of the corresponding status. */ export interface PollNotificationEvent { kind: PollNotificationKind; - /** 事件所属 PR 的本地 id */ + /** The local id of the PR the event belongs to */ localId: string; - /** 事件所属连接 id(头像缓存键 + 取 adapter 拉头像用) */ + /** The connection id the event belongs to (avatar cache key + for taking the adapter to fetch the avatar) */ connectionId: string; - /** 远端 PR 编号(如 #123),用于通知正文 */ + /** Remote PR number (such as #123), for the notification body */ remoteId: string; - /** PR 标题,用于通知正文 */ + /** PR title, for the notification body */ title: string; - /** PR 所在仓库,用于通知正文展示「项目 / 仓库」 */ + /** The repo the PR is in, for the notification body to show "project / repo" */ repo: RepoRef; /** - * 发起人(通知头像):new_pr=PR 作者;mention/reply/authored_comment=触发本轮该类事件的最新一条评论作者; - * authored_needs_work=新标记需修改的评审人;authored_conflict=PR 作者(无具体发起人)。 + * Initiator (notification avatar): new_pr=PR author; mention/reply/authored_comment=the author of the latest comment triggering this round's event of that type; + * authored_needs_work=the reviewer who newly marked needs-work; authored_conflict=PR author (no specific initiator). */ actor: PlatformUser; - /** mention / reply / authored_comment:本轮新增条数;其余省略 */ + /** mention / reply / authored_comment: the count added this round; omitted otherwise */ count?: number; /** - * 触发事件的最新一条评论的定位信息(通知点击跳转用)。`anchor` 非空=inline 评论(可跳 diff 行), - * 为 null=summary 评论(打开「活动」对话标签)。仅 mention / reply / authored_comment 带此字段。 + * Locating info of the latest comment that triggered the event (for notification click navigation). `anchor` non-null=inline comment (can jump to a diff line), + * null=summary comment (opens the "activity" conversation tab). Only mention / reply / authored_comment carry this field. */ comment?: { remoteId: string; anchor: PrCommentAnchor | null }; } diff --git a/packages/shared/src/pr-agent-status.ts b/packages/shared/src/pr-agent-status.ts index 1ce6b8d4..02e060e2 100644 --- a/packages/shared/src/pr-agent-status.ts +++ b/packages/shared/src/pr-agent-status.ts @@ -3,9 +3,9 @@ export type PrAgentStrategy = 'embedded' | 'local-cli'; export interface PrAgentAvailable { available: true; strategy: PrAgentStrategy; - /** 探测命令返回的版本/帮助首行,未必规范 */ + /** Version/help first line returned by the probe command, not necessarily well-formed */ version: string; - /** 探测耗时(ms) */ + /** Probe duration (ms) */ probeMs: number; } diff --git a/packages/shared/src/pr-filter.ts b/packages/shared/src/pr-filter.ts index 1074a7c9..c78bb1da 100644 --- a/packages/shared/src/pr-filter.ts +++ b/packages/shared/src/pr-filter.ts @@ -2,15 +2,16 @@ import type { LocalPrStatus, StoredPullRequest } from './poller-contract.js'; import type { PrDiscoveryFilter } from './platform.js'; /** - * PR 列表筛选与检索的**纯谓词**(单一真相源)。渲染层侧栏与本地 API 的 PR 列表端点共用同一套语义, - * 避免两处各写一份过滤逻辑而漂移。仅做无副作用的判定 / 过滤,不含 UI(计数、可见性、分组属各自表现层)。 + * **Pure predicates** for PR list filtering and search (single source of truth). The renderer sidebar + * and the local API's PR list endpoint share the same semantics, avoiding two divergent filter logics. + * Only side-effect-free judgment / filtering, no UI (count, visibility, grouping belong to each presentation layer). * - * 二级筛选 `PrSecondaryFilter`:`'all'` 不限定;`LocalPrStatus`(本人评审决断 pending/approved/needs_work) - * 按 `localStatus` 匹配;`'conflict'` / `'mergeable'` 是跨 localStatus 横切的远端合并态筛选。 + * Secondary filter `PrSecondaryFilter`: `'all'` no restriction; `LocalPrStatus` (own review verdict pending/approved/needs_work) + * matches by `localStatus`; `'conflict'` / `'mergeable'` are remote merge-state filters cutting across localStatus. */ export type PrSecondaryFilter = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; -/** 二级筛选全集(与 {@link PrSecondaryFilter} 同步;本地 API 的分类标签据此列出)。 */ +/** Full set of secondary filters (synced with {@link PrSecondaryFilter}; the local API lists its category labels from this). */ export const PR_SECONDARY_FILTERS: readonly PrSecondaryFilter[] = [ 'all', 'pending', @@ -20,7 +21,7 @@ export const PR_SECONDARY_FILTERS: readonly PrSecondaryFilter[] = [ 'mergeable', ]; -/** 一级(平台发现分类)匹配:未指定一级 = 不限定;否则按 PR 携带的 discoveryFilters 命中判定。 */ +/** Primary (platform discovery category) match: no primary specified = no restriction; otherwise judged by the discoveryFilters carried by the PR. */ export function matchesDiscoveryFilter( pr: StoredPullRequest, primary?: PrDiscoveryFilter, @@ -29,9 +30,9 @@ export function matchesDiscoveryFilter( } /** - * 二级筛选匹配(状态 / 合并态)。`primary` 为当前一级发现分类(可空),用于分类相关的语义细化: - * 「我创建的」(`created`)下「待处理」= 需作者跟进 —— 除本人评审决断 pending 外,还并入存在合并冲突的 - * PR(作者需解决冲突方能推进,即便评审已通过)。 + * Secondary filter match (status / merge state). `primary` is the current primary discovery category (nullable), used for + * category-related semantic refinement: under "created by me" (`created`), "pending" = needs author follow-up —— besides own + * review verdict pending, it also merges in PRs with merge conflicts (the author must resolve conflicts to proceed, even if the review passed). */ export function matchesSecondaryFilter( pr: StoredPullRequest, @@ -53,7 +54,7 @@ export function matchesSecondaryFilter( } } -/** 检索匹配:空查询恒真;否则在 标题 / 仓库 / 作者 / 编号 拼成的串里做大小写无关子串匹配。 */ +/** Search match: empty query always true; otherwise a case-insensitive substring match over the string joined from title / repo / author / number. */ export function matchesPrQuery(pr: StoredPullRequest, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; @@ -70,14 +71,14 @@ export function matchesPrQuery(pr: StoredPullRequest, query: string): boolean { .includes(q); } -/** 筛选条件(各项可省,省略即不限定)。 */ +/** Filter criteria (each item optional; omitting means no restriction). */ export interface PrFilterCriteria { primary?: PrDiscoveryFilter; secondary?: PrSecondaryFilter; query?: string; } -/** 按 一级 + 二级 + 检索 顺序过滤 PR 列表。 */ +/** Filter the PR list in primary + secondary + search order. */ export function filterPullRequests( prs: StoredPullRequest[], criteria: PrFilterCriteria, diff --git a/packages/shared/src/product.ts b/packages/shared/src/product.ts new file mode 100644 index 00000000..2ae61fc0 --- /dev/null +++ b/packages/shared/src/product.ts @@ -0,0 +1,15 @@ +/** + * Product-level constants shared across processes (brand-neutral codename `meebox`, public brand `Code Meeseeks`). + * Keep external-facing URLs here so both the renderer (settings About page, code-suggestion template `<HOME>` + * placeholder) and any future main-side use resolve the same single source of truth. + */ + +/** The public project website (GitHub Pages). Single source of truth for the `<HOME>` template placeholder and the About page link. */ +export const PRODUCT_HOME_URL = 'https://huhamhire.github.io/code-meeseeks/'; + +/** + * Default code-suggestion draft layout used when the user leaves `agent.strategy.code_suggestion_layout` empty. + * Single source of truth for both the deterministic fallback (renderCodeSuggestionDraft) and the settings editor's + * placeholder, so "placeholder == actual default". Placeholders: `<TITLE>` / `<HOME>` / `<MODEL>` / `<SUGGESTIONS>`. + */ +export const DEFAULT_CODE_SUGGESTION_LAYOUT = '[<TITLE>](<HOME>) (<MODEL>)\n<SUGGESTIONS>'; diff --git a/packages/shared/src/sync-progress.ts b/packages/shared/src/sync-progress.ts index d9b2fef6..d39e4ec7 100644 --- a/packages/shared/src/sync-progress.ts +++ b/packages/shared/src/sync-progress.ts @@ -1,16 +1,16 @@ /** - * 仓库 sync 进度事件。RepoMirrorManager 在 clone/fetch 期间通过 onProgress 回调发出; - * main 进程经 IPC(`sync:progress` 事件)转推到 renderer。既被 @meebox/repo-mirror(产出方) - * 也被 @meebox/ipc(IpcEvents 载荷)引用,故置于 shared 作为共享领域类型。 + * Repo sync progress event. Emitted by RepoMirrorManager via the onProgress callback during clone/fetch; + * the main process pushes it to the renderer over IPC (the `sync:progress` event). Referenced both by + * @meebox/repo-mirror (producer) and @meebox/ipc (IpcEvents payload), so it lives in shared as a shared domain type. */ export interface SyncProgressEvent { - /** "host/projectKey/repoSlug" 标识 */ + /** "host/projectKey/repoSlug" identifier */ repo: string; phase: 'start' | 'progress' | 'done' | 'error'; - /** simple-git 阶段名(compressing / receiving / resolving / ...) */ + /** simple-git stage name (compressing / receiving / resolving / ...) */ stage?: string; /** 0-100 */ percent?: number; - /** 人读消息 */ + /** Human-readable message */ message?: string; } diff --git a/packages/shared/src/theme.ts b/packages/shared/src/theme.ts index 48fd872c..afe7f21f 100644 --- a/packages/shared/src/theme.ts +++ b/packages/shared/src/theme.ts @@ -1,23 +1,23 @@ -/** 解析后的实际视觉主题(写入 data-theme 的值)。 */ +/** Resolved actual visual theme (the value written to data-theme). */ export type ResolvedTheme = 'light' | 'dark'; /** - * 全局主题(Monaco 编辑器 + 整个 GUI chrome 共用同一主题)的浅 / 深归属。 - * - `'light'` / `'dark'`:主题恒定浅 / 深。 - * - `'auto'`:跟随操作系统深 / 浅色(系统深色用 Dark 2026、浅色用 Light 2026)。 + * Light / dark classification of the global theme (Monaco editor + the whole GUI chrome share one theme). + * - `'light'` / `'dark'`: theme is constantly light / dark. + * - `'auto'`: follows the OS dark / light setting (system dark uses Dark 2026, light uses Light 2026). */ export type EditorThemeMode = 'light' | 'dark' | 'auto'; /** - * 全局主题选项(Monaco 配色主题,亦驱动整个 GUI chrome)。`id` 为生效的 Monaco 主题名,`label` 为展示名, - * `mode` 为浅 / 深归属(决定 `data-theme` 与原生窗口 themeSource)。 - * - `'auto'`:特殊值,跟随系统深 / 浅色(深 → `dark-2026`、浅 → `light-2026`),默认。 - * - `vs` / `vs-dark` / `hc-light` / `hc-black`:Monaco 内置主题。 - * - 其余为内置注册的第三方主题(见 renderer monaco-setup,取色自 monaco-themes)。 + * Global theme option (Monaco color theme, also drives the whole GUI chrome). `id` is the active Monaco theme name, `label` is the display name, + * `mode` is the light / dark classification (determines `data-theme` and the native window themeSource). + * - `'auto'`: special value, follows the system dark / light setting (dark → `dark-2026`, light → `light-2026`), the default. + * - `vs` / `vs-dark` / `hc-light` / `hc-black`: Monaco built-in themes. + * - The rest are built-in registered third-party themes (see renderer monaco-setup, colors from monaco-themes). * - * **label 不做 i18n**:主题为专有名(GitHub Dark / Monokai…),与语言 endonym 同理,各 UI 语言下 - * 展示一致、不翻译。**例外**:`'auto'` 非具体主题、而是「自动适应」模式,其展示文案走 i18n - * (见 settings.editorThemeOptionAuto),本 label 仅作兜底。 + * **label is not i18n'd**: themes are proper names (GitHub Dark / Monokai…), like language endonyms, displayed + * consistently across UI languages and not translated. **Exception**: `'auto'` is not a concrete theme but an "auto-adapt" mode, whose display text goes through i18n + * (see settings.editorThemeOptionAuto); this label is only a fallback. */ export interface EditorThemeOption { id: string; @@ -29,7 +29,7 @@ export const EDITOR_THEME_OPTIONS = [ { id: 'auto', label: 'Auto', mode: 'auto' }, { id: 'dark-2026', label: 'Dark 2026', mode: 'dark' }, { id: 'light-2026', label: 'Light 2026', mode: 'light' }, - // Monaco 内置 vs / vs-dark 作为 Modern 默认(无需另引 VS Code Dark/Light Modern)。 + // Monaco built-in vs / vs-dark serve as the Modern defaults (no need to add VS Code Dark/Light Modern separately). { id: 'vs-dark', label: 'Dark Modern', mode: 'dark' }, { id: 'vs', label: 'Light Modern', mode: 'light' }, { id: 'hc-black', label: 'High Contrast Dark', mode: 'dark' }, @@ -50,14 +50,14 @@ export const EDITOR_THEME_OPTIONS = [ export type EditorTheme = (typeof EDITOR_THEME_OPTIONS)[number]['id']; -/** 取主题的浅 / 深归属(未知 id 回落 `'auto'`)。 */ +/** Get the light / dark classification of a theme (unknown id falls back to `'auto'`). */ export function editorThemeMode(id: string): EditorThemeMode { return EDITOR_THEME_OPTIONS.find((o) => o.id === id)?.mode ?? 'auto'; } /** - * 把全局主题解析为实际视觉主题(写入 data-theme)。`'auto'` 主题按 `osPrefersDark` 落到深 / 浅。 - * main / renderer 共用:renderer 的 osPrefersDark 取 `prefers-color-scheme`,main 取 `nativeTheme`。 + * Resolve the global theme into the actual visual theme (written to data-theme). The `'auto'` theme falls to dark / light per `osPrefersDark`. + * Shared by main / renderer: renderer's osPrefersDark comes from `prefers-color-scheme`, main's from `nativeTheme`. */ export function resolveEditorThemeMode(id: string, osPrefersDark: boolean): ResolvedTheme { const mode = editorThemeMode(id); @@ -65,24 +65,24 @@ export function resolveEditorThemeMode(id: string, osPrefersDark: boolean): Reso } /** - * 主题对应的原生窗口 themeSource:`'auto'` 交回 OS(`'system'`),其余固定浅 / 深。 - * 主进程据此设 `nativeTheme.themeSource`,让原生窗口 chrome(Windows 细边框 / 窗控按钮)跟随主题。 + * The native window themeSource for a theme: `'auto'` hands back to the OS (`'system'`), the rest are fixed light / dark. + * The main process sets `nativeTheme.themeSource` accordingly, so the native window chrome (Windows thin border / window control buttons) follows the theme. */ export function editorThemeNativeSource(id: string): 'system' | 'light' | 'dark' { const mode = editorThemeMode(id); return mode === 'auto' ? 'system' : mode; } -/** 受支持的编辑器主题 id 元组(供 zod enum 校验用)。 */ +/** Tuple of supported editor theme ids (for zod enum validation). */ export const EDITOR_THEME_IDS = EDITOR_THEME_OPTIONS.map((o) => o.id) as [ EditorTheme, ...EditorTheme[], ]; -/** 编辑器字号合理范围(px)与默认值。下限保证可读、上限避免过大破坏布局;默认对齐历史 14px。 */ +/** Reasonable editor font-size range (px) and default. Lower bound keeps it readable, upper bound avoids breaking the layout; default aligns with the historical 14px. */ export const EDITOR_FONT_SIZE_MIN = 8; export const EDITOR_FONT_SIZE_MAX = 32; export const EDITOR_FONT_SIZE_DEFAULT = 14; -/** 设置页字号下拉的预设档位(仍受上面 min/max 约束;config 手改可取范围内任意整数)。 */ +/** Preset steps for the settings-page font-size dropdown (still bound by min/max above; manual config edits may use any integer in range). */ export const EDITOR_FONT_SIZE_PRESETS = [10, 11, 12, 13, 14, 15, 16, 18, 20, 24] as const; diff --git a/packages/shared/src/tool-registry.ts b/packages/shared/src/tool-registry.ts index c5a58d73..b1edfaa4 100644 --- a/packages/shared/src/tool-registry.ts +++ b/packages/shared/src/tool-registry.ts @@ -1,25 +1,25 @@ /** - * 统一工具注册表(唯一真相源,见 docs/arch/02-agent/01-agent.md「工具修改红线」)。新增 / 调整工具只改这里, - * 下列派生物自动跟随: - * - `ReviewRunTool`:pr-agent 运行队列工具 id(`isRun`)。 - * - agent 工具目录 `buildToolCatalog`:按 `kind` 标读 / 改、按 `grant` 放行修改类(红线策略在 agent 层)。 - * - 规划红线允许集:`READ_RUN_TOOL_IDS`。 + * Unified tool registry (single source of truth, see docs/arch/02-agent/01-agent.md "tool mutation red line"). Add / adjust tools only here, + * and the following derivations follow automatically: + * - `ReviewRunTool`: pr-agent run-queue tool ids (`isRun`). + * - agent tool catalog `buildToolCatalog`: marks read / mutating by `kind`, allows mutating ones by `grant` (red-line policy lives in the agent layer). + * - planning red-line allowed set: `READ_RUN_TOOL_IDS`. */ -/** 工具读 / 改分类。read 始终可用;mutating 对远端有副作用、默认禁止,仅 grant 放行。 */ +/** Tool read / mutate classification. read is always available; mutating has remote side effects, disabled by default, allowed only by grant. */ export type ToolKind = 'read' | 'mutating'; export interface ToolSpec { - /** 规范 id(无斜杠),如 `describe`。 */ + /** Canonical id (no slash), e.g. `describe`. */ id: string; - /** 展示 / 调用名(带斜杠),如 `/describe`。 */ + /** Display / invocation name (with slash), e.g. `/describe`. */ command: string; - /** 一句话说明(注入工具目录提示词;面向 LLM,英语)。 */ + /** One-line description (injected into the tool-catalog prompt; LLM-facing, English). */ summary: string; kind: ToolKind; - /** 修改类放行所需的 grant 键;读类省略。 */ + /** grant key required to allow a mutating tool; omitted for read tools. */ grant?: string; - /** 是否为 pr-agent 运行队列工具(经 run-queue 产出 ReviewRun)。 */ + /** Whether this is a pr-agent run-queue tool (produces a ReviewRun via the run-queue). */ isRun: boolean; } @@ -78,10 +78,10 @@ export const TOOLS = [ }, ] as const satisfies readonly ToolSpec[]; -/** pr-agent 运行队列工具 id(注册表中 `isRun` 的项)。 */ +/** pr-agent run-queue tool ids (the `isRun` entries in the registry). */ export type ReviewRunTool = Extract<(typeof TOOLS)[number], { isRun: true }>['id']; -/** 读类运行工具 id 集合:规划(ReAct)红线只放行这些工具自主调用,校验用。 */ +/** Set of read run-tool ids: the planning (ReAct) red line only allows these tools to be invoked autonomously, used for validation. */ export const READ_RUN_TOOL_IDS: ReadonlySet<string> = new Set( TOOLS.filter((t) => t.isRun && t.kind === 'read').map((t) => t.id), ); diff --git a/packages/shared/tests/pr-filter.test.ts b/packages/shared/tests/pr-filter.test.ts index beadc579..ff124463 100644 --- a/packages/shared/tests/pr-filter.test.ts +++ b/packages/shared/tests/pr-filter.test.ts @@ -8,7 +8,7 @@ import { matchesSecondaryFilter, } from '../src/pr-filter.js'; -/** 最小 StoredPullRequest 构造:只填谓词用到的字段,其余以 double-cast 略过。 */ +/** Minimal StoredPullRequest builder: only fills the fields the predicates use, skipping the rest via double-cast. */ function mkPr(over: Partial<StoredPullRequest>): StoredPullRequest { return { title: 'Fix login bug', @@ -24,20 +24,20 @@ function mkPr(over: Partial<StoredPullRequest>): StoredPullRequest { } describe('matchesDiscoveryFilter', () => { - it('无一级 = 不限定,恒真', () => { + it('no primary = unfiltered, always true', () => { expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: [] }), undefined)).toBe(true); }); - it('命中 discoveryFilters 为真', () => { + it('matches discoveryFilters → true', () => { expect( matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested', 'created'] }), 'created'), ).toBe(true); }); - it('未命中为假', () => { + it('no match → false', () => { expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested'] }), 'assigned')).toBe( false, ); }); - it('PR 无 discoveryFilters 且指定了一级 → 假', () => { + it('PR has no discoveryFilters and a primary is given → false', () => { expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: undefined }), 'review-requested')).toBe( false, ); @@ -45,18 +45,18 @@ describe('matchesDiscoveryFilter', () => { }); describe('matchesSecondaryFilter', () => { - it("'all' 恒真", () => { + it("'all' is always true", () => { expect(matchesSecondaryFilter(mkPr({ localStatus: 'needs_work' }), 'all')).toBe(true); }); - it('按 localStatus 匹配', () => { + it('matches by localStatus', () => { expect(matchesSecondaryFilter(mkPr({ localStatus: 'approved' }), 'approved')).toBe(true); expect(matchesSecondaryFilter(mkPr({ localStatus: 'pending' }), 'approved')).toBe(false); }); - it("'conflict' 看 hasConflict", () => { + it("'conflict' checks hasConflict", () => { expect(matchesSecondaryFilter(mkPr({ hasConflict: true }), 'conflict')).toBe(true); expect(matchesSecondaryFilter(mkPr({ hasConflict: false }), 'conflict')).toBe(false); }); - it("'mergeable' 看 mergeStatus.canMerge", () => { + it("'mergeable' checks mergeStatus.canMerge", () => { expect( matchesSecondaryFilter( mkPr({ mergeStatus: { canMerge: true, conflicted: false, vetoes: [] } }), @@ -70,14 +70,14 @@ describe('matchesSecondaryFilter', () => { ), ).toBe(false); }); - it("'pending' 默认按 localStatus,不含冲突", () => { + it("'pending' defaults to localStatus, excludes conflicts", () => { expect(matchesSecondaryFilter(mkPr({ localStatus: 'pending' }), 'pending')).toBe(true); expect( matchesSecondaryFilter(mkPr({ localStatus: 'approved', hasConflict: true }), 'pending'), ).toBe(false); }); - it("'created' 分类下 'pending' 并入冲突 PR(作者需跟进)", () => { - // 评审已通过但存在冲突 → created 下计入待处理 + it("'pending' under 'created' includes conflicted PRs (author must follow up)", () => { + // review already approved but has a conflict → counted as pending under created expect( matchesSecondaryFilter( mkPr({ localStatus: 'approved', hasConflict: true }), @@ -85,7 +85,7 @@ describe('matchesSecondaryFilter', () => { 'created', ), ).toBe(true); - // 无冲突且非 pending → 仍不计入 + // no conflict and not pending → still not counted expect( matchesSecondaryFilter( mkPr({ localStatus: 'approved', hasConflict: false }), @@ -93,7 +93,7 @@ describe('matchesSecondaryFilter', () => { 'created', ), ).toBe(false); - // localStatus pending 本就计入 + // localStatus pending is counted on its own expect( matchesSecondaryFilter( mkPr({ localStatus: 'pending', hasConflict: false }), @@ -111,19 +111,19 @@ describe('matchesPrQuery', () => { author: { displayName: 'Alice Zhang', name: 'alice' } as StoredPullRequest['author'], remoteId: '42', }); - it('空查询恒真', () => { + it('empty query is always true', () => { expect(matchesPrQuery(pr, '')).toBe(true); expect(matchesPrQuery(pr, ' ')).toBe(true); }); - it('大小写无关匹配标题 / 仓库 / 作者 / 编号', () => { - expect(matchesPrQuery(pr, 'LOGIN')).toBe(true); // 标题 + it('case-insensitive match on title / repo / author / id', () => { + expect(matchesPrQuery(pr, 'LOGIN')).toBe(true); // title expect(matchesPrQuery(pr, 'web-app')).toBe(true); // repoSlug expect(matchesPrQuery(pr, 'proj')).toBe(true); // projectKey expect(matchesPrQuery(pr, 'alice')).toBe(true); // author.name expect(matchesPrQuery(pr, 'Alice Zhang')).toBe(true); // author.displayName expect(matchesPrQuery(pr, '42')).toBe(true); // remoteId }); - it('未命中为假', () => { + it('no match → false', () => { expect(matchesPrQuery(pr, 'nonexistent')).toBe(false); }); }); @@ -151,25 +151,25 @@ describe('filterPullRequests', () => { }), ]; - it('空条件返回全部', () => { + it('empty criteria returns all', () => { expect(filterPullRequests(prs, {})).toHaveLength(3); }); - it('一级过滤', () => { + it('primary filter', () => { const out = filterPullRequests(prs, { primary: 'review-requested' }); expect(out.map((p) => p.remoteId)).toEqual(['1', '3']); }); - it('一级 + 二级 AND', () => { + it('primary + secondary AND', () => { const out = filterPullRequests(prs, { primary: 'review-requested', secondary: 'approved' }); expect(out.map((p) => p.remoteId)).toEqual(['3']); }); - it('二级 + 检索 AND', () => { + it('secondary + query AND', () => { const out = filterPullRequests(prs, { secondary: 'approved', query: 'beta' }); expect(out.map((p) => p.remoteId)).toEqual(['2']); }); - it('conflict 横切筛选', () => { + it('conflict cross-cutting filter', () => { expect(filterPullRequests(prs, { secondary: 'conflict' }).map((p) => p.remoteId)).toEqual(['3']); }); - it("'created' + 'pending' 并入冲突的已通过 PR", () => { + it("'created' + 'pending' includes conflicted approved PRs", () => { const createdPrs = [ mkPr({ remoteId: '10', localStatus: 'pending', discoveryFilters: ['created'] }), mkPr({ @@ -186,7 +186,7 @@ describe('filterPullRequests', () => { }); describe('PR_SECONDARY_FILTERS', () => { - it('含全部二级筛选键', () => { + it('contains all secondary filter keys', () => { expect(PR_SECONDARY_FILTERS).toEqual([ 'all', 'pending', diff --git a/packages/state-store/src/json-file-state-store.ts b/packages/state-store/src/json-file-state-store.ts index 904cc54f..cb855402 100644 --- a/packages/state-store/src/json-file-state-store.ts +++ b/packages/state-store/src/json-file-state-store.ts @@ -4,23 +4,26 @@ import path from 'node:path'; import type { Logger } from 'pino'; import type { StateStore } from './types.js'; -/** rename 自愈重试的退避梯度(ms);用尽仍失败则抛。 */ +/** Backoff gradient (ms) for rename self-healing retries; throws if exhausted and still failing. */ const RENAME_RETRY_DELAYS = [10, 25, 50, 100, 200]; /** - * 把 key 映射到 `<stateDir>/<key>.json`,写入走 "tmp → fsync → rename" 原子模式。 + * Maps a key to `<stateDir>/<key>.json`; writes go through the "tmp → fsync → rename" atomic pattern. * - * 假设单写者(Electron Main 进程独占),不做文件锁。多进程并发写同一 key 时, - * 最后一个 rename 胜出但中间不会出现半截文件。 + * Assumes a single writer (Electron Main process exclusive), no file locking. When multiple + * processes write the same key concurrently, the last rename wins but no half-written file + * ever appears in between. * - * Windows 自愈:同一 key 被并发写(多个 IPC handler 同时落同一份缓存,如打开 PR 时 - * 多路并发算 diff-base)时,`fs.rename` 覆盖既有文件可能撞上瞬时 EPERM/EACCES/EBUSY - * (目标正被另一并发 rename / 杀软 / 其它句柄短暂占用——POSIX 原子替换不会,Windows 会)。 - * 这是瞬时锁而非真实权限问题,小退避重试即自愈;用尽重试才抛。 + * Windows self-healing: when the same key is written concurrently (multiple IPC handlers + * flushing the same cache, e.g. multi-path parallel diff-base computation when opening a PR), + * `fs.rename` overwriting an existing file may hit transient EPERM/EACCES/EBUSY (the target + * is briefly held by another concurrent rename / antivirus / another handle — POSIX atomic + * replace won't, Windows will). This is a transient lock rather than a real permission problem; + * a small backoff retry self-heals; only throws once retries are exhausted. */ export class JsonFileStateStore implements StateStore { private readonly rootResolved: string; - /** tmp 文件名去重计数器:避免同进程内对同一 key 的并发写撞用同一 tmp 路径 */ + /** tmp filename dedup counter: avoids concurrent writes of the same key within one process colliding on the same tmp path */ private tmpSeq = 0; constructor( @@ -46,8 +49,9 @@ export class JsonFileStateStore implements StateStore { const filePath = this.keyToPath(key); await fs.mkdir(path.dirname(filePath), { recursive: true }); - // pid 隔离多进程、tmpSeq 隔离同进程内对同一 key 的并发写——否则两次并发写 - // 共用同一 tmp,先完成者 rename 走文件后,后完成者 rename 即 ENOENT。 + // pid isolates across processes, tmpSeq isolates concurrent writes of the same key within one + // process — otherwise two concurrent writes share one tmp, and after the first to finish renames + // the file away, the second's rename hits ENOENT. const tmp = `${filePath}.${String(process.pid)}.${String(this.tmpSeq++)}.tmp`; const handle = await fs.open(tmp, 'w'); try { @@ -60,9 +64,10 @@ export class JsonFileStateStore implements StateStore { } /** - * `fs.rename(tmp → dest)`,对 Windows 并发写的瞬时 EPERM/EACCES/EBUSY 做退避重试自愈。 - * rename 失败时 tmp 仍在原地,直接重试同一次 rename 即可。重试用尽 / 非瞬时错误:清理 tmp 后抛。 - * 每次自愈重试打 warn 级定位日志(key / dest / errno code / 第几次)。 + * `fs.rename(tmp → dest)`, self-heals transient EPERM/EACCES/EBUSY from Windows concurrent writes + * via backoff retry. When rename fails the tmp is still in place, so just retry the same rename. + * Retries exhausted / non-transient error: clean up tmp then throw. + * Each self-healing retry logs a warn-level diagnostic (key / dest / errno code / which attempt). */ private async renameWithRetry(tmp: string, dest: string, key: string): Promise<void> { for (let attempt = 0; ; attempt++) { @@ -73,7 +78,7 @@ export class JsonFileStateStore implements StateStore { const code = (e as NodeJS.ErrnoException).code; const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'; if (!transient || attempt >= RENAME_RETRY_DELAYS.length) { - // 用尽重试 / 非瞬时错误:清掉残留 tmp(best-effort)后抛原始错误 + // retries exhausted / non-transient error: remove leftover tmp (best-effort) then throw the original error await fs.rm(tmp, { force: true }).catch(() => undefined); throw e; } @@ -88,13 +93,17 @@ export class JsonFileStateStore implements StateStore { } /** - * 清扫残留的原子写临时文件(`*.tmp`)。正常写成功即 rename 走 tmp、失败(含 rename 重试用尽)也会主动 rm; - * 但进程在「write tmp」与「rename」之间被强杀 / 退出(如关窗瞬间仍有 in-flight 的异步写)会留下孤儿 tmp, - * 跨会话长期累积。 + * Sweeps leftover atomic-write temp files (`*.tmp`). A normal successful write renames the tmp away, + * and a failure (including exhausted rename retries) actively rm's it; but a process force-killed / + * exited between "write tmp" and "rename" (e.g. an in-flight async write still pending at window close) + * leaves an orphan tmp that accumulates across sessions over time. * - * **仅在启动、任何写入之前调用**才安全:单写者前提(Electron Main 独占)下,此刻不存在 in-flight 写,凡 `*.tmp` - * 皆为上次会话的孤儿,可放心删;**绝不在运行期清扫**——否则会误删并发写 / rename 重试正在用的 tmp(冲突场景下 - * 不生成、也不误删多余文件)。best-effort:单个删除失败仅记日志、不抛。返回清掉的文件数。 + * **Only safe to call at startup, before any write**: under the single-writer premise (Electron Main + * exclusive) there is no in-flight write at this moment, so every `*.tmp` is an orphan from the last + * session and safe to delete; **never sweep at runtime** — that would wrongly delete a tmp in use by a + * concurrent write / rename retry (in the conflict scenario no extra file is generated, nor wrongly + * deleted). best-effort: a single deletion failure only logs, does not throw. Returns the number of + * files swept. */ async sweepStaleTmpFiles(): Promise<number> { let removed = 0; @@ -103,7 +112,7 @@ export class JsonFileStateStore implements StateStore { try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { - return; // 目录不存在 / 不可读:忽略 + return; // directory does not exist / not readable: ignore } for (const entry of entries) { const full = path.join(dir, entry.name); @@ -125,13 +134,19 @@ export class JsonFileStateStore implements StateStore { } /** - * 清扫 `<prefix>/<child>/` 下的孤儿子目录:`child` 不在 `keep` 集**且**目录 mtime 早于 `nowMs - olderThanMs` - * 的整树删掉。用于启动期回收归档冷存储里的孤儿——统一索引丢失 / 被重建后,归档条目失去目录索引, - * 按索引遍历的硬清够不到它(见 docs/arch/99-core/01-state-storage)。无索引可依,故以目录 mtime 作 archivedAt 的代理(索引一并丢了)。 + * Sweeps orphan child directories under `<prefix>/<child>/`: deletes the whole tree of any `child` + * that is not in the `keep` set **and** whose directory mtime is earlier than `nowMs - olderThanMs`. + * Used at startup to reclaim orphans in archived cold storage — after the unified index is lost / + * rebuilt, archived entries lose their directory index and the index-driven hard cleanup can't reach + * them (see docs/arch/99-core/01-state-storage). With no index to rely on, directory mtime serves as + * a proxy for archivedAt (the index was lost along with it). * - * **双重保守**:必须同时「不在 keep」+「mtime 超期」才删——避免误删一个只是暂时不在索引里的目录(如中断的搬迁)。 - * **仅启动期、任何写入之前调用**才安全(单写者前提下此刻无 in-flight 搬迁会被误判为孤儿)。子目录直接子级遍历、 - * 不递归判定;非目录项跳过。best-effort:单个失败仅记日志、不抛。返回删除的孤儿目录数。 + * **Doubly conservative**: only deletes when both "not in keep" + "mtime past grace" hold — avoids + * wrongly deleting a directory that is merely temporarily absent from the index (e.g. an interrupted + * relocation). **Only safe to call at startup, before any write** (under the single-writer premise no + * in-flight relocation would be misjudged as an orphan). Traverses direct children of the subdirectory, + * no recursive judgement; non-directory entries are skipped. best-effort: a single failure only logs, + * does not throw. Returns the number of orphan directories deleted. */ async sweepOrphanDirs( prefix: string, @@ -144,7 +159,7 @@ export class JsonFileStateStore implements StateStore { try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { - return 0; // prefix 目录不存在 / 不可读 + return 0; // prefix directory does not exist / not readable } let removed = 0; for (const entry of entries) { @@ -156,7 +171,7 @@ export class JsonFileStateStore implements StateStore { } catch { continue; } - if (nowMs - mtimeMs <= olderThanMs) continue; // 太新:暂不动(保守) + if (nowMs - mtimeMs <= olderThanMs) continue; // too new: leave it for now (conservative) try { await fs.rm(dir, { recursive: true, force: true }); removed++; @@ -182,11 +197,11 @@ export class JsonFileStateStore implements StateStore { async deleteDir(prefix: string): Promise<void> { const fullPath = this.subpathInside(prefix); - // 双重保险:subpathInside 已经挡了越界,但避免误传空串 ('') 一刀清掉 stateDir 自身 + // double safeguard: subpathInside already blocks traversal, but guard against a stray empty string ('') wiping stateDir itself if (fullPath === this.rootResolved) { throw new Error('state-store: refused to deleteDir on stateDir root'); } - // recursive + force:不存在 / 是空目录 / 含子目录都接住,对应需求是"清掉整棵子树" + // recursive + force: handles nonexistent / empty directory / with subdirectories alike, matching the need to "clear the whole subtree" await fs.rm(fullPath, { recursive: true, force: true }); } @@ -218,13 +233,14 @@ export class JsonFileStateStore implements StateStore { } /** - * 安全屏障:所有文件系统操作必须落到 stateDir 内部。`..` 跳出 / 绝对路径 / 符号 - * 链接构造出的越界 key 都在此被拦截。 + * Safety barrier: every filesystem operation must land inside stateDir. Out-of-bounds keys + * constructed via `..` escapes / absolute paths / symbolic links are all intercepted here. * - * 为什么必须:StateStore key 由调用方拼接 (含 PR localId / runId / 评论缓存等), - * 一旦 key 在某个分支拼了未净化的用户输入 (比如远端 PR slug 含 `../`),没有这层 - * 屏障就能在用户工作目录之外读写文件。meebox 写过 user-controlled 字段进 key - * 的路径 (rules.dir id / repo slug / 远端 url 派生的 connectionId) 必须挡住。 + * Why required: StateStore keys are assembled by callers (containing PR localId / runId / + * comment cache etc.), and once a key on some branch splices in unsanitized user input (e.g. + * a remote PR slug containing `../`), without this barrier it could read/write files outside + * the user's working directory. Paths where meebox writes user-controlled fields into keys + * (rules.dir id / repo slug / connectionId derived from remote url) must be blocked. */ private subpathInside(rel: string): string { const joined = path.resolve(this.stateDir, rel); diff --git a/packages/state-store/src/types.ts b/packages/state-store/src/types.ts index c55ae064..311061b8 100644 --- a/packages/state-store/src/types.ts +++ b/packages/state-store/src/types.ts @@ -1,22 +1,22 @@ /** - * 持久化 KV 抽象。一期 JSON 文件实现;满足触发条件后可换 SQLite。 + * Persistent KV abstraction. Phase-one JSON file implementation; can switch to SQLite once trigger conditions are met. * - * key 形如 `connections` / `runs/pr-42/run-xyz`,由调用者保证结构。 - * 实现负责把 key 映射到具体存储位置,并保证写入原子性。 + * Keys look like `connections` / `runs/pr-42/run-xyz`, with structure guaranteed by the caller. + * The implementation is responsible for mapping keys to concrete storage locations and guaranteeing write atomicity. */ export interface StateStore { - /** 读取 key;不存在返回 null。 */ + /** Reads a key; returns null if it does not exist. */ read<T>(key: string): Promise<T | null>; - /** 原子写入 key;自动创建父目录。 */ + /** Atomically writes a key; creates parent directories automatically. */ write<T>(key: string, data: T): Promise<void>; - /** 删除 key;不存在 nop。 */ + /** Deletes a key; nop if it does not exist. */ delete(key: string): Promise<void>; - /** 列出指定前缀下的所有 key(不含值)。 */ + /** Lists all keys under the given prefix (without values). */ list(prefix: string): AsyncIterable<string>; /** - * 递归删除某个前缀下的整个目录树(含子目录 / 非 .json 文件 / 整个 prefix dir 自身)。 - * 用于 PR 退场时一次清掉 `prs/<hash>/` 下的 meta / comments / runs 等所有子文件。 - * 不存在 / 不是目录都 no-op。 + * Recursively deletes the entire directory tree under a prefix (including subdirectories / non-.json files / the whole prefix dir itself). + * Used to clear all sub-files under `prs/<hash>/` — meta / comments / runs etc. — in one shot when a PR exits. + * no-op if it does not exist / is not a directory. */ deleteDir(prefix: string): Promise<void>; } diff --git a/packages/state-store/tests/json-file-state-store.test.ts b/packages/state-store/tests/json-file-state-store.test.ts index 9a87f2d0..f3dc3e92 100644 --- a/packages/state-store/tests/json-file-state-store.test.ts +++ b/packages/state-store/tests/json-file-state-store.test.ts @@ -41,11 +41,12 @@ describe('JsonFileStateStore', () => { }); it('handles concurrent writes to the same key without ENOENT', async () => { - // 回归:tmp 文件名仅带 pid 时,同 key 的并发写共用同一 tmp,先完成者 rename 走文件后 - // 后完成者 rename 即 ENOENT。各并发写须各用唯一 tmp。 + // regression: when the tmp filename carries only the pid, concurrent writes of the same key share + // one tmp, and after the first to finish renames the file away, the later one's rename hits ENOENT. + // Each concurrent write must use its own unique tmp. await Promise.all(Array.from({ length: 20 }, (_, i) => store.write('hot', { v: i }))); const entries = await fs.readdir(tmpDir); - expect(entries).toEqual(['hot.json']); // 无遗留 .tmp + expect(entries).toEqual(['hot.json']); // no leftover .tmp expect(await store.read<{ v: number }>('hot')).toMatchObject({ v: expect.any(Number) }); }); @@ -89,33 +90,33 @@ describe('JsonFileStateStore', () => { expect(JSON.parse(text)).toEqual({ hello: 'world' }); }); - it('deleteDir removes the whole subtree (含子目录 + 非 .json 文件)', async () => { + it('deleteDir removes the whole subtree (including subdirectories + non-.json files)', async () => { await store.write('prs/abc/meta', { v: 1 }); await store.write('prs/abc/runs/run-1', { v: 1 }); - // 模拟非 .json 文件,确保也被一并清掉 + // simulate a non-.json file, ensuring it is cleared along with the rest await fs.writeFile(path.join(tmpDir, 'prs', 'abc', 'extra.txt'), 'hi'); await store.deleteDir('prs/abc'); await expect(fs.access(path.join(tmpDir, 'prs', 'abc'))).rejects.toThrow(); - // 兄弟目录不受影响 + // sibling directory is unaffected await store.write('prs/xyz/meta', { v: 1 }); expect(await store.read('prs/xyz/meta')).toEqual({ v: 1 }); }); - it('deleteDir 是 no-op 当目标不存在', async () => { + it('deleteDir is a no-op when the target does not exist', async () => { await expect(store.deleteDir('prs/nowhere')).resolves.toBeUndefined(); }); - it('deleteDir 拒绝清空 stateDir 自身 (空串 / "." 都视作 root)', async () => { + it('deleteDir refuses to clear stateDir itself (empty string / "." both treated as root)', async () => { await store.write('keep', { v: 1 }); await expect(store.deleteDir('')).rejects.toThrow(/stateDir root/); await expect(store.deleteDir('.')).rejects.toThrow(/stateDir root/); - // root 没被毁,原文件还在 + // root was not destroyed, the original file is still there expect(await store.read('keep')).toEqual({ v: 1 }); }); - // 路径越界保护:所有 fs 操作必须落在 stateDir 内部, - // `..` 跳出 / 绝对路径都得被挡,以防 key 拼接里混入未净化的用户输入 - it('read / write / delete / deleteDir / list 全都挡 ".." path traversal', async () => { + // path traversal protection: all fs operations must land inside stateDir, + // `..` escapes / absolute paths must be blocked, to guard against unsanitized user input spliced into key assembly + it('read / write / delete / deleteDir / list all block ".." path traversal', async () => { await expect(store.read('../escape')).rejects.toThrow(/path traversal/); await expect(store.write('../escape', { v: 1 })).rejects.toThrow(/path traversal/); await expect(store.delete('../escape')).rejects.toThrow(/path traversal/); @@ -125,7 +126,7 @@ describe('JsonFileStateStore', () => { }).rejects.toThrow(/path traversal/); }); - it('挡绝对路径 key(即使指向 stateDir 之外)', async () => { + it('blocks absolute-path keys (even ones pointing outside stateDir)', async () => { const outside = path.join(os.tmpdir(), 'meebox-outside'); await expect(store.read(outside)).rejects.toThrow(/path traversal/); await expect(store.write(outside, { v: 1 })).rejects.toThrow(/path traversal/); @@ -134,16 +135,16 @@ describe('JsonFileStateStore', () => { describe('sweepOrphanDirs', () => { const GRACE = 7 * 24 * 60 * 60 * 1000; const NOW = Date.parse('2026-06-10T00:00:00.000Z'); - // 把某个 <prefix>/<child> 目录的 mtime 回拨到 N 毫秒之前 + // backdate the mtime of some <prefix>/<child> directory to N milliseconds ago const backdateDir = async (rel: string, ageMs: number): Promise<void> => { const t = new Date(NOW - ageMs); await fs.utimes(path.join(tmpDir, rel), t, t); }; - it('删「不在 keep + mtime 超 grace」的孤儿,保留 keep 内 / 仍年轻的目录', async () => { - await store.write('prs/orphan/meta', { v: 1 }); // 不在 keep、且回拨到超期 → 删 - await store.write('prs/known/meta', { v: 1 }); // 在 keep → 留 - await store.write('prs/young/meta', { v: 1 }); // 不在 keep 但还年轻 → 留 + it('deletes orphans "not in keep + mtime past grace", keeps directories in keep / still young', async () => { + await store.write('prs/orphan/meta', { v: 1 }); // not in keep, and backdated past grace → delete + await store.write('prs/known/meta', { v: 1 }); // in keep → keep + await store.write('prs/young/meta', { v: 1 }); // not in keep but still young → keep await backdateDir('prs/orphan', GRACE + 60_000); await backdateDir('prs/known', GRACE + 60_000); await backdateDir('prs/young', GRACE - 60_000); @@ -155,12 +156,12 @@ describe('JsonFileStateStore', () => { expect(await store.read('prs/young/meta')).toEqual({ v: 1 }); }); - it('prefix 目录不存在 → 返回 0、不抛', async () => { + it('prefix directory does not exist → returns 0, does not throw', async () => { expect(await store.sweepOrphanDirs('archived/prs', new Set(), GRACE, NOW)).toBe(0); }); - it('跳过非目录项(如散落的 .json 文件)', async () => { - await store.write('prs/index', { schema_version: 1 }); // prs/index.json 是文件,非目录 + it('skips non-directory entries (e.g. stray .json files)', async () => { + await store.write('prs/index', { schema_version: 1 }); // prs/index.json is a file, not a directory await backdateDir('prs', GRACE + 60_000); const removed = await store.sweepOrphanDirs('prs', new Set(), GRACE, NOW); expect(removed).toBe(0); diff --git a/tools/gen-third-party-notices.mjs b/tools/gen-third-party-notices.mjs index 489717e3..cb5c7e67 100644 --- a/tools/gen-third-party-notices.mjs +++ b/tools/gen-third-party-notices.mjs @@ -1,14 +1,14 @@ #!/usr/bin/env node -// 生成 THIRD-PARTY-NOTICES.md —— 汇总分发产物里的第三方组件许可。 +// Generate THIRD-PARTY-NOTICES.md —— aggregate third-party component licenses in the distribution artifacts. // -// 覆盖三类: -// 1. npm 生产依赖闭包(`npm ls --omit=dev --all`,即打包进安装包的 node 依赖) -// 2. 嵌入式 Python 运行时(vendor/pragent)里的 pip 包(pr-agent + 其依赖) -// 3. 运行时载体(CPython / Electron / pr-agent)——手工 curated 头部 +// Covers three categories: +// 1. npm production dependency closure (`npm ls --omit=dev --all`, i.e. node deps bundled into the installer) +// 2. pip packages in the embedded Python runtime (vendor/pragent) (pr-agent + its dependencies) +// 3. Runtime carriers (CPython / Electron / pr-agent) —— manually curated header // -// 每个组件给出 名称@版本 + 许可标识 + 源地址,并尽量附上 LICENSE 正文(<details> 折叠)。 -// 用法:node tools/gen-third-party-notices.mjs → 写到仓库根 THIRD-PARTY-NOTICES.md -// 需要先 `npm ci` + `npm --prefix apps/desktop run prepare:pragent`(否则 python 段为空)。 +// Each component lists name@version + license identifier + source URL, and where possible attaches the LICENSE text (<details> collapsible). +// Usage: node tools/gen-third-party-notices.mjs → writes to repo root THIRD-PARTY-NOTICES.md +// Requires `npm ci` + `npm --prefix apps/desktop run prepare:pragent` first (otherwise the python section is empty). import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -46,10 +46,10 @@ function repoUrl(pkg) { return (u || pkg.homepage || '').replace(/^git\+/, '').replace(/\.git$/, '') || ''; } -// ── 1. npm 生产依赖 ─────────────────────────────────────────────── +// ── 1. npm production dependencies ──────────────────────────────── function collectNpm() { let json; - // Windows 上可执行是 npm.cmd;execFileSync 不会自动补后缀 + // On Windows the executable is npm.cmd; execFileSync won't auto-append the suffix const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'; try { const out = execFileSync(NPM, ['ls', '--omit=dev', '--all', '--json'], { @@ -59,7 +59,7 @@ function collectNpm() { }); json = JSON.parse(out); } catch (e) { - // npm ls 对 peer/extraneous 警告会非零退出,但 stdout 仍是有效 JSON + // npm ls exits non-zero on peer/extraneous warnings, but stdout is still valid JSON try { json = JSON.parse(e.stdout || '{}'); } catch { @@ -81,7 +81,7 @@ function collectNpm() { try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { - /* 嵌套去重的包根目录可能取不到,仅列名 */ + /* nested-dedup package root dir may be unavailable, list name only */ } seen.set(key, { name, @@ -98,7 +98,7 @@ function collectNpm() { return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); } -// ── 2. 嵌入式 Python(vendor/pragent)pip 包 ────────────────────── +// ── 2. Embedded Python (vendor/pragent) pip packages ────────────── function collectPython() { const base = join(ROOT, 'apps/desktop/vendor/pragent/python/lib'); if (!existsSync(base)) return []; @@ -122,7 +122,7 @@ function collectPython() { else if (line.startsWith('License:') && license === 'UNKNOWN') license = line.slice(8).trim(); else if (line.startsWith('Classifier: License ::')) license = line.split('::').pop().trim(); else if (/^(Home-page|Project-URL):/.test(line) && !url) url = line.split(':').slice(1).join(':').trim(); - if (line.trim() === '') break; // METADATA 头部到空行结束 + if (line.trim() === '') break; // METADATA header ends at the blank line } } catch { /* ignore */ diff --git a/tools/icons/gen-mac-icon.py b/tools/icons/gen-mac-icon.py index 8c64d7e5..e5e4612a 100644 --- a/tools/icons/gen-mac-icon.py +++ b/tools/icons/gen-mac-icon.py @@ -1,42 +1,43 @@ #!/usr/bin/env python3 -# 生成 macOS 专用 app 图标:深色圆角底板(squircle) + 居中留边的青色 glyph。 +# Generate the macOS-specific app icon: dark rounded plate (squircle) + centered, inset cyan glyph. # -# 为什么单独给 mac 出一张:Windows/Linux 用满铺透明异形图标没问题,但 macOS 的图标 -# 约定是「不透明圆角矩形底板 + logo 留边」。直接喂透明异形 glyph,macOS(尤其 26 Tahoe) -# 会把它套进圆角并垫一块默认白底 → 很丑。这里把品牌 glyph 合到深色 squircle 上规避。 +# Why produce a separate one for mac: Windows/Linux are fine with a full-bleed transparent +# free-form icon, but the macOS icon convention is "opaque rounded-rectangle plate + inset logo". +# Feeding a transparent free-form glyph directly, macOS (especially 26 Tahoe) wraps it into rounded +# corners and pads a default white background → ugly. Here the brand glyph is composited onto a dark squircle to avoid that. # -# 依赖 Pillow(非构建期依赖,仅改图标时手动跑): +# Depends on Pillow (not a build-time dependency, run manually only when changing the icon): # python3 -m venv /tmp/iconvenv && /tmp/iconvenv/bin/pip install Pillow # /tmp/iconvenv/bin/python tools/icons/gen-mac-icon.py \ # assets/icons/icon.png assets/icons/icon-mac.png # -# 产物 icon-mac.png 由 electron-builder.yml 的 mac.icon 引用(win/linux 仍用 icon.png)。 +# The output icon-mac.png is referenced by mac.icon in electron-builder.yml (win/linux still use icon.png). import sys from PIL import Image, ImageDraw SRC = sys.argv[1] OUT = sys.argv[2] -S = 1024 # 画布 -SS = 4 # 超采样倍数(圆角抗锯齿) -MARGIN = 100 # 底板四周留白(Apple 网格:body 824 居中) -RADIUS = 186 # 圆角半径(≈0.225*body) -GLYPH_MAX = 560 # glyph 最大边目标尺寸(body 内再留边) +S = 1024 # canvas +SS = 4 # supersampling factor (rounded-corner anti-aliasing) +MARGIN = 100 # plate padding on all sides (Apple grid: body 824 centered) +RADIUS = 186 # corner radius (≈0.225*body) +GLYPH_MAX = 560 # glyph max-edge target size (further inset within the body) -# 深色底,自上而下的细微渐变(避免死板纯色) +# Dark base, subtle top-to-bottom gradient (avoids a flat solid color) TOP = (42, 47, 57) # #2A2F39 BOT = (22, 26, 33) # #161A21 big = S * SS -# 1) 竖向渐变底 +# 1) vertical gradient base grad = Image.new("RGB", (1, big)) for y in range(big): t = y / (big - 1) grad.putpixel((0, y), tuple(round(TOP[i] + (BOT[i] - TOP[i]) * t) for i in range(3))) grad = grad.resize((big, big)) -# 2) squircle 蒙版(超采样圆角矩形) +# 2) squircle mask (supersampled rounded rectangle) mask = Image.new("L", (big, big), 0) d = ImageDraw.Draw(mask) d.rounded_rectangle( @@ -49,7 +50,7 @@ body.paste(grad, (0, 0), mask) body = body.resize((S, S), Image.LANCZOS) -# 3) glyph:裁到 alpha 包围盒 → 等比缩放到 GLYPH_MAX → 居中 +# 3) glyph: crop to alpha bounding box → scale proportionally to GLYPH_MAX → center glyph = Image.open(SRC).convert("RGBA") bbox = glyph.split()[3].getbbox() glyph = glyph.crop(bbox) diff --git a/tools/probes/bitbucket-server-probe.mjs b/tools/probes/bitbucket-server-probe.mjs deleted file mode 100644 index fa3ab095..00000000 --- a/tools/probes/bitbucket-server-probe.mjs +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env node -// Bitbucket Server REST v1 only-read probe. -// -// 用法: -// $env:BB_URL='https://code.fineres.com' -// $env:BB_TOKEN='<pat>' -// node tools/probes/bitbucket-server-probe.mjs [--verbose] -// -// 覆盖端点: -// GET /application-properties ← ping + 版本 -// GET /dashboard/pull-requests?role=REVIEWER&state=OPEN ← 当前用户待 review PR -// GET /projects/{p}/repos/{r}/pull-requests/{prId} ← PR 详情 -// GET .../pull-requests/{prId}/diff ← diff -// GET .../pull-requests/{prId}/changes ← 改动文件列表 -// GET .../pull-requests/{prId}/activities ← 活动 (含 comments) -// "whoami" 嗅探:检查响应头 X-AUSERNAME / X-AUSERID 等推测当前用户 -// -// 不做任何写入。token 仅从环境变量读取,绝不写入日志或文件。 - -const BB_URL = process.env.BB_URL; -const BB_TOKEN = process.env.BB_TOKEN; - -if (!BB_URL || !BB_TOKEN) { - console.error('需要环境变量 BB_URL 和 BB_TOKEN'); - process.exit(2); -} - -const VERBOSE = process.argv.includes('--verbose'); - -async function call(path, { accept = 'application/json' } = {}) { - const t0 = performance.now(); - let res, text; - try { - res = await fetch(`${BB_URL}${path}`, { - method: 'GET', - headers: { Authorization: `Bearer ${BB_TOKEN}`, Accept: accept }, - }); - text = await res.text(); - } catch (e) { - return { - path, - status: 0, - elapsed: Math.round(performance.now() - t0), - networkError: e.message, - }; - } - const elapsed = Math.round(performance.now() - t0); - let body; - try { - body = JSON.parse(text); - } catch { - body = text; - } - return { path, status: res.status, elapsed, body, raw: text, headers: res.headers }; -} - -function summarize(label, r, extract) { - if (r.networkError) { - console.log(`[NET] ${r.elapsed}ms ${label}\n ${r.networkError}`); - return null; - } - const ok = r.status >= 200 && r.status < 300; - const tag = ok ? 'OK ' : 'ERR'; - console.log( - `[${tag}] ${String(r.status).padStart(3)} ${String(r.elapsed).padStart(5)}ms ${label}`, - ); - if (!ok) { - const preview = (typeof r.body === 'string' ? r.body : JSON.stringify(r.body)).slice(0, 400); - console.log(` body: ${preview}`); - return null; - } - if (extract) { - try { - const line = extract(r.body); - if (line !== undefined) console.log(` ${line}`); - } catch (e) { - console.log(` (extract failed: ${e.message})`); - } - } - if (VERBOSE) { - const dump = typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2); - console.log(dump.slice(0, 4000)); - if (dump.length > 4000) console.log(` ...(truncated, full ${dump.length} bytes)`); - } - return r.body; -} - -async function main() { - console.log(`probe target: ${BB_URL}\n`); - - // 1. ping (with whoami header sniffing) - const pingRaw = await call('/rest/api/1.0/application-properties'); - const ping = summarize( - 'GET /application-properties', - pingRaw, - (b) => `server: ${b.version} (${b.displayName}), build ${b.buildNumber}`, - ); - if (!ping) process.exit(1); - - // 1.5 whoami 嗅探:枚举常见 Atlassian header + 候选端点 - console.log('\n--- whoami 嗅探 ---'); - const headerCandidates = [ - 'x-ausername', - 'x-auserid', - 'x-userid', - 'x-username', - 'x-atlassian-user', - ]; - const found = []; - for (const h of headerCandidates) { - const v = pingRaw.headers?.get(h); - if (v) found.push(`${h}: ${v}`); - } - if (found.length) { - console.log(' ping 响应头匹配:'); - for (const f of found) console.log(` ${f}`); - } else { - console.log(' ping 响应头无标准 whoami 信号'); - } - // 打印所有响应头便于人眼审计 - if (VERBOSE && pingRaw.headers) { - console.log(' 全部响应头:'); - for (const [k, v] of pingRaw.headers.entries()) { - console.log(` ${k}: ${v}`); - } - } - - // 候选 whoami 端点 - const whoamiEndpoints = [ - '/rest/api/1.0/users/me', - '/rest/api/1.0/users/-', - '/rest/api/latest/users/me', - '/rest/api/1.0/inbox/pull-requests/count', - '/rest/api/1.0/profile/recent/repos?limit=1', - ]; - for (const ep of whoamiEndpoints) { - const r = await call(ep); - const tag = r.status >= 200 && r.status < 300 ? 'OK ' : 'ERR'; - let extract = ''; - if (r.status === 200 && r.body && typeof r.body === 'object') { - // 看看 body 里有没有用户字段 - const userish = r.body.user ?? r.body.author ?? r.body; - const u = userish?.user ?? userish; - if (u?.name && u?.displayName) { - extract = `← name=${u.name} displayName=${u.displayName}`; - } - } - console.log(` [${tag}] ${String(r.status).padStart(3)} ${ep} ${extract}`); - } - - // 2. dashboard PRs as REVIEWER - const dash = summarize( - 'GET /dashboard/pull-requests?role=REVIEWER&state=OPEN&limit=50', - await call('/rest/api/1.0/dashboard/pull-requests?role=REVIEWER&state=OPEN&limit=50'), - (b) => - `pending as reviewer: size=${b.size} returned=${b.values?.length ?? 0} isLastPage=${b.isLastPage}`, - ); - if (!dash) process.exit(1); - - const prs = dash.values ?? []; - if (!prs.length) { - console.log('\n当前账号没有 reviewer 待处理 PR — 后续端点跳过,建议指定一个具体 PR 重跑'); - process.exit(0); - } - - console.log('\n 待处理 PR 列表(前 10 条):'); - for (const pr of prs.slice(0, 10)) { - const r = pr.toRef?.repository; - const author = pr.author?.user?.displayName ?? pr.author?.user?.name ?? '?'; - console.log(` #${pr.id} [${r?.project?.key}/${r?.slug}] author=${author} ${pr.title}`); - } - - // 3-6. Probe first PR - const target = prs[0]; - const projectKey = target.toRef.repository.project.key; - const repoSlug = target.toRef.repository.slug; - const prId = target.id; - const base = `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`; - - console.log(`\n 深探目标: ${projectKey}/${repoSlug} #${prId} — ${target.title}\n`); - - summarize( - `GET ${base}`, - await call(base), - (b) => - `state=${b.state} draft=${b.draft ?? false} reviewers=${(b.reviewers ?? []).length} fromRef=${b.fromRef?.displayId} toRef=${b.toRef?.displayId} updated=${new Date(b.updatedDate).toISOString()}`, - ); - - summarize(`GET ${base}/diff (JSON)`, await call(`${base}/diff`), (b) => { - if (b && typeof b === 'object' && Array.isArray(b.diffs)) { - const fileCount = b.diffs.length; - const hunkCount = b.diffs.reduce((n, d) => n + (d.hunks?.length ?? 0), 0); - const lineCount = b.diffs.reduce( - (n, d) => - n + - (d.hunks?.reduce( - (m, h) => m + (h.segments?.reduce((k, s) => k + (s.lines?.length ?? 0), 0) ?? 0), - 0, - ) ?? 0), - 0, - ); - return `JSON diff: ${fileCount} files, ${hunkCount} hunks, ${lineCount} lines truncated=${b.truncated ?? false}`; - } - if (typeof b === 'string') { - return `text diff (Accept fallback): ${b.length} bytes, ${b.split('\n').length} lines`; - } - return `unknown shape: ${typeof b}`; - }); - - summarize( - `GET ${base}/changes?limit=50`, - await call(`${base}/changes?limit=50`), - (b) => `changes: size=${b.size} returned=${b.values?.length ?? 0} isLastPage=${b.isLastPage}`, - ); - - summarize(`GET ${base}/activities?limit=50`, await call(`${base}/activities?limit=50`), (b) => { - const acts = b.values ?? []; - const commented = acts.filter((a) => a.action === 'COMMENTED'); - const inline = commented.filter((a) => a.commentAnchor); - const summary = commented.length - inline.length; - const types = [...new Set(acts.map((a) => a.action))].join(','); - return `activities: total=${b.size} commented=${commented.length} (inline=${inline.length}, summary=${summary}) actions=[${types}]`; - }); - - // 7. merge 状态 (用来判 conflict) - summarize(`GET ${base}/merge`, await call(`${base}/merge`), (b) => { - const fields = Object.keys(b ?? {}).join(','); - return `canMerge=${b?.canMerge} conflicted=${b?.conflicted} outcome=${b?.outcome} vetoes=${b?.vetoes?.length ?? 0} fields=[${fields}]`; - }); - - console.log('\nDone.'); -} - -main().catch((e) => { - console.error('FATAL:', e.stack || e.message); - process.exit(1); -}); diff --git a/typings/pino-roll.d.ts b/typings/pino-roll.d.ts index 439f9ffe..bd79b183 100644 --- a/typings/pino-roll.d.ts +++ b/typings/pino-roll.d.ts @@ -1,6 +1,6 @@ /** - * pino-roll 上游(3.x)不带 .d.ts。这里给出最小的全 workspace 共享声明, - * 各包按需在 tsconfig.json 的 include 里加 `../../typings/**` 引入。 + * pino-roll upstream (3.x) ships no .d.ts. This provides a minimal workspace-wide shared declaration; + * each package imports it as needed by adding `../../typings/**` to the include in tsconfig.json. */ declare module 'pino-roll' { import type { Writable } from 'node:stream'; diff --git a/typings/raw.d.ts b/typings/raw.d.ts index 69d5a546..a8e8807e 100644 --- a/typings/raw.d.ts +++ b/typings/raw.d.ts @@ -1,7 +1,7 @@ /** - * Vite `?raw` 资源导入:把文件正文作为字符串内联(electron-vite 主进程 / vitest / 内部包通用)。 - * 渲染层另由 `vite/client` 提供同名声明;此处覆盖主进程与内部包 - * (如 @meebox/agent 的模版加载)的 typecheck 范围。 + * Vite `?raw` asset import: inlines the file body as a string (shared across electron-vite main process / vitest / internal packages). + * The renderer gets the same-named declaration from `vite/client`; this covers the typecheck scope + * of the main process and internal packages (e.g. @meebox/agent's template loading). */ declare module '*?raw' { const content: string; diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..86854709 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ + +# Generated by scripts/sync-docs.mjs from docs/guide/ + CHANGELOG*.md (single source of truth) +guide/ +zh/guide/ +changelog.md +zh/changelog.md + +# Build-time release snapshot (scripts/fetch-release.mjs); regenerated per build +public/release-latest.json + +# Shared screenshots copied from assets/images/ by scripts/sync-assets.mjs +# (single source of truth is assets/images/ — never commit the copies here) +public/screenshot.light.png +public/screenshot.dark.png diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts new file mode 100644 index 00000000..3d9a76ab --- /dev/null +++ b/website/.vitepress/config.ts @@ -0,0 +1,157 @@ +import { defineConfig } from 'vitepress' + +const REPO = 'https://github.com/huhamhire/code-meeseeks' +const description = + 'A local, semi-automated AI code-review desktop client for the individual reviewer, built on pr-agent.' + +// GitHub Pages project site serves under /<repo>/ by default. +// Set SITE_BASE=/ when a custom domain (CNAME) is configured. +const base = process.env.SITE_BASE ?? '/code-meeseeks/' + +export default defineConfig({ + base, + title: 'Code Meeseeks', + description, + lang: 'en-US', + cleanUrls: true, + lastUpdated: true, + metaChunk: true, + + // README.md is the dev-facing readme (repo-relative links), not a site page. + srcExclude: ['README.md'], + + // The guide under /guide/ and /zh/guide/ is generated from docs/guide/ (single + // source of truth, not maintained here). Its cross-links may carry anchor slugs + // that don't match VitePress's, so scope dead-link tolerance to those subtrees + // and to anchor fragments — landing/nav links are still checked. + ignoreDeadLinks: [/^\/guide\//, /^\/zh\/guide\//, /#/], + + // base-aware so favicons resolve on the GitHub Pages project sub-path too. + head: [ + ['link', { rel: 'icon', href: `${base}favicon.ico`, sizes: '48x48' }], + ['link', { rel: 'icon', type: 'image/png', href: `${base}logo.png` }], + ['link', { rel: 'apple-touch-icon', href: `${base}logo.png` }], + ], + + // Use Dart Sass's modern API (silences the legacy-js-api deprecation warning). + vite: { + css: { preprocessorOptions: { scss: { api: 'modern-compiler' } } }, + }, + + themeConfig: { + logo: '/logo.png', + socialLinks: [{ icon: 'github', link: REPO }], + // Local search: the root (English) locale uses the built-in defaults; the zh + // locale needs its own UI-chrome translations (placeholder, no-results, + // keyboard hints) — the search index is per-locale, but the modal text isn't + // localized unless declared here (key must match the `locales` key: `zh`). + search: { + provider: 'local', + options: { + locales: { + zh: { + translations: { + button: { buttonText: '搜索', buttonAriaLabel: '搜索' }, + modal: { + displayDetails: '显示详细列表', + resetButtonTitle: '清除查询条件', + backButtonTitle: '关闭搜索', + noResultsText: '无法找到相关结果', + footer: { + selectText: '选择', + selectKeyAriaLabel: '回车', + navigateText: '切换', + navigateUpKeyAriaLabel: '上箭头', + navigateDownKeyAriaLabel: '下箭头', + closeText: '关闭', + closeKeyAriaLabel: 'esc', + }, + }, + }, + }, + }, + }, + }, + }, + + locales: { + root: { + label: 'English', + lang: 'en-US', + themeConfig: { + nav: [ + { text: 'Guide', link: '/guide/' }, + { text: 'Download', link: '/download' }, + { text: 'Changelog', link: '/changelog' }, + { text: 'FAQ', link: '/faq' }, + ], + sidebar: { + '/guide/': [ + { + text: 'User Guide', + items: [ + { text: 'Overview', link: '/guide/' }, + { text: 'Installation & first use', link: '/guide/00-getting-started' }, + { text: 'Code platform setup', link: '/guide/01-code-platform' }, + { text: 'LLM setup', link: '/guide/02-llm' }, + { text: 'Network proxy setup', link: '/guide/03-proxy' }, + { text: 'Config file reference', link: '/guide/04-config-reference' }, + { text: 'Custom review rules', link: '/guide/05-rules' }, + { text: 'CLI tool', link: '/guide/06-cli' }, + ], + }, + ], + }, + footer: { + message: 'Released under the Apache License 2.0.', + copyright: + 'Built on the community edition of <a href="https://docs.pr-agent.ai/" target="_blank" rel="noreferrer">PR-Agent</a> (Qodo), bundled under its own license.<br>An unofficial, independent open-source tool — not affiliated with Rick and Morty.', + }, + }, + }, + zh: { + label: '简体中文', + lang: 'zh-CN', + description: '面向 Reviewer 个人的本地化、半自动 AI 代码评审桌面客户端,基于 pr-agent 构建。', + themeConfig: { + nav: [ + { text: '使用说明', link: '/zh/guide/' }, + { text: '下载', link: '/zh/download' }, + { text: '更新日志', link: '/zh/changelog' }, + { text: '常见问题', link: '/zh/faq' }, + ], + sidebar: { + '/zh/guide/': [ + { + text: '使用说明', + items: [ + { text: '概览', link: '/zh/guide/' }, + { text: '安装与首次使用', link: '/zh/guide/00-getting-started' }, + { text: '代码平台配置', link: '/zh/guide/01-code-platform' }, + { text: 'LLM 配置', link: '/zh/guide/02-llm' }, + { text: '网络代理配置', link: '/zh/guide/03-proxy' }, + { text: '配置文件参考', link: '/zh/guide/04-config-reference' }, + { text: '自定义评审规则', link: '/zh/guide/05-rules' }, + { text: 'CLI 命令行工具', link: '/zh/guide/06-cli' }, + ], + }, + ], + }, + footer: { + message: '采用 Apache License 2.0 发布。', + copyright: + '基于 <a href="https://docs.pr-agent.ai/" target="_blank" rel="noreferrer">PR-Agent</a> 社区版(Qodo)构建,按其自身许可证分发。<br>非官方、独立的开源工具,与 Rick and Morty 无任何关联。', + }, + docFooter: { prev: '上一页', next: '下一页' }, + outline: { label: '本页目录' }, + lastUpdated: { text: '最后更新于' }, + langMenuLabel: '切换语言', + returnToTopLabel: '回到顶部', + sidebarMenuLabel: '菜单', + darkModeSwitchLabel: '主题', + lightModeSwitchTitle: '切换到浅色模式', + darkModeSwitchTitle: '切换到深色模式', + }, + }, + }, +}) diff --git a/website/.vitepress/theme/components/DownloadPanel.vue b/website/.vitepress/theme/components/DownloadPanel.vue new file mode 100644 index 00000000..b377876e --- /dev/null +++ b/website/.vitepress/theme/components/DownloadPanel.vue @@ -0,0 +1,316 @@ +<script setup> +import { ref, computed, onMounted } from 'vue' +import { useData, withBase } from 'vitepress' +import { detectOS } from '../os' +import { useLatestRelease } from '../composables/useLatestRelease' + +const REPO = 'huhamhire/code-meeseeks' +const RELEASES_URL = `https://github.com/${REPO}/releases` +const GUIDE_URL = `https://github.com/${REPO}/tree/master/docs/guide` +const CLI_GUIDE_URL = `https://github.com/${REPO}/tree/master/cli` +const CLI_INSTALL = 'curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash' + +// UI glyphs for the copy button. +const CLIPBOARD_ICON = + 'M16 1H4a2 2 0 00-2 2v14h2V3h12V1zm3 4H8a2 2 0 00-2 2v14a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2zm0 16H8V7h11v14z' +const CHECK_ICON = 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' + +// Platform glyphs (24×24, currentColor). Apple / Linux from simple-icons (CC0); +// Windows is the four-pane mark. +const ICON = { + windows: + 'M0 3.449 9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801', + apple: + 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701', + linux: + 'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.043c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.334-.17.838-.319 1.35-1.5 1.072-3.58 1.538-5.348.334a2.645 2.645 0 00-.402-.533 1.45 1.45 0 00-.275-.333c.182 0 .338-.03.465-.067a.615.615 0 00.314-.334c.108-.267 0-.697-.345-1.163-.345-.467-.931-.995-1.788-1.521-.63-.4-.986-.87-1.15-1.396-.165-.534-.143-1.085-.015-1.645.245-1.07.873-2.11 1.274-2.763.107-.065.037.135-.408.974-.396.751-1.14 2.497-.122 3.854a8.123 8.123 0 01.647-2.876c.564-1.278 1.743-3.504 1.836-5.268.048.036.217.135.289.202.218.133.38.333.59.465.21.201.477.335.876.335.039.003.075.006.11.006.412 0 .73-.134.997-.268.29-.134.52-.334.74-.4h.005c.467-.135.835-.402 1.044-.7zm2.185 8.958c.037.6.343 1.245.882 1.377.588.134 1.434-.333 1.791-.765l.211-.01c.315-.007.577.01.847.268l.003.003c.208.199.305.53.391.876.085.4.154.78.409 1.066.486.527.645.906.636 1.14l.003-.007v.018l-.003-.012c-.015.262-.185.396-.498.595-.63.401-1.746.712-2.457 1.57-.618.737-1.37 1.14-2.036 1.191-.664.053-1.237-.2-1.574-.898l-.005-.003c-.21-.4-.12-1.025.056-1.69.176-.668.428-1.344.463-1.897.037-.714.076-1.335.195-1.814.12-.465.308-.797.641-.984l.045-.022zm-10.814.049h.01c.053 0 .105.005.157.014.376.055.706.333 1.023.752l.91 1.664.003.003c.243.533.754 1.064 1.189 1.637.434.598.77 1.131.729 1.57v.006c-.057.744-.48 1.148-1.125 1.294-.645.135-1.52.002-2.395-.464-.968-.536-2.118-.469-2.857-.602-.369-.066-.61-.2-.723-.4-.11-.2-.113-.602.123-1.23v-.004l.002-.003c.117-.334.03-.752-.027-1.118-.055-.401-.083-.71.043-.94.16-.334.396-.4.69-.533.294-.135.64-.202.915-.47h.002v-.002c.256-.268.445-.601.668-.838.19-.201.38-.336.663-.336zm7.159-9.074c-.435.201-.945.535-1.488.535-.542 0-.97-.267-1.28-.466-.154-.134-.28-.268-.373-.335-.164-.134-.144-.333-.074-.333.109.016.129.134.199.2.096.066.215.2.36.333.292.2.68.467 1.167.467.485 0 1.053-.267 1.398-.466.195-.135.445-.334.648-.467.156-.136.149-.267.279-.267.128.016.034.134-.147.332a8.097 8.097 0 01-.69.468zm-1.082-1.583V5.64c-.006-.02.013-.042.029-.05.074-.043.18-.027.26.004.063 0 .16.067.15.135-.006.049-.085.066-.135.066-.055 0-.092-.043-.141-.068-.052-.018-.146-.008-.163-.065zm-.551 0c-.02.058-.113.049-.166.066-.047.025-.086.068-.14.068-.05 0-.13-.02-.136-.068-.01-.066.088-.133.15-.133.08-.031.184-.047.259-.005.019.009.036.03.03.05v.02h.003z', + desktop: + 'M2 4.5A1.5 1.5 0 0 1 3.5 3h17A1.5 1.5 0 0 1 22 4.5v10a1.5 1.5 0 0 1-1.5 1.5h-17A1.5 1.5 0 0 1 2 14.5zM11 16h2v2h4v2H7v-2h4z', + terminal: 'M4 7l2-2 7 7-7 7-2-2 5-5zM12 17h8v2h-8z', +} +function iconFor(goos) { + if (goos === 'darwin' || goos === 'macos') return ICON.apple + if (goos === 'windows') return ICON.windows + if (goos === 'linux') return ICON.linux + return '' +} + +const { lang } = useData() +const zh = computed(() => String(lang.value).toLowerCase().startsWith('zh')) +const t = computed(() => (zh.value ? STR.zh : STR.en)) +// "Release notes" points at the on-site changelog page (per locale, base-aware), not the GitHub release. +const changelogHref = computed(() => withBase(zh.value ? '/zh/changelog' : '/changelog')) + +const { state, release, desktop, cli } = useLatestRelease() +const os = ref('unknown') +const tab = ref('gui') // gui | cli +const copied = ref(false) + +async function copyInstall() { + try { + await navigator.clipboard.writeText(CLI_INSTALL) + copied.value = true + setTimeout(() => (copied.value = false), 1600) + } catch { + /* clipboard unavailable — ignore */ + } +} + +const recommendedDesktop = computed(() => { + if (os.value === 'windows') return desktop.value.windows + if (os.value === 'macos') return desktop.value.macos + return null +}) + +const recommendedCli = computed(() => { + const list = cli.value + if (os.value === 'windows') return list.find((x) => x.goos === 'windows') + if (os.value === 'macos') return list.find((x) => x.goos === 'darwin') + if (os.value === 'linux') return list.find((x) => x.goos === 'linux' && x.goarch === 'amd64') + return null +}) + +const osName = computed( + () => ({ windows: 'Windows', macos: 'macOS', linux: 'Linux', ios: 'iOS', android: 'Android' })[os.value] ?? '', +) + +function fmtSize(bytes) { + if (!bytes && bytes !== 0) return '' + const mb = bytes / 1024 / 1024 + return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB` +} + +function osLabel(goos, goarch) { + const o = { windows: 'Windows', darwin: 'macOS', linux: 'Linux' }[goos] ?? goos + let arch = goarch === 'amd64' ? 'x64' : 'ARM64' + if (goos === 'darwin' && goarch === 'arm64') arch = zh.value ? 'Apple 芯片' : 'Apple silicon' + return `${o} · ${arch}` +} + +onMounted(() => { + os.value = detectOS() + tab.value = os.value === 'linux' ? 'cli' : 'gui' +}) + +const STR = { + en: { + loading: 'Fetching the latest release…', + errorTitle: 'Could not reach GitHub', + errorBody: 'See all installers and archives on the Releases page.', + releasesLink: 'Open Releases', + latest: 'Latest release', + notes: 'Release notes', + recommended: 'Recommended for your system', + desktop: 'Desktop app', + desktopIntro: 'The full graphical client — discover, read, review, and publish PRs, all on your machine.', + guideLink: 'User guide', + cliTitle: 'Command-line tool (meebox)', + cliIntro: + 'Cross-platform CLI to browse PRs and drive review agents via the local API. The archive doubles as a drop-in agent skill.', + cliGuide: 'CLI guide', + cliQuick: 'One-line install (macOS / Linux)', + cliQuickNote: 'Auto-detects OS/arch, verifies SHA-256, installs meebox to your PATH. On Windows, download the archive below.', + copy: 'Copy', + copied: 'Copied', + download: 'Download', + linuxNoDesktop: 'No desktop build for Linux yet — use the meebox CLI below.', + unknown: 'Pick your platform below.', + firstLaunch: 'First launch', + winHint: [ + 'SmartScreen may warn “Windows protected your PC”.', + 'Click “More info” → “Run anyway”.', + 'One-time — this is an unsigned free / open-source build.', + ], + macHint: [ + 'Gatekeeper blocks the first launch.', + 'Right-click the app → Open, or System Settings → Privacy & Security → Open Anyway.', + 'One-time — ad-hoc signed, not notarized.', + ], + cliHint: 'Unzip and put meebox on your PATH, or drop the folder into your agent’s skills directory.', + }, + zh: { + loading: '正在获取最新版本…', + errorTitle: '无法连接 GitHub', + errorBody: '可在 Releases 页面查看全部安装包与压缩包。', + releasesLink: '打开 Releases', + latest: '最新版本', + notes: '更新说明', + recommended: '为你的系统推荐', + desktop: '桌面应用', + desktopIntro: '完整图形客户端——在本机发现、阅读、评审并发布 PR。', + guideLink: '使用说明', + cliTitle: '命令行工具(meebox)', + cliIntro: + '跨平台 CLI,经本地 API 浏览 PR、驱动评审 Agent;压缩包同时即 agent skill 目录,可直接投放。', + cliGuide: 'CLI 使用说明', + cliQuick: '一键安装(macOS / Linux)', + cliQuickNote: '自动探测系统 / 架构、校验 SHA-256,将 meebox 装入 PATH。Windows 请下载下方压缩包。', + copy: '复制', + copied: '已复制', + download: '下载', + linuxNoDesktop: '暂未提供 Linux 桌面版——请使用下方 meebox CLI。', + unknown: '请在下方选择你的平台。', + firstLaunch: '首次启动', + winHint: [ + 'SmartScreen 可能弹「Windows 已保护你的电脑」。', + '点「更多信息」→「仍要运行」。', + '一次即可——这是未签名的免费 / 开源构建。', + ], + macHint: [ + 'Gatekeeper 会拦下首次启动。', + '右键点应用 → 打开,或 系统设置 → 隐私与安全性 → 仍要打开。', + '一次即可——ad-hoc 签名、未公证。', + ], + cliHint: '解压后把 meebox 加入 PATH,或将整个目录投放到 agent 的 skills 目录。', + }, +} +</script> + +<template> + <div class="dl"> + <!-- Skeleton mirroring the loaded layout (head · recommended card · tabs · rows). --> + <div v-if="state === 'loading'" class="dl-skel" role="status" :aria-label="t.loading"> + <div class="dl-head"> + <span class="sk sk-tag"></span> + <span class="sk sk-link"></span> + </div> + <div class="dl-card dl-reco"> + <span class="sk sk-label"></span> + <span class="sk sk-btn"></span> + <span class="sk sk-note"></span> + </div> + <div class="dl-skel-tabs"> + <span class="sk sk-tab"></span> + <span class="sk sk-tab"></span> + </div> + <span class="sk sk-row"></span> + <span class="sk sk-row"></span> + </div> + + <div v-else-if="state === 'error'" class="dl-card dl-error"> + <strong>{{ t.errorTitle }}</strong> + <p class="dl-muted">{{ t.errorBody }}</p> + <a class="dl-btn dl-btn-brand" :href="RELEASES_URL" target="_blank" rel="noreferrer">{{ t.releasesLink }}</a> + </div> + + <template v-else> + <div class="dl-head"> + <span class="dl-tag">{{ t.latest }} · {{ release.tag_name }}</span> + <a class="dl-link" :href="changelogHref">{{ t.notes }} →</a> + </div> + + <!-- Recommended for the detected OS --> + <div class="dl-card dl-reco"> + <div class="dl-reco-label"> + {{ t.recommended }}<template v-if="osName"> + · <svg v-if="iconFor(os)" class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="iconFor(os)" /></svg> + {{ osName }}</template> + </div> + + <template v-if="recommendedDesktop"> + <a class="dl-btn dl-btn-brand" :href="recommendedDesktop.browser_download_url"> + {{ t.download }} · {{ os === 'windows' ? 'Windows x64' : 'macOS (Apple silicon)' }} + <span class="dl-size">{{ fmtSize(recommendedDesktop.size) }}</span> + </a> + <div class="custom-block info dl-note"> + <p class="custom-block-title">ℹ️ {{ t.firstLaunch }}</p> + <ul> + <li v-for="line in os === 'windows' ? t.winHint : t.macHint" :key="line">{{ line }}</li> + </ul> + </div> + </template> + + <template v-else-if="os === 'linux' && recommendedCli"> + <p class="dl-muted">{{ t.linuxNoDesktop }}</p> + <a class="dl-btn dl-btn-brand" :href="recommendedCli.browser_download_url"> + {{ t.download }} · meebox CLI · {{ osLabel(recommendedCli.goos, recommendedCli.goarch) }} + <span class="dl-size">{{ fmtSize(recommendedCli.size) }}</span> + </a> + <p class="dl-muted dl-hint">{{ t.cliHint }}</p> + </template> + + <p v-else class="dl-muted">{{ t.unknown }}</p> + </div> + + <!-- GUI / CLI tabs --> + <div class="dl-tabs" role="tablist"> + <button class="dl-tab" role="tab" :class="{ active: tab === 'gui' }" :aria-selected="tab === 'gui'" @click="tab = 'gui'"> + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="ICON.desktop" /></svg>{{ t.desktop }} + </button> + <button class="dl-tab" role="tab" :class="{ active: tab === 'cli' }" :aria-selected="tab === 'cli'" @click="tab = 'cli'"> + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="ICON.terminal" /></svg>{{ t.cliTitle }} + </button> + </div> + + <!-- Desktop app --> + <div v-show="tab === 'gui'" role="tabpanel"> + <p class="dl-muted">{{ t.desktopIntro }}</p> + <p class="dl-links"> + <a class="dl-link" :href="GUIDE_URL" target="_blank" rel="noreferrer">{{ t.guideLink }} →</a> + </p> + <ul class="dl-list"> + <li v-if="desktop.windows"> + <span class="dl-plat"> + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="ICON.windows" /></svg> + Windows · x64 + </span> + <span class="dl-file">{{ desktop.windows.name }}</span> + <a class="dl-btn" :href="desktop.windows.browser_download_url"> + {{ t.download }} <span class="dl-size">{{ fmtSize(desktop.windows.size) }}</span> + </a> + </li> + <li v-if="desktop.macos"> + <span class="dl-plat"> + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="ICON.apple" /></svg> + macOS · Apple silicon + </span> + <span class="dl-file">{{ desktop.macos.name }}</span> + <a class="dl-btn" :href="desktop.macos.browser_download_url"> + {{ t.download }} <span class="dl-size">{{ fmtSize(desktop.macos.size) }}</span> + </a> + </li> + </ul> + </div> + + <!-- CLI --> + <div v-show="tab === 'cli'" role="tabpanel"> + <p class="dl-muted">{{ t.cliIntro }}</p> + <p class="dl-links"> + <a class="dl-link" :href="CLI_GUIDE_URL" target="_blank" rel="noreferrer">{{ t.cliGuide }} →</a> + </p> + + <div class="dl-cmd"> + <div class="dl-cmd-label">{{ t.cliQuick }}</div> + <div class="dl-cmd-row"> + <code>{{ CLI_INSTALL }}</code> + <button + class="dl-copy" + :class="{ 'is-copied': copied }" + :title="copied ? t.copied : t.copy" + :aria-label="copied ? t.copied : t.copy" + @click="copyInstall" + > + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="copied ? CHECK_ICON : CLIPBOARD_ICON" /></svg> + </button> + </div> + <p class="dl-muted dl-hint">{{ t.cliQuickNote }}</p> + </div> + + <ul class="dl-list" v-if="cli.length"> + <li v-for="a in cli" :key="a.name"> + <span class="dl-plat"> + <svg class="dl-ico" viewBox="0 0 24 24" aria-hidden="true"><path :d="iconFor(a.goos)" /></svg> + {{ osLabel(a.goos, a.goarch) }} + </span> + <span class="dl-file">{{ a.name }}</span> + <a class="dl-btn" :href="a.browser_download_url"> + {{ t.download }} <span class="dl-size">{{ fmtSize(a.size) }}</span> + </a> + </li> + </ul> + <p class="dl-muted dl-hint">{{ t.cliHint }}</p> + </div> + </template> + </div> +</template> + diff --git a/website/.vitepress/theme/components/HomeFeatures.vue b/website/.vitepress/theme/components/HomeFeatures.vue new file mode 100644 index 00000000..94dc206b --- /dev/null +++ b/website/.vitepress/theme/components/HomeFeatures.vue @@ -0,0 +1,109 @@ +<script setup> +import { computed } from 'vue' +import { useData } from 'vitepress' + +const { lang } = useData() +const zh = computed(() => String(lang.value).toLowerCase().startsWith('zh')) + +// Conceptual icons from Lucide (ISC), monochrome currentColor stroke. +const PATHS = { + gavel: + '<path d="m14 13-8.381 8.38a1 1 0 0 1-3.001-3l8.384-8.381"/><path d="m16 16 6-6"/><path d="m21.5 10.5-8-8"/><path d="m8 8 6-6"/><path d="m8.5 7.5 8 8"/>', + lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', + globe: + '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>', + bot: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>', + sliders: + '<path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/>', + terminal: '<path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>', +} +function icon(name) { + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</svg>` +} + +const EN = [ + { + icon: 'gavel', + title: 'Human decides', + points: ["You confirm or edit every comment before it's published", 'The AI only drafts — you keep the final call'], + }, + { + icon: 'lock', + title: 'Data stays local', + points: ['Repo mirrors, PR metadata & drafts stay on your machine', 'Wire up a local model and nothing ever leaves it'], + }, + { + icon: 'globe', + title: 'Multi-platform access', + points: ['GitHub · Bitbucket · GitLab, incl. self-hosted Enterprise / Self-Managed', "Adapts to each platform's capabilities"], + }, + { + icon: 'bot', + title: 'Agentic review', + points: [ + 'Command-driven pr-agent + autonomous orchestration', + 'AutoPilot pre-review & re-review loop', + 'Observable, interruptible process', + ], + }, + { + icon: 'sliders', + title: 'Your models, your rules', + points: ['Many LLM providers (OpenAI, Anthropic, DeepSeek…)', 'A personalized rules directory you fully control'], + }, + { + icon: 'terminal', + title: 'CLI & integration', + points: ['Local API + cross-platform meebox CLI', 'Fold PR review into agents, scripts & CI'], + }, +] + +const ZH = [ + { + icon: 'gavel', + title: '决策权在人', + points: ['每条评论都需你二次确认 / 编辑后才发布', 'AI 只做草稿,最终决定权始终在你手里'], + }, + { + icon: 'lock', + title: '数据在本地', + points: ['仓库副本、PR 元数据、草稿都存本机', '接入本地模型即可全程不出本机'], + }, + { + icon: 'globe', + title: '多平台接入', + points: ['GitHub · Bitbucket · GitLab,含自建 Enterprise / Self-Managed', '按平台能力自适应降级'], + }, + { + icon: 'bot', + title: 'Agentic 评审', + points: ['指令驱动 pr-agent + 自主编排', 'AutoPilot 预评审 + 复评闭环', '过程可观测,可中途追加、随时停止'], + }, + { + icon: 'sliders', + title: '你的模型,你的规则', + points: ['多 LLM Provider(OpenAI / Anthropic / DeepSeek…)', '完全自控的个性化规则目录'], + }, + { + icon: 'terminal', + title: 'CLI 与外部集成', + points: ['本地 API + 跨平台 meebox CLI', '把 PR 评审纳入 agent、脚本、CI'], + }, +] + +const features = computed(() => (zh.value ? ZH : EN)) +</script> + +<template> + <div class="hf"> + <div v-for="(f, i) in features" :key="i" class="hf-card"> + <div class="hf-head"> + <span class="hf-icon" v-html="icon(f.icon)" /> + <h3 class="hf-title">{{ f.title }}</h3> + </div> + <ul class="hf-points"> + <li v-for="p in f.points" :key="p">{{ p }}</li> + </ul> + </div> + </div> +</template> diff --git a/website/.vitepress/theme/components/ModelStrip.vue b/website/.vitepress/theme/components/ModelStrip.vue new file mode 100644 index 00000000..cb972423 --- /dev/null +++ b/website/.vitepress/theme/components/ModelStrip.vue @@ -0,0 +1,46 @@ +<script setup> +import { computed } from 'vue' +import { useData } from 'vitepress' + +const { lang } = useData() +const zh = computed(() => String(lang.value).toLowerCase().startsWith('zh')) + +// Provider brand marks from simple-icons (CC0), rendered monochrome (currentColor). +const MODELS = [ + { + name: 'OpenAI', + d: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', + }, + { + name: 'Anthropic', + d: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z', + }, + { + name: 'DeepSeek', + d: 'M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45', + }, + { + name: 'Qwen', + d: 'M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z', + }, + { + name: 'Ollama', + d: 'M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Z', + }, +] + +const t = computed(() => (zh.value ? { label: '兼容你已在用的模型' } : { label: 'Works with your models' })) + +function svg(d) { + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${d}"/></svg>` +} +</script> + +<template> + <div class="ms"> + <p class="ms-label">{{ t.label }}</p> + <div class="ms-logos"> + <span v-for="m in MODELS" :key="m.name" class="ms-logo" :title="m.name" :aria-label="m.name" v-html="svg(m.d)" /> + </div> + </div> +</template> diff --git a/website/.vitepress/theme/composables/useLatestRelease.js b/website/.vitepress/theme/composables/useLatestRelease.js new file mode 100644 index 00000000..47fbab8f --- /dev/null +++ b/website/.vitepress/theme/composables/useLatestRelease.js @@ -0,0 +1,81 @@ +import { ref, computed, onMounted } from 'vue' +import { withBase } from 'vitepress' + +// Resolves the latest GitHub release and categorizes its assets into the +// desktop installers and the meebox CLI archives. Presentation (OS detection, +// recommendations, i18n) stays in the component. +// +// Resolution order (freshness → resilience): +// 1. sessionStorage cache (avoids re-hitting the API on same-session nav) +// 2. live GitHub API (always current) +// 3. build-time static snapshot /release-latest.json (survives API 403s, +// e.g. many corporate-NAT visitors sharing one rate-limited IP) +const REPO = 'huhamhire/code-meeseeks' +const API = `https://api.github.com/repos/${REPO}/releases/latest` +const FALLBACK = 'release-latest.json' +const CACHE_KEY = 'mb:latest-release' +const CLI_RE = /-(windows|darwin|linux)-(amd64|arm64)\.(zip|tar\.gz)$/i + +async function resolveRelease() { + try { + const cached = sessionStorage.getItem(CACHE_KEY) + if (cached) return JSON.parse(cached) + } catch { + /* sessionStorage unavailable — ignore */ + } + + let data = null + try { + const res = await fetch(API, { headers: { Accept: 'application/vnd.github+json' } }) + if (res.ok) data = await res.json() + } catch { + /* network / CORS — fall through to the static snapshot */ + } + + if (!data) { + const res = await fetch(withBase(`/${FALLBACK}`)) + if (!res.ok) throw new Error(`fallback ${res.status}`) + data = await res.json() + } + + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify(data)) + } catch { + /* ignore quota / private-mode errors */ + } + return data +} + +export function useLatestRelease() { + const state = ref('loading') // loading | ok | error + const release = ref(null) + + const desktop = computed(() => { + const a = release.value?.assets ?? [] + return { + windows: a.find((x) => x.name.endsWith('.exe')), + macos: a.find((x) => x.name.endsWith('.dmg')), + } + }) + + const cli = computed(() => { + const a = release.value?.assets ?? [] + return a + .filter((x) => x.name.startsWith('meebox-cli-') && CLI_RE.test(x.name)) + .map((x) => { + const m = x.name.match(CLI_RE) + return { ...x, goos: m[1].toLowerCase(), goarch: m[2].toLowerCase() } + }) + }) + + onMounted(async () => { + try { + release.value = await resolveRelease() + state.value = 'ok' + } catch { + state.value = 'error' + } + }) + + return { state, release, desktop, cli } +} diff --git a/website/.vitepress/theme/index.ts b/website/.vitepress/theme/index.ts new file mode 100644 index 00000000..ddc65f96 --- /dev/null +++ b/website/.vitepress/theme/index.ts @@ -0,0 +1,14 @@ +import DefaultTheme from 'vitepress/theme' +import DownloadPanel from './components/DownloadPanel.vue' +import HomeFeatures from './components/HomeFeatures.vue' +import ModelStrip from './components/ModelStrip.vue' +import './styles/index.scss' + +export default { + extends: DefaultTheme, + enhanceApp({ app }) { + app.component('DownloadPanel', DownloadPanel) + app.component('HomeFeatures', HomeFeatures) + app.component('ModelStrip', ModelStrip) + }, +} diff --git a/website/.vitepress/theme/os.js b/website/.vitepress/theme/os.js new file mode 100644 index 00000000..e253c620 --- /dev/null +++ b/website/.vitepress/theme/os.js @@ -0,0 +1,13 @@ +// Best-effort client-side OS detection, shared by download-page components. +// Returns: 'windows' | 'macos' | 'linux' | 'ios' | 'android' | 'unknown'. +export function detectOS() { + if (typeof navigator === 'undefined') return 'unknown' + const ua = navigator.userAgent || '' + const plat = (navigator.userAgentData?.platform || navigator.platform || '').toLowerCase() + if (plat.includes('win') || /Windows/i.test(ua)) return 'windows' + if (/iPhone|iPad|iPod/i.test(ua)) return 'ios' + if (plat.includes('mac') || /Mac OS X/i.test(ua)) return 'macos' + if (/Android/i.test(ua)) return 'android' + if (plat.includes('linux') || /Linux|X11/i.test(ua)) return 'linux' + return 'unknown' +} diff --git a/website/.vitepress/theme/styles/_tokens.scss b/website/.vitepress/theme/styles/_tokens.scss new file mode 100644 index 00000000..e156f020 --- /dev/null +++ b/website/.vitepress/theme/styles/_tokens.scss @@ -0,0 +1,14 @@ +// Brand color tokens — the single source of truth for the site's palette. +// VS Code blue, matching the desktop app's default theme +// (apps/desktop/src/renderer/src/styles/_palette.scss, $vscode-blue-*). +// Everything else (VitePress CSS vars, component styles) derives from these. + +// Light +$brand-primary-light: #005fb8; // vscode-blue-800 — solid buttons / badges +$brand-accent-light: #0e639c; // vscode-blue-700 — accent text / links +$brand-hover-light: #1177bb; // vscode-blue-600 — hover + +// Dark +$brand-primary-dark: #6cb6ff; // vscode-blue-300 — accent text / links on dark +$brand-accent-dark: #0e639c; // vscode-blue-700 — solid buttons on dark +$brand-hover-dark: #8cc6ff; // hover on dark diff --git a/website/.vitepress/theme/styles/background.scss b/website/.vitepress/theme/styles/background.scss new file mode 100644 index 00000000..e1bb8379 --- /dev/null +++ b/website/.vitepress/theme/styles/background.scss @@ -0,0 +1,39 @@ +// Subtle brand-tinted grid texture behind marketing pages (home / download / +// faq — those carry `pageClass: mb-grid`), so they aren't a flat fill. Masked +// with a radial fade: crisp near the top, gone lower down — never competes +// with body text. + +:root { + --mb-grid-line: rgba(60, 90, 150, 0.06); +} +.dark { + --mb-grid-line: rgba(160, 190, 255, 0.05); +} + +.mb-grid { + position: relative; + isolation: isolate; + + &::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + linear-gradient(to right, var(--mb-grid-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--mb-grid-line) 1px, transparent 1px); + background-size: 44px 44px; + // Crisp under the hero (top), faint through the middle, then deepening again + // toward the bottom so the grid never fully vanishes into the footer. + --mb-grid-fade: linear-gradient( + to bottom, + #000 0%, + #000 18%, + rgba(0, 0, 0, 0.07) 50%, + rgba(0, 0, 0, 0.4) 100% + ); + mask-image: var(--mb-grid-fade); + -webkit-mask-image: var(--mb-grid-fade); + } +} diff --git a/website/.vitepress/theme/styles/components.scss b/website/.vitepress/theme/styles/components.scss new file mode 100644 index 00000000..e617ea11 --- /dev/null +++ b/website/.vitepress/theme/styles/components.scss @@ -0,0 +1,395 @@ +// Page / component styles, extracted here for unified maintenance +// (moved out of DownloadPanel.vue's scoped block and the old custom.css). +// Class names are uniquely prefixed, so global scope is safe. + +// ── Screenshot frame (landing hero) ───────────────────────────── +.screenshot-frame { + // The mac-window capture bakes in its own outer frame margin. Break out of the + // content column (viewport-centered) so it can run wider than the text width. + position: relative; + left: 50%; + transform: translateX(-50%); + width: min(94vw, 1600px); + margin: 40px 0 0; + + img { + width: 100%; + height: auto; + // The PNG carries its own window chrome and a transparent (alpha) margin. + // drop-shadow follows the opaque pixels so the shadow hugs the window, + // instead of box-shadow ringing the padded bounds. No border/radius of ours. + filter: drop-shadow(0 16px 40px rgba(0, 0, 0, 0.3)); + } + + figcaption { + margin-top: 12px; + text-align: center; + font-size: 13px; + color: var(--vp-c-text-3); + } +} + +// Light/dark screenshot swap (default light; .dark on <html> flips it). +.mb-shot-dark { + display: none; +} +.dark .mb-shot-dark { + display: block; +} +.dark .mb-shot-light { + display: none; +} + +// ── Download panel ────────────────────────────────────────────── +.dl { + margin: 8px 0 4px; + + &-muted { + color: var(--vp-c-text-2); + } + + &-hint { + font-size: 13px; + margin-top: 8px; + } + + &-ico { + width: 1.05em; + height: 1.05em; + fill: currentColor; + flex: none; + vertical-align: -0.15em; + } + + &-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + &-tag { + font-weight: 600; + padding: 2px 10px; + border-radius: 12px; + background: var(--vp-c-brand-soft); + color: var(--vp-c-brand-1); + } + + &-link { + color: var(--vp-c-brand-1); + font-weight: 500; + } + + &-links { + margin: 6px 0 0; + } + + &-card { + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + padding: 20px; + background: var(--vp-c-bg-soft); + } + + &-tabs { + display: flex; + gap: 24px; + margin: 24px 0 4px; + border-bottom: 1px solid var(--vp-c-divider); + } + + &-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 2px; + font-weight: 600; + font-size: 15px; + color: var(--vp-c-text-2); + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--vp-c-text-1); + } + + &.active { + color: var(--vp-c-brand-1); + border-bottom-color: var(--vp-c-brand-1); + } + } + + &-cmd { + margin-top: 16px; + + &-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--vp-c-text-3); + margin-bottom: 8px; + } + + &-row { + display: flex; + align-items: stretch; + gap: 8px; + + code { + flex: 1; + overflow-x: auto; + white-space: nowrap; + padding: 10px 14px; + border-radius: 8px; + background: var(--vp-c-bg-alt); + border: 1px solid var(--vp-c-divider); + font-family: var(--vp-font-family-mono); + font-size: 13px; + color: var(--vp-c-text-1); + } + } + } + + &-copy { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--vp-c-brand-2); + background: none; + color: var(--vp-c-brand-1); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--vp-c-brand-soft); + } + } + + &-reco { + border-color: var(--vp-c-brand-3); + + &-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--vp-c-brand-1); + font-weight: 700; + margin-bottom: 12px; + } + } + + &-error { + border-color: var(--vp-c-warning-1, var(--vp-c-divider)); + } + + &-list { + list-style: none; + padding: 0; + margin: 12px 0 0; + display: flex; + flex-direction: column; + gap: 8px; + + li { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + border: 1px solid var(--vp-c-divider); + border-radius: 10px; + background: var(--vp-c-bg-soft); + } + } + + &-plat { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + min-width: 180px; + } + + &-file { + flex: 1 1 200px; + font-family: var(--vp-font-family-mono); + font-size: 12px; + color: var(--vp-c-text-3); + word-break: break-all; + } + + &-note { + margin: 14px 0 0; + + ul { + margin: 4px 0 0; + padding-left: 20px; + } + + li { + margin: 3px 0; + } + } + + &-btn { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--vp-c-brand-2); + color: var(--vp-c-brand-1); + font-weight: 600; + font-size: 14px; + text-decoration: none; + transition: all 0.2s; + + &:hover { + border-color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); + } + + &-brand { + background: var(--vp-c-brand-3); + border-color: var(--vp-c-brand-3); + color: #fff; + + // Keep the solid brand fill on hover (brand-2 is a light text-link shade — + // using it as a button bg makes white text unreadable in dark mode); just brighten. + &:hover { + background: var(--vp-c-brand-3); + border-color: var(--vp-c-brand-3); + color: #fff; + filter: brightness(1.12); + } + } + } + + &-size { + font-weight: 400; + font-size: 12px; + opacity: 0.8; + } +} + +// ── FAQ (expanded Q&A, no accordion) ──────────────────────────── +.faq { + margin-top: 8px; + + h3 { + margin-top: 36px; + border-top: none; + padding-top: 0; + font-size: 18px; + line-height: 1.5; + } + + h3:first-of-type { + margin-top: 12px; + } + + p { + margin: 12px 0; + line-height: 1.75; + color: var(--vp-c-text-2); + } +} + +// ── Override .vp-doc <a> defaults for panel buttons/links ──────── +// The panel renders inside .vp-doc, whose `a` rule (0,1,1) would otherwise +// win over our button classes — tinting the solid brand button's text blue +// (blue-on-blue) and underlining buttons. Out-specify it. +.vp-doc .dl { + a { + text-decoration: none; + } + + .dl-btn-brand, + .dl-btn-brand:hover { + color: #fff; + } +} + +// ── Loading skeleton (mirrors the loaded layout) ──────────────── +@keyframes dl-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +.dl-skel { + .dl-skel-tabs { + display: flex; + gap: 24px; + margin: 24px 0 12px; + } + + .sk { + display: block; + border-radius: 6px; + background: linear-gradient( + 90deg, + var(--vp-c-bg-soft) 25%, + var(--vp-c-bg-alt) 40%, + var(--vp-c-bg-soft) 55% + ); + background-size: 200% 100%; + animation: dl-shimmer 1.4s ease-in-out infinite; + } + + .sk-tag { + width: 150px; + height: 26px; + border-radius: 13px; + } + .sk-link { + width: 96px; + height: 18px; + } + .sk-label { + width: 200px; + height: 12px; + margin-bottom: 16px; + } + .sk-btn { + width: 260px; + max-width: 100%; + height: 40px; + border-radius: 8px; + margin-bottom: 18px; + } + .sk-note { + width: 100%; + height: 68px; + border-radius: 8px; + } + .sk-tab { + width: 130px; + height: 22px; + } + .sk-row { + width: 100%; + height: 66px; + border-radius: 10px; + margin-top: 8px; + } +} + diff --git a/website/.vitepress/theme/styles/features.scss b/website/.vitepress/theme/styles/features.scss new file mode 100644 index 00000000..7a2fb500 --- /dev/null +++ b/website/.vitepress/theme/styles/features.scss @@ -0,0 +1,69 @@ +// Landing feature cards (custom HomeFeatures component): a brand-colored line +// icon beside the title, with the description as a scannable bullet list below. +// Icons are monochrome Lucide SVGs (currentColor), consistent across platforms — +// unlike emoji. +// +// The component renders inside .vp-doc, whose `h3` / `ul` / `li` rules (0,1,1) +// would otherwise win over flat `.hf-*` classes and break sizing/alignment — so +// everything is nested under `.hf` (→ 0,2,0) to out-specify it. +.hf { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(288px, 1fr)); + gap: 16px; + margin: 24px 0 8px; + + .hf-card { + padding: 24px; + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + background: var(--vp-c-bg-soft); + transition: border-color 0.25s; + + &:hover { + border-color: var(--vp-c-brand-1); + } + } + + .hf-head { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 16px; + } + + // Icon sized to the title's line box so the two are equal height and truly + // center-align; display:block also drops the inline baseline gap. + .hf-icon { + display: inline-flex; + flex: none; + color: var(--vp-c-brand-1); + + svg { + display: block; + width: 22px; + height: 22px; + } + } + + .hf-title { + margin: 0; + padding: 0; + border: none; + font-size: 17px; + font-weight: 600; + line-height: 22px; + letter-spacing: normal; + } + + .hf-points { + margin: 0; + padding-left: 18px; + list-style: disc; + + li { + margin: 6px 0; + line-height: 1.6; + color: var(--vp-c-text-2); + } + } +} diff --git a/website/.vitepress/theme/styles/hero.scss b/website/.vitepress/theme/styles/hero.scss new file mode 100644 index 00000000..ff14885d --- /dev/null +++ b/website/.vitepress/theme/styles/hero.scss @@ -0,0 +1,49 @@ +// Icons for the hero action buttons (Download / View on GitHub). +// VitePress hero actions have no icon slot, so we inject one via a masked +// ::before that inherits the button's text color. Data-URI SVGs (default black +// fill) keep it base-path-safe — no asset URL to resolve. + +$icon-download: "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M11%204h2v6h3l-4%205-4-5h3zM5%2018h14v2H5z'/%3E%3C/svg%3E"; +$icon-github: "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Cpath%20d='M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E"; + +// Tighten the large default gap between the last home content (the screenshot) +// and the footer. +.VPHome { + padding-bottom: 32px; +} + +// Balance the hero headline / tagline line breaks so no lone word dangles. +.VPHero .text, +.VPHero .tagline { + text-wrap: balance; +} + +.VPHero .actions .VPButton { + display: inline-flex; + align-items: center; + gap: 8px; + + &::before { + content: ''; + width: 18px; + height: 18px; + flex: none; + background-color: currentColor; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + } + + &.brand::before { + mask-image: url("#{$icon-download}"); + -webkit-mask-image: url("#{$icon-download}"); + } + + &.alt::before { + mask-image: url("#{$icon-github}"); + -webkit-mask-image: url("#{$icon-github}"); + } +} diff --git a/website/.vitepress/theme/styles/index.scss b/website/.vitepress/theme/styles/index.scss new file mode 100644 index 00000000..fc06532c --- /dev/null +++ b/website/.vitepress/theme/styles/index.scss @@ -0,0 +1,8 @@ +// Theme style entry — brand vars first, then background + hero + component styles. +@use 'vars'; +@use 'background'; +@use 'nav'; +@use 'hero'; +@use 'features'; +@use 'models'; +@use 'components'; diff --git a/website/.vitepress/theme/styles/models.scss b/website/.vitepress/theme/styles/models.scss new file mode 100644 index 00000000..dd2e2c3c --- /dev/null +++ b/website/.vitepress/theme/styles/models.scss @@ -0,0 +1,44 @@ +// "Works with your models" provider strip below the hero screenshot. +// Monochrome brand marks (simple-icons), muted by default and brightening on +// hover, so the row reads as one calm system rather than a rainbow of logos. +.ms { + text-align: center; + margin: 56px auto 8px; + + &-label { + margin: 0 0 22px; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vp-c-text-3); + } + + &-logos { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 48px; + } + + &-logo { + display: inline-flex; + color: var(--vp-c-text-2); + opacity: 0.6; + transition: + opacity 0.2s, + color 0.2s; + + &:hover { + opacity: 1; + color: var(--vp-c-text-1); + } + + svg { + display: block; + width: 48px; + height: 48px; + } + } +} diff --git a/website/.vitepress/theme/styles/nav.scss b/website/.vitepress/theme/styles/nav.scss new file mode 100644 index 00000000..fb99a991 --- /dev/null +++ b/website/.vitepress/theme/styles/nav.scss @@ -0,0 +1,16 @@ +// Top nav: keep everything right-aligned (VitePress default), but put the page +// links (menu) to the LEFT of the search box, and keep the search compact +// instead of letting it fill the middle. Desktop only (≥768px, where the nav +// menu is visible) so the mobile search + hamburger layout is untouched. +// Right-side order becomes: [Guide · Download · FAQ] [Search] [theme · social]. +@media (min-width: 768px) { + .VPNavBar .menu { + order: -1; + margin-right: 24px; + } + + .VPNavBar .search { + flex-grow: 0; + padding-left: 0; + } +} diff --git a/website/.vitepress/theme/styles/vars.scss b/website/.vitepress/theme/styles/vars.scss new file mode 100644 index 00000000..c33d31e6 --- /dev/null +++ b/website/.vitepress/theme/styles/vars.scss @@ -0,0 +1,27 @@ +// Map brand tokens → VitePress theme CSS variables (light + dark). +// Keep the token → --vp-c-brand-* mapping here; never write raw brand hex elsewhere. +@use 'sass:color'; +@use 'tokens' as *; + +:root { + --vp-c-brand-1: #{$brand-accent-light}; + --vp-c-brand-2: #{$brand-hover-light}; + --vp-c-brand-3: #{$brand-primary-light}; + --vp-c-brand-soft: #{color.change($brand-hover-light, $alpha: 0.14)}; + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(120deg, #{$brand-primary-light} 20%, #{$brand-primary-dark}); + --vp-home-hero-image-background-image: linear-gradient( + 120deg, + #{color.change($brand-accent-light, $alpha: 0.28)}, + #{color.change($brand-primary-dark, $alpha: 0.28)} + ); + --vp-home-hero-image-filter: blur(48px); +} + +.dark { + --vp-c-brand-1: #{$brand-primary-dark}; + --vp-c-brand-2: #{$brand-hover-dark}; + --vp-c-brand-3: #{$brand-accent-dark}; + --vp-c-brand-soft: #{color.change($brand-primary-dark, $alpha: 0.16)}; +} diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..ed3c5997 --- /dev/null +++ b/website/README.md @@ -0,0 +1,41 @@ +# Code Meeseeks Website + +Brand website for Code Meeseeks, built with [VitePress](https://vitepress.dev/). English is the default locale; Simplified Chinese lives under `/zh/`. + +This is a **standalone sub-project** — it is not part of the npm workspace or Nx (the `workspaces` globs only cover `apps/*` and `packages/*`), so it keeps its own `package.json` / lockfile and is built and deployed on its own, like `cli/`. + +## Local development + +```bash +cd website +npm install +npm run dev # local dev server with hot reload +npm run build # production build → .vitepress/dist +npm run preview # preview the production build +``` + +Node >= 20 (22 recommended), same as the rest of the repo. + +## Structure + +``` +website/ +├── .vitepress/config.ts # site config + i18n (en root, /zh/) +├── index.md # English landing (home layout) +├── zh/index.md # Chinese landing +└── public/ # static assets (favicon, images) +``` + +## Deployment + +Deployed to GitHub Pages via [`.github/workflows/pages.yml`](../.github/workflows/pages.yml): +build runs on every PR touching `website/**` or `docs/**` (as a check), and deploy runs on push to `master`. The workflow is decoupled from the `v*` release pipeline. + +The site serves under `/code-meeseeks/` by default (GitHub Pages project site). When a custom domain is configured, set `SITE_BASE=/` for the build. + +## Content sync + +Per the [Roadmap](../docs/ROADMAP.md) content-sync convention, each kind of content has a single source of truth: + +- **Positioning / features** — source of truth is the repo README; the landing page paraphrases at marketing altitude, it does not copy verbatim. +- **User guide** — source of truth is `docs/guide/` (bilingual: English canonical at the root, Chinese mirror under `zh-CN/`). The site builds both locales from there via `scripts/sync-docs.mjs` (EN → `/guide/`, ZH → `/zh/guide/`) rather than keeping a second copy. diff --git a/website/download.md b/website/download.md new file mode 100644 index 00000000..a49eaddb --- /dev/null +++ b/website/download.md @@ -0,0 +1,10 @@ +--- +title: Download +aside: false +outline: false +pageClass: mb-grid +--- + +# Download + +<DownloadPanel /> diff --git a/website/faq.md b/website/faq.md new file mode 100644 index 00000000..fd82bd46 --- /dev/null +++ b/website/faq.md @@ -0,0 +1,50 @@ +--- +title: FAQ +aside: false +outline: false +pageClass: mb-grid +--- + +# FAQ + +<div class="faq"> + +### 🔒 Does my code leave my machine? + +Only what you configure. The PR diff and your rules go to the LLM provider you set up, and Code Meeseeks talks to the Git platform you connect — nothing else is reported. + +Wire up a local model (e.g. local Ollama) and nothing ever leaves your machine. + +### 📦 Do I need to install Python or Docker? + +No. pr-agent and its Python runtime are embedded in the installer — it works right after installation. + +### 🧩 Which platforms and models are supported? + +Platforms: GitHub (incl. Enterprise Server), Bitbucket Server / Data Center, and GitLab (incl. Self-Managed). + +Models: any OpenAI-compatible / litellm-supported provider — OpenAI, Anthropic, DeepSeek, and more — or a local model. + +### 🤖 Is this a CI review bot? + +No. Code Meeseeks is a desktop tool for the individual reviewer — every comment is confirmed or edited by you before it is published. + +Automated in-CI review is what pr-agent itself is for. + +### 💸 What about token cost? + +Agentic review and AutoPilot chain several model calls per PR, so token use is higher than a single manual review. + +Per-step usage is shown on the review timeline so you can watch it, whether you use a pay-as-you-go API or a local CLI / subscription account. + +### ⚖️ Is it free and open source? + +Yes — licensed under Apache-2.0. The source is public, auditable, and buildable yourself. + +### 🎬 Is this affiliated with Rick and Morty? + +No. Code Meeseeks is an unofficial, independent open-source tool — not affiliated with, authorized by, or endorsed by _Rick and Morty_ or its rights holders. + +Names and characters such as "Rick and Morty" and "Mr. Meeseeks" are trademarks of their respective owners (Adult Swim / Warner Bros. Discovery). The project's name and icon are an homage only. + +</div> diff --git a/website/index.md b/website/index.md new file mode 100644 index 00000000..b3c1b39d --- /dev/null +++ b/website/index.md @@ -0,0 +1,28 @@ +--- +layout: home +pageClass: mb-grid + +hero: + name: Code Meeseeks + text: AI code review, on your terms + tagline: 'A local, semi-automated AI code-review desktop client for the individual reviewer.<br>Human decides, rules stay local, data stays local.' + image: + src: /logo.png + alt: Code Meeseeks + actions: + - theme: brand + text: Download + link: /download + - theme: alt + text: View on GitHub + link: https://github.com/huhamhire/code-meeseeks +--- + +<HomeFeatures /> + +<figure class="screenshot-frame"> + <img class="mb-shot mb-shot-light" src="/screenshot.light.png" alt="Code Meeseeks — reviewing a pull request" /> + <img class="mb-shot mb-shot-dark" src="/screenshot.dark.png" alt="Code Meeseeks — reviewing a pull request" /> +</figure> + +<ModelStrip /> diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..dc567401 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,2941 @@ +{ + "name": "code-meeseeks-website", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-meeseeks-website", + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "sass": "^1.83.4", + "vitepress": "^1.6.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.1.tgz", + "integrity": "sha512-Wia5/mNTfiU0PIUN25UMfAGGdASkkwuCS9nBAdmhqrNPY/ff7U/6MgBVdwFDPsa3sA1msutPtO50gvOzx6MOXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.1.tgz", + "integrity": "sha512-miW8RzAtBgNiEJ9fGEhsOPgWUpekAe64YcVufqXrlykj0Jjmo5nj0a5f/HAzRVX5ZuU1GAVd7BkzFDx7q50P3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.1.tgz", + "integrity": "sha512-eR3J3kB9JX6DdCvDRi3I4KPfwO6fR9HWYRXhVke2TXIoOQafMKCRAneg33JRmIrb+DnnJ/eWApJLF1O1CLPERg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.1.tgz", + "integrity": "sha512-P5ak7EurwYqgAiDyb95mgA3WRR/Zu8CPMv36lWTISvL2AmlPyqQPy2nX/KEJRTcwaeTWwrk6wJV4/M93GfjOWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.1.tgz", + "integrity": "sha512-OVtj9uA//+pjvKQI5INnzbyLrf3ClNv3XRbWswwJ2kHIStQNHtBfHo+LofNB/WhM9xjuXlW5ANn2aMj65UGx7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.1.tgz", + "integrity": "sha512-oKlVFlp+qbIEe4p7E54zSiP2gEV/vDu972Ykv8VDMFwEvreS7m0YKA3a8hGGHwc7yiBUGGiR3LlwzMLfnJmy6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.1.tgz", + "integrity": "sha512-BOVrld6vdtsFmotVDMTVQfYXwrVplJ+DUvy60JFi+tkWV698q2J9NNPKEO3dr5qxtSLKQP4vHF8n+3U5PDWhOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.1.tgz", + "integrity": "sha512-GAqHl9zERhC3bbBfubwUu07G3UXO06gORvOcsiTBZB3et0s3auNUbHlYdYNp4VKa3sUZqH5AcD3OKzU/KDGXjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.1.tgz", + "integrity": "sha512-BXZw+C+gsWL7pZvbnhJUnCXASiDLGcQxVV7h55Pyh2DmSzwdZIVccE5xc9RVD2trtrhIqk5smuODTxtaZqd0IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.1.tgz", + "integrity": "sha512-9g/ceZrZTqA62FA3588Xj0onRPjDNfu0pVQqefK0rrHp9H6Wblph/YmzGjZ2g8uqbTh0ZGIvAGCzErU8f7MHpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.1.tgz", + "integrity": "sha512-cZTIrGyAP+W4A6jDVwvWM/JOaoJKQkD/2a5eLUEeNdKAD45jN7BCpsMDONyhZlosLa4UwL8uiINQzj4iFy9nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.1.tgz", + "integrity": "sha512-N6I3leW0UO8Y9Zv90yo2UHgYGuxZO0mjbvzNxDIJDjO0qECEF7Z9XMvSNeUWXQh/iNDA9lr8MfEy3rmZGIcclw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.1.tgz", + "integrity": "sha512-ukU5zeeFs44rQkzv+TRdYard+d+3lmPGs8lPZhHtWE8rfz+LlBSF6s9kP3VQ7LeOYL8Dz0u6tZfnyTrqrumbHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.1.tgz", + "integrity": "sha512-lCwXyijwPm3vbYHpBXPRomMcD6mgiptmps27gnMCf4HK+u/AOeFPBnIFh4V3l4A5SnP9VRiKBZqwGBpUH0vaTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.88", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.88.tgz", + "integrity": "sha512-+cvi1qCuvReL29ehi6t62L4fb7GDXe+UlGHFcsJcV7I2l9wtqn9XE2IBKcDr3CI5iGUGS5ISnXv699pSGpyx1Q==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.1.tgz", + "integrity": "sha512-FyaFnnsbVPtevQwqSj/SdxE3jAsSsY0BEH8IVLf9rXxEBdAhAmT6VKCVSMWoaPIHVN1Eufh/1w8q6k8URpIkWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.21.1", + "@algolia/client-abtesting": "5.55.1", + "@algolia/client-analytics": "5.55.1", + "@algolia/client-common": "5.55.1", + "@algolia/client-insights": "5.55.1", + "@algolia/client-personalization": "5.55.1", + "@algolia/client-query-suggestions": "5.55.1", + "@algolia/client-search": "5.55.1", + "@algolia/ingestion": "1.55.1", + "@algolia/monitoring": "1.55.1", + "@algolia/recommend": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", + "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 00000000..cb2dc9b6 --- /dev/null +++ b/website/package.json @@ -0,0 +1,19 @@ +{ + "name": "code-meeseeks-website", + "version": "0.0.0", + "private": true, + "description": "Code Meeseeks brand website (VitePress). Standalone sub-project, not part of the npm workspace / Nx.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "sync": "node scripts/sync-docs.mjs && node scripts/sync-assets.mjs", + "fetch-release": "node scripts/fetch-release.mjs", + "dev": "npm run sync && vitepress dev", + "build": "npm run sync && vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "sass": "^1.83.4", + "vitepress": "^1.6.4" + } +} diff --git a/website/public/favicon.ico b/website/public/favicon.ico new file mode 100644 index 00000000..e8499ad4 --- /dev/null +++ b/website/public/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fce064da083d33e581528e868b2800ebcc4ba5b1807fb64de8d81594997036e5 +size 285478 diff --git a/website/public/logo.png b/website/public/logo.png new file mode 100644 index 00000000..7d6bc805 --- /dev/null +++ b/website/public/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72cb1904c11a1cb51bcded880a1ec088164f9300f6b322cbf6224ed55af1af63 +size 266542 diff --git a/website/scripts/fetch-release.mjs b/website/scripts/fetch-release.mjs new file mode 100644 index 00000000..1c626737 --- /dev/null +++ b/website/scripts/fetch-release.mjs @@ -0,0 +1,43 @@ +// Build-time snapshot of the latest GitHub release, written to +// public/release-latest.json (gitignored). The download panel prefers the live +// API for freshness but falls back to this static file when the unauthenticated +// API is rate-limited (403) — e.g. many corporate-NAT visitors sharing one IP. +// +// Uses GITHUB_TOKEN when present (CI passes the Actions token → 1000 req/h), +// and never fails the build: if the fetch can't complete, the panel just relies +// on the live API as before. + +import { writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' + +const REPO = 'huhamhire/code-meeseeks' +const API = `https://api.github.com/repos/${REPO}/releases/latest` +const OUT = path.resolve(process.cwd(), 'public/release-latest.json') + +async function main() { + const headers = { Accept: 'application/vnd.github+json' } + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}` + + try { + const res = await fetch(API, { headers }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + // Keep only the fields the panel reads. + const slim = { + tag_name: data.tag_name, + html_url: data.html_url, + assets: (data.assets ?? []).map((a) => ({ + name: a.name, + size: a.size, + browser_download_url: a.browser_download_url, + })), + } + await mkdir(path.dirname(OUT), { recursive: true }) + await writeFile(OUT, JSON.stringify(slim), 'utf8') + console.log(`[fetch-release] wrote fallback for ${slim.tag_name} (${slim.assets.length} assets)`) + } catch (err) { + console.warn(`[fetch-release] skipped (${err.message}); panel will rely on the live API`) + } +} + +main() diff --git a/website/scripts/sync-assets.mjs b/website/scripts/sync-assets.mjs new file mode 100644 index 00000000..32ba011b --- /dev/null +++ b/website/scripts/sync-assets.mjs @@ -0,0 +1,25 @@ +// Copy shared screenshots into the VitePress public dir at build time. +// Single source of truth is the repo's assets/images/; the public/ copies are +// gitignored (never committed twice). VitePress can only serve /-rooted assets +// from its own public/, hence the copy rather than a cross-dir reference. + +import { copyFile, mkdir } from 'node:fs/promises' +import path from 'node:path' + +const CWD = process.cwd() // website/ +const SRC = path.resolve(CWD, '../assets/images') +const DEST = path.resolve(CWD, 'public') +const FILES = ['screenshot.light.png', 'screenshot.dark.png'] + +async function main() { + await mkdir(DEST, { recursive: true }) + for (const f of FILES) { + await copyFile(path.join(SRC, f), path.join(DEST, f)) + } + console.log(`[sync-assets] copied ${FILES.length} screenshot(s) → public/`) +} + +main().catch((err) => { + console.error('[sync-assets] failed:', err) + process.exit(1) +}) diff --git a/website/scripts/sync-docs.mjs b/website/scripts/sync-docs.mjs new file mode 100644 index 00000000..6b9643fc --- /dev/null +++ b/website/scripts/sync-docs.mjs @@ -0,0 +1,129 @@ +// Sync the user guide + changelog from the repo into the VitePress site (both locales). +// +// The repo is the single source of truth; this copies content into the site at +// dev/build time (the destinations are gitignored — never edit them by hand): +// docs/guide/*.md (English, canonical) → guide/ (EN root locale) +// docs/guide/zh-CN/*.md (Chinese) → zh/guide/ (zh locale) +// CHANGELOG.md (English, canonical) → changelog.md (EN root locale) +// CHANGELOG.zh-CN.md (Chinese) → zh/changelog.md (zh locale) +// Links that escape the guide (e.g. ../arch/, ../../README, or CHANGELOG's LICENSE) +// point at pages the site does not host, so they are rewritten to absolute GitHub +// URLs; intra-guide links stay relative for VitePress to resolve. The per-file +// language switcher (a "**English** · [简体中文]" line) is stripped — the site has +// its own locale menu, and the switcher's cross-locale relative links don't map +// onto the site. + +import { readdir, readFile, writeFile, rm, mkdir } from 'node:fs/promises' +import path from 'node:path' + +const CWD = process.cwd() // website/ +const REPO_BLOB = 'https://github.com/huhamhire/code-meeseeks/blob/master' + +// One sync unit: source guide dir, where it lives in the repo (for resolving +// escaping links), and the destination dir under the site. +const LOCALES = [ + { src: path.resolve(CWD, '../docs/guide'), repoDir: 'docs/guide', dest: path.resolve(CWD, 'guide') }, + { + src: path.resolve(CWD, '../docs/guide/zh-CN'), + repoDir: 'docs/guide/zh-CN', + dest: path.resolve(CWD, 'zh/guide'), + }, +] + +// Standalone top-level docs synced from the repo root (single source of truth). +// repoDir '.' resolves their escaping links (e.g. CHANGELOG's LICENSE) against the +// repo root, so they rewrite to absolute GitHub URLs. +const SINGLES = [ + { src: path.resolve(CWD, '../CHANGELOG.md'), repoDir: '.', dest: path.resolve(CWD, 'changelog.md') }, + { + src: path.resolve(CWD, '../CHANGELOG.zh-CN.md'), + repoDir: '.', + dest: path.resolve(CWD, 'zh/changelog.md'), + }, +] + +// Matches the per-file language switcher line, both directions. +const SWITCHER_RE = /^(?:\*\*English\*\*|\[English\]\()[^\n]*(?:简体中文)[^\n]*$/ + +// Rewrite a single markdown link target, resolving relative links against the +// guide's location in the repo (repoDir). +function rewriteTarget(target, repoDir) { + if (/^(https?:)?\/\//.test(target) || target.startsWith('#') || target.startsWith('/')) { + return target + } + const [rel, hash] = target.split('#') + if (!rel) return target // pure anchor + const resolved = path.posix.normalize(path.posix.join(repoDir, rel)) + if (resolved.startsWith('docs/guide/')) { + return target // stays inside the guide → keep relative + } + // Escapes the guide → link to the file on GitHub. + return `${REPO_BLOB}/${resolved}${hash ? '#' + hash : ''}` +} + +function rewriteLinks(md, repoDir) { + return md.replace(/\]\(([^)]+)\)/g, (_m, target) => `](${rewriteTarget(target, repoDir)})`) +} + +// Give changelog version headings a stable, date-independent anchor so release +// notes can deep-link to a version: `## [0.10.0] - 2026-07-05` gains a trailing +// `{#v0-10-0}` (VitePress custom-anchor syntax). GitHub's CHANGELOG stays clean — +// the anchor lives only in the synced site copy. release.yml builds the same slug +// from the tag (`v` + version, dots → hyphens). `## [Unreleased]` (non-numeric) is +// left untouched. +function addVersionAnchors(md) { + return md.replace( + /^(## \[)(\d[^\]]*)(\][^\n]*)$/gm, + (_line, open, version, rest) => `${open}${version}${rest} {#v${version.replace(/\./g, '-')}}`, + ) +} + +// Drop the language-switcher line (and a single adjacent blank line so we don't +// leave a stray gap under the H1). +function stripSwitcher(md) { + const lines = md.split('\n') + const i = lines.findIndex((l) => SWITCHER_RE.test(l.trim())) + if (i === -1) return md + lines.splice(i, 1) + if (lines[i] === '' && lines[i - 1] === '') lines.splice(i, 1) + return lines.join('\n') +} + +async function syncLocale({ src, repoDir, dest }) { + await rm(dest, { recursive: true, force: true }) + await mkdir(dest, { recursive: true }) + + const entries = await readdir(src, { withFileTypes: true }) + let count = 0 + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue // skip zh-CN/ subdir on the EN pass + const raw = await readFile(path.join(src, entry.name), 'utf8') + const md = rewriteLinks(stripSwitcher(raw), repoDir) + // README.md is the guide index → index.md + const outName = entry.name === 'README.md' ? 'index.md' : entry.name + await writeFile(path.join(dest, outName), md, 'utf8') + count++ + } + console.log(`[sync-docs] copied ${count} guide file(s) → ${path.relative(CWD, dest)}/`) +} + +// Sync one standalone file (the CHANGELOGs): strip the switcher, rewrite escaping +// links, add stable version anchors, write to the destination page (creating the +// parent dir if needed). +async function syncSingle({ src, repoDir, dest }) { + const raw = await readFile(src, 'utf8') + const md = addVersionAnchors(rewriteLinks(stripSwitcher(raw), repoDir)) + await mkdir(path.dirname(dest), { recursive: true }) + await writeFile(dest, md, 'utf8') + console.log(`[sync-docs] copied ${path.basename(src)} → ${path.relative(CWD, dest)}`) +} + +async function main() { + for (const locale of LOCALES) await syncLocale(locale) + for (const single of SINGLES) await syncSingle(single) +} + +main().catch((err) => { + console.error('[sync-docs] failed:', err) + process.exit(1) +}) diff --git a/website/zh/download.md b/website/zh/download.md new file mode 100644 index 00000000..914219e8 --- /dev/null +++ b/website/zh/download.md @@ -0,0 +1,10 @@ +--- +title: 下载 +aside: false +outline: false +pageClass: mb-grid +--- + +# 下载 + +<DownloadPanel /> diff --git a/website/zh/faq.md b/website/zh/faq.md new file mode 100644 index 00000000..ceb6ba6e --- /dev/null +++ b/website/zh/faq.md @@ -0,0 +1,50 @@ +--- +title: 常见问题 +aside: false +outline: false +pageClass: mb-grid +--- + +# 常见问题 + +<div class="faq"> + +### 🔒 我的代码会离开本机吗? + +只有你配置的内容会外发。PR diff 与你的规则发给你自行配置的 LLM 服务商,Code Meeseeks 只与你接入的 Git 平台通信——此外不上报任何数据。 + +接入本地模型(如本地 Ollama)即可全程不出本机。 + +### 📦 需要额外装 Python 或 Docker 吗? + +不需要。pr-agent 及其 Python 运行时已内嵌进安装包,装完即用。 + +### 🧩 支持哪些平台与模型? + +平台:GitHub(含 Enterprise Server)、Bitbucket Server / Data Center、GitLab(含 Self-Managed)。 + +模型:任意 OpenAI 兼容 / litellm 支持的服务商——OpenAI、Anthropic、DeepSeek 等,或本地模型。 + +### 🤖 这是 CI 里的评审 bot 吗? + +不是。Code Meeseeks 是面向 Reviewer 个人的桌面工具——每条评论都需你二次确认 / 编辑后才会发布。 + +CI 中自动评审是 pr-agent 本身的定位。 + +### 💸 token 成本如何? + +Agentic 评审与 AutoPilot 会对每个 PR 串联多次模型调用,token 消耗高于单次手动评审。 + +每步用量都在评审时间线上分步展示,便于观察——无论用按量计费 API 还是本地 CLI / 订阅账户。 + +### ⚖️ 是免费开源的吗? + +是,采用 Apache-2.0 许可。源码完全公开、可自行审计与构建。 + +### 🎬 与《瑞克和莫蒂》有关联吗? + +没有。Code Meeseeks 是非官方、独立的开源工具——与《_Rick and Morty_》及其权利方无任何关联,亦未获授权或认可。 + +「Rick and Morty」「Mr. Meeseeks」等名称与角色的商标归各自权利人所有(Adult Swim / Warner Bros. Discovery)。本项目名称与图标仅为致敬。 + +</div> diff --git a/website/zh/index.md b/website/zh/index.md new file mode 100644 index 00000000..908f9ef8 --- /dev/null +++ b/website/zh/index.md @@ -0,0 +1,28 @@ +--- +layout: home +pageClass: mb-grid + +hero: + name: Code Meeseeks + text: AI 代码评审,决策权在你 + tagline: '面向 Reviewer 个人的本地化、半自动 AI 代码评审桌面客户端。<br>决策权在人、规则在本地、数据在本地。' + image: + src: /logo.png + alt: Code Meeseeks + actions: + - theme: brand + text: 下载 + link: /zh/download + - theme: alt + text: 在 GitHub 查看 + link: https://github.com/huhamhire/code-meeseeks +--- + +<HomeFeatures /> + +<figure class="screenshot-frame"> + <img class="mb-shot mb-shot-light" src="/screenshot.light.png" alt="Code Meeseeks 界面预览" /> + <img class="mb-shot mb-shot-dark" src="/screenshot.dark.png" alt="Code Meeseeks 界面预览" /> +</figure> + +<ModelStrip />