17 KiB
📑 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流程设计
-
原生 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 自己判断是优化/排错/编辑。 -
Inline Prompt 输入框(v3-final):点击
🪄 AI 智能编辑按钮后,OpenCodeInlinePromptwidget 追加到当前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(前端不感知)。 - 顶部一行
-
流式替换/Diff 预览:
- 点击发送后,支持打字机实时替换或在 Cell 下方出现
[ Accept ]/[ Reject ]临时 Diff 比对面板。
🔌 3. 接口契约设计 (API Contract)
接口:POST /opencode-bridge/edit(v2 — 无 mode)
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"
}
v2 变化:body 不再含
mode/action字段。前端只把 cell 的 input/output/error 收集到context,加用户的自然语言prompt一起发出;后端用统一的UNIFIED_SYSTEM_PROMPT+ LLM 自己判断优化/排错/编辑。providerId/modelId来自 JupyterLab settings 透传。
Response(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
{
"ok": true,
"finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
"sessionId": "ses-abc123",
"notebookPath": "analysis/demo.ipynb"
}
成功时前端用
finalSource就地替换 cell 源码(cell.model.sharedModel.setSource)。Session 由服务端SessionManager按notebookPath复用(1 notebook 1 sid),404 /session not found时服务端自动 invalidate 并在下次请求重建。
🛠️ 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 核心规则
| 维度 | 决策 |
|---|---|
| 映射关系 | 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 接口
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 失败时:
- 检查
OpenCodeError消息是否含 "404" 或 "session not found" - 是 →
session_manager.invalidate(notebook_path),下次get_or_create会重新创建 - 否 → 透传错误给前端
v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
5.6 未解决问题(v0.4+)
- TTL reaper:notebook 关闭后 session 永远不释放 → 改用 mtime 追踪 + 30 分钟 idle 自动 release
- 多 server 横向扩展:in-memory state 不能 scale → 改成 Redis 或 sticky session
- 启动清理:Jupyter 重启后调
GET /session列出 OpenCode 端的孤儿,逐个 delete - 并发 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 持久默认"变为"每次手选",无持久化偏好。