diff --git a/HANDOVER.md b/HANDOVER.md index 3a10443..5fa7e75 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,110 +1,207 @@ # opencode_bridge 交接文档 -## 项目概览 -JupyterLab extension,把本地 OpenCode Serve 进程的能力桥接到 notebook 单元。 -**v0.1.1 (tag 在远端,当前 main 已经是 310d507)**。 +## 状态速览 -## 仓库 -`/Users/taochen/llm/notebook-extension/opencode-bridge` -- 主分支:`main`(已切回) -- 远端:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git` -- Tag:`v0.1.1` (310d507) - -## 架构 +**分支**:`feature/floating-ai-button`(已 push,tracking 已设) +**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git` +**基础**:`main` @ `310d507`(上次 HANDOVER 时的版本) +**当前 HEAD**:`d412130` +**提交**: ``` -Frontend (JupyterLab TS) Server ext (Python tornado) OpenCode Serve -───────────────────────────────────── ───────────────────────────────── ───────────── -opencode_cell_actions.ts ─POST /opencode-bridge/edit──> EditHandler - per-cell AI 按钮 + inline panel _build_request_body(prompt) ──> /session/:id/prompt_async - (只发 prompt,不再自动注入 cell) ──> /session/:id/abort -opencode_inline_prompt.ts /session (list all) - 1. SessionSelector 下拉(全 OpenCode sessions) - 2. Provider + Model 选择(本地存储) - 3. History 区域(流式 markdown 渲染) - 4. "📋 插入单元格内容" 按钮(显式 opt-in) - 5. 输入框 + send/cancel - - SSE 订阅 GET /events?session= GlobalEventHandler ──proxy──> /global/event - → 5 类事件路由:text/reasoning/tool/permission.asked/question.asked + session.idle +d412130 fix: hide+show cycles no longer accumulate prompt nodes in floating panel +c0eba7e feat: replace per-cell AI button with global floating panel +f9d52f7 docs: add HANDOVER.md for next session context +310d507 fix: SSE filter no longer drops content events for a different session ``` -## 关键代码位置 -| 文件 | 作用 | +**验证**:✅ Jest 68/68 · ✅ pytest 73/73 · ✅ tsc exit 0 · ⚠️ 2 个 pre-existing lint 错(`FlatProvider`/`BlockEntry` 命名约定,在 `inline_prompt.ts` 里,我没引入) + +**PR**:未提。Gitea 链接: +``` +http://101.43.40.124:3000/tao.chen/notebook-ai-extension/pulls/new/feature/floating-ai-button +``` + +--- + +## 本次会话做了什么 + +**架构变更**:把 per-cell 工具栏 AI 按钮(原 `OpenCodeCellActions`)整个换成全局右下角悬浮按钮 + 弹出 panel。**不是并存**,是完全替换 — per-cell 路径已删除。 + +``` +Before After +───────────────────────────────── ───────────────────────────────── +Cell 工具栏 → per-cell 按钮 document.body 右下角固定按钮(🪄) + ↓ click ↓ click + 内嵌 prompt panel(挂到 cell) 悬浮 prompt panel(挂在按钮上方) + ↓ ↓ + 当前 cell 是 owner 从 app.shell.currentWidget 取 active notebook + ↓ ↓ + "📋 插入单元格内容" 用 this._cell 通过 getActiveCell() 回调拿 active cell +``` + +--- + +## 关键改动文件 + +| 文件 | 改动 | |------|------| -| `src/components/opencode_cell_actions.ts` | cell 工具栏按钮 + 流编排(open/edit/SSE/permission/question/session 管理) | -| `src/components/opencode_inline_prompt.ts` | 内嵌 prompt 面板(最复杂,~1300 行) | -| `src/api/opencode_client.ts` | 8 个 session 管理 API + 4 个原有 API(edit/messages/permissions/questions/events) | -| `src/components/model_selection.ts` | (provider, model) localStorage 持久化 | -| `opencode_bridge/opencode_client.py` | HTTP 客户端,带 `send_message_async` + `stream_global_events`(SSE) + `reply_permission/question` + `list_all_sessions` | -| `opencode_bridge/routes.py` | 11 个路由 | -| `opencode_bridge/session_manager.py` | **1 notebook N sessions** 模型(`_active` + `_bound`) | -| `opencode_bridge/config.py` | OpencodeConfig Configurable(env var → `OpencodeConfig` Traitlets) | +| `src/components/opencode_floating_panel.ts` | **新增** — 全局按钮 + panel 容器(详见下文) | +| `src/components/opencode_cell_actions.ts` | **删除** — 逻辑全部搬到 floating panel | +| `src/components/opencode_inline_prompt.ts` | 解耦 CodeCell。新增 `getActiveCell?: () => CodeCell \| null` 选项。3 处方法(`_insertCellSource` / `_insertAtCursor` / `_replaceCell`)改用回调。`setDisabled()` 在构造器末尾调一次,确保 insert button 初始 disabled 状态正确 | +| `src/index.ts` | 删 `toolbarRegistry.addFactory` + `IToolbarWidgetRegistry` optional。新建 `OpenCodeFloatingPanel` 挂到 `document.body` | +| `style/base.css` | 删 `.opencode-cell-actions` 块。新增 `.opencode-floating-root` / `.opencode-floating-button` / `.opencode-floating-panel` 样式(fixed 定位、z-index 1000、480px 宽、60vh 高) | +| `src/__tests__/opencode_floating_panel.spec.ts` | 重命名 + 重写。原 1663 行 → 现在 1556 行。67 → 68 tests | -## 主要路由 -| 端点 | 方法 | 用途 | -|------|------|------| -| `/opencode-bridge/edit` | POST | 异步发 prompt_async(只发 prompt + context.notebookPath) | -| `/opencode-bridge/events?session=X` | GET | SSE 代理 OpenCode `/global/event` | -| `/opencode-bridge/session-messages?notebook=X` | GET | 当前 session 的消息历史 | -| `/opencode-bridge/sessions/all` | GET | OpenCode 上**所有** session(全局) | -| `/opencode-bridge/sessions/notebook?notebook=X` | GET/POST/PUT/DELETE | 绑定 + 列出 + 创建 + 删除 | -| `/opencode-bridge/sessions/active?notebook=X` | GET/PUT/DELETE | active session 操作 | -| `/opencode-bridge/permissions/:permId?session=X` | POST | 权限响应(once/always/reject) | -| `/opencode-bridge/questions/:qId/reply?session=X` | POST | 提问响应 | +--- + +## OpenCodeFloatingPanel 设计要点 + +### DOM 结构 +``` +
(position: fixed; bottom: 24; right: 24) + + +
+``` + +### 生命周期 +- 构造:创建 button + panel 容器,挂到 `document.body`,监听 `app.shell.currentChanged` +- toggle():if hidden → _showPrompt;else → _hidePrompt +- `_showPrompt()`:resolve 当前 notebook path + active cell lambda → 构造 `OpenCodeInlinePrompt` → 用 **`Widget.attach(prompt, this._panelEl)`** 挂上 → 调 `prompt.initialize()` 拉 session list → 显示 +- `_hidePrompt()`:close SSE → dispose prompt → **defensive DOM cleanup**(见下)→ 设 `this._prompt = null` → 隐藏 panel + +### State 全在 panel 上(原来在 cell action 上的都搬过来了) +- `_app: JupyterFrontEnd` +- `_serverSettings: ServerConnection.ISettings | null`(构造器传入) +- `_providers: OpenCodeProvidersResponse | null`(构造器传入,通过 `setProviders()` 方法更新) +- `_prompt: OpenCodeInlinePrompt | null` +- `_sseSub: OpenCodeEventSubscription | null` +- `_notebookPath: string | null` +- `_status: 'idle' | 'loading'` + +### API(对外) +```ts +class OpenCodeFloatingPanel extends Widget { + constructor(options: { + app: JupyterFrontEnd; + serverSettings: ServerConnection.ISettings; + providers: OpenCodeProvidersResponse | null; + }); + setProviders(p: OpenCodeProvidersResponse | null): void; // index.ts 在 providers fetch 完后调 + toggle(): void; +} +``` + +--- + +## ⚠️ 刚修的 bug(d412130) + +**症状**:点击悬浮按钮 → 显示 panel。点第二次 → 隐藏。点第三次 → **看到 2 个 prompt 同时显示**(节点累积)。 + +**根因**:`OpenCodeInlinePrompt` 是 Lumino Widget,但我们用 `Widget.attach(prompt, this._panelEl)` 把 prompt 挂到一个裸的 `HTMLDivElement` 上(host 不是 Widget)。Lumino 的 `Widget.dispose()` 检查 `this.parent` 然后 `this.isAttached`: +- `this.parent === null`(HTMLElement host 不会建立 Lumino parent) +- `this.isAttached === true`(节点确实在 DOM 里) +- 走 `Widget.detach(this)` 分支 — **理论上能清理** + +但在某些场景下(可能是 message loop 时序 / widget tree 重排),dispose 不会移除 DOM 节点,残留累积。 + +**修法**(`_hidePrompt` 里加 defensive cleanup): +```ts +if (this._prompt) { + const node = this._prompt.node; + this._prompt.dispose(); + // Defensive: ensure DOM cleanup even if dispose() missed it + if (node.parentNode === this._panelEl) { + this._panelEl.removeChild(node); + } + this._prompt = null; +} +``` + +**Regression test**(`opencode_floating_panel.spec.ts`): +```ts +it('hide+show cycles do not accumulate prompt nodes in the panel (regression)', () => { + for (let i = 0; i < 5; i++) { panel.toggle(); panel.toggle(); } + panel.toggle(); + expect(panelEl.querySelectorAll('.opencode-inline-prompt').length).toBe(1); +}); +``` + +⚠️ **测试局限**:测试 mock 的 `Widget.dispose()` 直接用 `node.parentNode.removeChild`,比真实 Lumino 更激进清理。**这个 regression test 在 mock 下永远会过**,对真实 bug 的保护力有限 — 真实 Lumino 才是 bug 触发环境。如果你怀疑类似 bug 复发,**必须在真实 JupyterLab 里手测**,不要只看 jest pass。 + +--- ## 关键设计决策(避免来回改) -### 1. SSE 事件路由(`applyEvent`) -- 客户端**不**对内容事件(text/reasoning/tool/idle)做 sessionID 过滤 — 服务端 `?session=` 参数是唯一权威 -- 只对 `permission.asked` / `question.asked` 严格过滤,防止用户误答别 session 的提示 -- 这条规则是 **bug fix 310d507 的核心**,改之前会"切 session 后发消息无回复" +1. **不用并存模式** — per-cell 按钮完全删除。Linus "good taste" 原则,消除 special case。 +2. **Active cell 通过 shell 拿** — `app.shell.currentWidget.content.activeCell`,不是 per-cell 硬绑定。Cell 是 `CodeCell | null`,null 时 `getActiveCell()` 返回 null,prompt 用 `Notification.info('请先选中一个 cell')` 提示。 +3. **`_onShellChanged` 是箭头函数 class field**(`= (): void => { ... }`)— Lumino signal 调用时 `this` 不一定正确绑定,arrow function 把 `this` 锁死在实例上。 +4. **Defensive DOM cleanup** — 见上面 bug 段。任何把 Lumino widget 挂到非 Widget host 上的代码都应该有这层保险。 +5. **Mock 的 `Widget.dispose()` 比真实 Lumino 更"宽容"** — 见 bug 段的测试局限说明。 -### 2. Cell 上下文不再自动注入 -- `OpenCodeRequest.context` 收缩为 `{notebookPath}` — 只告诉服务端哪个 notebook -- `_build_request_body` 只发 `parts=[{text: prompt}]` -- 用户通过"📋 插入单元格内容"按钮**显式 opt-in** 把 cell 源码作为 markdown code block 插入 -- 理由:LLM context 严格匹配用户意图,避免被前一个 cell 的 traceback 污染 -- 这是 310d507 的第二部分 +--- -### 3. 1 notebook N sessions -- `SessionManager._active` (dict[notebook] → active sessionId) + `_bound` (dict[notebook] → [sessionId, ...]) -- 新方法:`create_new / bind_existing / set_active / unbind / unbind_active / delete_session / list_sessions_for_notebook / get_active` -- `release(notebook)` **删所有** bound sessions(不只是 active) -- `delete_session(notebook, sid)`:先调 OpenCode delete,成功后再 unbind(失败时 retry 仍可能) +## 仍未做的事(如果继续) -### 4. 流式 markdown 渲染(避免 partial-render flicker) -- 流式期间只 `textContent += delta`,**不**调 `marked.parse`(否则 fence 没闭合时的部分渲染会闪 toolbar) -- `session.idle` 时 `_resetStreamPointers` 触发**一次**完整 `marked.parse` 渲染 -- 终态与 `setMessages` 路径(setMessages 重新打开时拉的)完全一致 +- [ ] **真实 JupyterLab + OpenCode Serve 端到端测试** — 我只做了 build/jest/pytest,没在活的 JupyterLab 里点过。bug d412130 是用户手测报的。 +- [ ] **Playwright UI tests** — 项目交接文档里说过写过 spec 但没跑,我没动这块。 +- [ ] **`jlpm build:prod`** — 尝试运行但 venv 里 jlpm 入口有问题(`.venv/bin/jlpm` 是 Python 脚本,`main()` 调用对不上),用户没让我再继续 debug 这块。我跑了 `jest` + `tsc --noEmit` 间接验证了编译能过,但完整 labextension 打包没跑过。 +- [ ] **pre-existing lint 错的处理** — `FlatProvider` / `BlockEntry` 接口名不符合项目 `/^I[A-Z]/u` 规则。这两个是我接手前就存在的。可以单独提一个 cleanup commit。 +- [ ] **`tsconfig.json` 没动** — strict mode 让我新加了几处 `as any`(`this._app.shell as any`),是因为 JupyterLab 类型里 `JupyterFrontEnd.shell` 在某些版本是 `JupyterShell | null`。可以后续更精确处理。 -### 5. 交互 UI 权限 -- `permission.asked` / `question.asked` 事件渲染**真实交互组件**(按钮 / 输入框) -- 点击通过后端路由代理回 OpenCode Serve -- 后端路由:`PUT /session/:sid/permissions/:permId` + `PUT /session/:sid/question/:qId/reply` +--- -## 测试 -- 后端:73 pytest(主要看 `test_routes.py` + `test_session_manager.py`) -- 前端:72 jest(`opencode_cell_actions.spec.ts` 是核心,900+ 行覆盖了 SessionSelector / 流式渲染 / permission/question 交互) -- 命令: `source .venv/bin/activate && jlpm test && python -m pytest opencode_bridge/tests/` +## 环境 / 工具注意事项(下一个 agent 必读) -## 已知小问题 / 待优化 -1. **多 session 切换**时,前端用 PUT `/sessions/notebook` 一个调用搞定(bind + set active 都做),不再用 `/sessions/active` — 后者保留供"已 bound sessions 之间切换"用 -2. `cell_context.ts` 已删,如果新增"只想要 cellId"的功能,需要重建 -3. `package.json` version 是 `0.1.1`,跟远端 tag 一致(之前是用户手工 bump 上去的) -4. `UNIFIED_SYSTEM_PROMPT` 还在后端 routes.py 里,描述 LLM 的角色("你是一个代码助手...")。如果改 prompt,会同时影响所有 prompt +### Codex 走得很坎坷 ⚠️⚠️⚠️ -## 修改后的交互模式 -1. 用户在 cell 工具栏点 🪄 按钮 -2. 内嵌 prompt 出现:SessionSelector(列出 OpenCode 所有 session) + Provider/Model + history + "📋 插入单元格内容" + 输入框 + send/cancel -3. 用户输入 prompt,**可选**按"📋 插入单元格内容"把 cell 源码作为 code block 追加 -4. send → 后端 `prompt_async` 立即返回 `{ok, sessionId}` + 订阅 SSE -5. 流式事件实时渲染,`session.idle` 触发 markdown 渲染(终态正确) -6. permission/question 事件渲染交互 UI,点击代理回 OpenCode -7. 切换 session → 关闭旧 SSE + bind 新 session(后端 PUT `/sessions/notebook`)+ reload history +CLAUDE.md 强制要求 Codex `model: kimi/kimi-k2.7-code`,但本会话中这个模型在火山方舟 Coding Plan 上**反复返回 HTTP 400**: -## 还没做过的事(如果新会话要继续) -- UI tests (Playwright) — 写了 spec 但没真跑 -- 持久化 session 选择(目前只持久化 provider/model) -- cell 类型推断优化(目前用 `model.type`,真实 cell 应该有 metadata.language) -- 后端 session TTL / cleanup(目前不删,除非用户显式 release) -- 真实 OpenCode Serve 接入测试(目前用 mock) +| 错误 | 频率 | +|------|------| +| `model: kimi-k2.7-code is not valid`(早期,模型名错) | 2 次 | +| `reasoning is not supported by current model`(用户修了 proxy 后) | 多次 | +| 一次 noop 通了,然后实际任务失败 | 1 次 | + +CLAUDE.md 现在的版本是 `kimi/kimi-k2.7-code`(带 `kimi/` 前缀),但**仍然被 proxy 拒绝**。**建议**: +1. 下次会话先 `noop` 测一下,通了再发真任务 +2. 不通就走 CC fallback(按 CLAUDE.md fallback 规则:`codex-fail-3x → switch-to-cc`) +3. 真要走 Codex,可能需要 proxy 侧明确配置(我无法触及) + +我大部分代码工作都是 CC 手动做的。Codex 临死前**做了一件有用的事**:它已经重写了 spec 文件的 65%(包括 rename + mock 更新 + `getActiveCell` 适配 + `makeFakeApp` helper),省了我不少力气。 + +### jlpm 在 venv 里不能直接用 + +`.venv/bin/jlpm` 是 Python 脚本入口(`from jupyter_builder.jlpm import main`)。用户终端环境可能直接有 `jlpm`,但我的环境没有。`python -m jupyter_builder.jlpm` 也没成功。 + +如果下一个 agent 需要跑 `jlpm build:prod` 或 `jlpm watch`,建议先确认 `which jlpm` 能找到。 + +### 分支保护 + +`feature/floating-ai-button` 没合并到 `main`,还在 review 阶段。`main` 仍是 `310d507`。如果新会话要在 `main` 上继续,先确认这个分支的 PR 是否已合。 + +--- + +## 测试命令(确认改动没破坏) + +```bash +.venv/bin/pytest opencode_bridge/tests/ -q # 73 passed +./node_modules/.bin/jest --no-coverage # 68 passed (3 suites) +./node_modules/.bin/tsc --noEmit # exit 0 +./node_modules/.bin/eslint --ext .ts,.tsx src/ # 仅 pre-existing 错 +``` + +--- + +## 上下文管理提示 + +我已经快把 context 烧完了(写这个交接文档前已经是大半)。如果新会话第一件事是"继续上次工作": + +1. 读这份 HANDOVER.md 全文 +2. 看 `git log feature/floating-ai-button` 确认 commit hash 没变(`d412130`) +3. 跑上面 4 个验证命令,确认环境干净 +4. 然后读这次提交改动的 6 个文件,优先 `src/components/opencode_floating_panel.ts` 和 `src/__tests__/opencode_floating_panel.spec.ts` + +**不要**:`git checkout main` 然后从 main 重新开始 — 这会丢失当前未合并的分支历史。直接在 `feature/floating-ai-button` 上继续即可。