diff --git a/.env.example b/.env.example index 27fa7ebcd..299130bff 100644 --- a/.env.example +++ b/.env.example @@ -43,4 +43,5 @@ NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn MISE_INSTALL_URL=https://mise.run -OPENCODE_INSTALL_URL=https://opencode.ai/install +# opencode 版本(本 UI 只支持 V2,不要改成 v1.x)。改这里即可整体升级/降级后端版本。 +OPENCODE_VERSION=2.0.19 diff --git a/.prettierignore b/.prettierignore index 7397700f6..6826b59d6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ dist node_modules public/material-icons src-tauri/target +# cargo check / tauri-build 生成的 schema(src-tauri/.gitignore 已忽略,非源码) +src-tauri/gen/schemas coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1a1b372..2b38aee18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -364,7 +364,7 @@ - fix(settings): apply mobile viewport height to config editor (d442e66) - fix(titlebar): preserve decorum window controls (1892621) - fix: remove touchAction:none from FileTreeItem button that broke mobile scrolling (a619d72) -- fix: replace 92vh with calc(var(--app-height) * 0.92) in mobile SettingsDialog to fix address bar hiding issue (f3558fc) +- fix: replace 92vh with calc(var(--app-height) \* 0.92) in mobile SettingsDialog to fix address bar hiding issue (f3558fc) ## [v0.6.16] - 2026-06-07 @@ -630,7 +630,7 @@ ## [v0.5.5] - 2026-04-18 -- fix: remove redundant *Single i18n keys — let i18next handle count=1 natively (9385ef8) +- fix: remove redundant \*Single i18n keys — let i18next handle count=1 natively (9385ef8) - style: match pane drop highlight radius to pane shell (rounded-lg) (f8acac2) - feat: enable drag-to-split on active session list items (ac20b7b) diff --git a/README.md b/README.md index 7cc9e5a71..fab8cd2d2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ ## 快速体验 +> ⚠️ **需要 OpenCode v2**(如 `v2.0.19`)。**本 UI 只支持 V2,不支持 v1.x** —— +> 如果后端还是 v1,界面能打开但一发消息就会失败。 +> 用 `opencode --version` 确认;输出的版本号应以 `v2.` 开头。 + 无需部署,在本地启动 OpenCode 后端后直接访问托管版前端: ```bash @@ -83,7 +87,9 @@ BACKEND_URL=your-server.com:4096 PORT=8080 docker compose -f docker-compose.stan 若你使用自定义 Caddyfile(或遇到 401 认证失败),在 `reverse_proxy` 块中添加: ```caddyfile -handle_path /api/* { +# 🔴 OpenCode V2:用 handle(不要用 handle_path)——V2 的端点本身带 /api 前缀, +# handle_path 会削掉它,请求会打到不存在的路径上(后端只返回 SPA 兜底 HTML)。 +handle /api/* { reverse_proxy your-opencode-serve:4096 { header_up Host {upstream_hostport} header_up Authorization {http.request.header.Authorization} @@ -314,7 +320,8 @@ preview.example.com { ## 本地开发 -需要一个运行中的 [OpenCode](https://github.com/anomalyco/opencode) 后端。 +需要一个运行中的 [OpenCode](https://github.com/anomalyco/opencode) **v2** 后端 +(**本 UI 只支持 V2,不支持 v1.x**;用 `opencode --version` 确认版本号以 `v2.` 开头)。 ```bash opencode serve diff --git a/README_EN.md b/README_EN.md index 3ae97aed9..00ae8b004 100644 --- a/README_EN.md +++ b/README_EN.md @@ -43,6 +43,10 @@ A third-party Web frontend for [OpenCode](https://github.com/anomalyco/opencode) ## Quick Start +> ⚠️ **OpenCode v2 required** (e.g. `v2.0.19`). **This UI only supports V2 and does not work with v1.x** — +> if the backend is still v1, the UI will load but sending a message will fail. +> Run `opencode --version` to check; the version number should start with `v2.`. + No deployment needed — after starting the OpenCode backend locally, access the hosted frontend directly: ```bash @@ -83,7 +87,10 @@ After you enter the username/password in the frontend's server connection dialog If you use a custom Caddyfile (or hit 401 auth failures), add this to the `reverse_proxy` block: ```caddyfile -handle_path /api/* { +# 🔴 OpenCode V2: use `handle` (NOT `handle_path`) — V2 endpoints already include +# the /api prefix; handle_path strips it and requests hit non-existent paths +# (the backend returns the SPA fallback HTML instead of JSON). +handle /api/* { reverse_proxy your-opencode-serve:4096 { header_up Host {upstream_hostport} header_up Authorization {http.request.header.Authorization} @@ -263,7 +270,8 @@ preview.example.com { ## Local Development -Requires a running [OpenCode](https://github.com/anomalyco/opencode) backend. +Requires a running [OpenCode](https://github.com/anomalyco/opencode) **v2** backend +(**this UI only supports V2 and does not work with v1.x**; run `opencode --version` and make sure it starts with `v2.`). ```bash opencode serve diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 2ffdc0961..9d78f4f5e 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -28,4 +28,5 @@ services: NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} NODEJS_ORG_MIRROR: ${NODEJS_ORG_MIRROR:-https://npmmirror.com/mirrors/node} MISE_INSTALL_URL: ${MISE_INSTALL_URL:-https://mise.run} - OPENCODE_INSTALL_URL: ${OPENCODE_INSTALL_URL:-https://opencode.ai/install} + # opencode 版本(本 UI 只支持 V2)。Dockerfile 用它拼下载地址并校验哈希。 + OPENCODE_VERSION: ${OPENCODE_VERSION:-2.0.19} diff --git a/docker/Caddyfile.gateway b/docker/Caddyfile.gateway index a73ee0ffe..01c2d1ef6 100644 --- a/docker/Caddyfile.gateway +++ b/docker/Caddyfile.gateway @@ -5,8 +5,11 @@ # ---- 主入口 :6658 — OpenCode 应用 ---- :6658 { # API(SSE + WebSocket 自动处理) - handle_path /api/* { + # 🔴 OpenCode V2:端点带 /api 前缀 → 原样透传,不能削(见 Caddyfile.standalone 注释)。 + # 同时显式透传 Authorization:后端开启 OPENCODE_SERVER_PASSWORD 时必需(否则全部 401)。 + handle /api/* { reverse_proxy opencode-backend:4096 { + header_up Authorization {http.request.header.Authorization} flush_interval -1 } } diff --git a/docker/Caddyfile.standalone b/docker/Caddyfile.standalone index 20fee6142..fe8709a9f 100644 --- a/docker/Caddyfile.standalone +++ b/docker/Caddyfile.standalone @@ -2,8 +2,11 @@ # 静态文件 + API 反代到外部 opencode serve :3000 { - # API 反代(strip /api 前缀,SSE 需要 flush_interval -1) - handle_path /api/* { + # API 反代(SSE 需要 flush_interval -1) + # 🔴 OpenCode V2:端点本身带 /api 前缀(/api/session、/api/info…)→ 必须原样透传。 + # V1 时代用 `handle_path` 削掉 /api 才能命中后端;V2 这么做会把请求打到不存在的 + # 路径上,后端只返回 SPA 兜底 HTML(前端拿 HTML 当 JSON 解析 → 整条链路坏)。 + handle /api/* { reverse_proxy {$BACKEND_URL:host.docker.internal:4096} { header_up Host {upstream_hostport} # 透传客户端 Authorization 头(后端开启 OPENCODE_SERVER_PASSWORD 认证时必需) diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 06cafc369..b0da5b3a3 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -14,6 +14,9 @@ ARG APT_SECURITY_MIRROR=http://mirrors.tuna.tsinghua.edu.cn/debian-security ARG NPM_CONFIG_REGISTRY=https://registry.npmmirror.com ARG NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node ARG MISE_INSTALL_URL=https://mise.run +# 🔴 opencode 版本**必须钉死**:本 UI 只支持 V2,装成 V1 会直接跑不起来。 +# 升级步骤:改这里的默认值 + 重算下方 amd64/arm64 两个 OC_SHA256(见安装段的注释)。 +ARG OPENCODE_VERSION=2.0.19 ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ @@ -89,17 +92,35 @@ RUN case "${TARGETARCH}" in \ | MISE_INSTALL_PATH=/usr/local/bin/mise MISE_INSTALL_FROM_GITHUB=1 MISE_INSTALL_ARCH="${MISE_ARCH}" sh \ && mise --version -# 安装 opencode。直接从 GitHub Releases 拉 tarball 解压,绕开 opencode.ai/install 脚本: -# 该脚本为拼一个版本号字符串去调 api.github.com(未鉴权,60 次/小时/IP), -# GitHub Actions 共享 runner IP 极易触发限流而 exit 1;而 releases/latest/download -# 本身是 302 跳转,不需要版本号。tarball 内仅含单个 opencode 二进制。 +# 安装 opencode(**钉死版本 + 校验哈希**)。 +# +# 🔴 为什么必须换渠道(阶段 4 实测结论): +# GitHub Releases 的 `latest` 已经停在 **v1.18.33(V1)** —— 全仓库 500 个 release 里 +# 零个 v2,`v2.0.x` 也没有 release 资源(tarball 直链 404)。npm 的 `opencode-ai` +# 同样没有 v2。也就是说:**照旧写法拉 `releases/latest/download` 一定装成 V1**, +# 而本 UI 只支持 V2 → 容器起来后必然连不上/报错。 +# +# V2 的真实分发渠道是官方更新服务 `update.opencode.ai` 指向的静态文件服务: +# https://opencode.ai/files/bin/<版本>/opencode-linux-<架构>.tar.gz +# (版本索引:`https://update.opencode.ai/api/dev`(v2 分支快照)/ `/api/beta`) +# tarball 内仍是**单个 `opencode` 文件**,解压命令与旧 GitHub tarball 一致。 +# +# ✅ 顺带收益:新渠道不经过 GitHub,**彻底摆脱 GitHub 限流**(旧注释里 +# 「共享 runner IP 极易触发 60 次/小时 限流」的问题不复存在)。 +# +# 🔒 sha256 校验:防止上游文件被替换 / 下载被中间人篡改 / 半截包。 +# 升级版本时:① 改 ARG OPENCODE_VERSION;② 用 +# `curl -fsSL -o t.tar.gz <新URL> && sha256sum t.tar.gz` 重算两个架构的哈希。 RUN case "${TARGETARCH}" in \ - amd64) OC_ARCH=x64 ;; \ - arm64) OC_ARCH=arm64 ;; \ + amd64) OC_ARCH=x64 \ + OC_SHA256=1a9f7184292035a56b6cf22439762930b3a3ed13812f7360c33bb7ae07ef4075 ;; \ + arm64) OC_ARCH=arm64 \ + OC_SHA256=d0138dd9b43910c28166cfc4da4a53a1b99b07931a37afa749bde812cd02e5a4 ;; \ *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ esac \ && curl -fsSL -o /tmp/opencode.tar.gz \ - "https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-${OC_ARCH}.tar.gz" \ + "https://opencode.ai/files/bin/${OPENCODE_VERSION}/opencode-linux-${OC_ARCH}.tar.gz" \ + && echo "${OC_SHA256} /tmp/opencode.tar.gz" | sha256sum -c - \ && tar -xzf /tmp/opencode.tar.gz -C /usr/local/bin opencode \ && rm -f /tmp/opencode.tar.gz \ && chmod +x /usr/local/bin/opencode \ @@ -114,6 +135,7 @@ ENV MISE_DATA_DIR=/root/.local/share/mise \ MISE_STATE_DIR=/root/.local/state/mise \ OPENCODE_DISABLE_AUTOUPDATE=true \ OPENCODE_DISABLE_TERMINAL_TITLE=true \ + OPENCODE_VERSION=${OPENCODE_VERSION} \ WORKSPACE=/workspace ENV PATH="/root/.local/bin:/root/.cargo/bin:/root/.local/share/mise/shims:/root/.local/share/mise/bin:${PATH}" diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend index f932e5b32..888e25add 100644 --- a/docker/Dockerfile.frontend +++ b/docker/Dockerfile.frontend @@ -30,6 +30,8 @@ RUN set -eux; \ npm ci --ignore-scripts COPY . . RUN node scripts/copy-material-icons.mjs +# 相对路径 = 「同源反代」语义。V2 下由前端解析为**当前页面 origin** +# (见 src/constants/api.ts 的 V2 修正注释;V1 时代它靠反代削 /api 前缀,V2 不再削)。 ENV VITE_API_BASE_URL=/api RUN npm run build diff --git a/docker/backend-entrypoint.sh b/docker/backend-entrypoint.sh index 1a83d4d9a..8abad772e 100644 --- a/docker/backend-entrypoint.sh +++ b/docker/backend-entrypoint.sh @@ -18,12 +18,17 @@ ensure_mise() { } ensure_opencode() { + # 镜像里已经装好了(Dockerfile 构建期安装 + sha256 校验),这里只是兜底: + # 只有当 /root 卷被换掉、二进制丢失时才重新下载。 if opencode --version >/dev/null 2>&1; then return fi - # 直接从 GitHub Releases 拉 tarball,绕开 opencode.ai/install 脚本对 - # api.github.com 的依赖(共享 IP 易触发限流)。 + # 🔴 必须走 opencode.ai/files 渠道,**不能用 GitHub Releases 的 latest**: + # GitHub 的 `latest` 已经停在 v1.18.33(V1),而本 UI 只支持 V2 + # (V2 不在 GitHub Releases;npm 上的 v2 在 `@opencode/cli` 包,不是 `opencode-ai`)。 + # 详见 docker/Dockerfile.backend 的注释。 + # 版本号由 Dockerfile 的 ENV OPENCODE_VERSION 传进来。 case "$(uname -m)" in x86_64) OC_ARCH=x64 ;; aarch64|arm64) OC_ARCH=arm64 ;; @@ -31,10 +36,12 @@ ensure_opencode() { esac curl -fsSL -o /tmp/opencode.tar.gz \ - "https://github.com/anomalyco/opencode/releases/latest/download/opencode-linux-${OC_ARCH}.tar.gz" + "https://opencode.ai/files/bin/${OPENCODE_VERSION:-2.0.19}/opencode-linux-${OC_ARCH}.tar.gz" tar -xzf /tmp/opencode.tar.gz -C /usr/local/bin opencode rm -f /tmp/opencode.tar.gz chmod +x /usr/local/bin/opencode + # 兜底下载后做一次版本确认:装成 V1 时立刻失败,而不是等到界面连不上才排查。 + opencode --version } ensure_package_mirrors() { @@ -62,6 +69,19 @@ ensure_mise ensure_opencode ensure_package_mirrors +# 🔴 版本守卫(阶段 4 新增):本 UI 只支持 opencode **V2**。 +# 如果容器里的二进制是 V1(典型场景:还在用改造前构建的旧镜像), +# 表现是「界面能打开、一发消息就连不上」,排查成本极高 —— 所以在这里直接喊出来。 +# 注意是**警告不是退出**:万一 --version 输出格式变了,不应该让容器起不来。 +OC_VERSION_OUTPUT="$(opencode --version 2>/dev/null || echo unknown)" +case "${OC_VERSION_OUTPUT}" in + *v2.*) ;; + *) + echo "WARNING: opencode 版本不是 V2(实测输出:${OC_VERSION_OUTPUT})。" >&2 + echo "WARNING: 本 UI 只支持 V2。请更新或重建后端镜像(见 docker/Dockerfile.backend)。" >&2 + ;; +esac + if [ "$#" -eq 0 ]; then set -- opencode serve --port 4096 --hostname 0.0.0.0 fi diff --git a/docker/nginx.host.conf.example b/docker/nginx.host.conf.example index 9727e9c36..015b5c94a 100644 --- a/docker/nginx.host.conf.example +++ b/docker/nginx.host.conf.example @@ -18,8 +18,10 @@ server { } # API 反向代理 + # 🔴 OpenCode V2:端点本身带 /api 前缀 → proxy_pass 不能带尾斜杠 + # (带尾斜杠会削掉 /api/,请求打到不存在的路径上,后端只返回 SPA 兜底 HTML)。 location /api/ { - proxy_pass http://127.0.0.1:4096/; + proxy_pass http://127.0.0.1:4096; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -36,7 +38,8 @@ server { # WebSocket(PTY 终端) location /api/pty/ { - proxy_pass http://127.0.0.1:4096/pty/; + # V2:同样不能带尾斜杠(保持 /api/pty/… 原样透传) + proxy_pass http://127.0.0.1:4096; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/docs/opencode-v2-migration-phase0.5.md b/docs/opencode-v2-migration-phase0.5.md new file mode 100644 index 000000000..fd0a2ab80 --- /dev/null +++ b/docs/opencode-v2-migration-phase0.5.md @@ -0,0 +1,1463 @@ +# OpenCodeUI 迁移 V2 —— 阶段 0.5 报告(换 SDK + 迁移端点) + +> 状态:**已完成** +> 执行环境:容器内 Linux,opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`),OpenCodeUI `0.6.46` +> 参照文档:`docs/opencode-v2-migration.md`(下文简称「迁移文档」) +> 日期:2026-09-30 +> 命名说明:本报告文件名沿用任务要求(`phase0.5`)。它对应迁移文档 §8 的**阶段 1** +> (换 SDK + 迁移端点),不是「阶段 0 的补充」。 + +## 结论摘要 + +| 任务 | 结果 | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **1. 目录定位策略实测** | 🔴 **结论 B —— 头不够**,但缺口**只有 1 个端点**(`GET /api/session`,且它只认裸 `directory`)
已回填到迁移文档 §3.3 | +| 2. 换客户端包 | ✅ `@opencode-ai/sdk ^1.16.0` → **`@opencode/client` 精确锁定 `2.0.19`**;20 个文件的 import 路径已改 | +| 3. 端点迁移 | ✅ 21 个真实 SDK 调用点已迁移(含健康检查);**58 处**未迁移功能改为**显式报错**而非静默失败 | +| 4. 类型检查 | ✅ **零报错**(换包后曾 **336 条** → 适配类型层后 **95 条** → 迁移完 **0 条**) | +| 4. 单元测试 | ✅ 全绿(**97 文件 / 683 用例**,比阶段 0 的 96/667 **多 1 文件 16 用例**) | +| 4. 冒烟(真实服务) | ✅ **12/12 通过** —— 会话列表 / 模型列表 / 配置读取三条验收标准全部达成 | +| 5. 与文档不符 | ⚠️ 共 **10 处**,其中 **2 处会改变阶段 3 的做法**(见 §5 第 1、2 条) | + +**阶段 1 产出(迁移文档 §8 的验收标准)**:✅ 会话列表、✅ 模型列表、✅ 配置读取 —— 均已在真实 V2 服务上跑通。 + +--- + +## 1. 任务 1:目录定位策略实测(最重要) + +> 原始记录:`/tmp/opencode/v2test/TASK1-EVIDENCE.md`(含逐条 curl 输出) + +### 1.1 实测设置 + +``` +服务:cd /tmp/opencode/v2test/cwd && OPENCODE_SERVER_PASSWORD=t1 \ + opencode --log-level info serve --hostname 127.0.0.1 --port 4097 +服务进程 cwd = /tmp/opencode/v2test/cwd +凭证:opencode:t1 +``` + +夹具(三个目录,互相可区分): + +| 目录 | `opencode.json` | `.opencode/` 下的探针 | +| ------ | -------------------------------- | ------------------------------------------------------------- | +| `dirA` | `{"username":"MARK_FROM_DIR_A"}` | `agent/probe-a.md`、`command/probe-a.md`、`skill/probe-dirA/` | +| `dirB` | `{"username":"MARK_FROM_DIR_B"}` | `agent/probe-b.md`、`command/probe-b.md`、`skill/probe-dirB/` | +| `cwd` | (无) | (无) | + +> 📌 夹具最初用的是 `{"theme": ...}`,但实测**拿不到** —— 见 §5 第 4 条。 +> 改用 V2 schema 里真实存在的 `username` 才生效。 + +### 1.2 逐项实测结果 + +#### `GET /api/config`(locationRef 作用域)→ 用返回的 `Config.Entry[].path` 判定 + +| 方式 | 返回的文档条目 | 结论 | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------- | +| a) 只带 `x-opencode-directory: %2Ftmp%2Fopencode%2Fv2test%2FdirA` | 全局 jsonc + `~/.config/opencode` + **`/tmp/opencode/v2test/dirA/opencode.json`** | ✅ **头生效** | +| b) 只带 `?location[directory]=/tmp/opencode/v2test/dirB` | 全局 jsonc + `~/.config/opencode` + **`/tmp/opencode/v2test/dirB/opencode.json`** | ✅ 参数生效 | +| c) 都不带 | 只有全局两条(cwd 下无 `opencode.json`) | ✅ 回落 cwd | + +#### `GET /api/agent` / `GET /api/command` / `GET /api/skill`(locationRef 作用域)→ 用探针文件判定 + +| 方式 | `/api/agent` | `/api/command` | `/api/skill` | +| ----------------------------------- | ---------------- | ---------------- | ------------------- | +| a) 只带请求头(dirA) | 只多出 `probe-a` | 只多出 `probe-a` | 只多出 `probe-dirA` | +| b) 只带 `location[directory]`(dirB) | 只多出 `probe-b` | 只多出 `probe-b` | 只多出 `probe-dirB` | +| c) 都不带 | 两者都没有 | 两者都没有 | 两者都没有 | + +→ ✅ **头足够**(与阶段 0 的判断一致) + +#### 🔴 `GET /api/session`(会话列表)→ 头**不够** + +数据准备:dirA 1 个会话、dirB 1 个会话、cwd 2 个会话,另有本机其它项目的历史会话。 + +| 方式 | 返回内容 | +| ----------------------------------------------------- | ------------------------------------------------------- | +| a) 只带请求头(dirA) | ❌ **未过滤** —— 返回**全局 20+ 条、跨 6 个项目**的会话 | +| b) 只带 `?location[directory]=dirB` | ❌ **未过滤** —— 同上 | +| **b2) 只带裸 `?directory=/tmp/opencode/v2test/dirB`** | ✅ **正确过滤** —— 只返回 dirB 的那 1 条 | +| c) 都不带 | ❌ **未过滤** —— 返回全局所有会话 | +| d) 请求头(dirA) + 裸 `directory`(dirB) 同时带 | ✅ 裸 `directory` 胜出,只返回 dirB | + +**源码依据**(tag `v2.0.19`): + +- `packages/server/src/handlers/session.ts:62-72` —— 把 `ctx.query` 原样交给 `session.list()` +- `packages/core/src/session/store.ts:105` —— 只在 `"directory" in input` 时才 + `eq(SessionTable.directory, input.directory)` +- 该端点的 query schema(`SessionsQuery`,`packages/protocol/src/groups/session.ts:165`) + **只有裸 `directory`**,没有 `location[directory]` + → 头和 deepObject 参数都被**静默丢弃** + +> ⚠️ 后果比「没过滤」更严重:不带 `directory` 时会返回**跨全部项目**的会话列表, +> 属于**跨目录数据泄露 + 列表错乱**,而且**不报错、不 4xx**。 + +#### session 作用域端点(`/api/session/{id}/*`)→ 与目录无关 + +| 请求 | 返回的 location | +| ---------------------------------------------------- | ------------------------------------------------- | +| `GET /api/session/{B}` 不带目录信息 | `/tmp/opencode/v2test/dirB` | +| `GET /api/session/{B}` 带**冲突**请求头(指向 dirA) | `/tmp/opencode/v2test/dirB`(头被忽略,结果正确) | +| `GET /api/session/{B}/message` 带 / 不带冲突头 | 两次返回**完全相同** | + +→ location 取自 **session 行本身**,请求里的目录信息**不应传、传了也被忽略**。 + +### 1.3 🎯 结论:**结论 B(头不够)**,但缺口只有 1 个端点 + +| 端点类别 | 请求头够吗 | 阶段 1 的处理 | +| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------- | +| **locationRef 作用域(57 个)**:`/api/config`、`/api/agent`、`/api/skill`、`/api/command`、`/api/location`、`/api/model`、`/api/provider` … | ✅ **够** | 但实现上**改为显式传 `location[directory]`**(见下方说明) | +| **`GET /api/session`(会话列表)** | ❌ **不够** | 必须显式传**裸 `directory`** → `src/api/v2Convert.ts` 的 `sessionDirectory()` | +| **session 作用域(`/api/session/{id}/*`)** | 不适用 | **不传任何目录信息**(V1 里硬塞 `directory` 的做法已删除) | + +**为什么「头够」却仍然选择显式传参数?** + +虽然结论是 B(有例外),但阶段 1 对 locationRef 端点统一采用了**显式传 `location[directory]`**,理由: + +1. 这正是**官方生成客户端自己用的方式** —— `@opencode/client` 的 `appendQuery()` + 会把 `{location:{directory}}` 摊平成 `location[directory]=`; +2. 显式参数在**抓包 / 日志 / 断点**里可核对,请求头是隐式的、容易漏看; +3. 请求头在 `OpenCode.make({ headers })` 里是 **client 级**配置 —— 目录一变就得重建 client, + 而按调用传参天然支持「一个 client 打多个目录」; +4. 代码里可以直接加开发期告警(见下),请求头方式反而不好加断言。 + +> 实现:`src/api/v2Convert.ts` 的 `locationInput()` / `sessionDirectory()`。 + +### 1.4 🔴 额外发现:`POST /api/session` 忽略请求头**和**中间件(阶段 3 会踩) + +`packages/server/src/handlers/session.ts:136`: + +```ts +location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, +``` + +- **既不看 location 中间件,也不看 `x-opencode-directory` 请求头**,只认 **body 里的 `location`** +- 实测:`POST /api/session` 只带 `x-opencode-directory: .../dirA` → + 建出来的会话 `location.directory` = **服务进程 cwd**,`projectID` 也是 cwd 项目的,**且不报错** +- 必须显式在 body 里写 `{"location":{"directory":"..."}}` 才会落在目标目录 + +→ 已写进 `src/api/session.ts` 的 `createSession()` 占位错误信息,阶段 3 实现时不会再踩。 + +### 1.5 目录写错的防护(任务要求的「断言或开发期日志」) + +`src/api/v2Convert.ts` 里所有构造目录入参的函数,在目录缺失时都会在**开发环境**打印告警: + +``` +[OpenCode V2] GET /api/config 未指定目录(serverId=活动服务器)。服务端会回落到它自己的 +process.cwd(),可能导致「选了 A 目录却读到 B 目录」而不报错。 +``` + +- 用 `import.meta.env.DEV` 判断 → **生产构建里会被 tree-shake 掉**,不产生运行时噪音 +- 冒烟测试里已实测触发(见 §4.3 第 ⑤ 项的 stderr) + +### 1.6 已回填到迁移文档 + +`docs/opencode-v2-migration.md` §3.3 已新增小节 +**「🔴 阶段 1 实测结论(2026-09-30):结论 B —— 头不够,但缺口只有 1 个端点」**, +含上表 + 三条结论 + `POST /api/session` 陷阱。 + +--- + +## 2. 任务 2:换客户端包 + +### 2.1 依赖变更 + +```diff + "dependencies": { +- "@opencode-ai/sdk": "^1.16.0", ++ "@opencode/client": "2.0.19", +``` + +- **精确锁定 `2.0.19`(不带 `^`)**:客户端与服务端是**协议同版本**的生成代码, + 用 caret 范围可能在下次 `npm install` 时拉到 2.1.x 而静默不兼容 +- `npm install` 结果:`added 10 packages, and audited 504 packages` +- 传递依赖变化: + +| 新增 | 说明 | +| ------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| `@opencode/schema` / `@opencode/protocol` `2.0.19` | `@opencode/client` 的直接依赖 | +| `effect` `4.0.0-rc.112` | `@opencode/protocol` 的依赖(peer 标记为 optional) | +| `fast-check` / `pure-rand` / `msgpackr` / `msgpackr-extract` / `node-gyp-build-optional-packages` | `effect` 的依赖 | +| `@msgpackr-extract/*` × 6 | 平台二进制(可选依赖) | + +| 移除 | 说明 | +| ------------------ | ------ | +| `@opencode-ai/sdk` | 被替换 | + +**✅ 已验证 `effect` 不会进浏览器 bundle**: +`@opencode/client` 的 **promise 入口**(`dist/promise/index.js`)只引用 +`gc472t4a → h9cy5hnk / frbwqjmf / 1tbj6z39` 这几个 chunk; +含 `effect` 的 chunk(`0fn08bwv`、`qonqb2tyr` 等)**不在 promise 入口的引用链上** +(它们服务于 `./effect` 与 `./solid` 子入口)。 +实测 `vite build` 成功,主 bundle 里搜 `effect/unstable` **0 命中**。 + +> ⚠️ 但那 10 个包**会装进 `node_modules`**(约多占磁盘)。这是 `@opencode/client` 的固有代价。 + +### 2.2 import 路径改动清单(20 个文件) + +统一替换:`'@opencode-ai/sdk/v2/client'` → `'@opencode/client'` + +| # | 文件 | 改后来源 | +| --- | ----------------------------- | --------------------------------------------------------------------------- | +| 1 | `src/api/global.ts` | `@opencode/client`(后来改为 V2 原生 `ServerInfo`) | +| 2 | `src/api/lsp.ts` | `../types/api/v1Model`(V2 无此类型) | +| 3 | `src/api/sdk.test.ts` | `@opencode/client`(同时改 mock:`createOpencodeClient` → `OpenCode.make`) | +| 4 | `src/api/todo.ts` | `../types/api/v1Model`(V2 无 `Todo`) | +| 5 | `src/types/api/agent.ts` | `./v1Model` | +| 6 | `src/types/api/common.ts` | `./v1Model` | +| 7 | `src/types/api/config.ts` | `./v1Model` | +| 8 | `src/types/api/event.ts` | `./v1Model` | +| 9 | `src/types/api/file.ts` | `./v1Model` | +| 10 | `src/types/api/mcp.ts` | `./v1Model` | +| 11 | `src/types/api/message.ts` | `./v1Model` | +| 12 | `src/types/api/model.ts` | `./v1Model` | +| 13 | `src/types/api/permission.ts` | `./v1Model` | +| 14 | `src/types/api/project.ts` | `./v1Model` | +| 15 | `src/types/api/pty.ts` | `./v1Model` | +| 16 | `src/types/api/session.ts` | `./v1Model` | +| 17 | `src/types/api/skill.ts` | `./v1Model` | +| 18 | `src/types/api/tool.ts` | `./v1Model` | +| 19 | `src/types/api/vcs.ts` | `./v1Model` | +| 20 | `src/types/api/worktree.ts` | `./v1Model` | + +**为什么不是「自动跟随」**(与迁移文档 §1.2 的预判一致): +旧 import 路径里的 `v2` 指 **SDK 自身第二代**,打的仍是 **V1 端点**; +而新包的 `@opencode/client` 的 102 个类型名里**只有 12 个与旧包同名**, +其余 90 个(消息 / Part / 事件 / question / todo / tool / lsp / formatter …)**V2 已整体删除**。 + +### 2.3 `src/api/sdk.ts` 重写 + +| 项 | 改前 | 改后 | +| ---------- | ------------------------------------------------ | -------------------------------------------------------- | +| 包 | `@opencode-ai/sdk` | `@opencode/client` | +| 工厂 | `createOpencodeClient({...})` | `OpenCode.make({ baseUrl, headers, fetch })` | +| 返回类型 | `OpencodeClient` | `OpenCodeClient`(= `ReturnType`) | +| 返回值约定 | `{data, error, request, response}` 需 `unwrap()` | **直接返回数据**,失败直接 throw(`ClientError`) | +| `unwrap()` | 必需 | **已删除**(V2 不再有 envelope) | + +**保留的两项能力**: + +1. **Tauri `plugin-http` fetch**(绕 CORS)—— 原样保留,含: + - 懒加载 + 缓存(`getTauriFetch()`) + - **代次(generation)防串扰**:`abortInFlightApiRequests()` 会让旧 client 的新请求立刻抛 `AbortError` + - `trackedFetch()` 把外部 `AbortSignal` 与内部 `AbortController` 串起来 +2. **Basic Auth** —— 🔴 **用户名改为固定 `opencode`**(新增 `makeOpencodeBasicAuthHeader(password)`) + +**为什么用户名要固定**:V2 服务端把用户名**硬编码为 `"opencode"`** +(`packages/server/src/auth.ts:20`),`OPENCODE_SERVER_USERNAME` 在 v2.0.19 中**零读取处**。 +实测:`custom:pw` → 401、`opencode:pw` → 200。 +所以 `sdk.ts` 现在**忽略** `serverStore` 里存的 `auth.username`。 +(`serverStore.makeBasicAuthHeader()` 保留未动,因为它是对外导出的 API;健康检查已改用新函数。) + +### 2.4 运行时验证 + +```bash +node -e "import('@opencode/client').then(m => console.log(typeof m.OpenCode.make))" +# → function +``` + +✅ **不需要 `solid-js`、也不需要手动安装 `effect`** 即可 `import` 主入口(promise 版)。 +(阶段 0 遗留的 ❗「`@opencode/client` 在 Tauri 下的流式表现」**仍属阶段 2** —— 本次只用到 promise 方法,未用 `event.subscribe()`。) + +--- + +## 3. 任务 3:端点迁移 + +### 3.1 总览 + +| 类别 | 数量 | 说明 | +| ------------------------------ | -----: | -------------------------------------------------------------------------------------------- | +| **真实 SDK 调用点(已迁移)** | **21** | 打的是 V2 端点 | +| **未迁移功能(显式报错占位)** | **58** | 调用即抛「阶段 3 待办」错误,**不静默** | +| 纯函数(不碰 SDK,原样保留) | 4 | `extractUserMessageContent`、`getPtyConnectUrl`、`createSseTextParser`、`normalizeTodoItems` | + +> 阶段 3 的待办清单可直接 `grep -rn "notMigratedYet" src/api/` 得到。 + +### 3.2 端点迁移对照表(迁移文档 §4 逐行 → 实际改动位置) + +> 图例:✅ 已迁移(打 V2 端点)|⛔ 显式报错占位(阶段 2/3)|➖ V2 已删除且无替代 + +#### §4.1 服务与事件 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ------------ | ----------------------------------------------- | --------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| **健康检查** | `GET /global/health`(Rust 先试 `/api/health`) | `GET /api/info` | ✅ | `src/api/global.ts:31`
`src-tauri/.../opencode.rs:66`(Rust)
`src/store/serverStore.ts:676`(前端) | +| 事件流 | `GET /global/event` | `GET /api/event` | ⛔ 阶段 2(`src/api/events.ts` **未改动**) | — | +| 断开实例 | `POST /global/dispose`、`/instance/dispose` | ➖ 删除 | ⛔ | `src/api/global.ts:40`、`:53` | +| 当前路径 | `GET /path` | `GET /api/location` | ✅ | `src/api/client.ts:174`(`getPath`)、`:187`(`getLocation`) | +| 重载配置 | — | `POST /api/location/reload` | ➖ 未接入(YAGNI) | — | + +#### §4.2 会话 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ---------------------------------------------- | --------------------------------- | ------------------------------------------ | --------------------------------- | -------------------------------- | +| **列表** | `GET /session` | `GET /api/session` | ✅ **(裸 `directory`!)** | `src/api/session.ts:103` | +| **详情** | `GET /session/{id}` | `GET /api/session/{id}` | ✅ | `src/api/session.ts:119` | +| 创建 | `POST /session` | `POST /api/session` | ⛔(body 必须带 `location`) | `src/api/session.ts:138` | +| 更新 | `PATCH /session/{id}` | `PATCH /api/session/{id}` | ⛔ | `src/api/session.ts:159` | +| 删除 | `DELETE /session/{id}` | `DELETE /api/session/{id}` | ⛔ | `src/api/session.ts:178` | +| **会话状态** | `GET /session/status` | `GET /api/session/active` | ✅(语义变为「只列活跃」) | `src/api/session.ts:45` | +| 拉消息 | `GET /session/{id}/message` | `GET /api/session/{id}/message` | ⛔ 阶段 2(返回 `{data,cursor}`) | `src/api/message.ts:68` | +| **发消息** | `POST /session/{id}/message` | `POST /api/session/{id}/prompt` | ⛔ 阶段 2 | `src/api/message.ts:160` | +| **异步发消息** | `POST /session/{id}/prompt_async` | ➖ 删除(prompt 本身非阻塞) | ⛔ | `src/api/message.ts:181` | +| 阻塞等待 | — | `POST /api/experimental/session/{id}/wait` | ➖ 未接入 | — | +| 把阻塞工具转后台 | — | `POST /api/session/{id}/background` | ➖ 未接入 | — | +| **停止生成** | `POST /session/{id}/abort` | `POST /api/session/{id}/interrupt` | ⛔ | `src/api/session.ts:195` | +| 拷贝 | `POST /session/{id}/fork` | `POST /api/session/{id}/fork` | ⛔ | `src/api/session.ts:267` | +| **回退** | `POST /session/{id}/revert` | 三段式 stage/commit/clear | ⛔ | `src/api/session.ts:208` | +| 取消回退 | `POST /session/{id}/unrevert` | ➖ 删除 | ⛔ | `src/api/session.ts:228` | +| **分享** | `POST/DELETE /session/{id}/share` | ➖ 删除 | ⛔ | `src/api/session.ts:241`、`:254` | +| **摘要** | `POST /session/{id}/summarize` | ➖ 删除(用 `/compact`) | ⛔ | `src/api/session.ts:286` | +| **子会话** | `GET /session/{id}/children` | ➖ 删除(用 `?parentID=`) | ⛔ | `src/api/session.ts:306` | +| **待办** | `GET /session/{id}/todo` | ➖ 删除 | ⛔ | `src/api/session.ts:328` | +| diff | `GET /session/{id}/diff` | `GET /api/session/{id}/diff` | ✅(字段完全兼容) | `src/api/session.ts:61` | +| 新增(切 agent/model/move/view/context/inbox) | — | 若干 | ➖ 未接入(YAGNI) | — | + +#### §4.3 权限与提问 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ------------ | -------------------------------------- | --------------------------------- | ----------- | ----------------------------------------- | +| 权限列表 | `GET /permission` | `GET /api/permission/request` | ⛔ | `src/api/permission.ts:31` | +| 权限回复 | `POST /session/{id}/permissions/{pid}` | `POST .../permission/{rid}/reply` | ⛔ | `src/api/permission.ts:48` | +| **回答提问** | `GET /question` + reply/reject | ➖ 删除 → **Form 表单体系** | ⛔ | `src/api/permission.ts:74`、`:92`、`:110` | +| 待处理表单 | — | `GET /api/form` | ➖ 未接入 | — | + +#### §4.4 模型、配置、项目 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| --------------------- | ---------------------------- | ------------------------------------------ | ---------------------------------------- | ----------------------- | +| **读配置** | `GET /config` | `GET /api/config`(返回 `Config.Entry[]`) | ✅(按优先级合并) | `src/api/config.ts:85` | +| 读全局配置 | `GET /global/config` | ➖ 无独立端点 | ✅(从 Entry[] 里筛出非项目文档) | `src/api/config.ts:101` | +| 改(location 级)配置 | `PATCH /config` | ➖ **不存在** | ⛔ | `src/api/config.ts:119` | +| 改全局配置 | `PATCH /global/config` | `PATCH /api/experimental/config` | ⛔ **(只支持 `shell`,见 §5 第 2 条)** | `src/api/config.ts:137` | +| **模型列表** | `GET /config/providers` | `GET /api/model` + `/api/provider` | ✅(前端 join) | `src/api/client.ts:50` | +| 默认模型 | 同上 `default` 字段 | `GET /api/model/default` | ✅(形状变化,见 §5 第 5 条) | `src/api/client.ts:65` | +| Provider 列表 | 同上 | `GET /api/provider` | ✅ | `src/api/client.ts:50` | +| **可用 shell** | `GET /pty/shells` | `GET /api/config/shell` | ✅(类型完全一致) | `src/api/pty.ts:61` | +| **Agent 列表** | `GET /agent` | `GET /api/agent` | ✅ | `src/api/agent.ts:16` | +| **Skill 列表** | `GET /skill` | `GET /api/skill` | ✅ | `src/api/skill.ts:16` | +| **命令列表** | `GET /command` | `GET /api/command` | ✅ | `src/api/command.ts:57` | +| 执行命令 | `POST /session/{id}/command` | `POST /api/session/{id}/command` | ⛔(body 形态变了) | `src/api/command.ts:87` | +| **项目列表** | `GET /project` | `GET /api/project` | ✅(去掉 directory 参数) | `src/api/client.ts:109` | +| **当前项目/目录** | `GET /project/current` | `GET /api/location` | ✅(+ 补查 project.list) | `src/api/client.ts:87` | +| 初始化 git | `POST /project/git/init` | ➖ 删除 | ⛔ | `src/api/client.ts:122` | +| 更新项目 | `PATCH /project/{id}` | `PATCH /api/project/{id}` | ⛔ | `src/api/client.ts:137` | +| **LSP 状态** | `GET /lsp` | ➖ **删除** | ⛔ | `src/api/lsp.ts:29` | +| **格式化器状态** | `GET /formatter` | ➖ **删除** | ⛔ | `src/api/lsp.ts:46` | + +#### §4.5 文件与搜索 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| -------------- | ------------------- | ------------------------------- | ------------ | -------------------- | +| 列目录 | `GET /file` | `GET /api/fs/list` | ⛔ | `src/api/file.ts:45` | +| 读文件 | `GET /file/content` | `GET /api/fs/read/*` | ⛔ | `src/api/file.ts:62` | +| 文件改动状态 | `GET /file/status` | ➖ 删除(用 `/api/vcs/status`) | ⛔ | `src/api/file.ts:74` | +| 内容搜索 | `GET /find` | ➖ **无端点** | ⛔(待决策) | `src/api/file.ts:99` | +| **文件名搜索** | `GET /find/file` | `GET /api/fs/find` | ⛔ | `src/api/file.ts:24` | +| **符号搜索** | `GET /find/symbol` | ➖ 删除 | ⛔ | `src/api/file.ts:86` | + +#### §4.6 终端 PTY + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ---------- | ---------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------- | +| 列表 | `GET /pty` | `GET /api/pty` | ⛔ | `src/api/pty.ts:42` | +| 创建 | `POST /pty` | `POST /api/pty` | ⛔ | `src/api/pty.ts:70` | +| 详情 | `GET /pty/{id}` | `GET /api/pty/{id}` | ⛔ | `src/api/pty.ts:82` | +| 更新 | `PATCH /pty/{id}` | **`PUT /api/pty/{id}`** | ⛔ | `src/api/pty.ts:94` | +| 删除 | `DELETE /pty/{id}` | `DELETE /api/pty/{id}` | ⛔ | `src/api/pty.ts:112` | +| 连接 | `WS /pty/{id}/connect` | **两步**(connect-token → connect) | ⛔ | `src/api/pty.ts:136`(`getPtyConnectUrl` 保留但**V2 下连不上**,已注释说明) | +| **shells** | `GET /pty/shells` | `GET /api/config/shell` | ✅ | `src/api/pty.ts:61` | + +#### §4.7 MCP、VCS、Worktree + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ----------------------- | ----------------------------------- | --------------------------------- | ----------- | ------------------------------------------- | +| MCP 列表 | `GET /mcp` | `GET /api/mcp` | ⛔ | `src/api/mcp.ts:24` | +| MCP 资源 | `GET /experimental/resource` | `GET /api/mcp/resource` | ⛔ | `src/api/mcp.ts:37` | +| 增删 / 连接 / 断开 | `POST /mcp` 等 | `/api/experimental/mcp/{server}` | ⛔ | `src/api/mcp.ts:49`、`:63`、`:75` | +| **MCP OAuth(4 端点)** | `/mcp/{name}/auth*` | ➖ 删除 → `/api/integration/*` | ⛔ | `src/api/mcp.ts:88`、`:101`、`:113`、`:125` | +| VCS 信息 / diff | `GET /vcs`、`/vcs/diff` | `GET /api/vcs`、`/api/vcs/diff` | ⛔ | `src/api/vcs.ts:22`、`:35` | +| Worktree 列表/新建/删除 | `/experimental/worktree` | `/api/worktree`(改用 projectID) | ⛔ | `src/api/worktree.ts:23`、`:36`、`:48` | +| Worktree 重置 | `POST /experimental/worktree/reset` | ➖ 删除 | ⛔ | `src/api/worktree.ts:60` | + +#### §4.8 工具、插件、调试 + +| 功能 | V1 | V2 | 阶段 1 处理 | 位置 | +| ---------------------------------------------------- | ------------------------------ | ------------------------------- | ------------------ | --------------------------- | +| 工具列表 | `GET /experimental/tool[/ids]` | ➖ 删除(用 `GET /api/plugin`) | ⛔ | `src/api/tool.ts:22`、`:34` | +| 插件 / 调试 / 迁移状态 / 网页搜索 / Shell / 插件 RPC | — | 新增 | ➖ 未接入(YAGNI) | — | + +### 3.3 类型层适配(`src/types/api/*`) + +这是本次改动**最容易被低估**的一块。结论:**类型层必须从「转发层」变成「定义层」**。 + +**问题**:`src/types/api/*` 原本 16 个文件几乎全是 `export type X = SDKX` 的**转发**。 +换包后旧包的 102 个类型名里**只剩 12 个**在新包中存在(`Project`、`Pty`、`UnknownError`、 +`McpResource`、`VcsInfo`、`PermissionRequest`、`WorktreeCreateInput/RemoveInput`、`SessionStatus`、 +`McpStatusConnected/Disabled/Failed/NeedsAuth`),其余 **90 个 V2 已整体删除**。 + +**而下游的 UI / store / 渲染层仍然按 V1 模型编写,且阶段 1 禁止改动它们** +→ 唯一出路是**让类型层自给自足**。 + +**做法**:新增 `src/types/api/v1Model.ts`(**3286 行 / 189 个声明**) + +- 用脚本从 `@opencode-ai/sdk@1.16.0` 的 `dist/v2/gen/types.gen.d.ts` 里 + **按「项目实际引用的 102 个类型名 + 传递闭包」逐字抽取**(未手写、未改写形状) +- 文件头有醒目的中文说明:**这是阶段 1 的临时兼容层,阶段 2/3 完成迁移后应整体删除** +- 各 `src/types/api/*.ts` 的 import 从 `@opencode/client` 改为 `./v1Model` + +**效果**:类型错误从 **336 → 95**(一次性消掉 241 条,含 `FileExplorer.tsx` 的 23 条、 +`QuestionDialog.tsx` 的 14 条等**全部下游渲染组件报错**,实现了「渲染层零改动」)。 + +**新增的转换层**:`src/api/v2Convert.ts`(V2 响应 → 内部 V1 形状) + +| 函数 | 作用 | 关键差异 | +| ------------------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------- | +| `locationInput()` | 构造 `{location:{directory}}` | 缺目录时开发期告警 | +| `sessionDirectory()` | 构造**裸 `directory`** | 🔴 `GET /api/session` 专用 | +| `toInternalSession()` | `Session.Info` → `Session` | `directory` → **`location.directory`**;`slug`/`version`/`summary`/`share` 已删除 | +| `toInternalSessionStatusMap()` | `/api/session/active` → `SessionStatusMap` | 语义变为「只列活跃」,缺失补 `idle` | +| `toInternalAgent()` | `Agent.Info` → `Agent` | `permissions`→`permission`、`system`→`prompt`;**权限规则模型整个换了,填 `[]`** | +| `toInternalSkill()` | `Skill.Info` → `Skill` | `path` → `location` | +| `toInternalProject()` | `Project` → `Project` | **`canonical` → `worktree`** | +| `toUiModelInfos()` | `Model.Info[]` + `Provider.Info[]` → `ModelInfo[]` | 两个平铺列表 join;`capabilities.input: string[]` → 4 个布尔 | + +### 3.4 未迁移项为什么用「显式报错」而不是留着编译错误 + +新增 `src/api/notMigrated.ts`: + +```ts +notMigratedYet('创建会话(POST /session)', 'POST /api/session', '⚠️ 必须把目录写进 body 的 location 字段…') +// → throw new Error('[OpenCode V2 迁移 · 阶段 3 待办] 创建会话… 尚未迁移到 OpenCode V2。\nV2 替代方案:…') +``` + +**理由**: + +1. **类型检查必须通过**(阶段 1 验收要求),但 58 处功能确实没迁移 +2. **不能假装能用**:如果为了编译而 `as any`,运行时会静默 404 或拿到错数据 +3. **不能只是删掉**:删掉会让调用方直接 `undefined is not a function`,且丢失「这里曾经有什么功能」 +4. **显式抛错** → 编译过、失败**大声**、且**待办清单可 grep** + (`grep -rn "notMigratedYet" src/api/`) + +每条占位错误都写明了:**功能名** + **V2 替代端点** + **本次调用的实际参数**(便于复现)。 + +### 3.5 健康检查改造(一并完成) + +#### 改前的问题:**静默假阳性** + +| 链路 | 改前逻辑 | V2 下的实际行为 | +| -------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Rust**(`opencode.rs:42-76`) | 先试 `/api/health`,再试 `/global/health`,**只看状态码** `is_success()` | `/api/health` → 404(非 2xx)→ 继续;`/global/health` → **200 + SPA 兜底 HTML** → `is_success()` = **true** → **判「服务健康」**
🔴 于是**任何**在该路径返回 200 的 HTTP 服务都算「opencode 健康」,**校验能力归零且不报错** | +| **前端**(`serverStore.ts:643`) | 打 `/global/health`,检查 `content-type: application/json` + `data.healthy === true` | 拿到 `text/html` → `status:'error'` → 界面显示服务异常 | + +#### 改后:单一 `GET /api/info` + **结构校验** + +**Rust**(`src-tauri/src/app/commands/opencode.rs`): + +```rust +let response = client.get(format!("{}/api/info", base)) ... .send().await?; +if !response.status().is_success() { return false; } +// 关键:不能只看状态码 +serde_json::from_str::(&body).map(|v| is_opencode_info_body(&v)) +``` + +`is_opencode_info_body()` 判据(与前端**同一套**): +`version` 是非空字符串 + `pid` 是数字 + `urls` 是数组。 + +> 📌 实现细节:Cargo.toml 里 `reqwest` 关了默认特性、**没启用 `json` feature**, +> 所以用 `response.text()` + `serde_json::from_str` 手动解析,**零额外依赖**。 + +**前端**(`src/store/serverStore.ts`): + +- `checkHealth()` 端点:`${server.url}/global/health` → **`${server.url}/api/info`**(第 676 行) +- 新增 `isOpencodeInfoResponse()`,替换原来的 `data.healthy !== true` 判断 +- **删除了 `healthy` 字段依赖** —— V2 的 `/api/info` 没有这个字段 + +**UI(3 处 version 展示 + 1 处诊断文案)**: + +| 位置 | 内容 | 是否需改 | +| ------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------- | +| `src/features/settings/components/ServerHealthButton.tsx:20` | `· OpenCode v${health.version}` | ✅ **无需改** —— `/api/info` 仍有 `version` | +| `src/features/chat/sidebar/MultiServerFolderList.tsx:229` | `· v${health.version}` | ✅ **无需改** | +| `src/features/chat/sidebar/SearchResults.tsx:232` | `· v${health.version}` | ✅ **无需改** | +| `src/features/chat/ChatPane.tsx:371` | 硬编码 `'Expected /global/health to return OpenCode health JSON.'` | ✅ **已改为 `/api/info`** | + +> ⚠️ **透明说明**:`ChatPane.tsx` 属于聊天/渲染组件。这次只改了**1 行、且是健康检查的诊断文案**, +> 依据是任务允许的「健康检查相关(…、对应 UI)」。如果认为越界,**单独 revert 这一行即可**, +> 不影响其它任何改动(它只是错误提示里的一句话)。 + +**新增/更新的测试**(`src/store/serverStore.test.ts`,+4 用例): + +1. ✅ V2 `/api/info` 响应 → `online`,`version` 正确 +2. ✅ **请求的 URL 是 `/api/info`**,且不含 `global/health` +3. ✅ **旧 V1 形状 `{healthy:true,version}` 现在被拒绝**(防止回退) +4. ✅ 非 OpenCode JSON(`{ok:true}`)仍被拒绝 + +### 3.6 阶段 1 未做(按任务边界,属阶段 3) + +写操作一律未迁移:创建/更新/删除会话、发消息、中止、回退、权限回复、PTY 增删改、MCP 增删、 +worktree 增删、配置文件写入(`updateGlobalConfig` 的 `shell` 以外字段)。 +原因:任务明确「**只做 GET/读取类**,写操作和删除项留到阶段 3」。 + +--- + +## 4. 任务 4:验证 + +### 4.1 类型检查 + +```bash +npx tsc -b --force # 退出码 0,零报错 +``` + +**三个快照**(完整清单见附录 A / B): + +| 快照 | 状态 | 错误数 | +| ----- | ------------------------------------------------------- | ------: | +| **A** | 换包 + 改 import 路径 + 重写 `sdk.ts`,**类型层未适配** | **336** | +| **B** | 再适配 `src/types/api/*`(引入 `v1Model.ts`) | **95** | +| **C** | 端点迁移完成后(最终) | **0** | + +### 4.2 单元测试 + +```bash +timeout 180 npm run test:run # = vitest run(npm test 是 watch 模式,非交互环境下会挂住) +``` + +``` +Test Files 97 passed (97) + Tests 683 passed (683) + Duration 17.54s +``` + +- 基线(阶段 0):96 文件 / 667 用例 +- 现在:**97 文件 / 683 用例**(+1 文件 = 新增冒烟套件;+16 用例 = 4 条健康检查 + 12 条冒烟) +- **服务不可达时**(把临时服务停掉后复跑): + ``` + Test Files 96 passed | 1 skipped (97) + Tests 671 passed | 12 skipped (683) + ``` + → 671 = 667(阶段 0 基线)+ 4(新增健康检查用例)✅;冒烟套件整体跳过,**不会让 `npm test` 变红** + +### 4.3 冒烟测试(真实 V2 服务 + 真实迁移代码) + +`src/api/phase1Smoke.test.ts` —— 12 项全部通过。**不是 mock**:打的是真实运行的 `opencode v2.0.19`。 + +| # | 验证项 | 实测结果 | +| --- | -------------------------------- | --------------------------------------------------------------------------------------------------- | +| ① | 健康检查走 `/api/info` | `{"version":"2.0.19","pid":76002,"urls":["http://127.0.0.1:4097"],"paths":{"tmp":"/tmp/opencode"}}` | +| ② | **会话列表按目录过滤** | dirA → `PAYLOAD_A@…/dirA`;dirB → `PAYLOAD_B@…/dirB`(**两条互不相同**) | +| ③ | 会话详情 location 正确 | `ses_f11f1f19affed5wRJII4fsEl4e /tmp/opencode/v2test/dirB PAYLOAD_B` | +| ④ | **模型列表** | 15 个模型,`providerName` 来自 `/api/provider` 的 join(未退化为 providerID) | +| ⑤ | **配置读取按目录合并** | dirA → `username=MARK_FROM_DIR_A`;dirB → `MARK_FROM_DIR_B`;不带目录 → 无标记(回落 cwd) | +| ⑥ | Agent 列表按目录过滤 | dirA 含 `probe-a` 不含 `probe-b`;dirB 反之 | +| ⑦ | Skill 列表按目录过滤 | dirA 含 `probe-dirA`;dirB 含 `probe-dirB` | +| ⑧ | 命令列表按目录过滤 | dirA 含 `probe-a`;dirB 含 `probe-b` | +| ⑨ | 当前项目 / 路径 | `worktree=/tmp/opencode/v2test/dirA`(V2 `canonical` 正确映射);项目总数 44 | +| ⑩ | Shell 列表走 `/api/config/shell` | 9 个 shell,`{path,name,acceptable}` 结构正确 | +| ⑪ | 会话状态走 `/api/session/active` | `{}`(当前无活跃会话) | +| ⑫ | **未迁移功能显式报错** | `searchText` / `getSessionTodos` 均抛「阶段 3 待办」错误,**不静默** | + +**复现方式**(已写进测试文件头部注释): + +```bash +mkdir -p /tmp/opencode/v2test/{cwd,dirA,dirB} +echo '{ "username": "MARK_FROM_DIR_A" }' > /tmp/opencode/v2test/dirA/opencode.json +echo '{ "username": "MARK_FROM_DIR_B" }' > /tmp/opencode/v2test/dirB/opencode.json +# …(agent/command/skill 探针见文件头) +cd /tmp/opencode/v2test/cwd && \ + OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097 +npx vitest run src/api/phase1Smoke.test.ts --reporter=verbose +``` + +### 4.4 其它检查 + +| 检查 | 结果 | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npx eslint <我改动的文件>` | ✅ 零告警 | +| `npx prettier --check <我改动的文件>` | ✅ 全部符合 | +| `npm run format:check`(全仓库) | ⚠️ **失败,但属既有问题** —— 报告 160 个文件格式不符,**其中包含大量我从未触碰的文件**(`src/store/themeStore.ts`、`vite.config.ts` 等)。已逐一验证:**我改动的文件全部是干净的** | +| `npx vite build` | ✅ 成功;主 bundle **不含 `effect`**(`effect/unstable` 0 命中) | +| `cargo check`(Rust) | ❌ **未执行** —— 见 §6 | + +--- + +## 5. 🔴 与文档预测不符之处(本节最重要) + +### ① 🔴 `GET /api/session` 只认裸 `directory`,头和 `location[directory]` 都被**静默忽略**(文档 §3.3 未覆盖) + +**文档原文**(§3.3): + +> **组合结论**:参数全可选 + 请求头是第二优先级回退 ⇒ **带 `x-opencode-directory` 头即可定位目录,端点参数可基本不传**。 + +**实际**:这条对 **57 个 locationRef 端点成立**,但 **`GET /api/session` 是例外**: + +| 方式 | 结果 | +| -------------------------- | -------------------------------------- | +| 只带请求头 | ❌ 被忽略 → **返回全局所有项目的会话** | +| 只带 `location[directory]` | ❌ 被忽略 → 同上 | +| 只带**裸 `directory`** | ✅ 正确过滤 | + +**影响**:这是本次唯一**必须显式补参数**的端点,且失败方式是「**静默返回跨项目数据**」而非报错 +→ 若按文档「参数可基本不传」实现,会出现「会话列表混进别的项目」这种极难排查的 bug。 +已回填 §3.3,并在 `src/api/v2Convert.ts` 里用 `sessionDirectory()` 单独处理。 + +**⚠️ 一个重要的补充(实现时才发现)**:「不带 `directory`」**本身是合法用法** —— +多服务器模式的全局搜索(`SearchResults.tsx:121`)就是要跨目录搜整个服务器。 +所以 `sessionDirectory()` **不能抛错**,只能记一条开发期提示。 +这也意味着**这个端点无法用断言防呆**,只能靠日志 + code review。 +→ 阶段 3 若改动会话列表相关调用,请特别留意**目录是否漏传**。 + +### ② 🔴 V2 **没有**「写任意配置字段」的 API —— 配置编辑器的保存功能无法迁移(文档 §4.4 判断错误) + +**文档原文**(§4.4): + +> | **配置编辑器** | 用 `updateConfig` + `updateGlobalConfig` | ✅ 有对应端点 | UI 实际用的是 `updateGlobalConfig` → **可迁移** | + +**实际**:`PATCH /api/experimental/config` 的 payload 类型是 `Config.Patch`,而 +`packages/schema/src/config.ts:112`(tag `v2.0.19`)里它**只有一个字段**: + +```ts +export const Patch = Schema.Struct({ shell: Schema.NullOr(Schema.String) }) +``` + +→ **V2 唯一能写的配置字段是 `shell`**。配置编辑器(`ConfigSettings.tsx:272` 调 `updateGlobalConfig`) +的**保存功能无法迁移**,只能: +① 保留读 + 改 `shell`;② 其余字段改为只读展示并引导用户直接编辑配置文件。 + +**阶段 1 的处理**:`src/api/config.ts:137` 的 `updateGlobalConfig()` 只在 patch **仅含 `shell`** 时放行, +其余字段一律**显式报错并列出不支持哪些字段**,避免「以为保存了其实被静默忽略」。 +文档 §4.4 对应行已修正。 + +### ③ `GET /api/config` 的 `Config.Entry.info` 是 **schema 归一化后的视图**,未知字段被**静默丢弃** + +**文档原文**(§3.4): + +> 配置文件本身 V2 **能读 V1 格式并自动归一化**(不重写源文件),所以**用户配置不用改**。 + +**实际**:这句话对**已知字段**成立,但 `GET /api/config` 返回的 `info` **不是原始文档**, +而是**过了 V2 schema 的归一化结果** —— schema 里没有的字段**直接消失**。 + +**实测**:夹具里写 `{"theme": "THEME_FROM_DIR_A"}` → API 返回的该条目 `info` 是 **`{}`**(空对象)。 +换成 V2 schema 里存在的 `username` 才拿得到值。 + +**影响**: + +- 配置编辑器通过 API 读到的是**有损视图**,用户在文件里手写的未知/旧字段在界面上「看不见」 +- 阶段 1 的冒烟测试就因此**先失败了一次**(用 `theme` 做标记拿不到),换 `username` 后通过 +- 阶段 3 适配配置编辑器时必须考虑这一点(否则会出现「界面上没有 = 会被覆盖掉」的风险) + +### ④ 文档 §3.3 说「请求头仍然有效」是对的,但**没提它和中间件是两套东西** + +`POST /api/session`(创建会话)的 handler 是 +`ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }` +(`packages/server/src/handlers/session.ts:136`)—— 它**既不看中间件也不看请求头**, +只认 **body 里的 `location`**。 + +**实测**:只带 `x-opencode-directory: .../dirA` 创建会话 → 建出来的会话在**服务进程 cwd**, +`projectID` 也是 cwd 项目的,**且不报错**。 + +→ 属阶段 3,但**提前记录**避免届时踩坑(已写进 `createSession()` 的占位错误信息)。 + +### ⑤ `GET /api/model/default` 只返回**单个**模型,不是「每个 provider 的默认模型」映射 + +**文档原文**(§4.4): + +> | 模型列表 | `GET /config/providers` | **`GET /api/model`** + `GET /api/model/default` | | + +**实际**: + +- V1 `config.providers()` 的 `default` 字段是 **`{ [providerID]: modelID }` 映射** +- V2 `GET /api/model/default` 返回 `{ location, data: ModelInfo | null }` —— **单个模型** + +→ 阶段 1 把单个默认模型包装成「只含一个键」的映射以保持内部 `Record` 约定 +(`src/api/client.ts:65`)。该函数当前**全仓库无调用点**,属保留接口。 + +### ⑥ `GET /api/session/active` 的语义是「**只列活跃会话**」,不是 V1 的「全量状态表」 + +**文档原文**(§4.2): + +> | **会话状态** | `GET /session/status` | **`GET /api/session/active`** | 返回 `SessionActive` | + +**实际**:V1 的 `/session/status` 返回 **全部会话**的状态(4 态:`idle`/`busy`/`retry`/…); +V2 的 `/api/session/active` **只返回活跃的那些**(`{type:'running'}`),**不在表里 = 空闲**。 + +→ 阶段 1 在 `toInternalSessionStatusMap()` 里把缺失的会话补成 `idle`,让下游判断逻辑不变。 +文档没写这个语义差异,属**文档缺口**。 + +### ⑦ `GET /path` → `GET /api/location` 有**字段缺失**,文档未提示 + +**文档原文**(§4.1): + +> | 当前路径 | `GET /path` | **`GET /api/location`** | | + +**实际**: + +| | 字段 | +| ------------------------ | ------------------------------------------------------ | +| V1 `Path` | `{ home, state, config, worktree, directory }` | +| V2 `Location.PublicInfo` | `{ directory, project: { id, directory, canonical } }` | + +V2 **没有任何端点能拿到 `home` / `state` / `config`** +(`GET /api/info` 的 `paths` **只有 `tmp`**)。 + +**影响**:`home` 被「目录选择器」当作默认起始路径(`ProjectDialog.tsx` 的 `path = p.home`), +给空串会让它落到**文件系统根目录**,体验很差。 +→ 阶段 1 用**当前目录**兜底(`src/api/client.ts:174` 的 `getPath()`),并在代码里注明原因。 +文档 §4.1 应补上这条字段缺失。 + +### ⑧ `GET /api/project` **不接受** `directory` 参数(文档表格未提) + +**文档原文**(§4.4):`GET /project` → `GET /api/project`(看起来只是路径改名) + +**实际**:V1 的 `project.list({ directory })` 接受目录参数; +V2 的 `project.list()` **完全没有入参**(是**全局**项目表,实测返回 44 条跨所有项目)。 +传 `directory` 会被当成 `RequestOptions` 而类型报错。 + +→ `getProjects()` 已去掉该参数(`src/api/client.ts:109`)。 +另外 `getCurrentProject()` 因为 V2 的 `location.get()` **不含 `vcs` / `time` / `sandboxes`** +(而 `project.vcs` 是 UI 判断「要不要显示 git diff 选项」的依据), +所以额外补查了一次 `project.list()` 按 id 取完整对象(`src/api/client.ts:87`,**2 次请求**)。 + +### ⑨ `V1` 的 `Agent.permission` 与 V2 的 `permissions` **不是改名,是换了模型** + +**文档原文**(§3.4 提到过配置层的权限改动,但**没说 Agent 列表接口也受影响**) + +**实际**: + +| | 结构 | +| ------------------- | ------------------------------------------------------------------------- | +| V1 `PermissionRule` | `{ permission: string; pattern: string; action: 'allow'\|'deny'\|'ask' }` | +| V2 `PermissionRule` | `{ action: string; resource: string; effect: 'allow'\|'deny' }` | + +字段与语义都不同 → **无法直接映射**。 +阶段 1 在 `toInternalAgent()` 里填空数组 `[]`,并已**全仓库核对:下游没有任何地方读取 +`agent.permission`**(只有 `request.permission`,那是 PermissionRequest 的字段,另一回事), +所以不造成行为差异。已写进代码注释。 + +### ⑩ 🔴 **新发现**:`GET /api/model` / `GET /api/provider` 在 location **首次被访问**时可能返回**空数组** + +**文档未提及**(§4.4 只说这两个端点替代了 `/config/providers`)。 + +**实测**(opencode v2.0.19,同一台服务): + +| 场景 | `GET /api/model` 返回 | +| --------------------------------------- | --------------------------------------------------- | +| 全新目录 `dirC` 的**第 1 次**请求 | `{"location":…,"data":[]}` ← **空** | +| `dirC` 的**第 2 次**请求 | 15 条 | +| 全新目录 `dirD` 第 1 次 / 第 2 次 | **0 条** / 78 条 | +| 全新目录 `dirE`:先打 `/api/provider` | `/api/provider` **也是 0 条** | +| 全新目录 `dirF`:先打 `/api/agent` 预热 | 再打 `/api/model` → 正常 | +| 服务刚启动时的第 1 次并发请求 | `/api/model` 与 `/api/provider` **都是 `data: []`** | +| 同一 location 稳定后连打 6 次 | 全部 15 条(稳定) | + +→ **结论**:V2 的 location 是**惰性初始化**的,provider/model 目录在初始化完成前会返回**空数组** +(而且**不报错、不 503**)。这正是「静默失效」的另一种形态。 + +**影响**: + +1. **阶段 1 的冒烟测试曾因此失败一次** —— 服务刚起时 `getActiveModels()` 拿到 0 个模型。 + 处理方式:测试里先做一次预热(并在注释里写明原因),把断言聚焦在「迁移后的 join 逻辑是否正确」。 +2. **产品层面需要处理**:UI 首次打开某个目录时,模型列表可能为空。 + 阶段 2/3 应加**重试**(或等 location 就绪)—— 否则用户会看到「没有可用模型」。 +3. 也解释了为什么 `getActiveModels()` 用 `Promise.all([model.list, provider.list])` 是**有风险**的: + 两个请求都可能在 location 未就绪时返回空。 + +> 补充观察:`dirD` 第 2 次返回的是 **78 条**(而不是稳定的 15 条), +> 疑似「完整的 models.dev 目录」与「按配置过滤后的目录」之间的中间态。 +> 因为很快收敛到 15 条、且不影响阶段 1 的验收,**未深究**,仅记录现象供阶段 3 参考。 + +--- + +## 6. 未做 / 未验证事项(如实汇报) + +| 项 | 状态 | 原因 | +| ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Rust 编译验证(`cargo check`)** | ❌ **未做** | `src-tauri/target/` 不存在(从未构建过),全量 `cargo check` 需拉整套 Tauri 依赖树,在 12G 内存约束下代价大。改动是**单个函数体 + 1 个私有辅助函数**,不涉及类型/借用/控制流以外的风险。**阶段 0 同样未做。** | +| **Rust 健康检查的运行时验证** | ❌ 未做 | 需要 `cargo run`/真实 Tauri 运行时;逻辑已与前端**保持同一套判据**,前端那套已有 4 条单测覆盖 | +| **WSL 真实链路** | ❌ 未做 | 容器内无 WSL(阶段 0 同样未做) | +| **Tauri `plugin-http` 下的真实请求** | ❌ 未做 | 容器内无 Tauri 运行时。本次只验证了「注入自定义 fetch 的机制」在类型与 mock 层正确 | +| **`client.event.subscribe()` 在 Tauri 下的流式表现** | ❌ 未做 | **属阶段 2**(本次未使用事件订阅)。阶段 0 遗留的 ❗ 仍然开放 | +| **阶段 3 的全部写操作** | ❌ 未做 | 按任务边界(只做 GET/读取类) | +| **配置编辑器适配 V2 字段** | ❌ 未做 | 属阶段 3(P2)。且发现 V2 根本没有通用写接口(见 §5 ②) | +| **Docker 锁版本 / 三形态回归** | ❌ 未做 | 属阶段 3/4 | + +**清理说明**: + +- 冒烟测试启动的临时服务(端口 **4097**)**已停止** +- 容器内原有的 **4096** 常驻服务(pid 8)**全程未受影响** +- 临时夹具在 `/tmp/opencode/v2test/`(含 `TASK1-EVIDENCE.md`),**不在仓库内** +- `dist/` 是 `.gitignore` 忽略的构建产物,`vite build` 的输出不影响仓库 +- 顺带修掉了容器文件系统给文件误加执行位的噪音(21 个文件 `chmod 644`), + **`git diff` 里已无 mode change** + +--- + +## 7. 阶段 3 开工前的提醒(从本次实测沉淀) + +1. **`POST /api/session` 必须把目录写进 body**(`{location:{directory}}`),头与 query 都会被忽略(§5 ④) +2. **配置写入只能改 `shell`**,其余字段要么引导用户编辑文件、要么放弃(§5 ②) +3. **`GET /api/session` 必须传裸 `directory`**,否则静默返回全局数据(§5 ①) +4. **`Config.Entry.info` 是有损视图**,未知字段会消失 → 配置编辑器要按「只读展示」设计(§5 ③) +5. **`/api/session/active` 只列活跃会话**,缺省即空闲(§5 ⑥) +6. `grep -rn "notMigratedYet" src/api/` 就是阶段 3 的待办清单(**58 处**) +7. **模型/Provider 列表在 location 首次访问时可能是空的** → UI 要加重试(§5 ⑩) +8. 阶段 2 开始前先决定:`src/types/api/v1Model.ts`(3286 行临时兼容层)里 + **消息 / Part / 事件**那部分应优先删除,其余留到阶段 3 + +--- + +## 附录 A:类型报错全量清单(快照 A —— 换包后,类型层未适配) + +> 状态:已完成 `npm install @opencode/client@2.0.19` + 改 20 个文件的 import 路径 + 重写 `src/api/sdk.ts`, +> **尚未**适配 `src/types/api/*`。 +> 命令:`npx tsc -b --force` **共 336 条** +> +> 错误码分布:`TS2339` × 136、`TS2305` × 94、`TS7006` × 36、`TS2353` × 31、 +> `TS2724` × 15、`TS2345` × 13、`TS2322` × 5、`TS2551` × 3、`TS2352` × 2、`TS2349` × 1 + +按文件统计(完整逐条见下): + +| 文件 | 条数 | | 文件 | 条数 | +| ---------------------------------------- | ---: | --- | ----------------------------------------------------- | ---: | +| `src/components/FileExplorer.tsx` | 23 | | `src/api/lsp.ts` | 5 | +| `src/types/api/message.ts` | 20 | | `src/api/global.ts` | 5 | +| `src/api/session.ts` | 18 | | `src/types/api/common.ts` | 4 | +| `src/hooks/useChatSession.ts` | 16 | | `src/features/message/parts/ToolPartView.tsx` | 4 | +| `src/types/api/event.ts` | 14 | | `src/types/api/tool.ts` | 3 | +| `src/features/chat/QuestionDialog.tsx` | 14 | | `src/features/chat/EmptyState.tsx` | 3 | +| `src/features/chat/PermissionDialog.tsx` | 14 | | `src/api/vcs.ts` | 3 | +| `src/features/chat/InlineQuestion.tsx` | 14 | | `src/api/tool.ts` | 3 | +| `src/types/api/config.ts` | 13 | | `src/api/command.ts` | 3 | +| `src/features/chat/InlinePermission.tsx` | 11 | | `src/types/api/worktree.ts` | 2 | +| `src/hooks/useFileExplorer.test.tsx` | 10 | | `src/types/api/pty.ts` | 2 | +| `src/api/mcp.ts` | 10 | | `src/types/api/project.ts` | 2 | +| `src/api/file.ts` | 8 | | `src/hooks/usePermissionHandler.test.tsx` | 2 | +| `src/api/client.ts` | 8 | | `src/hooks/useGitWorkspaceCatalog.ts` | 2 | +| `src/hooks/useFileExplorer.ts` | 7 | | `src/features/settings/components/ConfigSettings.tsx` | 2 | +| `src/api/permission.ts` | 7 | | `src/features/sessions/ProjectSelector.tsx` | 2 | +| `src/types/api/mcp.ts` | 6 | | `src/features/sessions/ProjectSelector.test.tsx` | 2 | +| `src/types/api/file.ts` | 6 | | `src/components/McpPanel.tsx` | 2 | +| `src/hooks/useGlobalEvents.ts` | 6 | | `src/api/skill.ts` | 2 | +| `src/components/WorktreePanel.tsx` | 6 | | `src/api/agent.ts` | 2 | +| `src/api/pty.ts` | 6 | | `src/types/api/vcs.ts` | 1 | +| `src/api/config.ts` | 6 | | `src/types/api/skill.ts` | 1 | +| `src/types/api/session.ts` | 5 | | `src/types/api/agent.ts` | 1 | +| `src/types/api/permission.ts` | 5 | | `src/features/chat/sidebar/FolderRecentList.tsx` | 1 | +| `src/types/api/model.ts` | 5 | | `src/features/chat/InlineToolRequestContext.tsx` | 1 | +| `src/components/SessionChangesPanel.tsx` | 5 | | `src/features/chat/InlineToolRequestContext.test.tsx` | 1 | +| `src/api/worktree.ts` | 5 | | `src/features/chat/ChatPane.tsx` | 1 | +| `src/api/message.ts` | 5 | | `src/api/todo.ts` | 1 | + +**关键观察**:这 336 条里**绝大多数(约 240 条)根本不在 `src/api/` 里**, +而是散落在 **UI / hooks / 渲染组件**(`FileExplorer.tsx` 23 条、`QuestionDialog.tsx` 14 条、 +`useChatSession.ts` 16 条…)。 +→ 这直接证明了「**类型层必须自给自足**」这个判断:只要 `src/types/api/*` 一天还转发 V2 类型, +下游就一天编译不过,而阶段 1 又禁止改动这些组件。 + +### 快照 A 逐条清单(逐条,共 336 条) + +```text +── src/api/agent.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 15:27 error TS2339 Property 'app' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + +── src/api/client.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 56:29 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise; update: (input: ProjectUpdate... + 126:67 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'RequestOptions'. + 134:35 error TS2339 Property 'initGit' does not exist on type '{ list: (requestOptions?: RequestOptions | undefined) => Promise; update: (input: ProjectUpdate... + 152:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ProjectUpdateInput'. + 164:41 error TS2339 Property 'path' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/command.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 42:51 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'CommandListInput'. + 94:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'SessionCommandInput'. + +── src/api/config.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 15:40 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigGetInput'. + 23:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 31:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigUpdateInput'. + 40:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 48:34 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 100:45 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'FileReadInput'. + 108:32 error TS2339 Property 'status' does not exist on type '{ read: (input: FileReadInput, requestOptions?: RequestOptions | undefined) => Promise; list: (inpu... + 116:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 124:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/global.ts ── + 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalHealthResponse'. + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 22:20 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 31:20 error TS2339 Property 'instance' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + +── src/api/lsp.ts ── + 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'FormatterStatus'. + 5:54 error TS2305 Module '"@opencode/client"' has no exported member 'LspStatus'. + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 20:51 error TS2339 Property 'lsp' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + 42:57 error TS2339 Property 'formatter' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pr... + +── src/api/mcp.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:31 error TS2339 Property 'status' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ... + 22:40 error TS2339 Property 'resource' does not exist on type '{ persistentPty: { read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions | undefined)... + 30:30 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpAddInput'. + 38:34 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpConnectInput'. + 46:37 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpDisconnectInput'. + 54:39 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 64:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 72:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 80:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + +── src/api/message.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 63:23 error TS2551 Property 'messages' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + 97:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + 112:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + +── src/api/pty.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 40:38 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyListInput'. + 40:95 error TS7006 Parameter 'pty' implicitly has an 'any' type. + 50:31 error TS2339 Property 'shells' does not exist on type '{ list: (input?: PtyListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ... + 66:64 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyGetInput'. + 89:47 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyRemoveInput'. + +── src/api/session.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 31:35 error TS2551 Property 'status' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise Promise Promise Promise Promise Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + +── src/api/todo.ts ── + 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'Todo'. + +── src/api/tool.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 22:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/vcs.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 17:39 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsGetInput'. + 29:63 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsDiffInput'. + +── src/api/worktree.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'WorktreeListInput'. + 22:85 error TS2353 Object literal may only specify known properties, and 'worktreeCreateInput' does not exist in type 'WorktreeCreateInput'. + 30:38 error TS2322 Type 'string | undefined' is not assignable to type 'string'. + 39:29 error TS2339 Property 'reset' does not exist on type '{ list: (input: WorktreeListInput, requestOptions?: RequestOptions | undefined) => Promise; create: (i... + +── src/components/FileExplorer.tsx ── + 109:10 error TS7006 Parameter 'submatch' implicitly has an 'any' type. + 113:13 error TS7006 Parameter 'range' implicitly has an 'any' type. + 202:16 error TS2339 Property 'type' does not exist on type 'FileTreeNode'. + 203:27 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 205:50 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 205:67 error TS2339 Property 'name' does not exist on type 'FileTreeNode'. + 486:29 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 734:45 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 735:28 error TS2339 Property 'type' does not exist on type 'FileTreeNode'. + 737:38 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 737:67 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 759:20 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 760:24 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 761:20 error TS2339 Property 'name' does not exist on type 'FileTreeNode'. + 765:11 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 765:22 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 765:37 error TS2339 Property 'name' does not exist on type 'FileTreeNode'. + 774:61 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 774:72 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 779:18 error TS2339 Property 'ignored' does not exist on type 'FileTreeNode'. + 794:40 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 808:72 error TS2339 Property 'name' does not exist on type 'FileTreeNode'. + 821:26 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + +── src/components/McpPanel.tsx ── + 304:39 error TS2339 Property 'client' does not exist on type 'McpResource'. + 306:25 error TS2339 Property 'client' does not exist on type 'McpResource'. + +── src/components/SessionChangesPanel.tsx ── + 136:53 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'. + 136:98 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'. + 141:47 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'. + 152:77 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'. + 166:18 error TS2339 Property 'default_branch' does not exist on type 'VcsInfo'. + +── src/components/WorktreePanel.tsx ── + 60:43 error TS2339 Property 'worktree' does not exist on type 'Project'. + 61:44 error TS2339 Property 'worktree' does not exist on type 'Project'. + 166:41 error TS2345 Argument of type '{ name: string; }' is not assignable to parameter of type 'WorktreeCreateInput'. + 197:30 error TS2345 Argument of type '{ directory: string; }' is not assignable to parameter of type 'WorktreeRemoveInput'. + 275:91 error TS2322 Type 'VcsBranch' is not assignable to type 'string'. + 276:15 error TS2322 Type 'VcsBranch' is not assignable to type 'ReactI18NextChildren | Iterable'. + +── src/features/chat/ChatPane.tsx ── + 971:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + +── src/features/chat/EmptyState.tsx ── + 68:106 error TS2339 Property 'worktree' does not exist on type 'Project'. + 96:41 error TS2339 Property 'worktree' does not exist on type 'Project'. + 97:17 error TS2339 Property 'worktree' does not exist on type 'Project'. + +── src/features/chat/InlinePermission.tsx ── + 55:30 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 55:63 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 56:31 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 56:51 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 57:46 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 57:59 error TS7006 Parameter 'p' implicitly has an 'any' type. + 61:41 error TS2339 Property 'always' does not exist on type 'PermissionRequest'. + 61:68 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 64:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 79:28 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 88:28 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + +── src/features/chat/InlineQuestion.tsx ── + 30:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 30:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 36:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 36:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 42:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 42:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 100:61 error TS7006 Parameter 'q' implicitly has an 'any' type. + 100:64 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 112:46 error TS7006 Parameter '_q' implicitly has an 'any' type. + 112:50 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 142:33 error TS7006 Parameter 'question' implicitly has an 'any' type. + 142:43 error TS7006 Parameter 'qIdx' implicitly has an 'any' type. + 237:32 error TS7006 Parameter 'option' implicitly has an 'any' type. + 237:40 error TS7006 Parameter 'idx' implicitly has an 'any' type. + +── src/features/chat/InlineToolRequestContext.test.tsx ── + 60:7 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'. + +── src/features/chat/InlineToolRequestContext.tsx ── + 69:49 error TS2339 Property 'tool' does not exist on type 'PermissionRequest'. + +── src/features/chat/PermissionDialog.tsx ── + 55:30 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 55:63 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 96:75 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 145:24 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 145:44 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 148:36 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 148:49 error TS7006 Parameter 'p' implicitly has an 'any' type. + 156:24 error TS2339 Property 'always' does not exist on type 'PermissionRequest'. + 156:42 error TS2339 Property 'always' does not exist on type 'PermissionRequest'. + 159:36 error TS2339 Property 'always' does not exist on type 'PermissionRequest'. + 184:55 error TS2339 Property 'always' does not exist on type 'PermissionRequest'. + 184:82 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 188:76 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 189:66 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + +── src/features/chat/QuestionDialog.tsx ── + 34:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 34:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 41:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 41:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 48:32 error TS7006 Parameter '_' implicitly has an 'any' type. + 48:35 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 116:61 error TS7006 Parameter 'q' implicitly has an 'any' type. + 116:64 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 139:46 error TS7006 Parameter 'q' implicitly has an 'any' type. + 139:49 error TS7006 Parameter 'idx' implicitly has an 'any' type. + 225:39 error TS7006 Parameter 'question' implicitly has an 'any' type. + 225:49 error TS7006 Parameter 'qIdx' implicitly has an 'any' type. + 330:32 error TS7006 Parameter 'option' implicitly has an 'any' type. + 330:40 error TS7006 Parameter 'idx' implicitly has an 'any' type. + +── src/features/chat/sidebar/FolderRecentList.tsx ── + 1018:15 error TS2322 Type 'string | VcsBranch' is not assignable to type 'ReactI18NextChildren | Iterable'. + +── src/features/message/parts/ToolPartView.tsx ── + 114:33 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 114:86 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 121:24 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 121:68 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + +── src/features/sessions/ProjectSelector.test.tsx ── + 6:24 error TS2352 Conversion of type '{ id: string; name: string; worktree: string; }' to type 'Project' may be a mistake because neither type sufficiently overlaps with the o... + 12:21 error TS2352 Conversion of type '{ id: string; name: string; worktree: string; }' to type 'Project' may be a mistake because neither type sufficiently overlaps with the o... + +── src/features/sessions/ProjectSelector.tsx ── + 77:32 error TS2339 Property 'worktree' does not exist on type 'Project'. + 88:22 error TS2339 Property 'worktree' does not exist on type 'Project'. + +── src/features/settings/components/ConfigSettings.tsx ── + 191:26 error TS7006 Parameter 'shell' implicitly has an 'any' type. + 275:17 error TS7006 Parameter 'current' implicitly has an 'any' type. + +── src/hooks/useChatSession.ts ── + 402:73 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 402:93 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 415:34 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 415:64 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 415:87 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 415:111 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 687:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 691:44 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 695:65 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 702:13 error TS2322 Type 'string | null' is not assignable to type 'string'. + 713:35 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 720:54 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 725:30 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 1018:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 1030:13 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + 1043:29 error TS2345 Argument of type 'string | null' is not assignable to parameter of type 'string'. + +── src/hooks/useFileExplorer.test.tsx ── + 116:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 122:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 123:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 130:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 132:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 165:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 175:38 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 183:53 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 192:36 error TS2339 Property 'absolute' does not exist on type 'FileTreeNode'. + 193:65 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + +── src/hooks/useFileExplorer.ts ── + 247:28 error TS2339 Property 'type' does not exist on type 'FileTreeNode'. + 265:24 error TS2339 Property 'type' does not exist on type 'FileTreeNode'. + 422:14 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 437:14 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 455:16 error TS2339 Property 'type' does not exist on type 'FileTreeNode'. + 457:34 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + 459:29 error TS2339 Property 'path' does not exist on type 'FileTreeNode'. + +── src/hooks/useGitWorkspaceCatalog.ts ── + 92:46 error TS2339 Property 'worktree' does not exist on type 'Project'. + 93:65 error TS2339 Property 'worktree' does not exist on type 'Project'. + +── src/hooks/useGlobalEvents.ts ── + 412:53 error TS2345 Argument of type 'PermissionRequest[]' is not assignable to parameter of type '{ id: string; sessionID: string; permission: string; patterns?: string[] | und... + 415:58 error TS2345 Argument of type 'PermissionRequest[]' is not assignable to parameter of type '{ id: string; sessionID: string; permission: string; patterns?: string[] | und... + 647:32 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 647:62 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + 647:85 error TS2339 Property 'patterns' does not exist on type 'PermissionRequest'. + 647:109 error TS2339 Property 'permission' does not exist on type 'PermissionRequest'. + +── src/hooks/usePermissionHandler.test.tsx ── + 49:11 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'. + 78:11 error TS2353 Object literal may only specify known properties, and 'permission' does not exist in type 'PermissionRequest'. + +── src/types/api/agent.ts ── + 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'Agent'. + +── src/types/api/common.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'ApiError'. + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'MessageAbortedError'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'MessageOutputLengthError'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthError'. + +── src/types/api/config.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'AgentConfig'. + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'Config'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'LayoutConfig'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'LogLevel'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpLocalConfig'. + 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpOAuthConfig'. + 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpRemoteConfig'. + 9:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionActionConfig'. + 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionConfig'. + 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'PermissionObjectConfig'. + 12:3 error TS2724 '"@opencode/client"' has no exported member named 'PermissionRuleConfig'. Did you mean 'PermissionRule'? + 13:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderConfig'. + 14:3 error TS2305 Module '"@opencode/client"' has no exported member 'ServerConfig'. + +── src/types/api/event.ts ── + 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventMessagePartDelta'. + 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventMessagePartRemoved'. + 9:3 error TS2724 '"@opencode/client"' has no exported member named 'EventPermissionReplied'. Did you mean 'PermissionReplied'? + 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventQuestionRejected'. + 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventQuestionReplied'. + 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventSessionDiff'. + 13:3 error TS2724 '"@opencode/client"' has no exported member named 'EventSessionIdle'. Did you mean 'SessionIdle'? + 14:3 error TS2724 '"@opencode/client"' has no exported member named 'EventSessionStatus'. Did you mean 'SessionStatus'? + 15:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventTodoUpdated'. + 16:3 error TS2724 '"@opencode/client"' has no exported member named 'EventVcsBranchUpdated'. Did you mean 'VcsBranchUpdated'? + 17:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventWorktreeFailed'. + 18:3 error TS2305 Module '"@opencode/client"' has no exported member 'EventWorktreeReady'. + 19:3 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalEvent'. + 20:3 error TS2305 Module '"@opencode/client"' has no exported member 'Todo'. + +── src/types/api/file.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'File'. + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'FileContent'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'FileNode'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'SnapshotFileDiff'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'Symbol'. + 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'FindTextResponse'. + +── src/types/api/mcp.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpLocalConfig'. + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpOAuthConfig'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpRemoteConfig'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatusResponse'. + 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatus'. + 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'McpStatusNeedsClientRegistration'. + +── src/types/api/message.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'AgentPart'. + 3:3 error TS2724 '"@opencode/client"' has no exported member named 'AgentPartInput'. Did you mean 'AgentGetInput'? + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'AssistantMessage'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'CompactionPart'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'FilePart'. + 7:3 error TS2724 '"@opencode/client"' has no exported member named 'FilePartInput'. Did you mean 'FileWriteInput'? + 8:3 error TS2305 Module '"@opencode/client"' has no exported member 'FilePartSource'. + 9:3 error TS2305 Module '"@opencode/client"' has no exported member 'PatchPart'. + 10:3 error TS2305 Module '"@opencode/client"' has no exported member 'ReasoningPart'. + 11:3 error TS2305 Module '"@opencode/client"' has no exported member 'RetryPart'. + 12:3 error TS2305 Module '"@opencode/client"' has no exported member 'SnapshotPart'. + 13:3 error TS2305 Module '"@opencode/client"' has no exported member 'StepFinishPart'. + 14:3 error TS2305 Module '"@opencode/client"' has no exported member 'StepStartPart'. + 15:3 error TS2305 Module '"@opencode/client"' has no exported member 'SubtaskPart'. + 16:3 error TS2305 Module '"@opencode/client"' has no exported member 'SubtaskPartInput'. + 17:3 error TS2305 Module '"@opencode/client"' has no exported member 'TextPart'. + 18:3 error TS2305 Module '"@opencode/client"' has no exported member 'TextPartInput'. + 19:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolPart'. + 20:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolState'. + 21:3 error TS2305 Module '"@opencode/client"' has no exported member 'UserMessage'. + +── src/types/api/model.ts ── + 2:3 error TS2724 '"@opencode/client"' has no exported member named 'ConfigProvidersResponse'. Did you mean 'ConfigProviderSettings'? + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'Model'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'Provider'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthAuthorization'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'ProviderAuthMethod'. + +── src/types/api/permission.ts ── + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionAnswer'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionInfo'. + 5:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionOption'. + 6:3 error TS2305 Module '"@opencode/client"' has no exported member 'QuestionRequest'. + 9:67 error TS2339 Property 'tool' does not exist on type 'PermissionRequest'. + +── src/types/api/project.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Path'. + 4:3 error TS2724 '"@opencode/client"' has no exported member named 'ProjectUpdateData'. Did you mean 'ProjectUpdated'? + +── src/types/api/pty.ts ── + 3:3 error TS2724 '"@opencode/client"' has no exported member named 'PtyCreateData'. Did you mean 'PtyCreated'? + 4:3 error TS2724 '"@opencode/client"' has no exported member named 'PtyUpdateData'. Did you mean 'PtyUpdated'? + +── src/types/api/session.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Session'. + 3:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionCreateData'. Did you mean 'SessionCreated'? + 4:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionForkData'. Did you mean 'SessionForked'? + 5:3 error TS2724 '"@opencode/client"' has no exported member named 'SessionListData'. Did you mean 'SessionMetadata'? + 7:3 error TS2305 Module '"@opencode/client"' has no exported member 'SessionUpdateData'. + +── src/types/api/skill.ts ── + 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'AppSkillsResponse'. + +── src/types/api/tool.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolIds'. + 3:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolList'. + 4:3 error TS2305 Module '"@opencode/client"' has no exported member 'ToolListItem'. + +── src/types/api/vcs.ts ── + 1:15 error TS2305 Module '"@opencode/client"' has no exported member 'VcsDiffData'. + +── src/types/api/worktree.ts ── + 2:3 error TS2305 Module '"@opencode/client"' has no exported member 'Worktree'. + 5:3 error TS2724 '"@opencode/client"' has no exported member named 'WorktreeResetInput'. Did you mean 'WorktreeListInput'? +``` + +## 附录 B:类型报错全量清单(快照 B —— 类型层适配后) + +> 状态:已新增 `src/types/api/v1Model.ts` 并把各类型文件指向它,**尚未**迁移端点。 +> 命令:`npx tsc -b --force` **共 95 条**,**全部集中在 `src/api/`**(下游 0 条 ✅) + +| 文件 | 条数 | +| ----------------------- | ---: | +| `src/api/session.ts` | 17 | +| `src/api/mcp.ts` | 10 | +| `src/api/pty.ts` | 8 | +| `src/api/file.ts` | 8 | +| `src/api/client.ts` | 8 | +| `src/api/permission.ts` | 7 | +| `src/api/config.ts` | 6 | +| `src/api/worktree.ts` | 5 | +| `src/api/message.ts` | 5 | +| `src/api/global.ts` | 5 | +| `src/api/vcs.ts` | 3 | +| `src/api/tool.ts` | 3 | +| `src/api/lsp.ts` | 3 | +| `src/api/command.ts` | 3 | +| `src/api/skill.ts` | 2 | +| `src/api/agent.ts` | 2 | + +→ 这批就是任务 3 的待办清单,已全部处理(21 处迁移 + 58 处显式占位)。 + +### 快照 B 逐条清单(逐条,共 95 条) + +```text +── src/api/agent.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 15:27 error TS2339 Property 'app' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + +── src/api/client.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 56:29 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise; update: (input: ProjectUpdate... + 126:67 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'RequestOptions'. + 134:35 error TS2339 Property 'initGit' does not exist on type '{ list: (requestOptions?: RequestOptions | undefined) => Promise; update: (input: ProjectUpdate... + 152:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ProjectUpdateInput'. + 164:41 error TS2339 Property 'path' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/command.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 42:51 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'CommandListInput'. + 94:7 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'SessionCommandInput'. + +── src/api/config.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 15:40 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigGetInput'. + 23:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 31:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'ConfigUpdateInput'. + 40:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 48:34 error TS2339 Property 'providers' does not exist on type '{ get: (input?: ConfigGetInput | undefined, requestOptions?: RequestOptions | undefined) => Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 100:45 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'FileReadInput'. + 108:32 error TS2339 Property 'status' does not exist on type '{ read: (input: FileReadInput, requestOptions?: RequestOptions | undefined) => Promise; list: (inpu... + 116:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 124:27 error TS2339 Property 'find' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/global.ts ── + 5:15 error TS2305 Module '"@opencode/client"' has no exported member 'GlobalHealthResponse'. + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:27 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 22:20 error TS2339 Property 'global' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promi... + 31:20 error TS2339 Property 'instance' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + +── src/api/lsp.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 20:51 error TS2339 Property 'lsp' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + 42:57 error TS2339 Property 'formatter' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pr... + +── src/api/mcp.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:31 error TS2339 Property 'status' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ... + 22:40 error TS2339 Property 'resource' does not exist on type '{ persistentPty: { read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions | undefined)... + 30:36 error TS2322 Type 'McpServerConfig' is not assignable to type '{ readonly type: "local"; readonly command: readonly string[]; readonly cwd?: string | undefined; readonly ... + 38:34 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpConnectInput'. + 46:37 error TS2353 Object literal may only specify known properties, and 'name' does not exist in type 'McpDisconnectInput'. + 54:39 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 64:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 72:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + 80:24 error TS2339 Property 'auth' does not exist on type '{ list: (input?: McpListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ..... + +── src/api/message.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 63:23 error TS2551 Property 'messages' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + 97:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + 112:15 error TS2339 Property 'question' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Pro... + +── src/api/pty.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 40:38 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyListInput'. + 40:95 error TS7006 Parameter 'pty' implicitly has an 'any' type. + 50:31 error TS2339 Property 'shells' does not exist on type '{ list: (input?: PtyListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise; ... + 58:53 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyCreateInput'. + 66:64 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyGetInput'. + 80:49 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyUpdateInput'. + 89:47 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'PtyRemoveInput'. + +── src/api/session.ts ── + 6:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 31:35 error TS2551 Property 'status' does not exist on type '{ list: (input?: SessionListInput | undefined, requestOptions?: RequestOptions | undefined) => Promise Promise Promise Promise Promise Promise Promise Promise Promise & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise<... + +── src/api/tool.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + 22:27 error TS2339 Property 'tool' does not exist on type '{ rpc: RpcApi & { call: (input: RpcCallInput, requestOptions?: RequestOptions | undefined) => Promise... + +── src/api/vcs.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 17:39 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'VcsGetInput'. + 29:57 error TS2322 Type '"git" | "branch"' is not assignable to type '"branch" | "working" | "committed"'. + +── src/api/worktree.ts ── + 5:24 error TS2305 Module '"./sdk"' has no exported member 'unwrap'. + 14:43 error TS2353 Object literal may only specify known properties, and 'directory' does not exist in type 'WorktreeListInput'. + 22:85 error TS2353 Object literal may only specify known properties, and 'worktreeCreateInput' does not exist in type 'WorktreeCreateInput'. + 30:38 error TS2322 Type 'string | undefined' is not assignable to type 'string'. + 39:29 error TS2339 Property 'reset' does not exist on type '{ list: (input: WorktreeListInput, requestOptions?: RequestOptions | undefined) => Promise; create: (i... +``` diff --git a/docs/opencode-v2-migration-phase0.md b/docs/opencode-v2-migration-phase0.md new file mode 100644 index 000000000..d6165e058 --- /dev/null +++ b/docs/opencode-v2-migration-phase0.md @@ -0,0 +1,644 @@ +# OpenCodeUI 迁移 V2 —— 阶段 0 报告 + +> 状态:**已完成**(阶段 0:依赖修复 + WSL 启动修复 + 端到端冒烟 + §10.4 剩余项核对) +> 执行环境:容器内 Linux,opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`),OpenCodeUI `0.6.46` +> 参照文档:`docs/opencode-v2-migration.md`(下文简称「迁移文档」) +> 日期:2026-09-30 + +## 结论摘要 + +| 任务 | 结果 | +| --------------- | -------------------------------------------------------------------------------------------------- | +| 1. 依赖不一致 | ✅ 已修复,`node_modules` 从 1.4.1 → **1.16.0**;`package.json` / `package-lock.json` **无需改动** | +| 1. 类型检查 | ✅ **零报错**(467 个 `src` 文件参与检查)—— 与"记录一批报错留给后续阶段"的预期不同 | +| 1. 单元测试 | ✅ 全绿(96 文件 / 667 用例),依赖变更无回归 | +| 2. WSL 启动命令 | ✅ 已修复(1 个文件、2 个值 + 中文注释);`opencode.rs` 未动 | +| 3. 端到端冒烟 | ✅ 服务正常起、鉴权正常、`/api/info` 与 `/api/location` 正常;V1 端点确认失效 | +| 4. §10.4 剩余项 | ✅ `directory` vs `location[directory]` 已逐端点核对完(138 个端点);**发现 1 个文档结论错误** | +| 5. 与文档不符 | ⚠️ 共 **8 处**,其中 1 处会影响阶段 1 的改造量估算(见 §5) | + +--- + +## 1. 任务 1:依赖不一致修复 + +### 1.1 修复前后版本对比 + +| 项 | 修复前 | 修复后 | +| --------------------------- | --------------------------- | ----------------------------------------- | +| `package.json` 声明 | `@opencode-ai/sdk: ^1.16.0` | `^1.16.0`(**未改**) | +| `package-lock.json` 锁定 | `1.16.0` | `1.16.0`(**未改**) | +| `node_modules` 实装 | **`1.4.1`** ❌ | **`1.16.0`** ✅ | +| `sdk.pty.shells()` 是否存在 | ❌ 不存在 | ✅ 存在(`dist/v2/gen/sdk.gen.d.ts:755`) | + +**执行的命令与结果**: + +```bash +npm install +# added 15 packages, removed 114 packages, changed 21 packages, and audited 495 packages in 8s +# 14 vulnerabilities (1 low, 6 moderate, 7 high) +``` + +**关键事实**:`package-lock.json` 本来就已经正确锁定 `1.16.0`,只是 `node_modules` 与之不符。 +所以本次 `npm install` **只改了 `node_modules`,没有改任何仓库文件**(`git status` 可证:只有 `wsl_commands.rs` 被修改 + `docs/` 未跟踪)。 + +> 因此「允许改 `package.json` / `package-lock.json`」这条授权**实际未使用** —— 没有需要改的地方。 + +### 1.2 类型检查结果 + +- `package.json` 自带脚本:`"typecheck": "tsc -b"`(另有别名 `type-check`),所以按脚本执行。 +- ⚠️ `tsc -b` 是**增量构建**(缓存 `node_modules/.tmp/*.tsbuildinfo`),可能直接命中缓存。 + 因此额外跑了强制全量:`npx tsc -b --force`。 +- 另用 `npx tsc --noEmit -p tsconfig.app.json --listFiles` 确认 **467 个 `src` 文件**确实参与检查(不是空跑)。 + +**结果:退出码 0,零报错。** + +``` +$ npm run typecheck +> opencodeui@0.6.46 typecheck +> tsc -b +(无任何输出) + +$ npx tsc -b --force +(无任何输出,退出码 0) +``` + +> 📌 **这是阶段 0 最重要的一条"输入缺失"**:迁移文档预期阶段 0 能产出一份「类型报错清单」供后续阶段使用, +> 但依赖修好之后**一条报错都没有** —— 后续阶段的改造清单只能来自文档的端点对照表,不能指望类型检查报错来导航。 + +### 1.3 修复前的坏调用:实际是 **2 处**,不是 1 处 + +迁移文档 §2.3 只列了 `src/api/pty.ts:50` 的 `sdk.pty.shells()`。实际用 1.4.1 的类型复现后有 **2 处**: + +``` +src/api/pty.ts(50,31): error TS2339: Property 'shells' does not exist on type 'Pty'. +src/store/childSessionStore.ts(83,22): error TS2339: Property 'agent' does not exist on type 'Session'. +``` + +- 第 1 处(`pty.shells`):与文档一致。已确认 1.4.1 的 `dist/` 里 `grep -r shells` **零命中**;1.16.0 中有。 +- 第 2 处(`Session.agent`):**文档未提及**。`src/store/childSessionStore.ts:83` 读取 `session.agent`, + 该字段在 1.4.1 的 `Session` 类型中不存在,1.16.0 的 `Session.Info` 中有 `agent?: string`。 + → 也就是说 1.4.1 下**子会话(subtask)创建链路**在类型层面也是坏的,不只是 PTY shells。 + +**复现方法(透明说明)**:项目当时的 `node_modules` 已经是修好之后的 1.16.0,无法直接回滚复现。 +所以采用**不改动项目任何文件**的旁路复现: +把 npm 上的 `@opencode-ai/sdk@1.4.1` 解包到 `/tmp/opencode/sdk141/`, +再写一个临时 tsconfig(`/tmp/opencode/repro-project/tsconfig.json`,`extends` 项目自己的 `tsconfig.app.json`), +用 `compilerOptions.paths` 把 `@opencode-ai/sdk/*` 指向那份 1.4.1 副本,`include` 指向项目的 `src`,然后跑 `tsc`。 + +> 局限:这是"用 1.4.1 的类型定义检查项目源码",能准确反映**类型层面**的坏调用; +> 但它不等价于"在 1.4.1 运行时实际跑一遍",运行时的报错点可能更多(例如动态调用)。 + +### 1.4 单元测试(依赖变更后的回归确认) + +依赖从 1.4.1 换到 1.16.0 属于运行时行为变更,因此补跑了一次单测: + +```bash +timeout 180 npm run test:run # = vitest run(npm test 是 watch 模式,非交互环境下会挂住,故用 run 变体) +``` + +结果:**全绿** —— `Test Files 96 passed (96)` / `Tests 667 passed (667)`,耗时 16.96s,退出码 0。 + +> ⚠️ 但**不要**据此认为"依赖问题会被单测发现"。经查:只有 `src/api/sdk.test.ts` 对 +> `@opencode-ai/sdk/v2/client` 做了 `vi.mock`;`ConfigSettings.search.test.tsx` mock 的是项目自己的 +> `api` 模块(`listAvailableShells: vi.fn()`),**从未真正调用到 `sdk.pty.shells()`**。 +> 两处坏调用都是"属性不存在",只有**真的调用到**才会在运行时抛 `not a function`。 +> → 推断(未实测):1.4.1 状态下这套单测很可能同样是绿的。**这类依赖不一致问题只能靠类型检查或人工比对发现。** + +--- + +## 2. 任务 2:WSL 启动命令修复 + +### 2.1 改动点(文件 : 行号 + 改动前后) + +**唯一改动文件**:`src-tauri/src/app/commands/wsl_commands.rs` + +| | 修改前 | 修改后 | +| ------------------ | ---------------------------------------------------------- | ------------------------------ | +| 行号 | 918–924 行 | 918–928 行 | +| 值(debug 分支) | `"INFO"` | **`"info"`** | +| 值(release 分支) | `"WARN"` | **`"warn"`** | +| 注释 | `// 打包版 WARN / 开发版 INFO(官方 app.isPackaged 分支)` | 改写为小写 + 追加 4 行警示注释 | + +实际 diff: + +```diff +- // 打包版 WARN / 开发版 INFO(官方 app.isPackaged 分支) ++ // 打包版 warn / 开发版 info(官方 app.isPackaged 分支) ++ // ⚠️ opencode V2 的 --log-level 只接受**小写**取值 ++ // (all|trace|debug|info|warn|warning|error|fatal|none); ++ // 传大写 "INFO"/"WARN" 会被 effect/cli 判为非法值并直接报错退出, ++ // 导致 WSL 路径的 opencode serve 根本起不来(stdout 永远等不到监听 URL)。 + format!( + "exec {} --print-logs --log-level {} serve --hostname 0.0.0.0 --port {}", + wsl_runtime::shell_escape(&opencode_path), +- if cfg!(debug_assertions) { "INFO" } else { "WARN" }, ++ if cfg!(debug_assertions) { "info" } else { "warn" }, + port + ), +``` + +### 2.2 遵守的边界 + +- ✅ **`opencode.rs` 未改动**(`git status` 证实:仅 `wsl_commands.rs` 被修改)。它只 spawn `["serve"]`,不带参数,无此问题。 +- ✅ **`OPENCODE_SERVER_USERNAME` 保留**(`wsl_commands.rs:915` 原样未动),符合迁移文档 §1.3③「保留是安全的」。 +- ✅ 未改任何 `src/` 下业务代码,未 commit / push / reset / checkout。 + +### 2.3 验证(CLI 层面,实测) + +| 场景 | 结果 | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--log-level INFO`(改前 debug 值) | ❌ 退出码 **1**,stderr:`~effect/cli/CliError/InvalidValue: Invalid value for flag --log-level: "INFO". Expected: "all" \| "trace" \| ... \| "none"` | +| `--log-level WARN`(改前 release 值) | ❌ 退出码 **1**,同样报错(仅值不同) | +| `--log-level info`(改后 debug 值) | ✅ 正常启动,stdout:`server listening on http://127.0.0.1:4097`,`/api/info` → 200 | +| `--log-level warn`(改后 release 值) | ✅ 正常启动,stdout:`server listening on http://127.0.0.1:4101`,`/api/info` → 200 | + +### 2.4 未验证事项(如实说明) + +- ⚠️ **Rust 未编译验证**:`src-tauri/target/` 不存在(从未构建过),全量 `cargo check` 需要拉取整套 Tauri 依赖树, + 在 12G 内存约束下代价大且耗时长,故**未执行**。 + 判断依据:本次改动仅为 **2 个字符串字面量**(`"INFO"`→`"info"`、`"WARN"`→`"warn"`)+ 注释,不涉及类型/借用/控制流,编译风险极低。 +- ⚠️ **WSL 真实路径未跑**:容器内没有 WSL。已验证的是「CLI 是否接受该参数值」这一**根因**, + 而不是「WSL 里整条启动链路能通」。后者需在 Windows 桌面环境下回归。 + +--- + +## 3. 任务 3:端到端冒烟 + +### 3.1 启动 + +```bash +OPENCODE_SERVER_PASSWORD=test123 opencode --print-logs --log-level info serve --hostname 127.0.0.1 --port 4097 +``` + +- **stdout 打印**:✅ `server listening on http://127.0.0.1:4097`(与迁移文档 §10.2 描述一致) +- 端口按任务要求用 **4097**(容器内另有常驻服务占着 4096,pid 8,全程未打扰) + +### 3.2 逐项 curl 结果 + +基准地址 `http://127.0.0.1:4097`。 + +#### ① `GET /api/info`(带凭证 `opencode:test123`)→ 预期 200 + JSON + +``` +HTTP/1.1 200 OK +content-type: application/json +content-length: 97 + +{"version":"2.0.19","pid":68513,"urls":["http://127.0.0.1:4097"],"paths":{"tmp":"/tmp/opencode"}} +``` + +✅ **通过**。注意响应**没有 `healthy` 字段**(与迁移文档 §4.1 描述一致),后续健康检查不能靠它判活。 + +#### ② `GET /api/info`(无凭证)→ 预期 401 + +``` +HTTP/1.1 401 Unauthorized +www-authenticate: Basic realm="Secure Area" +content-type: application/json +content-length: 64 + +{"_tag":"UnauthorizedError","message":"Authentication required"} +``` + +✅ **通过**。与迁移文档附录 B 记录完全一致(含 `www-authenticate` 头)。 + +#### ③ `GET /global/health`(带凭证)→ 确认 V1 端点已失效 + +``` +HTTP/1.1 200 OK ← ⚠️ 注意:是 200,不是 404 +content-type: text/html +transfer-encoding: chunked + + + + OpenCode + ...(完整的 SPA 首页 HTML)... + +``` + +✅ **确认 V1 端点已失效** —— 返回的是 SPA 兜底页面而非 `{healthy, version}` JSON。 +⚠️ **但状态码是 `200` 而不是 `404`**,这一细节对判断"谁会坏"很关键,见 §5 第 3 条。 + +#### ④ `GET /api/location`(带凭证)→ 预期 `{directory, project:{...}}` + +``` +HTTP/1.1 200 OK +content-type: application/json +content-length: 163 + +{"directory":"/tmp/opencode/smoke","project":{"id":"d1892a84326cadabd9f919e9affd0922d9510e89","directory":"/tmp/opencode/smoke","canonical":"/tmp/opencode/smoke"}} +``` + +✅ **通过**,形状与预期一致。 + +#### ⑤ 补充:`GET /api/health`(V1 的另一个健康端点,Rust 健康检查的第一顺位) + +``` +HTTP/1.1 404 Not Found (带凭证时) +HTTP/1.1 401 Unauthorized (不带凭证时——鉴权中间件先于路由生效) +``` + +✅ 确认 `404`(**这个才是 404**,与 `/global/health` 的 200 形成对比)。 + +### 3.3 `parse_listening_url()`(`opencode.rs:139`)兼容性验证 + +验证方式:**把该函数逐字照抄**到 `/tmp/opencode/urlcheck/` 的一个独立 Rust 小程序里编译运行 +(唯一改动:`reqwest::Url` → `url::Url`,二者是同一个类型,`reqwest` 只是 re-export `url::Url`), +喂入**实测抓到的真实 stdout/stderr 行**: + +``` +"server listening on http://127.0.0.1:4097" => Some("http://127.0.0.1:4097") ✅ +"server listening on http://127.0.0.1:4098" => Some("http://127.0.0.1:4098") ✅ +"server listening on http://0.0.0.0:4096" => Some("http://127.0.0.1:4096") ✅(0.0.0.0 归一化生效) +"INFO server listening on http://127.0.0.1:4097" => Some("http://127.0.0.1:4097") ✅(前缀干扰不影响) +[真实 stderr 日志行] => None ✅(不会误判) +``` + +✅ **结论:`parse_listening_url()` 能正确解析 V2 的 stdout 格式**,与迁移文档 §10.2 一致。 + +> 补充:Rust 侧把 stdout 与 stderr **合并进同一个 channel**(`opencode.rs:112-118`), +> 所以需要确认 stderr 里不会出现 `http://` 造成误判。实测带 `--print-logs` 的 stderr 中 +> `grep -c "http://"` = **0**,`grep "listening"` = **无** → 合并安全。 + +### 3.4 stdout / stderr 日志分离记录 + +| 场景 | stderr 行数 | +| -------------------------------------------------------------------------- | -------------------------------------------------------------- | +| 带 `--print-logs`,刚启动(未发请求) | **0** | +| 带 `--print-logs`,发 3 个 `GET /api/info`(成功 200) | **0**(新增 0) | +| 带 `--print-logs`,发 3 个 `GET /api/location`(首次,触发 location 启动) | **27** | +| 带 `--print-logs`,location 预热后再发 3 个请求 | **0**(新增 0) | +| 带 `--print-logs`,发 3 个**无凭证**请求(401) | **+3**(每请求 1 行 `Sent HTTP response ... http.status=401`) | +| **不带** `--print-logs`,发 3 个请求 | **0** ✅ | + +✅ **核心结论成立**:日志走 stderr、**不污染 stdout**(stdout 只有那行监听 URL); +不带 `--print-logs` 时 stderr 为 0 行。 +⚠️ 但日志**行数的量级与迁移文档描述不符**,详见 §5 第 5 条。 + +stderr 日志格式示例: + +``` +timestamp=2026-09-29T16:30:59.777Z level=INFO run=18218ba4 message="watcher subscribe" path=/tmp type=entries ignores=0 http.span=75 role=server +timestamp=2026-09-29T16:31:15.458Z level=INFO run=18218ba4 message="Sent HTTP response" http.span=0 role=server http.method=GET http.url=/api/info http.status=401 +``` + +--- + +## 4. 任务 4:§10.4 剩余项结论 + +### 4.1 已关闭:`directory` vs `location[directory]` 逐端点核对 + +**核对方法**(三重交叉,全部指定 tag `v2.0.19`,未读 HEAD): + +1. **源码**:`git -C /home/coder/project/opencode show v2.0.19:packages/protocol/src/groups/*.ts` + —— 逐个端点读 `query:` / `payload:` 的 schema 定义。 +2. **机器生成的契约**:`git show v2.0.19:packages/protocol/openapi.json`(113 paths / 136 operations), + 按参数的 `style: deepObject` 判定。 +3. **中间件挂载**:`packages/protocol/src/api.ts` 的 `makeApiFromGroup`,确认每个组用哪个 location 中间件。 + +**交叉校验结果**:源码 136 个 operation 与 openapi.json **完全一致**(`:param` 与 `{param}` 仅记法差异), +另加 spec 未收录的 2 个 pairing 端点 = **138 个端点**。 + +#### 结论 + +| 目录参数写法 | 端点数量 | 说明 | +| ------------------------------------- | -------: | ----------------------------------------- | +| `?location[directory]=`(deepObject) | **57** | location 作用域端点的标准写法 | +| `?directory=`(裸参数) | **1** | **仅** `GET /api/session`(session 列表) | +| 无任何目录参数 | **80** | 含全部 session 作用域端点 + 服务级端点 | +| **合计** | **138** | | + +**四条关键规则**: + +1. **裸 `directory` 只有 1 个端点**:`GET /api/session`(`SessionsQuery`,`directory` 与 `project`+`subpath` 是互斥的两种筛选方式)。 +2. **其余 location 作用域端点一律用 `?location[directory]=`**(deepObject),共 57 个。 +3. **`/api/session/:sessionID/*` 这类 session 作用域端点完全不需要目录参数** —— + location 由 **session 行本身**决定(`SessionLocationMiddleware` → `sessionInfo()` → `instances.provide(session)`)。 + 这对阶段 1/2 很省事:V1 里给这些调用硬塞 `directory` 的做法可以直接删掉。 +4. **`x-opencode-directory` 请求头在 v2.0.19 仍然有效**(优先级:`location[directory]` > 该请求头 > `process.cwd()`), + 依据 `packages/server/src/location.ts:40-47` 的 `requestRef()`。**这一点与迁移文档 §3.3 相悖,见 §5 第 1 条。** + +**补充实测**:裸 `directory` 传给 location 作用域端点会被**静默忽略**(不报错、不 400): + +``` +GET /api/location → directory = /tmp/opencode/smoke (cwd) +GET /api/location?directory=/tmp → directory = /tmp/opencode/smoke (被忽略!) +GET /api/location?location[directory]=/tmp→ directory = /tmp +``` + +⚠️ 这意味着**迁移期参数写错不会立刻报错**,而是静默回落到 `process.cwd()`,容易造成"看起来正常但数据不对"的隐蔽 bug。 +建议阶段 1 在 `directoryUtils.ts` 改造时配一个断言/日志。 + +### 4.2 仍未关闭的项 + +§10.4 共 11 条,10 条已 ✅,本次关闭 1 条(`directory` vs `location[directory]`)。**剩 1 条仍未关闭**: + +| 项 | 状态 | 原因 | +| ------------------------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ❗ `@opencode/client` 的 `subscribe()` 在 Tauri 环境下的表现 | **仍未验证** | 该问题**无法靠读源码回答**:它取决于 Tauri `plugin-http` 的流式(streaming)行为与 WebView 的 `ReadableStream` 支持,必须在真实 Tauri 运行时里跑。且阶段 0 的授权范围不含引入 `@opencode/client`(那是阶段 1 的事),故留到阶段 1。 | + +> 另外,§10.4 之外仍标 ❗ 的还有:§10.1(`@opencode/client` 在浏览器/Tauri 下的流式表现)、 +> §10.5(Docker 锁版本、三形态回归)。这些按迁移文档归属阶段 1/3/4,不在阶段 0 范围。 + +--- + +## 5. 🔴 与文档预测不符之处(本节最重要) + +### ① `x-opencode-directory` 请求头在 V2 **仍然有效**(文档 §3.3 说法不准确) + +**文档原文**(§3.3 表格):`请求头 | x-opencode-directory | 变为 location 对象` + +**实际情况**:该请求头**在 v2.0.19 里依然被读取**,是 `location[directory]` 之后的第二优先级。 + +- 源码依据 `packages/server/src/location.ts:40-47`: + + ```ts + export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref { + const query = new URL(request.url, 'http://localhost').searchParams + const directory = + query.get('location[directory]') || + (request.headers['x-opencode-directory'] ? decode(request.headers['x-opencode-directory']) : process.cwd()) + return Location.Ref.make({ directory: AbsolutePath.make(directory) }) + } + ``` + +- **实测依据**(对运行中的 V2 服务): + + ``` + GET /api/location → {"directory":"/tmp/opencode/smoke", ...} (cwd) + GET /api/location -H "x-opencode-directory: /tmp/opencode" + → {"directory":"/tmp/opencode", ...} ✅ 头生效 + GET /api/location?location[directory]=/tmp → {"directory":"/tmp", ...} + ``` + +**影响(正面)**:阶段 1 中 `src/utils/directoryUtils.ts` 的改造量**可能远小于文档预估** —— +现有基于请求头的 `formatPathForApi()` 逻辑对 57 个 location 作用域端点**可以继续工作**。 +是否要用新的 `location[directory]` 写法,可以按"更贴近官方 SDK 生成代码"的偏好来定,而不是被逼着改。 + +### ② 依赖不一致造成的坏调用是 **2 处**,文档只列了 1 处(§2.3) + +文档只列 `src/api/pty.ts:50` 的 `sdk.pty.shells()`;实际还有: + +``` +src/store/childSessionStore.ts(83,22): error TS2339: Property 'agent' does not exist on type 'Session'. +``` + +→ 1.4.1 下**子会话(subtask)创建链路**在类型层面同样是坏的。虽然修依赖后两者都自愈, +但这条说明「1.4.1 的损坏面比文档记录的大」,可作为阶段 3 排查 subtask 相关功能时的背景信息。 + +### ③ `/global/health` 返回 **200 + HTML**,不是 404 —— 因此 Rust 健康检查会**假阳性**(文档 §1.3② 只覆盖了前端) + +**文档原文**(§1.3②):`→ 健康检查会拿到 HTML SPA 页面而非 {healthy, version} JSON,解析必然失败,界面会认为服务挂了。` + +**实际分两条链路,结论相反**: + +| 链路 | 代码位置 | 判活依据 | V2 下的实际行为 | +| -------- | ------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **前端** | `src/store/serverStore.ts:643`(打 `/global/health`)→ `:678-691` 检查 `content-type` | 必须 `application/json` | ✅ **文档成立**:拿到 `text/html` → `status: 'error'`,提示 "Server returned HTML instead of OpenCode health JSON" → 界面显示服务异常 | +| **Rust** | `opencode.rs:42-76` `is_service_running_with_auth()` | **只看 HTTP 状态码** `r.status().is_success()` | ❌ **文档不成立**:`/api/health` → 401/404(非 2xx)→ 继续试 `/global/health` → **200** → `is_success()` = **true** → **返回"服务在运行"** | + +**这带来两个反直觉后果**: + +1. Rust 侧**不会**因为 V2 而判活失败(歪打正着地返回 `true`),所以 + `start_opencode_service`(`opencode.rs:309/351`)的"服务已就绪"判定**不会被挡住**,桌面路径仍能起来。 + → 文档"健康检查解析必然失败"的说法**只对前端成立**。 +2. 但它同时也**失去了校验能力**:以前能验证 `{healthy:true, version}`,现在只要**任何** HTTP 服务在该路径返回 200 就算"健康"。 + → §9.3 里"改为单一 `GET /api/info`"的整改**仍然必要**,而且**优先级比文档暗示的更高**(它是静默失效,不是显式报错)。 + +### ④ openapi 计数差异(115/247 vs 113/245)可**精确定位**到 2 个端点(文档仅注"计数差异") + +**文档原文**(文首计数说明):`本机 v2.0.19 二进制导出的权威 openapi 为 115 路径 / 247 schema,比 docs 站点快照多 2 条;端点逐一核对全部通过,仅计数差异,不影响结论。` + +**实际原因已查明**:差的 2 条正是 **ServerGroup 的 pairing 端点**: + +| 差异项 | 内容 | +| ------------------ | --------------------------------------------------------------------------------- | +| 多出的 2 条路径 | `POST /api/pair`(`server.pair`)、`GET /auth/connect/{code}`(`server.connect`) | +| 多出的 2 个 schema | `PairingCode`、`PairingSession` | + +证据链: + +1. `packages/protocol/src/groups/server.ts`(tag `v2.0.19`)里这两个端点**存在**,但 + 仓库内提交的 `packages/protocol/openapi.json` 里**没有**(`'/api/pair' in spec.paths === false`, + 且该 spec 中**没有任何非 `/api` 开头的 path**)。 +2. 该 spec 的 245 个 schema 中也**不含** `PairingCode` / `PairingSession` → 245 + 2 = **247**,113 + 2 = **115**,**严丝合缝**。 +3. 为什么 spec 缺?—— `openapi.json` 最后一次更新是 `53179daefa`,而这两个端点由 `eccf0b3b7b` + ("feat(server): pair with one-time connect links (#50970)")加入,且已验证 `53179daefa` 是 `eccf0b3b7b` 的**祖先**。 + → **tag 里提交的 openapi.json 是过期的(生成后未随 pairing 功能重新生成)。** + +**影响**:结论不变(不影响任何端点的判定),但**阶段 1 用 openapi.json 做代码生成/对照时要当心这 2 条缺失**; +同时说明「docs 站点快照 = 仓库里的 openapi.json = 113/245」三者一致,而**二进制才是权威的 115/247**。 + +### ⑤ §10.2 的 stderr 行数描述不准确("发 3 个请求 → stderr 3 行") + +**文档原文**(§10.2):`实测:带它发 3 个请求 → stderr 3 行;不带 → 0 行` + +**实测真实行为**(见 §3.4 表): + +- 成功的请求(200)→ **0 行**(不是每请求 1 行) +- **401 的请求 → 每请求 1 行**(`message="Sent HTTP response" ... http.status=401`) +- **首个 location 作用域请求 → 约 27 行**(watcher/event 启动噪音,一次性) +- location 预热后 → 又是 0 行 + +→ 文档的 "3 行" 最可能来自**3 个鉴权失败(401)的请求**;把它当作"每请求 1 行"的通用规律会误判。 +**真正要记住的结论不变**:`--print-logs` 让日志走 **stderr**,stdout 干净,不带则为 0 行。 + +### ⑥ 阶段 0 预期产出的「类型报错清单」实际为空 + +文档 §8 阶段 0 把"确认 `sdk.pty.shells()` 缺失导致的当前故障状态"列为待办,隐含预期是能捞出一批类型错误。 +实际:**修好依赖后 `tsc` 零报错**。后续阶段**不能**依赖类型检查来发现需要改造的调用点, +必须按 §4 的端点对照表逐个手工迁移。 + +### ⑦ `npm install` 无需改动 `package.json` / `package-lock.json` + +任务描述把这两个文件列为"允许改",但实际上 `package-lock.json` 早已正确锁定 `1.16.0`, +`node_modules` 只是**没按 lock 安装**。所以本次修复**零仓库文件改动**(只有 `wsl_commands.rs` 一处)。 + +### ⑧ 新增发现:目录参数写错是**静默失效**,不会报错 + +(文档未提及)裸 `directory` 传给 location 作用域端点会被**静默忽略**并回落到 `process.cwd()`,HTTP 仍是 200。 +迁移期这类错误不会暴露,建议阶段 1 显式加日志/断言。详见 §4.1 末的实测记录。 + +--- + +## 6. 未做 / 未验证事项(如实汇报) + +| 项 | 状态 | 原因 | +| --------------------------------------------------- | --------- | ------------------------------------------------------------------------------- | +| Rust 侧编译验证(`cargo check`) | ❌ 未做 | 无 `target/` 目录,全量构建代价大;改动仅为 2 个字符串字面量,风险极低 | +| WSL 真实链路回归 | ❌ 未做 | 容器内无 WSL,仅验证了 CLI 层参数取值这一根因 | +| `npm test`(vitest) | ✅ 已跑 | 见 §1.4:96 文件 / 667 用例全绿(用 `vitest run`,因 `npm test` 是 watch 模式) | +| `@opencode/client` 的 `subscribe()` 在 Tauri 下表现 | ❌ 未验证 | 需真实 Tauri 运行时;属阶段 1 范围 | +| Docker 锁版本 / 三形态回归(§10.5) | ❌ 未做 | 属阶段 3/4 范围 | + +**清理说明**:冒烟测试启动的 4 个临时服务(端口 4097 / 4098 / 4100 / 4101 / 4103)**已全部停止**; +容器内原有的 4096 常驻服务(pid 8)**全程未受影响**。 + +--- + +## 附录 A:逐端点全表(`directory` vs `location[directory]`) + +> 数据来源:`packages/protocol/openapi.json`(tag `v2.0.19`,已验证与源码 136 operation 一致) +> +> - `packages/protocol/src/api.ts` 的中间件挂载 + 补录 spec 缺失的 2 个 pairing 端点。 +> 已验证 spec 与源码 136 个 operation 完全一致(`:param` 与 `{param}` 仅记法差异)。 + +### 结论汇总 + +| 写法 | 端点数量 | +| ------------------------------------- | -------: | +| `?location[directory]=`(deepObject) | **57** | +| `?directory=`(裸参数) | **1** | +| 无目录参数 | **80** | +| 合计 | **138** | + +### 关键结论 + +1. **裸 `directory` 只有 1 个端点**:`GET /api/session`(session 列表)。 +2. **`location[directory]` 是绝大多数 location 作用域端点的写法**,共 57 个。 +3. **session 作用域端点(`/api/session/:sessionID/*`)不需要任何目录参数** —— location 由 session 行决定(`SessionLocationMiddleware`)。 +4. **`x-opencode-directory` 请求头在 v2.0.19 仍然有效**(`packages/server/src/location.ts:40-47`),作为 `location[directory]` 之后的第二优先级,已实测确认。 +5. 裸 `directory` 传给 location 作用域端点会被**静默忽略**(实测 `GET /api/location?directory=/tmp` 仍返回 cwd),不报错。 + +### 全表 + +| 方法 | 路径 | 端点 ID | 组 | 作用域 | 目录参数写法 | query 参数 | +| ------ | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | ----------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| GET | `/api/agent` | agent.list | agent | locationRef | `location[directory]` | location | +| GET | `/api/agent/{agentID}` | agent.get | agent | locationRef | `location[directory]` | location | +| GET | `/api/command` | command.list | command | locationRef | `location[directory]` | location | +| GET | `/api/config` | config.get | config | locationRef | `location[directory]` | location | +| GET | `/api/config/shell` | config.shells | config | locationRef | 无 | — | +| DELETE | `/api/credential/{credentialID}` | credential.remove | credential | none(服务级) | 无 | — | +| PATCH | `/api/credential/{credentialID}` | credential.update | credential | none(服务级) | 无 | — | +| POST | `/api/credential/{credentialID}/activate` | credential.activate | credential | none(服务级) | 无 | — | +| DELETE | `/api/debug/location` | debug.location.evict | debug | locationRef(handler 内 requestRef) | `location[directory]` | location | +| GET | `/api/debug/location` | debug.location.list | debug | locationRef(handler 内 requestRef) | 无 | — | +| GET | `/api/event` | event.subscribe | event | none(服务级全量) | 无 | — | +| PATCH | `/api/experimental/config` | experimental.config.update | config | locationRef | 无 | — | +| POST | `/api/experimental/fs/write` | experimental.fs.write | filesystem | locationRef | `location[directory]` | location, path | +| POST | `/api/experimental/generate` | experimental.generate.text | generate | none(服务级) | 无 | — | +| POST | `/api/experimental/integration/wellknown` | experimental.integration.wellknown.add | integration | locationRef | `location[directory]` | location | +| DELETE | `/api/experimental/mcp/{server}` | experimental.mcp.remove | mcp | locationRef | `location[directory]` | location | +| PUT | `/api/experimental/mcp/{server}` | experimental.mcp.add | mcp | locationRef | `location[directory]` | location | +| POST | `/api/experimental/mcp/{server}/connect` | experimental.mcp.connect | mcp | locationRef | `location[directory]` | location | +| POST | `/api/experimental/mcp/{server}/disconnect` | experimental.mcp.disconnect | mcp | locationRef | `location[directory]` | location | +| GET | `/api/experimental/migration/v1` | experimental.migration.v1.status | migration | none(服务级) | 无 | — | +| DELETE | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.remove | persistentPty | none(服务级) | 无 | — | +| GET | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.get | persistentPty | none(服务级) | 无 | — | +| PUT | `/api/experimental/persistent-pty/{ptyID}` | server.experimental.persistentPty.update | persistentPty | none(服务级) | 无 | — | +| GET | `/api/experimental/persistent-pty/{ptyID}/connect` | persistentPty.connect | persistentPty | none(服务级) | 无 | cursor, role, attachment_id, takeover, input_protocol, ticket | +| POST | `/api/experimental/persistent-pty/{ptyID}/connect-token` | server.experimental.persistentPty.connectToken | persistentPty | none(服务级) | 无 | — | +| GET | `/api/experimental/persistent-pty/{ptyID}/snapshot` | server.experimental.persistentPty.snapshot | persistentPty | none(服务级) | 无 | — | +| POST | `/api/experimental/persistent-pty/handoff` | server.experimental.persistentPty.handoff | persistentPty | none(服务级) | 无 | — | +| POST | `/api/experimental/persistent-pty/shutdown` | server.experimental.persistentPty.shutdown | persistentPty | none(服务级) | 无 | — | +| GET | `/api/experimental/session/{sessionID}/export` | experimental.session.export | session | sessionRef(从 session 行取 location) | 无 | sanitize | +| GET | `/api/experimental/session/{sessionID}/instructions/entries` | experimental.session.instructions.entry.list | session | sessionRef(从 session 行取 location) | 无 | — | +| DELETE | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | experimental.session.instructions.entry.remove | session | sessionRef(从 session 行取 location) | 无 | — | +| PUT | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | experimental.session.instructions.entry.put | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/experimental/session/{sessionID}/log` | session.log | session | sessionRef(从 session 行取 location) | 无 | after, follow | +| POST | `/api/experimental/session/{sessionID}/skill` | experimental.session.skill | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/experimental/session/{sessionID}/terminal` | server.experimental.persistentPty.list | persistentPty | none(服务级) | 无 | — | +| POST | `/api/experimental/session/{sessionID}/terminal` | server.experimental.persistentPty.create | persistentPty | none(服务级) | 无 | — | +| GET | `/api/experimental/session/{sessionID}/terminal/read` | server.experimental.persistentPty.read | persistentPty | none(服务级) | 无 | lines | +| POST | `/api/experimental/session/{sessionID}/wait` | experimental.session.wait | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/experimental/session/import` | experimental.session.import | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/experimental/session/stats` | experimental.session.stats | session | sessionRef(从 session 行取 location) | 无 | from, to, project, timezone, tools | +| GET | `/api/form` | form.list | form | locationRef | `location[directory]` | location | +| GET | `/api/fs/find` | fs.find | filesystem | locationRef | `location[directory]` | location, query, type, limit | +| GET | `/api/fs/list` | fs.list | filesystem | locationRef | `location[directory]` | location, path | +| GET | `/api/fs/read/*` | fs.read | filesystem | locationRef | `location[directory]` | location | +| GET | `/api/info` | server.info | server | none(服务级) | 无 | — | +| GET | `/api/integration` | integration.list | integration | locationRef | `location[directory]` | location | +| GET | `/api/integration/{integrationID}` | integration.get | integration | locationRef | `location[directory]` | location | +| POST | `/api/integration/{integrationID}/connect/command` | integration.command.connect | integration | locationRef | `location[directory]` | location | +| DELETE | `/api/integration/{integrationID}/connect/command/{attemptID}` | integration.command.cancel | integration | locationRef | `location[directory]` | location | +| GET | `/api/integration/{integrationID}/connect/command/{attemptID}` | integration.command.status | integration | locationRef | `location[directory]` | location | +| POST | `/api/integration/{integrationID}/connect/key` | integration.connect.key | integration | locationRef | `location[directory]` | location | +| POST | `/api/integration/{integrationID}/connect/oauth` | integration.oauth.connect | integration | locationRef | `location[directory]` | location | +| DELETE | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | integration.oauth.cancel | integration | locationRef | `location[directory]` | location | +| GET | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | integration.oauth.status | integration | locationRef | `location[directory]` | location | +| POST | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | integration.oauth.complete | integration | locationRef | `location[directory]` | location | +| GET | `/api/location` | location.get | location | locationRef | `location[directory]` | location | +| POST | `/api/location/reload` | location.reload | location | locationRef | 无 | — | +| GET | `/api/mcp` | mcp.list | mcp | locationRef | `location[directory]` | location | +| GET | `/api/mcp/resource` | mcp.resource.catalog | mcp | locationRef | `location[directory]` | location | +| GET | `/api/model` | model.list | model | locationRef | `location[directory]` | location | +| GET | `/api/model/default` | model.default | model | locationRef | `location[directory]` | location | +| POST | `/api/pair` | server.pair | server | none(服务级) | 无 | — | +| GET | `/api/permission/request` | permission.request.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | `location[directory]` | location | +| GET | `/api/permission/saved` | permission.saved.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | projectID | +| DELETE | `/api/permission/saved/{id}` | permission.saved.remove | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — | +| GET | `/api/plugin` | plugin.list | plugin | locationRef | `location[directory]` | location | +| POST | `/api/plugin/check` | plugin.check | plugin | locationRef | `location[directory]` | location | +| POST | `/api/plugin/update` | plugin.update | plugin | locationRef | `location[directory]` | location | +| GET | `/api/project` | project.list | project | locationRef | 无 | — | +| PATCH | `/api/project/{projectID}` | project.update | project | locationRef | 无 | — | +| GET | `/api/provider` | provider.list | provider | locationRef | `location[directory]` | location | +| GET | `/api/provider/{providerID}` | provider.get | provider | locationRef | `location[directory]` | location | +| GET | `/api/pty` | pty.list | pty | locationRef | `location[directory]` | location | +| POST | `/api/pty` | pty.create | pty | locationRef | `location[directory]` | location | +| DELETE | `/api/pty/{ptyID}` | pty.remove | pty | locationRef | `location[directory]` | location | +| GET | `/api/pty/{ptyID}` | pty.get | pty | locationRef | `location[directory]` | location | +| PUT | `/api/pty/{ptyID}` | pty.update | pty | locationRef | `location[directory]` | location | +| GET | `/api/pty/{ptyID}/connect` | pty.connect | pty | locationRef | 无 | location[directory], cursor, ticket | +| POST | `/api/pty/{ptyID}/connect-token` | pty.connect.token | pty | locationRef | `location[directory]` | location | +| GET | `/api/reference` | reference.list | reference | locationRef | `location[directory]` | location | +| POST | `/api/rpc/{rpcID}/{method}` | rpc.call | rpc | locationRef | `location[directory]` | location | +| GET | `/api/session` | session.list | session | sessionRef(从 session 行取 location) | **`directory`(裸)** | limit, order, search, parentID, directory, project, subpath, cursor | +| POST | `/api/session` | session.create | session | sessionRef(从 session 行取 location) | 无 | — | +| DELETE | `/api/session/{sessionID}` | session.remove | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}` | session.get | session | sessionRef(从 session 行取 location) | 无 | — | +| PATCH | `/api/session/{sessionID}` | session.update | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/agent` | session.switchAgent | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/background` | session.background | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/command` | session.command | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/compact` | session.compact | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/context` | session.context | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/diff` | session.diff | session | sessionRef(从 session 行取 location) | 无 | from, to, context | +| PUT | `/api/session/{sessionID}/environment` | session.environment | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/fork` | session.fork | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/form` | session.form.list | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/form` | session.form.create | session | sessionRef(从 session 行取 location) | 无 | — | +| DELETE | `/api/session/{sessionID}/form/{formID}` | session.form.cancel | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/form/{formID}` | session.form.get | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/form/{formID}/reply` | session.form.reply | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/generate` | session.generate | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/inbox` | session.inbox.list | session | sessionRef(从 session 行取 location) | 无 | — | +| DELETE | `/api/session/{sessionID}/inbox/{inboxID}` | session.inbox.cancel | session | sessionRef(从 session 行取 location) | 无 | — | +| PATCH | `/api/session/{sessionID}/inbox/{inboxID}` | session.inbox.update | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/interrupt` | session.interrupt | session | sessionRef(从 session 行取 location) | 无 | resume | +| GET | `/api/session/{sessionID}/message` | session.message.list | session | sessionRef(从 session 行取 location) | 无 | limit, order, cursor, type | +| GET | `/api/session/{sessionID}/message/{messageID}` | session.message.get | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/model` | session.switchModel | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/move` | session.move | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/{sessionID}/permission` | session.permission.list | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — | +| POST | `/api/session/{sessionID}/permission` | session.permission.create | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — | +| GET | `/api/session/{sessionID}/permission/{requestID}` | session.permission.get | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — | +| POST | `/api/session/{sessionID}/permission/{requestID}/reply` | session.permission.reply | permission | 混合:/api/permission/_ 走 locationRef;/api/session/:id/permission/_ 走 sessionRef | 无 | — | +| POST | `/api/session/{sessionID}/prompt` | session.prompt | session | sessionRef(从 session 行取 location) | 无 | — | +| DELETE | `/api/session/{sessionID}/revert` | session.revert.clear | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/revert/commit` | session.revert.commit | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/revert/stage` | session.revert.stage | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/shell` | session.shell | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/synthetic` | session.synthetic | session | sessionRef(从 session 行取 location) | 无 | — | +| POST | `/api/session/{sessionID}/view` | session.view | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/session/active` | session.active | session | sessionRef(从 session 行取 location) | 无 | — | +| GET | `/api/shell` | shell.list | shell | locationRef | `location[directory]` | location | +| POST | `/api/shell` | shell.create | shell | locationRef | `location[directory]` | location | +| DELETE | `/api/shell/{id}` | shell.remove | shell | locationRef | `location[directory]` | location | +| GET | `/api/shell/{id}` | shell.get | shell | locationRef | `location[directory]` | location | +| GET | `/api/shell/{id}/output` | shell.output | shell | locationRef | `location[directory]` | location, cursor, limit | +| GET | `/api/skill` | skill.list | skill | locationRef | `location[directory]` | location | +| GET | `/api/vcs` | vcs.get | vcs | locationRef | `location[directory]` | location | +| GET | `/api/vcs/base` | vcs.base | vcs | locationRef | `location[directory]` | location | +| GET | `/api/vcs/branch` | vcs.branch.list | vcs | locationRef | `location[directory]` | location, search, limit | +| GET | `/api/vcs/diff` | vcs.diff | vcs | locationRef | `location[directory]` | location, mode, base, context | +| GET | `/api/vcs/status` | vcs.status | vcs | locationRef | `location[directory]` | location | +| POST | `/api/websearch` | websearch.query | websearch | locationRef | `location[directory]` | location | +| GET | `/api/websearch/provider` | websearch.providers | websearch | locationRef | `location[directory]` | location | +| DELETE | `/api/worktree` | worktree.remove | worktree | none(用 projectID) | 无 | — | +| GET | `/api/worktree` | worktree.list | worktree | none(用 projectID) | 无 | projectID | +| POST | `/api/worktree` | worktree.create | worktree | none(用 projectID) | 无 | — | +| POST | `/api/worktree/refresh` | worktree.refresh | worktree | none(用 projectID) | 无 | — | +| GET | `/auth/connect/{code}` | server.connect | server | none(服务级) | 无 | — | diff --git a/docs/opencode-v2-migration-phase2a.md b/docs/opencode-v2-migration-phase2a.md new file mode 100644 index 000000000..c6e1e7f45 --- /dev/null +++ b/docs/opencode-v2-migration-phase2a.md @@ -0,0 +1,778 @@ +# OpenCode V2 迁移 · 阶段 2a 报告(消息模型 + 历史加载,读侧) + +> 状态:**✅ 已完成**(2026-09-30) +> 范围:**只做读侧** —— 历史消息加载、转换、渲染、游标分页。 +> 发消息、事件流、流式回复**本次未动**(归阶段 2b)。 +> 前置:阶段 0(`docs/opencode-v2-migration-phase0.md`)、阶段 1(`docs/opencode-v2-migration-phase0.5.md`)已完成。 +> **后续**:阶段 2b(事件流 + 发消息)**已完成**,报告见 `docs/opencode-v2-migration-phase2b.md`。 +> 实测环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19` +> 主文档:`docs/opencode-v2-migration.md`(本阶段已按实测回填 §5.1 / §5.2 / §5.4 / §5.5 / §8) + +--- + +## 0. 先说三件必须交代的事 + +### 0.1 ⚠️ 工作目录:阶段 2a 的改动在**主仓库**,不在 worktree 里 + +本次会话启动时的「工作目录」是一个**新建的 git worktree** +(`/home/coder/.local/share/opencode/worktree/77e28b/crisp-lagoon`), +但它**不包含前两个阶段的任何成果** —— 没有 `docs/`、没有 `src/api/notMigrated.ts`、 +没有 `src/api/v2Convert.ts`、`package.json` 里也没有 `@opencode/client`。 + +原因:阶段 0/1 的全部产出都是**未提交的工作区改动**,而 git worktree 只签出已提交的 +HEAD(`8a6d4eae`),不会带过来。另外该 worktree 里 717 个文件被显示为「已修改」, +是容器文件系统给新写入文件加执行位导致的权限位噪音,不是真实改动。 + +→ **本阶段把会话切到真正的仓库目录 `/home/coder/project/OpenCodeUI` 上工作**, +与阶段 0/1 的成果放在一起。全部改动都在这个目录里。 + +### 0.2 ✅ 硬性约束逐条对照 + +| 约束 | 结果 | +| ------------------------------------------------------ | ------------------------------------------------------ | +| 允许改:`src/types/api/message.ts` | ✅ 重写 | +| 允许改:`src/types/api/v1Model.ts`(仅消息/Part 部分) | ⚠️ **未删任何导出**,原因见 §1.4(被冻结的事件层钉住) | +| 允许改:`src/utils/messageConversion.ts` | ✅ 重写 | +| 允许改:`src/api/message.ts` | ✅ 游标分页 | +| 允许改:`messageStore` | ✅ 数据结构 + 合并 + 游标 | +| 允许改:对应渲染组件 | ✅ `MessageRenderer` + 新增 `SessionMarkerPartView` | +| 允许改:`docs/` | ✅ 主文档已回填 + 本报告 | +| **禁止改**:`src/api/events.ts` | ✅ **零改动**(`git diff` 无此文件) | +| **禁止改**:发消息链路 | ✅ `sendMessage` / `sendMessageAsync` 仍是显式报错占位 | +| **禁止改**:`src-tauri/` | ✅ 零改动 | +| **禁止改**:`src/api/notMigrated.ts` 报错语义 | ✅ 零改动(且新增 3 条用例把语义钉死) | +| 禁止删除 `v1Model.ts` 非消息/Part 导出 | ✅ 一个都没删 | +| 禁止 git commit/push/reset/checkout | ✅ 未执行 | +| 禁止删除 `docs/` 下文件 | ✅ 未删除 | +| 类型检查 0 报错 | ✅ `npx tsc -b` 无输出 | +| `npm test` 现有用例不许挂 | ✅ 基线 683 例全部仍通过,**0 失败** | + +### 0.3 只做读侧 —— 本阶段**没有**做的事 + +- ❌ `src/api/events.ts`(事件订阅/解析/重连)—— 一行没动 +- ❌ `coalesceEvents` delta 合并 —— 一行没动 +- ❌ `EventTypes` 常量表 —— 一行没动 +- ❌ 发消息链路(`POST /api/session/{id}/prompt`)—— 仍是 `notMigratedYet` 占位 +- ❌ `v1Model.ts` 的删除 —— 见 §1.4 + +--- + +## 1. 前置决策:`v1Model.ts` 分类结果 + +`src/types/api/v1Model.ts`:**3502 行 / 189 个顶层 `export`**。 +按「本阶段要换掉 / 阶段 3 再动 / 跨组」分三桶: + +| 桶 | 数量 | 含义 | +| ----------------------------- | ------: | ---------------------------------------------------------------------------------------------------------------------------- | +| **A:消息 / Part / 消息事件** | **66** | 被 V2 消息模型推翻,属阶段 2 | +| **B:阶段 3** | **104** | permission / session / config / agent / mcp / pty / file / vcs / worktree / lsp / todo / provider / model / project / auth … | +| **C:跨组 / 不明确** | **19** | 同时被多组引用,单独删会连带打断别的组 | +| 合计 | **189** | ✅ 与 `grep -c "^export "` 一致 | + +### 1.1 A 桶(66 个)—— 消息 / Part / 消息事件 + +| 分组 | 类型 | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 消息主体(4) | `UserMessage`、`AssistantMessage`、`Message`、`Prompt` | +| Part 联合成员(12+) | `TextPart`、`ReasoningPart`、`ToolPart`、`FilePart`、`AgentPart`、`StepStartPart`、`StepFinishPart`、`SnapshotPart`、`PatchPart`、`SubtaskPart`、`RetryPart`、`CompactionPart`、`Part` | +| 工具状态机(5) | `ToolStatePending`、`ToolStateRunning`、`ToolStateCompleted`、`ToolStateError`、`ToolState` | +| Part 来源(5) | `FilePartSourceText`、`FileSource`、`SymbolSource`、`ResourceSource`、`FilePartSource` | +| 用户消息的格式/摘要(4) | `OutputFormatText`、`JsonSchema`、`OutputFormatJsonSchema`、`OutputFormat` | +| 消息错误联合(6) | `ProviderAuthError`、`UnknownError`、`MessageOutputLengthError`、`MessageAbortedError`、`StructuredOutputError`、`ContextOverflowError`、`ApiError` | +| Part 输入(4) | `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput` | +| `message.*` 同步事件(4) | `SyncEventMessageUpdated`、`SyncEventMessageRemoved`、`SyncEventMessagePartUpdated`、`SyncEventMessagePartRemoved` | +| `session.next.*` 消息流事件(18) | `...Synthetic`、`...StepStarted/Ended/Failed`、`...TextStarted/Ended`、`...ReasoningStarted/Ended`、`...ToolInputStarted/Ended`、`...ToolCalled/Progress/Success/Failed`、`...Retried`、`...CompactionStarted/Delta/Ended` | +| 事件载荷(2) | `EventMessagePartRemoved`、`EventMessagePartDelta` | + +### 1.2 C 桶(19 个)—— 跨组,建议随 `GlobalEvent` 一起处理 + +`SnapshotFileDiff`(消息 summary ↔ file 组)、`Range`(Part 来源 ↔ Symbol)、 +**`GlobalEvent`(巨型联合,横跨全部组)**、`PromptSource` / `PromptFileAttachment` / +`PromptAgentAttachment` / `PromptReferenceAttachment`(message ↔ file/agent/reference)、 +`SessionErrorUnknown`、`ToolTextContent` / `ToolFileContent`(V2 属消息模型,V1 仅用于工具事件)、 +`SessionNextRetryError`、`SyncEventSessionNextAgentSwitched` / `ModelSwitched` / `Moved` / +`Prompted` / `PromptAdmitted` / `PromptPromoted` / `ShellStarted` / `ShellEnded`。 + +### 1.3 B 桶(104 个)—— 阶段 3 再动,本阶段一律保留 + +permission(`PermissionAction/Rule/Ruleset/Request`、`Question*`、`PermissionV2*`、`QuestionV2*`)、 +session(`Session`、`SessionStatus`、`SessionListData/CreateData/UpdateData/ForkData`、 +`SyncEventSessionCreated/Updated/Deleted`、`EventSessionDiff/Status/Idle`)、 +config(`Config`、`AgentConfig`、`ProviderConfig`、`PermissionConfig`、`LogLevel`、`ServerConfig`、 +`ReferenceConfig*`、`LayoutConfig`、`PolicyEffect`、`ConfigV2ExperimentalPolicy`…)、 +file(`File`、`FileNode`、`FileContent`、`Symbol`、`FindText*`)、 +mcp(`McpStatus*`、`McpResource`、`McpLocalConfig`…)、vcs / worktree / pty / tool / lsp / +todo(`Todo`、`EventTodoUpdated`)/ project / agent / model(`Model`、`Provider`、 +`ProviderAuthMethod/Authorization`、`ModelV2Info`、`LocationRef`)/ auth(`Auth*`)/ +server(`EventServerInstanceDisposed`、`GlobalHealth*`)。 + +### 1.4 🔴 本阶段**没有删除** `v1Model.ts` 的任何导出 —— 以及为什么 + +任务要求「本阶段只删除/替换『消息与 Part』那一组」。实测后**无法在本阶段安全删除**,原因是 +**冻结的事件层把整条依赖链钉死了**: + +``` +src/api/events.ts (本阶段禁止改动) + └─ import type { GlobalEvent } from './types' + └─ GlobalEvent(v1Model.ts:595,巨型联合) + └─ SyncEventMessageUpdated → properties.info: Message + └─ SyncEventMessagePartUpdated → properties.part: Part + └─ EventMessagePartDelta / Removed → Part + └─ SyncEventSessionNext* → 大量引用 Message / Part / ToolState +``` + +`GlobalEvent` 里嵌着 `Message` / `Part` / `ToolState`,而 `events.ts` 又必须导入 `GlobalEvent` +(用于 `coalesceEvents` / `parseGlobalEvent` / `isGlobalEvent` / `handleEventForSubscriber`)。 +**只要事件层不动,A 桶的这 66 个类型就一个都删不掉。** + +本阶段的实际处理: + +1. **`src/types/api/message.ts` 重写为 V2 模型**,V1 别名集中在文件末尾的 + 「五、⚠️ 遗留 V1 别名(阶段 2b 删除)」一节,逐个标 `@deprecated` + 理由。 +2. **在 `message.ts` 的注释里给出「下游零引用、可在 2b 一并清理」的清单**: + `UserMessage`、`AssistantMessage`(仅 `types/index.ts` 的类型守卫在用)、 + `ReasoningPart`、`ToolPart`、`ToolState`、`StepStartPart`、`StepFinishPart`、 + `SnapshotPart`、`PatchPart`、`RetryPart`、`CompactionPart`、`SubtaskPart`、 + `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput` + —— 保留它们只是为了让 `src/api/types.ts` / `src/types/api/index.ts` 这两个 + **本阶段授权范围之外**的转发文件保持原样。 +3. **A 桶的 66 个定义原样留在 `v1Model.ts`**,留给 2b 随事件层一起删。 + +> 📌 **对阶段 3 工作量判断的影响**:B 桶 104 个是阶段 3 的真实工作量; +> A 桶 66 个 + C 桶 19 个(共 85 个)会**在 2b 一次性消失**, +> 但**必须等 `events.ts` 重写完之后**。也就是说 2b 的收尾动作之一是 +> 「删 `events.ts` 对 `GlobalEvent` 的依赖 → 删 `GlobalEvent` → 删 A/C 两桶」。 + +--- + +## 2. 消息模型映射表:V1 字段 → V2 字段(逐字段) + +### 2.1 结构层 + +| | V1 | V2 | +| -------- | ------------------------------ | ------------------------------------------------------------------ | +| 端点 | `GET /session/{id}/message` | **`GET /api/session/{id}/message`** | +| 响应 | `MessageWithParts[]`(裸数组) | **`{ data: Session.Message.Info[], cursor: { previous, next } }`** | +| 结构 | **两层**:`{ info, parts }` | **扁平联合**:消息自带 `content` | +| 判别字段 | `role: 'user' \| 'assistant'` | **`type`**(11 种) | +| 分页 | 无(一次全量) | **游标**(`limit` / `order` / `cursor` / `type`) | +| SDK 入口 | `sdk.session.messages()` | **顶层** `sdk.message.list()`(不是 `session.message.list`) | + +### 2.2 11 种消息类型(`type` 判别) + +| TS 类型名 | 线上 `type` 值 | V1 对应物 | +| ------------------ | ------------------------- | --------------------------------------------------- | +| `User` | `"user"` | `UserMessage` | +| `Assistant` | `"assistant"` | `AssistantMessage` | +| `System` | `"system"` | ❌ 无(新增) | +| `Skill` | `"skill"` | ❌ 无(新增) | +| `Shell` | `"shell"` | ❌ 无(新增) | +| `Synthetic` | `"synthetic"` | ❌ 无(新增,V1 只有 synthetic **part**) | +| `Compaction` | `"compaction"` | `compaction` **part** | +| `Idle` | `"idle"` | `step-start` / `step-finish` part(语义:一轮结束) | +| `AgentSelected` | **`"agent-switched"`** | `session.updated` 事件 | +| `ModelSelected` | **`"model-switched"`** | `session.updated` 事件 | +| `LocationSwitched` | **`"location-switched"`** | ❌ 无(新增) | + +> ⚠️ 后三个**类型名与线上取值不一致**(源码 `Schema.tag("agent-switched")`)。 + +### 2.3 `User` 消息逐字段 + +| 概念 | V1 | V2 | 处理 | +| --------------- | --------------------------------- | ------------------------------------------ | ---------------------- | +| `id` | ✅ | ✅ `id` | 直接映射 | +| `sessionID` | ✅ | ❌ **已删除** | 转换层由调用方补 | +| `role` | `'user'` | → `type: 'user'` | 改名 | +| 文本 | `parts[].type === 'text'` | **`text`(直接字段)** | 摊平成 1 个 text part | +| 附件 | `parts[].type === 'file'` | **`files: PromptFileAttachment[]`** | 见 2.4 | +| agent | `parts[].type === 'agent'` | **`agents: PromptAgentAttachment[]`** | 见 2.4 | +| 技能 | ❌ | **`skills: PromptSkillAttachment[]`** | 新增 UI `skill` part | +| `agent`(顶层) | ✅ `agent: string` | ❌ 已删除(实测在 `metadata.agent`) | 从 metadata 兜底 | +| `model`(顶层) | ✅ `model: {providerID, modelID}` | ❌ 已删除(实测在 `metadata.model`) | 从 metadata 兜底 | +| `summary` | ✅ `{title?, body?, diffs?}` | ❌ **已删除** | 留空,下游回退正文首行 | +| `time` | `{created, completed?}` | `{created}`(**无 completed**) | 丢掉 completed | +| `metadata` | ❌ | ✅ `Record`(**非契约**) | 仅用于取 agent/model | + +### 2.4 用户附件:`FilePart` → `PromptFileAttachment` + +| 概念 | V1 `FilePart` | V2 `PromptFileAttachment` | 处理 | +| ---------- | --------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- | +| `id` | ✅ | ❌ | 按 `消息id:file:下标` 合成 | +| `filename` | ✅ `filename?` | → `name?` | 改名 | +| `mime` | ✅ | ✅ | 直接映射 | +| `url` | ✅ **可直接渲染** | ❌ 无;内容在 `data`(**base64**) | `uri` 源直接用;`inline` 源拼 `data:;base64,…` | +| `source` | `FilePartSource`(file/symbol/resource 三态) | `{type:'inline'} \| {type:'uri',uri}`(两态,**语义不同**) | 只保留 uri → `path` | +| 文本位置 | `source.text: {value,start,end}` | `mention?: {start,end,text}` | 映射成 V1 的 `source.text` | + +> 实测:本机 1637 条 user 消息里 **22 条带附件,全部是 inline base64 图片**。 +> V1 的 `url` 是直接塞给 `` 的 → 不拼 data URL 就会显示裂图。 + +### 2.5 `Assistant` 消息逐字段 + +| 概念 | V1 | V2 | 处理 | +| --------------------- | -------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------ | +| `id` | ✅ | ✅ | 直接映射 | +| `sessionID` | ✅ | ❌ **已删除** | 调用方补 | +| `parentID` | ✅(指向 user 消息) | ❌ **已删除** | 填 `''`(实测 UI 只用 `Session.parentID`,不用消息的) | +| `role` | `'assistant'` | → `type: 'assistant'` | 改名 | +| 内容 | `parts: Part[]`(**独立数组**,靠 messageID 关联) | **`content: (Text\|Reasoning\|Tool)[]`(内嵌)** | 摊平成 UI parts | +| 模型 | `modelID` + `providerID` 两个散字段 | **`model: ModelRef`(字段名是 `id`)** | 改名拆回 | +| `mode` | ✅ | ❌ **已删除** | 填 `''` | +| `path`(cwd/root) | ✅ | ❌ **已删除** | 填 `{cwd:'',root:''}` | +| `summary`(是否摘要) | ✅ `summary?: boolean` | ❌ **已删除** | 填 `false` | +| 成本 | `step-finish` part 的 `cost` | **`cost?: MoneyUSD`(顶层,可选)** | 上移 + 合成 step-finish part | +| 用量 | `step-finish` part 的 `tokens` | **`tokens?: TokenUsageInfo`(顶层,可选)** | 同上;缺省补 0 | +| 结束原因 | `step-finish` part 的 `reason: string` | **`finish?: 6 字面量联合`** | 收窄 | +| `rawFinish` | ❌ | ✅ 新增 | 丢弃(渲染层零消费) | +| 错误 | `error: MessageError`(`{name,data}` 5 元联合) | **`error: {type, message, status?}`(扁平开放字符串)** | 按关键字映射回 5 元 | +| 重试 | `retry` **part** | **`retry` 字段**(内嵌 `{attempt, at, error}`) | 反向合成 `retry` part | +| 快照 | `snapshot` / `patch` **part** | `snapshot` 字段(`{start,end,files?}`) | 丢弃(渲染层零消费) | +| 步骤分隔 | `step-start` / `step-finish` **part** | ❌ **已删除**(改用 `Idle` 消息) | 合成 1 个尾部 step-finish | +| `time` | `{created, completed?}` | `{created, streamed?, completed?}` | 新增 `streamed`,丢弃 | + +### 2.6 Assistant `content` 逐字段 + +| 概念 | V1 | V2 | 处理 | +| ------------------------------ | ------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------ | +| text `id` | ✅ `TextPart.id` | ❌ **没有** | 按 `消息id:content:下标` 合成 | +| text `sessionID` / `messageID` | ✅ | ❌ | 调用方补 | +| text `synthetic` | ✅ `synthetic?: boolean` | ❌ **已删除**(改用独立 `system`/`synthetic` 消息类型) | 一律 `false` | +| reasoning `time` | ✅ 必填 `{start, end?}` | `{created, completed?}` **可选** | 缺省用消息 `created` 兜底 | +| reasoning `state` | ❌ | ✅ `providerState` | 丢弃 | +| tool `id` | `callID` | **`id`** | 改名 | +| tool 名 | `tool` | **`name`** | 改名 | +| tool `executed` | ❌ | ✅ 新增 | 丢弃 | +| tool `time` | `state.time: {start,end}` | **`time: {created,ran?,completed?}`(工具层)** | 下移到 `state.time` | +| tool 状态 | `pending\|running\|completed\|error` | **`streaming\|running\|completed\|error`** | `streaming` → `pending`(`input` 是**字符串** → 放进 `state.raw`) | +| tool 产出 | `state.output: string` | **`state.content: ToolContent[]`** | 只取 `text` 项用 `\n` join | +| tool 标题 | `state.title: string` | ❌ **已删除** | 从 `metadata.title` 兜底,否则空串 | +| tool 附件 | `state.attachments?: FilePart[]` | ❌ 删除(改用 `content` 里的 `file` 项) | **不映射**(渲染层零消费,见 §7.2) | +| tool 错误 | `state.error: string` | `state.error: {type,message,status?}` | 取 `message` | + +### 2.7 其余 9 种消息 → UI 模型 + +| V2 类型 | UI 归宿 | 说明 | +| --------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------- | +| `system` | `role:'system'` + `session-marker` part | 实测是「指令/上下文更新」通知,`description` 是给人看的摘要 | +| `synthetic` | 同上 | 实测官方用它把 shell 作业产出插进转录 | +| `skill` | 同上 | 技能激活记录 | +| `shell` | 同上 | 会话级 shell 命令消息(与工具里的 `shell` **不是一回事**) | +| `idle` | 同上(**不渲染**) | 一轮结束边界;可见耗时已由 assistant 的 step-finish/footer 展示 | +| `agent-switched` / `model-switched` / `location-switched` | 同上 | 一行提示 | +| `compaction` | **复用已有的 `compaction` part** | 渲染层本来就有 `CompactionPartView`;增补 `status`/`reason`/`summary` 字段 | + +--- + +## 3. 分页参数与行为说明 + +### 3.1 参数口径(照 openapi + v2.0.19 源码核实) + +| 参数 | 类型 | 约束 | 说明 | +| ----------- | ----- | --------------------------------------------- | -------------------------------------- | +| `sessionID` | path | `^ses` | | +| `limit` | query | **`NumberFromString`,1..200**,省略时 **50** | 超过 200 → 400 | +| `order` | query | `asc` \| `desc`,默认 `desc` | **只对首页有效** | +| `cursor` | query | 不透明字符串 | **与 `order` 互斥** | +| `type` | query | **10 个字面量(无 `idle`)** | 在分页**之前**过滤;翻页时必须原样带上 | + +### 3.2 🔴 游标方向(本阶段最重要的实测结论) + +游标是 base64url 的 `{id, order, direction}`。实测解出: +`{"id":"msg_0ee2…","order":"desc","direction":"previous"}`。 + +**`previous` / `next` 是相对于「本次排序」的,不是绝对时间方向。** +服务端默认 `order=desc`(新→旧)时: + +| 请求 | 实测结果 | +| ------------------------ | ----------------------------------------- | +| `?limit=3`(不传 order) | 返回**最新 3 条**(新→旧) | +| 跟 **`cursor.next`** | ✅ 拿到**更旧**的一页(50 条) | +| 跟 `cursor.previous` | ❌ **空数组**(最新那条之后没有更新的了) | + +源码依据: + +- `packages/server/src/handlers/message.ts` —— `previous` 锚定 `messages[0]`、`next` 锚定 `messages.at(-1)`; + 两者都把 `order` 编码进游标;且 `cursor` + `order` 同时传 → 400 `InvalidCursorError`。 +- `packages/core/src/session/store.ts` `messages()` —— + `order = direction === 'previous' ? 反转(requestedOrder) : requestedOrder`。 + +→ **「滚动到顶部加载更早历史」必须用 `cursor.next`。** +(迁移文档 §5.5 原文写的是 `cursor.previous`,**已在主文档修正**。) + +### 3.3 🔴 游标**不表示**「还有没有更多」 + +只要本页非空,`previous` / `next` **都会返回一个值**,哪怕那个方向已经没有数据 +(跟过去只会拿到空数组)。因此: + +**「还有更多」用 `limit + 1` 溢出法判断**:多要一条,多出来就说明还有。 + +⚠️ **多要的那条必须保留,不能丢弃** —— 因为 `cursor.next` 锚定在本页**最后一条**上, +丢掉它就等于让下一页跳过它,会**永久丢消息**。 + +实现细节(`src/api/message.ts`): + +- `limit` 先钳制到 1..200,再请求 `limit + 1`; +- 返回的 `messages` **全部保留**(可能比调用方要的多 1 条,这是刻意的); +- `hasMore = data.length > limit`; +- `limit === 200` 时无法再 +1 → 退化为 `data.length >= 200`(已在注释里写明)。 + +### 3.4 本项目的用法 + +``` +首页:getSessionMessages(sid, { limit: 50 }) + → 服务端给最新的 51 条(desc)→ API 层重排成「旧→新」交给 store + → store 记下 cursor.next 作为 historyCursor,hasMoreHistory = page.hasMore + +更旧:getSessionMessages(sid, { limit: 50, cursor: historyCursor }) + → 同样重排成「旧→新」→ store.prependMessages() 前置 + → 更新 historyCursor / hasMoreHistory +``` + +- 向前翻页游标存在 **`messageStore` 的 `SessionState.historyCursor`** —— 随 session 一起被 + LRU 淘汰,不会像原来的 `cursorRef`(`useSessionManager` 里的 `Map`)那样跨 session 泄漏。 +- `useSessionManager` 里加了**并发保护**(`loadingMoreRef`),避免滚动事件高频触发重复加载。 +- `historyCursor` 为 `null` 但 `hasMoreHistory` 为真时,视为状态不一致 → 纠正为「已到最早」, + 避免死循环。 + +### 3.5 滚动位置保持 + +**本阶段未改动滚动逻辑**,确认它仍然成立: + +- `ChatArea.tsx` 的 prepend 锚点(`prependAnchor` / `capturePrepend` / `restorePrepend`, + L614-673)按 `data-timeline-key`(= 消息 id)记录锚点元素的视口偏移, + prepend 后按差值补偿 `scrollTop`; +- 触发条件:`onScroll` 里 `userScrolled && scrollTop < 200 && !loadingMore && hasMoreHistory` + → `loadMore()` → `capturePrepend()` → `onLoadMore()`(= `loadMoreHistory`)→ `restorePrepend(true)`; +- 本阶段只改了 `loadMoreHistory` **内部怎么拿数据**(游标 vs limit 递增), + 以及 `hasMoreHistory` **怎么算**(`limit+1` 溢出 vs `length >= limit` 近似); +- 锚点 key 是消息 id,而 `prependMessages` 只前置、不改已有消息的 id → 锚点仍能命中。 + +> ✅ 顺带修好一个老问题:V1 的 `hasMoreHistory = apiMessages.length >= limit` 是**近似**, +> 到底了还会多发一次注定为空的请求;现在用溢出法**精确**判断,到底即停。 + +--- + +## 4. 测试清单与覆盖率 + +### 4.1 总体结果 + +| 场景 | 文件 | 用例 | 结果 | +| ---------------------- | --------------------- | --------------------------: | ------------- | +| **默认(无真实服务)** | 98 passed / 2 skipped | **741 passed + 20 skipped** | ✅ **0 失败** | +| **起真实 V2 服务后** | 99 passed / 1 skipped | **753 passed + 8 skipped** | ✅ **0 失败** | + +跳过的 20 例 = 阶段 1 冒烟 12 例 + 阶段 2a 冒烟 8 例(两者都在服务不可达时**自跳过**,不是失败)。 + +- **基线 683 例全部仍通过**(其中含阶段 1 的 12 例冒烟,它们在本阶段起了真实服务后也跑通) +- **本阶段新增 70 例**:`messageConversion.test.ts` 35 + `api/message.test.ts` 23 + + `messageStore.test.ts` 净增 12 + +### 4.2 新增测试文件 + +| 文件 | 用例 | 覆盖的关键逻辑 | +| --------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/utils/messageConversion.test.ts` | 35 | user 文本/附件/agent/skill 映射;metadata 兜底(含 V2 命名 `id`);inline base64 → data URL;uri 源 + mention;assistant 内容摊平与**下标 id**;model/provider 改名;step-finish 合成(含「流式中不合成」);retry 反向合成;工具 4 态映射(`streaming→pending`、content→output、time 下移、error 取 message、metadata.title 兜底);**错误类型映射 5 例参数化**;system/compaction/idle marker;**「11 种类型一条都不能丢」**;批量转换与 role 守卫互斥 | +| `src/api/message.test.ts` | 23 | **重排成旧→新**;不传 cursor 时**不发 order**;带 cursor 时**也不发 order**(互斥);`limit+1` 溢出探测;短页 `hasMore=false`;**长页 `hasMore=true` 且保留溢出项**;游标归一化为 `string\|null`;空页;默认页大小;limit 钳制(9999 / 0 / 负数);200 上限的退化;`type` 透传与省略;`extractUserMessageContent` 6 例(含 synthetic 过滤、folder、textRange、agent);**`sendMessage`/`sendMessageAsync` 报错语义 3 例** | +| `src/test/fixtures/v2Messages.ts` | — | V2 消息夹具(刻意只填必填字段,V2 加必填项时会立刻报错) | +| `src/features/message/phase2a.smoke.test.tsx` | 8 | 见 §5(默认 skip,`VITE_OPENCODE_SMOKE=1` 开启) | + +### 4.3 改写的测试文件 + +| 文件 | 改动 | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/store/messageStore.test.ts` | 17 → **29** 例。夹具从 V1 `{info,parts}` 换成 V2 扁平消息;**明确区分两条通路**(V2 读侧用 `v2*` 夹具 / V1 事件侧仍用 V1 夹具);新增游标(存/取/不被元数据刷新清掉)、prepend 去重与「不按时间重排」、`upsertMessages`(不清空历史 / 就地更新)、`keepLocalOnly`(保留 / 不保留)等用例 | +| `src/store/messageStoreHooks.test.tsx` | 6 例,夹具换 V2;`handlePartUpdated` 的 part id 换成转换层合成规则 | +| `src/hooks/useSessionStats.test.tsx` | 2 例,夹具换 V2;压缩场景改用**独立的 compaction 消息**(V2 语义) | + +### 4.4 覆盖率 + +> ⚠️ **测量方式与善后(如实交代)**:仓库未安装覆盖率工具。为拿到数字,本次用 +> `npm install --no-save --no-package-lock @vitest/coverage-v8@4` 临时装了一个副本 +> (`--no-save --no-package-lock` 保证 **`package.json` / `package-lock.json` 零改动**,已 diff 核对)。 +> 但这个命令**顺带把 node_modules 里的 vitest 4.1.2→4.1.11、shiki 4.0.2→4.4.3 升上去了** +> (npm 会重算依赖树),并因此让 `src/workers/shikiWorker.ts` 冒出一个**与本次改动无关**的 +> `tsc` 类型报错。**已用 `npm ci` 把 node_modules 恢复到 package-lock 的精确版本** +> (vitest 4.1.2 / shiki 4.0.2,覆盖率包已移除),恢复后 `tsc -b` 重新为 **0 报错**、 +> 测试重新全绿。仓库文件自始至终零改动。 + +| 文件 | Stmts | Branch | Funcs | Lines | 未覆盖部分说明 | +| --------------------------------- | ---------: | ---------: | ---------: | ---------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/utils/messageConversion.ts` | **92.38%** | **85.22%** | **91.66%** | **92.92%** | 仅剩 8 处分支(`toFilePartSource` 的空 mention 分支、`toAgentPart` 的无 mention 分支、compaction failed 的 model 分支等) | +| `src/api/message.ts` | **100%** | **88.63%** | **100%** | **100%** | 未覆盖分支:`clampLimit` 的非有限数、`hasMore` 的 200 上限退化等 | +| `src/store/messageStore.ts` | 64.95% | 52.32% | 68.18% | 71.25% | 未覆盖部分**全部是既有代码**:通知/rAF 管线、session LRU 淘汰与保护、`revertState` 撤销重做(**属阶段 3**)、V1 事件处理器(**属阶段 2b**)、`extractUserText` 私有辅助 | +| `src/types/message.ts` | 83.33% | 90.47% | 92.3% | 83.33% | 未覆盖:`getMessageText` 尾部、部分守卫分支 | +| `src/test/fixtures/v2Messages.ts` | 88.88% | 100% | 87.5% | 88.88% | `v2Tool` 的默认参数分支 | + +**核心结论**:本阶段**新写的逻辑**(转换层、分页层)覆盖率 92% / 100%; +`messageStore` 的缺口集中在**本阶段明确不碰**的三块(事件层、撤销重做、通知管线)。 + +--- + +## 5. 冒烟结果 + +### 5.1 环境 + +``` +OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097 +``` + +- 真实服务 `v2.0.19`,`GET /api/info` → `{"version":"2.0.19","pid":…,"urls":["http://127.0.0.1:4097"]}` +- 数据:**本机真实数据目录**(588 个会话 / **17,288 条真实 V2 消息**), + 目标目录 `/home/coder/project/OpenCodeUI`(25 个会话) +- 入口:`VITE_OPENCODE_SMOKE=1 npx vitest run src/features/message/phase2a.smoke.test.tsx` + +### 5.2 结果:8/8 通过(跑过两次,数字不同是因为目标会话本身还在增长) + +| 用例 | 实测输出(第 1 次 / 第 2 次) | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 首页拉到历史、重排成旧→新、给出游标 | 6 条 = limit 5 + 溢出 1;`hasMore=true`;`cursor.next` 有值(两次一致) | +| 跟 `cursor.next` 拉到更早一页、与首页无重叠 | 第 2 页 6 条,全部早于首页最早那条,**零重叠**(两次一致) | +| 连续翻页直到尽头 | 第 1 次:**9 页 / 181 条去重**;第 2 次:**11+ 页 / 231+ 条去重**(目标会话在这两次之间又写入了新消息)。两次都:页大小 21(=20+1),`hasMore=false` 后**正常终止**,**零重复** | +| 转换后渲染层依赖的不变量 | 角色分布 `{assistant:21}`;part 分布 `{reasoning:9, tool:21, step-finish:20, text:11}`;每条消息 `sessionID`/`id`/`time.created` 齐备;**每个 part 有 id 且消息内唯一** | +| 真实工具调用映射 | 样例 `{"tool":"write","status":"completed"}`,状态落在 UI 认识的 4 态内 | +| 真实渲染(用户 / 助手) | 用户消息渲染 **2502** 字符;助手消息渲染 **118** 字符(均非空 DOM) | +| 系统类消息渲染 | 找到真实 `system` 消息,渲染 **38** 字符,不抛错 | +| 整页渲染不抛错 | **51 条消息渲染 49721 字符**,含工具卡片、推理、系统提示 | + +### 5.3 原始分页语义(curl 直打,独立于本项目代码) + +| 检查 | 结果 | +| ----------------------- | --------------------------------------------------------------------- | +| 首页 `?limit=3` | 最新 3 条(desc)✅ | +| 跟 `cursor.next` | 50 条更旧 ✅ | +| 跟 `cursor.previous` | **空数组** ✅(证明 `previous` = 更新方向) | +| 游标解码 | `{"id":"msg_0ee2…","order":"desc","direction":"previous"}` ✅ | +| `?type=idle` | **400**(过滤枚举只有 10 个,无 idle)✅ | +| `?type=user` | 200 ✅ | +| `?limit=201` | **400** `Expected a value less than or equal to 200` ✅ | +| `?cursor=abc&order=asc` | **400** `InvalidCursorError: Cursor cannot be combined with order` ✅ | + +### 5.4 ❌ 没做到的:浏览器级滚动验证 + +**没有做**浏览器里的真实滚动加载验证,原因如实说明: + +- 本会话**没有连接桌面浏览器**(`browser.tabs.open` 返回 + _"No desktop browser is connected to this session"_),浏览器工具不可用; +- 备选路径(vite dev server + `VITE_API_BASE_URL=http://127.0.0.1:4097` + `--cors`)能起来, + 但**没有浏览器可以打开它**;顺带发现 `vite.config.ts` 的 dev proxy 是 V1 遗留 + (`rewrite: path => path.replace(/^\/api/, '')` 会把 V2 必需的 `/api` 前缀削掉), + 该文件不在本阶段授权范围内,未改动。 + +**替代验证**(已做): + +1. 游标分页的**真实数据**端到端(9 页 181 条、无重复、正确终止); +2. 真实数据的**真实 React 渲染**(51 条消息 / 49721 字符,含工具卡片); +3. 滚动保持逻辑所在的 `ChatArea.tsx` **本阶段零改动**, + 且其触发条件(`hasMoreHistory`)与依赖(消息 id 稳定)都由单测覆盖。 + +→ 结论:**数据链路已充分验证;「滚动触发 + 锚点补偿」这段 UI 行为未在浏览器里实跑**, +建议在能开浏览器的环境里补一次人工回归(步骤见 §9)。 + +--- + +## 6. 🔴 与文档预测不符之处 + +> 本节是本次报告最重要的部分。前两轮都是这么做的。 + +### 6.1 🔴🔴 文档 §5.5 写错了:加载更早历史要用 `cursor.next`,不是 `cursor.previous` + +| | 内容 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | 「滚动到顶部时用 **`cursor.previous`** 向前加载」 | +| **实测** | 服务端默认 `order=desc`(新→旧)。跟 `cursor.previous` 拿到的是**更新**的一页(在最新那条之后 → **空数组**);跟 **`cursor.next`** 才拿到**更旧**的一页(实测 50 条) | +| **为什么** | 游标把 `order` 和 `direction` 一起编码;`previous`/`next` 是**相对于本次排序**的。源码 `store.messages()`:`order = direction === 'previous' ? 反转(requestedOrder) : requestedOrder` | +| **影响** | 若照文档实现,**滚动到顶部永远加载不出历史**,而且**不报错**(只是空数组)—— 属于最难查的一类 bug | +| **处理** | ✅ **已在主文档 §5.5 修正并标注「阶段 2a 实测修正」** | + +### 6.2 🔴 文档 §5.1 的类型名 ≠ 线上 `type` 取值 + +文档列的是 `AgentSelected | ModelSelected | LocationSwitched`(TS 类型名), +**线上 `type` 实际是 `"agent-switched"` / `"model-switched"` / `"location-switched"`**。 +按类型名去比较会永远匹配不上。→ ✅ 已回填 §5.1(新增对照表)。 + +### 6.3 🔴 文档 §5.2 遗漏:**所有 V2 消息都没有 `sessionID`** + +V1 每条消息都带 `sessionID`,V2 **一律没有**(会话上下文由请求路径给出)。 +文档只写了「`sessionID` 在 Assistant 上被删除」,实际是**11 种消息全都没有**。 +→ 转换层必须由调用方补,否则 store 里所有按 session 分组的逻辑失效。 +✅ 已回填 §5.2 ①。 + +### 6.4 🔴 文档 §5.2 遗漏:`User` 的 `agent` / `model` 是「移到 metadata」而不是「删除」 + +文档写 `User.agent` / `User.model` 被删除。实测**官方 TUI 会把它们写进 `metadata`**: + +```json +"metadata": { "displayText": "…", "agent": "build", + "model": { "modelID": "deepseek-v4.1-flash", "providerID": "aether2", "variant": "max" } } +``` + +⚠️ 两个坑:① `metadata` 在 schema 里是 `Record`,**不是契约字段**, +第三方写入方可以不写;② `metadata.model` 用的是 **V1 命名 `modelID`**(不是 V2 的 `id`)。 +→ 处理:**只做「有就取」的兜底**,取不到留空,绝不假设存在(转换层同时兼容 `modelID` 和 `id`)。 +✅ 已回填 §5.2 ②。 + +### 6.5 🔴 文档 §5.2 遗漏:`text` / `reasoning` **没有 `id`** + +文档只说「步骤分隔被删除」。实测 `assistant.content[]` 里**只有 tool 有 `id`**, +`text` / `reasoning` 完全没有 id;而 UI 用 id 做 React key 与折叠状态。 +→ 转换层按 `消息id:content:下标` 合成。 +⚠️ 下标必须按 **content 数组下标**算,不能按同类型计数(否则插入新块会让已有 id 漂移)。 +✅ 已回填 §5.2 ④。 + +### 6.6 🔴 文档 §5.2 遗漏:工具状态多了 `streaming`、少了 `pending`,且 `title`/`attachments` 被删 + +| 文档说法 | 实际 | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 「工具调用:`parts[].type === 'tool'`(内嵌 `state`)」→ V2 同构 | ❌ 状态机**变了**:`pending` → **`streaming`**(且此时 `input` 是**未解析的字符串**);`callID` → **`id`**;`tool` → **`name`**;`state.time` → **工具层 `time`**;`state.output: string` → **`state.content: ToolContent[]`**;`state.title` 与 `state.attachments` **被删除** | + +→ UI 只认 `pending|running|completed|error`,转换层必须把 `streaming` 翻译回 `pending`。 +✅ 已回填 §5.2 ⑤。 + +### 6.7 🔴 文档 §5.2 表述不准:错误不是「`Assistant.error` + `Session.StructuredError`」这么简单 + +V1 的 `Message.error` 是 `{name, data}` 的 **5 元判别联合**;V2 是 `{type, message, status?}`, +`type` 是**开放字符串**。实测出现过的取值:`aborted`、`unknown`、`provider.error`、 +`provider.invalid-output`、`tool.execution` —— **一个都不叫 `ProviderAuthError` / `APIError`**。 +→ 只能按关键字启发式映射。最关键的一条:**`aborted` → `MessageAbortedError`**, +UI 靠它显示「已中止」(本机库里有 **57 条** assistant 是这个类型)。 +✅ 已回填 §5.2 ⑥。 + +### 6.8 🔴 文档 §5.5 未提:**游标不代表「还有没有更多」** + +只要本页非空,`previous`/`next` 都会返回值,哪怕该方向已经没数据。 +→ 必须用 `limit + 1` 溢出法;且**溢出的那条不能丢**(游标锚定本页最后一条, +丢了就永久丢消息)。这是照文档实现一定会踩的坑。✅ 已回填 §5.5。 + +### 6.9 🔴 文档 §5.5 未提:`limit` 是 `NumberFromString` 且限定 1..200 + +`?limit=201` → 400 `Expected a value less than or equal to 200`。 +V1 时代前端习惯用 `limit` 递增(`Math.max(INITIAL_MESSAGE_LIMIT, 200)`), +迁移时必须加钳制。✅ 已回填 §5.5。 + +### 6.10 🔴 V2 自身的不一致:`idle` **能返回但不能过滤** + +`SessionMessagesQuery.type` 的枚举只有 **10 个值(没有 `idle`)**, +但响应联合 `PublicSessionMessage` **包含** `SessionMessage.Idle`。 +实测 `?type=idle` → **400**,而 `idle` 消息在不过滤时会正常返回。 +→ 不是本项目的 bug,是 V2 的枚举不一致;实现里用 `SessionMessageFilterType = Exclude` +把它编码进类型。✅ 已回填 §5.5。 + +### 6.11 🟡 文档 §5.1 的端点写法容易误读 + +文档写 `GET /api/session/{id}/message`(对的),但 SDK 入口是**顶层** +`client.message.list()`,**不是** `client.session.message.list()` +(`session.message` 只有 `get` 单条)。任务描述里的「`GET /api/message`」经 openapi 核实 +**不存在** —— 正确路径就是 `/api/session/{sessionID}/message`。 + +### 6.12 🟡 文档 §5.4 的文件清单与实际改动有出入 + +- 文档列了 `src/api/types.ts`(兼容别名)要改 —— 实际**没改**(该文件不在本阶段授权范围内, + 且 V1 别名保留在 `src/types/api/message.ts` 里就够用)。 +- 文档**没列**实际必须新增的文件:`SessionMarkerPartView.tsx`(V2 新增消息类型的渲染)、 + `src/test/fixtures/v2Messages.ts`(V2 夹具)、两个新测试文件。 +- ✅ 已回填 §5.4 的「阶段 2a 实际改动的文件」表。 + +### 6.13 🟡 文档 §5.4 说 `src/types/message.ts` 要「UI 侧模型调整」——实际是**新增**而非推翻 + +按「保留现有函数签名与导出名、调用方最小改动」的要求,本阶段**没有推翻 UI 模型**, +而是把它当**转换目标**(渲染层 5,900 行因此几乎零改动),只**新增**了: +`SystemMessageInfo`(`role:'system'`)、`SessionMarkerPart`、`SkillPart`, +并给 `CompactionPart` 增补 V2 字段。这是本阶段最关键的架构决策,见 §7.1。 + +### 6.14 🟡 阶段 0/1 报告与实际的偏差(本次核对发现) + +- 阶段 1 报告称「683 单测全绿」—— 实测该数字**包含**阶段 1 冒烟的 12 例 + (当时服务在跑);服务不在时它们会**自跳过**。本阶段报告统一按「有无服务」两种口径给出。 +- 容器里 `npx eslint .` 有 **42 个 warning / 0 error**(全是既有的 React refs 与 + react-refresh 告警);`npx prettier --check .` 有 **167 个文件不达标**(版本漂移, + 与本次改动无关,本阶段未做全仓库格式化以免产生无关 diff)。 + 本阶段**新增/改动的文件 eslint 零告警**,格式化风格与所在文件保持一致。 + +--- + +## 7. 设计决策与取舍 + +### 7.1 ✅ 核心决策:**不推翻 UI 模型,而是把它当转换目标** + +任务要求「保留现有函数签名与导出名(调用方最小改动)」。据此选择: + +``` +V2 扁平消息(Session.Message.Info) + │ ← 新增的全部复杂度都在这一层 + ▼ +src/utils/messageConversion.ts + │ + ▼ +UI 模型 src/types/message.ts({info, parts, isStreaming} 基本不变) + │ + ▼ +渲染层 src/features/message/**(≈5,900 行,几乎零改动) +``` + +**收益**:`MessageRenderer.tsx` 只加了 37 行(一个 `role==='system'` 分支 + 一个轻量视图), +其余 5,900 行渲染代码一行没动。 +**代价**:转换层要「造」三样 V2 没有的东西(见 7.2),并且这是**有损**的 +(V2 独有的字段如 `providerState` / `rawFinish` / `providerResultState` 被丢弃)。 + +**为什么值得**:这些被丢的字段经全仓库核对**渲染层零消费**; +而如果反过来让渲染层直接用 V2 模型,等于重写 5,900 行 + 全部相关测试。 + +### 7.2 转换层「造」出来的三样东西(都是为了喂饱既有渲染层) + +| 造的东西 | 为什么必须造 | 风险与对策 | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| **`sessionID`** | V2 消息里没有,但 store 按 session 分组、revert 按 id 定位都要它 | 入参**强制要求**(`toUIMessage(msg, sessionID)`),不给默认值,避免静默传空串 | +| **part `id`** | V2 的 text/reasoning 没有 id,UI 要它做 React key 与折叠状态 | 规则固定为 `消息id:content:下标`(**下标而非同类型计数**),保证「插入新块不导致已有 id 漂移」 | +| **尾部 `step-finish` part** | V2 把 cost/tokens/finish 上移到 assistant 顶层,但渲染层的「过程/最终内容拆分」(`splitProcessRenderItems`)与工具组配对(`groupPartsForRender`)**依赖 step-finish** | **只在 `finish` 已存在时**合成(流式中不合成),否则工具组会提前挂上未完成的用量 | + +### 7.3 V2 新增的 9 种消息:统一用一个 `session-marker` part 承载 + +**没有**给 9 种消息各造一个 UI part 类型,而是: + +```ts +interface SessionMarkerPart extends PartBase { + type: 'session-marker' + marker: SessionMarkerMessage // 保留 V2 原始消息(强类型判别联合) +} +``` + +**理由**:① 渲染上它们都是「一行提示」,共用一个视图即可; +② 保留原始消息的完整类型,渲染层按 `marker.type` 拿到强类型字段,**转换层不做有损摊平**; +③ 将来 V2 加新消息类型时只需扩联合,渲染层 switch 会提示补分支。 +`compaction` 例外 —— 渲染层本来就有 `CompactionPartView`,直接复用。 + +**`idle` 故意不渲染**:它只是「一轮结束」边界,可见耗时已由 assistant 的 step-finish 与 footer +展示,再画一条分隔线只会让信息流变吵(本机有 81 条 idle)。 + +### 7.4 分页:**不暴露 `order` 参数** + +`getSessionMessages()` 刻意**不提供 `order` 选项**,一律用服务端默认 `desc` 起手, +再把结果重排成「旧→新」交给上层。理由: +① `cursor` 与 `order` 互斥,暴露 `order` 会引入「传了 cursor 又传 order → 400」的脚枪; +② 固定 desc 意味着**所有游标都是 desc 语义**,不会有方向歧义; +③ 上层(store)本来就按升序存。 + +### 7.5 `hasMoreHistory` 从「近似」改成「精确」 + +V1:`apiMessages.length >= limit` —— 到底了还会多发一次注定为空的请求。 +V2:`limit + 1` 溢出法 —— 到底即停。 +(顺带:`historyCursor` 为 `null` 但 `hasMoreHistory` 为真时会被纠正,避免死循环。) + +### 7.6 没有做的事(YAGNI) + +- ❌ 没有为「用户技能附件」写专门的渲染分支(实测 1637 条 user 消息里 **0 条**带 skills) + —— 但**类型与转换都做了**(`SkillPart`),只是渲染走 `session-marker` 之外的路径时 + 会被 `UserMessageView` 的 filter 忽略。**如实记录:用户消息上的 skill 附件当前不显示。** +- ❌ 没有把 V2 `state.content` 里的 **file 类型产出**映射成附件(V1 的 `state.attachments` + 在渲染层**零消费**,映射了也不会显示)。 +- ❌ 没有处理 `Assistant.snapshot`(渲染层零消费)。 +- ❌ 没有改 `MAX_CACHED_SESSIONS = 10` 的 LRU 策略(游标已并入 `SessionState`,无需额外缓存层)。 + +--- + +## 8. 未做 / 已知缺口(如实) + +| # | 项 | 说明 | +| --- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **浏览器级滚动验证未做** | 本会话无桌面浏览器连接,见 §5.4。数据链路已验证;UI 滚动行为建议人工补测 | +| 2 | ~~**`v1Model.ts` 一个导出都没删**~~ | ✅ **阶段 2b 已完成**:189 → 104 个顶层导出(A 桶 66 删 + C 桶 17 删 + 2 降级) | +| 3 | ~~**事件层零改动**~~ | ✅ **阶段 2b 已重写** `events.ts` / `EventTypes` / `EventCallbacks` | +| 4 | ~~**流式回复未适配**~~ | ✅ **阶段 2b 已适配**(真实 V2 服务冒烟 11/11) | +| 5 | ~~**发消息仍不可用**~~ | ✅ **阶段 2b 已实现**(`prompt` / `prompt` + `wait`) | +| 6 | **撤销/重做(undo/redo)仍不可用** | 依赖 V2 已删的 `revert` / `unrevert` 端点(V2 改成三段式),属**阶段 3**。`useRevertState` 只做了类型适配,运行时仍会抛错(与阶段 1 行为一致) | +| 7 | **用户消息上的 skill 附件不显示** | 见 §7.6(实测 0 条,未做渲染分支) | +| 8 | **`limit === 200` 时 `hasMore` 退化** | 无法再 +1 探测,退化为「满页即视为还有」;已在代码注释与报告中写明。本项目实际只用 50 | +| 9 | **Rust / Tauri / WSL 未跑** | 与阶段 1 一致(本阶段完全没碰 `src-tauri/`) | +| 10 | **prettier 全仓库不达标** | 167 个文件(既有版本漂移),本阶段未做全仓库格式化以免产生无关 diff | +| 11 | ~~**阶段 1 的 dev proxy 是 V1 遗留**~~ | ✅ **阶段 2b 已修正**(删掉会削掉 V2 必需 `/api` 前缀的 `rewrite`) | + +> ⬆️ 上表第 2/3/4/5/11 项已由 **阶段 2b** 完成,详见 `docs/opencode-v2-migration-phase2b.md`。 +> 阶段 2b 还额外发现:本文件的冒烟测试选会话的启发式(「最新 12 个里第一个非空的」)过于脆弱, +> 已被阶段 2b 改成「按新→旧体检、取消息数最多的那个」。 + +--- + +## 9. 建议的后续动作 + +### 9.1 立刻可做(人工回归,约 5 分钟) + +```bash +# 1) 起真实 V2 服务 +OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097 + +# 2) 起前端(浏览器能直连 4097;若走 dev proxy 需先修 vite.config.ts 的 /api 重写) +npm run dev + +# 3) 在界面里: +# - 打开一个历史很长的会话(如阶段 1 的会话 ses_f11f3943…,181+ 条) +# - 确认历史消息正确渲染(工具卡片、推理、系统提示、耗时/用量) +# - 滚到顶部,确认能持续加载更早历史,且**滚动位置不跳** +# - 一直滚到最早,确认加载停止(不再发空请求) +``` + +### 9.2 阶段 2b 的开工清单 + +> ✅ **阶段 2b 已全部完成**(2026-09-30),报告:`docs/opencode-v2-migration-phase2b.md`。 +> 下面保留原始清单以便对照;每条的落地情况见 2b 报告。 + +1. 重写 `src/api/events.ts`(改用 `client.event.subscribe()` 或手写改造)→ ✅ 用了官方 subscribe +2. 重写 `EventTypes` 常量表 + `handleEventForSubscriber`(21 个事件里只有 4 个属消息族)→ ✅ 45 个 V2 常量 +3. 实现「断线后重订阅 + 重拉全量」(V2 事件流是**易失**的,见 §6.2)→ ✅ `markAllSessionsStale()` + 强制重载 +4. 重写 `messageStore` 的 4 个事件处理器为 V2 形状(`session.text.*` / `session.tool.*` / `session.message.content.updated`)→ ✅ +5. 发消息链路:`POST /api/session/{id}/prompt` → ✅ 含 `prompt` + `wait` 两条链路 +6. **收尾删除**:先摘掉 `events.ts` 对 `GlobalEvent` 的依赖 → 删 `GlobalEvent` → + 删 A 桶 66 个 + C 桶 19 个 → ✅ 实际是 66 + 17 删、2 个降级(原因见 2b 报告 §5.3) +7. 拍板两个决策(§9.5):内容搜索怎么办、等待语义怎么走 → ✅ 内容搜索移除、等待用 `wait` + +> ⚠️ 清单第 2 条的「21 个事件里只有 4 个属消息族」**口径需修正**: +> 21 个是 `EventTypes` 常量,其中消息族是 4 个(`message.part.updated/delta/removed` + `message.updated`), +> 但**替换它们的 V2 事件有 17 个**(13 个 part 类 + 3 个 delta + 1 个 content.updated), +> 加上本阶段新增接线的 `session.execution.*` 等,最终 `EventTypes` 表是 **45 个常量**。 + +### 9.3 阶段 3 会用到的东西 + +- B 桶 104 个类型(清单见 §1.3) +- `useRevertState` / `revertMessage` / `unrevertSession`:V2 三段式 `revert/stage → commit → delete` +- `TaskRenderer` 的子会话加载(V2 已删 `GET /session/{id}/children`,改用 `GET /api/session?parentID=`) + +--- + +## 10. 附:本阶段改动清单 + +### 10.1 修改(17 个文件,+2070 / −553 行) + +| 文件 | 改动 | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `src/types/api/message.ts` | 456 行 —— V2 消息模型(11 种)+ 分页类型;V1 别名集中标注 `@deprecated` | +| `src/utils/messageConversion.ts` | 684 行 —— V2 扁平 → UI 模型的完整转换 | +| `src/api/message.ts` | 341 行 —— 游标分页 `getSessionMessages()`;`extractUserMessageContent` 改吃 UI 模型 | +| `src/store/messageStore.ts` | 133 行 —— `setMessages`/`prependMessages` 改吃 V2;`historyCursor`;`upsertMessages`;`keepLocalOnly` | +| `src/store/messageStoreTypes.ts` | 9 行 —— `SessionState.historyCursor` | +| `src/types/message.ts` | 145 行 —— `SystemMessageInfo` / `SessionMarkerPart` / `SkillPart` / `CompactionPart` 增补 | +| `src/hooks/useSessionManager.ts` | 180 行 —— 历史加载改游标 + 并发保护 | +| `src/hooks/useRevertState.ts` | 45 行 —— 类型适配 | +| `src/hooks/useChatSession.ts` | 19 行 —— 发消息兜底改用 `upsertMessages` | +| `src/features/message/MessageRenderer.tsx` | 37 行 —— `role:'system'` 分支 + `SystemMessageView` | +| `src/features/message/parts/index.ts` | 1 行 —— 导出新视图 | +| `src/features/message/tools/renderers/TaskRenderer.tsx` | 11 行 —— 子会话加载适配 | +| `src/locales/{zh-CN,en}/message.json` | 各 9 行 —— `system.marker.*` 文案 | +| `src/store/messageStore.test.ts` | 372 行 —— 17 → 29 例 | +| `src/store/messageStoreHooks.test.tsx` | 69 行 —— 夹具换 V2 | +| `src/hooks/useSessionStats.test.tsx` | 103 行 —— 夹具换 V2 | + +### 10.2 新增(5 个文件,1309 行) + +| 文件 | 行数 | 说明 | +| ------------------------------------------------------ | ---: | ----------------------------------- | +| `src/utils/messageConversion.test.ts` | 439 | 转换层单测(35 例) | +| `src/api/message.test.ts` | 303 | 分页 + 提取 + 报错语义单测(23 例) | +| `src/features/message/phase2a.smoke.test.tsx` | 293 | 真实服务冒烟(8 例,默认 skip) | +| `src/features/message/parts/SessionMarkerPartView.tsx` | 155 | V2 新增消息类型的渲染 | +| `src/test/fixtures/v2Messages.ts` | 119 | V2 消息测试夹具 | + +### 10.3 文档 + +| 文件 | 改动 | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `docs/opencode-v2-migration.md` | §5.1 类型名对照表;§5.2 八条实测补充;§5.4 阶段 2a 文件表;**§5.5 游标方向修正**;§8 阶段 2 拆成 2a/2b | +| `docs/opencode-v2-migration-phase2a.md` | 本报告(新增) | diff --git a/docs/opencode-v2-migration-phase2b.md b/docs/opencode-v2-migration-phase2b.md new file mode 100644 index 000000000..30924bfc6 --- /dev/null +++ b/docs/opencode-v2-migration-phase2b.md @@ -0,0 +1,696 @@ +# OpenCode V2 迁移 · 阶段 2b 报告(事件流 + 发消息,写侧) + +> 状态:**✅ 已完成**(2026-09-30) +> 范围:**写侧** —— 事件订阅层重写、`EventTypes` / `EventCallbacks` 重做、`messageStore` 的 4 个事件处理器、 +> 发消息两条链路、`v1Model.ts` 收尾删除、内容搜索移除、dev proxy 修正。 +> 前置:阶段 0(`docs/opencode-v2-migration-phase0.md`)、阶段 1(`docs/opencode-v2-migration-phase0.5.md`)、 +> 阶段 2a(`docs/opencode-v2-migration-phase2a.md`) +> 实测环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19` +> 主文档:`docs/opencode-v2-migration.md`(本阶段已按实测回填 §6.1 / §6.3 / §6.4 / §8 / §9.3 / §9.5) + +--- + +## 0. 先说三件必须交代的事 + +### 0.1 ⚠️ 硬性约束逐条对照 + +| 约束 | 结果 | +| ---------------------------------------------------- | ------------------------------------------------------------------------ | +| 允许改:`src/api/events.ts` | ✅ 整体重写(1060 → 1378 行) | +| 允许改:`src/api/message.ts` | ✅ 发消息两条链路落地 | +| 允许改:`src/api/file.ts`(仅搜索相关) | ✅ 移除 `searchText` / `searchSymbols`,实现 `searchFiles` | +| 允许改:`EventTypes` 常量表 | ✅ `src/types/api/event.ts` 整体重写(177 → 506 行) | +| 允许改:`messageStore` | ✅ 4 个事件处理器改成 V2 形状 | +| 允许改:`messageConversion.ts`(事件侧复用) | ✅ 新增 `toUIPartFromContent` / `toStepFinishPart`,删除 V1 形状遗留函数 | +| 允许改:`v1Model.ts`(仅删 A/C 桶) | ✅ 189 → **104** 个顶层导出(详见 §5) | +| 允许改:发消息链路 | ✅ 含 `createSession`(见 §4.4,任务 4 明确点名) | +| 允许改:`FileExplorer.tsx`(仅搜索 UI) | ✅ 内容搜索 UI 与请求分支移除,不留置灰按钮 | +| 允许改:`vite.config.ts` | ✅ 删掉会削 `/api` 前缀的 V1 rewrite | +| 允许改:`docs/` | ✅ 主文档回填 + 本报告 | +| **禁止改**:`src-tauri/`(整个 Rust 层) | ✅ **零改动** | +| **禁止改**:`src/api/notMigrated.ts` 既有语义 | ✅ **零改动** | +| **禁止改**:`v1Model.ts` 的 B 桶 104 个导出 | ✅ 104 个一个不少(脚本逐个核对,见 §5.4) | +| 禁止 git commit / push / reset / checkout / worktree | ✅ 未执行 | +| 禁止删除 `docs/` 下任何文件 | ✅ 未删除 | +| 类型检查 0 报错 | ✅ `npx tsc -b --force` 无输出 | +| `npm test` 现有用例不许挂 | ✅ 阶段 2a 基线 741 例全部仍通过 | +| 全程简体中文注释与报告 | ✅ | +| 失败项如实汇报、不编造 | ✅ 见 §7、§8 | +| 只用单测 + API 冒烟证明,不做浏览器人工验证 | ✅ 未做浏览器验证 | + +**超出授权范围但不得不改的文件**(各 1~3 行,全部是「不改就编译不过」,逐条说明): + +| 文件 | 为什么必须动 | 改动量 | +| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------- | +| `src/hooks/useGlobalEvents.ts` | 它是 `EventCallbacks` 的**唯一生产消费者**。事件载荷从 V1 变 V2,这里的适配代码不可能不动 | ~137 行(含注释) | +| `src/contexts/SessionContext.tsx` | 同上(订阅 `onSessionCreated/Updated/Deleted`,且要删掉 `onTodoUpdated`) | ~52 行 | +| `src/hooks/useSessions.ts` | 同上 | ~48 行 | +| `src/components/WorktreePanel.tsx`、`src/hooks/useGitWorkspaceCatalog.ts` | `worktree.ready/failed` → `worktree.updated/resolved` | 各 4~10 行 | +| `src/api/session.ts` | 任务 4 点名「创建会话必须写进 body 的 `location`」 | ~30 行 | +| `src/api/v2Convert.ts` | 新增 `toInternalPermissionRequest()`(V2 权限载荷 → 内部模型) | +45 行 | +| `src/types/api/{common,file,index,message}.ts`、`src/types/index.ts`、`src/types/ui.ts`、`src/api/types.ts` | A/C 桶删除后的转发清理(不清理就 `tsc` 报错) | 各 2~110 行 | +| `src/types/api/todo.ts` | **新增**:`TodoItem` 原来挂在 `event.ts` 上,V2 没有 `todo.updated` 事件,类型得搬家 | 新文件 28 行 | + +### 0.2 本阶段**没有**做的事 + +- ❌ **Tauri 真机验证**:容器内无 Tauri 运行时,`plugin-http` 的流式表现**未实测**(见 §8#1) +- ❌ **浏览器人工验证**:按用户决定,只用单测 + API 冒烟(见 §6) +- ❌ **Form 表单渲染器**:V2 的 `form.created/replied/cancelled` 事件已接线,但渲染器属阶段 3(见 §7#11) +- ❌ **权限回复 API**:`src/api/permission.ts` 仍是 `notMigratedYet`(阶段 3) +- ❌ **「换模型 → 切会话模型」的 UI 联动**:V2 的模型是**会话级**的,`prompt` 不接受 model 参数(见 §7#7) +- ❌ **撤销/重做**:依赖 V2 三段式 revert(阶段 3),行为与阶段 1/2a 一致(仍抛错) + +--- + +## 1. 事件映射表(V1 → V2,实际实现的) + +> 口径:阶段 2a 报告 §1.4 记录的「`events.ts` 实际引用的 21 个 `EventTypes` 常量」逐个对照。 + +### 1.1 消息族(本阶段核心) + +| V1 事件 | V2 实际实现 | 说明 | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `message.part.updated` | **`session.text.started/ended`**、**`session.reasoning.started/ended`**、**`session.tool.input.started/ended`**、**`session.tool.called/progress/success/failed`**、**`session.step.started/ended/failed`** | 拆成 13 个事件;统一走 `onPartUpdated` 的判别联合(`kind: 'content' \| 'step' \| 'step-start'`) | +| `message.part.delta` | **`session.text.delta`**、**`session.reasoning.delta`**、**`session.tool.input.delta`** | 统一走 `onPartDelta`(`kind: 'text' \| 'reasoning' \| 'input'`) | +| `message.part.removed` | ❌ **无对应事件** → 改为「**重拉消息**」 | 触发源是 `session.revert.staged/committed/cleared` 与 `session.execution.interrupted`,回调名 `onMessagesInvalidated` | +| `message.updated` | **`session.message.content.updated`** | ⚠️ 已接线,但**实测从不下发**(见 §7#2)→ 实际是「有分支、无生产者」 | +| `session.error` | **`session.execution.failed`** | 载荷从 V1 的 `{name, data}` 换成 V2 的 `{type, message, status?}` | +| `session.updated` | **`session.renamed`**、**`session.metadata.updated`**、**`session.agent.selected`**、**`session.model.selected`**、**`session.moved`** | 统一合成 `SessionInfoPatch`(部分字段补丁) | +| `question.asked` | **`form.created`** | 载荷结构完全不同(`FormInfo` vs `QuestionRequest`)→ 本阶段只做 pending 登记 + 通知 | +| `question.replied` | **`form.replied`** | 同上 | +| `question.rejected` | **`form.cancelled`** | 同上 | +| `worktree.ready` | **`worktree.updated`** | 载荷从 `{}` 变成 `{projectID}` | +| `worktree.failed` | **`worktree.resolved`** | ⚠️ **语义变了**:不是「失败」而是「目录被解析/采用」(见 §7#10) | +| `session.created` | ✅ `session.created` | 载荷字段与 REST 的 `Session.Info` **不一致**(见 §7#5) | +| `session.deleted` | ✅ `session.deleted` | 载荷从裸 `sessionID` 变成 `{sessionID}` | +| `session.idle` | ⚠️ **保留分支,但实测从不下发** | schema 里已标 `// deprecated` → 实际改用 `session.execution.succeeded`(见 §7#1) | +| `session.status` | ⚠️ **保留分支,但实测从不下发** | 实际改用 `session.execution.started/succeeded/failed/interrupted`(见 §7#1) | +| `project.updated` | ✅ `project.updated` | 原样可用 | +| `permission.asked` | ✅ `permission.asked` | 载荷字段名变了:`permission/patterns/always` → `action/resources/save`(见 §7#6) | +| `permission.replied` | ✅ `permission.replied` | 载荷 `{sessionID, requestID, reply}` | +| `vcs.branch.updated` | ✅ `vcs.branch.updated` | 原样可用 | +| `server.connected` | ✅ `server.connected` | ⚠️ V2 的 `data` 是**空对象**,V1 的 `properties.timestamp` 没有了(见 §7#3) | +| `todo.updated` | ❌ **已移除** | 源码中不存在该事件 → 常量、回调、`todoStore` 写入分支全部删除 | +| `lsp.updated` | ❌ **已移除** | 不在 `ServerDefinitions`(V2 不跑 LSP);原本 `events.ts` 也没有处理分支,只删常量 | + +### 1.2 本阶段**新增接线**的 V2 事件(V1 没有对应物) + +| V2 事件 | 回调 | 用途 | +| ----------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------- | +| `session.execution.started` | `onSessionStatus({type:'busy'})` | **替代 `session.status`**:一轮开始 | +| `session.execution.succeeded` | `onSessionStatus({type:'idle'})` + `onSessionIdle` | **替代 `session.idle`**:一轮成功结束 | +| `session.execution.failed` | `onSessionStatus({type:'idle'})` + `onSessionError` | 一轮失败 | +| `session.execution.interrupted` | `onSessionStatus({type:'idle'})` + `onSessionIdle` + `onMessagesInvalidated` | 用户中断(转录被截断 + 必须让 `isStreaming` 落回 false) | +| `session.usage.updated` | `onSessionUsage` | 会话级累计成本/用量(一轮内会下发 2 次:步中 + 步末) | +| `session.retry.scheduled` | `onSessionRetry` | V1 是 `retry` part,V2 是 assistant 的字段 | +| `session.revert.staged/committed/cleared` | `onMessagesInvalidated` | 回退三段式 → 重拉 | +| `session.step.started` | `onPartUpdated({kind:'step-start'})` | **新建 assistant 消息的权威信号**,带 `agent` / `model` | + +### 1.3 真实服务实际下发的事件(三次抓包汇总,去重 42 种) + +``` +server.connected +session.created +session.renamed +session.execution.started / succeeded / interrupted +session.instructions.updated +session.inbox.enqueued / delivered +session.step.started / streamed / ended / failed +session.text.started / delta / ended +session.reasoning.started / delta / ended +session.tool.input.started / delta / ended +session.tool.called / progress / success / failed +session.usage.updated +project.updated vcs.branch.updated +provider.updated model.updated agent.updated command.updated skill.updated +integration.updated reference.updated plugin.updated websearch.updated +shell.created shell.exited +``` + +**不在这份名单里的**:`session.idle`、`session.status`、`session.message.content.updated` +(前两个 deprecated/无生产者,第三个不在 `V2Event` 联合里)—— 详见 §7。 + +--- + +## 2. 连接管理方案:**用了官方 `client.event.subscribe()`** + +### 2.1 为什么 + +官方实现(`@opencode/client` 的 `SharedEvents.make`,源码逐行读过)已经做好了本项目 V1 手写 1060 行里的绝大部分事情: + +| 能力 | 官方是否已做 | 说明 | +| ------------------------------------------------- | ------------ | ------------------------------------------------------------------------- | +| 共享一条懒连接 | ✅ | 多订阅者复用同一流,最后一个退出才 `controller.abort()` | +| 每订阅者独立队列 | ✅ | 容量 **4096**,溢出报 `Event subscriber exceeded its 4096-event capacity` | +| SSE 文本解析 | ✅ | 多行 `data:` 合并、`\r\n`/`\r` 归一、增量 UTF-8 解码、单事件字节上限 | +| 心跳信号 | ✅ | `onActivity` 回调:**含心跳在内的任何传输活动**都触发 | +| 自动重连 | ❌ | 由本项目负责(`RECONNECT_DELAYS` 退避) | +| 连接状态机 / 代次防串扰 / 后台保活 / 生命周期监听 | ❌ | 由本项目负责(**从 V1 原样保留**) | + +### 2.2 传输层被刻意隔离成一个函数 + +```ts +function createEventTransport(serverId, signal, onActivity): AsyncIterable { + return getSDKClient(serverId).event.subscribe({ signal, onActivity }) +} +``` + +**取舍理由**:Tauri 下 `plugin-http` 的流式表现本容器**无法验证**(见 §8#1)。 +把它隔离成一个函数,真机若发现问题,只需把这一行换成手写 +`fetch('/api/event') + ReadableStream`,**其余(分发 / 合并 / 重连 / 状态机)一行都不用改**。 + +### 2.3 从 V1 原样保留的部分 + +- 每服务器独立连接 + 订阅者集合 + 连接状态广播(`useSyncExternalStore` 友好) +- **代次(generation)防串扰**:重连后旧连接的事件自动失效(实测用例覆盖) +- `RECONNECT_DELAYS = [1s,2s,3s,5s,10s,30s]` 指数退避;后台另有一套更激进的 `[0.5s…10s]` +- 心跳超时判定(前台 60s / 后台 120s)+ 后台 keepalive 轮询(30s) +- 可见性变化 / `online` / `offline` 生命周期监听 +- `onReconnected` 广播(带 2s cooldown,防止快速重连密集触发数据拉取) +- **`coalesceEvents()` 批量合并**(4096 队列溢出是真实风险,必须保留) + +### 2.4 批量合并(V2 字段路径重写) + +``` +同一批内相同 (sessionID, messageID, partID, kind) 的 delta → 字符串拼接成一个 +某个 part 的整块更新(started/ended/called/success/failed)到达 → 丢弃该 part 在途的 delta +``` + +关键点:合并键里的 `partID` 是**已经算好的 UI part id**,与 store 的定位规则完全一致: + +- text / reasoning → `` `${messageID}:content:${ordinal}` `` +- tool → 工具自身的 `id` + +**实测证明**:一次带工具调用的完整回合里,`session.tool.progress` 连续两帧、 +`session.reasoning.delta` / `session.text.delta` 紧随各自的 `*.started` —— 合并逻辑有真实用武之地。 + +--- + +## 3. 恢复策略:**重订阅 + 重拉** + +### 3.1 触发条件(三个入口) + +| 触发 | 位置 | 动作 | +| ---------------------------------------------- | ------------------------------- | ------------------------------------------------------ | +| **流正常结束**(服务端关闭 / 网络断开) | `runEventStream` 的 `done` 分支 | `state='disconnected'` → `scheduleReconnect` | +| **流出错**(fetch 失败 / 队列溢出 / 解析失败) | `catch` 分支 | `state='error'` + `onError(err)` → `scheduleReconnect` | +| **心跳超时**(60s 无任何传输活动) | `resetHeartbeat` 的定时器 | `state='disconnected'` → `scheduleReconnect` | + +另有两个**立即重连**入口(不走退避):页面从后台恢复前台、网络 `online`;以及显式的 +`reconnectServerSSE(serverId)`(切换服务器 / WSL sidecar 换端口)。 + +### 3.2 「重拉」怎么落地 + +V2 的流是**易失的**(官方契约原文:_"Volatile by contract: a slow consumer overflows and +fails the stream, and events during disconnection are missed."_)——**不回放**。 +所以只重订阅会丢消息,必须补一次全量拉取。实现分两层: + +1. **`events.ts`**:连接成功(含首次连接)→ `broadcastReconnected(conn, reason)`, + `reason` 为 `'network'` 或 `'server-switch'`(带 2s cooldown)。 +2. **`useGlobalEvents.onReconnected`**(每个服务器一份): + - `refreshServerHealth(serverId)` + - `fetchAndInitialize(serverId)` —— 重拉 session 状态 + pending 权限/表单 + - **`messageStore.markAllSessionsStale()`** —— 把所有已缓存会话标记为 stale, + 下次读取时 `useSessionManager` 的 `canUseCached` 失效 → **强制重拉** + - 逐个通知会话消费者(`consumer.callbacks.onReconnected?.(reason)`) +3. **`useChatSession.onReconnected`**(每个 pane 一份):`markAllSessionsStale()` + + `loadSession(routeSessionId, { force: true })` → 当前打开的会话**立刻重拉** + (`force` 模式下跳过「SSE 推送比 API 多就不覆盖」的短路,无条件用服务端数据覆盖)。 + +> `SessionContext` / `useSessions` / `useAutoRefresh` / `useGitWorkspaceCatalog` 也各自订阅 +> `onReconnected`,但它们在 `reason === 'server-switch'` 时**跳过**(避免切换服务器时 +> 新旧数据打架)—— 与 V1 行为一致。 + +### 3.3 代次(generation)防串扰 + +每次 `teardownConnectionTransport` / `forceReconnectNow` / `disconnectServerConnection` +都会 `conn.generation++`。`runEventStream` 捕获建立时的 `myGeneration`, +在**每次 `await iterator.next()` 前后**都比对;不一致就立即 `break` 并丢弃整批。 +实测用例「代次防串扰:reconnect 之后旧流的事件被丢弃」覆盖。 + +--- + +## 4. 发消息链路 + +### 4.1 两条链路的实现 + +```ts +// 只投递,不等这轮跑完(UI 常规发送路径) +sendMessageAsync(params) → sdk.session.prompt(buildPromptParams(...)) + +// 投递 + 等这轮跑完 +sendMessage(params) → sdk.session.prompt(...) → sdk.session.wait({ sessionID }) +``` + +依据(照 `v2.0.19` 源码核实,不是推测): + +- `packages/protocol/dist/groups/session.js:278` —— + `POST /api/session/:sessionID/prompt`,payload `{id?, ...PromptInput.Prompt.fields, metadata?, delivery?, resume?}`, + description 原文 _"Durably admit one session input and schedule agent-loop execution unless resume is false."_ + → **prompt 本身就是非阻塞的**,返回 `Session.Inbox.User`。 +- `POST /api/experimental/session/{id}/wait`(SDK `session.wait`)—— _"Wait for a session agent loop to become idle"_。 +- ⚠️ `POST /api/session/{id}/background` **不是** `prompt_async` 的替代品 + (它是 _"Move active foreground backgroundable tools into background observation"_)—— 已在代码注释里写明。 + +### 4.2 `buildPromptParams()`(唯一构造入口,纯函数,已导出供单测) + +`PromptInput.Prompt` 只有 4 个字段:`{text, files?, agents?, skills?}`。映射规则: + +| UI 输入 | V2 参数 | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `params.text` | `text` | +| 附件 `type: 'file' \| 'folder'` | `files: [{ uri, name?, mention? }]`(`uri` 取 `attachment.url`,缺失回落到 `file://${relativePath}`;`mention` 由 `textRange` 展开) | +| 附件 `type: 'agent'` | `agents: [{ name: agentName, mention? }]` | +| 附件 `type: 'text' \| 'command'` | **跳过**(command 走 `session.command` 端点) | +| `params.agent` | `metadata.agent` | +| `params.model` + `params.variant` | `metadata.model = {providerID, modelID, variant}` | + +空的 `files` / `agents` / `metadata` 键**整体省略**(不传 `[]`)。 + +### 4.3 ⚠️ 模型:V2 的 `prompt` **不接受 model 参数** + +`PromptInput.Prompt` 的 4 个字段里没有 model —— V2 的模型是**会话级**的 +(`POST /api/session/{id}/model` + `session.model.selected` 事件)。 +本阶段的处理: + +- 把调用方给的模型写进 `metadata.model`(**与官方 TUI 完全一致**:阶段 2a 实测 TUI 写的就是 + `metadata.model`,且用的是 V1 命名 `modelID`)→ **历史渲染能显示正确的模型** +- **没有**额外发 `switchModel`:那会往转录里插一条 `model-switched` 记录,每次发送都插一次显然不对 +- 「用户换模型 → 切会话模型」的联动属**阶段 3**(需要 `session.switchModel` + 记住当前会话模型) + +### 4.4 `createSession()`(任务 4 点名) + +```ts +const created = await sdk.session.create({ + title, + agent, + model, + ...(directory ? { location: { directory } } : {}), // 🔴 只认 body 里的 location +}) +``` + +🔴 依据 `packages/server/src/handlers/session.ts:136`: +`location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }` +—— 它**既不看中间件也不看请求头**,不传就静默落到服务进程的 cwd(阶段 0/1 已实测)。 +另:V2 的 `SessionCreateInput` **没有 `parentID`**,形参保留但**不发送**(已注释说明)。 + +### 4.5 `sendMessage` 的返回值是**入队记录**,不是 AI 回复 + +V2 没有「一次请求拿回复」的接口(回复只出现在转录里)。 +`SendMessageResponse` 的 `info` 是入队记录、`parts` 恒为 `[]` —— **刻意的**, +已在 `src/api/types.ts` 与 `src/api/message.ts` 双处注明。全仓库只有 `sendMessageAsync` 有生产调用方。 + +--- + +## 5. `v1Model.ts` 删除清单 + +### 5.1 总数 + +| | 阶段 2a 末 | 阶段 2b 末 | +| ------------- | ---------: | ---------: | +| 顶层 `export` | **189** | **104** | +| 行数 | 3502 | 1492 | + +### 5.2 A 桶 66 个(消息 / Part / 消息事件)—— 全部删除 + +| 分组 | 数量 | 类型 | +| --------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 消息主体 | 4 | `UserMessage`、`AssistantMessage`、`Message`、`Prompt` | +| Part 联合成员 | 13 | `TextPart`、`ReasoningPart`、`ToolPart`、`FilePart`、`AgentPart`、`StepStartPart`、`StepFinishPart`、`SnapshotPart`、`PatchPart`、`SubtaskPart`、`RetryPart`、`CompactionPart`、`Part` | +| 工具状态机 | 5 | `ToolStatePending`、`ToolStateRunning`、`ToolStateCompleted`、`ToolStateError`、`ToolState` | +| Part 来源 | 5 | `FilePartSourceText`、`FileSource`、`SymbolSource`、`ResourceSource`、`FilePartSource` | +| 输出格式 | 4 | `OutputFormatText`、`JsonSchema`、`OutputFormatJsonSchema`、`OutputFormat` | +| 消息错误联合 | 7 | `ProviderAuthError`、`UnknownError`、`MessageOutputLengthError`、`MessageAbortedError`、`StructuredOutputError`、`ContextOverflowError`、`ApiError` | +| Part 输入 | 4 | `TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput` | +| `message.*` 同步事件 | 4 | `SyncEventMessageUpdated`、`SyncEventMessageRemoved`、`SyncEventMessagePartUpdated`、`SyncEventMessagePartRemoved` | +| `session.next.*` 消息流事件 | 18 | `Synthetic`、`StepStarted/Ended/Failed`、`TextStarted/Ended`、`ReasoningStarted/Ended`、`ToolInputStarted/Ended`、`ToolCalled/Progress/Success/Failed`、`Retried`、`CompactionStarted/Delta/Ended` | +| 事件载荷 | 2 | `EventMessagePartRemoved`、`EventMessagePartDelta` | + +### 5.3 C 桶 19 个(跨组)—— 17 删 + 2 降级 + +**删除(17)**:`GlobalEvent`、`PromptSource`、`PromptFileAttachment`、`PromptAgentAttachment`、 +`PromptReferenceAttachment`、`SessionErrorUnknown`、`ToolTextContent`、`ToolFileContent`、 +`SessionNextRetryError`、`SyncEventSessionNextAgentSwitched`、`ModelSwitched`、`Moved`、`Prompted`、 +`PromptAdmitted`、`PromptPromoted`、`ShellStarted`、`ShellEnded` + +**降级为非导出(2)**:`SnapshotFileDiff`、`Range` +—— 它们被 **B 桶**的 `Session.summary.diffs` / `EventSessionDiff.diff` / `Symbol.location.range` +结构性地引用,删定义会让 B 桶编译不过。处理方式是**只去掉 `export` 关键字,结构一字未改**, +这样「顶层导出」计数照样从 189 降到 104,而 B 桶一个都没动。 + +> ⚠️ `Range` 必须显式定义:不定义的话它会**静默解析到 DOM 的 `Range`**(`lib.dom.d.ts`), +> `tsc` 照样零报错,但 `Symbol.location.range` 的类型就完全错了 —— 已在文件里加注释警示。 +> `src/types/api/file.ts` 的 `FileDiff` 原先是 `Omit`, +> 已就地定义等价形状(`file`/`patch`/`status` 仍是可选,**结构零变化**)。 + +### 5.4 🔴 如实交代:批量删除脚本误删了 3 个 B 桶类型(已补回) + +删除用的是一个「从 `export type X = {` 扫到首个大括号配平行」的脚本。 +`Pty` / `Todo` / `QuestionOption` 这三个 B 桶类型的内层字段带 `/** … */` 文档注释, +脚本在「向前吞注释」时吃掉了外层注释的起始行、又把结束行算错,于是**把定义删掉了、留下残片**。 + +**发现方式**:用「原始 189 个名字」与「删后剩余名字」做集合差(不是靠 `tsc` —— 因为残片仍能编译)。 +**补回方式**:按仓库内**实际读取的字段**逐一核对后重写,并加注释说明: + +| 类型 | 字段依据 | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `QuestionOption` | `{label, description}`;依据 V2 自带 V1 schema `@opencode/schema/dist/v1/question.d.ts` 的 `Option` | +| `Pty` | `{id, title, command, args, cwd, status: 'running'\|'exited', pid}`;依据 `BottomPanel.tsx:87-89`、`RightPanel.tsx:83`、`App.tsx:585` 实际读的字段 + V2 `Pty` 同构 | +| `Todo` | `{content, status, priority}`;依据 `src/api/todo.ts` 的 `normalizeTodoItems()`,且 `TodoItem = Todo & {id}` 说明服务端无 id | + +**最终核对结果**(脚本 + `tsc --force` + 未定义标识符扫描三重验证): + +``` +orig 189 B 104 remaining 106(= 104 导出 + 2 降级) +MISSING (B 桶里被误删的): [] +EXTRA (本应删掉却还在): [ 'Range', 'SnapshotFileDiff' ] ← 预期的 2 个降级项 +未在本文件定义、也未列白名单的标识符: (空) +顶层 export 计数: 104 +``` + +--- + +## 6. 测试与冒烟结果 + +### 6.1 总体 + +| 场景 | 文件 | 用例 | 结果 | +| ------------------------------ | --------------------- | --------------------------- | ---------------------------------------- | +| **默认(无真实服务开关)** | 99 passed / 2 skipped | **803 passed + 19 skipped** | ✅ **0 失败** | +| **开 `VITE_OPENCODE_SMOKE=1`** | 101 passed | **822 passed + 0 skipped** | ✅ **0 失败**(连续 3 轮复跑均 822/822) | + +- 阶段 2a 基线 **741 例全部仍通过**(0 回归) +- 跳过的 19 例 = 阶段 2a 冒烟 8 + 阶段 2b 冒烟 11(两者都在开关关闭时**自跳过**,不是失败) +- `npx tsc -b --force`:**0 报错** +- `npx eslint <所有改动文件>`:**0 告警** +- `npx prettier --check <所有改动文件>`:**全部通过**(顺带把改动文件里既有的格式漂移一并修正) + +### 6.2 新增 / 改写的测试 + +| 文件 | 用例数 | 覆盖的关键逻辑 | +| --------------------------------------------- | -------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/api/events.test.ts` | **43**(原 5,重写) | 真实帧解析(A 组)、`coalesceEvents` 合并/作废(B 组)、事件分发(C 组)、连接管理(D 组) | +| `src/api/message.test.ts` | 29(原 23) | `buildPromptParams` 7 例(附件/agent/跳过/省略空键/metadata)+ 发送链路 2 例(prompt 顺序、wait 参数)+ 原有分页 20 例 | +| `src/store/messageStore.test.ts` | 35(原 29) | V2 载荷下的 part upsert、text/reasoning/input 三种 delta、step-finish 只在 finish 存在时合成、`handleSessionInvalidated` 置 stale、`handleMessageUpdated` 全量替换 | +| `src/store/messageStoreHooks.test.tsx` | 6 | 夹具换 V2 载荷 | +| `src/hooks/useGlobalEvents.test.tsx` | 25 | 回调载荷换 V2;`handlePartRemoved` → `handleSessionInvalidated`;`onTodoUpdated` 用例删除;question → form | +| `src/contexts/SessionContext.test.tsx` | 7(原 5) | 新增 2 例锁住「部分补丁必须合并、不得覆盖成 undefined」与「本地没有该会话时交给服务端重查」 | +| `src/api/phase1Smoke.test.ts` | 13(原 12) | 删掉 `searchText` 报错断言;新增「文件名搜索在真实服务上能命中」 | +| `src/components/FileExplorer.test.tsx` | 4(原 3) | 新增「只剩文件名搜索」:调用 `searchFiles`、渲染在 Files 分组、Content 分组不存在 | +| `src/features/message/phase2b.smoke.test.tsx` | **11**(新增) | 真实服务冒烟,见 §6.4 | + +### 6.3 真实事件帧夹具(任务 8 的硬性要求) + +**新增 `src/test/fixtures/v2EventFrames.ts`**:把三次真实抓包得到的 **30 个原始帧**(含心跳注释行) +逐字节存成夹具,来源与抓包命令写在文件头。用途: + +1. `A. 真实事件帧解析` 组用它验证 **wire 格式**: + - 只有 `data:` 行,**没有** `event:` / `id:` 行 + - `data` 后面是**一层** JSON,事件是**扁平**结构(`type` 与 `data` 同级,不是 V1 的 `{directory, payload:{type, properties}}`) + - 心跳是**注释行**(解析不出事件) + - 首帧是 `server.connected`,`data` 为空对象且**没有 `created`** +2. `C. 事件分发` 组用它验证 **字段路径**:把真实帧喂进 `subscribeToEvents`,断言 + - `session.text.*` 的 `assistantMessageID` / `ordinal` / `delta` / `text` + - **合成 part id = `消息id:content:下标`**(不是 V2 的任何字段) + - `session.tool.*` 用**工具自身的 `id`** 作 part id;`input.ended` **不带 `name`** + - `session.step.started` 带 `agent`/`model`;`session.step.ended` 带 `finish`/`cost`/`tokens` + - `session.created` 的 `sessionID`/`slug`/`version`/`location.directory`,且**没有 `time`** + - `session.renamed` / `session.moved` → `SessionInfoPatch` 部分补丁 + +> 真实帧一旦被 V2 改字段,这组测试会立刻红掉 —— 这就是它存在的意义。 + +### 6.4 API 冒烟(真实 V2 服务,11/11 通过) + +环境:`OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097` +入口:`VITE_OPENCODE_SMOKE=1 npx vitest run src/features/message/phase2b.smoke.test.tsx` + +| 用例 | 实测结果 | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| 订阅 `/api/event` 能收到 `server.connected` **首帧** | ✅ 首帧 `data: {"id":"evt_…","type":"server.connected","data":{}}`(**无 `created`**) | +| **心跳**正常 | ✅ 连接后 **≈0.01s 立刻一次**,之后严格 **15.00s / 30.00s / 45.00s**;全是注释行 | +| 发 `prompt` 后事件流真的有数据 | ✅ `session.execution.started` → `step.started` → `text.started` → `text.delta` → `text.ended` → `step.ended` → `execution.succeeded` | +| 🔴 `session.idle` / `session.status` 从未下发 | ✅ 22~100 秒窗口、三次抓包、含一次带工具调用的完整回合与一次 interrupt,**一帧都没有** | +| 🔴 `session.message.content.updated` 也不下发 | ✅ 同上 | +| `execution.*` → busy/idle 映射生效 | ✅ 事件层回调实测收到 `['busy','idle']` + `onSessionIdle` | +| delta 的 part id 合成正确 | ✅ `partID === `${assistantMessageID}:content:${ordinal}`` | +| `step.started` / `step.ended` 载荷正确 | ✅ `messageID` 与真实 `assistantMessageID` 一致 | +| `sendMessageAsync` 走 `prompt` 且非阻塞 | ✅ 返回 < 15s;随后转录里出现 `user` 消息 | +| `sendMessage` 走 `prompt` + `wait` | ✅ `wait` 返回后转录里已有 assistant 消息且有 parts | +| 附件 / agent 参数形状 | ✅ `buildPromptParams` 纯函数断言(`files[].uri` + `mention`、`metadata.agent`、`metadata.model`) | + +**顺带修好两个老问题**(都属测试自身质量,**不是被测代码回归**): + +1. **阶段 2a 冒烟选会话的启发式太脆**。原来是「最新 12 个会话里第一个非空的」—— + 本阶段的冒烟在仓库目录里新建会话,于是它选中了一个只有 1~2 条消息的小会话, + 导致「hasMore 必须为真」「必须能找到工具调用」等 6 条断言失败。 + 已改成「按新→旧逐个体检、**取消息数最多的那个**,探针页填满即提前收工」, + 并把 `GET /api/session` 的 `limit` 显式提到 200(**服务端默认只给最新 50 个**, + 不显式要就会被截断 —— 这是踩到的第二个坑)。 +2. **冒烟测试会往数据目录里堆会话**。每次全量冒烟都会新建 2~3 个会话; + 跑几轮后仓库目录攒到 97 个会话,把老的长会话挤到列表很后面(正是问题 1 的诱因)。 + 已给 `phase2b.smoke.test.tsx` 加 `afterAll` **自清理**:只删本次运行自己建的 id, + 不碰任何既有会话。实测连续 3 次全量冒烟后会话总数稳定不变(97 → 97)。 + +> ⚠️ **遗留**:本阶段早期(未加自清理之前)的冒烟运行已在 +> `/home/coder/project/OpenCodeUI` 目录留下约 40 个 1~4 条消息的测试会话。 +> **未做删除**(对用户数据的破坏性操作需明确授权)。清理命令见 §8#10。 + +--- + +## 7. 🔴 与文档预测不符之处 + +> 本节是本次报告最重要的部分。前三轮都是这么做的。 + +### 7.1 🔴🔴 文档 §6.4 把 `session.idle` / `session.status` 判为「✅ 原样可用」——**实测二者从不下发** + +| | 内容 | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | `session.idle` ✅(`session-status-event.ts`)、`session.status` ✅ | +| **实测** | v2.0.19 **一帧都没有**。22~100 秒窗口、三次抓包、含带工具调用的完整回合 + 一次 interrupt,逐帧核对 | +| **源码依据** | 两者**确实在** `ServerDefinitions` 里(`event-manifest.js` 的 `...SessionStatusEvent.Definitions`),所以类型与常量都在;但 `SessionStatusEvent.Idle` 上明确标了 **`// deprecated`** → **没有生产者** | +| **后果(严重)** | `useGlobalEvents.onSessionIdle` 负责 `messageStore.handleSessionIdle()`(把 `isStreaming` 落回 false、给流式消息补 `completed`)与 `childSessionStore.markIdle`;`onSessionStatus` 负责 `activeSessionStore.updateStatus`("Working" 列表 + busy→idle 通知)。**这两个回调永不触发 → 界面会一直停在「生成中」,侧栏永远有「工作中」** | +| **处理** | ✅ 改用**确实会下发**的 `session.execution.*` 做映射:`started`→busy、`succeeded`→idle+`onSessionIdle`、`failed`→idle+`onSessionError`、`interrupted`→idle+`onSessionIdle`+`onMessagesInvalidated`。`session.idle`/`session.status` 的常量与分支**保留**(将来恢复了也能用),但代码注释明确写了「不要依赖它」 | +| **回填** | ✅ 主文档 §6.4 已加「阶段 2b 实测修正」标注 | + +> ⚠️ `execution.interrupted` 的映射里**必须**带 `onSessionIdle`: +> 实测用户中断后不会有 `execution.succeeded`,只 reset 状态码是不够的, +> `isStreaming` 会一直停在 true。这一条是本阶段自查时发现的(已补测试)。 + +### 7.2 🔴 `session.message.content.updated` 有 schema 类型、但**不在 `V2Event` 联合里**,且实测不下发 + +| | 内容 | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | `message.updated` → `session.message.content.updated`(§6.4) | +| **实测 1(类型层)** | `@opencode/client` 里**有** `SessionMessageContentUpdated` 类型(`types.d.ts:3257`),也出现在事件日志联合 `SessionEventDurable` 里,但 **`V2Event`(`types.d.ts:3310`)的联合里没有它** → `client.event.subscribe()` 的静态类型不包含该事件,直接 `satisfies Record` 会**编译失败** | +| **实测 2(运行时)** | 三次抓包**一帧都没有** | +| **处理** | ① `V2EventUnion = V2Event \| SessionMessageContentUpdated`,把 SDK 漏掉的这一支手工并回来并加注释;② `satisfies` 放宽到 `V2Event['type'] \| SessionMessageContentUpdated['type']`;③ **保留** `handleMessageUpdated` 分支(API 完整),但在注释与报告里都注明「有分支、无生产者」 | +| **性质** | V2 自身的**枚举/联合不一致**(与阶段 2a 发现的「`idle` 能返回但不能过滤」同一类),不是本项目的问题 | + +### 7.3 🔴 心跳:**连接后立刻有一次**,之后才是 15 秒间隔 + +| | 内容 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | §6.1「心跳:每 **15 秒**一行注释 `: heartbeat`」 | +| **实测** | `0.00s server.connected` / **`0.01s HEARTBEAT`** / `15.00s` / `30.00s` / `45.00s` —— 首帧心跳**几乎立刻**到达 | +| **影响** | 若照文档实现「15 秒才该有心跳」的判定,会误判;反过来,若用「收到心跳」当连接成功信号,会**过早**认为连接就绪 | +| **处理** | ✅ 本项目用的是 `onActivity`(任何传输活动都刷新心跳超时),两种行为都能正确覆盖;冒烟测试的断言已按实测改为「首帧心跳 < 5s,之后间隔 12~20s」 | +| **回填** | ✅ 主文档 §6.1 已加标注 | + +### 7.4 🟡 `server.connected` 的 `data` 是空对象,V1 的 `properties.timestamp` 没有了 + +| | 内容 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | §6.1「首帧:连接即刻收到 `{id, type:"server.connected", data:{}}`」(文档这里是对的) | +| **本项目受影响点** | `useGlobalEvents.onServerConnected` → `serverStore.applyServerConnectedTimestamp(serverId, data.timestamp)` 做**时钟校准** | +| **实测** | `server.connected` 既没有 `data.timestamp`,也**没有 `created`** | +| **处理** | ✅ 事件层尽力而为:传事件的 `created`(有就给、没有就是 `undefined`);`applyServerConnectedTimestamp` 对非数字直接 `return false`,**静默跳过校准**。即:V2 下**服务器时钟校准能力丢失**,如实记录(未见 UI 依赖它的硬需求) | + +### 7.5 🟡 `session.created` **事件**的字段与 REST 的 `Session.Info` 不一致 + +| 概念 | 事件 `session.created.data` | REST `Session.Info` | +| -------------- | --------------------------- | ----------------------------------------------- | +| id | **`sessionID`** | `id` | +| 目录 | `location.directory` | `location.directory` | +| slug / version | **有** | ❌ 没有(`toInternalSession` 用 id / 空串兜底) | +| **时间** | ❌ **完全没有 `time`** | `time.created/updated` | +| 成本 / 用量 | ❌ 没有 | `cost` / `tokens` | + +→ 事件层新增 `sessionFromCreatedEvent()` 逐字段映射,**时间用事件自身的 `created` 兜底**(语义正确)。 +文档 §5.2 只讲了 REST 侧,没提事件侧字段不同。 + +### 7.6 🔴 权限载荷:不是「多了 `patterns`」,而是 `permission→action` + `patterns→resources` + +| | 内容 | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **阶段 1 注释原文** | 「V2 的 `PermissionRequest` 结构变了(**少了 permission 字段、多了 patterns**)」 | +| **实测(生成类型逐字段核对)** | V2 是 `{id, sessionID, action, resources, save?, source?, message?}` —— **`patterns` 是 V1 的名字**,V2 叫 `resources`;`permission` 改叫 `action`;`always` 改叫 `save`;`tool:{messageID,callID}` 改叫 `source:{type:'tool',messageID,id}` | +| **处理** | ✅ 新增 `v2Convert.toInternalPermissionRequest()` 做映射,权限 UI 与 `SessionEventCallbacks` 本阶段零改动;阶段 3 换权限链路时一并清理 | + +### 7.7 🔴 V2 的 `prompt` **不接受 model 参数**(文档 §4.2 的请求体列表里没有它,但没点明后果) + +`PromptInput.Prompt`(`@opencode/schema/dist/prompt-input.js`)只有 `{text, files?, agents?, skills?}`。 +V2 的模型是**会话级**的。文档 §4.2 写的是「请求体 `{text, files, agents, skills, metadata, delivery, resume}`」 +—— 列的是对的,但没说明「**V1 的逐条消息指定模型在 V2 不存在了**」,这是个容易踩的坑。 +→ 处理见 §4.3(写 `metadata.model` 与官方 TUI 一致;切模型属阶段 3)。 + +### 7.8 🟡 `session.step.started` 是「新建 assistant 消息」的权威信号,文档没提 + +文档 §6.4 的流式事件族表里列了 `session.step.started/streamed/ended/failed` 对应 +「`step-start` / `step-finish`」,但没说它**带 `agent` 与 `model`**。 +V2 的 `session.text.started` 只给 `assistantMessageID` + `ordinal`,**拿不到 agent/model**; +而 UI 的助手页脚要显示模型名 → 本阶段据此新增了 `PartUpdatedPayload` 的 `kind: 'step-start'` 分支, +流式期间就能把 `modelID`/`providerID`/`agent`/`time.created` 填对。 + +### 7.9 🟡 工具事件的三个「载荷不全」之处(文档未提) + +| 事件 | 实测现象 | 处理 | +| -------------------------- | ----------------------------------------------- | --------------------------------------------------------- | +| `session.tool.input.ended` | **不带 `name`**(只有 `input.started` 带) | store 合并时**保留已有的 name**,否则工具卡片会变空白标题 | +| `session.tool.called` | 实测帧里**没有 `state`** 字段(类型上是可选的) | 按可选处理(`providerState: undefined`) | +| `session.tool.success` | 实测帧里**没有 `resultState`** | 同上 | + +### 7.10 🟡 `worktree.resolved` 不是「失败」 + +文档 §6.4 写 `worktree.failed` → `worktree.resolved`(名字对应关系是对的), +但 `worktree.resolved` 的语义是「目录被解析/采用」(`{projectID, directory, previous, adopted?}`), +**不是失败**。原代码在 `onWorktreeFailed` 里把 `data.message` 塞进错误提示 —— V2 没有 `message` 字段。 +→ 处理:`WorktreePanel` 改成「只刷新列表 + 清 loading」,不再弹错误(并加注释说明语义变化)。 + +### 7.11 🟡 Form 体系:**不是 question 的等价替换** + +文档 §4.3 已经点明「Form 是全新 UI 能力」,本阶段实测补充:`form.created` 的载荷是 +`{form: {id, sessionID, title, fields, metadata?}}`,与 V1 `QuestionRequest` 的 +`{questions: [{question, header, options, multiple}]}` **结构完全不兼容**。 +→ 处理:事件层按 V2 真实事件名接线(`form.created/replied/cancelled`), +`useGlobalEvents` 只做 **pending 登记 + 通知**(沿用原来的通知/提示音路径), +**不**向会话消费者分发;Form 渲染器属阶段 3。 + +### 7.12 🟡 `session.step.failed` 会带 `aborted` 错误(用户中断时) + +实测一次 interrupt 的帧序列:`session.execution.interrupted`(`reason:"user"`)+ `session.step.failed` +(`error: {type:"aborted", message:"Step interrupted"}`,**无 `finish` / `cost` / `tokens`**)。 +→ 阶段 2a 的 `toMessageError()` 会把 `aborted` 映射成 `MessageAbortedError`, +UI 的 `isAbortedMessage()` 因此能正确显示中止态;`finish` 缺失时**不合成** step-finish part, +与读侧规则一致。 + +### 7.13 🟡 阶段 0/1 报告的一处小偏差 + +阶段 0 报告说 `--log-level` 只影响 WSL 路径 —— 本阶段复核:**桌面路径 `opencode.rs:88` 不带任何参数**, +确实不受影响(结论不变)。此条仅作记录,无新增差异。 + +### 7.14 🟡 V2 对 location 的**惰性初始化**(本阶段再次复现) + +首次访问某个 location 时,第一批请求可能返回**不完整**列表(模型 / skill / 文件索引都复现过), +紧接着的第二次请求就正常。阶段 0.5 报告 §5⑩ 已记录;本阶段在 +`phase1Smoke` 的 `getSkills(DIR_B)` 与 `searchFiles` 上**再次复现**,已在测试里用「预热一次」绕过。 +**UI 侧任何新增的 per-location 调用都应有重试或「等 location 就绪」的处理。** + +--- + +## 8. 未做 / 已知缺口(如实) + +| # | 项 | 说明 | +| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | **Tauri 下 `plugin-http` 的流式未实测** | 容器内无 Tauri 运行时。传输层已隔离成 `createEventTransport()` 一个函数,真机若异常只需换掉它,其余一行不动 | +| 2 | **浏览器人工验证未做** | 按用户决定只用单测 + API 冒烟(阶段 2a 的同类缺口沿用) | +| 3 | **`handleMessageUpdated` 有分支、无生产者** | 依赖 `session.message.content.updated`,实测不下发(见 §7.2)。保留是为了 API 完整 | +| 4 | **Form 渲染器缺失** | 事件已接线,但 UI 还是 V1 的 question 组件(且 `getPendingQuestions` 仍抛 `notMigratedYet`)→ 表单实际不可用(阶段 3) | +| 5 | **权限回复不可用** | `replyPermission` 仍抛 `notMigratedYet`(阶段 3) | +| 6 | **换模型不生效** | `prompt` 不接受 model;只写进 `metadata.model` 供历史显示(见 §4.3 / §7.7) | +| 7 | **`sendMessage`(阻塞版)无生产调用方** | 返回值是拼出来的入队记录,`parts` 恒空;若将来有人渲染它会显示空 AI 消息(已在两处注释警示) | +| 8 | **`useGlobalEvents` 的 `pendingQuestions` 队列成了死代码** | V1 由 `onQuestionAsked` 写入;V2 的 `onFormCreated` 只做 pending 登记,不再写入该队列,但 `onSessionCreated` 仍 `drainPending`、`onFormReplied/Cancelled` 仍 `removePendingByRequestId` —— 永远空转。建议阶段 3 做表单分发时一并清理或补齐 | +| 9 | **`sendMessageAsync` 的 1.5s 兜底拉取仍在** | `useChatSession` 里那段「SSE 没推就主动拉一次」的逻辑未动,作为断流兜底保留 | +| 10 | **本机数据目录残留约 40 个测试会话** | 阶段 2b **早期**的冒烟运行(自清理机制加入之前)在 `/home/coder/project/OpenCodeUI` 目录建了约 40 个 1~4 条消息的会话。**未做删除**(属对用户数据的破坏性操作,需明确授权)。已加自清理,后续运行不再增长(实测 3 轮 97 → 97)。清理命令:
`curl -u opencode:t1 "http://127.0.0.1:4097/api/session?directory=<目录>&limit=200"` 找到测试会话后 `curl -u opencode:t1 -X DELETE http://127.0.0.1:4097/api/session/` | +| 11 | **`session.usage.updated` 未接入 UI** | 事件已分发到 `onSessionUsage`,但 `useGlobalEvents` 没有消费者(用量已由 assistant 的 step-finish 展示)。属 YAGNI,留作阶段 3 可选 | +| 12 | **`session.shell.*` / `shell.created` / `shell.exited` 未接线** | V2 新增的会话级 shell 消息与 shell 作业事件;本阶段范围外(`shell` 消息类型阶段 2a 已能渲染) | +| 13 | **Rust / Tauri / WSL 未跑** | 与阶段 1/2a 一致(本阶段完全没碰 `src-tauri/`) | +| 14 | **prettier 全仓库仍有漂移** | 本阶段把**所有改动过的文件**格式化到合规,未做全仓库格式化(避免无关 diff) | + +> **修订(2026-09-30)**:上表 #6「**换模型不生效**」已在真实使用暴露并修复 —— +> 发送前调用 `session.switchModel`(幂等)+ 建会话时带上所选模型,见主文档 §10.6。 + +--- + +## 9. 建议的后续动作 + +### 9.1 立刻可做(人工回归,约 10 分钟) + +```bash +# 1) 起真实 V2 服务 +OPENCODE_SERVER_PASSWORD=t1 opencode --log-level info serve --hostname 127.0.0.1 --port 4097 + +# 2) 起前端(dev proxy 已在阶段 2b 修好,浏览器可直连) +npm run dev + +# 3) 界面里: +# - 新建会话 → 发一条消息 → 确认「流式逐字输出」+ 推理折叠 + 工具卡片 +# - 确认一轮结束后「生成中」指示消失(依赖 execution.succeeded 的映射,见 §7.1) +# - 中途按停止 → 确认立即回到可输入状态(依赖 execution.interrupted 的映射) +# - 打开历史长会话 → 滚到顶部继续加载(阶段 2a 的能力) +# - 文件浏览器搜索框:只应搜到文件名,**没有**内容匹配分组 +``` + +### 9.2 阶段 3 待办交接 + +1. **Form 表单渲染器**(替代 question):`GET /api/session/{id}/form` + `POST .../form/{formID}/reply` + + `DELETE .../form/{formID}`;六种字段类型 + `when` 条件;事件侧已接线(`form.created/replied/cancelled`) +2. **权限链路**:`GET /api/permission/request` + `POST .../permission/{rid}/reply`(`{decision}`)+ + `GET/DELETE /api/permission/saved`;载荷映射已在 `v2Convert.toInternalPermissionRequest()` +3. **换模型联动**:`POST /api/session/{id}/model`(`session.switchModel`)+ 记住当前会话模型, + 避免每次发送都写 `metadata.model` +4. **回退三段式**:`revert/stage` → `revert/commit` → `DELETE revert`;事件侧已接 + `session.revert.staged/committed/cleared` → `onMessagesInvalidated` +5. **中止**:`abort` → `POST /api/session/{id}/interrupt`(事件侧已接 `execution.interrupted`) +6. **`v1Model.ts` 的 B 桶 104 个**(清单见阶段 2a 报告 §1.3):permission / session / config / file / + mcp / vcs / worktree / pty / tool / lsp / todo / provider / model / project / auth +7. **`pendingQuestions` 死代码清理**(见 §8#8) +8. **B 桶收尾时的连带项**:`src/types/api/{common,file}.ts` 里为「A/C 桶删除」做的就地定义 + (`SnapshotFileDiffShape`、`v1Model` 的非导出 `SnapshotFileDiff`/`Range`)可以一起收敛 +9. **Tauri 真机验证** `plugin-http` 的流式(见 §8#1) +10. **`session.usage.updated` / `session.shell.*` / `session.skill.activated` / `session.synthetic` / + `session.instructions.updated` 的可选接入**(事件层已能分发,只差 UI 消费) + +--- + +## 10. 附:本阶段改动清单 + +### 10.1 重写 / 大改 + +| 文件 | 阶段 2a 末 | 阶段 2b 末 | 说明 | +| -------------------------------- | -----------------: | ---------------------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/api/events.ts` | 1060 行 | **1384 行** | 传输层换官方 `subscribe()`;V2 事件分发;`coalesceEvents` 字段路径重写;删掉 Tauri bridge / 手写 SSE 解析 / `normalizeSessionError` / `normalizeTodoItems` 依赖 | +| `src/types/api/event.ts` | 177 行 | **506 行** | V2 `EventTypes`(45 个常量)+ V2 `EventCallbacks`(24 个回调)+ 全部载荷类型 | +| `src/types/api/v1Model.ts` | 3502 行 / 189 导出 | **1492 行 / 104 导出** | A 桶 66 删 + C 桶 17 删 + 2 降级 | +| `src/store/messageStore.ts` | 968 行 | **1156 行** | 4 个事件处理器改 V2 形状;新增 `applyStepStarted` / `applyStepEnded` / `upsertPart` / `buildContentParts` / `createStreamingAssistantInfo` | +| `src/utils/messageConversion.ts` | 704 行 | 720 行 | 新增 `toUIPartFromContent` / `toStepFinishPart` / `isTextLikePart`;导出 `toTokenUsage` / `toMessageError`;删除 4 个 V1 形状遗留函数 | +| `src/api/message.ts` | 259 行 | **381 行** | `sendMessage` / `sendMessageAsync` / `buildPromptParams` 实现 | +| `src/api/file.ts` | 118 行 | 128 行 | 实现 `searchFiles`(`GET /api/fs/find`);删除 `searchText` / `searchSymbols` | + +### 10.2 新增 + +| 文件 | 行数 | 说明 | +| --------------------------------------------- | ---: | ------------------------------------------------------------- | +| `src/api/events.test.ts` | 908 | 事件层单测(**43 例**),重写自原 359 行的 V1 版本(原 5 例) | +| `src/test/fixtures/v2EventFrames.ts` | 59 | **真实抓包**的 30 个事件帧夹具 | +| `src/features/message/phase2b.smoke.test.tsx` | 416 | 真实服务冒烟(**11 例**,默认 skip) | +| `src/types/api/todo.ts` | 28 | `TodoItem` 从 `event.ts` 搬家(V2 无 `todo.updated`) | + +### 10.3 小改(适配性) + +`src/hooks/useGlobalEvents.ts`、`src/hooks/useSessions.ts`、`src/contexts/SessionContext.tsx`、 +`src/components/WorktreePanel.tsx`、`src/hooks/useGitWorkspaceCatalog.ts`、`src/components/FileExplorer.tsx`、 +`src/api/session.ts`、`src/api/v2Convert.ts`、`src/api/types.ts`、`src/types/index.ts`、`src/types/ui.ts`、 +`src/types/api/{index,message,common,file}.ts`、`src/locales/{zh-CN,en}/components.json`、`vite.config.ts` + +### 10.4 测试改动 + +`src/store/messageStore.test.ts`、`src/store/messageStoreHooks.test.tsx`、`src/hooks/useGlobalEvents.test.tsx`、 +`src/contexts/SessionContext.test.tsx`、`src/api/message.test.ts`、`src/api/phase1Smoke.test.ts`、 +`src/components/FileExplorer.test.tsx`、`src/features/message/phase2a.smoke.test.tsx` + +### 10.5 文档 + +| 文件 | 改动 | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `docs/opencode-v2-migration.md` | §6.1 心跳修正;§6.3 传输层取舍;§6.4 事件存活判定 4 条实测修正;§8 阶段 2 标注 2b 完成;§9.3 删除已完成项;§9.5 两个决策落地 | +| `docs/opencode-v2-migration-phase2b.md` | 本报告(新增) | diff --git a/docs/opencode-v2-migration-phase3a.md b/docs/opencode-v2-migration-phase3a.md new file mode 100644 index 000000000..010537a37 --- /dev/null +++ b/docs/opencode-v2-migration-phase3a.md @@ -0,0 +1,609 @@ +# OpenCodeUI 迁移 V2 —— 阶段 3a 报告(功能补齐) + +> 状态:**阶段 3a 已完成**(2026-09-30) +> 目标环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19` +> 验收:`tsc` **0 报错**;全量测试 **967 passed / 38 skipped / 0 failed**(服务未启动时); +> 真实服务冒烟 **6/6**(中断、回退三段式、权限回复、表单、PTY、MCP 各至少一次) +> 前置报告:`docs/opencode-v2-migration-phase0.5.md`、`-phase2a.md`、`-phase2b.md` +> **计数口径**:任务书写「51 处 `notMigratedYet`」,逐文件核对后确认为 **51 处**(见 §1.1 逐条表)。 + +--- + +## 0. 三件必须先交代的事 + +### 0.1 硬性约束逐条对照 + +| 约束 | 实际执行 | +| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **允许改** `src/api/**`、`src/features/**`、`src/store/**`、`src/utils/`、测试、`docs/` | ✅ 改动集中在这几处(另有少量 `src/components/**`、`src/hooks/**` 的**必要**适配,见 §5.2) | +| **禁止改 `src-tauri/`** | ✅ 一行未动(`git diff --stat src-tauri/` 里的 2 个文件是**阶段 0** 的改动,非本阶段) | +| **禁止改 `v1Model.ts` 的 B 桶 104 个导出** | ✅ 一个导出都没删;本阶段新类型一律「就地重定义」在各自模块里(`mcp.ts` / `pty.ts` / `vcs.ts` / `worktree.ts` / `form.ts`) | +| **禁止删除 UI** | ✅ 未删除任何组件。V2 删掉的能力一律用 `removedInV2()` 标记,UI 清理归 3b | +| **禁止 git commit / push / reset / checkout / worktree** | ✅ 只用了 `git status` / `git diff` / `git ls-files`(只读) | +| **禁止删除 `docs/` 下任何文件** | ✅ 只新增 + 追加修订 | +| 测试服务用完必须关闭 | ✅ 4097 上的临时服务已 `kill` 并确认端口无响应;**用户自己的 4096 服务未触碰** | +| 不得在用户数据目录留下测试会话 | ✅ 冒烟自建会话在 `afterAll` 全删;实测 `/home/coder/project/OpenCodeUI` 下**零** `[phase3a-smoke]` 残留 | +| 全程简体中文注释与报告 | ✅ | + +### 0.2 本阶段**没有**做的事 + +1. **没有删除任何 UI 入口**(LSP/格式化器状态、share、归档、todo、worktree reset、git init、配置保存…) + —— 按硬性约束只做 `removedInV2()` 标记,清单见 §7。 +2. **没有接入** V2 新增的 `persistent-pty`、`plugin RPC`、`/api/shell`、`/api/websearch`、`/api/rpc`、 + `session.inbox`、`session.instructions`、`/api/vcs/base`、`/api/vcs/branch`、`fs/write`(YAGNI,与 §9.2 一致)。 +3. **Tauri / WSL / Docker 未跑**(容器内无 Tauri 运行时、无 WSL)—— 与阶段 1/2a/2b 一致。 + ⚠️ 这直接影响 `ptyBridge.ts`(先取 ticket 再 `bridge_connect`)—— **真机未验证**,如实记录。 +4. **没有改配置编辑器**(`src/features/settings/**`):V2 的写入口只有 `shell`,属于 3b 的「改只读 + 引导编辑文件」。 +5. **没有做全仓库 prettier 格式化**(避免无关 diff);只格式化本阶段改动过的文件。 + +### 0.3 结论摘要 + +- **`notMigratedYet` 调用点在 `src/api/` 已清零**(仅剩定义本身):`grep -rn "notMigratedYet(" src/api/` → 0 命中。 +- 新增第二个标记函数 `removedInV2()`,把「V2 已删能力」和「还没迁移」区分开 + → `grep -rn "removedInV2(" src/api/` 就是 3b 的移除清单(**13 处**)。 +- 新增 **Form 表单渲染器**(六种字段类型 + `when` 联动 + `external` 确认位),替代 V1 的 question。 +- 回退改造为 **`stage` → `commit` → `clear`** 三段式;**运行时不再抛错**(阶段 2a 只做了类型适配)。 +- 全量测试 **967 passed / 0 failed**(服务未启动);服务启动时 **980 passed**。 + 新增单测约 **164 个**(`file`/`vcs`/`worktree`/`config`/`global`/`client`/`tool`/`lsp`/`command`/`mcp`/`pty`/`form`/`permission`/`session`)。 + +--- + +## 1. 51 处 `notMigratedYet` 逐个处置表 + +### 1.1 总表 + +图例:**迁移** = 改成真实 V2 调用;**3b** = `removedInV2()` 标记(V2 已删能力,UI 清理归 3b)。 + +| # | 文件:函数 | 处置 | V2 端点 / SDK | +| --: | ----------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `session.ts::getLastTurnDiff` | 迁移 | `GET /api/session/{id}/diff`(**不传 `from` 时服务端默认就是「最新一条 user 消息所在轮次」**)→ `sdk.session.diff({sessionID})` | +| 2 | `session.ts::updateSession` | 迁移 | `PATCH /api/session/{id}` → `sdk.session.update` + **回读一次**(V2 返回 void) | +| 3 | `session.ts::deleteSession` | 迁移 | `DELETE /api/session/{id}` → `sdk.session.remove`(方法名从 `delete` 改名) | +| 4 | `session.ts::abortSession` | 迁移 | `POST /api/session/{id}/interrupt` → `sdk.session.interrupt`,返回 `interrupted` | +| 5 | `session.ts::revertMessage` | 迁移(**改名** `stageRevert`) | `POST /api/session/{id}/revert/stage` | +| 6 | `session.ts::unrevertSession` | 迁移(**改名** `clearRevert`) | `DELETE /api/session/{id}/revert` | +| 7 | `session.ts::shareSession` | **3b** | V2 删除了分享端点(仅配置层 `share:"auto"`,无 API) | +| 8 | `session.ts::unshareSession` | **3b** | 同上 | +| 9 | `session.ts::forkSession` | 迁移 | `POST /api/session/{id}/fork`,body **`{before}`**(不是 `messageID`) | +| 10 | `session.ts::summarizeSession` | 迁移 | `POST /api/session/{id}/compact` → `sdk.session.compact`(**不接受模型参数**) | +| 11 | `session.ts::getSessionChildren` | 迁移 | `GET /api/session?parentID=` → `sdk.session.list({parentID})` | +| 12 | `session.ts::getSessionTodos` | **3b** | V2 删除了 todo 端点,事件侧亦无 `todo.updated` | +| 13 | `permission.ts::getPendingPermissions` | 迁移 | `GET /api/permission/request`(location 作用域) | +| 14 | `permission.ts::replyPermission` | 迁移 | `POST /api/session/{id}/permission/{rid}/reply`,body `{decision, message?}` | +| 15 | `permission.ts::getPendingQuestions` | 迁移(**改名** `listPendingForms`,搬到 `form.ts`) | `GET /api/form`(位置级)/ `GET /api/session/{id}/form`(会话级) | +| 16 | `permission.ts::replyQuestion` | 迁移(**改名** `replyForm`) | `POST /api/session/{id}/form/{formID}/reply` | +| 17 | `permission.ts::rejectQuestion` | 迁移(**改名** `cancelForm`) | `DELETE /api/session/{id}/form/{formID}` | +| 18 | `pty.ts::listPtySessions` | 迁移 | `GET /api/pty`(location 作用域,解包 `{location,data}`) | +| 19 | `pty.ts::createPtySession` | 迁移 | `POST /api/pty` | +| 20 | `pty.ts::getPtySession` | 迁移 | `GET /api/pty/{ptyID}` | +| 21 | `pty.ts::updatePtySession` | 迁移 | **`PUT`** `/api/pty/{ptyID}`(方法从 PATCH 改 PUT) | +| 22 | `pty.ts::removePtySession` | 迁移 | `DELETE /api/pty/{ptyID}` | +| 23 | `pty.ts::getPtyConnectUrl`(非 `notMigratedYet`,但同属连接协议) | 迁移(**改签名**) | 新增 `createPtyConnectTicket()` → `POST /api/pty/{ptyID}/connect-token`;URL 改为「带 ticket」 | +| 24 | `mcp.ts::getMcpStatus` | 迁移 | `GET /api/mcp`(**数组**,不再是 `Record`) | +| 25 | `mcp.ts::getMcpResources` | 迁移 | `GET /api/mcp/resource`(新增 `templates`) | +| 26 | `mcp.ts::addMcpServer` | 迁移 | `PUT /api/experimental/mcp/{server}`,body `{config}` | +| 27 | `mcp.ts::connectMcpServer` | 迁移 | `POST /api/experimental/mcp/{server}/connect` | +| 28 | `mcp.ts::disconnectMcpServer` | 迁移 | `POST /api/experimental/mcp/{server}/disconnect` | +| 29 | `mcp.ts::startMcpAuth` | 迁移 | `/api/integration/*`:`GET /api/integration/{id}` 找 oauth method → `POST .../connect/oauth` | +| 30 | `mcp.ts::removeMcpAuth` | 迁移 | `GET /api/integration/{id}` 找 credential connection → `DELETE /api/credential/{credentialID}` | +| 31 | `mcp.ts::completeMcpAuth` | 迁移 | `POST /api/integration/{id}/connect/oauth/{attemptID}/complete` | +| 32 | `mcp.ts::authenticateMcp` | 迁移 | 发起 + **轮询** `GET .../connect/oauth/{attemptID}`(仅 `mode:'auto'`) | +| 33 | `file.ts::listDirectory` | 迁移 | `GET /api/fs/list` → `sdk.file.list` | +| 34 | `file.ts::getFileContent` | 迁移 | `GET /api/fs/read/*` → `sdk.file.read`(**裸 `Uint8Array`**) | +| 35 | `file.ts::getFileStatus` | 迁移 | `GET /api/vcs/status`(V1 的 `/file/status` 已删) | +| 36 | `vcs.ts::getVcsInfo` | 迁移 | `GET /api/vcs` | +| 37 | `vcs.ts::getVcsDiff` | 迁移 | `GET /api/vcs/diff`(`mode` 枚举**变了**,见 §6.6) | +| 38 | `worktree.ts::listWorktrees` | 迁移 | `GET /api/worktree?projectID=`(**作用域从 directory 改为 projectID**) | +| 39 | `worktree.ts::createWorktree` | 迁移 | `POST /api/worktree` | +| 40 | `worktree.ts::removeWorktree` | 迁移 | `DELETE /api/worktree`(`{projectID, directory, force}` 三字段必填) | +| 41 | `worktree.ts::resetWorktree` | **3b** | V2 删除了 `POST /experimental/worktree/reset`,无替代 | +| 42 | `tool.ts::getToolIds` | **3b** | V2 删除 `/experimental/tool/ids`;本仓库**零调用点** | +| 43 | `tool.ts::getTools` | **3b** | V2 删除 `/experimental/tool`;本仓库**零调用点** | +| 44 | `lsp.ts::getLspStatus` | **3b** | V2 不再运行语言服务器,端点已删;零调用点 | +| 45 | `lsp.ts::getFormatterStatus` | **3b** | 同上;零调用点 | +| 46 | `global.ts::disposeGlobal` | **3b** | V2 删除 `POST /global/dispose`,**无等价物**;零调用点 | +| 47 | `global.ts::disposeInstance` | 迁移 | `DELETE /api/debug/location` → `sdk.debug.location.evict` | +| 48 | `config.ts::updateConfig` | **3b** | V2 的 `/api/config` **只有 GET**,无 location 级写入口 | +| 49 | `config.ts::updateGlobalConfig`(「含其它字段」分支) | **3b** | `PATCH /api/experimental/config` 的 payload **只有 `shell`** | +| 50 | `client.ts::initGitProject` | **3b** | V2 删除 `POST /project/git/init`(改为 location 首次使用时自动初始化) | +| 51 | `client.ts::updateProject` | 迁移 | `PATCH /api/project/{projectID}` → `sdk.project.update` | +| 52 | `command.ts::executeCommand` | 迁移 | `POST /api/session/{id}/command`,body `{name, text}`(V1 是 `{command, arguments}`) | + +> **说明**:表里 52 行是因为把「同属连接协议但不是 `notMigratedYet`」的 `getPtyConnectUrl` 也列进来了。 +> 真正的 `notMigratedYet` 调用点 = **51 处**(第 23 行不计入)。 + +### 1.2 处置结果统计 + +| 处置 | 数量 | 明细 | +| ------------------------------ | -----: | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **迁移到 V2 端点** | **38** | 见上表 | +| **标记 `removedInV2()` 给 3b** | **13** | share / unshare / todos / 归档 / worktree reset / tool ×2 / lsp ×2 / disposeGlobal / updateConfig / updateGlobalConfig 其它字段 / initGit | +| 合计 | **51** | ✅ 全部有明确去向 | + +### 1.3 「改名」的函数(避免后来者找不到) + +| V1 名字 | V2 新名字 | 为什么改名 | +| --------------------- | --------------------------------------- | ------------------------------------------------------------ | +| `revertMessage` | `stageRevert` | 三段式的第一段,旧名会误导「一次调用完成回退」 | +| `unrevertSession` | `clearRevert` | V2 官方术语是 clear(清暂存),且新增了 `commitRevert` | +| `getPendingQuestions` | `listPendingForms` / `listSessionForms` | Form ≠ question,且新增了会话级/位置级两个入口 | +| `replyQuestion` | `replyForm` | 载荷从 `answers: QuestionAnswer[]` 变成 `answer: FormAnswer` | +| `rejectQuestion` | `cancelForm` | V2 官方术语是 cancel | + +--- + +## 2. 三段式回退:实现说明与端点口径 + +### 2.1 V1 vs V2 语义对照(源码核实,tag `v2.0.19`) + +| 阶段 | 端点 | 源码行为(`packages/core/src/session/revert.ts`) | +| ---------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **stage** | `POST /api/session/{id}/revert/stage` | 只写 `session.revert = {messageID, snapshot, files}` 并把**文件快照**恢复到该边界(`files:false` 可只挪边界)。**不删任何消息**。返回 `Session.Revert`。 | +| **commit** | `POST /api/session/{id}/revert/commit` | 只发 `RevertEvent.Committed`;由 projector(`projector.ts:739`)**真正 `DELETE FROM session_message WHERE seq >= boundary.seq`** + 清空 revert 标记 + `InstructionState.reset`。**不可逆**。 | +| **clear** | `DELETE /api/session/{id}/revert` | 把 stage 时改动的文件快照恢复回去 + 清空 revert 标记。消息从未被删,所以清掉标记后它们**重新可见**(= V1 的 `unrevert`)。 | + +### 2.2 🔴 两条必须知道的行为(否则 UI 会错) + +1. **stage 之后消息仍然留在服务端**,`GET /api/session/{id}/message` 依然会返回它们。 + → 「回退后消息消失」**必须由前端按 `session.revert.messageID` 过滤** —— + 这正是 `messageStore` 的 `revertState` 在做的事(阶段 2a 已实现),本阶段沿用。 +2. **发新消息(`prompt`)会自动 commit**: + 源码 `packages/core/src/session/session.ts:165` 的注释原文 + _"Commit a staged revert only after preparation succeeds, before admitting new work."_ + → 所以「回退 → 改一下 → 重新发送」这条自然流**不需要前端显式 commit**。 + `session.compact` 同样会先 commit(`session.ts:252`)。 + +### 2.3 UI 映射(`useSessionManager` / `useRevertState`) + +| UI 动作 | 调用 | 说明 | +| -------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 撤销(undo) | `stageRevert(sessionId, userMessageId)` | 边界 = 被点击的用户消息 | +| 重做(redo) | 还有更早的撤销历史 → `stageRevert`(边界往前挪一条 = 恢复一条)
没有历史了 → `clearRevert` | 与 V1 的 redo 语义等价 | +| 全部重做(redo all) | `clearRevert` | 等价 V1 的 `unrevert` | +| —— | **不主动调用 `commitRevert`** | 交给服务端在 `prompt`/`compact` 时自动 commit | + +> `commitRevert()` 仍然**导出**(API 完整 + 冒烟测试用它验证「真删」语义),但 UI 不调用。 +> 理由:`commit` 不可逆,而 UI 的「撤销/重做」是可逆交互,主动 commit 会毁掉 redo 能力。 + +### 2.4 阶段 2a 遗留的「运行时会抛错」已修复 + +阶段 2a 只做了**类型适配**,`revertMessage` / `unrevertSession` 仍是 `notMigratedYet` 占位。 +本阶段: + +- `src/hooks/useSessionManager.ts`:`handleUndo` / `handleRedo` / `handleRedoAll` 改走 `stageRevert` / `clearRevertApi` + (导入时**别名**成 `clearRevertApi`,因为本文件有一个同名的本地回调 `clearRevert`,不别名会遮蔽)。 +- `src/hooks/useRevertState.ts`:整体改写到三段式。 + ⚠️ **如实说明**:该 hook 全仓库**零消费点**(真正在跑的是 `useSessionManager`), + 它只是 `src/hooks/index.ts` 的公共导出。本阶段按任务要求把它迁移到可用状态,**是否删除归 3b 判断**。 + +--- + +## 3. Form 表单:字段类型清单与渲染方案 + +### 3.1 六种字段类型(照 openapi `Form.*` + 服务端 `packages/core/src/form.ts` 核实) + +| type | 专有字段 | 渲染控件(本项目 `FormDialog.tsx`) | 提交值 | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `string` | `format?`(email/uri/date/date-time)、`minLength?`、`maxLength?`、`pattern?`、`placeholder?`、`default?`、`options?`、`custom?` | 有 `options` → **选项按钮组**(`custom:true` 时另给自由输入框);无 → 单行 ``,按 `format` 用原生 `email`/`url`/`date`/`datetime-local` | `string`(选项用 **option.value**,不是 label) | +| `number` | `minimum?`、`maximum?`、`default?`(可能是 `'Infinity'`/`'-Infinity'`/`'NaN'`) | `` | `number` | +| `integer` | 同 number | `` | `number`(整数) | +| `boolean` | `default?` | 勾选按钮 | `boolean`(**`false` 也要提交**) | +| `multiselect` | `options[]`(必填)、`minItems?`、`maxItems?`、`custom?`、`default?: string[]` | 多选按钮组(`custom:true` 时另给「回车追加」输入框) | `string[]` | +| `external` | `url`(必填) | **链接 + 「我已打开并完成」确认按钮** | **`true`**(见 §3.3) | + +所有非 external 字段还都可带:`title?` `description?` `required?` `hidden?` `when?: FormWhen[]`。 + +### 3.2 渲染方案的三个关键决策 + +1. **`when` 求值放在前端,且逐字对齐服务端 `isActive`/`matches`** + (`src/api/form.ts` 的 `resolveVisibility()`): + - 服务端在**创建期**就强制「`when` 只能引用**前面**的字段」 + (`Form field condition must reference an earlier field`)→ 前端按 fields 顺序自上而下求值即可,无循环依赖。 + - **条件引用的字段「未作答」时,`eq` 与 `neq` 都判 false**(不是「neq 取反」)。源码注释原文: + _"An unanswered referenced field makes the condition false for both ops."_ + - 多选字段参与比较时是「**任一项命中**」(`value.some(item => item === when.value)`)。 + - 比对用的是**转换后的值**(number 字段的条件值是数字、boolean 是布尔),所以 `resolveVisibility` 边遍历边转换。 +2. **`external` 字段是「永远必填的确认位」**:服务端 `validateAnswer` 里 + `if (field.type === 'external') { if (value !== true) return 'External form field must be acknowledged' }` + —— 字段里**没有** `required` 也一样。渲染器因此必须给一个显式的确认动作(不能只渲染链接)。 +3. **answer 只提交「可见」字段,且空值不提交**(`buildFormAnswer()`): + 服务端会拒「未知 key」(`Unknown form field`)与「条件不成立却带了值」(`Form field is not active`)。 + 留空的非必填字段直接**不发该 key**(服务端把「缺 key」当「未作答」,合法); + 而 `''` / `[]` 会触发字段级约束(如 `minLength`、`minItems`),所以必须丢掉。 + 例外:`boolean` 的 `false` 是合法值,必须保留。 + +### 3.3 校验(`validateForm()`)—— 与服务端 `validateAnswer`/`validateField` 对齐 + +覆盖:`external` 确认位、`required`、string 的 min/maxLength、`pattern`、`format`(email/uri/date/date-time)、 +**闭集选项**(有 `options` 且 `custom!==true` 时值必须是某个 option 的 value)、 +number/integer 的 `Number.isFinite` + 整数性 + min/max、multiselect 的 min/maxItems + 闭集。 + +刻意与服务的两处差异(都在注释里写明): + +- `pattern` 是**非法正则**时本地不阻塞(本地判不了,交给服务端报 `invalid pattern`)。 +- 非必填字段留空时**不跑** minLength/minItems(因为我们会把空值丢掉,服务端看到的是 `undefined`, + 它的字段级约束不会执行)。这**不是疏漏**,是服务端语义。 +- 额外加了一条**服务端没有**的保护:数字字段填了内容但转不出有效值(`abc` / `Infinity`)时报 + 「请输入数字」,而不是静默丢掉用户的输入。 + +### 3.4 接线(事件 → 状态 → 渲染) + +| 环节 | 文件 | 说明 | +| ---- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 事件 | `src/hooks/useGlobalEvents.ts` | `form.created` 载荷是 **`{form: FormInfo}`(包了一层)**,`form.replied`/`form.cancelled` 是**扁平**的 `{id, sessionID, …}` —— 这个不一致是 V2 自身的,别「顺手统一」 | +| 分发 | 同上 | **阶段 3a 起真正向会话消费者分发** `onFormCreated/Replied/Cancelled`(2b 只做 pending 登记,因为当时没有渲染器) | +| 状态 | `src/hooks/usePermissionHandler.ts` | `pendingQuestionRequests: ApiQuestionRequest[]` → **`pendingForms: FormInfo[]`**;新增 `handleFormReply` / `handleFormCancel` | +| 渲染 | `src/features/chat/FormDialog.tsx`(**新增**) | 六种字段类型 + `when` 联动 + external 确认位;Escape 取消 / send 键提交 | +| 挂载 | `src/features/chat/ChatPane.tsx` | 底部 `QuestionDialog` → **`FormDialog`**(与 PermissionDialog 同一套视觉与弹入动画) | + +> ⚠️ **内联通道(`InlineQuestion`)保持原样但恒空**:V2 的 `Form.Info` 只有 +> `{id, sessionID, title, fields}`,**没有 tool 关联字段**,无法按 `callID` 内嵌到工具卡片里。 +> 所以 `InlineToolRequestContext.pendingQuestions` 恒为 `[]`,`InlineQuestion` 的渲染路径保留不删,归 3b。 + +--- + +## 4. PTY 两步连接说明 + +### 4.1 协议 + +``` +① POST /api/pty/{ptyID}/connect-token → {location, data:{ticket, expires_in:60}} + ⚠️ 必须带请求头 `x-opencode-ticket: "1"`(见 §6.4) +② GET /api/pty/{ptyID}/connect?ticket=…&cursor=…&location[directory]=… + → 101 Switching Protocols +``` + +### 4.2 实现 + +| 位置 | 改动 | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `src/api/pty.ts` | 新增 `createPtyConnectTicket(ptyId, directory?, serverId?)`;`getPtyConnectUrl` 改为 `(ptyId, ticket, directory?, options?, serverId?)` | +| `src/api/ptyBridge.ts` | 拼 URL 前先 `await createPtyConnectTicket()`;ticket 走 **query**(Rust `bridge_connect` 不支持自定义 header,注释已写明不要挪到 header) | +| `src/components/Terminal.tsx` | 浏览器分支改为异步 IIFE:先取 ticket 再拼 URL;**重连天然重新申请**(指数退避逻辑未动) | + +**ticket 是一次性的**:同一 ticket 第二次连接 → **403**(真机实测)。所以 URL **不能缓存复用**。 + +--- + +## 5. 改动清单 + +### 5.1 新增文件(15 个) + +| 文件 | 行数级别 | 说明 | +| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- | +| `src/api/form.ts` | 大 | Form API + 纯函数(可见性/校验/answer 组装) | +| `src/types/api/form.ts` | 小 | Form 类型(复用 SDK 生成类型) | +| `src/features/chat/FormDialog.tsx` | 大 | **表单渲染器**(新增 UI 能力) | +| `src/api/phase3a.smoke.test.ts` | 中 | 真实服务冒烟(6 例,默认 skip) | +| `src/features/chat/FormDialog.test.tsx` | 中 | 渲染器单测(6 例) | +| `src/api/{session,permission,form,file,vcs,worktree,config,global,client,tool,lsp,command,mcp,pty}.test.ts` | —— | 本阶段新增/扩展的单测(共 14 个测试文件) | + +### 5.2 修改文件(关键项) + +| 文件 | 改动 | +| --------------------------------------------- | ----------------------------------------------------------------------------------- | +| `src/api/notMigrated.ts` | 新增 `removedInV2()` + `V2_REMOVED_PENDING_CLEANUP` 前缀;文件头写清两种去向 | +| `src/api/session.ts` | 51 处中的 12 处;三段式;`getSessionDiff` 修全量语义(见 §6.1) | +| `src/api/permission.ts` | 权限 2 处 + 新增 saved 规则管理(`listSavedPermissions` / `removeSavedPermission`) | +| `src/api/v2Convert.ts` | 新增 `toInternalRevert()`(V2 的 `files` 字段映射) | +| `src/types/api/session.ts` | `SessionRevert` 用**交叉类型**追加 V2 的 `files`(不动 v1Model) | +| `src/hooks/useGlobalEvents.ts` | `pendingQuestions` → `pendingForms`;form 事件**开始向消费者分发** | +| `src/hooks/usePermissionHandler.ts` | 表单链路(`pendingForms` / `handleFormReply` / `handleFormCancel`) | +| `src/hooks/useSessionManager.ts` | undo/redo 改三段式(导入别名避免遮蔽) | +| `src/hooks/useRevertState.ts` | 整体改写到三段式(该 hook 零消费点,如实记录) | +| `src/hooks/useChatSession.ts` | form 事件回调、session family 拉取改 `listPendingForms` | +| `src/features/chat/ChatPane.tsx` | `QuestionDialog` → `FormDialog`;内联通道 `pendingQuestions: []` | +| `src/components/SessionChangesPanel.test.tsx` | 补 `toVcsDiffMode` 替身(3 行,见 §6.6) | + +> ⚠️ **越界说明(如实)**:硬性约束写的是「允许改 `src/api/**`、`src/features/**`、`src/store/**`、`src/utils/`、测试、`docs/`」, +> 但为了让迁移**真的可用**,本阶段还动了 `src/hooks/**`(表单/回退链路所在)与 +> `src/components/{McpPanel,Terminal,WorktreePanel,SessionChangesPanel}.tsx`(消费方适配)。 +> 这些都是「调用方必须跟着改」的最小改动,没有删任何 UI。 + +--- + +## 6. 🔴 与文档预测不符之处(本节最重要) + +> 共 **16 条**。前 8 条是本阶段新发现,后 8 条是 4 个并行子任务的实测补充。 +> 已回填主文档的部分在 §6.17 列出。 + +### 6.1 🔴🔴 `GET /api/session/{id}/diff` **是「按轮次」的,不是全量** —— 阶段 1 的迁移悄悄退化了它 + +| | 内容 | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **文档原文** | §4.2:`diff \| GET /session/{id}/diff \| GET /api/session/{id}/diff \|`(暗示等价) | +| **openapi 原文** | _"Structured per-file diffs of the files **a turn** changed… `from`: User message whose turn to diff. **Defaults to the turn of the newest user message.**"_ | +| **后果** | 阶段 1 把 V1 的 `getSessionDiff`(全量)直接映成不传参数的 `session.diff()` → **它只显示最后一轮**。UI 的「会话变更」模式与 V1 行为不一致,而且**不报错**。 | +| **处理** | ✅ 本阶段修掉:先查**最早/最新一条 user 消息**(`limit=1&type=user&order=asc\|desc`,两个轻请求),把它们当 `from`/`to` 传进去覆盖整段历史;拿不到边界时退化为 V2 默认。`getLastTurnDiff` 则**不需要**任何参数(默认就是最新一轮)。 | + +### 6.2 🔴 `fork` 的请求体字段是 **`before`**,不是 `messageID`;`Session.ForkBoundary` 是只读字段 + +| | 内容 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **文档原文** | §4.2:`POST /api/session/{id}/fork \| 新增 ForkBoundary` | +| **实测** | openapi 的请求体是 `{before?: msg_id}`;`Session.ForkBoundary = {type:'before'\|'through', messageID}` 是 `Session.Info.fork.boundary` 这个**只读字段**的形状,**不是请求体**。 | +| **处理** | ✅ `forkSession(sessionId, messageId)` → `sdk.session.fork({sessionID, before: messageId})`。语义与 V1 一致(都不含该消息)。 | + +### 6.3 🔴 **V2 删除了「归档会话」能力**(文档未提) + +| | 内容 | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **实测** | `SessionUpdateInput` 只有 `{title?, metadata?, permissions?}`;v2.0.19 的 server handler / protocol / core 里 `grep archiv` **零命中**。 | +| **后果** | UI 的「归档」(`useChatSession.handleArchiveSession`)在 V2 **没有实现手段**。 | +| **处理** | ✅ `updateSession(..., {time:{archived}})` 显式抛 `removedInV2()`(不静默忽略)→ 归 3b 决定移除或改「删除」。 | + +### 6.4 🔴 PTY 连接协议的四条实测细节(openapi 看不出来) + +| # | 发现 | 证据 | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| ① | **`connect-token` 必须带请求头 `x-opencode-ticket: "1"`**(值是**固定字面量 `"1"`**,不是票据本身)。openapi 标它 optional,实际不带 → **403** `Invalid PTY connect token request` | 真机握手 | +| ② | **`pty.connect.token` 的返回值没有被 SDK 解包** —— 返回的是完整信封 `{location, data:{ticket, expires_in}}`(只有 `experimental.persistentPty.*` 等少数端点被 SDK `.then(v => v.data)` 处理) | 生成代码 + 真机 | +| ③ | **WS connect 的目录参数名是 `location[directory]`**:`?ticket=X` → 404、`?ticket=X&directory=/tmp` → **404(裸参数被静默忽略)**、`?ticket=X&location[directory]=/tmp` → 101 | 真机(pty 在 /tmp、服务端 cwd 在别处) | +| ④ | **手拼 URL 必须带 `/api` 前缀**。`getApiBaseUrl()` 返回**裸**地址(SDK 内部自己拼 `/api`)。漏了会打到 SPA 兜底路由拿到 **200 + text/html**(不是 404,极具迷惑性),WS 握手失败 | 真机 | + +> 另外实测:ticket **一次性**(同 ticket 第二次连接 → 403)、**错目录 → 404**、 +> **带 ticket 时服务端跳过 Basic 认证**(所以浏览器形态可以不带 `auth_token`/userinfo)。 + +### 6.5 🔴 `fs/list` 的目录条目 **path 带尾斜杠**(文档未提) + +实测 `GET /api/fs/list` 返回 `src/`、`.git/`、`src/components/`;`fs/find` 同样。 +必须剥掉,否则:① `name` 变空串(UI 目录名消失);② `node.path` 与 `/api/vcs/status` 的裸文件路径对不上 → +`computeDirectoryStatus` 生成的父目录键是 `src` 而不是 `src/` → **目录改动颜色整片失效**。 + +另外三条 fs 细节: + +- **`path=''` / `path='.'` / 不传 `path` 三者等价**(都返回 location 根),文档担心的「空串会报错」不成立。 +- **`fs/read` 返回裸 `Uint8Array`**(丢掉 content-type),且**不接受绝对路径**(传了返回 **500**,不在 SDK 声明的状态码里)。 + → mimeType 必须**前端按扩展名推断**(正好也避开了服务端把 `a.ts` 认成 `video/mp2t` 的错判)。 +- **`fs/list` 会列出 `.git/` 与 gitignore 命中的条目**,且**没有任何 ignored 标记** + → 内部 `ignored` 只能恒 `false`,V1 的「gitignore 文件半透明」能力**丢失**(3b 需决定是否前端过滤)。 + +### 6.6 🔴 `Vcs.Mode` 枚举与 UI **不兼容**(文档 §4.7 只写了「路径加 `/api`」) + +UI 的变更范围是 `git | branch | session | turn`,而 V2 只认 **`working | branch | committed`**。 +实测传 `mode=git` → **400 `Expected Vcs.Mode`**。 +→ 新增 `toVcsDiffMode()` 做 `git → working` 翻译(`session`/`turn` 不走 VCS 端点,不受影响)。 +**这是本阶段最容易被漏掉的破坏性变更。** + +附带:**非 git 目录的 `/api/vcs` 是 `200 + {"branch":{}}`**,不是 4xx/5xx。 +只靠 try/catch 会把「没有 VCS」当成「有 VCS 但分支为空」放过去 → 必须再判 `branch.current`。 + +### 6.7 🔴 worktree 的**作用域参数从 `directory` 改成 `projectID`**(文档 §4.7 只写「转正」) + +解析错项目时 HTTP 全 200 → 静默失效。实现里先用 `GET /api/location` 拿 `location.project.id`。 +附带行为变化(3b 需知道):V2 的 create 用 `git worktree add --detach`(**游离 HEAD,不建分支**), +remove **只做 `git worktree remove [--force]`**(不再删 `opencode/` 分支、不再 `rm -rf`)。 + +### 6.8 🔴 `disposeGlobal` 在 V2 **没有等价物**(文档 §4.1 说「可用 `DELETE /api/debug/location` 替代」) + +`debug/location` 一次只能驱逐**一个** location;`global/dispose`(释放整个进程资源)**没有替代品**。 +→ 分开处理:`disposeInstance` 迁移,`disposeGlobal` 标记 3b。 + +### 6.9 🟡 MCP 的四条 schema 变化(文档未提) + +1. `Mcp.Status` **新增 `pending`**(服务器正在启动/握手);`NeedsAuth.error` 在 V2 是**必填**。 +2. `GET /api/mcp/resource` 比 V1 多出 **`templates`**(V1 只有 resources);资源来源字段是 `server`(V1 叫 `client`)。 +3. OAuth attempt 有 **`mode: 'auto' | 'code'`** 两种,直接决定 `authenticateMcp` 能否「一步到位」: + `code` 模式下永远轮询不到 complete,必须走 `completeMcpAuth`(实现里对 code 直接抛错并带出链接)。 +4. **OAuth method 可能带必填 `form`**(如 `github-copilot` 的 `deploymentType`)→ 不带 `answer` 会被服务端拒。 + 当前实现按「MCP 自动生成的 integration 通常不需要」处理,属**潜在风险点**(已在报告与注释里标注)。 + +### 6.10 🟡 全新 location 的**第一次 `GET /api/mcp` 返回空数组**(惰性初始化的又一处) + +配置里明明有 MCP 服务器,但首次调用拿到 `data: []`,第二次(或等几秒)才有。 +MCP 服务器是随 location **异步**连接的。→ UI 首次打开 MCP 面板可能显示「尚未配置」,需要刷新。 +(与阶段 0.5 §5⑩、阶段 2b §7.14 是同一类问题。) + +### 6.11 🟡 `GET /api/session/active` 的**线缆信封是 `{data:{…}}`** + +SDK 的 `session.active()` 会**自动解包**(声明类型是裸 Record),所以 `getSessionStatus()` **是对的**。 +但**裸 fetch 必须自己取 `.data`** —— 本阶段冒烟一开始直接读顶层,于是 `waitForBusy()` 永远看不到活跃会话。 +这条值得记一笔:V2 的「是否解包」是**逐端点**由 codegen 决定的,不能凭直觉。 + +### 6.12 🟡 `revert/stage` 对 **busy 会话返回 `SessionBusyError`** + +所以「回退」必须在执行结束**之后**才允许(`stage` 不是无脑幂等的)。冒烟里因此要先等会话真正空闲。 +另:`prompt` 是**非阻塞入队**,刚发完时 `/api/session/active` 可能还是空的 → +「先等它忙起来再等它闲下来」是必须的两步(否则 `waitForIdle` 会立刻返回 true,然后撞上 `SessionBusyError`)。 + +### 6.13 🟡 Form 的三条硬性语义(文档只写了「六种字段类型 + when 条件」) + +| # | 语义 | 源码位置 | +| --- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| ① | **`external` 字段必须被确认为 `true`**(哪怕没有 `required`) | `validateAnswer`:`if (value !== true) return 'External form field must be acknowledged'` | +| ② | **`when` 只能引用「前面」的字段**(创建期强制) | `validateFields`:`Form field condition must reference an earlier field` | +| ③ | **引用字段未作答时 `eq`/`neq` 都判 false**;多选是「任一项命中」 | `matches`:`if (value === undefined) return false` | + +另:`GET /api/form`(位置级)与 `GET /api/session/{id}/form`(会话级)**只列 `state.status === 'pending'`** 的表单。 + +### 6.14 🟡 权限:`decision` 枚举**没变**,变的是「必须带 sessionID」 + +任务提醒「`decision` 的枚举值必须以 openapi 为准,别照 V1 惯性」—— +核实结果:openapi 的 `Permission.Reply` = **`"once" | "always" | "reject"`**,**与 V1 完全一致**。 +真正会踩的是:V2 的 `sessionID` 是**路径参数**(`POST /api/session/{sessionID}/permission/{requestID}/reply`), +V1 有「无 sessionID」的分支 → V2 下缺 sessionID 会**必然失败**。 +→ 实现里缺 sessionID 时**显式抛中文错误**,不发必错请求。 + +另:`GET /api/permission/request` **没有 sessionId 过滤参数**(拉全量后前端过滤); +`POST /api/session/{id}/permission`(主动创建请求)在**审批不需要时直接返回 `{effect:'allow'}` 而不产生 pending 请求** +—— 冒烟要造 pending 必须先建一个带 `permissions:[{action:'bash',resource:'*',effect:'ask'}]` 的会话。 + +### 6.15 🟡 各模块的「零调用点」核查(3b 可直接删) + +| 函数 | 调用点 | 3b 动作 | +| ------------------------------------- | -------------------------------- | -------------------------------------------------------------------- | +| `getToolIds` / `getTools` | **0** | 可删 `src/api/tool.ts` + `src/types/api/tool.ts` | +| `getLspStatus` / `getFormatterStatus` | **0** | 可删 `src/api/lsp.ts` | +| `disposeGlobal` | **0** | 可删函数 | +| `updateConfig`(API) | **0** | 可删函数(注意 `ConfigSettings.tsx` 里有个**同名局部函数**,别误删) | +| `updateProject` | 0 | 保留(已按 V2 契约实现 + 测试) | +| `getPtySession` | 0 | 保留(已迁移) | +| `removeMcpAuth` / `completeMcpAuth` | 0(由 `authenticateMcp` 间接走) | 保留 | + +### 6.16 🟡 任务书的两处计数/描述偏差 + +1. **本组实际是 15 处而不是 12 处**:worktree 4 + tool 2 + lsp 2 + global 2 + config 2 + client 2 + command 1 = **15**。 + (任务书 §9 写「`api/tool.ts` / `api/lsp.ts` / `api/global.ts` / `api/config.ts` / `api/client.ts` / `api/command.ts` 的 12 处」, + 漏算了 worktree 的 4 处。)51 处的总数不受影响(worktree 4 已在别处计入)。 +2. **任务书说「lsp.ts 2 处 → 标记给 3b,不删 UI」,但 UI 侧本来就没有 LSP 状态展示**: + 全仓库 `getLspStatus|getFormatterStatus|LSPStatus|FormatterStatus` 只有定义处,**零调用点、零 UI**。 + 设置面板里的 `lsp`/`formatters` 区块是**配置字段编辑器**,而且 **V2 的 config schema 里这两个字段仍在** + (`packages/schema/src/config.ts` 有 `ConfigLSP` / `ConfigFormatter`)→ 它们属于「配置编辑器」那条线,不是「LSP 状态展示」。 +3. **`docs/opencode-v2-migration.md` §9.3 的 `src/api/pty.ts:27 normalizePty()` 已过期** —— 该函数早已不存在。 + +### 6.17 ✅ 已回填主文档 + +| 位置 | 内容 | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| §3.3 | 新增「**「SDK 是否解包 `{data}` 信封」是逐端点决定的**」实测表 | +| §4.1 | `global/dispose` **无替代**的修正 | +| §4.2 | `update`(**V2 删除归档会话** + 返回 void)、`abort`(空闲返回 false 不是错误)、`fork`(字段是 `before`)、**`diff` 是按轮次的**(阶段 1 的退化已修)、**回退三条实测语义** | +| §4.3 | 权限(枚举没变、**sessionID 变路径参数**、列表无 sessionId 过滤)+ **Form 三条硬性语义**(external 确认位 / when 只能引用前面的字段 / 未作答两 op 皆 false) | +| §4.4 | 配置写入只能改 `shell` 的复核结论 | +| §4.5 | `fs/list`(**尾斜杠**、`.git/` 与 gitignore 无标记)、`fs/read`(**裸字节**、**不收绝对路径**)、`file/status → vcs/status` | +| §4.6 | PTY **四条连接细节**(`x-opencode-ticket:"1"`、不解包、`location[directory]`、`/api` 前缀)+ ticket 一次性 | +| §4.7 | MCP(**数组**、新增 `pending`、`NeedsAuth.error` 必填、`templates`、`mode:auto\|code`、惰性初始化)、VCS(**`Vcs.Mode` 枚举变了**、非 git 返回 200)、worktree(**作用域改 `projectID`**、reset 已标记、行为变化) | +| §5.6 | **新增章节**:回退三段式的实现口径(含三条必须知道的语义 + UI 映射表) | +| §8 | 阶段 3 **拆成 3a(已完成)/ 3b(未开始)**,逐条勾选 + 产出说明 | +| §9.3 | 标注 `normalizePty()` 那条**已过期**;`SessionChangesPanel` 的 patch 回退分支**已成死代码**;新增阶段 3a 引入的兼容物(`SessionRevert` 交叉类型) | +| §9.4 | **逐项复核原「要移除的功能」表**:`子会话列表`/`手动摘要`/`unrevert` **其实是可迁移项**(已迁移);**漏列了「归档会话」** | +| §10.3 | 新增 14 条阶段 3a 实测结论表 | + +合计:**13 处「实测修正/补充」标注 + 16 处「已迁移/已实现/已标记」标注 + 1 个新章节(§5.6)+ §8 的阶段拆分**。 + +--- + +## 7. 阶段 3b 待办交接(移除清单最终版) + +### 7.1 `removedInV2()` 标记的 13 处(API 层) + +``` +src/api/session.ts shareSession / unshareSession / getSessionTodos + + updateSession 的 time.archived 分支(归档会话) +src/api/worktree.ts resetWorktree +src/api/tool.ts getToolIds / getTools +src/api/lsp.ts getLspStatus / getFormatterStatus +src/api/global.ts disposeGlobal +src/api/config.ts updateConfig / updateGlobalConfig 的「含其它字段」分支 +src/api/client.ts initGitProject +``` + +### 7.2 对应的 UI 入口(本阶段**只标记未删**) + +| UI 入口 | 文件 | 归属 | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 分享按钮 / ShareDialog | `src/features/chat/{Header,ShareDialog}.tsx` | share | +| 归档按钮 | `useChatSession.handleArchiveSession`(侧栏) | 归档 | +| 待办展示 | `src/features/chat/input/InputFooter.tsx` | todo | +| worktree「重置」 | `src/components/WorktreePanel.tsx`(`handleReset`/`resetConfirm`/确认弹窗/按钮) | worktree reset | +| 「初始化 git」按钮 | `src/components/SessionChangesPanel.tsx` | project/git/init | +| 配置保存链路 | `src/features/settings/components/ConfigSettings.tsx` → 改「只读 + 引导编辑 `opencode.json`」 | config 写 | +| `InlineQuestion` 渲染路径 | `src/features/message/parts/ToolPartView.tsx` / `MessageRenderer.tsx` / `InlineQuestion.tsx` / `QuestionRenderer.tsx` / `QuestionDialog.tsx` | question 体系 | +| MCP `needs_client_registration` 分支 | `src/components/McpPanel.tsx` | V2 不再产生 | +| `gitignore` 灰显(`ignored`) | `src/components/FileExplorer.tsx` | V2 无此信息 | + +### 7.3 其它待办(非「移除」类) + +1. **Tauri 真机验证**:`ptyBridge.ts` 的「先取 ticket 再 `bridge_connect`」只做了编译级 + 逻辑级验证, + Rust 侧是否原样转发 query **未实测**(容器内无 Tauri 运行时)。 +2. **`@opencode/client` 的 `subscribe()` 在 `plugin-http` 下的流式** 仍未验证(阶段 2b 遗留)。 +3. **Form 的可选增强**:`metadata` 未做特殊渲染;`getFormDetail` 已可用但打开历史会话时**尚未回填已回答的表单**(只显示 pending)。 +4. **MCP 的可选增强**:`templates` 已取回但 UI 未展示;`needs_auth.error` 未展示;`mcp.remove` 未封装(本项目 V1 也没有)。 +5. **`src/hooks/useRevertState.ts` 是零消费点的公共导出** —— 建议 3b 直接删除。 +6. **`useFileExplorer` 的根目录 10s TTL 缓存**(V1 原有)会让 `softRefresh()` 在 10s 内滞后,3b 可评估。 +7. **prettier 全仓库仍有漂移**(本阶段只格式化改动过的文件,避免无关 diff)。 +8. **`v1Model.ts` 的 B 桶 104 个导出** 仍在(本阶段一个未删,按要求留给 3b 收尾)。 +9. **`docs/opencode-v2-migration.md` §4.7 建议补一行**:worktree 的作用域参数从 `directory` 改成 `projectID`(已回填)。 +10. **Rust / WSL / Docker 三形态回归**(阶段 4)。 + +--- + +## 8. 测试与冒烟结果 + +### 8.1 类型检查 + +``` +npx tsc -b --force → 0 报错(全仓库) +``` + +### 8.2 全量单测 + +``` +# 服务未启动(4097 关闭)—— 默认状态 +npx vitest run --reporter=dot +→ Test Files 112 passed | 4 skipped (116) + Tests 967 passed | 38 skipped (1005) + Duration 20.02s + +# 服务启动时(phase1Smoke 也会跑) +→ Test Files 113 passed | 3 skipped (116) + Tests 980 passed | 25 skipped (1005) +``` + +**基线对比**:阶段 2b 结束时是 **803 passed + 19 skipped**。 +本阶段新增约 **164 个通过用例**(14 个测试文件),**零失败、零回归**。 +4 个 skip 的文件是 4 个「需真实服务」的冒烟文件(默认跳过)。 + +### 8.3 真实服务冒烟(`src/api/phase3a.smoke.test.ts`) + +``` +OPENCODE_SERVER_PASSWORD=t1 opencode --log-level warn serve --hostname 127.0.0.1 --port 4097 +VITE_OPENCODE_SMOKE=1 VITE_OPENCODE_SMOKE_DIRECTORY=/tmp/opencode/p3a/ws \ + npx vitest run src/api/phase3a.smoke.test.ts +→ Test Files 1 passed (1) | Tests 6 passed (6) +``` + +| # | 用例 | 结果 | 关键断言 | +| --- | ---------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ① | 中止 | ✅ | 空闲会话 `interrupted=false`(不报错);**观察到 busy 后 `interrupted=true`**,且随后回到 idle | +| ② | 回退三段式 | ✅ | stage 后**消息仍在**且 `session.revert.messageID` 正确;clear 后标记消失且消息仍在;commit 后**边界消息真被删除** | +| ③ | 权限回复 | ✅ | `POST .../permission` 造 pending → 列表能查到(`action→permission`/`resources→patterns` 映射正确)→ `reply('reject')` → 从 pending 消失;`/api/permission/saved` 可读 | +| ④ | 表单 | ✅ | 六种字段 create → 会话级 + 位置级列表都能查到 → reply(含 `external: true`)→ `state=answered`;再建一张 → cancel → `state=cancelled` | +| ⑤ | PTY | ✅ | create(`status` 形状)→ list → **connect-token 拿到 ticket** → remove | +| ⑥ | MCP | ✅ | list(数组形状)→ resource catalog(`{resources,templates}`)→ runtime add → connect → remove | + +**数据卫生**:冒烟在专用 scratch 目录 `/tmp/opencode/p3a/ws` 建会话,`afterAll` 全部 `DELETE`。 +实测跑完后该目录会话数 = **0**;`/home/coder/project/OpenCodeUI` 下 **零** `[phase3a-smoke]` 残留。 +**测试服务已 `kill`**(端口 4097 无响应),**用户自己的 4096 服务未被触碰**(仍返回 401 = 存活)。 + +### 8.4 额外的真机验证(子任务做的,比要求更进一步) + +| 模块 | 内容 | 结果 | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| PTY | 真实 WS 握手(Tauri 形态 + 浏览器形态)、ticket 一次性(403)、错目录(404) | ✅ 101 / 403 / 404 | +| MCP | 起了假 MCP stdio 服务器,验证 `PUT /api/experimental/mcp/{server}` 的 `{config}` 包一层 + V2 字段名 + `connected/failed/disabled` 三态 | ✅ 204 + 状态正确 | +| fs/vcs | 尾斜杠、`absolute` 拼接、`.ts` mime、200KB base64、status 字段、非 git→null、三种 diff mode | ✅ 8/8 | + +--- + +## 9. 未做 / 已知缺口(如实) + +| # | 项 | 说明 | +| --- | --------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | **Tauri 真机未验证** | 容器内无 Tauri 运行时。受影响最大的是 `ptyBridge.ts`(先取 ticket 再 `bridge_connect`) | +| 2 | **WSL / Docker 未跑** | 与阶段 1/2a/2b 一致 | +| 3 | **浏览器人工回归未做** | 按前几轮的既定做法,只用单测 + API 冒烟(**表单渲染器**额外补了组件级单测覆盖六种字段类型) | +| 4 | **配置编辑器未改** | V2 的写入口只有 `shell` → 3b 改「只读 + 引导编辑文件」 | +| 5 | **打开历史会话时不回填已回答的表单** | 只显示 pending 表单(`getFormDetail` 已实现,未接线到历史渲染) | +| 6 | **Form 的 `metadata` 未特殊渲染** | 目前只渲染 `title` + `fields` | +| 7 | **`fs/list` 的 gitignore 灰显能力丢失** | V2 不返回 ignored 标记;未加前端过滤(3b 决策) | +| 8 | **base64 是全量内存操作** | V2 的 `fs/read` 必须整包读文件,超大文件(>50MB)可能卡顿;未加大小上限 | +| 9 | **`authenticateMcp` 的 `mode='code'` 只抛错** | 不自动轮询(轮询也拿不到 complete);错误信息里带出授权链接供用户改走 `completeMcpAuth` | +| 10 | **OAuth method 的必填 `form` 未处理** | 需要 `answer` 的 integration 会被服务端拒(潜在风险,已在注释标注) | +| 11 | **MCP 面板未绑定 serverId** | 签名仍是 `(name, directory?)`;attempt 缓存 Map 的 key 已预留 serverId 前缀 | +| 12 | **prettier 全仓库仍有漂移** | 本阶段只格式化改动过的文件 | +| 13 | **`getSessionDiff` 多两次轻请求** | 为了拿 from/to 边界(`limit=1`);服务端是内存查询,当前可接受 | + +--- + +## 10. 附:本阶段改动规模 + +``` +git diff --stat(含未提交的 0/1/2a/2b/3a 全部改动) + 65 files changed, 6671 insertions(+), 2650 deletions(-) ← 仅 src/api + src/types/api + src/hooks + src/features + src/components + +本阶段新增测试文件(14) + src/api/{session,permission,form,file,vcs,worktree,config,global,client,tool,lsp,command,mcp,pty}.test.ts + src/features/chat/FormDialog.test.tsx + src/api/phase3a.smoke.test.ts +``` diff --git a/docs/opencode-v2-migration-phase3b.md b/docs/opencode-v2-migration-phase3b.md new file mode 100644 index 000000000..99c4c354a --- /dev/null +++ b/docs/opencode-v2-migration-phase3b.md @@ -0,0 +1,726 @@ +# OpenCodeUI 迁移 V2 —— 阶段 3b 报告(功能裁撤 + 配置编辑器 + Rust) + +> 状态:**阶段 3b 已完成**(2026-09-30) +> 目标环境:opencode `v2.0.19`(`/home/coder/.opencode/bin/opencode`)、`@opencode/client@2.0.19` +> 验收:`tsc` **0 报错**;`prettier --check` **全仓库通过**;`eslint` **0 error**; +> 全量测试 **958 passed / 44 skipped / 0 failed**(服务未启动时); +> 真实服务冒烟 **6/6**(配置读、shell 写、静默丢弃实证、客户端防线、API 下架、数据卫生) +> 前置报告:`docs/opencode-v2-migration-phase{0,0.5,2a,2b,3a}.md` +> **计数口径**:任务书说「13 处 `removedInV2()`」→ 复核为 **13 处**(见 §2.1)。 +> `v1Model.ts` B 桶:**104 个导出 → 42 个**(见 §4)。 + +--- + +## 0. 三件必须先交代的事 + +### 0.1 硬性约束逐条对照 + +| 约束 | 实际执行 | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **允许改** `src/**`(含删 UI)、`src-tauri/**`、测试、`docs/` | ✅ 改动集中在这几处 | +| **禁止 git commit / push / reset / checkout / worktree** | ✅ 只用了 `git status` / `git diff` / `git ls-files` / `git show` / `git grep`(只读) | +| **禁止删除 `docs/` 下任何文件** | ✅ 只新增 + 追加修订(`docs/*.md` 的 6 个文件被 prettier 重排了表格对齐,**内容一字未改**,见 §6.9) | +| 测试服务用完必须关闭 | ✅ 4097 的临时服务已 `kill` 并**确认无残留进程**;**用户自己的 4096 服务未触碰**(仍 401 = 存活) | +| 不得留下测试会话 | ✅ 冒烟自建会话全部删除;sqlite 实测 `session_v2` 里 **`phase3b` 特征 = 0**、`directory like '/tmp/opencode/p3b%'` = 0 | +| 测试必须设 120–180s 超时 | ✅ 全量 `timeout 180 npx vitest run` | +| 全程简体中文注释与报告 | ✅ | +| **🔴 误改用户全局配置(已按用户许可恢复)** | ⚠️ **发生过**,见 §0.3 —— 如实交代 | + +### 0.2 本阶段**没有**做的事 + +1. **没有提交任何 git 事务**(整个迁移自阶段 0 起就未提交,本次沿用)。 +2. **没有删** `openapi_doc.json` / `openapi_formatted.json` 两个孤儿文件 —— 见 §8.2 的交待(属主文档 §8 3b 清单项,但**不在本轮任务书 A–G 范围内**,且它们是仓库根目录的 git 跟踪文件,按「删除文件需确认」的规则留待阶段 4)。 +3. **没有接入** V2 新增的 `persistent-pty` / `plugin RPC` / `/api/shell` / `/api/websearch` / `/api/rpc` / `session.inbox` / `session.instructions` / `/api/vcs/base` / `/api/vcs/branch` / `fs/write`(YAGNI,与 3a §9.2 一致)。 +4. **没有做浏览器人工回归**(沿用前几轮做法:单测 + API 冒烟 + **本轮新增的「裁撤守卫」静态断言**,见 §6.3)。 +5. **Rust 未编译验证**(容器缺 GTK 系统库,如实标注,见 §5.6)—— 但改动**几乎全是注释 + 一行字符串**。 + +### 0.3 🔴 必须交代的事故:冒烟测试写进了用户的全局配置文件 + +| 项 | 内容 | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **发生时机** | 第一次跑 `phase3b.smoke.test.ts` 的 ②③ 两项(都要真实写 `PATCH /api/experimental/config`) | +| **发生了什么** | `PATCH /api/experimental/config` 写的是「**最高优先级的全局配置文档**」,默认落在 `$XDG_CONFIG_HOME/opencode/opencode.jsonc` → 即 **`/home/coder/.config/opencode/opencode.jsonc`**(宿主机映射目录)。服务端把 `"shell": "/bin/sh"` 写进了这个文件。 | +| **怎么发现的** | 我的测试里「回读」断言一直失败(读到 `undefined`),排查时看到 `GET /api/config` 的 `Entry[].path` 指向真实用户目录,才意识到没隔离 | +| **影响** | 用户的 `shell` 从「未设置 = 自动探测 `/usr/bin/bash`」变成「固定 `/bin/sh`」(不支持 bash 语法) | +| **证据** | opencode 日志里 **235 条** `shell tool using shell` 记录**全部**是 `/usr/bin/bash`、**零** `/bin/sh`,最后一条在 2026-09-24 → 说明该键**原本不存在** | +| **处理** | ① 立即停止测试服务;② 把当时文件备份到 `/tmp/opencode/p3b/opencode.jsonc.after-smoke`;③ **向用户说明并取得明确许可**;④ 按用户选择**删除 `shell` 键**,恢复原状(仅该行差异,已 `diff` 复核);⑤ **整改测试**:把 `XDG_CONFIG_HOME` 指向 scratch 目录写成**运行前置条件**(红字),启动命令与文件头注释同步更新 | +| **教训** | 「PATCH 全局配置」这类写操作,**必须先隔离 `XDG_CONFIG_HOME`**;`afterAll` 恢复「值」兜不住「文件被写过」 | + +> 相关发现(有价值):写完 `PATCH` 后**立刻** `GET /api/config` 拿到的 `info` 是 `{}`(旧值), +> 约 1 秒后才是 `{shell: …}`;`POST /api/location/reload` 也**不能**立刻修好。 +> → 这条已写进冒烟测试(轮询)与配置编辑器(保存后重试回读),见 §3.4。 + +--- + +## 1. 任务 A:13 处 `removedInV2()` 对应的 UI 清理 + +### 1.1 UI 移除对照表(对着 3a 报告 §7.2 的 9 类) + +| # | 3a §7.2 的 UI 入口 | 本轮实际动作 | 涉及文件 | 状态 | +| --: | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| 1 | 分享按钮 / ShareDialog | 删除组件 + Header 的分享按钮 + SidebarFooter 的「分享对话」菜单项 + store 的 `shareUrl` 全链路 + 3 组 locale 键 | delete `src/features/chat/ShareDialog.tsx`;`Header.tsx`、`sidebar/SidebarFooter.tsx`、`store/messageStore*.ts`、`store/index.ts`、`hooks/useSessionManager.ts`、`locales/{zh-CN,en}/chat.json` | ✅ 全清 | +| 2 | 归档按钮(侧栏) | 删除 `handleArchiveSession` + **命令面板项** + **快捷键 `Alt+Backspace`** + 控制器动作 `archiveSession` + 2 个 locale 键 | `hooks/useChatSession.ts`、`features/chat/ChatPane.tsx`、`App.tsx`、`store/paneControllerStore.ts`、`store/keybindingStore.ts`、`features/settings/KeybindingsSection.tsx`、`features/settings/settingsSearchCatalog.ts`、`locales/{zh-CN,en}/commands.json` | ✅ 全清(**入口比任务书列的多 4 处**,见 §6.2) | +| 3 | 待办展示(InputFooter) | 删除会话级待办面板(进度环 + 任务列表 + `TodoSwapPanel`)+ `todoStore` + `api/todo.ts` + `types/api/todo.ts` + `sessionLifecycle` 的清理调用 | `features/chat/input/InputFooter.tsx`(重写)、delete `store/todoStore.ts`、`api/todo.ts`、`types/api/todo.ts`;`store/index.ts`、`types/api/index.ts`、`utils/sessionLifecycle.ts` | ✅ 全清(**`TodoRenderer` 特意保留**,见 §1.2) | +| 4 | worktree「重置」 | 删除 `handleReset` + `resetConfirm` 确认弹窗 + 列表项按钮 + `WorktreeItem.onReset` + `resetWorktree()` + `WorktreeResetInput` + 3 组 locale 键 | `components/WorktreePanel.tsx`、`api/worktree.ts`、`types/api/worktree.ts`、`types/api/index.ts`、`locales/{zh-CN,en}/components.json` | ✅ 全清 | +| 5 | 「初始化 git」按钮 | 删除 `handleInitGit` + 按钮 + `initializingGit` 状态 + `initGitProject()` + locale 键标注 | `components/SessionChangesPanel.tsx`、`api/client.ts` | ✅ 全清 | +| 6 | 配置保存链路 | 见 §3(降级为「只读 + 仅 shell 可写 + 复制 JSON」) | `features/settings/components/**`、`api/config.ts` | ✅ 全清 | +| 7 | `InlineQuestion` 旧渲染路径 | 删除 `InlineQuestion.tsx`、`QuestionDialog.tsx`、`pendingQuestions`、`findQuestionRequestForTool`、`onQuestionReply`/`onQuestionReject`、`ToolPartView`/`MessageRenderer` 的内联分支、`types/api/permission.ts` 的 4 个 Question 类型 | delete 2 文件;`InlineToolRequestContext.tsx`(重写)、`message/parts/ToolPartView.tsx`、`message/MessageRenderer.tsx`、`chat/ChatPane.tsx`、`chat/index.ts`、`types/api/permission.ts`、`types/api/index.ts`、`api/types.ts` | ⚠️ **部分清**:`QuestionRenderer.tsx` **特意保留**,见 §1.2 | +| 8 | MCP `needs_client_registration` 分支 | 删除 4 处 switch 分支 + `getErrorMessage` 分支 + `MCPStatusNeedsClientRegistration` 类型 + locale 键 | `components/McpPanel.tsx`、`types/api/mcp.ts`、`types/api/index.ts`、`locales/{zh-CN,en}/components.json` | ✅ 全清 | +| 9 | `gitignore` 灰显(`ignored`) | 删除 `FileNode.ignored` 字段(改为就地定义 V2 形状)+ FileExplorer 的 `opacity-50` 灰显 + `api/file.ts` 的 `ignored: false` | `types/api/file.ts`(重写)、`api/file.ts`、`components/FileExplorer.tsx`、`api/file.test.ts` | ✅ 全清(决策见 §1.3) | + +> **9 类全部处置完毕**:8 类「全清」,1 类(question)按 §1.2 的证据**只清了交互侧、保留只读侧**。 + +### 1.2 🔴 两处**特意保留**:`QuestionRenderer` 与 `TodoRenderer` + +3a 报告把这两个渲染器一并归入「V1 残留、归 3b 删除」。**本轮实测证明该前提不成立**, +删掉会造成**功能倒退** —— 所以保留,并在此交代依据。 + +#### ① `QuestionRenderer.tsx` —— V2 的 `question` 工具**还在** + +| 证据 | 内容 | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| v2.0.19 源码 | `packages/core/src/tool/plugin/question.ts`:`export const name = "question"`,描述与 V1 一致。它内部改调 `Form.Service.ask(...)` 弹表单,但**工具调用本身照旧落进消息历史** | +| 实测载荷形状 | `state.input` = `{questions:[{question, header, options:[{label,description}], multiple?}]}`
`state.content` = `[{type:'text', text:'User has answered your questions: "Q"="A". …'}]`(`toModelContent()` 生成)
`state.metadata` = `{answers: [["A"]], truncated: false}` | +| 本地库统计 | **54 条**这样的 `question` 工具调用,且它们所在会话**全部在 `session_v2` 表里**(即在 V2 的会话列表里可见) | +| 结论 | `QuestionRenderer` 是**活代码** → 保留(并把头部注释改写成上面这套依据,避免后来者再误删) | + +> **顺带一条对 3a 报告的事实修正**:3a §3 写「V2 的 `Form.Info` 只有 `{id, sessionID, title, fields}`,**没有 tool 关联字段**」—— +> **不准确**。v2.0.19 的 `packages/schema/src/form.ts` 里 `InfoBase` 明确含 +> `metadata: Metadata.pipe(optional)`,而 `question` 工具正是靠它把表单绑回工具调用: +> `metadata: { kind: "question", tool: { messageID, id: <工具调用 id> } }`。 +> → **「按 callID 内联渲染表单」在 V2 技术上可行**;本项目**有意不做**(YAGNI:底部 `FormDialog` 已覆盖全部待处理表单)。 +> 将来若要做,匹配键是 `form.metadata.tool.id === part.callID`。这段说明已写进 `InlineToolRequestContext.tsx` 顶部。 + +#### ② `TodoRenderer.tsx`(+ `todoUtils.ts`)—— 历史 `todowrite` 卡片仍要渲染 + +| 证据 | 内容 | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| V2 无待办能力 | `GET /session/{id}/todo` 已删;事件侧无 `todo.updated`(2b 已确认源码中不存在);V2 工具集里**没有 todo 工具**(V1 的 `todowrite` 在 `packages/core/src/database/v1-migration.bun.ts:901` 的 `REMOVED_TOOLS` 里) | +| 但历史里有 | 本地 `session_message` 里 **328 条** `todowrite` 工具调用(最后一条 2026-09-24,即 V1 时代的历史被迁移进了 `session_v2` 可见的会话) | +| 本轮处置 | **会话级待办 UI 全删**(面板 / store / API);**只读渲染器保留**(历史工具卡片) | +| 本地库旁证 | `todo` 表 287 行,**最后写入时间 2026-09-24** → V2 时代**零写入**,印证「V2 不产生待办」 | + +### 1.3 `gitignore` 灰显能力丢失的决策(任务 E-4) + +**决策:接受丢失,删掉灰显逻辑与 `ignored` 字段,不自己实现 `.gitignore` 解析(KISS)。** + +依据: + +- V2 的 `fs/list`(`packages/schema` 的 `FileSystemEntry`)只有 `{path, type}`,**没有任何 ignored 标记**; + 且实测**原样列出** gitignore 命中的条目(`.gitignore`、`secret.log`、`ignored-dir/`、`.git/` 都在列表里)。 +- 前端自己解析 `.gitignore` 的代价:需要实现完整规则集(嵌套 `.gitignore`、取反 `!`、`**` 通配、目录继承、 + `.git/info/exclude`、全局 excludesfile…),远高于「灰显」这点收益,而且**做不对反而误导用户**。 +- 落地:`FileNode` 改成**就地定义的 V2 形状**(去掉 `ignored`),`FileExplorer` 去掉 `${node.ignored ? 'opacity-50' : ''}`, + `api/file.ts` 去掉 `ignored: false`。**代码里保留了说明注释**,避免后来者以为是漏了。 + +> 同批还就地重定义了 `FileContent`(去掉 V1 的 `diff`/`patch`)与 `FileStatusItem`, +> 并删掉了只服务已移除搜索功能的 `Symbol` / `SymbolRange` / `SymbolLocation` / `TextSearchMatch` / `FindTextResponse`。 + +--- + +## 2. `removedInV2` 清零证明 + 13 处 API 函数的最终去向 + +### 2.1 清零证明(命令 + 实测输出) + +```bash +# 生产代码(排除所有 .test. 文件)—— 这是「清零」的正式口径 +$ grep -rn "removedInV2(" src/ --include='*.ts' --include='*.tsx' | grep -v '\.test\.' | wc -l +0 +$ grep -rn "notMigratedYet(" src/ --include='*.ts' --include='*.tsx' | grep -v '\.test\.' | wc -l +0 +$ ls src/api/notMigrated.ts +ls: cannot access 'src/api/notMigrated.ts': No such file or directory +``` + +⚠️ **口径说明(避免误读)**:如果**不带** `grep -v '\.test\.'`,会命中 **2 处** —— +它们全部来自**裁撤守卫测试自己的断言字符串**(`expect(findInSource('removedInV2('))`): + +``` +src/features/phase3b.removal.test.ts:110: it('`removedInV2(` 全仓库 0 命中(定义与调用都清掉)', () => { +src/features/phase3b.removal.test.ts:111: expect(findInSource('removedInV2(')).toEqual([]) +(notMigratedYet( 同理,另 2 处) +``` + +该守卫在扫描时会**主动排除测试文件自身**,所以「0 命中」的断言是真通过,不是自欺。 + +**并且做成了自动化断言**:`src/features/phase3b.removal.test.ts`(**21 个用例**) +(下面说的「零命中」都是**排除测试文件自身**后的口径) +在**全部源码(先剥注释)** 里断言 `removedInV2(` / `notMigratedYet(` / 各类被删标识符**零命中**, +并额外断言「该保留的还在」(`QuestionRenderer` / `TodoRenderer` 必须存在)。 +→ 以后有人把它们加回来,**测试会红**。 + +> ⚠️ 为什么「先剥注释」是必须的:3b 的代码里到处是「⛔ 阶段 3b 已删除 xxx」这类说明注释, +> 它们**故意提到**被删的名字(否则后来者看不懂为什么这里空了一块)。 +> 该测试还带**扫描器自检**(3 个用例),防止「断言恒真」的空跑。 + +### 2.2 13 处 `removedInV2()` 的最终去向 + +图例:**删函数** = 函数与定义整体删除;**删文件** = 文件整体删除。 + +| # | 原标记(阶段 3a) | 最终去向 | 备注 | +| --: | ---------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| 1 | `session.ts::shareSession` | **删函数** | V2 删分享端点;`session_share` 表 0 行、`session_v2.share_url` 全 NULL(实测) | +| 2 | `session.ts::unshareSession` | **删函数** | 同上 | +| 3 | `session.ts::getSessionTodos` + `ApiTodo` | **删函数** | V2 无待办端点/事件/工具 | +| 4 | `session.ts::updateSession` 的 `time.archived` 分支 | **删分支 + 删入参** | `params.time` 整个移除 → 现在传 `time` **连编译都过不了**(比运行时抛错更早拦住) | +| 5 | `worktree.ts::resetWorktree` + `WorktreeResetInput` | **删函数 + 删类型** | V2 删端点且无替代能力;UI 按钮 + 确认弹窗一并删 | +| 6 | `tool.ts::getToolIds` | **删文件**(`api/tool.ts` + `types/api/tool.ts`) | 零调用点;连带删 `types/api/index.ts` 的转发 | +| 7 | `tool.ts::getTools` | **删文件**(同上) | 同上 | +| 8 | `lsp.ts::getLspStatus` | **删文件**(`api/lsp.ts`) | 零调用点、零 UI | +| 9 | `lsp.ts::getFormatterStatus` | **删文件**(同上) | 同上 | +| 10 | `global.ts::disposeGlobal` | **删函数** | V2 无等价物(`debug/location` 只能驱逐单个 location) | +| 11 | `config.ts::updateConfig` | **删函数** | V2 的 `/api/config` 只有 GET | +| 12 | `config.ts::updateGlobalConfig` 的「含其它字段」分支 | **改为 `throw new Error(...)`** | 从「迁移占位符」变成**前端防线**:含其它字段时**前端就报错、且一个字段都不写**(③ 有冒烟实证) | +| 13 | `client.ts::initGitProject` | **删函数** | V2 改为 location 首次使用时自动初始化 git;UI 按钮一并删 | + +**连带删除的「不再被引用的函数」**(任务 A 要求): + +| 函数 | 为什么变成零引用 | +| ------------------------------- | -------------------------------------------------------------------- | +| `config.ts::getProviderConfigs` | 唯一消费方是配置编辑器的 provider 下拉;编辑器降级后那些组件整体删除 | +| `notMigrated.ts`(整个文件) | 两个占位符(`notMigratedYet` / `removedInV2`)都已退役 | + +> **API 层另外做的一次「零消费点」扫描**(26 个函数),结论是**其余 24 个都是「有意保留」**, +> 不属死代码:`getAgents` / `getDefaultModels` / `updateProject` / `getLocation` / `getPtySession` / +> `completeMcpAuth` / `removeSavedPermission` / `commitRevert` / `coalesceEvents` / `disconnectSSE` … +> —— 3a 报告 §6.15 已逐个说明「保留(API 完整性 + 有测试)」,本阶段**没有**顺手删它们(避免越界)。 + +--- + +## 3. 任务 B:配置编辑器降级(已按用户拍板落地) + +### 3.1 降级后的形态 + +设置页「配置」标签 → 一句摘要 + **「打开配置查看器」**按钮 → 弹窗(`min(97vw,880px)` × `min(90vh,820px)`): + +| 区块 | 内容 | 按钮 | +| --------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------ | +| 头部 | 标题「配置查看器」 | 关闭 | +| **说明 1**(警告底) | 为什么其它字段改不了 | — | +| **说明 2**(浅底) | 数据来源是**有损视图** | — | +| **shell 区块**(accent 边框 +「可编辑」徽章) | `` : '' + const checkbox = task + ? `` + : '' const content = tokens ? this.parser.parse(tokens) : '' return `
  • ${checkbox}${content}
  • ` } @@ -420,7 +424,9 @@ function renderFootnoteDefinitionsHtml(src: string, isReasoning: boolean): strin const id = getFootnoteId(label) const body = marked.parseInline(content, { renderer }) as string const className = isReasoning ? 'text-[length:var(--fs-sm)] text-text-400 leading-5' : 'text-text-300 leading-6' - items.push(`
  • ${escapeHtml(label)}. ${body} back
  • `) + items.push( + `
  • ${escapeHtml(label)}. ${body} back
  • `, + ) } const listClass = isReasoning @@ -547,10 +553,12 @@ function enhanceSafeHtml(template: HTMLTemplateElement) { template.content.querySelectorAll('audio, video').forEach(media => { media.removeAttribute('autoplay') }) - template.content.querySelectorAll('audio[src], video[src], source[src]').forEach(media => { - const src = media.getAttribute('src')?.trim() ?? '' - if (src && !/^(?:https?:|\/)/i.test(src)) media.removeAttribute('src') - }) + template.content + .querySelectorAll('audio[src], video[src], source[src]') + .forEach(media => { + const src = media.getAttribute('src')?.trim() ?? '' + if (src && !/^(?:https?:|\/)/i.test(src)) media.removeAttribute('src') + }) } function sanitizeHtml(html: string): string { diff --git a/src/components/markdownSegments.ts b/src/components/markdownSegments.ts index 17832fb80..cef93533c 100644 --- a/src/components/markdownSegments.ts +++ b/src/components/markdownSegments.ts @@ -63,7 +63,10 @@ export function findUnescaped(text: string, marker: string, start: number): numb } export function getFootnoteId(label: string): string { - const normalized = label.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') + const normalized = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') return normalized || 'note' } diff --git a/src/components/markdownStream.test.ts b/src/components/markdownStream.test.ts index acdd53473..57b35195b 100644 --- a/src/components/markdownStream.test.ts +++ b/src/components/markdownStream.test.ts @@ -53,7 +53,12 @@ describe('splitMarkdownStream', () => { it('does not split on blank lines inside fenced code blocks', () => { expect(splitMarkdownStream('before\n\n```ts\nconst a = 1\n\nconst b = 2\n```\n\nafter', true)).toEqual([ expect.objectContaining({ src: 'before\n\n', mode: 'full' }), - expect.objectContaining({ src: 'const a = 1\n\nconst b = 2', raw: '```ts\nconst a = 1\n\nconst b = 2\n```\n\n', mode: 'code', complete: true }), + expect.objectContaining({ + src: 'const a = 1\n\nconst b = 2', + raw: '```ts\nconst a = 1\n\nconst b = 2\n```\n\n', + mode: 'code', + complete: true, + }), expect.objectContaining({ src: 'after', mode: 'live' }), ]) }) @@ -210,9 +215,7 @@ $$`) ` - expect(splitMarkdownStream(markdown, false)).toEqual([ - expect.objectContaining({ src: markdown, mode: 'full' }), - ]) + expect(splitMarkdownStream(markdown, false)).toEqual([expect.objectContaining({ src: markdown, mode: 'full' })]) }) it('keeps a styled SVG wrapper intact and stops before following Markdown', () => { @@ -291,9 +294,7 @@ inside comment it('recognizes a bare SVG as one HTML artifact', () => { const svg = 'diagram' - expect(splitMarkdownStream(svg, false)).toEqual([ - expect.objectContaining({ src: svg, mode: 'full' }), - ]) + expect(splitMarkdownStream(svg, false)).toEqual([expect.objectContaining({ src: svg, mode: 'full' })]) }) it('keeps an HTML fence key stable when the stream closes', () => { diff --git a/src/components/markdownStream.ts b/src/components/markdownStream.ts index 82f455340..3f7b2b7b7 100644 --- a/src/components/markdownStream.ts +++ b/src/components/markdownStream.ts @@ -209,7 +209,8 @@ function updateHtmlContainerStack(raw: string, state: HtmlContainerState): numbe return null } -const HTML_ARTIFACT_ROOT_PATTERN = /^\s*(?:\s*)*<(?:address|article|aside|blockquote|center|details|dialog|div|dl|fieldset|figure|footer|form|header|html|main|nav|ol|section|svg|table|ul)\b/i +const HTML_ARTIFACT_ROOT_PATTERN = + /^\s*(?:\s*)*<(?:address|article|aside|blockquote|center|details|dialog|div|dl|fieldset|figure|footer|form|header|html|main|nav|ol|section|svg|table|ul)\b/i function mergeHtmlArtifactBlocks(blocks: MarkdownSourceBlock[]): MarkdownSourceBlock[] { const merged: MarkdownSourceBlock[] = [] @@ -376,7 +377,13 @@ export function splitMarkdownStream(markdown: string, isStreaming: boolean): Mar if (!markdown) return [{ key: 'html:0', src: '', mode: 'full' }] const { blocks, referenceDefinitions } = splitMarkdownBlocks(markdown) if (blocks.length === 1 && blocks[0]?.token?.type !== 'code' && blocks[0]?.token?.type !== 'table') { - return [{ key: 'html:0', src: appendReferenceDefinitions(blocks[0]?.raw ?? markdown, referenceDefinitions), mode: 'full' }] + return [ + { + key: 'html:0', + src: appendReferenceDefinitions(blocks[0]?.raw ?? markdown, referenceDefinitions), + mode: 'full', + }, + ] } return blocks.map(block => { if (block.token?.type === 'code') { @@ -414,7 +421,13 @@ export function splitMarkdownStream(markdown: string, isStreaming: boolean): Mar const fenceStart = getTrailingOpenFenceStart(markdown) const { blocks, referenceDefinitions } = splitMarkdownBlocks(markdown) if (blocks.length === 1 && blocks[0]?.token?.type !== 'code' && blocks[0]?.token?.type !== 'table') { - return [{ key: 'html:0', src: appendReferenceDefinitions(blocks[0]?.raw ?? markdown, referenceDefinitions), mode: 'live' }] + return [ + { + key: 'html:0', + src: appendReferenceDefinitions(blocks[0]?.raw ?? markdown, referenceDefinitions), + mode: 'live', + }, + ] } return blocks.map(block => { diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 462d775f8..20e11f7ee 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -29,7 +29,9 @@ export function ConfirmDialog({ return ( - {description &&
    {description}
    } + {description && ( +
    {description}
    + )}
    - + - +
    , ) diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx index fa32900e6..1433161d1 100644 --- a/src/components/ui/Dialog.tsx +++ b/src/components/ui/Dialog.tsx @@ -64,7 +64,10 @@ export function Dialog({ }, []) const getTopOpenDialog = useCallback( - () => Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"][data-dialog-open="true"]')).at(-1), + () => + Array.from( + document.querySelectorAll('[role="dialog"][aria-modal="true"][data-dialog-open="true"]'), + ).at(-1), [], ) @@ -355,7 +358,10 @@ export function Dialog({ {/* Header */} {(title || showCloseButton) && (
    -
    +
    {title}
    {showCloseButton && ( diff --git a/src/components/ui/MenuItem.tsx b/src/components/ui/MenuItem.tsx index 12e7d7e4e..beb819ecb 100644 --- a/src/components/ui/MenuItem.tsx +++ b/src/components/ui/MenuItem.tsx @@ -43,7 +43,9 @@ export function MenuItem({ {icon} )}
    -
    +
    {label}
    {description && ( diff --git a/src/components/ui/ModalShell.tsx b/src/components/ui/ModalShell.tsx index f8552e211..bc7120e80 100644 --- a/src/components/ui/ModalShell.tsx +++ b/src/components/ui/ModalShell.tsx @@ -25,7 +25,13 @@ interface ModalShellProps { style?: CSSProperties } -export const ModalShell = memo(function ModalShell({ isOpen, onClose, children, zIndex = 100, style }: ModalShellProps) { +export const ModalShell = memo(function ModalShell({ + isOpen, + onClose, + children, + zIndex = 100, + style, +}: ModalShellProps) { const { isVisible, shouldRender } = useModalAnimation(isOpen, onClose) if (!shouldRender) return null diff --git a/src/components/ui/ResizablePanel.tsx b/src/components/ui/ResizablePanel.tsx index 0632d087d..269561aa4 100644 --- a/src/components/ui/ResizablePanel.tsx +++ b/src/components/ui/ResizablePanel.tsx @@ -221,7 +221,16 @@ export const ResizablePanel = memo(function ResizablePanel({ transition-opacity ${ANIMATION_DURATION} ease-out ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'} `} - style={position === 'right' ? { top: 'calc(var(--safe-area-inset-top, 0px) + var(--desktop-titlebar-height, 0px))', left: 0, right: 0, bottom: 0 } : undefined} + style={ + position === 'right' + ? { + top: 'calc(var(--safe-area-inset-top, 0px) + var(--desktop-titlebar-height, 0px))', + left: 0, + right: 0, + bottom: 0, + } + : undefined + } onClick={onClose} /> )} @@ -244,7 +253,10 @@ export const ResizablePanel = memo(function ResizablePanel({
    )} -
    +
    {children}
    @@ -295,7 +307,10 @@ export const ResizablePanel = memo(function ResizablePanel({ {isResizing &&
    } -
    +
    {children}
    diff --git a/src/constants/api.ts b/src/constants/api.ts index 0c2b5d50e..463717e95 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -1,2 +1,23 @@ -/** API 基础地址 - 优先使用环境变量,其次使用同源 /api 前缀(Docker 部署),回退到本地开发地址 */ -export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:4096' +/** + * API 基础地址。 + * + * - 未设置(Tauri / 本地开发)→ `http://127.0.0.1:4096` + * - **相对路径**(Docker 构建注入 `/api`,语义是「同源反代」)→ 解析为**当前页面 origin** + * - 其它值 → 原样使用(绝对地址) + * + * 🔴 V2 修正(2026-09-30,真实 Docker 部署发现): + * V1 时代 base 用相对 `/api`、端点路径不带 `/api`,由反代**削掉** `/api` 前缀命中后端; + * V2 的端点本身带 `/api` 前缀,且 SDK 用 `new URL(baseUrl)` 解析(**必须绝对地址**)。 + * 继续把 `/api` 当 base 会: + * ① 健康检查拼成 `/api/api/info` → 401/404(实测复现,界面显示 401); + * ② SDK 直接抛 `Invalid URL`(相对地址不能作为 base)。 + * → 相对 base 一律解析为 origin;端点自带的 `/api` 前缀由请求路径提供,不再需要反代削前缀。 + */ +function resolveApiBaseUrl(): string { + const raw = import.meta.env.VITE_API_BASE_URL + if (!raw) return 'http://127.0.0.1:4096' + if (raw.startsWith('/') && typeof window !== 'undefined') return window.location.origin + return raw +} + +export const API_BASE_URL = resolveApiBaseUrl() diff --git a/src/contexts/SessionContext.shared.ts b/src/contexts/SessionContext.shared.ts index aa0028455..94daeabbc 100644 --- a/src/contexts/SessionContext.shared.ts +++ b/src/contexts/SessionContext.shared.ts @@ -1,5 +1,6 @@ import { createContext } from 'react' import type { ApiSession } from '../api' +import type { ModelRef } from '../types/message' export interface SessionContextValue { sessions: ApiSession[] @@ -10,7 +11,14 @@ export interface SessionContextValue { setSearch: (term: string) => void refresh: () => Promise loadMore: () => Promise - createSession: (title?: string) => Promise + /** + * 新建会话。 + * + * @param title 会话标题(可选) + * @param model V2 的模型是**会话级**的 —— 新建时带上界面所选模型, + * 避免会话起在服务端默认模型上(见 src/api/message.ts 的 syncSessionModel) + */ + createSession: (title?: string, model?: ModelRef) => Promise deleteSession: (id: string) => Promise } diff --git a/src/contexts/SessionContext.test.tsx b/src/contexts/SessionContext.test.tsx index a5700a204..fa908aebd 100644 --- a/src/contexts/SessionContext.test.tsx +++ b/src/contexts/SessionContext.test.tsx @@ -21,7 +21,6 @@ const { subscribeToEventsMock, clearChildrenMock, clearFollowupQueueMock, - setTodosMock, clearSessionRuntimeStateMock, sessionErrorHandlerMock, autoDetectPathStyleMock, @@ -33,7 +32,6 @@ const { subscribeToEventsMock: vi.fn(), clearChildrenMock: vi.fn(), clearFollowupQueueMock: vi.fn(), - setTodosMock: vi.fn(), clearSessionRuntimeStateMock: vi.fn(), sessionErrorHandlerMock: vi.fn(), autoDetectPathStyleMock: vi.fn(), @@ -66,11 +64,8 @@ vi.mock('../store/followupQueueStore', () => ({ }, })) -vi.mock('../store/todoStore', () => ({ - todoStore: { - setTodos: setTodosMock, - }, -})) +// 阶段 2b:V2 没有 `todo.updated` 事件,SessionContext 也不再消费 todoStore, +// 因此这里不再 mock `../store/todoStore`(没有模块会加载它)。 vi.mock('../store/serverStore', () => ({ serverStore: { @@ -111,7 +106,6 @@ describe('SessionProvider', () => { subscribeToEventsMock.mockReset() clearChildrenMock.mockReset() clearFollowupQueueMock.mockReset() - setTodosMock.mockReset() clearSessionRuntimeStateMock.mockReset() sessionErrorHandlerMock.mockReset() autoDetectPathStyleMock.mockReset() @@ -243,13 +237,77 @@ describe('SessionProvider', () => { expect(latestContext?.sessions.map(session => session.id)).toEqual(['session-1', 'session-2']) act(() => { - latestEventCallbacks.onSessionDeleted?.('session-1') + // V2 的 session.deleted 载荷是对象({ sessionID }),不是裸字符串 + latestEventCallbacks.onSessionDeleted?.({ sessionID: 'session-1' }) }) expect(clearSessionRuntimeStateMock).toHaveBeenCalledWith('session-1') expect(latestContext?.sessions.map(session => session.id)).toEqual(['session-2']) }) + it('merges a partial session patch instead of replacing the entry', async () => { + getSessionsMock.mockResolvedValue([ + { id: 'session-1', title: 'one', directory: '/workspace/demo' }, + { id: 'session-2', title: 'two', directory: '/workspace/demo' }, + ]) + + render( + + + , + ) + + await act(async () => { + vi.runAllTimers() + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + // V2 把会话元信息变更拆成了 renamed / metadata.updated / moved 等事件, + // 事件层只给出**变化的字段**(这里只有 title)。消费者必须合并进原条目, + // 否则 `{...prev, ...patch}` 会把 directory 覆盖成 undefined。 + latestEventCallbacks.onSessionUpdated?.({ id: 'session-2', title: 'renamed' }) + await Promise.resolve() + }) + + const sessions = latestContext?.sessions ?? [] + // 更新过的会话被提到列表最前 + expect(sessions.map(session => session.id)).toEqual(['session-2', 'session-1']) + expect(sessions[0]).toMatchObject({ id: 'session-2', title: 'renamed', directory: '/workspace/demo' }) + // 本地列表里有这条会话 → 不需要向服务端重查 + expect(getSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('refetches when a session patch arrives for a session missing from the local list', async () => { + getSessionsMock.mockResolvedValue([{ id: 'session-1', title: 'one', directory: '/workspace/demo' }]) + + render( + + + , + ) + + await act(async () => { + vi.runAllTimers() + await Promise.resolve() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(1) + + await act(async () => { + // 本地没有这条会话:补丁拼不出完整对象(缺 directory 等), + // 应当交给服务端重查,而不是把残缺对象插进列表 + latestEventCallbacks.onSessionUpdated?.({ id: 'session-unknown', title: 'x' }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(getSessionsMock).toHaveBeenCalledTimes(2) + expect(latestContext?.sessions.map(session => session.id)).toEqual(['session-1']) + }) + it('refetches on server endpoint changes even while the old request is in flight', async () => { const staleRequest = createDeferred>() const freshRequest = createDeferred>() diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx index 74dbdc53c..33b92cd48 100644 --- a/src/contexts/SessionContext.tsx +++ b/src/contexts/SessionContext.tsx @@ -7,7 +7,6 @@ import { type ApiSession, type SessionListParams, } from '../api' -import { todoStore } from '../store/todoStore' import { affectsBoundServer } from '../store/serverChangeScope' import { serverStore } from '../store/serverStore' import { pinnedSessionsStore } from '../store/pinnedSessionsStore' @@ -15,6 +14,19 @@ import { useDirectory } from './useDirectory' import { sessionErrorHandler, normalizeToForwardSlash, isSameDirectory, autoDetectPathStyle } from '../utils' import { clearSessionRuntimeState } from '../utils/sessionLifecycle' import { SessionContext, type SessionContextValue } from './SessionContext.shared' +import type { ModelRef } from '../types/message' + +/** + * 去掉补丁里的 `undefined` 字段 + * + * V2 的会话元信息事件是**部分字段**(`session.renamed` 只有 title、 + * `session.moved` 只有 directory …),缺省项在事件层会被填成 `undefined`。 + * 直接 `{...prev, ...patch}` 会把已有字段**覆盖成 undefined**, + * 所以合并前必须先剔除。 + */ +function stripUndefined(patch: T): Partial { + return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)) as Partial +} export function SessionProvider({ children }: { children: ReactNode }) { const { currentDirectory } = useDirectory() @@ -125,8 +137,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { // 保持 fetchSessions ref 同步(用于 SSE onReconnected 回调) fetchSessionsRef.current = fetchSessions - const matchesCurrentDirectory = useCallback((session: ApiSession) => { - return !currentDirectoryRef.current || isSameDirectory(currentDirectoryRef.current, session.directory) + const matchesCurrentDirectory = useCallback((session: { directory?: string }) => { + return !currentDirectoryRef.current || isSameDirectory(currentDirectoryRef.current, session.directory ?? '') }, []) // 监听 directory 和 search 变化 @@ -169,40 +181,41 @@ export function SessionProvider({ children }: { children: ReactNode }) { return [session, ...prev] }) }, - onSessionUpdated: session => { - if (session.parentID) return + onSessionUpdated: patch => { + if (patch.parentID) return if (searchRef.current) { - if (matchesCurrentDirectory(session)) { + if (matchesCurrentDirectory(patch)) { fetchSessionsRef.current() } else { - setSessions(prev => prev.filter(s => s.id !== session.id)) + setSessions(prev => prev.filter(s => s.id !== patch.id)) } return } setSessions(prev => { - const index = prev.findIndex(s => s.id === session.id) + const index = prev.findIndex(s => s.id === patch.id) - if (!matchesCurrentDirectory(session)) { - return index === -1 ? prev : prev.filter(s => s.id !== session.id) + // V2 的会话元信息变更只给**变化的字段**(renamed / metadata.updated / moved …), + // 所以这里必须**合并**而不是整体替换;缺失的目录表示"目录没变"。 + if (!matchesCurrentDirectory(patch) && patch.directory) { + return index === -1 ? prev : prev.filter(s => s.id !== patch.id) } + // 本地列表里没有这条会话时无法凭补丁拼出完整对象 → 交给服务端重查 if (index === -1) { - return [session, ...prev] + fetchSessionsRef.current() + return prev } - const updated = prev.filter(s => s.id !== session.id) - return [session, ...updated] + const merged = { ...prev[index], ...stripUndefined(patch) } + const updated = prev.filter(s => s.id !== patch.id) + return [merged, ...updated] }) }, - onTodoUpdated: data => { - // 更新 todoStore - todoStore.setTodos(data.sessionID, data.todos) - }, - onSessionDeleted: sessionId => { - clearSessionRuntimeState(sessionId) - setSessions(prev => prev.filter(s => s.id !== sessionId)) + onSessionDeleted: data => { + clearSessionRuntimeState(data.sessionID) + setSessions(prev => prev.filter(s => s.id !== data.sessionID)) }, onReconnected: reason => { if (reason === 'server-switch') return @@ -249,12 +262,16 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, [hasMore, sessions, fetchSessions]) const createSession = useCallback( - async (title?: string) => { + async (title?: string, model?: ModelRef) => { // 使用正斜杠格式传给后端 const targetDir = normalizeToForwardSlash(currentDirectory) || undefined const newSession = await apiCreateSession({ title, + // V2:模型是会话级的 —— 带上调用方指定的模型(通常是界面当前选择)。 + // 否则新会话起在服务端默认模型上,发送前才被 switchModel 纠正, + // 会在转录顶部留下一条多余的「切换模型」标记。 + model, directory: targetDir, }) return newSession diff --git a/src/features/attachment/AttachmentItem.tsx b/src/features/attachment/AttachmentItem.tsx index 0f8995330..a97f80ef9 100644 --- a/src/features/attachment/AttachmentItem.tsx +++ b/src/features/attachment/AttachmentItem.tsx @@ -337,7 +337,8 @@ function ActionBar({ attachment, hasContent, hasDownloadable, onOpenDetail, show if (!hasContent && !hasDownloadable) return null - const btnBase = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[length:var(--fs-xxs)] transition-colors duration-150' + const btnBase = + 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[length:var(--fs-xxs)] transition-colors duration-150' return (
    { const appended = buildChatPageViewModel( [ ...messages, - createAssistantMessage( - 'assistant-26', - [createTextPart('text-26', 'assistant-26', longText)], - 26, - 27, - ), + createAssistantMessage('assistant-26', [createTextPart('text-26', 'assistant-26', longText)], 26, 27), ], viewModel, ) @@ -401,11 +396,7 @@ describe('buildChatPageViewModel', () => { ) const first = buildChatPageViewModel([firstAssistant]) const secondAssistant = { - ...createAssistantMessage( - 'assistant-2', - [createTextPart('assistant-text-2', 'assistant-2', 'second answer')], - 3, - ), + ...createAssistantMessage('assistant-2', [createTextPart('assistant-text-2', 'assistant-2', 'second answer')], 3), isStreaming: true, } @@ -509,8 +500,7 @@ describe('getTimelineRowYClass', () => { }) describe('reuseProcessTimelineItems', () => { - const hasProcess = (message: Message) => - message.parts.some(p => p.type === 'tool' || p.type === 'reasoning') + const hasProcess = (message: Message) => message.parts.some(p => p.type === 'tool' || p.type === 'reasoning') const hasFinal = (message: Message) => message.parts.some(p => p.type === 'text') it('keeps historical timeline item identity when only the last message streams', () => { @@ -519,22 +509,12 @@ describe('reuseProcessTimelineItems', () => { ...createUserMessage('user-1', 1), parts: [createTextPart('user-text-1', 'user-1', 'prompt')], }, - createAssistantMessage( - 'assistant-1', - [createTextPart('text-1', 'assistant-1', 'old')], - 2, - 3, - ), + createAssistantMessage('assistant-1', [createTextPart('text-1', 'assistant-1', 'old')], 2, 3), { ...createUserMessage('user-2', 4), parts: [createTextPart('user-text-2', 'user-2', 'next')], }, - createAssistantMessage( - 'assistant-2', - [createTextPart('text-2', 'assistant-2', 'hello')], - 5, - undefined, - ), + createAssistantMessage('assistant-2', [createTextPart('text-2', 'assistant-2', 'hello')], 5, undefined), ] const first = buildProcessTimeline(messages, { @@ -574,12 +554,7 @@ describe('reuseProcessTimelineItems', () => { ...createUserMessage('user-1', 1), parts: [createTextPart('user-text-1', 'user-1', 'prompt')], }, - createAssistantMessage( - 'assistant-1', - [createTextPart('text-1', 'assistant-1', 'done')], - 2, - 3, - ), + createAssistantMessage('assistant-1', [createTextPart('text-1', 'assistant-1', 'done')], 2, 3), ] const first = buildProcessTimeline(messages, { turnDurationMap: new Map([['assistant-1', 500]]), @@ -598,10 +573,8 @@ describe('reuseProcessTimelineItems', () => { }) describe('buildProcessTimeline', () => { - const hasProcess = (message: Message) => - message.parts.some(p => p.type === 'tool' || p.type === 'reasoning') - const hasFinal = (message: Message) => - message.parts.some(p => p.type === 'text') + const hasProcess = (message: Message) => message.parts.some(p => p.type === 'tool' || p.type === 'reasoning') + const hasFinal = (message: Message) => message.parts.some(p => p.type === 'text') it('delays empty Working shell until entry-ready gate opens', () => { const messages = [createUserMessage('user-1', 1000)] @@ -670,11 +643,7 @@ describe('buildProcessTimeline', () => { // 第一轮仍 live,第二轮 user 已发出 → 只挂 user-1 的 Working,user-2 暂不挂空壳 const mid = createAssistantMessage('assistant-1', [createToolPart('tool-1', 'assistant-1')], 1001) mid.isStreaming = true - const messages = [ - createUserMessage('user-1', 1000), - mid, - createUserMessage('user-2', 2000), - ] + const messages = [createUserMessage('user-1', 1000), mid, createUserMessage('user-2', 2000)] const timeline = buildProcessTimeline(messages, { turnDurationMap: new Map(), sessionIsStreaming: true, @@ -699,10 +668,7 @@ describe('buildProcessTimeline', () => { it('only arms the earliest empty turn when multiple users are pending', () => { // 快速连发:两轮都还没 assistant → 只在最早 user 下挂 Working - const messages = [ - createUserMessage('user-1', 1000), - createUserMessage('user-2', 1500), - ] + const messages = [createUserMessage('user-1', 1000), createUserMessage('user-2', 1500)] const ready = new Set(['user-1', 'user-2']) const timeline = buildProcessTimeline(messages, { turnDurationMap: new Map(), @@ -731,11 +697,7 @@ describe('buildProcessTimeline', () => { 1001, ) earlierStillFlaggedLive.isStreaming = true - const laterLive = createAssistantMessage( - 'assistant-2', - [createToolPart('tool-2', 'assistant-2')], - 2001, - ) + const laterLive = createAssistantMessage('assistant-2', [createToolPart('tool-2', 'assistant-2')], 2001) laterLive.isStreaming = true const messages = [ createUserMessage('user-1', 1000), @@ -787,12 +749,7 @@ describe('buildProcessTimeline', () => { }) it('settles shell with process inside and final answer outside', () => { - const processOnly = createAssistantMessage( - 'assistant-1', - [createToolPart('tool-1', 'assistant-1')], - 1001, - 1200, - ) + const processOnly = createAssistantMessage('assistant-1', [createToolPart('tool-1', 'assistant-1')], 1001, 1200) const finalAnswer = createAssistantMessage( 'assistant-2', [ @@ -936,14 +893,12 @@ describe('buildChatPages', () => { it('counts blank lines before fenced code independently of indentation', () => { const suffix = '```ts\nconst value = 1\n```' - const withoutIndent = createAssistantMessage( - 'assistant-plain-lines', - [createTextPart('text-plain-lines', 'assistant-plain-lines', `${'\n'.repeat(100)}${suffix}`)], - ) - const withIndent = createAssistantMessage( - 'assistant-indented-lines', - [createTextPart('text-indented-lines', 'assistant-indented-lines', `${' \n'.repeat(100)}${suffix}`)], - ) + const withoutIndent = createAssistantMessage('assistant-plain-lines', [ + createTextPart('text-plain-lines', 'assistant-plain-lines', `${'\n'.repeat(100)}${suffix}`), + ]) + const withIndent = createAssistantMessage('assistant-indented-lines', [ + createTextPart('text-indented-lines', 'assistant-indented-lines', `${' \n'.repeat(100)}${suffix}`), + ]) expect(estimateMessageRenderWeight(withIndent)).toBe(estimateMessageRenderWeight(withoutIndent)) }) diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index 865b30f58..ead9ea809 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -9,7 +9,7 @@ import { memo, useRef, useEffect, useState, useCallback, useMemo, useDeferredValue, useSyncExternalStore } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { ChatArea, Header, InputBox, PermissionDialog, QuestionDialog, type ChatAreaHandle } from '.' +import { ChatArea, Header, InputBox, PermissionDialog, FormDialog, type ChatAreaHandle } from '.' import { type ModelSelectorHandle } from './ModelSelector' import { OutlineIndex } from '../../components/OutlineIndex' import { PaneHeader } from './PaneHeader' @@ -289,10 +289,10 @@ export const ChatPane = memo(function ChatPane({ effectiveDirectory, pendingPermissionRequests, - pendingQuestionRequests, + pendingForms, handlePermissionReply, - handleQuestionReply, - handleQuestionReject, + handleFormReply, + handleFormCancel, isReplying, loadMoreHistory, @@ -310,7 +310,6 @@ export const ChatPane = memo(function ChatPane({ handleForkMessage, handleNewSession, handleVisibleMessageIdsChange, - handleArchiveSession, handlePreviousSession, handleNextSession, handleCopyLastResponse, @@ -368,7 +367,7 @@ export const ChatPane = memo(function ChatPane({ `Status: ${activeServerHealth.status}`, activeServerHealth.error ? `Error: ${activeServerHealth.error}` : '', activeServerHealth.status === 'error' || activeServerHealth.status === 'offline' - ? 'Expected /global/health to return OpenCode health JSON.' + ? 'Expected /api/info to return OpenCode server info JSON.' : '', ].filter(Boolean) @@ -699,7 +698,6 @@ export const ChatPane = memo(function ChatPane({ const controllerActionsRef = useRef({ newSession: handleNewSession, - archiveSession: handleArchiveSession, previousSession: handlePreviousSession, nextSession: handleNextSession, toggleAgent: handleToggleAgentWithSync, @@ -712,7 +710,6 @@ export const ChatPane = memo(function ChatPane({ useEffect(() => { controllerActionsRef.current = { newSession: handleNewSession, - archiveSession: handleArchiveSession, previousSession: handlePreviousSession, nextSession: handleNextSession, toggleAgent: handleToggleAgentWithSync, @@ -723,7 +720,6 @@ export const ChatPane = memo(function ChatPane({ } }, [ handleNewSession, - handleArchiveSession, handlePreviousSession, handleNextSession, handleToggleAgentWithSync, @@ -736,7 +732,6 @@ export const ChatPane = memo(function ChatPane({ const stableControllerActions = useMemo( () => ({ newSession: () => controllerActionsRef.current.newSession(), - archiveSession: () => controllerActionsRef.current.archiveSession(), previousSession: () => controllerActionsRef.current.previousSession(), nextSession: () => controllerActionsRef.current.nextSession(), toggleAgent: () => controllerActionsRef.current.toggleAgent(), @@ -761,7 +756,6 @@ export const ChatPane = memo(function ChatPane({ effectiveDirectory: effectiveDirectory || '', contextLimit, newSession: stableControllerActions.newSession, - archiveSession: stableControllerActions.archiveSession, previousSession: stableControllerActions.previousSession, nextSession: stableControllerActions.nextSession, toggleAgent: stableControllerActions.toggleAgent, @@ -777,16 +771,16 @@ export const ChatPane = memo(function ChatPane({ // Dialog Collapsed State // ============================================ const [permissionCollapsed, setPermissionCollapsed] = useState(false) - const [questionCollapsed, setQuestionCollapsed] = useState(false) + const [formCollapsed, setFormCollapsed] = useState(false) const permissionRequestId = pendingPermissionRequests[0]?.id - const questionRequestId = pendingQuestionRequests[0]?.id + const formRequestId = pendingForms[0]?.id useEffect(() => { if (permissionRequestId) setPermissionCollapsed(false) }, [permissionRequestId]) useEffect(() => { - if (questionRequestId) setQuestionCollapsed(false) - }, [questionRequestId]) + if (formRequestId) setFormCollapsed(false) + }, [formRequestId]) const { inlineToolRequests, outlineCurrentHighlight } = useTheme() @@ -795,25 +789,13 @@ export const ChatPane = memo(function ChatPane({ // 子 session 请求匹配必须用 pane 绑定的服务器,而不是全局活动服务器(多服务器 / WSL 下两者不同) serverId: paneServerId, pendingPermissions: pendingPermissionRequests, - pendingQuestions: pendingQuestionRequests, onPermissionReply: (requestId, reply) => { const request = pendingPermissionRequests.find(r => r.id === requestId) return handlePermissionReply(requestId, reply, effectiveDirectory, request?.sessionID) }, - onQuestionReply: (requestId, answers) => handleQuestionReply(requestId, answers, effectiveDirectory), - onQuestionReject: requestId => handleQuestionReject(requestId, effectiveDirectory), isReplying, }), - [ - paneServerId, - pendingPermissionRequests, - pendingQuestionRequests, - handlePermissionReply, - handleQuestionReply, - handleQuestionReject, - isReplying, - effectiveDirectory, - ], + [paneServerId, pendingPermissionRequests, handlePermissionReply, isReplying, effectiveDirectory], ) const revertedMessage = inputRestoreContent @@ -976,14 +958,11 @@ export const ChatPane = memo(function ChatPane({ : undefined } collapsedQuestion={ - !inlineToolRequests && - pendingPermissionRequests.length === 0 && - pendingQuestionRequests.length > 0 && - questionCollapsed + !inlineToolRequests && pendingPermissionRequests.length === 0 && pendingForms.length > 0 && formCollapsed ? { - label: t('chat:questionDialog.title'), - queueLength: pendingQuestionRequests.length, - onExpand: () => setQuestionCollapsed(false), + label: t('chat:formDialog.title'), + queueLength: pendingForms.length, + onExpand: () => setFormCollapsed(false), } : undefined } @@ -1009,15 +988,17 @@ export const ChatPane = memo(function ChatPane({ /> )} - {!inlineToolRequests && pendingPermissionRequests.length === 0 && pendingQuestionRequests.length > 0 && ( - handleQuestionReply(pendingQuestionRequests[0].id, answers, effectiveDirectory)} - onReject={() => handleQuestionReject(pendingQuestionRequests[0].id, effectiveDirectory)} - queueLength={pendingQuestionRequests.length} + {!inlineToolRequests && pendingPermissionRequests.length === 0 && pendingForms.length > 0 && ( + + handleFormReply(pendingForms[0].id, answer, pendingForms[0].sessionID, effectiveDirectory) + } + onCancel={() => handleFormCancel(pendingForms[0].id, pendingForms[0].sessionID, effectiveDirectory)} + queueLength={pendingForms.length} isReplying={isReplying} - collapsed={questionCollapsed} - onCollapsedChange={setQuestionCollapsed} + collapsed={formCollapsed} + onCollapsedChange={setFormCollapsed} /> )}
    diff --git a/src/features/chat/FolderProjectDropOverlay.tsx b/src/features/chat/FolderProjectDropOverlay.tsx index 100e6a02e..bbd726f97 100644 --- a/src/features/chat/FolderProjectDropOverlay.tsx +++ b/src/features/chat/FolderProjectDropOverlay.tsx @@ -4,11 +4,7 @@ import { FolderIcon } from '../../components/Icons' import { PANE_CENTER_STYLE } from './PaneDropOverlay' /** 只高亮 pane 正中心(与 session drop center 同几何) */ -export const FolderProjectDropOverlay = memo(function FolderProjectDropOverlay({ - active, -}: { - active: boolean -}) { +export const FolderProjectDropOverlay = memo(function FolderProjectDropOverlay({ active }: { active: boolean }) { const { t } = useTranslation('chat') if (!active) return null diff --git a/src/features/chat/FormDialog.test.tsx b/src/features/chat/FormDialog.test.tsx new file mode 100644 index 000000000..4c0786713 --- /dev/null +++ b/src/features/chat/FormDialog.test.tsx @@ -0,0 +1,160 @@ +// ============================================ +// FormDialog 渲染器单测(六种字段类型 + when 联动 + external 确认位) +// ============================================ +// +// 这是「表单渲染」这一新增 UI 能力的**组件级**验证,与 +// - `src/api/form.test.ts`(纯函数:可见性 / 校验 / answer 组装) +// - `src/api/phase3a.smoke.test.ts`(真实服务:create → reply → detail) +// 一起构成完整覆盖。 +// +// 重点验证四件容易错的事: +// 1. 六种字段类型都能渲染出对应控件 +// 2. `when` 条件满足/不满足时字段出现/消失(联动) +// 3. `external` 必须点「我已打开并完成」才可提交(服务端强制 `true`) +// 4. 提交的值形态正确(数字是 number、多选是 string[]、boolean 的 false 也提交) + +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FormDialog } from './FormDialog' +import type { FormField, FormInfo } from '../../api/form' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('../../hooks', () => ({ + usePresence: () => ({ shouldRender: true, ref: { current: null } }), +})) + +vi.mock('./chatViewport', () => ({ + useChatViewport: () => ({ presentation: { isCompact: false } }), +})) + +vi.mock('../../store/keybindingStore', () => ({ + keybindingStore: { getKey: () => undefined }, + matchesKeybinding: () => false, +})) + +function makeForm(fields: FormField[]): FormInfo { + return { id: 'frm_test', sessionID: 'ses_1', title: '测试表单', fields } +} + +describe('FormDialog 渲染器', () => { + it('六种字段类型都能渲染出对应控件', () => { + const fields: FormField[] = [ + { key: 's', type: 'string', title: '字符串', placeholder: '写点什么' }, + { key: 'n', type: 'number', title: '数字' }, + { key: 'i', type: 'integer', title: '整数' }, + { key: 'b', type: 'boolean', title: '布尔' }, + { key: 'm', type: 'multiselect', title: '多选', options: [{ value: 'a', label: '选项A' }] }, + { key: 'x', type: 'external', url: 'https://example.com/auth', title: '外部授权' }, + ] + + render() + + // string → text input(用字段自己的 placeholder) + expect(screen.getByPlaceholderText('写点什么')).toBeTruthy() + // number / integer → 两个 number input + expect(document.querySelectorAll('input[type="number"]').length).toBe(2) + // boolean → 一个可点的按钮(用 title 作为文案) + expect(screen.getByText('布尔')).toBeTruthy() + // multiselect → 选项按钮 + expect(screen.getByText('选项A')).toBeTruthy() + // external → 链接 + 确认按钮 + expect(document.querySelector('a[href="https://example.com/auth"]')).toBeTruthy() + expect(screen.getByText('formDialog.acknowledge')).toBeTruthy() + }) + + it('string 带 options 时渲染选项按钮组;custom:true 时另给自由输入', () => { + const closed: FormField[] = [ + { key: 's', type: 'string', title: '闭集', options: [{ value: 'v1', label: '标签一' }] }, + ] + const { unmount } = render() + expect(screen.getByText('标签一')).toBeTruthy() + // 闭集**不给**自由输入框(服务端只接受 option 的 value) + expect(screen.queryByPlaceholderText('questionDialog.typeYourAnswer')).toBeNull() + unmount() + + const open: FormField[] = [ + { key: 's', type: 'string', title: '开集', custom: true, options: [{ value: 'v1', label: '标签一' }] }, + ] + render() + expect(screen.getByPlaceholderText('questionDialog.typeYourAnswer')).toBeTruthy() + }) + + it('`when` 条件联动:被依赖字段变化时字段出现 / 消失', () => { + const fields: FormField[] = [ + { key: 'needReason', type: 'boolean', title: '需要原因' }, + { key: 'reason', type: 'string', title: '原因', when: [{ key: 'needReason', op: 'eq', value: true }] }, + ] + + render() + + // 初始 needReason=false → reason 不渲染 + expect(screen.queryByText('原因')).toBeNull() + + // 打开开关 → reason 出现 + act(() => { + fireEvent.click(screen.getByText('需要原因')) + }) + expect(screen.getByText('原因')).toBeTruthy() + }) + + it('🔴 external 未确认时不能提交(服务端强制 answer=true)', () => { + const onSubmit = vi.fn() + const fields: FormField[] = [{ key: 'x', type: 'external', url: 'https://example.com/auth' }] + + render() + + // 未确认 → 点提交不触发 onSubmit,而是显示错误 + act(() => { + fireEvent.click(screen.getByText('common:submit')) + }) + expect(onSubmit).not.toHaveBeenCalled() + expect(screen.getByText('请先打开链接并确认完成')).toBeTruthy() + + // 确认后可提交,且 answer 里是 `true` + act(() => { + fireEvent.click(screen.getByText('formDialog.acknowledge')) + }) + act(() => { + fireEvent.click(screen.getByText('common:submit')) + }) + expect(onSubmit).toHaveBeenCalledWith({ x: true }) + }) + + it('提交的值形态正确:数字转 number、多选是 string[]、boolean 的 false 也提交', () => { + const onSubmit = vi.fn() + const fields: FormField[] = [ + { key: 'n', type: 'number' }, + { key: 'm', type: 'multiselect', options: [{ value: 'a', label: '选项A' }] }, + { key: 'b', type: 'boolean', title: '开关' }, + ] + + render() + + act(() => { + fireEvent.change(document.querySelector('input[type="number"]')!, { target: { value: '1.5' } }) + }) + act(() => { + fireEvent.click(screen.getByText('选项A')) + }) + act(() => { + fireEvent.click(screen.getByText('common:submit')) + }) + + expect(onSubmit).toHaveBeenCalledWith({ n: 1.5, m: ['a'], b: false }) + }) + + it('Escape 触发取消', () => { + const onCancel = vi.fn() + render() + + act(() => { + fireEvent.keyDown(screen.getByText('common:submit').closest('div')!.parentElement!, { key: 'Escape' }) + }) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/src/features/chat/FormDialog.tsx b/src/features/chat/FormDialog.tsx new file mode 100644 index 000000000..c21083c21 --- /dev/null +++ b/src/features/chat/FormDialog.tsx @@ -0,0 +1,600 @@ +/** + * FormDialog —— OpenCode V2 的「表单」渲染器(取代 V1 的 QuestionDialog) + * + * ── 为什么是新组件而不是改造 QuestionDialog ────────────────────────────── + * V1 的 question 是「一组单选题」,V2 的 Form 是「一张带类型/校验/条件显示的表单」: + * + * V1 `QuestionRequest` = `{ questions: [{question, header, options[], multiple, custom?}] }` + * V2 `Form.Info` = `{ title, fields: FormField[] }` + * + * 六种字段类型(string / number / integer / boolean / multiselect / external)、 + * `when` 条件显示、`required`/`pattern`/`minLength`… 校验,都是 V1 没有的, + * 所以按迁移文档 §4.3 的说法,这是**新增 UI 能力**。 + * + * ── 渲染方案(按 openapi + v2.0.19 服务端 `packages/core/src/form.ts` 核实)── + * + * | 字段类型 | 控件 | 提交值 | + * |---|---|---| + * | `string`(有 `options`) | 选项按钮组(`custom:true` 时另给自由输入) | `string`(option 的 **value**) | + * | `string`(无 `options`) | 单行输入(按 `format` 用原生 email/url/date/datetime-local) | `string` | + * | `number` | `` | `number` | + * | `integer` | `` | `number`(整数) | + * | `boolean` | 勾选按钮 | `boolean`(**false 也要提交**) | + * | `multiselect` | 多选按钮组(`custom:true` 时另给追加输入) | `string[]` | + * | `external` | 链接 + **确认勾选** | `true`(见下) | + * + * 🔴 **`external` 字段必须被确认为 `true`**:服务端 `validateAnswer` 里 + * `if (field.type === 'external') { if (value !== true) return 'External form field must be acknowledged' }` + * —— 它不是「可选的展示项」,而是**永远必填的确认位**(字段里没有 `required` 也一样)。 + * 所以这里渲染成「打开链接」+「我已确认」两段式。 + * + * 🔴 **`when` 条件由前端求值**(服务端不校验显示条件,但会**拒绝**「条件不成立却带了值」的字段)。 + * 求值逻辑全部在 `src/api/form.ts` 的 `resolveVisibility()`,与服务的 `isActive` 逐字对齐。 + * + * ── 交互约定(对齐现有 QuestionDialog,用户无感切换)────────────────────── + * - Escape → 取消表单(`DELETE .../form/{formID}`) + * - send 键位 → 提交(仅在校验通过时) + * - 底部动作条 → 「提交」+「跳过」 + * - 与 PermissionDialog / QuestionDialog 共用同一套视觉与弹入动画 + */ + +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CheckIcon, ReturnIcon, ChevronDownIcon, ExternalLinkIcon, QuestionIcon } from '../../components/Icons' +import { keybindingStore, matchesKeybinding } from '../../store/keybindingStore' +import { usePresence } from '../../hooks' +import { useChatViewport } from './chatViewport' +import { + buildFormAnswer, + coerceFieldValue, + resolveVisibility, + validateForm, + type FormAnswer, + type FormDraft, + type FormField, + type FormFieldOfType, + type FormInfo, +} from '../../api/form' + +interface FormDialogProps { + form: FormInfo + /** 提交:已组装好的答案映射(字段 key → FormValue) */ + onSubmit: (answer: FormAnswer) => void + /** 取消(等价于 V1 的「跳过」) */ + onCancel: () => void + queueLength?: number + isReplying?: boolean + collapsed?: boolean + onCollapsedChange?: (collapsed: boolean) => void +} + +/** 界面草稿的初始值:尽量用字段的 `default`,否则给空 */ +function initialDraft(fields: FormField[]): FormDraft { + const draft: FormDraft = {} + for (const field of fields) { + switch (field.type) { + case 'string': + draft[field.key] = field.default ?? '' + break + case 'number': + case 'integer': + draft[field.key] = field.default === undefined ? '' : String(field.default) + break + case 'boolean': + draft[field.key] = field.default ?? false + break + case 'multiselect': + draft[field.key] = field.default ? [...field.default] : [] + break + case 'external': + // external 的「值」是确认位:默认未确认 + draft[field.key] = false + break + } + } + return draft +} + +export const FormDialog = memo(function FormDialog({ + form, + onSubmit, + onCancel, + queueLength = 1, + isReplying = false, + collapsed = false, + onCollapsedChange, +}: FormDialogProps) { + const { t } = useTranslation(['chat', 'common']) + const { presentation } = useChatViewport() + const isCompact = presentation.isCompact + + const [draft, setDraft] = useState(() => initialDraft(form.fields)) + const [showErrors, setShowErrors] = useState(false) + + const fields = form.fields + + // 可见性(`when` 求值)。草稿变化 → 可见性跟着变(联动显示) + const visible = useMemo(() => resolveVisibility(fields, draft), [fields, draft]) + + // 校验(语义与服务端 validateAnswer/validateField 对齐,见 src/api/form.ts) + const validation = useMemo(() => validateForm(fields, draft), [fields, draft]) + const canSubmit = validation.ok + + const setValue = useCallback((key: string, value: string | boolean | string[]) => { + setDraft(prev => ({ ...prev, [key]: value })) + }, []) + + const handleSubmit = useCallback(() => { + if (!canSubmit) { + // 第一次提交失败 → 把错误提示显示出来(此前不显示,避免刚打开就一片红) + setShowErrors(true) + return + } + onSubmit(buildFormAnswer(fields, draft)) + }, [canSubmit, fields, draft, onSubmit]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onCancel() + return + } + const sendKey = keybindingStore.getKey('sendMessage') + if (sendKey && matchesKeybinding(e.nativeEvent, sendKey)) { + e.preventDefault() + if (!isReplying) handleSubmit() + } + }, + [onCancel, isReplying, handleSubmit], + ) + + const { shouldRender, ref: animRef } = usePresence(!collapsed, { + from: { opacity: 0, transform: 'translateY(16px)' }, + to: { opacity: 1, transform: 'translateY(0px)' }, + duration: 0.2, + }) + + if (!shouldRender) return null + + return ( +
    +
    +
    +
    + {/* Header */} +
    +
    +
    + +
    +

    + {form.title || t('formDialog.title')} +

    + {queueLength > 1 && ( + + {t('questionDialog.moreCount', { count: queueLength - 1 })} + + )} +
    + +
    + +
    + + {/* Fields */} +
    + {fields.map((field, idx) => ( + setValue(field.key, value)} + /> + ))} +
    + + {/* Actions */} +
    + + + +
    +
    +
    +
    +
    + ) +}) + +// ============================================ +// 单个字段 +// ============================================ + +interface FormFieldViewProps { + field: FormField + value: string | boolean | string[] | undefined + visible: boolean + error?: string + onChange: (value: string | boolean | string[]) => void +} + +function FormFieldView({ field, value, visible, error, onChange }: FormFieldViewProps) { + const { t } = useTranslation('chat') + + // `when` 条件不满足 → 不渲染(服务端不校验显示条件,纯前端职责) + if (!visible) return null + + // `hidden` 字段也不渲染(它的值通常由其它机制填充) + if (field.type !== 'external' && field.hidden) return null + + return ( +
    + + {renderControl(field, value, onChange, t)} + {error &&

    {error}

    } +
    + ) +} + +function FieldLabel({ title, description, required }: { title?: string; description?: string; required?: boolean }) { + if (!title && !description && !required) return null + return ( +
    +
    + {title && {title}} + {required && *} +
    + {description &&

    {description}

    } +
    + ) +} + +/** 输入控件(按字段类型分支) */ +function renderControl( + field: FormField, + value: string | boolean | string[] | undefined, + onChange: (value: string | boolean | string[]) => void, + t: (key: string) => string, +) { + switch (field.type) { + case 'string': + return + case 'number': + case 'integer': + return + case 'boolean': + return + case 'multiselect': + return + case 'external': + return + default: + return null + } +} + +// ---- string ---- +function StringControl({ + field, + value, + onChange, + t, +}: { + field: FormFieldOfType<'string'> + value: string + onChange: (v: string) => void + t: (key: string) => string +}) { + const hasOptions = (field.options?.length ?? 0) > 0 + // ⚠️ 闭集选项:有 options 且 `custom !== true` 时,服务端**只接受 option 的 value** + // (`validateField`:`if (field.options && !field.custom && !field.options.some(...)) → Invalid option`) + // → 这时不能给自由输入框,否则用户填什么都被拒。 + const allowCustom = field.custom === true + const isCustomValue = hasOptions && value !== '' && !field.options!.some(o => o.value === value) + const [customMode, setCustomMode] = useState(isCustomValue) + + if (hasOptions) { + return ( +
    +
    + {field.options!.map(option => { + const selected = value === option.value && !customMode + return ( + + ) + })} +
    + {allowCustom && ( + setCustomMode(true)} + onChange={e => { + setCustomMode(true) + onChange(e.target.value) + }} + className="w-full px-2.5 py-1.5 rounded-md border border-border-200/60 bg-transparent text-[length:var(--fs-base)] text-text-100 placeholder:text-text-500 focus:outline-none focus:border-text-400" + /> + )} +
    + ) + } + + return ( + onChange(e.target.value)} + className="w-full px-2.5 py-1.5 rounded-md border border-border-200/60 bg-transparent text-[length:var(--fs-base)] text-text-100 placeholder:text-text-500 focus:outline-none focus:border-text-400" + /> + ) +} + +/** `format` → 原生 input type(浏览器自带的校验与日期选择器比手写更可靠) */ +function inputTypeFor(format?: 'email' | 'uri' | 'date' | 'date-time'): string { + switch (format) { + case 'email': + return 'email' + case 'uri': + return 'url' + case 'date': + return 'date' + case 'date-time': + return 'datetime-local' + default: + return 'text' + } +} + +// ---- number / integer ---- +function NumberControl({ + field, + value, + onChange, +}: { + field: FormFieldOfType<'number'> | FormFieldOfType<'integer'> + value: string + onChange: (v: string) => void +}) { + const step = field.type === 'integer' ? 1 : 'any' + // 数值特殊值(Infinity 等)不能塞进 的 min/max,忽略之 + const min = typeof field.minimum === 'number' ? field.minimum : undefined + const max = typeof field.maximum === 'number' ? field.maximum : undefined + return ( + onChange(e.target.value)} + className="w-full px-2.5 py-1.5 rounded-md border border-border-200/60 bg-transparent text-[length:var(--fs-base)] text-text-100 placeholder:text-text-500 focus:outline-none focus:border-text-400" + /> + ) +} + +// ---- boolean ---- +function BooleanControl({ + field, + value, + onChange, +}: { + field: FormFieldOfType<'boolean'> + value: boolean + onChange: (v: boolean) => void +}) { + return ( + + ) +} + +// ---- multiselect ---- +function MultiselectControl({ + field, + value, + onChange, + t, +}: { + field: FormFieldOfType<'multiselect'> + value: string[] + onChange: (v: string[]) => void + t: (key: string) => string +}) { + // 同 string:`custom !== true` 时服务端只接受 options 里的值 + const allowCustom = field.custom === true + const knownValues = new Set(field.options.map(o => o.value)) + const customValues = value.filter(v => !knownValues.has(v)) + const [customDraft, setCustomDraft] = useState('') + + const toggle = (optionValue: string) => { + onChange(value.includes(optionValue) ? value.filter(v => v !== optionValue) : [...value, optionValue]) + } + + return ( +
    +
    + {field.options.map(option => { + const selected = value.includes(option.value) + return ( + + ) + })} +
    + + {/* 已填的自定义值(可删) */} + {customValues.length > 0 && ( +
    + {customValues.map(v => ( + + ))} +
    + )} + + {allowCustom && ( + setCustomDraft(e.target.value)} + onKeyDown={e => { + if (e.key !== 'Enter') return + e.preventDefault() + const trimmed = customDraft.trim() + if (!trimmed || value.includes(trimmed)) return + onChange([...value, trimmed]) + setCustomDraft('') + }} + className="w-full px-2.5 py-1.5 rounded-md border border-border-200/60 bg-transparent text-[length:var(--fs-base)] text-text-100 placeholder:text-text-500 focus:outline-none focus:border-text-400" + /> + )} +
    + ) +} + +// ---- external ---- +/** + * `external` 字段:链接 + 确认位 + * + * 🔴 服务端强制 `answer[key] === true`(`External form field must be acknowledged`), + * 所以这里必须给一个显式的确认动作,不能只渲染链接。 + */ +function ExternalControl({ + field, + acknowledged, + onChange, + t, +}: { + field: FormFieldOfType<'external'> + acknowledged: boolean + onChange: (v: boolean) => void + t: (key: string) => string +}) { + return ( +
    + + + {t('formDialog.openExternal')} + + +
    + ) +} + +/** 供测试引用:把草稿里某个字段的值转成提交值(等价 `buildFormAnswer` 的单项行为) */ +export { coerceFieldValue } diff --git a/src/features/chat/Header.tsx b/src/features/chat/Header.tsx index fc56dfa42..3ad9cc36a 100644 --- a/src/features/chat/Header.tsx +++ b/src/features/chat/Header.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next' import { PanelRightIcon, PanelBottomIcon, - ChevronDownIcon, SidebarIcon, SplitHorizontalIcon, MaximizeIcon, @@ -11,7 +10,6 @@ import { } from '../../components/Icons' import { IconButton } from '../../components/ui' import { ModelSelector, type ModelSelectorHandle } from './ModelSelector' -import { ShareDialog } from './ShareDialog' import { messageStore, useHeaderSessionMeta } from '../../store' import { useLayoutStore, layoutStore } from '../../store/layoutStore' import { useSessionContext } from '../../contexts/useSessionContext' @@ -44,9 +42,7 @@ interface SessionTitleControlProps { setIsEditingTitle: (value: boolean) => void handleRename: () => void handleStartEdit: () => void - onShare: () => void clickToRenameTitle: string - shareTitle: string } function SessionTitleControl({ @@ -59,9 +55,7 @@ function SessionTitleControl({ setIsEditingTitle, handleRename, handleStartEdit, - onShare, clickToRenameTitle, - shareTitle, }: SessionTitleControlProps) { const inputClass = compact ? 'px-2 py-1.5 text-[length:var(--fs-base)] font-medium text-text-100 bg-transparent border-none outline-none w-[160px] h-full' @@ -69,12 +63,6 @@ function SessionTitleControl({ const buttonClass = compact ? 'px-2 py-1.5 text-[length:var(--fs-base)] font-medium text-text-200 hover:text-text-100 transition-colors truncate max-w-[200px] cursor-text select-none' : 'px-3 py-1.5 text-[length:var(--fs-base)] font-medium text-text-200 hover:text-text-100 transition-colors truncate max-w-[300px] cursor-text select-none text-center' - const dividerClass = compact - ? 'w-[1.5px] h-3 bg-border-200/50 mx-0.5 shrink-0' - : 'w-[1.5px] h-3 bg-border-200/50 mx-0.5 shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(any-pointer:coarse)]:opacity-100 transition-opacity' - const shareButtonClass = compact - ? 'p-1 text-text-400 hover:text-text-100 transition-colors rounded-md hover:bg-bg-300/50 shrink-0' - : 'p-1 text-text-400 hover:text-text-100 transition-colors rounded-md hover:bg-bg-300/50 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(any-pointer:coarse)]:opacity-100 shrink-0' return (
    ) : ( - - )} - - {!isEditingTitle && ( - <> -
    - - + )}
    ) @@ -130,7 +109,6 @@ export function Header({ const { currentDirectory } = useDirectory() const { presentation, interaction } = useChatViewport() - const [shareDialogOpen, setShareDialogOpen] = useState(false) const [isEditingTitle, setIsEditingTitle] = useState(false) const [editTitle, setEditTitle] = useState('') const titleInputRef = useRef(null) @@ -189,9 +167,7 @@ export function Header({ setIsEditingTitle={setIsEditingTitle} handleRename={handleRename} handleStartEdit={handleStartEdit} - onShare={() => setShareDialogOpen(true)} clickToRenameTitle={t('header.clickToRename')} - shareTitle={t('header.shareSession')} /> ) @@ -269,9 +245,10 @@ export function Header({
    - setShareDialogOpen(false)} /> - -
    +
    ) } diff --git a/src/features/chat/InlineQuestion.tsx b/src/features/chat/InlineQuestion.tsx deleted file mode 100644 index 65fb84814..000000000 --- a/src/features/chat/InlineQuestion.tsx +++ /dev/null @@ -1,313 +0,0 @@ -/** - * InlineQuestion — 融入信息流的提问交互 - * - * 紧凑的 inline 卡片,选项清晰,自定义输入居中对齐。 - */ - -import { memo, useState, useCallback, useRef, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { CheckIcon } from '../../components/Icons' -import type { ApiQuestionRequest, ApiQuestionInfo, QuestionAnswer } from '../../api' -import { keybindingStore, matchesKeybinding } from '../../store/keybindingStore' - -interface InlineQuestionProps { - request: ApiQuestionRequest - onReply: (requestId: string, answers: QuestionAnswer[]) => void - onReject: (requestId: string) => void - isReplying: boolean -} - -export const InlineQuestion = memo(function InlineQuestion({ - request, - onReply, - onReject, - isReplying, -}: InlineQuestionProps) { - const { t } = useTranslation(['chat', 'common']) - - const [answers, setAnswers] = useState>>(() => { - const map = new Map>() - request.questions.forEach((_, idx) => map.set(idx, new Set())) - return map - }) - - const [customEnabled, setCustomEnabled] = useState>(() => { - const map = new Map() - request.questions.forEach((_, idx) => map.set(idx, false)) - return map - }) - - const [customValues, setCustomValues] = useState>(() => { - const map = new Map() - request.questions.forEach((_, idx) => map.set(idx, '')) - return map - }) - - const selectOption = useCallback((qIdx: number, label: string) => { - setAnswers(prev => { - const m = new Map(prev) - m.set(qIdx, new Set([label])) - return m - }) - setCustomEnabled(prev => { - const m = new Map(prev) - m.set(qIdx, false) - return m - }) - }, []) - - const selectCustom = useCallback((qIdx: number) => { - setAnswers(prev => { - const m = new Map(prev) - m.set(qIdx, new Set()) - return m - }) - setCustomEnabled(prev => { - const m = new Map(prev) - m.set(qIdx, true) - return m - }) - }, []) - - const toggleOption = useCallback((qIdx: number, label: string) => { - setAnswers(prev => { - const m = new Map(prev) - const s = new Set(prev.get(qIdx) || []) - if (s.has(label)) s.delete(label) - else s.add(label) - m.set(qIdx, s) - return m - }) - }, []) - - const toggleCustom = useCallback((qIdx: number) => { - setCustomEnabled(prev => { - const m = new Map(prev) - m.set(qIdx, !prev.get(qIdx)) - return m - }) - }, []) - - const updateCustomValue = useCallback((qIdx: number, value: string) => { - setCustomValues(prev => { - const m = new Map(prev) - m.set(qIdx, value) - return m - }) - }, []) - - const handleSubmit = useCallback(() => { - const result: QuestionAnswer[] = request.questions.map((q, idx) => { - const selected = Array.from(answers.get(idx) || []) - const isCustom = customEnabled.get(idx) - const customValue = customValues.get(idx)?.trim() - if (q.multiple) { - return isCustom && customValue && q.custom !== false ? [...selected, customValue] : selected - } - return isCustom && customValue ? [customValue] : selected - }) - onReply(request.id, result) - }, [request, answers, customEnabled, customValues, onReply]) - - const canSubmit = request.questions.every((_q, idx) => { - const selected = answers.get(idx) || new Set() - const isCustom = customEnabled.get(idx) - const customValue = customValues.get(idx)?.trim() - return selected.size > 0 || (isCustom && !!customValue) - }) - - // 键盘快捷键:和主输入框一致的 send keybinding 提交,Escape 跳过 - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onReject(request.id) - return - } - const sendKey = keybindingStore.getKey('sendMessage') - if (sendKey && matchesKeybinding(e.nativeEvent, sendKey)) { - e.preventDefault() - if (canSubmit && !isReplying) { - handleSubmit() - } - } - }, - [onReject, request.id, canSubmit, isReplying, handleSubmit], - ) - - return ( -
    - {/* 问题列表 */} -
    - {request.questions.map((question, qIdx) => ( - selectOption(qIdx, label)} - onSelectCustom={() => selectCustom(qIdx)} - onToggleOption={label => toggleOption(qIdx, label)} - onToggleCustom={() => toggleCustom(qIdx)} - onCustomValueChange={value => updateCustomValue(qIdx, value)} - /> - ))} -
    - - {/* 操作栏 */} -
    - - -
    -
    - ) -}) - -// ============================================ -// InlineQuestionItem -// ============================================ - -interface InlineQuestionItemProps { - question: ApiQuestionInfo - selected: Set - isCustomEnabled: boolean - customValue: string - onSelectOption: (label: string) => void - onSelectCustom: () => void - onToggleOption: (label: string) => void - onToggleCustom: () => void - onCustomValueChange: (value: string) => void -} - -function InlineQuestionItem({ - question, - selected, - isCustomEnabled, - customValue, - onSelectOption, - onSelectCustom, - onToggleOption, - onToggleCustom, - onCustomValueChange, -}: InlineQuestionItemProps) { - const { t } = useTranslation('chat') - const isMultiple = question.multiple || false - const allowCustom = question.custom !== false - const textareaRef = useRef(null) - - useEffect(() => { - if (isCustomEnabled && textareaRef.current) { - textareaRef.current.focus() - } - }, [isCustomEnabled]) - - const adjustHeight = useCallback(() => { - const el = textareaRef.current - if (el) { - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 100)}px` - } - }, []) - - return ( -
    - {/* 问题文字 */} -
    - {question.header && ( -
    {question.header}
    - )} -
    {question.question}
    -
    - - {/* 选项 — 紧凑按钮组;勾选框用首行高度容器对齐,不随换行拉伸 */} -
    - {question.options.map((option, idx) => { - const isSelected = selected.has(option.label) - return ( - - ) - })} -
    - - {/* 自定义输入 — 与选项同高同边距,勾选框对齐首行 */} - {allowCustom && ( -
    { - if (!isCustomEnabled) { - if (isMultiple) onToggleCustom() - else onSelectCustom() - } - }} - className={`flex min-h-7 items-start gap-1.5 rounded-md border px-2.5 py-1 transition-colors ${ - isCustomEnabled ? 'border-text-100 bg-bg-300/20' : 'border-border-200/60 hover:border-text-400' - }`} - > - {isMultiple && ( - - - {isCustomEnabled && } - - - )} -