Files
notebook-ai-extension/design.md
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00

343 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 📑 1. 系统总体架构
采用 **前端 UI 注入 + 本地 Server Extension 透传** 的轻量双层架构:
```text
┌────────────────────────────────────────────────────────────────────────┐
│ JupyterLab / Notebook v7 (前端 TS) │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Cell Component │ │
│ │ [CodeEditor] ─── (挂载) ───► [Cell Action Toolbar] │ │
│ │ │ (点击 🪄 智能编辑) │
│ │ ▼ │
│ │ [Inline Prompt Box] │ │
│ └───────────────────────────────────┬────────────────────────────┘ │
│ │ │
│ Context Provider 收集 │
│ (Target Cell, Error, Neighbors) │
│ │ │
└───────────────────────────────────────┼────────────────────────────────┘
│ REST / SSE Stream
┌────────────────────────────────────────────────────────────────────────┐
│ Jupyter Server Extension (后端 Python) │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ OpenCode Bridge Handler │ │
│ │ - 接收 Context & Prompt │ │
│ │ - 拼装标准 OpenCode API Payload │ │
│ │ - SSE 流式透传代理 (Stream Proxy) │ │
│ └───────────────────────────────────┬────────────────────────────┘ │
└───────────────────────────────────────┼────────────────────────────────┘
│ Local Socket / HTTP
┌────────────────────────────────────────────────────────────────────────┐
│ OpenCode Serve 进程 │
└────────────────────────────────────────────────────────────────────────┘
```
---
## 🎨 2. 交互与UI流程设计
1. **原生 Cell Toolbar 单按钮(2026-07-23 v2 修正)**:不再自绘悬浮 toolbar,也合并了 v1 的三个 mode(优化/排错/编辑)为单个 `🪄 AI 智能编辑` 图标按钮。通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。**前端不做任何语义处理** —— 不再发 `mode` 字段,后端用统一的系统提示词 + 用户的自然语言指令 + 上下文(含 traceback)由 LLM 自己判断是优化/排错/编辑。
2. **Inline Prompt 输入框(v3-final**:点击 `🪄 AI 智能编辑` 按钮后,`OpenCodeInlinePrompt` widget 追加到当前 `CodeCell` 的 DOM 末尾,呈现内嵌输入面板:
* 顶部一行 `模型: <select>` —— 来自启动时 `GET /opencode-bridge/providers` 缓存的 `(provider, model)` 列表,**默认选中第一项**(v3-final 不再有任何 settings-based 默认),用户每次请求前可改。providers 拉取失败时该下拉框不渲染。
* 多行 `textarea`placeholder "向 AI 描述你想要的修改…",无默认值)。
* `🚀 发送` + `✕ 取消` 两个按钮。
提交时前端仅收集 cell `source` / `outputs` / `error` 与用户在 textarea 里写的指令,POST `/opencode-bridge/edit`body 为 `{prompt, context, providerId?, modelId?}`**无 `mode`**)。`providerId`/`modelId` 仅在下拉框有选中项时发出;下拉框不存在时省略,OpenCode Serve 用其默认。**成功** → 前端调用 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + `Notification.info`**失败** → `Notification.error`,输入框保留(可改文案重发)。Session 由服务端 `SessionManager` 保证 1 notebook 1 sid(前端不感知)。
3. **流式替换/Diff 预览**
* 点击发送后,支持**打字机实时替换**或在 Cell 下方出现 `[ Accept ]` / `[ Reject ]` 临时 Diff 比对面板。
---
## 🔌 3. 接口契约设计 (API Contract)
### 接口:`POST /opencode-bridge/edit`v4 — 异步 + SSE
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
```json
{
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
"context": {
"notebookPath": "analysis/demo.ipynb",
"cellId": "cell-3",
"language": "python",
"cellIndex": 3,
"totalCells": 5,
"source": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
"previousCode": "import pandas as pd",
"error": null
},
"providerId": "anthropic",
"modelId": "claude-sonnet-4-20250514"
}
```
> body 不含 `mode` / `action`。`providerId` / `modelId` 来自 inline 输入框动态选择,无 settings 持久化。
**Response**(v4 异步):后端调用 OpenCode `/session/:id/prompt_async` 后**立即返回**——不等 LLM 完成。
```json
{
"ok": true,
"sessionId": "ses-abc123",
"notebookPath": "analysis/demo.ipynb"
}
```
> 响应中**无** `markdown` 字段。AI 的回复通过独立的 SSE 端点(`/opencode-bridge/events`)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 仍由 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。cell 源码**不**被替换;用户可对 AI 消息中的每个代码块使用 Copy / Insert / Replace 按钮。
### 接口:`GET /opencode-bridge/events?session=<sid>`v4 新增 — SSE 代理)
服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=<sid>` 过滤后以 `text/event-stream` 透传给前端。OpenCode 事件 schema 较松散,事件示例:
```json
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "hel"}}
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "lo"}}
{"type": "session.next.reasoning.delta", "properties": {"sessionID": "ses-abc123", "delta": "thinking..."}}
{"type": "session.next.tool.called", "properties": {"sessionID": "ses-abc123", "name": "bash"}}
{"type": "session.next.tool.input.delta", "properties": {"sessionID": "ses-abc123", "delta": "{\"cmd\": \"ls\"}"}}
{"type": "session.next.tool.success", "properties": {"sessionID": "ses-abc123", "result": {"exitCode": 0}}}
{"type": "session.next.agent.switched", "properties": {"sessionID": "ses-abc123", "agentID": "build"}}
{"type": "session.next.model.switched", "properties": {"sessionID": "ses-abc123", "modelID": "claude-..."}}
{"type": "file.edited", "properties": {"sessionID": "ses-abc123", "path": "foo.py"}}
{"type": "permission.asked", "properties": {"sessionID": "ses-abc123", "id": "perm-1", "description": "rm -rf"}}
{"type": "question.asked", "properties": {"sessionID": "ses-abc123", "id": "q-1", "question": "Which env?"}}
{"type": "session.idle", "properties": {"sessionID": "ses-abc123"}}
```
> v4 前端 `OpenCodeInlinePrompt.applyEvent()` 按 `type` 路由到不同渲染器:text/reasoning/tool 块用 `<details>` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
---
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
以下是前端实现“Toolbar 注入”和“上下文抓取”的伪代码骨架,可直接作为开发参考:
### `src/context_provider.ts` (上下文感知模块)
```typescript
import { Notebook, NotebookTracker } from '@jupyterlab/notebook';
import { Cell } from '@jupyterlab/cells';
export interface CellContext {
notebookPath: string;
targetCode: string;
errorOutput?: string;
surroundingCells: string[];
}
export function extractCellContext(tracker: NotebookTracker, targetCell: Cell): CellContext | null {
const currentWidget = tracker.currentWidget;
if (!currentWidget) return null;
const notebook: Notebook = currentWidget.content;
const cells = notebook.widgets;
const targetIndex = cells.findIndex(c => c === targetCell);
// 1. 抓取当前 Cell 的错误输出
let errorMsg = '';
if (targetCell.model.type === 'code') {
const outputs = (targetCell.model as any).outputs;
for (let i = 0; i < outputs.length; i++) {
const out = outputs.get(i);
if (out.type === 'error') {
errorMsg += `${out.ename}: ${out.evalue}\n${(out.traceback || []).join('\n')}`;
}
}
}
// 2. 抓取前一个 Cell 的代码(上下文)
const prevCode = targetIndex > 0 ? cells[targetIndex - 1].model.sharedModel.getSource() : '';
return {
notebookPath: currentWidget.context.path,
targetCode: targetCell.model.sharedModel.getSource(),
errorOutput: errorMsg,
surroundingCells: [prevCode]
};
}
```
### `src/components/cell_toolbar.ts` (工具栏与内嵌框注入)
```typescript
import { Cell } from '@jupyterlab/cells';
export function injectOpenCodeActionBox(cell: Cell, onSendPrompt: (prompt: string) => void) {
// 避免重复注入
if (cell.node.querySelector('.opencode-toolbar')) return;
// 1. 创建 Toolbar 容器
const toolbar = document.createElement('div');
toolbar.className = 'opencode-toolbar';
toolbar.innerHTML = `
<button class="opencode-btn edit-btn">🪄 智能编辑</button>
`;
// 2. 绑定展开事件
toolbar.querySelector('.edit-btn')!.addEventListener('click', () => {
let promptBox = cell.node.querySelector('.opencode-inline-box') as HTMLDivElement;
if (promptBox) {
promptBox.style.display = promptBox.style.display === 'none' ? 'block' : 'none';
return;
}
// 动态创建 Inline Prompt 输入框
promptBox = document.createElement('div');
promptBox.className = 'opencode-inline-box';
promptBox.innerHTML = `
<textarea placeholder="输入提示词(例如:重构这段代码或添加注释..."></textarea>
<div class="actions">
<button class="submit-btn">🚀 发送给 OpenCode</button>
</div>
`;
promptBox.querySelector('.submit-btn')!.addEventListener('click', () => {
const input = promptBox.querySelector('textarea')!.value;
if (input.trim()) onSendPrompt(input);
});
cell.node.appendChild(promptBox);
});
cell.node.insertBefore(toolbar, cell.node.firstChild);
}
```
---
## 🔗 5. Session 管理策略(v0.2.1 修正)
> **2026-07-22 修正**:原 v0.2 设计 `EditHandler` 采用"每请求一个 session + finally 清理"——这是错的。OpenCode 的 session 是有状态的对话上下文,每个 notebook 必须复用同一个 session,否则用户先优化 cell1、再编辑 cell2 时,AI 看不到前一次的结果。
### 5.1 核心规则
| 维度 | 决策 |
|---|---|
| 映射关系 | `notebookPath`1 个)→ OpenCode `sessionID`1 个) |
| 生命周期 | 首次 `EditHandler` 调用时 lazy create;显式 `DELETE /session` 才释放 |
| 并发 | 同 notebook 的并发请求用 `asyncio.Lock` 串行化 session 创建;消息发送并发无锁(OpenCode 端自己排队) |
| 失效恢复 | 当 `send_message_sync``OpenCodeError` 且消息含 404/410 → 自动 invalidate 缓存,下次请求重新 create |
| 清理 | **不做自动释放**。前端 notebook 关闭时**不**调 release(避免两个 tab 共享 session 时误杀)。用户可手动点"重置对话"触发 release |
| 跨重启 | Jupyter server 重启 → in-memory map 丢失 → OpenCode 端孤立 session 接受泄漏(v0.4 加启动清理) |
### 5.2 SessionManager 接口
```python
class SessionManager:
def __init__(self, client_factory: Callable[[], OpenCodeClient]): ...
async def get_or_create(self, notebook_path: str) -> str:
"""Return existing sessionID, or create one. Async-safe via per-path lock."""
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if session existed."""
def has_session(self, notebook_path: str) -> bool: ...
def list_sessions(self) -> list[dict]:
"""For debug: [{notebookPath, sessionId}, ...]"""
```
**关键不变量**
- `get_or_create` 永远不返回 None 或抛 "not found"
- 重复调用 `get_or_create(same_path)` → 同一 sessionID
- `release(path)` 后再 `get_or_create(path)` → 新 sessionID
### 5.3 HTTP 端点
| Method | Path | 作用 | 备注 |
|---|---|---|---|
| `GET` | `/opencode-bridge/sessions` | 列出所有 active session | debug 用 |
| `DELETE` | `/opencode-bridge/session?notebook=<path>` | 显式释放指定 notebook 的 session | URL-encode path |
**为什么用 query param 而不是 path param**
- notebook path 可能含 `/``空格``中文`
- `url_path_join` + regex 捕获在含 `/` 的路径上行为不可控
- query string 是 RFC 3986 标准做法,tornado 自动 decode
### 5.4 EditHandler 改造
**改造前(错的)**
```
create_session → send_message_sync → delete_session # 每个请求都这样
```
**改造后(对的)**
```
session_id = session_manager.get_or_create(notebook_path) # 首次创建,复用后续
send_message_sync(session_id, ...) # 不管 session
# 没有 delete — session 留着给下次
```
### 5.5 错误恢复
`send_message_sync` 失败时:
1. 检查 `OpenCodeError` 消息是否含 "404" 或 "session not found"
2. 是 → `session_manager.invalidate(notebook_path)`,下次 `get_or_create` 会重新创建
3. 否 → 透传错误给前端
v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
### 5.6 未解决问题(v0.4+
1. **TTL reaper**notebook 关闭后 session 永远不释放 → 改用 mtime 追踪 + 30 分钟 idle 自动 release
2. **多 server 横向扩展**in-memory state 不能 scale → 改成 Redis 或 sticky session
3. **启动清理**Jupyter 重启后调 `GET /session` 列出 OpenCode 端的孤儿,逐个 delete
4. **并发 LLM 调用**:当前两次 `send_message_sync` 到同一 session 是串行(OpenCode 端排队)— 可加 `asyncio.Semaphore(per_session=1)` 显式控制
---
## ⚙️ 6. 启动配置 + 动态模型选择(v3-final2026-07-23
> **v3-final 修正**`opencode_bridge` 插件**完全不再使用 JupyterLab Settings Editor**`schema/plugin.json` 的 `properties: {}`)。所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定;模型在 inline 输入框中动态选择(见 §2 item 2),无任何持久默认。
### 6.1 启动环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth | HTTP Basic Auth 密码 |
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒,整数) |
`opencode_bridge/config.py` `resolve_config` 统一解析,**不再**从 JupyterLab settings 字典兜底(`jupyter_settings` 参数保留仅为 API 兼容,实际未读)。
### 6.2 模型选择
`opencodeProvider` / `opencodeModel` v0.2.2 settings 字段**已移除**。inline 输入框顶部 `<select>` 的选项来自 `index.ts` 启动时 `callOpenCodeProviders` 拉取并写入 `setOpenCodeProviders(p)` 的模块级缓存:
```
index.ts (启动)
└─ GET /opencode-bridge/providers
└─ setOpenCodeProviders(data) // 模块级 _providers
└─ OpenCodeInlinePrompt 构造时读
└─ <select> 列出所有 (provider, model)
└─ 用户选一项 → 提交时
└─ OpenCodeRequest.providerId / modelId
└─ POST /opencode-bridge/edit
```
- 默认选中:下拉框**第一项**(无 settings 可作默认)。
- 拉取失败(OpenCode Serve 未起)→ 缓存为 `null` → inline 不渲染 select → 提交时省略 `providerId`/`modelId`OpenCode Serve 用其默认。**优雅降级**。
### 6.3 行为变更(v0.2.2 → v3-final
- 之前 6 个 JupyterLab settings 字段(`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` / `opencodeProvider` / `opencodeModel`)**全部失效**,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。
- 连接配置从"可在 Settings Editor 改"变为"启动时定",更符合服务端连接参数的语义。
- 模型从"settings 持久默认"变为"每次手选",无持久化偏好。