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

19 KiB
Raw Permalink Blame History

📑 1. 系统总体架构

采用 前端 UI 注入 + 本地 Server Extension 透传 的轻量双层架构:

┌────────────────────────────────────────────────────────────────────────┐
│                   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 拉取失败时该下拉框不渲染。
    • 多行 textareaplaceholder "向 AI 描述你想要的修改…",无默认值)。
    • 🚀 发送 + ✕ 取消 两个按钮。

    提交时前端仅收集 cell source / outputs / error 与用户在 textarea 里写的指令,POST /opencode-bridge/editbody 为 {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/editv4 — 异步 + SSE

Request Payload(前端 OpenCodeCellActions 在用户提交 inline 输入框时发出):

{
  "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 / actionproviderId / modelId 来自 inline 输入框动态选择,无 settings 持久化。

Response(v4 异步):后端调用 OpenCode /session/:id/prompt_async立即返回——不等 LLM 完成。

{
  "ok": true,
  "sessionId": "ses-abc123",
  "notebookPath": "analysis/demo.ipynb"
}

响应中 markdown 字段。AI 的回复通过独立的 SSE 端点(/opencode-bridge/events)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 仍由 SessionManagernotebookPath 复用(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 较松散,事件示例:

{"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 (上下文感知模块)

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 (工具栏与内嵌框注入)

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 核心规则

维度 决策
映射关系 notebookPath1 个)→ OpenCode sessionID1 个)
生命周期 首次 EditHandler 调用时 lazy create;显式 DELETE /session 才释放
并发 同 notebook 的并发请求用 asyncio.Lock 串行化 session 创建;消息发送并发无锁(OpenCode 端自己排队)
失效恢复 send_message_syncOpenCodeError 且消息含 404/410 → 自动 invalidate 缓存,下次请求重新 create
清理 不做自动释放。前端 notebook 关闭时调 release(避免两个 tab 共享 session 时误杀)。用户可手动点"重置对话"触发 release
跨重启 Jupyter server 重启 → in-memory map 丢失 → OpenCode 端孤立 session 接受泄漏(v0.4 加启动清理)

5.2 SessionManager 接口

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 reapernotebook 关闭后 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 Editorschema/plugin.jsonproperties: {})。所有 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/modelIdOpenCode Serve 用其默认。优雅降级

6.3 行为变更(v0.2.2 → v3-final

  • 之前 6 个 JupyterLab settings 字段(opencodeServerUrl / User / Password / requestTimeoutSeconds / opencodeProvider / opencodeModel全部失效,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。
  • 连接配置从"可在 Settings Editor 改"变为"启动时定",更符合服务端连接参数的语义。
  • 模型从"settings 持久默认"变为"每次手选",无持久化偏好。