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>
343 lines
19 KiB
Markdown
343 lines
19 KiB
Markdown
## 📑 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-final,2026-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 持久默认"变为"每次手选",无持久化偏好。
|