Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae1eed1b1 | ||
|
|
4f8bbaf09f |
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "Release created with ID ${RELEASE_ID}"
|
echo "Release created with ID ${RELEASE_ID}"
|
||||||
|
|
||||||
for asset in dist/*.whl dist/*.tar.gz; do
|
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
|
||||||
if [ -f "${asset}" ]; then
|
if [ -f "${asset}" ]; then
|
||||||
echo "Uploading ${asset}..."
|
echo "Uploading ${asset}..."
|
||||||
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
name: CI
|
name: CI
|
||||||
|
|||||||
-207
@@ -1,207 +0,0 @@
|
|||||||
# opencode_bridge 交接文档
|
|
||||||
|
|
||||||
## 状态速览
|
|
||||||
|
|
||||||
**分支**:`feature/floating-ai-button`(已 push,tracking 已设)
|
|
||||||
**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
|
|
||||||
**基础**:`main` @ `310d507`(上次 HANDOVER 时的版本)
|
|
||||||
**当前 HEAD**:`d412130`
|
|
||||||
**提交**:
|
|
||||||
```
|
|
||||||
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_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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenCodeFloatingPanel 设计要点
|
|
||||||
|
|
||||||
### DOM 结构
|
|
||||||
```
|
|
||||||
<div class="opencode-floating-root"> (position: fixed; bottom: 24; right: 24)
|
|
||||||
<button class="opencode-floating-button">🪄</button>
|
|
||||||
<div class="opencode-floating-panel" hidden>
|
|
||||||
<!-- OpenCodeInlinePrompt 在这里 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 生命周期
|
|
||||||
- 构造:创建 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. **不用并存模式** — 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 段的测试局限说明。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 仍未做的事(如果继续)
|
|
||||||
|
|
||||||
- [ ] **真实 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`。可以后续更精确处理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 环境 / 工具注意事项(下一个 agent 必读)
|
|
||||||
|
|
||||||
### Codex 走得很坎坷 ⚠️⚠️⚠️
|
|
||||||
|
|
||||||
CLAUDE.md 强制要求 Codex `model: kimi/kimi-k2.7-code`,但本会话中这个模型在火山方舟 Coding Plan 上**反复返回 HTTP 400**:
|
|
||||||
|
|
||||||
| 错误 | 频率 |
|
|
||||||
|------|------|
|
|
||||||
| `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` 上继续即可。
|
|
||||||
@@ -159,16 +159,6 @@ class OpenCodeClient:
|
|||||||
"""
|
"""
|
||||||
return await self._request("GET", "/session/%s/message" % session_id)
|
return await self._request("GET", "/session/%s/message" % session_id)
|
||||||
|
|
||||||
async def list_all_sessions(self) -> list[dict[str, Any]]:
|
|
||||||
"""List ALL sessions on the OpenCode Serve side (not just the ones
|
|
||||||
bound to a notebook in our SessionManager).
|
|
||||||
|
|
||||||
Returns the raw OpenCode response: a list of session objects with
|
|
||||||
metadata (``id``, ``title``, ``createdAt``, ``updatedAt``,
|
|
||||||
``messageCount`` etc., whatever the upstream returns).
|
|
||||||
"""
|
|
||||||
return await self._request("GET", "/session")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoint(self) -> str:
|
def endpoint(self) -> str:
|
||||||
return self._config.url
|
return self._config.url
|
||||||
|
|||||||
+37
-252
@@ -48,19 +48,44 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
|||||||
return sm
|
return sm
|
||||||
|
|
||||||
|
|
||||||
def _build_request_body(prompt: str) -> dict:
|
def _build_request_body(prompt: str, context: dict) -> dict:
|
||||||
"""Build request body for OpenCode POST /session/:id/prompt_async.
|
"""Build full request body for OpenCode POST /session/:id/prompt_async.
|
||||||
|
|
||||||
v0.2.x: only the user's prompt is sent to the LLM. We no longer
|
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
|
||||||
auto-wrap the cell source / previous-cell / traceback in tags —
|
the LLM interprets the user's natural-language instruction, with the code
|
||||||
the user inserts whatever they want via the "📋 插入单元格内容"
|
context and the optional traceback in front of it.
|
||||||
button (or pastes manually), so the LLM context exactly matches
|
|
||||||
user intent. Returns dict with 'parts' and 'system' keys.
|
|
||||||
"""
|
"""
|
||||||
return {
|
parts: list[dict] = []
|
||||||
"parts": [{"type": "text", "text": prompt}],
|
|
||||||
"system": UNIFIED_SYSTEM_PROMPT,
|
if context.get("previousCode"):
|
||||||
}
|
parts.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
|
||||||
|
})
|
||||||
|
|
||||||
|
error = context.get("error")
|
||||||
|
if error:
|
||||||
|
parts.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
"<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
|
||||||
|
+ "\n".join(error.get("traceback", []))
|
||||||
|
+ "\n</traceback>\n"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
parts.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": "<cell language='%s'>\n%s\n</cell>\n" % (
|
||||||
|
context.get("language", "python"),
|
||||||
|
context["source"],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
||||||
|
|
||||||
|
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_fence(s: str) -> str:
|
def _strip_code_fence(s: str) -> str:
|
||||||
@@ -179,7 +204,7 @@ class EditHandler(APIHandler):
|
|||||||
sm = get_session_manager(self)
|
sm = get_session_manager(self)
|
||||||
try:
|
try:
|
||||||
sid = await sm.get_or_create(notebook_path)
|
sid = await sm.get_or_create(notebook_path)
|
||||||
request_body = _build_request_body(prompt)
|
request_body = _build_request_body(prompt, context)
|
||||||
await client.send_message_async(
|
await client.send_message_async(
|
||||||
sid,
|
sid,
|
||||||
request_body["parts"],
|
request_body["parts"],
|
||||||
@@ -433,242 +458,6 @@ class SessionMessagesHandler(APIHandler):
|
|||||||
self.finish(json.dumps({"messages": messages}))
|
self.finish(json.dumps({"messages": messages}))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Multi-session management endpoints
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#
|
|
||||||
# Model: each notebook can have multiple OpenCode sessions bound to it.
|
|
||||||
# One of the bound sessions is the "active" one (used by the next /edit).
|
|
||||||
# The user can:
|
|
||||||
# - list all OpenCode sessions (global, for the "browse" UI)
|
|
||||||
# - list sessions bound to a specific notebook
|
|
||||||
# - create a brand-new session and bind it (becomes active)
|
|
||||||
# - bind an existing session id (e.g. one they saw in the global list)
|
|
||||||
# - switch which bound session is active
|
|
||||||
# - unbind the active one (next /edit will lazy-create a new one)
|
|
||||||
# - delete a specific bound session (also removes it from OpenCode Serve)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class AllSessionsListHandler(APIHandler):
|
|
||||||
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
|
|
||||||
Serve, regardless of which notebook (if any) it is bound to. Powers
|
|
||||||
the "browse all conversations" UI in the cell-action panel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
async def get(self):
|
|
||||||
try:
|
|
||||||
client = make_client(self)
|
|
||||||
sessions = await client.list_all_sessions()
|
|
||||||
except OpenCodeError as e:
|
|
||||||
log.exception("list all sessions failed")
|
|
||||||
self.set_status(502)
|
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("list all sessions failed")
|
|
||||||
self.set_status(502)
|
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
|
||||||
return
|
|
||||||
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
|
|
||||||
|
|
||||||
|
|
||||||
class NotebookSessionsHandler(APIHandler):
|
|
||||||
"""Manage the list of sessions BOUND to one notebook.
|
|
||||||
|
|
||||||
GET /opencode-bridge/sessions/notebook?notebook=...
|
|
||||||
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
|
|
||||||
The metadata (title, createdAt, ...) is enriched from
|
|
||||||
OpenCode's `GET /session` so the frontend can render
|
|
||||||
the picker without a second round-trip per session.
|
|
||||||
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
|
|
||||||
-> { ok, sessionId, activeSessionId } — creates a new
|
|
||||||
session on OpenCode, binds it, sets as active.
|
|
||||||
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
|
|
||||||
-> { ok, sessionId, activeSessionId } — binds an
|
|
||||||
existing sessionId (e.g. one the user picked from
|
|
||||||
the global list) to this notebook and sets as active.
|
|
||||||
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
|
|
||||||
-> { ok, deleted } — deletes the session on OpenCode
|
|
||||||
Serve AND removes its binding from this notebook.
|
|
||||||
If it was the active one, clears active.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
async def get(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
if not notebook_path:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
bound_ids = sm.list_sessions_for_notebook(notebook_path)
|
|
||||||
active = sm.get_active(notebook_path)
|
|
||||||
# Enrich with metadata from OpenCode Serve so the UI can show
|
|
||||||
# the title / createdAt without a second round-trip.
|
|
||||||
try:
|
|
||||||
all_sessions = await make_client(self).list_all_sessions()
|
|
||||||
by_id = {s.get("id"): s for s in (all_sessions or [])}
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(
|
|
||||||
"could not enrich bound sessions with metadata: %s", e
|
|
||||||
)
|
|
||||||
by_id = {}
|
|
||||||
sessions = []
|
|
||||||
for sid in bound_ids:
|
|
||||||
meta = by_id.get(sid, {})
|
|
||||||
sessions.append({
|
|
||||||
"sessionId": sid,
|
|
||||||
"title": meta.get("title"),
|
|
||||||
"createdAt": meta.get("createdAt"),
|
|
||||||
"updatedAt": meta.get("updatedAt"),
|
|
||||||
"isActive": sid == active,
|
|
||||||
})
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"activeSessionId": active,
|
|
||||||
"sessions": sessions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
async def post(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
if not notebook_path:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
|
|
||||||
return
|
|
||||||
title = self.get_query_argument("title", None) or None
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
try:
|
|
||||||
sid = await sm.create_new(notebook_path, title=title)
|
|
||||||
except OpenCodeError as e:
|
|
||||||
log.exception("create session failed")
|
|
||||||
self.set_status(502)
|
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("create session failed")
|
|
||||||
self.set_status(502)
|
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
|
||||||
return
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"sessionId": sid,
|
|
||||||
"activeSessionId": sid,
|
|
||||||
}))
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
def put(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
session_id = self.get_query_argument("sessionId", "")
|
|
||||||
if not notebook_path or not session_id:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"error": "missing 'notebook' and/or 'sessionId' query parameter"
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
sm.bind_existing(notebook_path, session_id)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"sessionId": session_id,
|
|
||||||
"activeSessionId": sm.get_active(notebook_path),
|
|
||||||
}))
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
async def delete(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
session_id = self.get_query_argument("sessionId", "")
|
|
||||||
if not notebook_path or not session_id:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"error": "missing 'notebook' and/or 'sessionId' query parameter"
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
deleted = await sm.delete_session(notebook_path, session_id)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"sessionId": session_id,
|
|
||||||
"deleted": deleted,
|
|
||||||
"activeSessionId": sm.get_active(notebook_path),
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
class ActiveSessionHandler(APIHandler):
|
|
||||||
"""Manage the ACTIVE session for one notebook.
|
|
||||||
|
|
||||||
GET /opencode-bridge/sessions/active?notebook=...
|
|
||||||
-> { ok, activeSessionId, notebookPath }
|
|
||||||
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
|
|
||||||
-> { ok, activeSessionId } — switch the active bound
|
|
||||||
session. sessionId MUST already be in the bound list.
|
|
||||||
DELETE /opencode-bridge/sessions/active?notebook=...
|
|
||||||
-> { ok } — unbind the active session (next /edit will
|
|
||||||
lazy-create a new one). Does NOT delete the session
|
|
||||||
on OpenCode Serve (use the notebook DELETE for that).
|
|
||||||
"""
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
def get(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
if not notebook_path:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"activeSessionId": sm.get_active(notebook_path),
|
|
||||||
}))
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
def put(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
session_id = self.get_query_argument("sessionId", "")
|
|
||||||
if not notebook_path or not session_id:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"error": "missing 'notebook' and/or 'sessionId' query parameter"
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
if not sm.set_active(notebook_path, session_id):
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": False,
|
|
||||||
"error": "sessionId is not bound to this notebook; "
|
|
||||||
"POST /sessions/notebook or PUT /sessions/notebook first"
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"activeSessionId": sm.get_active(notebook_path),
|
|
||||||
}))
|
|
||||||
|
|
||||||
@tornado.web.authenticated
|
|
||||||
def delete(self):
|
|
||||||
notebook_path = self.get_query_argument("notebook", "")
|
|
||||||
if not notebook_path:
|
|
||||||
self.set_status(400)
|
|
||||||
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
|
|
||||||
return
|
|
||||||
sm = get_session_manager(self)
|
|
||||||
sm.unbind_active(notebook_path)
|
|
||||||
self.finish(json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"notebookPath": notebook_path,
|
|
||||||
"activeSessionId": sm.get_active(notebook_path),
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
def setup_route_handlers(web_app):
|
def setup_route_handlers(web_app):
|
||||||
host_pattern = ".*$"
|
host_pattern = ".*$"
|
||||||
base_url = web_app.settings["base_url"]
|
base_url = web_app.settings["base_url"]
|
||||||
@@ -682,10 +471,6 @@ def setup_route_handlers(web_app):
|
|||||||
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
|
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
||||||
# Multi-session management (notebook can bind N sessions; one is active).
|
|
||||||
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
|
|
||||||
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
|
|
||||||
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
|
|
||||||
# /permissions/<perm_id> and /questions/<q_id>/reply
|
# /permissions/<perm_id> and /questions/<q_id>/reply
|
||||||
# use a URLSpec with a regex so the path param is forwarded to
|
# use a URLSpec with a regex so the path param is forwarded to
|
||||||
# the handler as a positional argument.
|
# the handler as a positional argument.
|
||||||
|
|||||||
@@ -1,29 +1,13 @@
|
|||||||
"""Per-notebook session manager for OpenCode.
|
"""Per-notebook session manager for OpenCode.
|
||||||
|
|
||||||
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
|
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
|
||||||
the bound sessions is the "active" one (the one used for the next
|
Async-safe via per-path asyncio.Lock. No automatic cleanup.
|
||||||
``/edit``). The others are historical — they were created in this
|
|
||||||
notebook at some point but the user switched away (e.g. started a
|
|
||||||
new conversation topic).
|
|
||||||
|
|
||||||
Lifecycle:
|
|
||||||
- ``get_or_create`` returns the active session, lazily creating one on
|
|
||||||
first use. This is the path used by ``EditHandler`` on every prompt.
|
|
||||||
- ``create_new`` always creates a brand-new session on OpenCode Serve,
|
|
||||||
binds it, sets it as active. Used by the "New session" UI action.
|
|
||||||
- ``bind_existing`` / ``set_active`` / ``unbind`` / ``delete_session``
|
|
||||||
are the management operations for the per-notebook session list.
|
|
||||||
|
|
||||||
Async-safety: a per-notebook ``asyncio.Lock`` serializes
|
|
||||||
``get_or_create`` so concurrent prompts on the same notebook don't
|
|
||||||
double-create. The lock survives across calls (held in ``_locks``) so
|
|
||||||
subsequent calls can reuse it.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, Optional
|
from typing import Callable
|
||||||
|
|
||||||
from .opencode_client import OpenCodeClient
|
from .opencode_client import OpenCodeClient
|
||||||
|
|
||||||
@@ -33,216 +17,81 @@ ClientFactory = Callable[[], OpenCodeClient]
|
|||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
|
"""Tracks one OpenCode session per notebook path.
|
||||||
|
|
||||||
|
Threading/async model:
|
||||||
|
- Multiple coroutines may call get_or_create for the same notebook.
|
||||||
|
- First call creates; subsequent calls return the same sessionID.
|
||||||
|
- Per-notebook asyncio.Lock prevents double-create under concurrency.
|
||||||
|
- Locks remain in the map; they may be needed again for the same path.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, client_factory: ClientFactory) -> None:
|
def __init__(self, client_factory: ClientFactory) -> None:
|
||||||
self._client_factory = client_factory
|
self._client_factory = client_factory
|
||||||
# notebookPath -> active sessionId
|
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
|
||||||
self._active: dict[str, str] = {}
|
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
|
||||||
# notebookPath -> ordered list of sessionIds bound to this
|
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
|
||||||
# notebook (the first is the oldest; the active one may be
|
|
||||||
# anywhere in the list). Used by the UI to render the session
|
|
||||||
# picker.
|
|
||||||
self._bound: dict[str, list[str]] = {}
|
|
||||||
# Per-notebook lock for serializing lazy-create.
|
|
||||||
self._locks: dict[str, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
# ---------- active-session lookup / create ----------
|
|
||||||
|
|
||||||
async def get_or_create(self, notebook_path: str) -> str:
|
async def get_or_create(self, notebook_path: str) -> str:
|
||||||
"""Return the active session id for this notebook, creating one
|
"""Return session ID for the notebook, creating one if needed.
|
||||||
if no active session is bound yet. This is what ``EditHandler``
|
|
||||||
calls on every prompt.
|
Idempotent for the same path. Different paths get different sessions.
|
||||||
"""
|
"""
|
||||||
active = self._active.get(notebook_path)
|
existing = self._sessions.get(notebook_path)
|
||||||
if active is not None:
|
if existing is not None:
|
||||||
return active
|
return existing
|
||||||
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
active = self._active.get(notebook_path)
|
existing = self._sessions.get(notebook_path)
|
||||||
if active is not None:
|
if existing is not None:
|
||||||
return active
|
return existing
|
||||||
return await self._create_and_bind(notebook_path, title=None)
|
|
||||||
|
|
||||||
async def _create_and_bind(
|
|
||||||
self, notebook_path: str, title: Optional[str]
|
|
||||||
) -> str:
|
|
||||||
client = self._client_factory()
|
client = self._client_factory()
|
||||||
session = await client.create_session(
|
session = await client.create_session(
|
||||||
title=title or "jupyter:%s" % notebook_path
|
title="jupyter:%s" % notebook_path
|
||||||
)
|
)
|
||||||
sid = session["id"]
|
sid = session["id"]
|
||||||
self._bind(notebook_path, sid)
|
self._sessions[notebook_path] = sid
|
||||||
|
self._titles[notebook_path] = notebook_path
|
||||||
log.info("created opencode session %s for %s", sid, notebook_path)
|
log.info("created opencode session %s for %s", sid, notebook_path)
|
||||||
return sid
|
return sid
|
||||||
|
|
||||||
def get_active(self, notebook_path: str) -> Optional[str]:
|
async def release(self, notebook_path: str) -> bool:
|
||||||
"""Return the active sessionId for the notebook, or None if no
|
"""Delete session and remove from map. Returns True if a session existed."""
|
||||||
active session has been bound/created yet. Does NOT create one.
|
sid = self._sessions.pop(notebook_path, None)
|
||||||
"""
|
self._titles.pop(notebook_path, None)
|
||||||
return self._active.get(notebook_path)
|
self._locks.pop(notebook_path, None)
|
||||||
|
if sid is None:
|
||||||
def peek(self, notebook_path: str) -> Optional[str]:
|
|
||||||
"""Alias for :meth:`get_active` (legacy name)."""
|
|
||||||
return self.get_active(notebook_path)
|
|
||||||
|
|
||||||
def has_session(self, notebook_path: str) -> bool:
|
|
||||||
"""True iff at least one session has ever been bound to this
|
|
||||||
notebook (active or not)."""
|
|
||||||
return notebook_path in self._bound
|
|
||||||
|
|
||||||
# ---------- mutation: bind / unbind / set_active / create_new ----------
|
|
||||||
|
|
||||||
def _bind(self, notebook_path: str, session_id: str) -> None:
|
|
||||||
"""Add session_id to the bound list (if not already) and set it
|
|
||||||
as active. Idempotent on re-bind.
|
|
||||||
"""
|
|
||||||
self._active[notebook_path] = session_id
|
|
||||||
bound = self._bound.setdefault(notebook_path, [])
|
|
||||||
if session_id not in bound:
|
|
||||||
bound.append(session_id)
|
|
||||||
|
|
||||||
async def create_new(
|
|
||||||
self, notebook_path: str, title: Optional[str] = None
|
|
||||||
) -> str:
|
|
||||||
"""Create a brand new session on OpenCode Serve, bind it to
|
|
||||||
this notebook, set as active, return its id.
|
|
||||||
"""
|
|
||||||
return await self._create_and_bind(notebook_path, title)
|
|
||||||
|
|
||||||
def bind_existing(self, notebook_path: str, session_id: str) -> None:
|
|
||||||
"""Bind an existing sessionId to this notebook and set as active.
|
|
||||||
Does NOT call OpenCode — the caller must verify the session
|
|
||||||
exists (e.g. via ``GET /session``).
|
|
||||||
"""
|
|
||||||
self._bind(notebook_path, session_id)
|
|
||||||
|
|
||||||
def set_active(self, notebook_path: str, session_id: str) -> bool:
|
|
||||||
"""Switch the active session. Returns False if ``session_id`` is
|
|
||||||
not currently bound to this notebook (callers should bind first
|
|
||||||
if uncertain).
|
|
||||||
"""
|
|
||||||
bound = self._bound.get(notebook_path, [])
|
|
||||||
if session_id not in bound:
|
|
||||||
return False
|
|
||||||
self._active[notebook_path] = session_id
|
|
||||||
return True
|
|
||||||
|
|
||||||
def unbind(self, notebook_path: str, session_id: str) -> bool:
|
|
||||||
"""Remove ``session_id`` from this notebook's bindings. If it was
|
|
||||||
the active one, also clear active (next prompt will lazy-create
|
|
||||||
a new session). Returns True if it was bound.
|
|
||||||
"""
|
|
||||||
bound = self._bound.get(notebook_path)
|
|
||||||
if not bound or session_id not in bound:
|
|
||||||
return False
|
|
||||||
bound.remove(session_id)
|
|
||||||
if self._active.get(notebook_path) == session_id:
|
|
||||||
self._active.pop(notebook_path, None)
|
|
||||||
if not bound:
|
|
||||||
self._bound.pop(notebook_path, None)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def unbind_active(self, notebook_path: str) -> bool:
|
|
||||||
"""Remove the active session binding only (leaves the rest of
|
|
||||||
the notebook's bound sessions intact). Next prompt will
|
|
||||||
lazy-create a new active session. Returns True if there was an
|
|
||||||
active binding to remove.
|
|
||||||
"""
|
|
||||||
active = self._active.pop(notebook_path, None)
|
|
||||||
if active is None:
|
|
||||||
return False
|
|
||||||
bound = self._bound.get(notebook_path)
|
|
||||||
if bound is not None:
|
|
||||||
bound.remove(active)
|
|
||||||
if not bound:
|
|
||||||
self._bound.pop(notebook_path, None)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def delete_session(
|
|
||||||
self, notebook_path: str, session_id: str
|
|
||||||
) -> bool:
|
|
||||||
"""Delete the session on OpenCode Serve AND remove its binding.
|
|
||||||
|
|
||||||
Order matters: we ask OpenCode to delete FIRST, and only remove
|
|
||||||
the local binding if OpenCode confirmed the delete. That way a
|
|
||||||
failed delete leaves the binding intact (the user can retry);
|
|
||||||
a deleted session never leaves a phantom binding behind.
|
|
||||||
|
|
||||||
Returns True if the session was actually deleted (i.e. it was
|
|
||||||
bound AND OpenCode acknowledged the delete). If the session is
|
|
||||||
not bound to this notebook, do NOT call OpenCode (we don't
|
|
||||||
want to silently delete sessions owned by other tools).
|
|
||||||
"""
|
|
||||||
if not self._is_bound(notebook_path, session_id):
|
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
client = self._client_factory()
|
client = self._client_factory()
|
||||||
deleted = await client.delete_session(session_id)
|
return await client.delete_session(sid)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("failed to delete opencode session %s", session_id)
|
log.warning(
|
||||||
|
"failed to delete opencode session %s for %s", sid, notebook_path
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
# Whether the delete succeeded or just returned False (404), the
|
|
||||||
# session is gone on the OpenCode side — drop the binding to
|
|
||||||
# keep the UI consistent. We return whether OpenCode said
|
|
||||||
# "yes" so the caller can surface a warning on a 404.
|
|
||||||
self.unbind(notebook_path, session_id)
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def _is_bound(self, notebook_path: str, session_id: str) -> bool:
|
|
||||||
bound = self._bound.get(notebook_path)
|
|
||||||
return bool(bound) and session_id in bound
|
|
||||||
|
|
||||||
# ---------- legacy / compat ----------
|
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
|
||||||
"""Tear down ALL state for this notebook: delete every bound
|
|
||||||
session on OpenCode Serve AND clear every local binding.
|
|
||||||
Returns True iff at least one session was actually deleted
|
|
||||||
on the OpenCode side. (For deleting a SPECIFIC session use
|
|
||||||
:meth:`delete_session`.)
|
|
||||||
"""
|
|
||||||
bound = list(self._bound.get(notebook_path, []))
|
|
||||||
deleted_any = False
|
|
||||||
for sid in bound:
|
|
||||||
if await self.delete_session(notebook_path, sid):
|
|
||||||
deleted_any = True
|
|
||||||
# `delete_session` clears the binding. Belt-and-suspenders: drop
|
|
||||||
# any straggler state.
|
|
||||||
self._bound.pop(notebook_path, None)
|
|
||||||
self._active.pop(notebook_path, None)
|
|
||||||
self._locks.pop(notebook_path, None)
|
|
||||||
return deleted_any
|
|
||||||
|
|
||||||
def invalidate(self, notebook_path: str) -> bool:
|
def invalidate(self, notebook_path: str) -> bool:
|
||||||
"""Drop the cached active sessionId without calling OpenCode.
|
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
|
||||||
Returns True if a binding was removed. Use this when an
|
|
||||||
upstream error indicates the session is dead (e.g. 404).
|
Use this when an upstream error indicates the session is dead (e.g., 404).
|
||||||
"""
|
"""
|
||||||
was_active = self._active.pop(notebook_path, None)
|
sid = self._sessions.pop(notebook_path, None)
|
||||||
if was_active is None:
|
self._titles.pop(notebook_path, None)
|
||||||
return False
|
return sid is not None
|
||||||
bound = self._bound.get(notebook_path)
|
|
||||||
if bound is not None:
|
def has_session(self, notebook_path: str) -> bool:
|
||||||
try:
|
return notebook_path in self._sessions
|
||||||
bound.remove(was_active)
|
|
||||||
except ValueError:
|
def peek(self, notebook_path: str) -> Optional[str]:
|
||||||
pass
|
"""Return the cached sessionID for the notebook, or None if no
|
||||||
if not bound:
|
session has been created yet. Does NOT create one (unlike
|
||||||
self._bound.pop(notebook_path, None)
|
get_or_create) — used by the history endpoint to avoid spawning
|
||||||
return True
|
a session just to report that there is none."""
|
||||||
|
return self._sessions.get(notebook_path)
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict]:
|
def list_sessions(self) -> list[dict]:
|
||||||
"""Debug: list all (notebookPath, activeSessionId) pairs across
|
|
||||||
every notebook. One row per notebook — does NOT enumerate the
|
|
||||||
full bound-list per notebook. Use
|
|
||||||
:meth:`list_sessions_for_notebook` for that.
|
|
||||||
"""
|
|
||||||
return [
|
return [
|
||||||
{"notebookPath": path, "sessionId": sid}
|
{"notebookPath": path, "sessionId": sid}
|
||||||
for path, sid in sorted(self._active.items())
|
for path, sid in sorted(self._sessions.items())
|
||||||
]
|
]
|
||||||
|
|
||||||
def list_sessions_for_notebook(self, notebook_path: str) -> list[str]:
|
|
||||||
"""All sessionIds bound to this notebook (in bind order: oldest
|
|
||||||
first, newest last). Empty list if none.
|
|
||||||
"""
|
|
||||||
return list(self._bound.get(notebook_path, []))
|
|
||||||
|
|||||||
@@ -266,22 +266,6 @@ async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
|
|||||||
assert body == {"answer": "use 3.12"}
|
assert body == {"answer": "use 3.12"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None:
|
|
||||||
"""list_all_sessions proxies OpenCode Serve's GET /session and returns
|
|
||||||
the raw list (no projection)."""
|
|
||||||
payload = [
|
|
||||||
{"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2},
|
|
||||||
{"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4},
|
|
||||||
]
|
|
||||||
client, mock = _make_client(base_config, [(200, payload)])
|
|
||||||
sessions = await client.list_all_sessions()
|
|
||||||
assert sessions == payload
|
|
||||||
call = mock.calls[0]
|
|
||||||
assert call["request"].url == "http://127.0.0.1:4096/session"
|
|
||||||
assert call["request"].method == "GET"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
||||||
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
||||||
|
|||||||
@@ -57,29 +57,6 @@ class FakeOpenCodeClient:
|
|||||||
self.calls.append(("list_session_messages", session_id))
|
self.calls.append(("list_session_messages", session_id))
|
||||||
return self.messages_response
|
return self.messages_response
|
||||||
|
|
||||||
async def list_all_sessions(self):
|
|
||||||
self.calls.append(("list_all_sessions",))
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": "fake-session-123",
|
|
||||||
"title": "jupyter:foo.ipynb",
|
|
||||||
"createdAt": 1700000000000,
|
|
||||||
"updatedAt": 1700000001000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "other-session",
|
|
||||||
"title": "manual session",
|
|
||||||
"createdAt": 1700000010000,
|
|
||||||
"updatedAt": 1700000011000,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async def create_session(self, title):
|
|
||||||
self.calls.append(("create_session", title))
|
|
||||||
# Return a fresh id (different from the default "fake-session-123").
|
|
||||||
new_id = "newly-created-" + title
|
|
||||||
return {"id": new_id, "title": title}
|
|
||||||
|
|
||||||
async def reply_permission(self, session_id, permission_id, response):
|
async def reply_permission(self, session_id, permission_id, response):
|
||||||
self.calls.append(("reply_permission", session_id, permission_id, response))
|
self.calls.append(("reply_permission", session_id, permission_id, response))
|
||||||
return True
|
return True
|
||||||
@@ -95,84 +72,35 @@ class FakeOpenCodeClient:
|
|||||||
|
|
||||||
|
|
||||||
class FakeSessionManager:
|
class FakeSessionManager:
|
||||||
"""Thin wrapper around the real SessionManager with a recording
|
"""Drop-in replacement for SessionManager with recording."""
|
||||||
``FakeOpenCodeClient``. Lets route tests exercise the real
|
|
||||||
multi-session logic without touching OpenCode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
|
def __init__(self, session_id: str = "fake-session-123") -> None:
|
||||||
from opencode_bridge.session_manager import SessionManager
|
self._session_id = session_id
|
||||||
# `_client` attribute used by some legacy tests. Allow injection
|
self.calls: list = []
|
||||||
# so a test can share its FakeOpenCodeClient with the SessionManager
|
|
||||||
# (the real SessionManager calls into its own client_factory, NOT
|
|
||||||
# the route's make_client).
|
|
||||||
self._client = client if client is not None else FakeOpenCodeClient()
|
|
||||||
self._sm = SessionManager(lambda: self._client)
|
|
||||||
self.calls: list = self._client.calls
|
|
||||||
# Legacy attribute used by list_sessions() etc.
|
|
||||||
self._sessions: dict = {}
|
|
||||||
|
|
||||||
# ---- delegation to the real SessionManager ----
|
async def get_or_create(self, notebook_path: str) -> str:
|
||||||
def _record(self, name, *args):
|
self.calls.append(("get_or_create", notebook_path))
|
||||||
self.calls.append((name, *args))
|
return self._session_id
|
||||||
return None
|
|
||||||
|
|
||||||
def get_or_create(self, notebook_path: str):
|
|
||||||
self._record("get_or_create", notebook_path)
|
|
||||||
return self._sm.get_or_create(notebook_path)
|
|
||||||
|
|
||||||
def peek(self, notebook_path: str):
|
def peek(self, notebook_path: str):
|
||||||
return self._sm.peek(notebook_path)
|
# Mirror SessionManager.peek: return the session id without
|
||||||
|
# creating one. None means "no session yet" (used by the no-session
|
||||||
def get_active(self, notebook_path: str):
|
# test to short-circuit the history route).
|
||||||
return self._sm.get_active(notebook_path)
|
return self._session_id or None
|
||||||
|
|
||||||
def list_sessions(self) -> list:
|
|
||||||
return self._sm.list_sessions()
|
|
||||||
|
|
||||||
def list_sessions_for_notebook(self, notebook_path: str):
|
|
||||||
return self._sm.list_sessions_for_notebook(notebook_path)
|
|
||||||
|
|
||||||
def bind_existing(self, notebook_path: str, session_id: str) -> None:
|
|
||||||
self._record("bind_existing", notebook_path, session_id)
|
|
||||||
self._sm.bind_existing(notebook_path, session_id)
|
|
||||||
|
|
||||||
def set_active(self, notebook_path: str, session_id: str) -> bool:
|
|
||||||
self._record("set_active", notebook_path, session_id)
|
|
||||||
return self._sm.set_active(notebook_path, session_id)
|
|
||||||
|
|
||||||
def unbind(self, notebook_path: str, session_id: str) -> bool:
|
|
||||||
self._record("unbind", notebook_path, session_id)
|
|
||||||
return self._sm.unbind(notebook_path, session_id)
|
|
||||||
|
|
||||||
def unbind_active(self, notebook_path: str) -> bool:
|
|
||||||
self._record("unbind_active", notebook_path)
|
|
||||||
return self._sm.unbind_active(notebook_path)
|
|
||||||
|
|
||||||
def invalidate(self, notebook_path: str) -> bool:
|
|
||||||
self._record("invalidate", notebook_path)
|
|
||||||
return self._sm.invalidate(notebook_path)
|
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
async def release(self, notebook_path: str) -> bool:
|
||||||
self._record("release", notebook_path)
|
self.calls.append(("release", notebook_path))
|
||||||
return await self._sm.release(notebook_path)
|
return True
|
||||||
|
|
||||||
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
|
def list_sessions(self) -> list:
|
||||||
self._record("delete_session", notebook_path, session_id)
|
return [
|
||||||
return await self._sm.delete_session(notebook_path, session_id)
|
{"notebookPath": path, "sessionId": sid}
|
||||||
|
for path, sid in sorted(self._sessions.items())
|
||||||
|
]
|
||||||
|
|
||||||
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
|
def invalidate(self, notebook_path: str) -> bool:
|
||||||
self._record("create_new", notebook_path, title)
|
self.calls.append(("invalidate", notebook_path))
|
||||||
return await self._sm.create_new(notebook_path, title)
|
return True
|
||||||
|
|
||||||
# ---- internal-state proxies (used by tests that poke internals) ----
|
|
||||||
@property
|
|
||||||
def _active(self) -> dict:
|
|
||||||
return self._sm._active
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _bound(self) -> dict:
|
|
||||||
return self._sm._bound
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hello(jp_fetch):
|
async def test_hello(jp_fetch):
|
||||||
@@ -250,11 +178,11 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
)
|
)
|
||||||
assert response.code == 200
|
assert response.code == 200
|
||||||
payload = json.loads(response.body)
|
payload = json.loads(response.body)
|
||||||
# The exact sessionId comes from the FakeClient; just check the
|
assert payload == {
|
||||||
# shape — keys are present and ok is true.
|
"ok": True,
|
||||||
assert payload["ok"] is True
|
"sessionId": "fake-session-123",
|
||||||
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
|
"notebookPath": "test.ipynb",
|
||||||
assert payload["notebookPath"] == "test.ipynb"
|
}
|
||||||
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
|
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
|
||||||
assert "markdown" not in payload
|
assert "markdown" not in payload
|
||||||
|
|
||||||
@@ -265,21 +193,16 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
assert "send_message_async" in call_names
|
assert "send_message_async" in call_names
|
||||||
assert "send_message_sync" not in call_names # legacy sync path is gone
|
assert "send_message_sync" not in call_names # legacy sync path is gone
|
||||||
|
|
||||||
# send_message_async received a session id from the manager (the
|
# send_message_async received the session ID from the manager.
|
||||||
# exact value comes from the FakeClient; just verify it matches
|
|
||||||
# the response's sessionId).
|
|
||||||
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
||||||
assert send_call[1] == payload["sessionId"]
|
assert send_call[1] == "fake-session-123"
|
||||||
# system prompt was passed.
|
# system prompt was passed.
|
||||||
assert "你是一个代码助手" in send_call[3]
|
assert "你是一个代码助手" in send_call[3]
|
||||||
|
|
||||||
# SessionManager.get_or_create was called with the notebook path.
|
# SessionManager.get_or_create was called with the notebook path.
|
||||||
# (The real SessionManager internally calls create_session on the
|
|
||||||
# FakeClient too, so the shared calls list has both entries.)
|
|
||||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||||
assert "get_or_create" in sm_call_names
|
assert sm_call_names == ["get_or_create"]
|
||||||
get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0]
|
assert fake_sm.calls[0][1] == "test.ipynb"
|
||||||
assert get_or_create_call[1] == "test.ipynb"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_edit_handler_includes_model_when_provider_and_model_given(
|
async def test_edit_handler_includes_model_when_provider_and_model_given(
|
||||||
@@ -319,12 +242,10 @@ async def test_edit_handler_includes_model_when_provider_and_model_given(
|
|||||||
|
|
||||||
async def test_session_list_handler(monkeypatch, jp_fetch):
|
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||||
fake_sm = FakeSessionManager()
|
fake_sm = FakeSessionManager()
|
||||||
# Seed the underlying real SessionManager's state so list_sessions
|
fake_sm._sessions = {
|
||||||
# returns the expected debug mapping.
|
"foo.ipynb": "sid-1",
|
||||||
fake_sm._active["foo.ipynb"] = "sid-1"
|
"bar.ipynb": "sid-2",
|
||||||
fake_sm._active["bar.ipynb"] = "sid-2"
|
} # direct injection
|
||||||
fake_sm._bound["foo.ipynb"] = ["sid-1"]
|
|
||||||
fake_sm._bound["bar.ipynb"] = ["sid-2"]
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
)
|
)
|
||||||
@@ -369,10 +290,6 @@ async def test_session_messages_handler_projects_opencode_messages(
|
|||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
||||||
# Seed the bound state so peek() returns the session id (the route
|
|
||||||
# only fetches messages if there's an active session).
|
|
||||||
fake_sm._active["test.ipynb"] = "fake-session-123"
|
|
||||||
fake_sm._bound["test.ipynb"] = ["fake-session-123"]
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
)
|
)
|
||||||
@@ -415,11 +332,6 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
|
|||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up state: get_or_create actually binds a session to the
|
|
||||||
# notebook in the real SessionManager (via the FakeClient). release
|
|
||||||
# then has something to delete.
|
|
||||||
await fake_sm.get_or_create("foo.ipynb")
|
|
||||||
|
|
||||||
response = await jp_fetch(
|
response = await jp_fetch(
|
||||||
"opencode-bridge", "session",
|
"opencode-bridge", "session",
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
@@ -547,221 +459,3 @@ async def test_global_event_handler_forwards_all_events_unfiltered(
|
|||||||
assert '"server.connected"' in body
|
assert '"server.connected"' in body
|
||||||
# The body should use the SSE format: each event as a `data:` line.
|
# The body should use the SSE format: each event as a `data:` line.
|
||||||
assert body.count("data: ") == 3
|
assert body.count("data: ") == 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Multi-session management endpoints
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
|
|
||||||
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
response = await jp_fetch("opencode-bridge", "sessions", "all")
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["ok"] is True
|
|
||||||
assert len(payload["sessions"]) == 2
|
|
||||||
# The fake returns both the default session and a hand-rolled "other-session".
|
|
||||||
ids = {s["id"] for s in payload["sessions"]}
|
|
||||||
assert "fake-session-123" in ids
|
|
||||||
assert "other-session" in ids
|
|
||||||
assert ("list_all_sessions",) in fake.calls
|
|
||||||
|
|
||||||
|
|
||||||
async def test_notebook_sessions_get_returns_bound_with_metadata(
|
|
||||||
monkeypatch, jp_fetch
|
|
||||||
):
|
|
||||||
"""GET /sessions/notebook?notebook=... returns the bound sessions
|
|
||||||
enriched with title/createdAt from the global list."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
sm._active["foo.ipynb"] = "fake-session-123"
|
|
||||||
sm._bound["foo.ipynb"] = ["fake-session-123"]
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "notebook",
|
|
||||||
params={"notebook": "foo.ipynb"},
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["notebookPath"] == "foo.ipynb"
|
|
||||||
assert payload["activeSessionId"] == "fake-session-123"
|
|
||||||
assert len(payload["sessions"]) == 1
|
|
||||||
s = payload["sessions"][0]
|
|
||||||
assert s["sessionId"] == "fake-session-123"
|
|
||||||
assert s["title"] == "jupyter:foo.ipynb"
|
|
||||||
assert s["isActive"] is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
|
|
||||||
"""POST /sessions/notebook creates a new session on OpenCode and
|
|
||||||
binds it as active."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
# Share the client with the SessionManager so the test can assert
|
|
||||||
# against `fake.calls` (the real SessionManager calls its own
|
|
||||||
# client_factory, NOT the route's make_client).
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager",
|
|
||||||
lambda h: FakeSessionManager(client=fake),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "notebook",
|
|
||||||
method="POST",
|
|
||||||
params={"notebook": "foo.ipynb", "title": "fresh"},
|
|
||||||
body=json.dumps({}),
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["ok"] is True
|
|
||||||
assert payload["sessionId"] == "newly-created-fresh"
|
|
||||||
# The new session is the active one.
|
|
||||||
assert payload["activeSessionId"] == "newly-created-fresh"
|
|
||||||
# OpenCode was called.
|
|
||||||
assert ("create_session", "fresh") in fake.calls
|
|
||||||
|
|
||||||
|
|
||||||
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
|
|
||||||
"""PUT /sessions/notebook binds an existing sessionId without
|
|
||||||
calling OpenCode. Sets it as active."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "notebook",
|
|
||||||
method="PUT",
|
|
||||||
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
|
|
||||||
body=json.dumps({}),
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["sessionId"] == "some-existing-sid"
|
|
||||||
assert payload["activeSessionId"] == "some-existing-sid"
|
|
||||||
# No OpenCode round-trip for the bind.
|
|
||||||
assert ("create_session", ...) not in [
|
|
||||||
c for c in fake.calls if c[0] == "create_session"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
|
|
||||||
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
|
|
||||||
)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
|
||||||
await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "active",
|
|
||||||
method="PUT",
|
|
||||||
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
|
|
||||||
body=json.dumps({}),
|
|
||||||
)
|
|
||||||
assert exc_info.value.code == 400
|
|
||||||
|
|
||||||
|
|
||||||
async def test_active_session_set_switches(monkeypatch, jp_fetch):
|
|
||||||
"""PUT /sessions/active successfully switches when the sessionId is bound."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
|
|
||||||
)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "active",
|
|
||||||
method="PUT",
|
|
||||||
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
|
|
||||||
body=json.dumps({}),
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["activeSessionId"] == "sid-A"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
|
|
||||||
"""DELETE /sessions/active unbinds the active session; other
|
|
||||||
bound sessions are kept; OpenCode is NOT called."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "active",
|
|
||||||
method="DELETE",
|
|
||||||
params={"notebook": "foo.ipynb"},
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["activeSessionId"] is None
|
|
||||||
# sid-A is still bound; sid-B was the active and is gone.
|
|
||||||
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
|
|
||||||
# No OpenCode call.
|
|
||||||
assert not any(c[0] == "delete_session" for c in fake.calls)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
|
|
||||||
)
|
|
||||||
sm = FakeSessionManager()
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "active",
|
|
||||||
params={"notebook": "foo.ipynb"},
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["activeSessionId"] == "sid-A"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
|
|
||||||
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
|
|
||||||
the session on OpenCode AND removes its binding."""
|
|
||||||
fake = FakeOpenCodeClient()
|
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
||||||
# Share the client with the SessionManager so the test can assert
|
|
||||||
# against `fake.calls` (the real SessionManager calls its own
|
|
||||||
# client_factory, NOT the route's make_client).
|
|
||||||
sm = FakeSessionManager(client=fake)
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: sm
|
|
||||||
)
|
|
||||||
response = await jp_fetch(
|
|
||||||
"opencode-bridge", "sessions", "notebook",
|
|
||||||
method="DELETE",
|
|
||||||
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
|
|
||||||
)
|
|
||||||
assert response.code == 200
|
|
||||||
payload = json.loads(response.body)
|
|
||||||
assert payload["deleted"] is True
|
|
||||||
# OpenCode delete was called for sid-A.
|
|
||||||
assert ("delete_session", "sid-A") in fake.calls
|
|
||||||
# sid-A is gone; sid-B remains active.
|
|
||||||
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-B"
|
|
||||||
|
|||||||
@@ -7,31 +7,13 @@ from opencode_bridge.session_manager import SessionManager
|
|||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
"""Mimics OpenCodeClient just enough for SessionManager.
|
"""Mimics OpenCodeClient just enough for SessionManager."""
|
||||||
|
def __init__(self, session_id: str = "sid-from-fake") -> None:
|
||||||
By default returns the same id for every create_session call
|
|
||||||
(matching the original legacy behavior; several pre-existing tests
|
|
||||||
rely on that). Tests that need a fresh id per call should set
|
|
||||||
``unique_ids_per_call=True`` in the constructor.
|
|
||||||
"""
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session_id: str = "sid-from-fake",
|
|
||||||
unique_ids_per_call: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self._session_id = session_id
|
self._session_id = session_id
|
||||||
self._unique_ids = unique_ids_per_call
|
|
||||||
self._next_id = 0
|
|
||||||
self.calls: list = []
|
self.calls: list = []
|
||||||
|
|
||||||
async def create_session(self, title: str) -> dict:
|
async def create_session(self, title: str) -> dict:
|
||||||
self.calls.append(("create_session", title))
|
self.calls.append(("create_session", title))
|
||||||
if self._unique_ids:
|
|
||||||
self._next_id += 1
|
|
||||||
return {
|
|
||||||
"id": "%s-%d" % (self._session_id, self._next_id),
|
|
||||||
"title": title,
|
|
||||||
}
|
|
||||||
return {"id": self._session_id, "title": title}
|
return {"id": self._session_id, "title": title}
|
||||||
|
|
||||||
async def delete_session(self, session_id: str) -> bool:
|
async def delete_session(self, session_id: str) -> bool:
|
||||||
@@ -130,7 +112,7 @@ async def test_list_sessions_returns_all_mappings():
|
|||||||
sm = SessionManager(lambda: client)
|
sm = SessionManager(lambda: client)
|
||||||
await sm.get_or_create("a.ipynb")
|
await sm.get_or_create("a.ipynb")
|
||||||
# Manually inject a second to test listing
|
# Manually inject a second to test listing
|
||||||
sm._active["b.ipynb"] = "sid-b"
|
sm._sessions["b.ipynb"] = "sid-b"
|
||||||
listing = sm.list_sessions()
|
listing = sm.list_sessions()
|
||||||
paths = {s["notebookPath"] for s in listing}
|
paths = {s["notebookPath"] for s in listing}
|
||||||
assert paths == {"a.ipynb", "b.ipynb"}
|
assert paths == {"a.ipynb", "b.ipynb"}
|
||||||
@@ -151,147 +133,3 @@ async def test_concurrent_get_or_create_does_not_double_create():
|
|||||||
assert sids[0] == sids[1] == sids[2]
|
assert sids[0] == sids[1] == sids[2]
|
||||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
||||||
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
|
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Multi-session: 1 notebook can bind N sessions, one is active
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_new_binds_and_makes_active():
|
|
||||||
client = FakeClient(unique_ids_per_call=True)
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
sid = await sm.create_new("foo.ipynb", title="custom")
|
|
||||||
assert sid == "sid-from-fake-1"
|
|
||||||
# New session is the active one and the only bound one.
|
|
||||||
assert sm.get_active("foo.ipynb") == sid
|
|
||||||
assert sm.list_sessions_for_notebook("foo.ipynb") == [sid]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_new_when_active_exists_keeps_old_bound():
|
|
||||||
client = FakeClient(unique_ids_per_call=True)
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
first = await sm.get_or_create("foo.ipynb")
|
|
||||||
second = await sm.create_new("foo.ipynb")
|
|
||||||
# Two distinct session ids.
|
|
||||||
assert first != second
|
|
||||||
assert first == "sid-from-fake-1"
|
|
||||||
assert second == "sid-from-fake-2"
|
|
||||||
# Both are bound; the new one is active.
|
|
||||||
bound = sm.list_sessions_for_notebook("foo.ipynb")
|
|
||||||
assert set(bound) == {first, second}
|
|
||||||
assert sm.get_active("foo.ipynb") == second
|
|
||||||
# get_or_create still returns the active one (does NOT auto-pick the latest).
|
|
||||||
assert await sm.get_or_create("foo.ipynb") == second
|
|
||||||
|
|
||||||
|
|
||||||
def test_bind_existing_does_not_call_opencode():
|
|
||||||
client = FakeClient()
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-from-list")
|
|
||||||
assert client.calls == [] # no OpenCode round-trip
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-from-list"
|
|
||||||
assert "sid-from-list" in sm.list_sessions_for_notebook("foo.ipynb")
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_requires_session_to_be_bound():
|
|
||||||
sm = SessionManager(lambda: FakeClient())
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
assert sm.set_active("foo.ipynb", "sid-A") is True
|
|
||||||
# Switching to a session that is NOT bound must fail.
|
|
||||||
assert sm.set_active("foo.ipynb", "sid-UNKNOWN") is False
|
|
||||||
# Active didn't change.
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-A"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unbind_removes_from_bound_and_clears_active_if_was_active():
|
|
||||||
sm = SessionManager(lambda: FakeClient())
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
# B is the active one (last bind wins). Unbind B.
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-B"
|
|
||||||
assert sm.unbind("foo.ipynb", "sid-B") is True
|
|
||||||
# Active falls back to None (no other session auto-promotes).
|
|
||||||
assert sm.get_active("foo.ipynb") is None
|
|
||||||
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unbind_non_active_leaves_active_intact():
|
|
||||||
sm = SessionManager(lambda: FakeClient())
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-B"
|
|
||||||
# Unbind A (not the active one).
|
|
||||||
assert sm.unbind("foo.ipynb", "sid-A") is True
|
|
||||||
# Active is still B.
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-B"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unbind_active_keeps_other_sessions():
|
|
||||||
sm = SessionManager(lambda: FakeClient())
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
sm.unbind_active("foo.ipynb")
|
|
||||||
assert sm.get_active("foo.ipynb") is None
|
|
||||||
# B is gone from the bound list; A remains.
|
|
||||||
assert "sid-A" in sm.list_sessions_for_notebook("foo.ipynb")
|
|
||||||
assert "sid-B" not in sm.list_sessions_for_notebook("foo.ipynb")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_session_unbinds_and_calls_opencode():
|
|
||||||
client = FakeClient()
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
deleted = await sm.delete_session("foo.ipynb", "sid-A")
|
|
||||||
assert deleted is True
|
|
||||||
# OpenCode was called.
|
|
||||||
assert any(
|
|
||||||
c[0] == "delete_session" and c[1] == "sid-A" for c in client.calls
|
|
||||||
)
|
|
||||||
# sid-A is gone from bindings; sid-B remains as active.
|
|
||||||
assert "sid-A" not in sm.list_sessions_for_notebook("foo.ipynb")
|
|
||||||
assert sm.get_active("foo.ipynb") == "sid-B"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_session_not_bound_returns_false():
|
|
||||||
client = FakeClient()
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
assert await sm.delete_session("foo.ipynb", "never-bound") is False
|
|
||||||
# No OpenCode call.
|
|
||||||
assert not any(c[0] == "delete_session" for c in client.calls)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_release_drops_all_bindings_and_deletes_active():
|
|
||||||
client = FakeClient()
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-A")
|
|
||||||
sm.bind_existing("foo.ipynb", "sid-B")
|
|
||||||
ok = await sm.release("foo.ipynb")
|
|
||||||
assert ok is True
|
|
||||||
# OpenCode delete called for the active one (sid-B).
|
|
||||||
assert any(
|
|
||||||
c[0] == "delete_session" and c[1] == "sid-B" for c in client.calls
|
|
||||||
)
|
|
||||||
# Both bindings cleared.
|
|
||||||
assert sm.list_sessions_for_notebook("foo.ipynb") == []
|
|
||||||
assert sm.get_active("foo.ipynb") is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_or_create_after_set_active_returns_the_active_one():
|
|
||||||
client = FakeClient()
|
|
||||||
sm = SessionManager(lambda: client)
|
|
||||||
first = await sm.get_or_create("foo.ipynb")
|
|
||||||
sm.bind_existing("foo.ipynb", "another-sid")
|
|
||||||
sm.set_active("foo.ipynb", "another-sid")
|
|
||||||
# Next /edit-style call returns the manually-set active session.
|
|
||||||
assert await sm.get_or_create("foo.ipynb") == "another-sid"
|
|
||||||
# No additional create_session call.
|
|
||||||
creates = [c for c in client.calls if c[0] == "create_session"]
|
|
||||||
assert len(creates) == 1 # only the initial get_or_create
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode_bridge",
|
"name": "opencode_bridge",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"description": "A JupyterLab extension.",
|
"description": "A JupyterLab extension.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"jupyter",
|
"jupyter",
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
||||||
|
import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||||
|
|
||||||
|
import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
|
||||||
|
|
||||||
|
function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return items.length;
|
||||||
|
},
|
||||||
|
get(i: number) {
|
||||||
|
return items[i];
|
||||||
|
},
|
||||||
|
// Other IObservableList methods are unused in collectErrorOutputs.
|
||||||
|
} as unknown as ICodeCellModel['outputs'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockModel(overrides: {
|
||||||
|
id?: string;
|
||||||
|
type?: 'code' | 'markdown' | 'raw';
|
||||||
|
source?: string;
|
||||||
|
outputs?: unknown[];
|
||||||
|
}): ICodeCellModel {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'cell-1',
|
||||||
|
type: overrides.type ?? 'code',
|
||||||
|
sharedModel: {
|
||||||
|
getSource: () => overrides.source ?? '',
|
||||||
|
},
|
||||||
|
outputs: makeMockOutputs(overrides.outputs ?? []),
|
||||||
|
} as unknown as ICodeCellModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
|
||||||
|
return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
|
||||||
|
return { widgets: cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockPanel(
|
||||||
|
notebook: { widgets: CodeCell[] },
|
||||||
|
path: string
|
||||||
|
): NotebookPanel {
|
||||||
|
return {
|
||||||
|
context: { path } as NotebookPanel['context'],
|
||||||
|
content: notebook as unknown as NotebookPanel['content'],
|
||||||
|
} as unknown as NotebookPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collectErrorOutputs', () => {
|
||||||
|
it('returns null when there are no outputs', () => {
|
||||||
|
const model = makeMockModel({ outputs: [] });
|
||||||
|
expect(collectErrorOutputs(model)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when outputs are non-error', () => {
|
||||||
|
const model = makeMockModel({
|
||||||
|
outputs: [{ type: 'stream', text: 'hello' }],
|
||||||
|
});
|
||||||
|
expect(collectErrorOutputs(model)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the first error output', () => {
|
||||||
|
const errOut = {
|
||||||
|
type: 'error',
|
||||||
|
ename: 'ValueError',
|
||||||
|
evalue: 'bad value',
|
||||||
|
traceback: ['line 1', 'line 2'],
|
||||||
|
};
|
||||||
|
const model = makeMockModel({ outputs: [errOut] });
|
||||||
|
const result = collectErrorOutputs(model);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ename: 'ValueError',
|
||||||
|
evalue: 'bad value',
|
||||||
|
traceback: ['line 1', 'line 2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing traceback gracefully', () => {
|
||||||
|
const model = makeMockModel({
|
||||||
|
outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
|
||||||
|
});
|
||||||
|
const result = collectErrorOutputs(model);
|
||||||
|
expect(result?.traceback).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCellContext', () => {
|
||||||
|
it('returns null when cell is not in notebook', () => {
|
||||||
|
const cell = makeMockCell('x = 1');
|
||||||
|
const other = makeMockCell('y = 2');
|
||||||
|
const notebook = makeMockNotebook([other]);
|
||||||
|
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
||||||
|
expect(extractCellContext(cell, panel)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts source, language, and indices', () => {
|
||||||
|
const cell = makeMockCell('x = 1');
|
||||||
|
const notebook = makeMockNotebook([cell]);
|
||||||
|
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
||||||
|
const ctx = extractCellContext(cell, panel);
|
||||||
|
expect(ctx).toMatchObject({
|
||||||
|
notebookPath: 'foo.ipynb',
|
||||||
|
cellId: 'cell-1',
|
||||||
|
language: 'python',
|
||||||
|
cellIndex: 0,
|
||||||
|
totalCells: 1,
|
||||||
|
source: 'x = 1',
|
||||||
|
previousCode: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the previous cell source as previousCode', () => {
|
||||||
|
const prev = makeMockCell('import os');
|
||||||
|
const cell = makeMockCell('os.getcwd()');
|
||||||
|
const notebook = makeMockNotebook([prev, cell]);
|
||||||
|
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
||||||
|
const ctx = extractCellContext(cell, panel);
|
||||||
|
expect(ctx?.previousCode).toBe('import os');
|
||||||
|
expect(ctx?.cellIndex).toBe(1);
|
||||||
|
expect(ctx?.totalCells).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the error when the cell has an error output', () => {
|
||||||
|
const cell = makeMockCell('1/0', [
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
ename: 'ZeroDivisionError',
|
||||||
|
evalue: 'division by zero',
|
||||||
|
traceback: ['Traceback...'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const notebook = makeMockNotebook([cell]);
|
||||||
|
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
||||||
|
const ctx = extractCellContext(cell, panel);
|
||||||
|
expect(ctx?.error).toEqual({
|
||||||
|
ename: 'ZeroDivisionError',
|
||||||
|
evalue: 'division by zero',
|
||||||
|
traceback: ['Traceback...'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when given null cell or panel', () => {
|
||||||
|
expect(
|
||||||
|
extractCellContext(
|
||||||
|
null as unknown as CodeCell,
|
||||||
|
makeMockPanel(makeMockNotebook([]), 'x.ipynb')
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+508
-707
File diff suppressed because it is too large
Load Diff
@@ -72,8 +72,15 @@ function mockResponse(overrides: {
|
|||||||
const sampleRequest: OpenCodeRequest = {
|
const sampleRequest: OpenCodeRequest = {
|
||||||
prompt: 'add type hints',
|
prompt: 'add type hints',
|
||||||
context: {
|
context: {
|
||||||
notebookPath: 'foo.ipynb'
|
notebookPath: 'foo.ipynb',
|
||||||
}
|
cellId: 'cell-1',
|
||||||
|
language: 'python',
|
||||||
|
cellIndex: 0,
|
||||||
|
totalCells: 1,
|
||||||
|
source: 'def foo(x): return x',
|
||||||
|
previousCode: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('callOpenCodeEdit', () => {
|
describe('callOpenCodeEdit', () => {
|
||||||
|
|||||||
+1
-228
@@ -11,14 +11,11 @@ import { URLExt } from '@jupyterlab/coreutils';
|
|||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeAllSessionsResponse,
|
|
||||||
OpenCodeEditResponse,
|
OpenCodeEditResponse,
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
OpenCodeMessagesResponse,
|
OpenCodeMessagesResponse,
|
||||||
OpenCodeNotebookSessionsResponse,
|
|
||||||
OpenCodeProvidersResponse,
|
OpenCodeProvidersResponse,
|
||||||
OpenCodeRequest,
|
OpenCodeRequest
|
||||||
OpenCodeSessionOpResponse
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,230 +215,6 @@ export async function callOpenCodeReplyQuestion(
|
|||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Multi-session management (1 notebook can bind N sessions; one is active)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function _jsonRequest<T>(
|
|
||||||
url: string,
|
|
||||||
init: RequestInit,
|
|
||||||
serverSettings: ServerConnection.ISettings,
|
|
||||||
label: string
|
|
||||||
): Promise<T> {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await ServerConnection.makeRequest(url, init, serverSettings);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
|
|
||||||
}
|
|
||||||
if (!text) {
|
|
||||||
throw new Error(`empty response from opencode-bridge/${label}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as T;
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
|
|
||||||
export async function callOpenCodeListAllSessions(
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeAllSessionsResponse> {
|
|
||||||
const url = URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'all'
|
|
||||||
);
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'GET' },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/all'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /opencode-bridge/sessions/notebook?notebook=... — sessions bound
|
|
||||||
* to this notebook (enriched with metadata, marked which is active). */
|
|
||||||
export async function callOpenCodeListNotebookSessions(
|
|
||||||
notebookPath: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeNotebookSessionsResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'notebook'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath);
|
|
||||||
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=... —
|
|
||||||
* create a brand-new session on OpenCode, bind it, set as active. */
|
|
||||||
export async function callOpenCodeCreateNotebookSession(
|
|
||||||
notebookPath: string,
|
|
||||||
title: string | undefined,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'notebook'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath) +
|
|
||||||
(title ? '&title=' + encodeURIComponent(title) : '');
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/notebook (POST)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
|
||||||
* bind an existing sessionId to this notebook (no OpenCode round-trip)
|
|
||||||
* and set it as active. */
|
|
||||||
export async function callOpenCodeBindExistingSession(
|
|
||||||
notebookPath: string,
|
|
||||||
sessionId: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'notebook'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath) +
|
|
||||||
'&sessionId=' +
|
|
||||||
encodeURIComponent(sessionId);
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/notebook (PUT)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
|
||||||
* delete a specific bound session on OpenCode AND remove its binding. */
|
|
||||||
export async function callOpenCodeDeleteNotebookSession(
|
|
||||||
notebookPath: string,
|
|
||||||
sessionId: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'notebook'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath) +
|
|
||||||
'&sessionId=' +
|
|
||||||
encodeURIComponent(sessionId);
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/notebook (DELETE)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /opencode-bridge/sessions/active?notebook=... — get the active
|
|
||||||
* sessionId (or null). */
|
|
||||||
export async function callOpenCodeGetActiveSession(
|
|
||||||
notebookPath: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'active'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath);
|
|
||||||
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=... —
|
|
||||||
* switch which bound session is active. sessionId MUST already be
|
|
||||||
* in the bound list.
|
|
||||||
*
|
|
||||||
* TODO(frontend): unused in v0.1.x — the cell-action's session
|
|
||||||
* switcher uses PUT /sessions/notebook (which does both bind and set
|
|
||||||
* active in one call). This is kept for a future "switch between
|
|
||||||
* already-bound sessions" flow that avoids the bind round-trip.
|
|
||||||
* Do NOT delete the corresponding server route without also
|
|
||||||
* removing this client function (or vice versa). */
|
|
||||||
export async function callOpenCodeSetActiveSession(
|
|
||||||
notebookPath: string,
|
|
||||||
sessionId: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'active'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath) +
|
|
||||||
'&sessionId=' +
|
|
||||||
encodeURIComponent(sessionId);
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/active (PUT)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DELETE /opencode-bridge/sessions/active?notebook=... — unbind the
|
|
||||||
* active session (next /edit lazy-creates a new one). Does NOT delete
|
|
||||||
* the session on OpenCode Serve. */
|
|
||||||
export async function callOpenCodeUnbindActiveSession(
|
|
||||||
notebookPath: string,
|
|
||||||
serverSettings: ServerConnection.ISettings
|
|
||||||
): Promise<OpenCodeSessionOpResponse> {
|
|
||||||
const url =
|
|
||||||
URLExt.join(
|
|
||||||
serverSettings.baseUrl,
|
|
||||||
'opencode-bridge',
|
|
||||||
'sessions',
|
|
||||||
'active'
|
|
||||||
) +
|
|
||||||
'?notebook=' +
|
|
||||||
encodeURIComponent(notebookPath);
|
|
||||||
return _jsonRequest(
|
|
||||||
url,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
serverSettings,
|
|
||||||
'sessions/active (DELETE)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenCodeEventHandlers {
|
export interface OpenCodeEventHandlers {
|
||||||
onEvent: (event: OpenCodeEvent) => void;
|
onEvent: (event: OpenCodeEvent) => void;
|
||||||
onError?: (err: Error) => void;
|
onError?: (err: Error) => void;
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
||||||
|
* (top-right of the active cell). Clicking it toggles an
|
||||||
|
* OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||||
|
*
|
||||||
|
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||||
|
*
|
||||||
|
* v4 message flow (matches demo.html):
|
||||||
|
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
|
||||||
|
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
|
||||||
|
* sessionId
|
||||||
|
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
|
||||||
|
* forward each event to the prompt's applyEvent() dispatcher
|
||||||
|
* 4. On session.idle we close the stream and re-enable the input
|
||||||
|
*
|
||||||
|
* The frontend does NO semantic processing of the LLM reply. The chosen
|
||||||
|
* provider/model comes from the inline picker (no settings-based default).
|
||||||
|
*/
|
||||||
|
import type { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
|
import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||||
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
|
import {
|
||||||
|
callOpenCodeEdit,
|
||||||
|
callOpenCodeReplyPermission,
|
||||||
|
callOpenCodeReplyQuestion,
|
||||||
|
callOpenCodeSessionMessages,
|
||||||
|
subscribeOpenCodeEvents,
|
||||||
|
type OpenCodeEventSubscription
|
||||||
|
} from '../api/opencode_client';
|
||||||
|
import { extractCellContext } from '../context/cell_context';
|
||||||
|
import type {
|
||||||
|
CellContext,
|
||||||
|
OpenCodeProvidersResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||||
|
|
||||||
|
// Module-level runtime injection (set by index.ts after activation).
|
||||||
|
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||||
|
let _providers: OpenCodeProvidersResponse | null = null;
|
||||||
|
|
||||||
|
export function setOpenCodeServerSettings(
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): void {
|
||||||
|
_serverSettings = serverSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOpenCodeProviders(
|
||||||
|
p: OpenCodeProvidersResponse | null
|
||||||
|
): void {
|
||||||
|
_providers = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading';
|
||||||
|
|
||||||
|
export class OpenCodeCellActions extends Widget {
|
||||||
|
private _cell: CodeCell;
|
||||||
|
private _context: CellContext | null = null;
|
||||||
|
private _status: Status = 'idle';
|
||||||
|
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||||
|
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||||
|
|
||||||
|
constructor(cell: CodeCell) {
|
||||||
|
super();
|
||||||
|
this._cell = cell;
|
||||||
|
this.addClass('opencode-cell-actions');
|
||||||
|
this._cell.model.contentChanged.connect(this._onModelChange, this);
|
||||||
|
this._cell.model.stateChanged.connect(this._onModelChange, this);
|
||||||
|
this._render();
|
||||||
|
this._onModelChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._closeSse();
|
||||||
|
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||||
|
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||||
|
this._hidePrompt();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onModelChange(): void {
|
||||||
|
this._context = extractCellContextFromCell(this._cell);
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(!this._context || this._status === 'loading');
|
||||||
|
} else {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _render(): void {
|
||||||
|
const node = this.node;
|
||||||
|
node.textContent = '';
|
||||||
|
|
||||||
|
const baseDisabled = !this._context || this._status === 'loading';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'opencode-btn opencode-btn-ai';
|
||||||
|
btn.textContent = 'AI';
|
||||||
|
btn.disabled = baseDisabled;
|
||||||
|
btn.title = baseDisabled
|
||||||
|
? 'OpenCode: 等待 cell 上下文…'
|
||||||
|
: '让 AI 修改这个 cell 的代码';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this._togglePrompt();
|
||||||
|
});
|
||||||
|
node.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _togglePrompt(): void {
|
||||||
|
if (this._prompt) {
|
||||||
|
this._hidePrompt();
|
||||||
|
} else {
|
||||||
|
this._showPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showPrompt(): void {
|
||||||
|
if (this._prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||||
|
disabled: !this._context || this._status === 'loading',
|
||||||
|
providers: _providers,
|
||||||
|
notebookPath: this._context?.notebookPath,
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
|
void this._onSubmit(text, providerId, modelId);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this._hidePrompt();
|
||||||
|
},
|
||||||
|
onPermissionReply: async (permId, response) => {
|
||||||
|
if (!_serverSettings) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||||
|
if (!sessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeReplyPermission(
|
||||||
|
sessionId,
|
||||||
|
permId,
|
||||||
|
response,
|
||||||
|
_serverSettings
|
||||||
|
);
|
||||||
|
return r.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: permission reply failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onQuestionReply: async (qId, answer) => {
|
||||||
|
if (!_serverSettings) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||||
|
if (!sessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeReplyQuestion(
|
||||||
|
sessionId,
|
||||||
|
qId,
|
||||||
|
answer,
|
||||||
|
_serverSettings
|
||||||
|
);
|
||||||
|
return r.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: question reply failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStreamEnd: () => {
|
||||||
|
// Centralized: the prompt detected "idle" (any shape) and tells
|
||||||
|
// us to close the SSE stream + restore the input. We no longer
|
||||||
|
// parse event types here.
|
||||||
|
this._closeSse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Widget.attach(prompt, this._cell.node);
|
||||||
|
this._prompt = prompt;
|
||||||
|
|
||||||
|
// Fetch the current session's static history and render it before
|
||||||
|
// streaming begins. Empty list is a normal no-op.
|
||||||
|
const notebookPath = this._context?.notebookPath;
|
||||||
|
if (notebookPath) {
|
||||||
|
void this._refreshHistory(notebookPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||||
|
if (!_serverSettings || !this._prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await callOpenCodeSessionMessages(
|
||||||
|
notebookPath,
|
||||||
|
_serverSettings
|
||||||
|
);
|
||||||
|
this._prompt.setMessages(resp.messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hidePrompt(): void {
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.dispose();
|
||||||
|
this._prompt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onSubmit(
|
||||||
|
text: string,
|
||||||
|
providerId?: string,
|
||||||
|
modelId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this._context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_serverSettings) {
|
||||||
|
Notification.error('OpenCode 运行时未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
prompt: text,
|
||||||
|
context: this._context,
|
||||||
|
providerId,
|
||||||
|
modelId
|
||||||
|
};
|
||||||
|
|
||||||
|
this._status = 'loading';
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(true);
|
||||||
|
this._prompt.setSessionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await callOpenCodeEdit(request, _serverSettings);
|
||||||
|
} catch (e) {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async edit accepted. Bind the prompt to the returned session, clear
|
||||||
|
// the input, then subscribe to the SSE event stream for this session.
|
||||||
|
this._prompt?.setSessionId(resp.sessionId);
|
||||||
|
this._prompt?.clearInput();
|
||||||
|
this._sseSub = subscribeOpenCodeEvents(
|
||||||
|
resp.sessionId,
|
||||||
|
_serverSettings,
|
||||||
|
{
|
||||||
|
onEvent: ev => {
|
||||||
|
this._prompt?.applyEvent(ev);
|
||||||
|
// Stream-end is detected by the prompt and signalled via
|
||||||
|
// onStreamEnd (see _showPrompt); no idle parsing here.
|
||||||
|
},
|
||||||
|
onError: err => {
|
||||||
|
console.warn('opencode_bridge: SSE error', err);
|
||||||
|
// Don't flip status here: the server may just have hiccuped.
|
||||||
|
// session.idle (if it comes through) will re-enable the input.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closeSse(): void {
|
||||||
|
if (this._sseSub) {
|
||||||
|
this._sseSub.close();
|
||||||
|
this._sseSub = null;
|
||||||
|
}
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||||
|
* then build the CellContext.
|
||||||
|
*/
|
||||||
|
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||||
|
let node: Widget | null = cell.parent;
|
||||||
|
let notebookPanel: NotebookPanel | null = null;
|
||||||
|
while (node) {
|
||||||
|
const candidate = node as any;
|
||||||
|
if (
|
||||||
|
candidate.context &&
|
||||||
|
candidate.content &&
|
||||||
|
Array.isArray(candidate.content.widgets)
|
||||||
|
) {
|
||||||
|
notebookPanel = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = node.parent;
|
||||||
|
}
|
||||||
|
if (!notebookPanel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return extractCellContext(cell, notebookPanel);
|
||||||
|
}
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
/**
|
|
||||||
* Global floating AI button + panel, mounted on document.body. Replaces
|
|
||||||
* the per-cell toolbar AI button (formerly OpenCodeCellActions). Click
|
|
||||||
* the round bottom-right button to expand the OpenCodeInlinePrompt; click
|
|
||||||
* again to collapse it.
|
|
||||||
*
|
|
||||||
* The panel operates on the JupyterLab shell's currently-active
|
|
||||||
* NotebookPanel — the (notebookPath, activeCell) pair is resolved at
|
|
||||||
* panel-open time from `app.shell.currentWidget`, and re-resolved when
|
|
||||||
* `app.shell.currentChanged` fires while the panel is open.
|
|
||||||
*
|
|
||||||
* All session/SSE/history state that used to live on
|
|
||||||
* OpenCodeCellActions now lives here. The prompt is created lazily on
|
|
||||||
* the first panel open and disposed on close (recreated on every
|
|
||||||
* re-open so the user always sees a fresh history fetch).
|
|
||||||
*/
|
|
||||||
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
|
||||||
import type { CodeCell } from '@jupyterlab/cells';
|
|
||||||
import { ServerConnection } from '@jupyterlab/services';
|
|
||||||
import { Widget } from '@lumino/widgets';
|
|
||||||
|
|
||||||
import {
|
|
||||||
callOpenCodeBindExistingSession,
|
|
||||||
callOpenCodeCreateNotebookSession,
|
|
||||||
callOpenCodeEdit,
|
|
||||||
callOpenCodeListAllSessions,
|
|
||||||
callOpenCodeReplyPermission,
|
|
||||||
callOpenCodeReplyQuestion,
|
|
||||||
callOpenCodeSessionMessages,
|
|
||||||
subscribeOpenCodeEvents,
|
|
||||||
type OpenCodeEventSubscription
|
|
||||||
} from '../api/opencode_client';
|
|
||||||
import type { OpenCodeProvidersResponse } from '../types';
|
|
||||||
|
|
||||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
|
||||||
|
|
||||||
export interface IOpenCodeFloatingPanelOptions {
|
|
||||||
app: JupyterFrontEnd;
|
|
||||||
serverSettings: ServerConnection.ISettings;
|
|
||||||
providers: OpenCodeProvidersResponse | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Status = 'idle' | 'loading';
|
|
||||||
|
|
||||||
export class OpenCodeFloatingPanel extends Widget {
|
|
||||||
private _app: JupyterFrontEnd;
|
|
||||||
private _serverSettings: ServerConnection.ISettings | null;
|
|
||||||
private _providers: OpenCodeProvidersResponse | null;
|
|
||||||
private _button: HTMLButtonElement;
|
|
||||||
private _panelEl: HTMLDivElement;
|
|
||||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
|
||||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
|
||||||
private _notebookPath: string | null = null;
|
|
||||||
private _status: Status = 'idle';
|
|
||||||
|
|
||||||
constructor(options: IOpenCodeFloatingPanelOptions) {
|
|
||||||
super();
|
|
||||||
this._app = options.app;
|
|
||||||
this._serverSettings = options.serverSettings;
|
|
||||||
this._providers = options.providers;
|
|
||||||
this.addClass('opencode-floating-root');
|
|
||||||
this.node.style.position = 'fixed';
|
|
||||||
this.node.style.bottom = '24px';
|
|
||||||
this.node.style.right = '24px';
|
|
||||||
this.node.style.zIndex = '1000';
|
|
||||||
|
|
||||||
this._button = document.createElement('button');
|
|
||||||
this._button.className = 'opencode-floating-button';
|
|
||||||
this._button.textContent = '🪄';
|
|
||||||
this._button.title = 'OpenCode AI';
|
|
||||||
this._button.addEventListener('click', () => this.toggle());
|
|
||||||
this.node.appendChild(this._button);
|
|
||||||
|
|
||||||
this._panelEl = document.createElement('div');
|
|
||||||
this._panelEl.className = 'opencode-floating-panel';
|
|
||||||
this._panelEl.hidden = true;
|
|
||||||
this.node.appendChild(this._panelEl);
|
|
||||||
|
|
||||||
// JupyterFrontEnd.shell is typed as `JupyterShell | null` in some
|
|
||||||
// JupyterLab versions; cast through any to satisfy strict null checks
|
|
||||||
// while keeping the public API simple.
|
|
||||||
(this._app.shell as any).currentChanged.connect(this._onShellChanged, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.isDisposed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
(this._app.shell as any).currentChanged.disconnect(
|
|
||||||
this._onShellChanged,
|
|
||||||
this
|
|
||||||
);
|
|
||||||
this._closeSse();
|
|
||||||
this._hidePrompt();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update the providers payload after the initial async fetch. */
|
|
||||||
setProviders(p: OpenCodeProvidersResponse | null): void {
|
|
||||||
this._providers = p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle the panel open/closed. */
|
|
||||||
toggle(): void {
|
|
||||||
if (this._panelEl.hidden) {
|
|
||||||
this._showPrompt();
|
|
||||||
} else {
|
|
||||||
this._hidePrompt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onShellChanged = (): void => {
|
|
||||||
// The active notebook changed. If the panel is open, refresh the
|
|
||||||
// underlying prompt so the (provider, model) selection, history,
|
|
||||||
// and active-cell context all reflect the new notebook.
|
|
||||||
//
|
|
||||||
// Implemented as an arrow-function class field (not a method) so
|
|
||||||
// `this` is bound regardless of how the signal's slot invokes us.
|
|
||||||
if (!this._panelEl.hidden) {
|
|
||||||
this._hidePrompt();
|
|
||||||
this._showPrompt();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private _resolveNotebookContext(): {
|
|
||||||
notebookPath: string | null;
|
|
||||||
getActiveCell: () => CodeCell | null;
|
|
||||||
} {
|
|
||||||
const widget = this._app.shell.currentWidget as any;
|
|
||||||
const notebookPath =
|
|
||||||
widget && widget.context && typeof widget.context.path === 'string'
|
|
||||||
? (widget.context.path as string)
|
|
||||||
: null;
|
|
||||||
const getActiveCell = (): CodeCell | null => {
|
|
||||||
const cur = this._app.shell.currentWidget as any;
|
|
||||||
if (!cur || !cur.content) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const active = cur.content.activeCell;
|
|
||||||
return (active as CodeCell) || null;
|
|
||||||
};
|
|
||||||
return { notebookPath, getActiveCell };
|
|
||||||
}
|
|
||||||
|
|
||||||
private _showPrompt(): void {
|
|
||||||
if (this._prompt) {
|
|
||||||
this._panelEl.hidden = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { notebookPath, getActiveCell } = this._resolveNotebookContext();
|
|
||||||
this._notebookPath = notebookPath;
|
|
||||||
const prompt = new OpenCodeInlinePrompt({
|
|
||||||
disabled: !this._notebookPath || this._status === 'loading',
|
|
||||||
providers: this._providers,
|
|
||||||
notebookPath: this._notebookPath ?? undefined,
|
|
||||||
getActiveCell,
|
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
|
||||||
void this._onSubmit(text, providerId, modelId);
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
this._hidePrompt();
|
|
||||||
},
|
|
||||||
onPermissionReply: async (permId, response) => {
|
|
||||||
if (!this._serverSettings) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
|
||||||
if (!sessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeReplyPermission(
|
|
||||||
sessionId,
|
|
||||||
permId,
|
|
||||||
response,
|
|
||||||
this._serverSettings
|
|
||||||
);
|
|
||||||
return r.ok;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: permission reply failed', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onQuestionReply: async (qId, answer) => {
|
|
||||||
if (!this._serverSettings) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
|
||||||
if (!sessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeReplyQuestion(
|
|
||||||
sessionId,
|
|
||||||
qId,
|
|
||||||
answer,
|
|
||||||
this._serverSettings
|
|
||||||
);
|
|
||||||
return r.ok;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: question reply failed', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStreamEnd: () => {
|
|
||||||
// Centralized: the prompt detected "idle" and signals us to
|
|
||||||
// close the SSE stream + restore the input.
|
|
||||||
this._closeSse();
|
|
||||||
},
|
|
||||||
onListSessions: async () => {
|
|
||||||
if (!this._serverSettings) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeListAllSessions(this._serverSettings);
|
|
||||||
return r.sessions || [];
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: list all sessions failed', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateSession: async () => {
|
|
||||||
if (!this._serverSettings || !this._notebookPath) {
|
|
||||||
throw new Error('notebook not ready');
|
|
||||||
}
|
|
||||||
this._closeSse();
|
|
||||||
const r = await callOpenCodeCreateNotebookSession(
|
|
||||||
this._notebookPath,
|
|
||||||
undefined,
|
|
||||||
this._serverSettings
|
|
||||||
);
|
|
||||||
if (!r.ok || !r.sessionId) {
|
|
||||||
throw new Error(r.error || 'create_session failed');
|
|
||||||
}
|
|
||||||
return r.sessionId;
|
|
||||||
},
|
|
||||||
onSwitchSession: async (sessionId: string) => {
|
|
||||||
if (!this._serverSettings || !this._notebookPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this._closeSse();
|
|
||||||
// PUT /sessions/notebook binds AND sets active in one call.
|
|
||||||
const r = await callOpenCodeBindExistingSession(
|
|
||||||
this._notebookPath,
|
|
||||||
sessionId,
|
|
||||||
this._serverSettings
|
|
||||||
);
|
|
||||||
return !!r.ok;
|
|
||||||
},
|
|
||||||
onReloadHistory: async () => {
|
|
||||||
if (!this._notebookPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this._refreshHistory(this._notebookPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Use Widget.attach (not raw appendChild) so Lumino sends the
|
|
||||||
// before-attach / after-attach messages — the prompt relies on
|
|
||||||
// these for isAttached/isVisible tracking. The defensive DOM
|
|
||||||
// cleanup in _hidePrompt handles the case where dispose() leaves
|
|
||||||
// a residual node behind (the panel host is an HTMLElement, not a
|
|
||||||
// Widget, so the Lumino parent stays null and dispose() can't
|
|
||||||
// always clean up the DOM on its own).
|
|
||||||
Widget.attach(prompt, this._panelEl);
|
|
||||||
this._prompt = prompt;
|
|
||||||
|
|
||||||
// Load the current session's static history before streaming starts.
|
|
||||||
if (this._notebookPath) {
|
|
||||||
void this._refreshHistory(this._notebookPath);
|
|
||||||
}
|
|
||||||
prompt.initialize();
|
|
||||||
this._panelEl.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
|
||||||
if (!this._serverSettings || !this._prompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await callOpenCodeSessionMessages(
|
|
||||||
notebookPath,
|
|
||||||
this._serverSettings
|
|
||||||
);
|
|
||||||
this._prompt.setMessages(resp.messages);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _hidePrompt(): void {
|
|
||||||
this._closeSse();
|
|
||||||
if (this._prompt) {
|
|
||||||
const node = this._prompt.node;
|
|
||||||
this._prompt.dispose();
|
|
||||||
// Defensive DOM cleanup: the prompt is attached via raw DOM
|
|
||||||
// (appendChild/Widget.attach with an HTMLElement host), so its
|
|
||||||
// Lumino parent is null. In some scenarios — for example when
|
|
||||||
// the dispose path runs synchronously while another show+hide
|
|
||||||
// cycle is already in flight — the dispose() call does not
|
|
||||||
// remove the node from this._panelEl, leaving a residual node
|
|
||||||
// that the next _showPrompt appends to. Without this defensive
|
|
||||||
// removeChild, the user would see the panel content multiply
|
|
||||||
// with every click.
|
|
||||||
if (node.parentNode === this._panelEl) {
|
|
||||||
this._panelEl.removeChild(node);
|
|
||||||
}
|
|
||||||
this._prompt = null;
|
|
||||||
}
|
|
||||||
this._panelEl.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _onSubmit(
|
|
||||||
text: string,
|
|
||||||
providerId?: string,
|
|
||||||
modelId?: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this._notebookPath) {
|
|
||||||
Notification.info('请先打开一个 notebook');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this._serverSettings) {
|
|
||||||
Notification.error('OpenCode 运行时未初始化');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
prompt: text,
|
|
||||||
context: { notebookPath: this._notebookPath },
|
|
||||||
providerId,
|
|
||||||
modelId
|
|
||||||
};
|
|
||||||
|
|
||||||
this._status = 'loading';
|
|
||||||
if (this._prompt) {
|
|
||||||
this._prompt.setDisabled(true);
|
|
||||||
this._prompt.setSessionId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await callOpenCodeEdit(request, this._serverSettings);
|
|
||||||
} catch (e) {
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._prompt?.setSessionId(resp.sessionId);
|
|
||||||
this._prompt?.clearInput();
|
|
||||||
this._sseSub = subscribeOpenCodeEvents(
|
|
||||||
resp.sessionId,
|
|
||||||
this._serverSettings,
|
|
||||||
{
|
|
||||||
onEvent: ev => {
|
|
||||||
this._prompt?.applyEvent(ev);
|
|
||||||
},
|
|
||||||
onError: err => {
|
|
||||||
console.warn('opencode_bridge: SSE error', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closeSse(): void {
|
|
||||||
if (this._sseSub) {
|
|
||||||
this._sseSub.close();
|
|
||||||
this._sseSub = null;
|
|
||||||
}
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* history area. The user can still Copy / Insert / Replace individual
|
* history area. The user can still Copy / Insert / Replace individual
|
||||||
* code blocks from a finished assistant message.
|
* code blocks from a finished assistant message.
|
||||||
*/
|
*/
|
||||||
import type { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
@@ -28,10 +28,12 @@ import { marked } from 'marked';
|
|||||||
import type {
|
import type {
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
OpenCodeMessage,
|
OpenCodeMessage,
|
||||||
OpenCodeProvidersResponse,
|
OpenCodeProvidersResponse
|
||||||
OpenCodeSessionMeta
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { loadModelSelection, saveModelSelection } from './model_selection';
|
import {
|
||||||
|
loadModelSelection,
|
||||||
|
saveModelSelection
|
||||||
|
} from './model_selection';
|
||||||
|
|
||||||
export interface IOpenCodeInlinePromptOptions {
|
export interface IOpenCodeInlinePromptOptions {
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||||
@@ -45,16 +47,6 @@ export interface IOpenCodeInlinePromptOptions {
|
|||||||
* to the default selection (first provider / `default[pid]` model).
|
* to the default selection (first provider / `default[pid]` model).
|
||||||
*/
|
*/
|
||||||
notebookPath?: string;
|
notebookPath?: string;
|
||||||
/**
|
|
||||||
* Return the currently active CodeCell, used by the cell-edit actions
|
|
||||||
* ("📋 插入单元格内容" / "插入到光标" / "替换单元格"). When the host is
|
|
||||||
* decoupled from a specific cell (e.g. a global floating panel that
|
|
||||||
* targets whatever cell the user last focused), it passes a lambda
|
|
||||||
* that resolves the active cell from the JupyterLab shell at click
|
|
||||||
* time. Returns null if no cell is currently selectable — the prompt
|
|
||||||
* then surfaces a "请先选中一个 cell" notification instead of acting.
|
|
||||||
*/
|
|
||||||
getActiveCell?: () => CodeCell | null;
|
|
||||||
/**
|
/**
|
||||||
* Respond to a `permission.asked` event. Returns a promise that resolves
|
* Respond to a `permission.asked` event. Returns a promise that resolves
|
||||||
* to true on success, false on failure (the prompt updates the UI in
|
* to true on success, false on failure (the prompt updates the UI in
|
||||||
@@ -73,19 +65,6 @@ export interface IOpenCodeInlinePromptOptions {
|
|||||||
* the prompt keeps the cell action from re-parsing event types.
|
* the prompt keeps the cell action from re-parsing event types.
|
||||||
*/
|
*/
|
||||||
onStreamEnd?: () => void;
|
onStreamEnd?: () => void;
|
||||||
// ----- session management (multi-session UI) -----
|
|
||||||
/** List ALL OpenCode sessions (for the session-picker dropdown).
|
|
||||||
* Returns the raw array; the prompt picks the active one out. */
|
|
||||||
onListSessions?: () => Promise<OpenCodeSessionMeta[]>;
|
|
||||||
/** Create a brand-new session, bind it to the current notebook, and
|
|
||||||
* return its id (which becomes the new active session). */
|
|
||||||
onCreateSession?: () => Promise<string>;
|
|
||||||
/** Switch the active session. Returns true on success. */
|
|
||||||
onSwitchSession?: (sessionId: string) => Promise<boolean>;
|
|
||||||
/** Reload the message history for the currently-active session.
|
|
||||||
* Called after a create / switch so the panel reflects the new
|
|
||||||
* conversation's scrollback. */
|
|
||||||
onReloadHistory?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlatProvider {
|
interface FlatProvider {
|
||||||
@@ -94,16 +73,12 @@ interface FlatProvider {
|
|||||||
models: { [modelID: string]: unknown };
|
models: { [modelID: string]: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenProviders(providers: OpenCodeProvidersResponse | null): {
|
function flattenProviders(
|
||||||
providers: FlatProvider[];
|
providers: OpenCodeProvidersResponse | null
|
||||||
defaultMap: { [pid: string]: string };
|
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
||||||
} {
|
|
||||||
const out: FlatProvider[] = [];
|
const out: FlatProvider[] = [];
|
||||||
if (!providers || !providers.providers) {
|
if (!providers || !providers.providers) {
|
||||||
return {
|
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
||||||
providers: out,
|
|
||||||
defaultMap: (providers && providers.default) || {}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
for (const p of providers.providers) {
|
for (const p of providers.providers) {
|
||||||
if (!p.models || typeof p.models !== 'object') {
|
if (!p.models || typeof p.models !== 'object') {
|
||||||
@@ -128,10 +103,7 @@ function pickDefaultModelId(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const fromDefault = defaultMap[providerId];
|
const fromDefault = defaultMap[providerId];
|
||||||
if (
|
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
||||||
fromDefault &&
|
|
||||||
Object.prototype.hasOwnProperty.call(models, fromDefault)
|
|
||||||
) {
|
|
||||||
return fromDefault;
|
return fromDefault;
|
||||||
}
|
}
|
||||||
return keys[0];
|
return keys[0];
|
||||||
@@ -143,11 +115,11 @@ interface BlockEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeInlinePrompt extends Widget {
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
|
private _cell: CodeCell;
|
||||||
private _options: IOpenCodeInlinePromptOptions;
|
private _options: IOpenCodeInlinePromptOptions;
|
||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
private _cancelBtn: HTMLButtonElement;
|
private _cancelBtn: HTMLButtonElement;
|
||||||
private _insertCellBtn: HTMLButtonElement;
|
|
||||||
private _providerSelect: HTMLSelectElement | null = null;
|
private _providerSelect: HTMLSelectElement | null = null;
|
||||||
private _modelSelect: HTMLSelectElement | null = null;
|
private _modelSelect: HTMLSelectElement | null = null;
|
||||||
private _providerModels: FlatProvider[];
|
private _providerModels: FlatProvider[];
|
||||||
@@ -157,10 +129,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// Streaming state. _sessionId is set by the cell action after a successful
|
// Streaming state. _sessionId is set by the cell action after a successful
|
||||||
// /edit response; applyEvent() drops events that don't belong to it.
|
// /edit response; applyEvent() drops events that don't belong to it.
|
||||||
private _sessionId: string | null = null;
|
private _sessionId: string | null = null;
|
||||||
// Multi-session UI: the dropdown and its row (null when the host
|
|
||||||
// did not wire up the session callbacks).
|
|
||||||
private _sessionRow: HTMLDivElement | null = null;
|
|
||||||
private _sessionSelect: HTMLSelectElement | null = null;
|
|
||||||
// The current assistant message DOM node (re-rendered as markdown on
|
// The current assistant message DOM node (re-rendered as markdown on
|
||||||
// every text delta so the user sees a properly formatted response in
|
// every text delta so the user sees a properly formatted response in
|
||||||
// real-time, not raw ```fence``` source).
|
// real-time, not raw ```fence``` source).
|
||||||
@@ -173,8 +141,12 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
||||||
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
||||||
|
|
||||||
constructor(options: IOpenCodeInlinePromptOptions) {
|
constructor(
|
||||||
|
cell: CodeCell,
|
||||||
|
options: IOpenCodeInlinePromptOptions
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
this._cell = cell;
|
||||||
this._options = options;
|
this._options = options;
|
||||||
this.addClass('opencode-inline-prompt');
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
@@ -234,7 +206,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
const modelId = this._modelSelect?.value || undefined;
|
const modelId = this._modelSelect?.value || undefined;
|
||||||
options.onSubmit(text, providerId, modelId);
|
options.onSubmit(text, providerId, modelId);
|
||||||
});
|
});
|
||||||
this._textarea.addEventListener('keydown', e => {
|
this._textarea.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._sendBtn.click();
|
this._sendBtn.click();
|
||||||
@@ -247,86 +219,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
options.onCancel();
|
options.onCancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// "📋 插入单元格内容" button: appends the current cell's source
|
|
||||||
// (wrapped in a markdown code fence) to the textarea. Lets the
|
|
||||||
// user explicitly opt-in to including the cell content in the
|
|
||||||
// LLM prompt — the cell is no longer auto-injected.
|
|
||||||
this._insertCellBtn = document.createElement('button');
|
|
||||||
this._insertCellBtn.type = 'button';
|
|
||||||
this._insertCellBtn.className = 'opencode-btn-insert-cell';
|
|
||||||
this._insertCellBtn.textContent = '📋 插入单元格内容';
|
|
||||||
this._insertCellBtn.title =
|
|
||||||
'把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
|
||||||
this._insertCellBtn.addEventListener('click', () => {
|
|
||||||
this._insertCellSource();
|
|
||||||
});
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'opencode-inline-actions';
|
actions.className = 'opencode-inline-actions';
|
||||||
actions.appendChild(this._insertCellBtn);
|
|
||||||
actions.appendChild(this._sendBtn);
|
actions.appendChild(this._sendBtn);
|
||||||
actions.appendChild(this._cancelBtn);
|
actions.appendChild(this._cancelBtn);
|
||||||
|
|
||||||
// Session selector (top of the panel, above provider/model).
|
|
||||||
// Only rendered when the host has provided the multi-session
|
|
||||||
// callbacks. The active session id is the option currently
|
|
||||||
// selected; switching the dropdown calls onSwitchSession, picking
|
|
||||||
// "+ 新建会话" calls onCreateSession, and the refresh button
|
|
||||||
// re-fetches the global session list.
|
|
||||||
if (
|
|
||||||
this._options.onListSessions &&
|
|
||||||
this._options.onCreateSession &&
|
|
||||||
this._options.onSwitchSession
|
|
||||||
) {
|
|
||||||
this._sessionRow = document.createElement('div');
|
|
||||||
this._sessionRow.className = 'opencode-prompt-sessions';
|
|
||||||
|
|
||||||
const sLabel = document.createElement('label');
|
|
||||||
const sSpan = document.createElement('span');
|
|
||||||
sSpan.textContent = 'Session:';
|
|
||||||
sLabel.appendChild(sSpan);
|
|
||||||
|
|
||||||
this._sessionSelect = document.createElement('select');
|
|
||||||
this._sessionSelect.className = 'opencode-session-select';
|
|
||||||
// Single placeholder until the list loads.
|
|
||||||
this._sessionSelect.appendChild(
|
|
||||||
this._mkSessionOption('', '加载中…', false)
|
|
||||||
);
|
|
||||||
this._sessionSelect.disabled = true;
|
|
||||||
this._sessionSelect.addEventListener('change', () => {
|
|
||||||
const v = this._sessionSelect!.value;
|
|
||||||
if (v === '__new__') {
|
|
||||||
void this._handleCreateSession();
|
|
||||||
} else if (v && v !== this._sessionId) {
|
|
||||||
void this._handleSwitchSession(v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sLabel.appendChild(this._sessionSelect);
|
|
||||||
this._sessionRow.appendChild(sLabel);
|
|
||||||
|
|
||||||
const newBtn = document.createElement('button');
|
|
||||||
newBtn.type = 'button';
|
|
||||||
newBtn.className = 'opencode-session-btn-new';
|
|
||||||
newBtn.textContent = '+ 新建';
|
|
||||||
newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook';
|
|
||||||
newBtn.addEventListener('click', () => {
|
|
||||||
void this._handleCreateSession();
|
|
||||||
});
|
|
||||||
this._sessionRow.appendChild(newBtn);
|
|
||||||
|
|
||||||
const refreshBtn = document.createElement('button');
|
|
||||||
refreshBtn.type = 'button';
|
|
||||||
refreshBtn.className = 'opencode-session-btn-refresh';
|
|
||||||
refreshBtn.textContent = '🔄';
|
|
||||||
refreshBtn.title = '刷新 OpenCode 全局会话列表';
|
|
||||||
refreshBtn.addEventListener('click', () => {
|
|
||||||
void this._refreshSessionList();
|
|
||||||
});
|
|
||||||
this._sessionRow.appendChild(refreshBtn);
|
|
||||||
|
|
||||||
this.node.appendChild(this._sessionRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._providerSelect || this._modelSelect) {
|
if (this._providerSelect || this._modelSelect) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'opencode-prompt-providers';
|
row.className = 'opencode-prompt-providers';
|
||||||
@@ -357,15 +254,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
|
|
||||||
this.node.appendChild(this._textarea);
|
this.node.appendChild(this._textarea);
|
||||||
this.node.appendChild(actions);
|
this.node.appendChild(actions);
|
||||||
|
|
||||||
// Apply the initial disabled state to ALL interactive elements
|
|
||||||
// (provider/model selects, send button, AND the insert-cell
|
|
||||||
// button — the last one depends on `getActiveCell()` which only
|
|
||||||
// resolves correctly here in the constructor). Without this, the
|
|
||||||
// insert button would stay enabled until the first external
|
|
||||||
// setDisabled() call, which is too late for the user's first
|
|
||||||
// click.
|
|
||||||
this.setDisabled(options.disabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _rebuildModelSelect(
|
private _rebuildModelSelect(
|
||||||
@@ -442,156 +330,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return sel;
|
return sel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- session selector (multi-session UI) ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repopulate the session dropdown with the given list. The currently
|
|
||||||
* active session (this._sessionId, set by setSessionId or by
|
|
||||||
* _handleSwitchSession) is marked selected.
|
|
||||||
*/
|
|
||||||
private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void {
|
|
||||||
if (!this._sessionSelect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Clear existing options (drop the loading placeholder).
|
|
||||||
while (this._sessionSelect.firstChild) {
|
|
||||||
this._sessionSelect.removeChild(this._sessionSelect.firstChild);
|
|
||||||
}
|
|
||||||
for (const s of sessions) {
|
|
||||||
const label = s.title
|
|
||||||
? `${s.title} (${s.id.slice(0, 8)}…)`
|
|
||||||
: `${s.id.slice(0, 8)}…`;
|
|
||||||
this._sessionSelect.appendChild(
|
|
||||||
this._mkSessionOption(s.id, label, s.id === this._sessionId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Always-available "create new" sentinel.
|
|
||||||
this._sessionSelect.appendChild(
|
|
||||||
this._mkSessionOption('__new__', '+ 新建会话…', false)
|
|
||||||
);
|
|
||||||
// Reflect the current active id (or "" if none).
|
|
||||||
this._sessionSelect.value = this._sessionId || '';
|
|
||||||
if (!this._sessionId) {
|
|
||||||
// No active session; show a hint via the first option being empty.
|
|
||||||
this._sessionSelect.value = '';
|
|
||||||
}
|
|
||||||
this._sessionSelect.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _mkSessionOption(
|
|
||||||
value: string,
|
|
||||||
label: string,
|
|
||||||
selected: boolean
|
|
||||||
): HTMLOptionElement {
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = value;
|
|
||||||
o.textContent = label;
|
|
||||||
if (selected) {
|
|
||||||
o.selected = true;
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the global session list from the host and repopulate the
|
|
||||||
* dropdown. Called on prompt show, on "刷新" click, and after
|
|
||||||
* every create / switch. Errors are non-fatal — the dropdown just
|
|
||||||
* keeps whatever it had (or stays empty).
|
|
||||||
*/
|
|
||||||
async _refreshSessionList(): Promise<void> {
|
|
||||||
if (!this._options.onListSessions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const sessions = await this._options.onListSessions();
|
|
||||||
this._populateSessionSelect(sessions);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: failed to list sessions', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "新建" button or dropdown's __new__ entry. Creates a session,
|
|
||||||
* binds it, sets as active, refreshes the dropdown, and reloads the
|
|
||||||
* message history for the new (empty) session.
|
|
||||||
*/
|
|
||||||
private async _handleCreateSession(): Promise<void> {
|
|
||||||
if (!this._options.onCreateSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.disabled = true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const newId = await this._options.onCreateSession();
|
|
||||||
// setSessionId() handles _sessionId + dropdown + reset pointers
|
|
||||||
// in one call; avoids duplicate work in this handler.
|
|
||||||
this.setSessionId(newId);
|
|
||||||
this._historyEl.textContent = '';
|
|
||||||
// Re-fetch the global list so the new id is in the dropdown
|
|
||||||
// and marked active.
|
|
||||||
await this._refreshSessionList();
|
|
||||||
// Reload history for the new session (empty list, but this
|
|
||||||
// also re-syncs the prompt's view of "current state").
|
|
||||||
if (this._options.onReloadHistory) {
|
|
||||||
try {
|
|
||||||
await this._options.onReloadHistory();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: reload history failed', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Notification.error('创建会话失败: ' + (e as Error).message);
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User picked a different session from the dropdown. Asks the host
|
|
||||||
* to bind + set active; on success, refreshes history.
|
|
||||||
*/
|
|
||||||
private async _handleSwitchSession(sessionId: string): Promise<void> {
|
|
||||||
if (!this._options.onSwitchSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.disabled = true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const ok = await this._options.onSwitchSession(sessionId);
|
|
||||||
if (ok) {
|
|
||||||
// setSessionId() handles _sessionId + dropdown + reset
|
|
||||||
// pointers in one call; avoids duplicate work.
|
|
||||||
this.setSessionId(sessionId);
|
|
||||||
this._historyEl.textContent = '';
|
|
||||||
if (this._options.onReloadHistory) {
|
|
||||||
try {
|
|
||||||
await this._options.onReloadHistory();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: reload history failed', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Roll back the select to the previous value.
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.value = this._sessionId || '';
|
|
||||||
}
|
|
||||||
Notification.error('切换会话失败');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Notification.error('切换会话失败: ' + (e as Error).message);
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.value = this._sessionId || '';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (this._sessionSelect) {
|
|
||||||
this._sessionSelect.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the current (providerSelect.value, modelSelect.value) for
|
* Persist the current (providerSelect.value, modelSelect.value) for
|
||||||
* this notebook. No-op if the prompt has no notebookPath, no
|
* this notebook. No-op if the prompt has no notebookPath, no
|
||||||
@@ -618,20 +356,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (this._modelSelect) {
|
if (this._modelSelect) {
|
||||||
this._modelSelect.disabled = disabled;
|
this._modelSelect.disabled = disabled;
|
||||||
}
|
}
|
||||||
if (this._sessionSelect) {
|
|
||||||
// Don't disable the select while streaming — the user can still
|
|
||||||
// switch to a different session to inspect it. The cell action
|
|
||||||
// closes the SSE on switch.
|
|
||||||
this._sessionSelect.disabled = false;
|
|
||||||
}
|
|
||||||
// The "📋 插入单元格内容" button is only meaningful when there is
|
|
||||||
// an active cell. Reflect availability in its disabled state so
|
|
||||||
// the user can see at a glance whether the action will work, and
|
|
||||||
// the click handler can still no-op gracefully via Notification.
|
|
||||||
if (this._insertCellBtn) {
|
|
||||||
const hasActiveCell = !!this._options.getActiveCell?.();
|
|
||||||
this._insertCellBtn.disabled = disabled || !hasActiveCell;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the user input textarea. */
|
/** Clear the user input textarea. */
|
||||||
@@ -639,80 +363,18 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
this._textarea.value = '';
|
this._textarea.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "📋 插入单元格内容" button: append the current cell's source as a
|
|
||||||
* markdown code block to the textarea. The LLM only sees what the
|
|
||||||
* user explicitly chose to include — the cell source is NEVER
|
|
||||||
* auto-injected (v0.2.x change).
|
|
||||||
*
|
|
||||||
* The cell is resolved at click time through the host-provided
|
|
||||||
* `getActiveCell` callback, so the prompt itself stays decoupled
|
|
||||||
* from any specific CodeCell instance.
|
|
||||||
*/
|
|
||||||
private _insertCellSource(): void {
|
|
||||||
const cell = this._options.getActiveCell?.() ?? null;
|
|
||||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
|
||||||
Notification.info('请先选中一个 cell');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const source = cell.model.sharedModel.getSource() as string;
|
|
||||||
if (!source || !source.trim()) {
|
|
||||||
Notification.info('当前 cell 为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Use the cell's language as the fence info string when it
|
|
||||||
// looks like a real language identifier; otherwise use a plain
|
|
||||||
// fence so markdown stays portable.
|
|
||||||
const langRaw = (cell.model as any).type
|
|
||||||
? ((cell.model as any).type as string)
|
|
||||||
: 'python';
|
|
||||||
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
|
|
||||||
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
|
|
||||||
const before = this._textarea.value;
|
|
||||||
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
|
||||||
const insertion = (needsLeadingNewline ? '\n' : '') + block;
|
|
||||||
this._textarea.value = before + insertion;
|
|
||||||
// Move cursor to end so the user can continue typing.
|
|
||||||
this._textarea.focus();
|
|
||||||
const end = this._textarea.value.length;
|
|
||||||
this._textarea.setSelectionRange(end, end);
|
|
||||||
this._scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
||||||
* `applyEvent` for a different session are dropped. Pass null to clear.
|
* `applyEvent` for a different session are dropped. Pass null to clear.
|
||||||
*/
|
*/
|
||||||
setSessionId(sessionId: string | null): void {
|
setSessionId(sessionId: string | null): void {
|
||||||
this._sessionId = sessionId;
|
this._sessionId = sessionId;
|
||||||
if (this._sessionSelect) {
|
|
||||||
// Reflect the new active id in the dropdown without rebuilding it.
|
|
||||||
if (sessionId) {
|
|
||||||
const has = Array.from(this._sessionSelect.options).some(
|
|
||||||
o => o.value === sessionId
|
|
||||||
);
|
|
||||||
if (has) {
|
|
||||||
this._sessionSelect.value = sessionId;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._sessionSelect.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
||||||
this._resetStreamPointers();
|
this._resetStreamPointers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* First-time wiring after the prompt is constructed. Loads the global
|
|
||||||
* session list into the dropdown. Call from the host (cell action)
|
|
||||||
* right after constructing the widget.
|
|
||||||
*/
|
|
||||||
initialize(): void {
|
|
||||||
void this._refreshSessionList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the current session's static messages (loaded once from
|
* Render the current session's static messages (loaded once from
|
||||||
* /session-messages when the panel opens). Subsequent assistant turns
|
* /session-messages when the panel opens). Subsequent assistant turns
|
||||||
@@ -740,7 +402,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
applyEvent(event: OpenCodeEvent): void {
|
applyEvent(event: OpenCodeEvent): void {
|
||||||
const type = event.type || (event.payload && event.payload.type) || '';
|
const type = event.type || (event.payload && event.payload.type) || '';
|
||||||
const props =
|
const props =
|
||||||
event.properties || (event.payload && event.payload.properties) || {};
|
event.properties ||
|
||||||
|
(event.payload && event.payload.properties) ||
|
||||||
|
{};
|
||||||
const sessionID =
|
const sessionID =
|
||||||
props.sessionID ||
|
props.sessionID ||
|
||||||
props.sessionId ||
|
props.sessionId ||
|
||||||
@@ -749,25 +413,16 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
event.payload.syncEvent &&
|
event.payload.syncEvent &&
|
||||||
event.payload.syncEvent.aggregateID);
|
event.payload.syncEvent.aggregateID);
|
||||||
|
|
||||||
// System / workspace / pty / etc. events are not displayed.
|
// Drop events for other sessions (defensive; matches demo.html
|
||||||
if (this._isSystemEvent(type)) {
|
// handleGlobalEvent). Only drop when the event has an explicit
|
||||||
|
// sessionID — events without one (e.g. system events) are still
|
||||||
|
// processed and may be dropped by _isSystemEvent below.
|
||||||
|
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-session filtering: only strict for permission/question
|
// System / workspace / pty / etc. events are not displayed.
|
||||||
// events (the user must NOT be able to reply to a permission
|
if (this._isSystemEvent(type)) {
|
||||||
// prompt that belongs to a different session). Content events
|
|
||||||
// (text delta, reasoning, tool, idle) pass through regardless of
|
|
||||||
// sessionID match — the user is looking at the active session's
|
|
||||||
// panel, and we don't want to drop their reply because of a
|
|
||||||
// sessionID extraction edge case. The server already forwards
|
|
||||||
// ALL events; the SSE handler routes by URL ?session= param.
|
|
||||||
if (
|
|
||||||
(type === 'permission.asked' || type === 'question.asked') &&
|
|
||||||
sessionID &&
|
|
||||||
this._sessionId &&
|
|
||||||
sessionID !== this._sessionId
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,14 +444,13 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
type: string,
|
type: string,
|
||||||
props: { [k: string]: any }
|
props: { [k: string]: any }
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
||||||
if (type === 'permission.asked') {
|
if (type === 'permission.asked') {
|
||||||
const permId = String(props.id || props.permissionID || '');
|
const permId = String(props.id || props.permissionID || '');
|
||||||
this._createPermissionBlock(
|
this._createPermissionBlock(
|
||||||
permId,
|
permId,
|
||||||
String(
|
String(
|
||||||
props.description ||
|
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
|
||||||
props.title ||
|
|
||||||
'系统请求执行权限(命令行/文件修改)'
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -848,11 +502,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (type === 'session.next.tool.input.delta') {
|
if (type === 'session.next.tool.input.delta') {
|
||||||
this._appendToBlock(
|
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
|
||||||
'tool',
|
|
||||||
'🛠️ 工具输入与参数',
|
|
||||||
String(props.delta || '')
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (type === 'session.next.tool.progress') {
|
if (type === 'session.next.tool.progress') {
|
||||||
@@ -914,7 +564,10 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
if (payload) {
|
if (payload) {
|
||||||
return check(payload.type || topType, payload.properties || topProps);
|
return check(
|
||||||
|
payload.type || topType,
|
||||||
|
payload.properties || topProps
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -943,19 +596,16 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (!this._currentAssistantEl) {
|
if (!this._currentAssistantEl) {
|
||||||
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
||||||
}
|
}
|
||||||
// During streaming, append the raw delta to the assistant element's
|
// Accumulate the raw markdown source, then re-render the whole
|
||||||
// textContent. We do NOT run marked.parse on every delta: the
|
// message through marked.parse. This way the user sees properly
|
||||||
// partial source (e.g. an opening ```py fence with no closing
|
// formatted output (code blocks as <pre><code>, headings, lists)
|
||||||
// fence yet) is structurally different from the complete message
|
// in real-time as text deltas arrive — NOT the raw ```fence```
|
||||||
// and the toolbar post-processing creates visible flicker —
|
// source that an append-only .textContent would show.
|
||||||
// <pre>s appear and disappear as the renderer re-classifies the
|
|
||||||
// incomplete markdown between deltas. The user gets stable
|
|
||||||
// real-time text here, and the full markdown render happens once
|
|
||||||
// at the end of the turn (see _resetStreamPointers) so the result
|
|
||||||
// is identical to what setMessages would produce from the stored
|
|
||||||
// history.
|
|
||||||
this._currentAssistantText += text;
|
this._currentAssistantText += text;
|
||||||
this._currentAssistantEl.textContent = this._currentAssistantText;
|
this._renderAssistantMarkdown(
|
||||||
|
this._currentAssistantEl,
|
||||||
|
this._currentAssistantText
|
||||||
|
);
|
||||||
this._scrollToBottom();
|
this._scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,7 +650,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.id = domId;
|
details.id = domId;
|
||||||
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
details.className =
|
||||||
|
'opencode-msg-block opencode-msg-block-interaction';
|
||||||
details.open = true;
|
details.open = true;
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = '🔐 权限请求等待处理';
|
summary.textContent = '🔐 权限请求等待处理';
|
||||||
@@ -1082,7 +733,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.id = domId;
|
details.id = domId;
|
||||||
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
details.className =
|
||||||
|
'opencode-msg-block opencode-msg-block-interaction';
|
||||||
details.open = true;
|
details.open = true;
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = '❓ Agent 提问等待回复';
|
summary.textContent = '❓ Agent 提问等待回复';
|
||||||
@@ -1105,9 +757,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
submit.className = 'opencode-q-submit';
|
submit.className = 'opencode-q-submit';
|
||||||
submit.textContent = '提交回答';
|
submit.textContent = '提交回答';
|
||||||
submit.addEventListener('click', () => {
|
submit.addEventListener('click', () => {
|
||||||
void this._handleQuestionResponse(questionId, input, inputRow, details);
|
void this._handleQuestionResponse(
|
||||||
|
questionId,
|
||||||
|
input,
|
||||||
|
inputRow,
|
||||||
|
details
|
||||||
|
);
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', e => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit.click();
|
submit.click();
|
||||||
@@ -1135,8 +792,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this._options.onQuestionReply) {
|
if (!this._options.onQuestionReply) {
|
||||||
inputRow.innerHTML =
|
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||||
'<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
||||||
@@ -1151,19 +807,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _resetStreamPointers(): void {
|
private _resetStreamPointers(): void {
|
||||||
// Finalize the in-progress assistant message by running the
|
|
||||||
// markdown render ONCE on the accumulated raw text. This is the
|
|
||||||
// moment where the streaming path converges to the same DOM
|
|
||||||
// structure that setMessages would produce from the stored
|
|
||||||
// history (i.e. identical to "reopen" behavior). Without this
|
|
||||||
// final pass the user would see raw ```fence``` source
|
|
||||||
// permanently until they close+reopen the panel.
|
|
||||||
if (this._currentAssistantEl && this._currentAssistantText) {
|
|
||||||
this._renderAssistantMarkdown(
|
|
||||||
this._currentAssistantEl,
|
|
||||||
this._currentAssistantText
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._currentAssistantEl = null;
|
this._currentAssistantEl = null;
|
||||||
this._currentAssistantText = '';
|
this._currentAssistantText = '';
|
||||||
this._activeBlocks = {};
|
this._activeBlocks = {};
|
||||||
@@ -1266,9 +909,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _insertAtCursor(text: string): void {
|
private _insertAtCursor(text: string): void {
|
||||||
const cell = this._options.getActiveCell?.() ?? null;
|
const cell = this._cell;
|
||||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
Notification.info('请先选中一个 cell');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sharedModel = cell.model.sharedModel;
|
const sharedModel = cell.model.sharedModel;
|
||||||
@@ -1276,8 +918,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
let offset = source.length;
|
let offset = source.length;
|
||||||
try {
|
try {
|
||||||
const editor = (cell as any).editor;
|
const editor = (cell as any).editor;
|
||||||
const pos =
|
const pos = editor && editor.getCursorPosition
|
||||||
editor && editor.getCursorPosition ? editor.getCursorPosition() : null;
|
? editor.getCursorPosition()
|
||||||
|
: null;
|
||||||
if (pos) {
|
if (pos) {
|
||||||
const lines = source.split('\n');
|
const lines = source.split('\n');
|
||||||
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
||||||
@@ -1300,9 +943,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _replaceCell(text: string): void {
|
private _replaceCell(text: string): void {
|
||||||
const cell = this._options.getActiveCell?.() ?? null;
|
const cell = this._cell;
|
||||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
Notification.info('请先选中一个 cell');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cell.model.sharedModel.setSource(text);
|
cell.model.sharedModel.setSource(text);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
|
||||||
|
*
|
||||||
|
* Pure functions, no DOM, no signals — testable with plain object mocks.
|
||||||
|
*/
|
||||||
|
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
||||||
|
import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||||
|
|
||||||
|
import type { CellContext, ErrorOutput } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first error output in a code cell's outputs.
|
||||||
|
* Returns null if the cell has no error output or is not a code cell.
|
||||||
|
*/
|
||||||
|
export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
|
||||||
|
const outputs = model.outputs;
|
||||||
|
for (let i = 0; i < outputs.length; i++) {
|
||||||
|
const out = outputs.get(i);
|
||||||
|
if (out && out.type === 'error') {
|
||||||
|
const e = out as {
|
||||||
|
ename?: unknown;
|
||||||
|
evalue?: unknown;
|
||||||
|
traceback?: unknown;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ename: String(e.ename ?? ''),
|
||||||
|
evalue: String(e.evalue ?? ''),
|
||||||
|
traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CellContext from a CodeCell and its parent NotebookPanel.
|
||||||
|
* Returns null if essential data is missing.
|
||||||
|
*
|
||||||
|
* @param cell The code cell to inspect.
|
||||||
|
* @param panel The notebook panel that contains the cell.
|
||||||
|
*/
|
||||||
|
export function extractCellContext(
|
||||||
|
cell: CodeCell,
|
||||||
|
panel: NotebookPanel
|
||||||
|
): CellContext | null {
|
||||||
|
if (!cell || !panel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = panel.content.widgets;
|
||||||
|
const cellIndex = cells ? cells.indexOf(cell) : -1;
|
||||||
|
if (cellIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCells = cells ? cells.length : 0;
|
||||||
|
const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
|
||||||
|
const previousCode = previousCell
|
||||||
|
? previousCell.model.sharedModel.getSource()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Language: use 'python' as default for code cells.
|
||||||
|
// Future improvement: read from kernel info_reply.
|
||||||
|
const language = 'python';
|
||||||
|
|
||||||
|
let error: ErrorOutput | null = null;
|
||||||
|
if (cell.model.type === 'code') {
|
||||||
|
error = collectErrorOutputs(cell.model as ICodeCellModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notebookPath: panel.context.path,
|
||||||
|
cellId: cell.model.id,
|
||||||
|
language,
|
||||||
|
cellIndex,
|
||||||
|
totalCells,
|
||||||
|
source: cell.model.sharedModel.getSource(),
|
||||||
|
previousCode,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
+41
-23
@@ -3,56 +3,74 @@ import {
|
|||||||
JupyterFrontEndPlugin
|
JupyterFrontEndPlugin
|
||||||
} from '@jupyterlab/application';
|
} from '@jupyterlab/application';
|
||||||
|
|
||||||
|
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||||
|
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import { OpenCodeFloatingPanel } from './components/opencode_floating_panel';
|
import {
|
||||||
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeServerSettings
|
||||||
|
} from './components/opencode_cell_actions';
|
||||||
import { requestAPI } from './request';
|
import { requestAPI } from './request';
|
||||||
import { callOpenCodeProviders } from './api/opencode_client';
|
import { callOpenCodeProviders } from './api/opencode_client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialization data for the opencode_bridge extension.
|
* Initialization data for the opencode_bridge extension.
|
||||||
*
|
*
|
||||||
* v5-floating: the per-cell AI button is gone. A single
|
* v3-final: no JupyterLab plugin settings. Connection config is read from
|
||||||
* OpenCodeFloatingPanel is attached to document.body and operates
|
* environment variables by the server extension; the model is picked
|
||||||
* against the JupyterLab shell's currently-active notebook. The model
|
* dynamically in the inline prompt.
|
||||||
* is picked dynamically in the inline prompt.
|
|
||||||
*/
|
*/
|
||||||
const plugin: JupyterFrontEndPlugin<void> = {
|
const plugin: JupyterFrontEndPlugin<void> = {
|
||||||
id: 'opencode_bridge:plugin',
|
id: 'opencode_bridge:plugin',
|
||||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
activate: (app: JupyterFrontEnd) => {
|
optional: [IToolbarWidgetRegistry],
|
||||||
|
activate: (
|
||||||
|
app: JupyterFrontEnd,
|
||||||
|
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||||
|
) => {
|
||||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||||
|
|
||||||
// Mount the floating AI button on the body. The panel itself is
|
// Push the Jupyter server settings into the actions module so that
|
||||||
// hidden until the user clicks the button.
|
// callOpenCodeEdit can build the right base URL.
|
||||||
const floating = new OpenCodeFloatingPanel({
|
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
||||||
app,
|
|
||||||
serverSettings: app.serviceManager.serverSettings,
|
|
||||||
providers: null
|
|
||||||
});
|
|
||||||
Widget.attach(floating, document.body);
|
|
||||||
|
|
||||||
// Fetch available providers once at activation and cache them for
|
// Register the per-cell AI actions into the native Cell toolbar
|
||||||
// the inline prompt's model picker. Failure is non-fatal (the
|
// (top-right of the active cell, next to move up/down). Non-code
|
||||||
// picker hides). Also push the payload into the floating panel so
|
// cells get an empty widget so they show no AI buttons.
|
||||||
// the prompt can render selects without an extra round trip.
|
if (toolbarRegistry) {
|
||||||
|
toolbarRegistry.addFactory<Cell>(
|
||||||
|
'Cell',
|
||||||
|
'opencode-cell-actions',
|
||||||
|
(cell: Cell) => {
|
||||||
|
if (cell instanceof CodeCell) {
|
||||||
|
return new OpenCodeCellActions(cell);
|
||||||
|
}
|
||||||
|
return new Widget();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch available providers once at activation and cache them for the
|
||||||
|
// inline prompt's model picker. Failure is non-fatal (the picker hides).
|
||||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
floating.setProviders(data);
|
setOpenCodeProviders(data);
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'[opencode_bridge] Available OpenCode providers:'
|
'[opencode_bridge] Available OpenCode providers:'
|
||||||
];
|
];
|
||||||
for (const p of data.providers) {
|
for (const p of data.providers) {
|
||||||
const models = Object.values(p.models)
|
const models = Object.values(p.models).map(m => m.id).join(', ');
|
||||||
.map(m => m.id)
|
|
||||||
.join(', ');
|
|
||||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log(lines.join('\n'));
|
console.log(lines.join('\n'));
|
||||||
})
|
})
|
||||||
.catch(reason => {
|
.catch(reason => {
|
||||||
console.warn(
|
// eslint-disable-next-line no-console
|
||||||
|
+ console.warn(
|
||||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-59
@@ -10,19 +10,21 @@
|
|||||||
* a JSON object with a `type` string; consumers route on `type`.
|
* a JSON object with a `type` string; consumers route on `type`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
export interface ErrorOutput {
|
||||||
* Minimal context carried with each edit request. Only the
|
ename: string;
|
||||||
* notebookPath is needed (so the server can pick the right OpenCode
|
evalue: string;
|
||||||
* session). The cell source, previous-cell, and traceback are NO
|
traceback: string[];
|
||||||
* LONGER auto-injected into the LLM prompt — the user inserts them
|
}
|
||||||
* manually via the "📋 插入单元格内容" button (or pastes anything
|
|
||||||
* else they want) so the LLM only sees what they explicitly chose
|
|
||||||
* to send. v0.1.x used to pack a lot more here; that auto-wrap
|
|
||||||
* made the LLM context hard to reason about and made the user's
|
|
||||||
* intent ambiguous.
|
|
||||||
*/
|
|
||||||
export interface CellContext {
|
export interface CellContext {
|
||||||
notebookPath: string;
|
notebookPath: string;
|
||||||
|
cellId: string;
|
||||||
|
language: string;
|
||||||
|
cellIndex: number;
|
||||||
|
totalCells: number;
|
||||||
|
source: string;
|
||||||
|
previousCode: string | null;
|
||||||
|
error: ErrorOutput | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeRequest {
|
export interface OpenCodeRequest {
|
||||||
@@ -80,54 +82,6 @@ export interface OpenCodeMessagesResponse {
|
|||||||
messages: OpenCodeMessage[];
|
messages: OpenCodeMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Multi-session management types (1 notebook can bind N sessions, one active)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** One session on the OpenCode Serve side (returned by GET /session). */
|
|
||||||
export interface OpenCodeSessionMeta {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
createdAt?: number;
|
|
||||||
updatedAt?: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/sessions/all. */
|
|
||||||
export interface OpenCodeAllSessionsResponse {
|
|
||||||
ok: boolean;
|
|
||||||
sessions: OpenCodeSessionMeta[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One session bound to a notebook (returned by GET /sessions/notebook). */
|
|
||||||
export interface OpenCodeNotebookSession {
|
|
||||||
sessionId: string;
|
|
||||||
title?: string;
|
|
||||||
createdAt?: number;
|
|
||||||
updatedAt?: number;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */
|
|
||||||
export interface OpenCodeNotebookSessionsResponse {
|
|
||||||
ok: boolean;
|
|
||||||
notebookPath: string;
|
|
||||||
activeSessionId: string | null;
|
|
||||||
sessions: OpenCodeNotebookSession[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */
|
|
||||||
export interface OpenCodeSessionOpResponse {
|
|
||||||
ok: boolean;
|
|
||||||
notebookPath: string;
|
|
||||||
sessionId?: string;
|
|
||||||
activeSessionId?: string | null;
|
|
||||||
deleted?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||||
export interface OpenCodeModel {
|
export interface OpenCodeModel {
|
||||||
|
|||||||
+15
-120
@@ -4,72 +4,30 @@
|
|||||||
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Floating AI launcher (replaces the per-cell toolbar button).
|
/* Single AI action button inside the native Cell toolbar (active cell, top-right). */
|
||||||
Mounted on document.body with position: fixed so it sits above the
|
.opencode-cell-actions {
|
||||||
JupyterLab layout regardless of which dock is active. The panel
|
|
||||||
(when open) renders above the button via column flex. */
|
|
||||||
.opencode-floating-root {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
z-index: 1000; /* above JupyterLab's main UI (z-index tops out around 100) */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-floating-button {
|
.opencode-cell-actions .opencode-btn {
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--jp-brand-color1, #1976d2);
|
background: transparent;
|
||||||
color: white;
|
padding: 0 6px;
|
||||||
font-size: 20px;
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
line-height: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
white-space: nowrap;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.opencode-floating-button:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
.opencode-floating-button:focus {
|
|
||||||
outline: 2px solid var(--jp-brand-color2, #42a5f5);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-floating-panel {
|
.opencode-cell-actions .opencode-btn:hover:enabled {
|
||||||
width: 480px;
|
background: var(--jp-layout-color2);
|
||||||
max-width: calc(100vw - 48px);
|
border-radius: 2px;
|
||||||
max-height: 60vh;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--jp-layout-color1, #fff);
|
|
||||||
border: 1px solid var(--jp-border-color2, #ddd);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
.opencode-floating-panel[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The inline prompt inside the floating panel should fill it: drop
|
.opencode-cell-actions .opencode-btn:disabled {
|
||||||
the standalone panel's own margin/border, and let the panel's
|
opacity: 0.4;
|
||||||
scrollable region take over. */
|
cursor: default;
|
||||||
.opencode-floating-panel .opencode-inline-prompt {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.opencode-floating-panel .opencode-inline-history {
|
|
||||||
max-height: 40vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
||||||
@@ -233,7 +191,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-inline-prompt .opencode-inline-actions button {
|
.opencode-inline-prompt .opencode-inline-actions button {
|
||||||
@@ -254,19 +211,6 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
|
|
||||||
send/cancel so the user can tell at a glance it's a "modifier"
|
|
||||||
rather than a "submit/cancel" action. */
|
|
||||||
.opencode-inline-prompt .opencode-btn-insert-cell {
|
|
||||||
margin-right: auto; /* push to the left; send/cancel stay on the right */
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
|
|
||||||
background: #bfdbfe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
|
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
@@ -395,52 +339,3 @@
|
|||||||
.opencode-inline-prompt .opencode-q-submit:hover {
|
.opencode-inline-prompt .opencode-q-submit:hover {
|
||||||
background: var(--jp-layout-color3, #e0e0e0);
|
background: var(--jp-layout-color3, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
|
||||||
Session selector (multi-session UI) — top of the inline prompt
|
|
||||||
------------------------------------------------------------------ */
|
|
||||||
.opencode-inline-prompt .opencode-prompt-sessions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 2px 0 4px;
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-prompt-sessions label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: var(--jp-ui-font-size1);
|
|
||||||
color: var(--jp-ui-font-color1, #333);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-session-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 2px 4px;
|
|
||||||
font-size: var(--jp-ui-font-size1);
|
|
||||||
font-family: inherit;
|
|
||||||
border: 1px solid var(--jp-border-color2, #ccc);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--jp-layout-color1, #fff);
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-session-btn-new,
|
|
||||||
.opencode-inline-prompt .opencode-session-btn-refresh {
|
|
||||||
border: 1px solid var(--jp-border-color2, #ccc);
|
|
||||||
background: var(--jp-layout-color2, #f0f0f0);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--jp-ui-font-size0, 11px);
|
|
||||||
color: var(--jp-ui-font-color1, #333);
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-session-btn-new:hover,
|
|
||||||
.opencode-inline-prompt .opencode-session-btn-refresh:hover {
|
|
||||||
background: var(--jp-layout-color3, #e0e0e0);
|
|
||||||
}
|
|
||||||
.opencode-inline-prompt .opencode-session-btn-new {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user