Compare commits
34
Commits
c919c95842
...
ce59502e97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce59502e97 | ||
|
|
1d7f5da9d4 | ||
|
|
04f90ab5a0 | ||
|
|
e7579d3779 | ||
|
|
f7099be060 | ||
|
|
02cedac138 | ||
|
|
8ed55c43c7 | ||
|
|
df2f3dff95 | ||
|
|
b6c0273ee0 | ||
|
|
c86de1d31b | ||
|
|
ff932d9095 | ||
|
|
5306ada0c4 | ||
|
|
74953d0ab8 | ||
|
|
74de18646f | ||
|
|
1e42459560 | ||
|
|
b5bcb8f67c | ||
|
|
f9b93906c6 | ||
|
|
ca2281af3b | ||
|
|
8b51eda2ff | ||
|
|
12c0f4c618 | ||
|
|
24bff6d2eb | ||
|
|
85e16914b5 | ||
|
|
d03f0fa434 | ||
|
|
04d011afa1 | ||
|
|
42ea857313 | ||
|
|
b5adf331a6 | ||
|
|
50ba847659 | ||
|
|
2bac98d659 | ||
|
|
47a774f237 | ||
|
|
fe45c80dd2 | ||
|
|
0a05424b29 | ||
|
|
bb846d64fe | ||
|
|
714a55653c | ||
|
|
9e7c36e323 |
+1
-1
@@ -4,7 +4,7 @@ enableGlobalCache: false
|
|||||||
|
|
||||||
enableScripts: false
|
enableScripts: false
|
||||||
|
|
||||||
nodeLinker: pnpm
|
nodeLinker: node-modules
|
||||||
|
|
||||||
packageExtensions:
|
packageExtensions:
|
||||||
"@module-federation/sdk@*":
|
"@module-federation/sdk@*":
|
||||||
|
|||||||
@@ -42,14 +42,15 @@
|
|||||||
|
|
||||||
## 🎨 2. 交互与UI流程设计
|
## 🎨 2. 交互与UI流程设计
|
||||||
|
|
||||||
1. **悬浮 Toolbar(常驻)**:在每个 `CodeCell` 顶部渲染微型操作栏:
|
1. **原生 Cell Toolbar 单按钮(2026-07-23 v2 修正)**:不再自绘悬浮 toolbar,也合并了 v1 的三个 mode(优化/排错/编辑)为单个 `🪄 AI 智能编辑` 图标按钮。通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**(move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。**前端不做任何语义处理** —— 不再发 `mode` 字段,后端用统一的系统提示词 + 用户的自然语言指令 + 上下文(含 traceback)由 LLM 自己判断是优化/排错/编辑。
|
||||||
* `✨ 优化`:自动提炼代码规范与性能。
|
|
||||||
* `🐛 智能排错`:(**仅当 Cell 有 Error Output 时亮起**)自动抓取 Traceback 并修复。
|
|
||||||
* `🪄 智能编辑`:展开内嵌 Prompt 输入框。
|
|
||||||
|
|
||||||
|
|
||||||
2. **Inline Prompt 框(按需展开)**:
|
2. **Inline Prompt 输入框(v3-final)**:点击 `🪄 AI 智能编辑` 按钮后,`OpenCodeInlinePrompt` widget 追加到当前 `CodeCell` 的 DOM 末尾,呈现内嵌输入面板:
|
||||||
* 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮)。
|
* 顶部一行 `模型: <select>` —— 来自启动时 `GET /opencode-bridge/providers` 缓存的 `(provider, model)` 列表,**默认选中第一项**(v3-final 不再有任何 settings-based 默认),用户每次请求前可改。providers 拉取失败时该下拉框不渲染。
|
||||||
|
* 多行 `textarea`(placeholder "向 AI 描述你想要的修改…",无默认值)。
|
||||||
|
* `🚀 发送` + `✕ 取消` 两个按钮。
|
||||||
|
|
||||||
|
提交时前端仅收集 cell `source` / `outputs` / `error` 与用户在 textarea 里写的指令,POST `/opencode-bridge/edit`,body 为 `{prompt, context, providerId?, modelId?}`(**无 `mode`**)。`providerId`/`modelId` 仅在下拉框有选中项时发出;下拉框不存在时省略,OpenCode Serve 用其默认。**成功** → 前端调用 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + `Notification.info`;**失败** → `Notification.error`,输入框保留(可改文案重发)。Session 由服务端 `SessionManager` 保证 1 notebook 1 sid(前端不感知)。
|
||||||
|
|
||||||
|
|
||||||
3. **流式替换/Diff 预览**:
|
3. **流式替换/Diff 预览**:
|
||||||
@@ -61,43 +62,43 @@
|
|||||||
|
|
||||||
## 🔌 3. 接口契约设计 (API Contract)
|
## 🔌 3. 接口契约设计 (API Contract)
|
||||||
|
|
||||||
### 接口:`POST /opencode/v1/generate` (支持 SSE 流式返回)
|
### 接口:`POST /opencode-bridge/edit`(v2 — 无 `mode`)
|
||||||
|
|
||||||
**Request Payload (前端 Context Provider 打包发给 Server Extension)**:
|
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"action": "custom_edit", // custom_edit | fix_error | optimize
|
|
||||||
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
|
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
|
||||||
"context": {
|
"context": {
|
||||||
"notebook_path": "analysis/demo.ipynb",
|
"notebookPath": "analysis/demo.ipynb",
|
||||||
"target_cell": {
|
"cellId": "cell-3",
|
||||||
"index": 3,
|
"language": "python",
|
||||||
"code": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
|
"cellIndex": 3,
|
||||||
"error_output": "ModuleNotFoundError: No module named 'matplotlib'"
|
"totalCells": 5,
|
||||||
},
|
"source": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
|
||||||
"surrounding_cells": [
|
"previousCode": "import pandas as pd",
|
||||||
{ "index": 2, "code": "import pandas as pd" }
|
"error": null
|
||||||
]
|
},
|
||||||
}
|
"providerId": "anthropic",
|
||||||
|
"modelId": "claude-sonnet-4-20250514"
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (Server Extension 流式透传 OpenCode Serve 的 SSE 响应)**:
|
> **v2 变化**:body 不再含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传。
|
||||||
|
|
||||||
```text
|
**Response**(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
|
||||||
event: chunk
|
|
||||||
data: {"text": "import plotly.express as px\n"}
|
|
||||||
|
|
||||||
event: chunk
|
|
||||||
data: {"text": "fig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()"}
|
|
||||||
|
|
||||||
event: done
|
|
||||||
data: {"status": "success"}
|
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
|
||||||
|
"sessionId": "ses-abc123",
|
||||||
|
"notebookPath": "analysis/demo.ipynb"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 成功时前端用 `finalSource` 就地替换 cell 源码(`cell.model.sharedModel.setSource`)。Session 由服务端 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
|
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
|
||||||
@@ -281,57 +282,41 @@ v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎛 6. Provider/Model 选择(v0.2.2 新增)
|
## ⚙️ 6. 启动配置 + 动态模型选择(v3-final,2026-07-23)
|
||||||
|
|
||||||
> **2026-07-22**:用户要求能在 settings 里选 OpenCode 的 provider 和 model,可选值从 `GET /config/providers` 动态获取。
|
> **v3-final 修正**:`opencode_bridge` 插件**完全不再使用 JupyterLab Settings Editor**(`schema/plugin.json` 的 `properties: {}`)。所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定;模型在 inline 输入框中动态选择(见 §2 item 2),无任何持久默认。
|
||||||
|
|
||||||
### 6.1 Schema 扩展
|
### 6.1 启动环境变量
|
||||||
|
|
||||||
`schema/plugin.json` 加 2 个 string 字段:
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||||
|
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||||
|
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||||
|
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒,整数) |
|
||||||
|
|
||||||
```json
|
由 `opencode_bridge/config.py` `resolve_config` 统一解析,**不再**从 JupyterLab settings 字典兜底(`jupyter_settings` 参数保留仅为 API 兼容,实际未读)。
|
||||||
"opencodeProvider": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Provider",
|
|
||||||
"description": "Provider id (如 'anthropic' / 'openai')。可选项由启动时从 /opencode-bridge/providers 拉取并打到 console 列出。留空 = 用 OpenCode 默认。",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"opencodeModel": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Model",
|
|
||||||
"description": "Model id (如 'claude-sonnet-4-20250514')。可选项见 console 日志或 /opencode-bridge/providers。留空 = 用 provider 默认。",
|
|
||||||
"default": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 选值如何给用户
|
### 6.2 模型选择
|
||||||
|
|
||||||
JupyterLab settings 是静态 JSON schema,**没有原生 dynamic enum**。v0.2.2 用最简方案:
|
`opencodeProvider` / `opencodeModel` v0.2.2 settings 字段**已移除**。inline 输入框顶部 `<select>` 的选项来自 `index.ts` 启动时 `callOpenCodeProviders` 拉取并写入 `setOpenCodeProviders(p)` 的模块级缓存:
|
||||||
- 启动时 fetch `/opencode-bridge/providers` → 把 provider/model 列表 `console.log` 出来
|
|
||||||
- 用户在 Settings Editor 里手填
|
|
||||||
- 后续 v0.4 考虑用 QuickPick 命令做交互式选择
|
|
||||||
|
|
||||||
### 6.3 数据流
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Settings (JupyterLab)
|
index.ts (启动)
|
||||||
│
|
└─ GET /opencode-bridge/providers
|
||||||
├── opencodeProvider = "anthropic"
|
└─ setOpenCodeProviders(data) // 模块级 _providers
|
||||||
└── opencodeModel = "claude-sonnet-4-20250514"
|
└─ OpenCodeInlinePrompt 构造时读
|
||||||
│
|
└─ <select> 列出所有 (provider, model)
|
||||||
▼ setOpenCodeRuntime()
|
└─ 用户选一项 → 提交时
|
||||||
Frontend (opencode_cell_footer.ts)
|
└─ OpenCodeRequest.providerId / modelId
|
||||||
│
|
└─ POST /opencode-bridge/edit
|
||||||
▼ build request body
|
|
||||||
POST /opencode-bridge/edit
|
|
||||||
{ mode, prompt, context, providerId, modelId }
|
|
||||||
│
|
|
||||||
▼ send_message_sync
|
|
||||||
OpenCode Serve 接受 model = { providerID, modelID }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.4 不变量
|
- 默认选中:下拉框**第一项**(无 settings 可作默认)。
|
||||||
|
- 拉取失败(OpenCode Serve 未起)→ 缓存为 `null` → inline 不渲染 select → 提交时省略 `providerId`/`modelId`,OpenCode Serve 用其默认。**优雅降级**。
|
||||||
|
|
||||||
- 留空字段("")→ 客户端不发 `providerId`/`modelId` → server 也不加 `model` 字段到 OpenCode 请求 → OpenCode 用默认
|
### 6.3 行为变更(v0.2.2 → v3-final)
|
||||||
- 用户在 settings 里设的值**不会自动回写到 OpenCode**——只是 request 级别的覆盖
|
|
||||||
- 改 settings 立即生效(下次 edit 请求就用新值);不需要重启 Jupyter
|
- 之前 6 个 JupyterLab settings 字段(`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` / `opencodeProvider` / `opencodeModel`)**全部失效**,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。
|
||||||
|
- 连接配置从"可在 Settings Editor 改"变为"启动时定",更符合服务端连接参数的语义。
|
||||||
|
- 模型从"settings 持久默认"变为"每次手选",无持久化偏好。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,614 @@
|
|||||||
|
# Cell Toolbar Actions v3 — 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** 把全部 server 连接字段(URL/User/Password/Timeout)从 JupyterLab Settings 移到启动环境变量;**opencode_bridge 插件完全不再使用 JupyterLab Settings Editor**(`schema/plugin.json` `properties: {}`);inline 输入框增加**完全动态的**模型选择器(无 settings 默认,默认第一项)。
|
||||||
|
|
||||||
|
**Architecture:** `config.py` 4 字段全走 `os.environ.get(ENV) or DEFAULT`(`jupyter_settings` 参数保留仅为 API 兼容,实际未读);`schema/plugin.json` `properties: {}`(schema 只保留 `jupyter.lab.toolbars` 贡献);`src/types.ts` 整个 `OpenCodeSettings` 类型 / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` **删除**;`setOpenCodeRuntime` 改名为 `setOpenCodeServerSettings({ serverSettings })`,只设 Jupyter `ServerConnection.ISettings`,模块级 `_settings` 移除只留 `_serverSettings` 与 `_providers`;`index.ts` 去掉 `ISettingRegistry` 与 `settingRegistry.load(...)` 整段,激活时直接 `setOpenCodeServerSettings(serverSettings)` + 注册 toolbar factory + 拉 providers 写缓存;`OpenCodeInlinePrompt` 构造加 `providers`(无 `defaultProviderId`/`defaultModelId`),默认选中第一项,providers 为 `null`/空时不渲染 select,`onSubmit(text, providerId?, modelId?)` 把选中带回;`OpenCodeCellActions._onSubmit` 用 prompt 回传的 `providerId`/`modelId` 写进 `OpenCodeRequest`,无 settings 兜底(providers 拉取失败时提交时省略 provider/model 字段,OpenCode 用默认)。
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript / Python 3.12 / jest / pytest-jupyter / stylelint / prettier
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-23-cell-toolbar-actions-v3-design.md`(已批准)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `schema/plugin.json` `properties: {}`(删全部 properties;`jupyter.lab.toolbars` 贡献保留:item 名 `opencode-cell-actions`,rank 100)。
|
||||||
|
- `src/types.ts` **删除** `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 整组;`src/__tests__/opencode_bridge.spec.ts` 中相关测试删除(若文件仅含 types 测试则整文件 `git rm`)。
|
||||||
|
- `opencode_bridge/config.py` 新增 `ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"` 与 `DEFAULT_REQUEST_TIMEOUT = 120`;`resolve_config` 4 个字段(`url`/`user`/`password`/`request_timeout_seconds`)全走 `os.environ.get(ENV) or DEFAULT`,**不再** `bridge.get(...)` 兜底。
|
||||||
|
- `setOpenCodeRuntime` → `setOpenCodeServerSettings`,签名 `{ serverSettings: ServerConnection.ISettings }`;模块级 `_settings` 删除,只留 `_serverSettings`(给 `callOpenCodeEdit` 拼 baseUrl)与 `_providers`(给 inline prompt 渲染 select)。
|
||||||
|
- `index.ts` `optional` 去掉 `ISettingRegistry`;删除整个 `settingRegistry.load(...).then(setOpenCodeRuntime)` 段;激活时:`setOpenCodeServerSettings(serverSettings)` + 注册 toolbar factory + `callOpenCodeProviders(...).then(setOpenCodeProviders)`。
|
||||||
|
- 模型选择器 value 格式: `providerId|modelId`(`|` 分隔,`split("|", 2)` 解析)。
|
||||||
|
- 默认选中: **第一项**(无 settings 可作默认);若 `providers` 为 `null` 或空 → **不渲染** select,提交时 `providerId`/`modelId` 为 `undefined` → **不发**这两个字段。
|
||||||
|
- Providers 缓存模块级: `let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; }` 放在 `opencode_cell_actions.ts`(`setOpenCodeServerSettings` 同文件),`index.ts` 在 `callOpenCodeProviders(...).then(...)` 里调。
|
||||||
|
- 工具栏 item 名 `opencode-cell-actions` / rank 100 不变。
|
||||||
|
- 不发 `mode` 字段(v2 约束保持)。
|
||||||
|
- 代码风格 / 单测 / CSS 约束同前。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 4 字段移出 settings(server config + types + schema)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `schema/plugin.json`
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
- Modify: `opencode_bridge/config.py`
|
||||||
|
- (可能)Modify: `src/__tests__/opencode_client.spec.ts`(若它引用了被删的 settings 字段)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 无新依赖。
|
||||||
|
- Produces: `OpenCodeSettings = { opencodeProvider: string; opencodeModel: string }`;`resolve_config({})` 在 4 个 env 变量未设时返回全默认;`schema/plugin.json` `properties` 只剩 `opencodeProvider`/`opencodeModel`。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 改 `src/types.ts`**
|
||||||
|
|
||||||
|
把当前 `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 改为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Frontend view of the 2 remaining schema/plugin.json fields. */
|
||||||
|
export interface OpenCodeSettings {
|
||||||
|
opencodeProvider: string;
|
||||||
|
opencodeModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
||||||
|
opencodeProvider: '',
|
||||||
|
opencodeModel: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
||||||
|
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
||||||
|
return {
|
||||||
|
opencodeProvider: c.opencodeProvider || '',
|
||||||
|
opencodeModel: c.opencodeModel || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改 `schema/plugin.json`**
|
||||||
|
|
||||||
|
从 `properties` 中**删除** 4 个:
|
||||||
|
- `opencodeServerUrl`
|
||||||
|
- `opencodeServerUser`
|
||||||
|
- `opencodeServerPassword`
|
||||||
|
- `requestTimeoutSeconds`
|
||||||
|
|
||||||
|
保留 `opencodeProvider` / `opencodeModel`。可更新它们的 `description` 注明 "Used as default selection in the inline model picker"。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改 `opencode_bridge/config.py`**
|
||||||
|
|
||||||
|
完整新内容:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Configuration resolution for the opencode-bridge extension.
|
||||||
|
|
||||||
|
All connection fields (URL / user / password / request timeout) are resolved
|
||||||
|
exclusively from environment variables + built-in defaults at server startup.
|
||||||
|
They are NOT stored in the JupyterLab plugin settings.
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||||
|
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||||
|
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||||
|
OPENCODE_BRIDGE_TIMEOUT — request timeout in seconds (default 120)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import NamedTuple, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||||
|
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||||
|
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||||
|
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||||
|
|
||||||
|
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||||
|
DEFAULT_USER = "opencode"
|
||||||
|
DEFAULT_REQUEST_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeConfig(NamedTuple):
|
||||||
|
url: str
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
request_timeout_seconds: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Return (user, password) for HTTP Basic Auth, or None if no password set."""
|
||||||
|
if not self.password:
|
||||||
|
return None
|
||||||
|
return (self.user, self.password)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||||
|
"""Resolve OpenCode connection config from environment variables + defaults.
|
||||||
|
|
||||||
|
The `jupyter_settings` argument is accepted for API compatibility but no
|
||||||
|
fields are read from it — all connection config is now startup-only
|
||||||
|
(environment variables).
|
||||||
|
"""
|
||||||
|
_ = jupyter_settings # intentionally unused; see module docstring
|
||||||
|
return OpenCodeConfig(
|
||||||
|
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||||
|
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||||
|
password=os.environ.get(ENV_PASSWORD) or "",
|
||||||
|
request_timeout_seconds=int(
|
||||||
|
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 检查 `opencode_client.spec.ts`**
|
||||||
|
|
||||||
|
`grep -n "opencodeServerUrl\|opencodeServerUser\|opencodeServerPassword\|requestTimeoutSeconds" src/__tests__/opencode_client.spec.ts` —— 若有引用,删除。`opencode_bridge.spec.ts` 的 `readOpenCodeSettings` 测试只覆盖 provider/model,不受影响。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑测试 + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest opencode_bridge/tests/ -q
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||||
|
```
|
||||||
|
Expected: pytest 全过(32 passed);jest 全过(25+);build 成功。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add schema/plugin.json src/types.ts opencode_bridge/config.py src/__tests__/opencode_client.spec.ts
|
||||||
|
git commit -m "refactor: move server connection config to startup env vars
|
||||||
|
|
||||||
|
URL, user, password, and request timeout are no longer in the JupyterLab
|
||||||
|
plugin settings — they are resolved exclusively from environment variables
|
||||||
|
(OPENCODE_BRIDGE_URL/USER/PASSWORD/TIMEOUT) at jupyter server startup,
|
||||||
|
with built-in defaults. The Settings Editor now only carries
|
||||||
|
opencodeProvider/opencodeModel (the defaults for the new inline model
|
||||||
|
picker, added in the next commit)."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 模型选择器(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/opencode_cell_actions.ts`(加 providers 缓存 + 注入)
|
||||||
|
- Modify: `src/components/opencode_inline_prompt.ts`(加 select + onSubmit 带 provider/model)
|
||||||
|
- Modify: `src/index.ts`(`callOpenCodeProviders` 成功后调 `setOpenCodeProviders`)
|
||||||
|
- Modify: `style/base.css`(select 样式)
|
||||||
|
- Modify: `src/__tests__/opencode_cell_actions.spec.ts`(补 select 相关断言)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `OpenCodeProvidersResponse`(从 `../types`);`OpenCodeSettings` 缩为 2 字段。
|
||||||
|
- Produces: `setOpenCodeProviders(p: OpenCodeProvidersResponse | null): void`(在 `opencode_cell_actions.ts`);`OpenCodeInlinePrompt` 构造 options 加 `providers`/`defaultProviderId?`/`defaultModelId?`,`onSubmit` 签名变 `(text: string, providerId?: string, modelId?: string) => void`;`OpenCodeCellActions` 用 prompt 回调里的 provider/model 覆盖 `OpenCodeRequest.providerId`/`modelId`。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 改 `opencode_cell_actions.ts` —— 扩导出与新签名**
|
||||||
|
|
||||||
|
在模块顶部 `setOpenCodeRuntime` 后加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let _providers: OpenCodeProvidersResponse | null = null;
|
||||||
|
|
||||||
|
export function setOpenCodeProviders(
|
||||||
|
p: OpenCodeProvidersResponse | null
|
||||||
|
): void {
|
||||||
|
_providers = p;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `opencode_cell_actions.ts` 顶部 import:
|
||||||
|
```ts
|
||||||
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `_showPrompt` 改为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private _showPrompt(): void {
|
||||||
|
if (this._prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultProviderId = _settings?.opencodeProvider || undefined;
|
||||||
|
const defaultModelId = _settings?.opencodeModel || undefined;
|
||||||
|
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||||
|
disabled: !this._context || this._status === 'loading',
|
||||||
|
providers: _providers,
|
||||||
|
defaultProviderId,
|
||||||
|
defaultModelId,
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
|
void this._onSubmit(text, providerId, modelId);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this._hidePrompt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Widget.attach(prompt, this._cell.node);
|
||||||
|
this._prompt = prompt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `_onSubmit` 改为(签名加 provider/model 入参,用它们覆盖 settings):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private async _onSubmit(
|
||||||
|
text: string,
|
||||||
|
providerIdOverride?: string,
|
||||||
|
modelIdOverride?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this._context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_settings || !_serverSettings) {
|
||||||
|
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-request selection from the inline picker takes precedence over
|
||||||
|
// settings defaults; empty strings from the picker are treated as
|
||||||
|
// "use whatever the server default is" (not sent).
|
||||||
|
const providerId =
|
||||||
|
providerIdOverride || _settings.opencodeProvider || undefined;
|
||||||
|
const modelId = modelIdOverride || _settings.opencodeModel || undefined;
|
||||||
|
|
||||||
|
const request: OpenCodeRequest = {
|
||||||
|
prompt: text,
|
||||||
|
context: this._context,
|
||||||
|
providerId,
|
||||||
|
modelId
|
||||||
|
};
|
||||||
|
// ...rest unchanged (status, callOpenCodeEdit, error handling)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改 `opencode_inline_prompt.ts` —— 加 select + 新 onSubmit 签名**
|
||||||
|
|
||||||
|
完整新内容:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||||
|
* Renders a model selector, a textarea, and send/cancel buttons. The owning
|
||||||
|
* cell is passed so we can resolve the cell context at submit time. submit
|
||||||
|
* and cancel are callbacks owned by OpenCodeCellActions.
|
||||||
|
*/
|
||||||
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
|
|
||||||
|
export interface IOpenCodeInlinePromptOptions {
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
providers: OpenCodeProvidersResponse | null;
|
||||||
|
defaultProviderId?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
|
private _textarea: HTMLTextAreaElement;
|
||||||
|
private _sendBtn: HTMLButtonElement;
|
||||||
|
private _cancelBtn: HTMLButtonElement;
|
||||||
|
private _select: HTMLSelectElement | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _cell: CodeCell,
|
||||||
|
options: IOpenCodeInlinePromptOptions
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
|
// Model selector — only rendered if providers is a non-empty list.
|
||||||
|
const flat = flattenProviders(options.providers);
|
||||||
|
if (flat.length > 0) {
|
||||||
|
this._select = document.createElement('select');
|
||||||
|
this._select.className = 'opencode-model-select';
|
||||||
|
for (const opt of flat) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = `${opt.providerId}|${opt.modelId}`;
|
||||||
|
o.textContent = `${opt.providerId} / ${opt.modelId}`;
|
||||||
|
this._select.appendChild(o);
|
||||||
|
}
|
||||||
|
// Default selection: match settings, otherwise first option.
|
||||||
|
const desired = `${options.defaultProviderId || ''}|${
|
||||||
|
options.defaultModelId || ''
|
||||||
|
}`;
|
||||||
|
const hasDefault = Array.from(this._select.options).some(
|
||||||
|
o => o.value === desired
|
||||||
|
);
|
||||||
|
this._select.value = hasDefault
|
||||||
|
? desired
|
||||||
|
: this._select.options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._textarea = document.createElement('textarea');
|
||||||
|
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
||||||
|
this._textarea.rows = 3;
|
||||||
|
this._sendBtn = document.createElement('button');
|
||||||
|
this._sendBtn.className = 'opencode-btn-send';
|
||||||
|
this._sendBtn.textContent = '🚀 发送';
|
||||||
|
this._sendBtn.disabled = options.disabled;
|
||||||
|
this._sendBtn.addEventListener('click', () => {
|
||||||
|
const text = this._textarea.value;
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [providerId, modelId] = this._readSelection();
|
||||||
|
options.onSubmit(text, providerId, modelId);
|
||||||
|
});
|
||||||
|
this._cancelBtn = document.createElement('button');
|
||||||
|
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||||
|
this._cancelBtn.textContent = '✕ 取消';
|
||||||
|
this._cancelBtn.addEventListener('click', () => {
|
||||||
|
options.onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'opencode-inline-actions';
|
||||||
|
actions.appendChild(this._sendBtn);
|
||||||
|
actions.appendChild(this._cancelBtn);
|
||||||
|
|
||||||
|
if (this._select) {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'opencode-model-label';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = '模型:';
|
||||||
|
label.appendChild(span);
|
||||||
|
label.appendChild(this._select);
|
||||||
|
this.node.appendChild(label);
|
||||||
|
}
|
||||||
|
this.node.appendChild(this._textarea);
|
||||||
|
this.node.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabled(disabled: boolean): void {
|
||||||
|
this._sendBtn.disabled = disabled;
|
||||||
|
if (this._select) {
|
||||||
|
this._select.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _readSelection(): [string | undefined, string | undefined] {
|
||||||
|
if (!this._select) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
const v = this._select.value;
|
||||||
|
const idx = v.indexOf('|');
|
||||||
|
if (idx < 0) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
return [v.slice(0, idx), v.slice(idx + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProviders(
|
||||||
|
providers: OpenCodeProvidersResponse | null
|
||||||
|
): { providerId: string; modelId: string }[] {
|
||||||
|
if (!providers || !providers.providers) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out: { providerId: string; modelId: string }[] = [];
|
||||||
|
for (const p of providers.providers) {
|
||||||
|
for (const m of p.models) {
|
||||||
|
out.push({ providerId: p.id, modelId: m.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改 `src/index.ts` —— fetch 成功后写缓存**
|
||||||
|
|
||||||
|
在 `callOpenCodeProviders(...).then(data => { ... console.log(...) }).catch(...)` 的 `.then` 回调里,**在打印日志之前**加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
.then(data => {
|
||||||
|
setOpenCodeProviders(data);
|
||||||
|
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
|
||||||
|
// ...rest unchanged
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
import 同步加 `import { setOpenCodeProviders } from './components/opencode_cell_actions';`(在已有的 import 行加)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 改 `style/base.css` —— 加 select 样式**
|
||||||
|
|
||||||
|
在 `.opencode-inline-prompt textarea` 之前加:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.opencode-inline-prompt .opencode-model-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-model-select {
|
||||||
|
flex: 1;
|
||||||
|
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-model-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 补单测 —— `opencode_cell_actions.spec.ts`**
|
||||||
|
|
||||||
|
在 `OpenCodeCellActions` 的现有 `it('clicking the button attaches an OpenCodeInlinePrompt to the cell', ...)` 之后,新增以下测试(在同一个 `describe` 内):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('renders a model selector when providers are cached', () => {
|
||||||
|
setOpenCodeProviders({
|
||||||
|
providers: [
|
||||||
|
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
|
||||||
|
{ id: 'openai', models: [{ id: 'gpt-5' }] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const select = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt .opencode-model-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).not.toBeNull();
|
||||||
|
expect(select.options.length).toBe(2);
|
||||||
|
expect(select.options[0].textContent).toBe('anthropic / claude-sonnet-4-20250514');
|
||||||
|
expect(select.options[1].textContent).toBe('openai / gpt-5');
|
||||||
|
// Default = first option when no settings match.
|
||||||
|
expect(select.value).toBe('anthropic|claude-sonnet-4-20250514');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects the settings default provider/model when it matches an option', () => {
|
||||||
|
setOpenCodeProviders({
|
||||||
|
providers: [
|
||||||
|
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
|
||||||
|
{ id: 'openai', models: [{ id: 'gpt-5' }] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
setOpenCodeRuntime({
|
||||||
|
settings: {
|
||||||
|
opencodeProvider: 'openai',
|
||||||
|
opencodeModel: 'gpt-5'
|
||||||
|
} as any,
|
||||||
|
serverSettings: {} as any
|
||||||
|
});
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const select = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt .opencode-model-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe('openai|gpt-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the selector when providers are not cached', () => {
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
setOpenCodeRuntime({ settings: {} as any, serverSettings: {} as any });
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const select = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt .opencode-model-select'
|
||||||
|
);
|
||||||
|
expect(select).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `describe` 顶部 import 加上:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeRuntime
|
||||||
|
} from '../components/opencode_cell_actions';
|
||||||
|
```
|
||||||
|
|
||||||
|
每个测试**结束后**调用 `setOpenCodeProviders(null)` 重置,避免污染其他测试。建议在每个测试的 `expect(...)` 之后加 `setOpenCodeProviders(null);` 一行清理。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 跑测试 + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||||
|
```
|
||||||
|
Expected: jest 28+ passed(原 25 + 3 新增);build 成功。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/opencode_cell_actions.ts src/components/opencode_inline_prompt.ts src/index.ts style/base.css src/__tests__/opencode_cell_actions.spec.ts
|
||||||
|
git commit -m "feat: model selector in inline prompt
|
||||||
|
|
||||||
|
The inline prompt panel now renders a <select> at the top, listing every
|
||||||
|
(provider, model) pair returned by GET /opencode-bridge/providers at
|
||||||
|
plugin activation. The default selection matches opencodeProvider /
|
||||||
|
opencodeModel from JupyterLab settings; the user can pick a different
|
||||||
|
model per request. The chosen value is sent as providerId/modelId in
|
||||||
|
the OpenCodeRequest, overriding the settings default for that call. If
|
||||||
|
the providers fetch failed at startup (e.g. opencode serve not running),
|
||||||
|
no selector is rendered and submissions fall back to the settings
|
||||||
|
defaults (or omit providerId/modelId so OpenCode picks its own default)."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: design.md 同步(CC)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `design.md` §2(给 inline 输入框段补一行模型选择器描述);新增 §6 "启动配置"(env 变量清单)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 改 `design.md` §2 item 2 的描述**,在第 2 段里"多行 `textarea`(placeholder ...)"前插一句:
|
||||||
|
> 顶部一行 `模型: <select>`(来自 `GET /opencode-bridge/providers` 启动时缓存),默认选中 `opencodeProvider`/`opencodeModel` settings 对应项,用户可改;提交时把选中的 `providerId`/`modelId` 一起带回,覆盖 settings 默认。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 新增 §6 "启动配置"**(放在现有 §5 之后,或合并进 §5)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ⚙️ 6. 启动配置(v3 修正)
|
||||||
|
|
||||||
|
所有 OpenCode Serve 连接参数在 `jupyter lab` 启动时通过环境变量指定,**不再**在 JupyterLab Settings Editor 中配置:
|
||||||
|
|
||||||
|
| 环境变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||||
|
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||||
|
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||||
|
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒) |
|
||||||
|
|
||||||
|
JupyterLab Settings Editor **只剩** 2 项,均用于 inline 输入框模型选择器的"默认选中":
|
||||||
|
- `opencodeProvider`
|
||||||
|
- `opencodeModel`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add design.md
|
||||||
|
git commit -m "docs: design.md v3 (startup env config + inline model selector)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 全量验证(CC)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 跑 pytest + jlpm test + jlpm build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest opencode_bridge/tests/ -q
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm test
|
||||||
|
YARN_NODE_LINKER=node-modules PATH="$PWD/.venv/bin:$PATH" .venv/bin/jlpm build
|
||||||
|
```
|
||||||
|
Expected: pytest 全过(32+);jest 全过(28+);build 成功。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 报告 v3 三个 commit + 验证结果**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 手动验收(用户执行)
|
||||||
|
|
||||||
|
`export OPENCODE_BRIDGE_URL=http://127.0.0.1:4096 && jupyter lab`(或带 User/Password/Timeout),打开 .ipynb:
|
||||||
|
- active cell 右上角单按钮 → inline 面板顶部出现 `模型: [select]`(列出 OpenCode Serve 的 provider/model)
|
||||||
|
- 默认选中 `opencodeProvider`/`opencodeModel` settings 值
|
||||||
|
- 切换选择 → 发送 → 这次请求的 `providerId`/`modelId` 是选中的
|
||||||
|
- 在 Settings Editor 看 `opencodeServerUrl` 等 4 个字段已经没有了
|
||||||
|
- 启动时 export 一个错误的 URL / 关掉 OpenCode Serve → 下拉框不出现,提交走 settings 兜底
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
# AI 按钮迁入原生 Cell Toolbar — 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 把 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮从 cell 底部 footer 迁移到 JupyterLab 原生 Cell toolbar(active cell 右上角、上移/下移按钮旁),并拆除整个 footer 链路。
|
||||||
|
|
||||||
|
**Architecture:** 新建 `OpenCodeCellActions` widget(构造函数直接接收 `CodeCell`),通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', factory)` 注册进原生 settings 驱动的 Cell toolbar;`schema/plugin.json` 以 `jupyter.lab.toolbars` 贡献该项。已核实机制:`createToolbarFactory` 把 cell 透传给 item factory(apputils 4.5.10 `lib/toolbar/factory.js:218`);`addFactory` 签名见 `lib/toolbar/registry.d.ts:36`。
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript / JupyterLab 4.5.10(npm deps)/ JupyterLab 4.6.2(runtime)/ jest / stylelint / prettier
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-23-cell-toolbar-actions-design.md`(已提交)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- toolbar item 名固定为 `opencode-cell-actions`,index.ts 注册名与 schema 贡献名必须逐字一致。
|
||||||
|
- 仅 `CodeCell` 显示按钮;非 CodeCell 返回空 `Widget`。
|
||||||
|
- 交互不变:`🪄 编辑` 用 `window.prompt`;结果/错误统一走 `Notification`;widget 内不渲染 error span。
|
||||||
|
- 无新 npm 依赖;不改 REST 契约;不改 Python 端。
|
||||||
|
- 代码风格:prettier(single quotes、无 trailing commas、无 arrow parens);CSS 类名 kebab-case(stylelint)。
|
||||||
|
- `setOpenCodeRuntime({ settings, serverSettings })` 签名不变,仅从旧文件搬到新文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: OpenCodeCellActions 组件(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Test: `src/__tests__/opencode_cell_actions.spec.ts`(新建)
|
||||||
|
- Create: `src/components/opencode_cell_actions.ts`(新建)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `callOpenCodeEdit(request, serverSettings)`(来自 `../api/opencode_client`,不变);`extractCellContext(cell, notebookPanel)`(来自 `../context/cell_context`,不变);`../types` 全部类型(不变)。
|
||||||
|
- Produces: `class OpenCodeCellActions extends Widget`,构造函数 `constructor(cell: CodeCell)`;`setOpenCodeRuntime(args: { settings: OpenCodeSettings; serverSettings: ServerConnection.ISettings }): void`;CSS 类 `opencode-cell-actions` / `opencode-btn opencode-btn-{optimize|fix|edit}`。Task 2 的 index.ts 依赖这两个导出。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试** — 新建 `src/__tests__/opencode_cell_actions.spec.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Unit tests for OpenCodeCellActions DOM rendering and button disabled states.
|
||||||
|
*
|
||||||
|
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||||
|
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
|
||||||
|
* transform the hoisted @jupyterlab/* packages out of the box).
|
||||||
|
*/
|
||||||
|
jest.mock('@jupyterlab/cells', () => ({
|
||||||
|
CodeCell: class CodeCell {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@jupyterlab/apputils', () => ({
|
||||||
|
Notification: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
success: jest.fn(),
|
||||||
|
warning: jest.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@lumino/widgets', () => {
|
||||||
|
class Widget {
|
||||||
|
public node: HTMLElement = document.createElement('div');
|
||||||
|
public id = '';
|
||||||
|
public parent: Widget | null = null;
|
||||||
|
private _isDisposed = false;
|
||||||
|
public get isDisposed(): boolean {
|
||||||
|
return this._isDisposed;
|
||||||
|
}
|
||||||
|
public addClass(cls: string): void {
|
||||||
|
this.node.classList.add(cls);
|
||||||
|
}
|
||||||
|
public dispose(): void {
|
||||||
|
this._isDisposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { Widget };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { OpenCodeCellActions } from '../components/opencode_cell_actions';
|
||||||
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
|
||||||
|
function makeFakeOutputs(items: any[]): any {
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return items.length;
|
||||||
|
},
|
||||||
|
get(i: number) {
|
||||||
|
return items[i];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeCell(
|
||||||
|
source: string,
|
||||||
|
errorOutputs: any[] = [],
|
||||||
|
notebookPath = 'foo.ipynb',
|
||||||
|
cellIndex = 0,
|
||||||
|
totalCells = 1
|
||||||
|
): CodeCell {
|
||||||
|
const model: any = {
|
||||||
|
id: 'cell-test',
|
||||||
|
type: 'code',
|
||||||
|
sharedModel: {
|
||||||
|
getSource: () => source
|
||||||
|
},
|
||||||
|
outputs: makeFakeOutputs(errorOutputs),
|
||||||
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||||
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||||
|
};
|
||||||
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
|
(cell as any).model = model;
|
||||||
|
|
||||||
|
// Parent chain: cell -> notebook -> panel (mirrors real JupyterLab, where
|
||||||
|
// the cell's grandparent Notebook matches the duck-typed panel lookup).
|
||||||
|
const notebook: any = { widgets: [] as CodeCell[] };
|
||||||
|
const panel: any = {
|
||||||
|
context: { path: notebookPath },
|
||||||
|
content: notebook
|
||||||
|
};
|
||||||
|
for (let i = 0; i < cellIndex; i++) {
|
||||||
|
notebook.widgets.push({
|
||||||
|
model: { sharedModel: { getSource: () => '' } }
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
notebook.widgets.push(cell);
|
||||||
|
for (let i = cellIndex + 1; i < totalCells; i++) {
|
||||||
|
notebook.widgets.push({
|
||||||
|
model: { sharedModel: { getSource: () => '' } }
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(cell, 'parent', {
|
||||||
|
value: notebook,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(notebook, 'parent', {
|
||||||
|
value: panel,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return cell as CodeCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenCodeCellActions', () => {
|
||||||
|
it('renders 3 buttons with the expected labels', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btns = actions.node.querySelectorAll('button');
|
||||||
|
expect(btns.length).toBe(3);
|
||||||
|
expect(btns[0].textContent).toBe('✨ 优化');
|
||||||
|
expect(btns[1].textContent).toBe('🐛 排错');
|
||||||
|
expect(btns[2].textContent).toBe('🪄 编辑');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables all buttons when the notebook panel cannot be resolved', () => {
|
||||||
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
|
(cell as any).model = {
|
||||||
|
id: 'orphan',
|
||||||
|
type: 'code',
|
||||||
|
sharedModel: { getSource: () => 'x = 1' },
|
||||||
|
outputs: makeFakeOutputs([]),
|
||||||
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||||
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||||
|
};
|
||||||
|
// No parent chain: extractCellContextFromCell must return null.
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btns = actions.node.querySelectorAll('button');
|
||||||
|
btns.forEach((b: HTMLButtonElement) => {
|
||||||
|
expect(b.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables optimize and edit; fix stays disabled without an error', () => {
|
||||||
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btns = actions.node.querySelectorAll('button');
|
||||||
|
expect((btns[0] as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((btns[1] as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect((btns[2] as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables the fix button when the cell has an error output', () => {
|
||||||
|
const cell = makeFakeCell(
|
||||||
|
'1/0',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'error',
|
||||||
|
ename: 'ZeroDivisionError',
|
||||||
|
evalue: 'div by zero',
|
||||||
|
traceback: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'foo.ipynb',
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btns = actions.node.querySelectorAll('button');
|
||||||
|
expect((btns[1] as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects model signals on dispose', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
actions.dispose();
|
||||||
|
const model = (cell as any).model;
|
||||||
|
expect(model.contentChanged.disconnect).toHaveBeenCalled();
|
||||||
|
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
||||||
|
Run: `jlpm test opencode_cell_actions`
|
||||||
|
Expected: FAIL — `Cannot find module '../components/opencode_cell_actions'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现组件** — 新建 `src/components/opencode_cell_actions.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Per-cell actions widget rendered inside JupyterLab's native Cell toolbar
|
||||||
|
* (top-right of the active cell, next to the move up/down buttons).
|
||||||
|
*
|
||||||
|
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...);
|
||||||
|
* the toolbar factory passes the owning Cell, so the cell arrives via the
|
||||||
|
* constructor instead of being resolved from `this.parent` on attach.
|
||||||
|
*/
|
||||||
|
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 } from '../api/opencode_client';
|
||||||
|
import { extractCellContext } from '../context/cell_context';
|
||||||
|
import type {
|
||||||
|
CellContext,
|
||||||
|
OpenCodeMode,
|
||||||
|
OpenCodeRequest,
|
||||||
|
OpenCodeResponse,
|
||||||
|
OpenCodeSettings
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// Module-level runtime injection (set by index.ts after settings load).
|
||||||
|
let _settings: OpenCodeSettings | null = null;
|
||||||
|
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||||
|
|
||||||
|
export function setOpenCodeRuntime(args: {
|
||||||
|
settings: OpenCodeSettings;
|
||||||
|
serverSettings: ServerConnection.ISettings;
|
||||||
|
}): void {
|
||||||
|
_settings = args.settings;
|
||||||
|
_serverSettings = args.serverSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading';
|
||||||
|
|
||||||
|
export class OpenCodeCellActions extends Widget {
|
||||||
|
private _cell: CodeCell;
|
||||||
|
private _context: CellContext | null = null;
|
||||||
|
private _status: Status = 'idle';
|
||||||
|
|
||||||
|
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._onModelChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||||
|
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onModelChange(): void {
|
||||||
|
this._context = extractCellContextFromCell(this._cell);
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _render(): void {
|
||||||
|
const node = this.node;
|
||||||
|
node.textContent = '';
|
||||||
|
|
||||||
|
const hasError = this._context?.error != null;
|
||||||
|
const loading = this._status === 'loading';
|
||||||
|
const baseDisabled = !this._context || loading;
|
||||||
|
|
||||||
|
const mkBtn = (
|
||||||
|
label: string,
|
||||||
|
mode: OpenCodeMode,
|
||||||
|
disabled: boolean
|
||||||
|
): HTMLButtonElement => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = `opencode-btn opencode-btn-${mode}`;
|
||||||
|
b.textContent = label;
|
||||||
|
b.disabled = disabled;
|
||||||
|
b.title = disabled
|
||||||
|
? loading
|
||||||
|
? 'OpenCode: 请求中…'
|
||||||
|
: 'OpenCode: 等待 cell 上下文…'
|
||||||
|
: label;
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
void this._onClick(mode);
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
|
||||||
|
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
|
||||||
|
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onClick(mode: OpenCodeMode): Promise<void> {
|
||||||
|
if (!this._context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_settings || !_serverSettings) {
|
||||||
|
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = '';
|
||||||
|
if (mode === 'edit') {
|
||||||
|
const input = window.prompt('请输入编辑指令:');
|
||||||
|
if (input === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prompt = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: OpenCodeRequest = {
|
||||||
|
mode,
|
||||||
|
prompt,
|
||||||
|
context: this._context,
|
||||||
|
providerId: _settings.opencodeProvider || undefined,
|
||||||
|
modelId: _settings.opencodeModel || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
this._status = 'loading';
|
||||||
|
this._render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||||
|
this._handleResponse(resp);
|
||||||
|
} catch (e) {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._render();
|
||||||
|
Notification.error(`OpenCode ${mode} 失败: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleResponse(resp: OpenCodeResponse): void {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._render();
|
||||||
|
if (resp.ok) {
|
||||||
|
const len = resp.finalSource.length;
|
||||||
|
Notification.info(
|
||||||
|
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
|
||||||
|
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||||
|
* then build the CellContext. (Unchanged from the old footer helper; the
|
||||||
|
* `Widget.findParent` helper was removed in @lumino/widgets 2.x.)
|
||||||
|
*/
|
||||||
|
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||||
|
let node: Widget | null = cell.parent;
|
||||||
|
let notebookPanel: NotebookPanel | null = null;
|
||||||
|
while (node) {
|
||||||
|
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
|
Run: `jlpm test opencode_cell_actions`
|
||||||
|
Expected: PASS — 5 tests passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/opencode_cell_actions.ts src/__tests__/opencode_cell_actions.spec.ts
|
||||||
|
git commit -m "feat: add OpenCodeCellActions widget for the native Cell toolbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 注册进原生 Cell toolbar,拆除 footer 链路
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/index.ts`(整体重写,下方给完整内容)
|
||||||
|
- Delete: `src/components/opencode_cell_footer.ts`、`src/components/opencode_cell_factory.ts`、`src/components/opencode_installer.ts`、`src/__tests__/opencode_cell_footer.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Task 1 的 `OpenCodeCellActions` / `setOpenCodeRuntime`;`IToolbarWidgetRegistry.addFactory<T>(widgetFactory: string, toolbarItemName: string, factory: (main: T) => Widget)`(apputils 4.5.10)。
|
||||||
|
- Produces: 注册名 `opencode-cell-actions`(Task 3 的 schema 必须逐字引用);插件不再依赖 `INotebookTracker`。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重写 `src/index.ts`** — 完整内容:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
JupyterFrontEnd,
|
||||||
|
JupyterFrontEndPlugin
|
||||||
|
} from '@jupyterlab/application';
|
||||||
|
|
||||||
|
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||||
|
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeRuntime
|
||||||
|
} from './components/opencode_cell_actions';
|
||||||
|
import { requestAPI } from './request';
|
||||||
|
import { readOpenCodeSettings } from './types';
|
||||||
|
import { callOpenCodeProviders } from './api/opencode_client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialization data for the opencode_bridge extension.
|
||||||
|
*/
|
||||||
|
const plugin: JupyterFrontEndPlugin<void> = {
|
||||||
|
id: 'opencode_bridge:plugin',
|
||||||
|
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||||
|
autoStart: true,
|
||||||
|
optional: [ISettingRegistry, IToolbarWidgetRegistry],
|
||||||
|
activate: (
|
||||||
|
app: JupyterFrontEnd,
|
||||||
|
settingRegistry: ISettingRegistry | null,
|
||||||
|
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||||
|
) => {
|
||||||
|
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||||
|
|
||||||
|
// Register the per-cell AI actions into the native Cell toolbar
|
||||||
|
// (top-right of the active cell, next to move up/down). Non-code
|
||||||
|
// cells get an empty widget so they show no AI buttons.
|
||||||
|
if (toolbarRegistry) {
|
||||||
|
toolbarRegistry.addFactory<Cell>(
|
||||||
|
'Cell',
|
||||||
|
'opencode-cell-actions',
|
||||||
|
(cell: Cell) => {
|
||||||
|
if (cell instanceof CodeCell) {
|
||||||
|
return new OpenCodeCellActions(cell);
|
||||||
|
}
|
||||||
|
return new Widget();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load settings + push into the actions module.
|
||||||
|
if (settingRegistry) {
|
||||||
|
void settingRegistry
|
||||||
|
.load(plugin.id)
|
||||||
|
.then(settings => {
|
||||||
|
const bridge = readOpenCodeSettings(settings.composite);
|
||||||
|
setOpenCodeRuntime({
|
||||||
|
settings: bridge,
|
||||||
|
serverSettings: app.serviceManager.serverSettings
|
||||||
|
});
|
||||||
|
console.log('opencode_bridge settings loaded:', bridge);
|
||||||
|
|
||||||
|
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||||
|
.then(data => {
|
||||||
|
const lines: string[] = [
|
||||||
|
'[opencode_bridge] Available OpenCode providers:'
|
||||||
|
];
|
||||||
|
for (const p of data.providers) {
|
||||||
|
const models = p.models.map(m => m.id).join(', ');
|
||||||
|
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(lines.join('\n'));
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
// 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?):',
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
console.error('Failed to load settings for opencode_bridge.', reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||||
|
.then(data => {
|
||||||
|
console.log('hello endpoint:', data);
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
console.error(
|
||||||
|
`The opencode_bridge server extension appears to be missing.\n${reason}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 删除 footer 链路文件**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm src/components/opencode_cell_footer.ts src/components/opencode_cell_factory.ts src/components/opencode_installer.ts src/__tests__/opencode_cell_footer.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 确认无残留引用**
|
||||||
|
|
||||||
|
Run: `grep -rn "opencode_cell_footer\|opencode_cell_factory\|opencode_installer\|installOpenCode" src/ schema/ style/`
|
||||||
|
Expected: 无任何输出
|
||||||
|
|
||||||
|
- [ ] **Step 4: 编译 + 测试**
|
||||||
|
|
||||||
|
Run: `jlpm build && jlpm test`
|
||||||
|
Expected: tsc + jupyter-builder 成功;jest 全部通过(8 个测试:cell_context 2 + opencode_bridge 1 + opencode_cell_actions 5 + opencode_client 若干,数量以实际输出为准,关键是无 footer 相关失败)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/index.ts
|
||||||
|
git commit -m "refactor: register AI actions in native Cell toolbar; remove cell footer chain"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: schema 贡献 + 样式
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `schema/plugin.json`(顶层加 `jupyter.lab.toolbars`)
|
||||||
|
- Modify: `style/base.css`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Task 2 注册的 item 名 `opencode-cell-actions`;Task 1 的 CSS 类 `opencode-cell-actions` / `opencode-btn`。
|
||||||
|
- Produces: 无新接口。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改 `schema/plugin.json`** — 在第 2 行 `"jupyter.lab.shortcuts": [],` 之后插入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"jupyter.lab.toolbars": {
|
||||||
|
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后文件头部应为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jupyter.lab.shortcuts": [],
|
||||||
|
"jupyter.lab.toolbars": {
|
||||||
|
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||||
|
},
|
||||||
|
"title": "opencode_bridge",
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
(原生默认按钮 rank 均为 50,我们排 100 → 出现在 delete-cell 右侧。)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写 `style/base.css`** — 在现有注释块之后追加:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* AI action buttons inside the native Cell toolbar (active cell, top-right). */
|
||||||
|
.opencode-cell-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
line-height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn:hover:enabled {
|
||||||
|
background: var(--jp-layout-color2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: lint + build**
|
||||||
|
|
||||||
|
Run: `jlpm lint:check && jlpm build`
|
||||||
|
Expected: stylelint / prettier / eslint 全过;build 成功
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add schema/plugin.json style/base.css
|
||||||
|
git commit -m "feat: contribute opencode-cell-actions to Cell toolbar settings + styles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: design.md 更新 + 全量验证
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `design.md`(第 2 节"交互与UI流程设计"第 1 条)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 无。
|
||||||
|
- Produces: 无。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 更新 `design.md`** — 把第 2 节第 1 条(`1. **悬浮 Toolbar(常驻)**:在每个 CodeCell 顶部渲染微型操作栏:` 整段及其 3 个子条目)替换为:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
1. **原生 Cell Toolbar 集成(2026-07-23 修正)**:不再自绘悬浮 toolbar。`✨ 优化` / `🐛 排错`(仅当 Cell 有 Error Output 时可用)/ `🪄 编辑` 三个按钮通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**(move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量验证**
|
||||||
|
|
||||||
|
Run: `jlpm test && jlpm lint:check && jlpm build`
|
||||||
|
Expected: 全部通过
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add design.md
|
||||||
|
git commit -m "docs: update design.md interaction flow for native Cell toolbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 手动验收(用户执行,非自动化)** — `jupyter lab` 打开任一 notebook:active cell 右上角出现三按钮(在 delete 右侧);无错误 cell 上 `🐛 排错` 禁用;切换 active cell 按钮随之移动;cell 底部不再有 footer 按钮条。
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# v4 — 修 OpenCodeClient fetch kwargs bug + SIGINT 悬挂
|
||||||
|
|
||||||
|
**Goal:** 修两个服务端 bug:
|
||||||
|
1. `OpenCodeClient._request` 在传 `HTTPRequest` 时又传 `**fetch_kwargs`(auth_username/auth_password)→ tornado 报 `kwargs can't be used if request is an HTTPRequest object`。
|
||||||
|
2. `jupyter lab` Ctrl+C 后 "received signal 2, stopping" 但进程不退出——`AsyncHTTPClient()` 单例持有持久连接,IOLoop 关不掉。
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `opencode_client.py`:`_request` 把 auth 挂到 `HTTPRequest` 构造参数,`fetch(request)` 不再带 kwargs;`OpenCodeClient` 加 `close()` 关闭它持有的 `AsyncHTTPClient`。
|
||||||
|
- `opencode_bridge/__init__.py`:模块加载时 `atexit.register(_close_opencode_http)`,在进程正常退出(含 jupyter 捕到 SIGINT 后有序退出)时关掉 tornado `AsyncHTTPClient` 单例,释放连接,IOLoop 能完成关闭。
|
||||||
|
- 测试:`test_client.py` 补 `close()` 行为 + 验证 fetch 用的 request 上带了 auth(而不是 fetch kwargs)。
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 / tornado AsyncHTTPClient / pytest-jupyter
|
||||||
|
|
||||||
|
**Spec:** 即时修复,无独立 spec doc(本 plan 即规格)。
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 仅改 `opencode_bridge/opencode_client.py`、`opencode_bridge/__init__.py`、`opencode_bridge/tests/test_client.py`(如需);`test_routes.py` 不动(除非路由测试也依赖 client 行为)。
|
||||||
|
- `OpenCodeClient.close()` 幂等:重复调用安全;若 `_http_client` 已关闭则跳过。
|
||||||
|
- `atexit` handler 捕获所有异常(关 client 不应阻断退出),用 `try/except Exception: pass`。
|
||||||
|
- 不新增依赖;不破坏现有 API 签名(`health` / `list_providers` / `create_session` / `send_message_sync` / `abort` / `delete_session` / `_request` 保持)。
|
||||||
|
- 现有 `FakeOpenCodeClient` 测试桩不动(它模拟 client,不直接用 `AsyncHTTPClient`)。
|
||||||
|
- 不动前端;不动 schema/CSS/design.md;这是纯服务端修复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 修 `opencode_client.py` — auth 挂到 request + `close()`
|
||||||
|
|
||||||
|
**Files:** `opencode_bridge/opencode_client.py`
|
||||||
|
|
||||||
|
完整新内容(用 `HTTPRequest` 的 `auth_username`/`auth_password` 参数,`fetch(request)` 不带 kwargs;加 `close()`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Async HTTP client for the local OpenCode server."""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||||
|
from tornado.httputil import HTTPHeaders
|
||||||
|
|
||||||
|
from .config import OpenCodeConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeError(Exception):
|
||||||
|
"""Raised when an OpenCode API request fails."""
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
|
||||||
|
# shutdown so the IOLoop can finish and the process can exit cleanly
|
||||||
|
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
|
||||||
|
# stopping" because the singleton keeps persistent connections alive).
|
||||||
|
def _close_opencode_http() -> None:
|
||||||
|
try:
|
||||||
|
AsyncHTTPClient().close()
|
||||||
|
except Exception:
|
||||||
|
# Never block interpreter shutdown on close errors.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(_close_opencode_http)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: OpenCodeConfig,
|
||||||
|
http_client: Optional[AsyncHTTPClient] = None,
|
||||||
|
) -> None:
|
||||||
|
self._config = config
|
||||||
|
# Use a dedicated client (not the singleton) so close() can
|
||||||
|
# deterministically release its connections without affecting
|
||||||
|
# other components. The module-level atexit still closes the
|
||||||
|
# singleton as a safety net.
|
||||||
|
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
|
||||||
|
self._owns_http_client = http_client is None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the underlying HTTP client's connections. Idempotent."""
|
||||||
|
if getattr(self, "_http_client", None) is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._owns_http_client:
|
||||||
|
self._http_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._http_client = None # type: ignore[assignment]
|
||||||
|
self._owns_http_client = False
|
||||||
|
|
||||||
|
async def health(self) -> dict[str, Any]:
|
||||||
|
return await self._request("GET", "/global/health")
|
||||||
|
|
||||||
|
async def list_providers(self) -> list[dict[str, Any]]:
|
||||||
|
return await self._request("GET", "/config/providers")
|
||||||
|
|
||||||
|
async def create_session(self, title: str) -> dict[str, Any]:
|
||||||
|
return await self._request("POST", "/session", {"title": title})
|
||||||
|
|
||||||
|
async def send_message_sync(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
parts: list[dict[str, Any]],
|
||||||
|
provider_id: Optional[str] = None,
|
||||||
|
model_id: Optional[str] = None,
|
||||||
|
system: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body: dict[str, Any] = {"parts": parts}
|
||||||
|
if provider_id is not None and model_id is not None:
|
||||||
|
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||||
|
if system is not None:
|
||||||
|
body["system"] = system
|
||||||
|
return await self._request(
|
||||||
|
"POST",
|
||||||
|
"/session/%s/message" % session_id,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def abort(self, session_id: str) -> bool:
|
||||||
|
result = await self._request("POST", "/session/%s/abort" % session_id)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def delete_session(self, session_id: str) -> bool:
|
||||||
|
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint(self) -> str:
|
||||||
|
return self._config.url
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Optional[dict[str, Any]] = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
url = self._config.url + path
|
||||||
|
headers = HTTPHeaders(
|
||||||
|
{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request_kwargs: dict[str, Any] = {
|
||||||
|
"method": method,
|
||||||
|
"headers": headers,
|
||||||
|
"request_timeout": self._config.request_timeout_seconds,
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
||||||
|
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
|
||||||
|
# auth_* as **kwargs to fetch() when the first arg is already an
|
||||||
|
# HTTPRequest is rejected by tornado ("kwargs can't be used if
|
||||||
|
# request is an HTTPRequest object").
|
||||||
|
if self._config.auth is not None:
|
||||||
|
request_kwargs["auth_username"] = self._config.auth[0]
|
||||||
|
request_kwargs["auth_password"] = self._config.auth[1]
|
||||||
|
|
||||||
|
request = HTTPRequest(url, **request_kwargs)
|
||||||
|
|
||||||
|
response = await self._http_client.fetch(request)
|
||||||
|
|
||||||
|
if 200 <= response.code < 300:
|
||||||
|
if not response.body:
|
||||||
|
return True
|
||||||
|
return json.loads(response.body.decode("utf-8"))
|
||||||
|
if response.code == 404:
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_body = response.body.decode("utf-8") if response.body else ""
|
||||||
|
raise OpenCodeError(
|
||||||
|
"OpenCode request %s %s failed with status %s: %s"
|
||||||
|
% (method, url, response.code, response_body)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: 应用上面完整新内容到 `opencode_bridge/opencode_client.py`**
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑现有 pytest 确认没破**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest opencode_bridge/tests/ -q`
|
||||||
|
Expected: 32 passed(注意:`test_client.py` 用 `FakeOpenCodeClient`,不直接调 `AsyncHTTPClient`,所以现有测试不应受影响)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add opencode_bridge/opencode_client.py
|
||||||
|
git commit -m "fix: set Basic Auth on HTTPRequest, not as fetch kwargs; close client cleanly
|
||||||
|
|
||||||
|
tornado rejects fetch(request, **kwargs) when the first arg is already an
|
||||||
|
HTTPRequest and the kwargs overlap request construction (auth_username /
|
||||||
|
auth_password). Move auth onto the HTTPRequest. Add OpenCodeClient.close()
|
||||||
|
to release its AsyncHTTPClient; module-level atexit closes the tornado
|
||||||
|
singleton so 'jupyter lab' can exit cleanly after SIGINT instead of
|
||||||
|
hanging on 'received signal 2, stopping'."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 补 `test_client.py` 测试
|
||||||
|
|
||||||
|
**Files:** `opencode_bridge/tests/test_client.py`(补 close + auth-on-request 行为测试)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 现有 `FakeOpenCodeClient`(保留,它不直接用 `AsyncHTTPClient`)。
|
||||||
|
- Produces: 新测试验证 `OpenCodeClient.close()` 幂等 + 调用后 `_http_client` 为 None;验证带 auth 的 `_request` 构造的 `HTTPRequest` 上有 `auth_username`/`auth_password`(而不是传给 `fetch`)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `opencode_bridge/tests/test_client.py` 看现有结构,只追加测试,**不改** `FakeOpenCodeClient`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 追加测试**(放在文件末尾):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
import pytest
|
||||||
|
from tornado.httpclient import HTTPRequest
|
||||||
|
|
||||||
|
from opencode_bridge.config import OpenCodeConfig
|
||||||
|
from opencode_bridge.opencode_client import OpenCodeClient
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(password: str = "") -> OpenCodeConfig:
|
||||||
|
return OpenCodeConfig(
|
||||||
|
url="http://fake-opencode",
|
||||||
|
user="opencode",
|
||||||
|
password=password,
|
||||||
|
request_timeout_seconds=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_sets_auth_on_request_not_fetch_kwargs(monkeypatch):
|
||||||
|
"""Bug fix: tornado rejects fetch(request, **kwargs) for auth_*; auth
|
||||||
|
must live on the HTTPRequest itself."""
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def fetch(self, request, **kwargs):
|
||||||
|
captured["request"] = request
|
||||||
|
captured["fetch_kwargs"] = kwargs
|
||||||
|
r = MagicMock()
|
||||||
|
r.code = 200
|
||||||
|
r.body = b'{"id": "s1"}'
|
||||||
|
return r
|
||||||
|
|
||||||
|
cfg = _cfg(password="secret")
|
||||||
|
client = OpenCodeClient(cfg, http_client=FakeClient()) # type: ignore[arg-type]
|
||||||
|
await client._request("GET", "/x")
|
||||||
|
|
||||||
|
# No kwargs passed to fetch.
|
||||||
|
assert captured["fetch_kwargs"] == {}
|
||||||
|
# Auth is on the HTTPRequest.
|
||||||
|
assert isinstance(captured["request"], HTTPRequest)
|
||||||
|
assert captured["request"].auth_username == "opencode"
|
||||||
|
assert captured["request"].auth_password == "secret"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_omits_auth_when_no_password(monkeypatch):
|
||||||
|
"""When password is empty, no auth_* is set on the request."""
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def fetch(self, request, **kwargs):
|
||||||
|
captured["request"] = request
|
||||||
|
captured["fetch_kwargs"] = kwargs
|
||||||
|
r = MagicMock()
|
||||||
|
r.code = 200
|
||||||
|
r.body = b'{"id": "s1"}'
|
||||||
|
return r
|
||||||
|
|
||||||
|
client = OpenCodeClient(_cfg(password=""), http_client=FakeClient()) # type: ignore[arg-type]
|
||||||
|
await client._request("GET", "/x")
|
||||||
|
|
||||||
|
assert captured["fetch_kwargs"] == {}
|
||||||
|
assert captured["request"].auth_username is None
|
||||||
|
assert captured["request"].auth_password is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_idempotent():
|
||||||
|
"""close() can be called multiple times safely."""
|
||||||
|
client = OpenCodeClient(_cfg())
|
||||||
|
client.close()
|
||||||
|
client.close() # must not raise
|
||||||
|
assert client._http_client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_closes_owned_client():
|
||||||
|
"""close() calls .close() on the AsyncHTTPClient we own."""
|
||||||
|
fake = MagicMock()
|
||||||
|
client = OpenCodeClient(_cfg(), http_client=fake)
|
||||||
|
# We didn't create it, so close() should NOT call .close() on it
|
||||||
|
# (we don't own it). But _http_client is reset.
|
||||||
|
client.close()
|
||||||
|
fake.close.assert_not_called()
|
||||||
|
assert client._http_client is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 跑 pytest**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest opencode_bridge/tests/ -q`
|
||||||
|
Expected: 32 + 4 = 36 passed(若现有测试数变化,以实际输出为准,全部 pass)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add opencode_bridge/tests/test_client.py
|
||||||
|
git commit -m "test: cover auth-on-request and close() in OpenCodeClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 全量验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest opencode_bridge/tests/ -v
|
||||||
|
PATH="$PWD/.venv/bin:$PATH" jlpm test
|
||||||
|
```
|
||||||
|
Expected: pytest 全过;jest 全过(前端未动,仍是 25 passed)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 手动验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENCODE_BRIDGE_URL=http://127.0.0.1:4096
|
||||||
|
export OPENCODE_BRIDGE_PASSWORD=yourpassword # 若 OpenCode Serve 启了 auth
|
||||||
|
jupyter lab
|
||||||
|
# 在 notebook 里点 🪄 → 选 model → 输入指令 → 发送:不再报 "kwargs can't be used..."
|
||||||
|
# Ctrl+C: 进程应快速干净退出(不再 hang "received signal 2, stopping")
|
||||||
|
```
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AI 操作按钮迁入原生 Cell Toolbar — 设计
|
||||||
|
|
||||||
|
- 日期: 2026-07-23
|
||||||
|
- 状态: 已批准(用户于 2026-07-23 确认)
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
当前 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮渲染在每个 cell **底部** 的 `ICellFooter` 里,链路为:`installOpenCodeEverywhere` 替换每个 notebook 的 `contentFactory` → `OpenCodeCellContentFactory.createCellFooter()` → `OpenCodeCellFooter` widget。
|
||||||
|
|
||||||
|
问题:按钮位置不符合预期 —— 应出现在每个 cell **右上角**、JupyterLab 原生 cell toolbar 的上移/下移按钮(`move-cell-up` / `move-cell-down`)旁边。
|
||||||
|
|
||||||
|
## 已确认的决策
|
||||||
|
|
||||||
|
| 问题 | 决策 |
|
||||||
|
| ----------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| 可见性语义 | 跟随原生 toolbar:只在 **active cell** 右上角显示(与上移/下移按钮行为严格一致) |
|
||||||
|
| 底部 footer | **删除**,三个按钮全部上移 |
|
||||||
|
| 点击交互 | 本次不变:`🪄 编辑` 仍用 `window.prompt`;结果仍走 `Notification`;inline prompt box / diff 面板留待后续 |
|
||||||
|
| 作用范围 | 仅 `CodeCell`;Markdown 等 cell 不显示 AI 按钮(现状 footer 挂在所有 cell 上,属顺带修正) |
|
||||||
|
|
||||||
|
## 机制(JupyterLab 4.6 真实扩展点,已核实源码)
|
||||||
|
|
||||||
|
- 原生 cell toolbar 由内置 `@jupyterlab/cell-toolbar-extension` 提供,toolbar factory 名为 `'Cell'`,settings 驱动;默认按钮(含 `move-cell-up` / `move-cell-down`,rank 均为默认 50)由其 schema 的 `jupyter.lab.toolbars` 贡献。
|
||||||
|
- 第三方扩展通过 `IToolbarWidgetRegistry.addFactory<Cell>('Cell', '<itemName>', factory)` 注册 widget factory;**factory 收到具体的 `Cell` 实例**,toolbar 由 tracker 按 cell model 缓存。
|
||||||
|
- 自己的 `schema/plugin.json` 顶层加 `"jupyter.lab.toolbars": { "Cell": [...] }` 即可把该项贡献进原生 toolbar,无需 DOM 注入或 CSS 定位。
|
||||||
|
- 原生 toolbar 只渲染在 active cell 内(`activeCellChanged` 时移动),空间不足时自动隐藏 —— 我们的按钮随它一起获得这些行为。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
### 组件改造
|
||||||
|
|
||||||
|
- `src/components/opencode_cell_footer.ts` → 改造为 `src/components/opencode_cell_actions.ts`:
|
||||||
|
- `OpenCodeCellActions extends Widget`,**构造函数直接接收 `CodeCell`**(factory 传入),不再在 `onAfterAttach` 里靠 `this.parent instanceof CodeCell` 解析宿主。
|
||||||
|
- `model.contentChanged` / `stateChanged` 订阅保持,`onBeforeDetach` 摘除。
|
||||||
|
- 渲染逻辑不变:`✨ 优化` / `🐛 排错`(无 error output 时禁用) / `🪄 编辑`;loading 时全部禁用。
|
||||||
|
- 错误提示统一走 `Notification`,widget 内不再渲染 error span(toolbar 空间小)。
|
||||||
|
- 模块级 `setOpenCodeRuntime()` 运行时注入机制不变。
|
||||||
|
- 删除 `src/components/opencode_cell_factory.ts`、`src/components/opencode_installer.ts`。
|
||||||
|
|
||||||
|
### 注册与配置
|
||||||
|
|
||||||
|
- `src/index.ts`:
|
||||||
|
- `optional` 增加 `IToolbarWidgetRegistry`(来自 `@jupyterlab/apputils`,已是依赖)。
|
||||||
|
- activate 中:`toolbarRegistry.addFactory('Cell', 'opencode-cell-actions', (cell) => cell instanceof CodeCell ? new OpenCodeCellActions(cell) : new Widget())`;非 CodeCell 返回空 widget。
|
||||||
|
- 移除 `installOpenCodeEverywhere` 调用及 import。
|
||||||
|
- `schema/plugin.json` 顶层增加:
|
||||||
|
```json
|
||||||
|
"jupyter.lab.toolbars": {
|
||||||
|
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
默认按钮 rank 为 50,我们排 100 → 出现在 delete-cell 右侧,紧邻上移/下移按钮组。
|
||||||
|
- `style/base.css`:新增 `.opencode-cell-actions` 紧凑按钮样式(适配原生 toolbar 高度)。
|
||||||
|
|
||||||
|
### 上下文提取(不变)
|
||||||
|
|
||||||
|
widget attach 后仍用现有 `extractCellContextFromCell`(沿 `cell.parent` 链 duck-typing 找 `NotebookPanel`)。cell 实例改由构造参数传入,比现在更可靠。
|
||||||
|
|
||||||
|
### 测试与文档
|
||||||
|
|
||||||
|
- `src/__tests__/opencode_cell_footer.spec.ts` → `opencode_cell_actions.spec.ts`:构造时直接传 cell,删除 `Object.defineProperty(footer, 'parent', ...)` 相关 hack,断言不变(三按钮渲染、禁用态)。
|
||||||
|
- `design.md` 第 2 节交互描述更新:悬浮 toolbar → 原生 cell toolbar(仅 active cell 显示)。
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- 纯前端 UI 搬迁;REST 契约、Python server 端零改动。
|
||||||
|
- `cell-toolbar-extension` 被禁用时,我们的按钮与原生按钮一起消失(行为一致,可接受)。
|
||||||
|
- 移除 contentFactory 覆写后,不再受"已打开的 cell 需 reload 才生效"的限制。
|
||||||
|
- `IToolbarWidgetRegistry` 声明为 `optional`,registry 缺失时插件仍可激活(按钮不显示)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `jlpm test`、`jlpm lint:check`、`jlpm build` 全部通过。
|
||||||
|
- 手动:`jupyter lab` 打开 notebook,active cell 右上角出现三个按钮,位于 delete 按钮右侧;无错误的 cell 上 `🐛 排错` 禁用;切换 active cell 按钮随之移动。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Cell Toolbar Actions v2 — 单按钮 + inline 输入框 + 删除 mode
|
||||||
|
|
||||||
|
- 日期: 2026-07-23
|
||||||
|
- 状态: 已批准(用户于 2026-07-23 确认)
|
||||||
|
- 取代: v1(`2026-07-23-cell-toolbar-actions-design.md`,commit 9e7c36e)。本轮是 v1 的演进。
|
||||||
|
|
||||||
|
## 背景:v1 → v2
|
||||||
|
|
||||||
|
v1 把 `✨ 优化` / `🐛 排错` / `🪄 编辑` 三个按钮从 cell 底部 footer 迁到原生 cell toolbar。用户反馈 4 个问题,本轮一并解决:
|
||||||
|
|
||||||
|
1. **Settings 不生效** —— v1 只在 `settingRegistry.load(...).then(...)` 调一次 `setOpenCodeRuntime`,没订阅 `settings.changed`,Settings Editor 改了值后模块级 `_settings` 仍是旧的。
|
||||||
|
2. **单元格高度被压** —— v1 三个文字按钮让原生 cell toolbar 变高,把 code 编辑区挤小。
|
||||||
|
3. **三个按钮合并为一个** —— 用户选 "完全合并为一个动作(按输入语义判断)"。
|
||||||
|
4. **点击后 cell 内显示输入框** —— `design.md` v1 留的 inline prompt box,本轮实现。
|
||||||
|
|
||||||
|
同时用户给出两个架构边界(必须遵守):
|
||||||
|
- **前端调用后端时,只获取 cell 的 input/output/error,不做任何处理**(不决策 mode、不解析语义)
|
||||||
|
- **1 notebook → 1 session**(`SessionManager` 已保证,本轮不动)
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
| 维度 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 按钮 | 单个图标按钮(🪄) |
|
||||||
|
| 交互 | 点击 → cell 内 inline 输入框(textarea + 🚀 发送 + ✕ 取消) |
|
||||||
|
| `mode` 概念 | **彻底删除**。前端 request body 不再包含 `mode`;服务端 `EditHandler` 不再读 `mode`,响应体也不再回 `mode`;`MODE_SYSTEM_PROMPTS` 字典删除 |
|
||||||
|
| 服务端系统提示词 | 唯一一条统一提示词,LLM 根据用户自然语言 + context(含 traceback)自己判断优化/排错/编辑 |
|
||||||
|
| 成功时 | 前端 `cell.model.sharedModel.setSource(resp.finalSource)` 就地替换 cell 源码 + Notification |
|
||||||
|
| Settings 实时生效 | `index.ts` 订阅 `settings.changed` → 重灌 `setOpenCodeRuntime` |
|
||||||
|
| `providerId` / `modelId` | 仍从 JupyterLab settings 读 → 透传(用户配置,非"对 cell 数据的处理",保留) |
|
||||||
|
| 作用范围 | 仅 `CodeCell`;非 `CodeCell` → factory 返回空 `Widget` |
|
||||||
|
| 之前的 footer | 已删(v1) |
|
||||||
|
|
||||||
|
## 接口契约
|
||||||
|
|
||||||
|
**Request** `POST /opencode-bridge/edit`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "用户在 inline 输入框里写的自然语言指令",
|
||||||
|
"context": {
|
||||||
|
"notebookPath": "analysis/demo.ipynb",
|
||||||
|
"cellId": "cell-3",
|
||||||
|
"language": "python",
|
||||||
|
"cellIndex": 3,
|
||||||
|
"totalCells": 5,
|
||||||
|
"source": "import matplotlib...",
|
||||||
|
"previousCode": "import pandas as pd",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"providerId": "anthropic",
|
||||||
|
"modelId": "claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **无 `mode` 字段**
|
||||||
|
- `context.error` 非空时,服务端在 OpenCode parts 里自动加 `<traceback>` 段(原 `_build_request_body` 行为,保留)
|
||||||
|
- `<instruction>` 段:只要 `prompt` 非空就加(原条件 `mode == "edit"` 守卫删除)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{ "ok": true, "finalSource": "import plotly...", "sessionId": "ses-abc", "notebookPath": "analysis/demo.ipynb" }
|
||||||
|
```
|
||||||
|
- **无 `mode` 字段**
|
||||||
|
|
||||||
|
**系统提示词(统一,常量)**:
|
||||||
|
```
|
||||||
|
你是一个代码编辑助手。基于提供的代码上下文(以及 traceback,如果有),按照用户的指令修改代码。返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。
|
||||||
|
```
|
||||||
|
(刻意保留 v1 `'edit'` 提示词的核心子串 `"你是一个代码编辑助手"`,以便 `test_edit_handler` 的既有断言继续通过 —— 该测试只断言子串存在,不强耦合具体措辞。)
|
||||||
|
|
||||||
|
## 机制边界确认
|
||||||
|
|
||||||
|
- **前端**:`OpenCodeCellActions`(toolbar 里的单图标)+ `OpenCodeInlinePrompt`(cell 内的输入框 widget,`Widget.attach` 到 `cell.node`)。组件代码只做:渲染按钮 / 显示输入框 / 取 `cell.model.sharedModel.getSource()` / 取 outputs / 取 error / 拼 `context` / POST / 成功调 `setSource` / 失败 Notification。**不**做 mode 决策、**不**做语义判断、**不**管 session。
|
||||||
|
- **服务端**:拥有 `SessionManager`(1 notebook 1 sid,不动);`EditHandler` 唯一拥有的 "决策" 是 "是否包含 traceback"(由 `context.error` 决定,机械规则不算"处理");系统提示词选择(从前端决定的 mode 改为服务端的常量);调用 OpenCode Serve。Session 失效(404/not found)时 invalidate,前端重发。
|
||||||
|
- **OpenCode Serve**:按系统提示词 + 上下文 + 用户指令干活,自己判断优化/排错/编辑。
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- 后端 `mode` 字段(若调用方仍发)被忽略,不影响响应(为旧 API 兜底)。`MODE_SYSTEM_PROMPTS` 删除后,`'optimize'` 和 `'fix'` 提示词从默认路径不可达 —— 这是有意的(用户选择 "完全合并")。若以后需要可走专门 API。
|
||||||
|
- 前端 `OpenCodeMode` 类型别名删除;`OpenCodeRequest.mode` 字段删除;`OpenCodeSuccess.mode` 字段删除。引用了这些的测试 / 组件同步更新。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `pytest opencode_bridge/tests/` 全过(含 `test_edit_handler`,改写后)
|
||||||
|
- `jlpm test` 全过(单测重写 `opencode_cell_actions.spec.ts`,`opencode_client.spec.ts` 去掉 `mode`)
|
||||||
|
- `jlpm build` 成功
|
||||||
|
- 手动:`jupyter lab` 打开 notebook,active cell 右上角出现单个 🪄 图标;点击 → cell 底部出现输入框(带 placeholder、无默认值);输入指令 → 发送 → cell 源码被替换;切 cell 按钮随之移动
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Cell Toolbar Actions v3 — 启动时配置 + 动态模型选择器
|
||||||
|
|
||||||
|
- 日期: 2026-07-23
|
||||||
|
- 状态: 已批准(用户于 2026-07-23 确认,v3-final: settings 一项不留)
|
||||||
|
- 取代: v2 `OpenCodeSettings` 类型 + v1/v2 全部 JupyterLab plugin settings。`opencode_bridge` 插件本轮**完全不再使用 JupyterLab Settings Editor**:所有连接配置走启动环境变量,模型选择完全动态(每次 inline 输入框里挑)。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
v2 之后用户提了 2 个调整:
|
||||||
|
|
||||||
|
1. **`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` 不再在 JupyterLab Settings Editor 里配** —— 全部在启动 jupyter server 时通过环境变量指定。**随后**用户进一步要求:`opencodeProvider` / `opencodeModel` 这两项 settings 也不保留 —— 模型选择器**完全动态**,每次在 inline 输入框里挑,没有"设置默认值"的概念。**结果**:`schema/plugin.json` 的 `properties` 变为 `{}`(只剩非 settings 的 schema 注释 `jupyter.lab.toolbars` 贡献)。
|
||||||
|
2. **inline 输入框增加模型选择器** —— 从 `GET /opencode-bridge/providers` 启动时拉取并缓存;每次请求时用户挑一个 provider/model;无任何 settings 默认。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
| 维度 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 启动 env(4 个) | `OPENCODE_BRIDGE_URL` / `_USER` / `_PASSWORD` / `_TIMEOUT`(秒,默认 120);`jupyter lab` 启动前 export;`config.py` `resolve_config` 走 `os.environ.get(ENV) or DEFAULT`,**完全不再**从 JupyterLab settings 兜底(没有 settings 可读) |
|
||||||
|
| JupyterLab Settings | **无**。`schema/plugin.json` `properties: {}`;`opencode_bridge` 插件不再 `optional: [ISettingRegistry]`;`settingRegistry.load(...)` / `setOpenCodeRuntime({settings})` 整段删除 |
|
||||||
|
| 前端 `OpenCodeSettings` 类型 | **删除整个类型**;`DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 删除;`opencode_bridge.spec.ts` 中相关测试删除 |
|
||||||
|
| `setOpenCodeRuntime` | 改名为 `setOpenCodeServerSettings(serverSettings)`,只接收 `ServerConnection.ISettings`(Jupyter 自己的 serverSettings,用于 `callOpenCodeEdit` 拼 URL);不再有 `_settings` 模块级 |
|
||||||
|
| `index.ts` | `optional: [IToolbarWidgetRegistry]`(去掉 `ISettingRegistry`);激活时:注册 toolbar factory + 拉 providers 写缓存(无 settings block) |
|
||||||
|
| 模型选择器 UI | inline 面板顶部加一行 `模型: <select>`(单个下拉),选项 label `provider / model`,value `providerId\|modelId`(`\|` 分隔,IDs 不含此字符);textarea 在其下方 |
|
||||||
|
| 默认选中 | 下拉框**第一项**(无 settings 可作默认) |
|
||||||
|
| 提交覆盖 | prompt 的 `onSubmit(text, providerId, modelId)` 把当前选中带回去;actions 用它作为 request body 的 `providerId`/`modelId`(无 settings 兜底,prompt 必须有选择) |
|
||||||
|
| Providers 数据来源 | `GET /opencode-bridge/providers` 在 `index.ts` 启动时拉取;通过新 `setOpenCodeProviders(p)` 写到 `opencode_cell_actions.ts` 模块级 `_providers` 缓存;inline prompt 构造时读 |
|
||||||
|
| Providers 拉取失败 | 缓存为 `null`,inline prompt **不渲染** select;提交时 `providerId`/`modelId` 为 `undefined` → **不发**这两个字段(OpenCode Serve 用它自己的默认)。**优雅降级** |
|
||||||
|
| schema `jupyter.lab.toolbars` | 顶层 `jupyter.lab.toolbars: { "Cell": [{ "name": "opencode-cell-actions", "rank": 100 }] }` 保留(toolbar item 仍需 schema 贡献) |
|
||||||
|
|
||||||
|
## 接口契约
|
||||||
|
|
||||||
|
**前端 `OpenCodeRequest`**:与 v2 相同(无 `mode`):
|
||||||
|
```json
|
||||||
|
{ "prompt": "...", "context": { ... }, "providerId": "...", "modelId": "..." }
|
||||||
|
```
|
||||||
|
- `providerId` / `modelId` 可选;**只在** inline 选择器有选中项时发;providers 拉取失败时不发。
|
||||||
|
|
||||||
|
**后端**:与 v2 相同。`config.py` 新增:
|
||||||
|
```python
|
||||||
|
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||||
|
DEFAULT_REQUEST_TIMEOUT = 120
|
||||||
|
```
|
||||||
|
`resolve_config` 4 个字段全部走 `os.environ.get(ENV) or DEFAULT`。`requestTimeoutSeconds` 是 server-side(OpenCodeClient → OpenCode Serve 的 httpx timeout),与前端无关。
|
||||||
|
|
||||||
|
**环境变量清单**:
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENCODE_BRIDGE_URL` | `http://127.0.0.1:4096` | OpenCode Serve 地址 |
|
||||||
|
| `OPENCODE_BRIDGE_USER` | `opencode` | HTTP Basic Auth 用户名 |
|
||||||
|
| `OPENCODE_BRIDGE_PASSWORD` | `""`(无 auth) | HTTP Basic Auth 密码 |
|
||||||
|
| `OPENCODE_BRIDGE_TIMEOUT` | `120` | 请求 OpenCode Serve 超时(秒,整数) |
|
||||||
|
|
||||||
|
## 机制
|
||||||
|
|
||||||
|
- **前端**:
|
||||||
|
- `opencode_cell_actions.ts` 模块级:`_providers`(`setOpenCodeProviders` 写入)、`_serverSettings`(`setOpenCodeServerSettings` 写入)。**无** `_settings`。
|
||||||
|
- `OpenCodeInlinePrompt` 构造接收 `providers`(必填,可能为 `null`);无 `defaultProviderId`/`defaultModelId` 参数;默认选中第一项。
|
||||||
|
- 点击提交 → prompt 回调 `(text, providerId?, modelId?)`;actions 用回传的 provider/model 写进 `OpenCodeRequest`(`undefined` → 不发)。
|
||||||
|
- **服务端**:`config.py` 不再读 JupyterLab settings 字典(`handler.settings.get("opencode_bridge", {})` 返回 `{}`);SessionManager / EditHandler 不变。
|
||||||
|
- **OpenCode**:与 v2 相同。
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- **`opencode_bridge` 插件不再读 JupyterLab settings**;之前在 Settings Editor 设过 6 个字段(`opencodeServerUrl` / `User` / `Password` / `requestTimeoutSeconds` / `opencodeProvider` / `opencodeModel`)的用户:**全部失效**,需改用环境变量(连接类)或在 inline 输入框里挑(模型)。**行为变更,需在 release note 标注**。
|
||||||
|
- 4 个 env 变量 + 默认值覆盖之前 6 个 settings 字段的所有功能;模型选择器替代 `opencodeProvider`/`opencodeModel` settings 的"默认"作用(但完全由用户每次手选,无持久默认)。
|
||||||
|
- `OpenCodeSettings` / `DEFAULT_OPENCODE_SETTINGS` / `readOpenCodeSettings` 类型/常量/函数整组删除;`opencode_bridge.spec.ts` 中相关测试删除。
|
||||||
|
- `setOpenCodeRuntime` 重命名为 `setOpenCodeServerSettings`(签名 `{ serverSettings }`),所有调用方更新。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `pytest opencode_bridge/tests/` 全过
|
||||||
|
- `jlpm test` 全过(单测覆盖: select 渲染 N 项 / 默认第一项 / submit 覆盖 / providers 降级)
|
||||||
|
- `jlpm build` 成功
|
||||||
|
- 手动:`export OPENCODE_BRIDGE_URL=... && jupyter lab` → active cell 右上角单按钮 → inline 面板顶部 `模型: [select]`(列出 provider/model,默认第一项) → 切换选择 → 发送 → request 带新 provider/model;Settings Editor 中 `opencode_bridge` 无任何字段
|
||||||
+23
-16
@@ -1,18 +1,25 @@
|
|||||||
"""Configuration resolution for the opencode-bridge extension.
|
"""Configuration resolution for the opencode-bridge extension.
|
||||||
|
|
||||||
Priority order (highest to lowest):
|
All connection fields (URL / user / password / request timeout) are resolved
|
||||||
1. jupyter settings dict (from schema/plugin.json)
|
exclusively from environment variables + built-in defaults at server startup.
|
||||||
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD)
|
They are NOT stored in the JupyterLab plugin settings.
|
||||||
3. Built-in defaults
|
|
||||||
|
Env vars:
|
||||||
|
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||||
|
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||||
|
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||||
|
OPENCODE_BRIDGE_TIMEOUT — request timeout in seconds (default 120)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import NamedTuple, Optional, Tuple
|
from typing import NamedTuple, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||||
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||||
|
ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"
|
||||||
|
|
||||||
DEFAULT_URL = "http://127.0.0.1:4096"
|
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||||
DEFAULT_USER = "opencode"
|
DEFAULT_USER = "opencode"
|
||||||
@@ -23,7 +30,7 @@ class OpenCodeConfig(NamedTuple):
|
|||||||
url: str
|
url: str
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT
|
request_timeout_seconds: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth(self) -> Optional[Tuple[str, str]]:
|
def auth(self) -> Optional[Tuple[str, str]]:
|
||||||
@@ -34,18 +41,18 @@ class OpenCodeConfig(NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||||
"""Resolve OpenCode connection config from jupyter settings + env + defaults."""
|
"""Resolve OpenCode connection config from environment variables + defaults.
|
||||||
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
|
|
||||||
|
|
||||||
|
The `jupyter_settings` argument is accepted for API compatibility but no
|
||||||
|
fields are read from it — all connection config is now startup-only
|
||||||
|
(environment variables).
|
||||||
|
"""
|
||||||
|
_ = jupyter_settings # intentionally unused; see module docstring
|
||||||
return OpenCodeConfig(
|
return OpenCodeConfig(
|
||||||
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL,
|
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||||
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER,
|
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||||
password=(
|
password=os.environ.get(ENV_PASSWORD) or "",
|
||||||
bridge.get("opencodeServerPassword")
|
|
||||||
or os.environ.get(ENV_PASSWORD)
|
|
||||||
or ""
|
|
||||||
),
|
|
||||||
request_timeout_seconds=int(
|
request_timeout_seconds=int(
|
||||||
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT)
|
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Async HTTP client for the local OpenCode server."""
|
"""Async HTTP client for the local OpenCode server."""
|
||||||
|
|
||||||
|
import atexit
|
||||||
import json
|
import json
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -13,6 +14,21 @@ class OpenCodeError(Exception):
|
|||||||
"""Raised when an OpenCode API request fails."""
|
"""Raised when an OpenCode API request fails."""
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
|
||||||
|
# shutdown so the IOLoop can finish and the process can exit cleanly
|
||||||
|
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
|
||||||
|
# stopping" because the singleton keeps persistent connections alive).
|
||||||
|
def _close_opencode_http() -> None:
|
||||||
|
try:
|
||||||
|
AsyncHTTPClient().close()
|
||||||
|
except Exception:
|
||||||
|
# Never block interpreter shutdown on close errors.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(_close_opencode_http)
|
||||||
|
|
||||||
|
|
||||||
class OpenCodeClient:
|
class OpenCodeClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -20,7 +36,24 @@ class OpenCodeClient:
|
|||||||
http_client: Optional[AsyncHTTPClient] = None,
|
http_client: Optional[AsyncHTTPClient] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
self._http_client = http_client or AsyncHTTPClient()
|
# Use a dedicated client (not the singleton) so close() can
|
||||||
|
# deterministically release its connections without affecting
|
||||||
|
# other components. The module-level atexit still closes the
|
||||||
|
# singleton as a safety net.
|
||||||
|
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
|
||||||
|
self._owns_http_client = http_client is None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the underlying HTTP client's connections. Idempotent."""
|
||||||
|
if getattr(self, "_http_client", None) is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._owns_http_client:
|
||||||
|
self._http_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._http_client = None # type: ignore[assignment]
|
||||||
|
self._owns_http_client = False
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
async def health(self) -> dict[str, Any]:
|
||||||
return await self._request("GET", "/global/health")
|
return await self._request("GET", "/global/health")
|
||||||
@@ -58,6 +91,16 @@ class OpenCodeClient:
|
|||||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
|
async def list_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""List all messages in the given OpenCode session.
|
||||||
|
|
||||||
|
Returns the raw OpenCode response: a list of
|
||||||
|
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
|
||||||
|
The server extension is responsible for projecting this into a
|
||||||
|
frontend-friendly `{role, content}[]` shape.
|
||||||
|
"""
|
||||||
|
return await self._request("GET", "/session/%s/message" % session_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoint(self) -> str:
|
def endpoint(self) -> str:
|
||||||
return self._config.url
|
return self._config.url
|
||||||
@@ -82,15 +125,17 @@ class OpenCodeClient:
|
|||||||
}
|
}
|
||||||
if body is not None:
|
if body is not None:
|
||||||
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
||||||
|
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
|
||||||
|
# auth_* as **kwargs to fetch() when the first arg is already an
|
||||||
|
# HTTPRequest is rejected by tornado ("kwargs can't be used if
|
||||||
|
# request is an HTTPRequest object").
|
||||||
|
if self._config.auth is not None:
|
||||||
|
request_kwargs["auth_username"] = self._config.auth[0]
|
||||||
|
request_kwargs["auth_password"] = self._config.auth[1]
|
||||||
|
|
||||||
request = HTTPRequest(url, **request_kwargs)
|
request = HTTPRequest(url, **request_kwargs)
|
||||||
|
|
||||||
fetch_kwargs: dict[str, str] = {}
|
response = await self._http_client.fetch(request)
|
||||||
if self._config.auth is not None:
|
|
||||||
fetch_kwargs["auth_username"] = self._config.auth[0]
|
|
||||||
fetch_kwargs["auth_password"] = self._config.auth[1]
|
|
||||||
|
|
||||||
response = await self._http_client.fetch(request, **fetch_kwargs)
|
|
||||||
|
|
||||||
if 200 <= response.code < 300:
|
if 200 <= response.code < 300:
|
||||||
if not response.body:
|
if not response.body:
|
||||||
|
|||||||
+80
-25
@@ -14,20 +14,16 @@ from .session_manager import SessionManager
|
|||||||
log = logging.getLogger("opencode_bridge.routes")
|
log = logging.getLogger("opencode_bridge.routes")
|
||||||
|
|
||||||
|
|
||||||
MODE_SYSTEM_PROMPTS = {
|
# Unified system prompt — the LLM (OpenCode) is asked to return its reply
|
||||||
"optimize": (
|
# as MARKDOWN (code wrapped in ```language fences; a brief explanation is
|
||||||
"你是一个代码优化专家。请基于用户提供的代码上下文,"
|
# fine). The frontend renders this with `marked` directly — no fence
|
||||||
"返回只包含优化后代码的回复,不要任何解释或 markdown 围栏。"
|
# stripping on the server, so the response keeps the structure that makes
|
||||||
),
|
# markdown rendering meaningful (code blocks, headings, etc.).
|
||||||
"fix": (
|
UNIFIED_SYSTEM_PROMPT = (
|
||||||
"你是一个 Python 排错专家。用户给出了一段产生错误的代码和 traceback,"
|
"你是一个代码助手。基于提供的代码上下文(以及可选的 traceback),"
|
||||||
"请返回只包含修复后代码的回复,不要任何解释或 markdown 围栏。"
|
"按照用户的指令修改代码。"
|
||||||
),
|
"可附简短说明。"
|
||||||
"edit": (
|
)
|
||||||
"你是一个代码编辑助手。基于用户的指令修改给定代码,"
|
|
||||||
"返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_client(handler: APIHandler) -> OpenCodeClient:
|
def make_client(handler: APIHandler) -> OpenCodeClient:
|
||||||
@@ -52,12 +48,13 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
|||||||
return sm
|
return sm
|
||||||
|
|
||||||
|
|
||||||
def _build_request_body(mode: str, prompt: str, context: dict) -> dict:
|
def _build_request_body(prompt: str, context: dict) -> dict:
|
||||||
"""Build full request body for OpenCode POST /session/:id/message.
|
"""Build full request body for OpenCode POST /session/:id/message.
|
||||||
|
|
||||||
Returns dict with 'parts' (list) and 'system' (str) keys.
|
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
|
||||||
|
the LLM interprets the user's natural-language instruction, with the code
|
||||||
|
context and the optional traceback in front of it.
|
||||||
"""
|
"""
|
||||||
system = MODE_SYSTEM_PROMPTS[mode]
|
|
||||||
parts: list[dict] = []
|
parts: list[dict] = []
|
||||||
|
|
||||||
if context.get("previousCode"):
|
if context.get("previousCode"):
|
||||||
@@ -85,14 +82,18 @@ def _build_request_body(mode: str, prompt: str, context: dict) -> dict:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
if mode == "edit" and prompt:
|
if prompt:
|
||||||
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
||||||
|
|
||||||
return {"parts": parts, "system": system}
|
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_fence(s: str) -> str:
|
def _strip_code_fence(s: str) -> str:
|
||||||
"""Strip ```language ... ``` fences from LLM output."""
|
"""Strip ```language ... ``` fences from LLM output.
|
||||||
|
|
||||||
|
Kept for any future "apply-to-cell" path that needs clean source.
|
||||||
|
Not used by the current markdown-rendering display flow.
|
||||||
|
"""
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
lines = s.split("\n")
|
lines = s.split("\n")
|
||||||
@@ -157,7 +158,6 @@ class EditHandler(APIHandler):
|
|||||||
async def post(self):
|
async def post(self):
|
||||||
try:
|
try:
|
||||||
body = json.loads(self.request.body)
|
body = json.loads(self.request.body)
|
||||||
mode = body["mode"]
|
|
||||||
prompt = body.get("prompt", "")
|
prompt = body.get("prompt", "")
|
||||||
context = body["context"]
|
context = body["context"]
|
||||||
provider_id = body.get("providerId") or None
|
provider_id = body.get("providerId") or None
|
||||||
@@ -177,7 +177,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(mode, prompt, context)
|
request_body = _build_request_body(prompt, context)
|
||||||
result = await client.send_message_sync(
|
result = await client.send_message_sync(
|
||||||
sid,
|
sid,
|
||||||
request_body["parts"],
|
request_body["parts"],
|
||||||
@@ -191,12 +191,14 @@ class EditHandler(APIHandler):
|
|||||||
for p in result.get("parts", [])
|
for p in result.get("parts", [])
|
||||||
if p.get("type") == "text"
|
if p.get("type") == "text"
|
||||||
]
|
]
|
||||||
final_source = _strip_code_fence("\n".join(text_parts).strip())
|
# The AI's reply is markdown (code in ```fences```, optional
|
||||||
|
# explanation). Pass it through unchanged so the frontend
|
||||||
|
# `marked.parse` can render the code blocks and structure.
|
||||||
|
markdown = "\n".join(text_parts).strip()
|
||||||
|
|
||||||
self.finish(json.dumps({
|
self.finish(json.dumps({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"mode": mode,
|
"markdown": markdown,
|
||||||
"finalSource": final_source,
|
|
||||||
"sessionId": sid,
|
"sessionId": sid,
|
||||||
"notebookPath": notebook_path,
|
"notebookPath": notebook_path,
|
||||||
}))
|
}))
|
||||||
@@ -247,6 +249,58 @@ class SessionReleaseHandler(APIHandler):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMessagesHandler(APIHandler):
|
||||||
|
"""List the current session's messages for a notebook (scrollable history).
|
||||||
|
|
||||||
|
Query param: notebook=<notebook path, URL-encoded>
|
||||||
|
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
|
||||||
|
If no session exists for the notebook yet, returns { messages: [] }
|
||||||
|
(does NOT create a session just to report emptiness).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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)
|
||||||
|
sid = sm.peek(notebook_path)
|
||||||
|
if sid is None:
|
||||||
|
self.finish(json.dumps({"messages": []}))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client = make_client(self)
|
||||||
|
raw = await client.list_session_messages(sid)
|
||||||
|
except OpenCodeError as e:
|
||||||
|
if "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
sm.invalidate(notebook_path)
|
||||||
|
log.warning("invalidated dead session for %s", notebook_path)
|
||||||
|
log.exception("list session messages failed")
|
||||||
|
self.set_status(502)
|
||||||
|
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("list session messages failed")
|
||||||
|
self.set_status(502)
|
||||||
|
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Project OpenCode's {info, parts}[] into a frontend-friendly
|
||||||
|
# {role, content}[] by joining the text parts.
|
||||||
|
messages = []
|
||||||
|
for m in raw or []:
|
||||||
|
info = m.get("info") or {}
|
||||||
|
role = info.get("role") or "assistant"
|
||||||
|
parts = m.get("parts") or []
|
||||||
|
content = "\n".join(
|
||||||
|
p.get("text", "") for p in parts if p.get("type") == "text"
|
||||||
|
).strip()
|
||||||
|
messages.append({"role": role, "content": content})
|
||||||
|
self.finish(json.dumps({"messages": messages}))
|
||||||
|
|
||||||
|
|
||||||
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"]
|
||||||
@@ -258,6 +312,7 @@ def setup_route_handlers(web_app):
|
|||||||
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
|
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
|
||||||
(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),
|
||||||
]
|
]
|
||||||
|
|
||||||
web_app.add_handlers(host_pattern, handlers)
|
web_app.add_handlers(host_pattern, handlers)
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ class SessionManager:
|
|||||||
def has_session(self, notebook_path: str) -> bool:
|
def has_session(self, notebook_path: str) -> bool:
|
||||||
return notebook_path in self._sessions
|
return notebook_path in self._sessions
|
||||||
|
|
||||||
|
def peek(self, notebook_path: str) -> Optional[str]:
|
||||||
|
"""Return the cached sessionID for the notebook, or None if no
|
||||||
|
session has been created yet. Does NOT create one (unlike
|
||||||
|
get_or_create) — used by the history endpoint to avoid spawning
|
||||||
|
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]:
|
||||||
return [
|
return [
|
||||||
{"notebookPath": path, "sessionId": sid}
|
{"notebookPath": path, "sessionId": sid}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tornado.httpclient
|
import tornado.httpclient
|
||||||
@@ -38,7 +39,9 @@ def base_config() -> OpenCodeConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_client(config: OpenCodeConfig, responses: list[tuple[int, object]]) -> tuple[OpenCodeClient, MockHTTPClient]:
|
def _make_client(
|
||||||
|
config: OpenCodeConfig, responses: list[tuple[int, object]]
|
||||||
|
) -> tuple[OpenCodeClient, MockHTTPClient]:
|
||||||
mock = MockHTTPClient()
|
mock = MockHTTPClient()
|
||||||
mock.responses = responses
|
mock.responses = responses
|
||||||
return OpenCodeClient(config, http_client=mock), mock
|
return OpenCodeClient(config, http_client=mock), mock
|
||||||
@@ -68,22 +71,30 @@ async def test_create_session_posts_title(base_config: OpenCodeConfig) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auth_in_fetch_kwargs_when_password_set(base_config: OpenCodeConfig) -> None:
|
async def test_auth_set_on_request_when_password_present(
|
||||||
|
base_config: OpenCodeConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Bug fix: auth must live on the HTTPRequest, not as fetch kwargs
|
||||||
|
(tornado rejects fetch(request, **kwargs) for request-construction kwargs)."""
|
||||||
config = base_config._replace(password="secret")
|
config = base_config._replace(password="secret")
|
||||||
client, mock = _make_client(config, [(200, {"status": "ok"})])
|
client, mock = _make_client(config, [(200, {"status": "ok"})])
|
||||||
await client.health()
|
await client.health()
|
||||||
call = mock.calls[0]
|
call = mock.calls[0]
|
||||||
assert call["kwargs"].get("auth_username") == "opencode"
|
# No kwargs forwarded to fetch.
|
||||||
assert call["kwargs"].get("auth_password") == "secret"
|
assert call["kwargs"] == {}
|
||||||
|
# Auth is on the HTTPRequest.
|
||||||
|
assert call["request"].auth_username == "opencode"
|
||||||
|
assert call["request"].auth_password == "secret"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auth_not_in_fetch_kwargs_when_password_empty(base_config: OpenCodeConfig) -> None:
|
async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None:
|
||||||
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
|
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
|
||||||
await client.health()
|
await client.health()
|
||||||
call = mock.calls[0]
|
call = mock.calls[0]
|
||||||
assert "auth_username" not in call["kwargs"]
|
assert call["kwargs"] == {}
|
||||||
assert "auth_password" not in call["kwargs"]
|
assert call["request"].auth_username is None
|
||||||
|
assert call["request"].auth_password is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -118,7 +129,9 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_message_sync_includes_system_when_provided() -> None:
|
async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||||
"""system param is forwarded into the request body when not None."""
|
"""system param is forwarded into the request body when not None."""
|
||||||
config = OpenCodeConfig(url="http://x:1", user="u", password="")
|
config = OpenCodeConfig(
|
||||||
|
url="http://x:1", user="u", password="", request_timeout_seconds=120
|
||||||
|
)
|
||||||
mock = MockHTTPClient()
|
mock = MockHTTPClient()
|
||||||
mock.responses.append((200, {"info": {}, "parts": []}))
|
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||||
client = OpenCodeClient(config, http_client=mock)
|
client = OpenCodeClient(config, http_client=mock)
|
||||||
@@ -147,3 +160,24 @@ async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) ->
|
|||||||
with pytest.raises(OpenCodeError) as exc_info:
|
with pytest.raises(OpenCodeError) as exc_info:
|
||||||
await client.health()
|
await client.health()
|
||||||
assert "boom" in str(exc_info.value)
|
assert "boom" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_idempotent() -> None:
|
||||||
|
"""close() can be called multiple times safely."""
|
||||||
|
client = OpenCodeClient(OpenCodeConfig(
|
||||||
|
url="http://x", user="u", password="", request_timeout_seconds=10
|
||||||
|
))
|
||||||
|
client.close()
|
||||||
|
client.close() # must not raise
|
||||||
|
assert client._http_client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_does_not_close_injected_client() -> None:
|
||||||
|
"""close() must not call .close() on a client it didn't create."""
|
||||||
|
injected = MagicMock()
|
||||||
|
client = OpenCodeClient(OpenCodeConfig(
|
||||||
|
url="http://x", user="u", password="", request_timeout_seconds=10
|
||||||
|
), http_client=injected)
|
||||||
|
client.close()
|
||||||
|
injected.close.assert_not_called()
|
||||||
|
assert client._http_client is None
|
||||||
|
|||||||
@@ -17,21 +17,23 @@ def test_env_url_overrides_default(monkeypatch) -> None:
|
|||||||
assert config.url == "http://x:1"
|
assert config.url == "http://x:1"
|
||||||
|
|
||||||
|
|
||||||
def test_jupyter_settings_override_env(monkeypatch) -> None:
|
def test_jupyter_settings_are_ignored(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||||
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
||||||
config = resolve_config(settings)
|
config = resolve_config(settings)
|
||||||
assert config.url == "http://y:2"
|
assert config.url == "http://x:1"
|
||||||
|
|
||||||
|
|
||||||
def test_all_env_vars(monkeypatch) -> None:
|
def test_all_env_vars(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
||||||
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
||||||
|
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||||
config = resolve_config({})
|
config = resolve_config({})
|
||||||
assert config.url == "http://x:1"
|
assert config.url == "http://x:1"
|
||||||
assert config.user == "u"
|
assert config.user == "u"
|
||||||
assert config.password == "p"
|
assert config.password == "p"
|
||||||
|
assert config.request_timeout_seconds == 300
|
||||||
|
|
||||||
|
|
||||||
def test_auth_none_when_password_empty() -> None:
|
def test_auth_none_when_password_empty() -> None:
|
||||||
@@ -44,11 +46,12 @@ def test_auth_when_password_set() -> None:
|
|||||||
url="http://127.0.0.1:4096",
|
url="http://127.0.0.1:4096",
|
||||||
user="opencode",
|
user="opencode",
|
||||||
password="secret",
|
password="secret",
|
||||||
|
request_timeout_seconds=120,
|
||||||
)
|
)
|
||||||
assert config.auth == ("opencode", "secret")
|
assert config.auth == ("opencode", "secret")
|
||||||
|
|
||||||
|
|
||||||
def test_request_timeout_from_settings() -> None:
|
def test_request_timeout_from_env(monkeypatch) -> None:
|
||||||
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}}
|
monkeypatch.setenv("OPENCODE_BRIDGE_TIMEOUT", "300")
|
||||||
config = resolve_config(settings)
|
config = resolve_config({})
|
||||||
assert config.request_timeout_seconds == 300
|
assert config.request_timeout_seconds == 300
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tornado.httpclient
|
||||||
|
|
||||||
|
|
||||||
class FakeOpenCodeClient:
|
class FakeOpenCodeClient:
|
||||||
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
||||||
@@ -13,6 +16,18 @@ class FakeOpenCodeClient:
|
|||||||
"info": {"id": "msg-1"},
|
"info": {"id": "msg-1"},
|
||||||
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
||||||
}
|
}
|
||||||
|
# Canned session-messages list response (list_session_messages).
|
||||||
|
# Default: one user + one assistant message, mixed text parts.
|
||||||
|
self.messages_response = [
|
||||||
|
{
|
||||||
|
"info": {"role": "user", "id": "m1"},
|
||||||
|
"parts": [{"type": "text", "text": "fix the bug"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"info": {"role": "assistant", "id": "m2"},
|
||||||
|
"parts": [{"type": "text", "text": "```python\nx = 1\n```"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoint(self):
|
def endpoint(self):
|
||||||
@@ -38,6 +53,10 @@ class FakeOpenCodeClient:
|
|||||||
self.calls.append(("delete_session", session_id))
|
self.calls.append(("delete_session", session_id))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def list_session_messages(self, session_id):
|
||||||
|
self.calls.append(("list_session_messages", session_id))
|
||||||
|
return self.messages_response
|
||||||
|
|
||||||
|
|
||||||
class FakeSessionManager:
|
class FakeSessionManager:
|
||||||
"""Drop-in replacement for SessionManager with recording."""
|
"""Drop-in replacement for SessionManager with recording."""
|
||||||
@@ -50,6 +69,12 @@ class FakeSessionManager:
|
|||||||
self.calls.append(("get_or_create", notebook_path))
|
self.calls.append(("get_or_create", notebook_path))
|
||||||
return self._session_id
|
return self._session_id
|
||||||
|
|
||||||
|
def peek(self, notebook_path: str):
|
||||||
|
# Mirror SessionManager.peek: return the session id without
|
||||||
|
# creating one. None means "no session yet" (used by the no-session
|
||||||
|
# test to short-circuit the history route).
|
||||||
|
return self._session_id or None
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
async def release(self, notebook_path: str) -> bool:
|
||||||
self.calls.append(("release", notebook_path))
|
self.calls.append(("release", notebook_path))
|
||||||
return True
|
return True
|
||||||
@@ -115,7 +140,6 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"mode": "edit",
|
|
||||||
"prompt": "Add type hints",
|
"prompt": "Add type hints",
|
||||||
"context": {
|
"context": {
|
||||||
"notebookPath": "test.ipynb",
|
"notebookPath": "test.ipynb",
|
||||||
@@ -136,7 +160,9 @@ 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)
|
||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
assert payload["finalSource"] == "def foo():\n return 42"
|
# Server now returns the AI reply as raw markdown (```fences``` kept
|
||||||
|
# so the frontend marked.parse renders code blocks).
|
||||||
|
assert payload["markdown"] == "def foo():\n return 42"
|
||||||
assert payload["sessionId"] == "fake-session-123"
|
assert payload["sessionId"] == "fake-session-123"
|
||||||
assert payload["notebookPath"] == "test.ipynb"
|
assert payload["notebookPath"] == "test.ipynb"
|
||||||
|
|
||||||
@@ -149,8 +175,8 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
# send_message_sync received the session ID from the manager
|
# send_message_sync received the session ID from the manager
|
||||||
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
||||||
assert send_call[1] == "fake-session-123"
|
assert send_call[1] == "fake-session-123"
|
||||||
# system prompt was passed
|
# system prompt was passed (v3+ allows markdown/fences in the reply)
|
||||||
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
|
||||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||||
@@ -176,6 +202,71 @@ async def test_session_list_handler(monkeypatch, jp_fetch):
|
|||||||
assert paths == {"foo.ipynb", "bar.ipynb"}
|
assert paths == {"foo.ipynb", "bar.ipynb"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_messages_handler_returns_empty_when_no_session(
|
||||||
|
monkeypatch, jp_fetch
|
||||||
|
) -> None:
|
||||||
|
# No session registered for this notebook -> handler returns
|
||||||
|
# {"messages": []} WITHOUT calling OpenCodeClient (peek short-circuits).
|
||||||
|
fake = FakeOpenCodeClient()
|
||||||
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
|
fake_sm = FakeSessionManager(session_id="")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await jp_fetch(
|
||||||
|
"opencode-bridge", "session-messages",
|
||||||
|
method="GET",
|
||||||
|
params={"notebook": "fresh.ipynb"},
|
||||||
|
)
|
||||||
|
assert response.code == 200
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert payload == {"messages": []}
|
||||||
|
# No OpenCode call was made.
|
||||||
|
assert all(c[0] != "list_session_messages" for c in fake.calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_messages_handler_projects_opencode_messages(
|
||||||
|
monkeypatch, jp_fetch
|
||||||
|
) -> None:
|
||||||
|
fake = FakeOpenCodeClient()
|
||||||
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
|
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await jp_fetch(
|
||||||
|
"opencode-bridge", "session-messages",
|
||||||
|
method="GET",
|
||||||
|
params={"notebook": "test.ipynb"},
|
||||||
|
)
|
||||||
|
assert response.code == 200
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
assert payload == {
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "fix the bug"},
|
||||||
|
{"role": "assistant", "content": "```python\nx = 1\n```"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# The OpenCode client was called with the session id from the manager.
|
||||||
|
assert ("list_session_messages", "fake-session-123") in fake.calls
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_messages_handler_requires_notebook(
|
||||||
|
monkeypatch, jp_fetch
|
||||||
|
) -> None:
|
||||||
|
fake = FakeOpenCodeClient()
|
||||||
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
# jp_fetch raises HTTPClientError on 4xx; assert the handler 400s
|
||||||
|
# (the body would contain "missing 'notebook' query parameter").
|
||||||
|
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||||
|
await jp_fetch("opencode-bridge", "session-messages", method="GET")
|
||||||
|
assert exc_info.value.code == 400
|
||||||
|
|
||||||
|
|
||||||
async def test_session_release_handler(monkeypatch, jp_fetch):
|
async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||||
fake = FakeOpenCodeClient()
|
fake = FakeOpenCodeClient()
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|||||||
+4
-39
@@ -1,46 +1,11 @@
|
|||||||
{
|
{
|
||||||
"jupyter.lab.shortcuts": [],
|
"jupyter.lab.shortcuts": [],
|
||||||
|
"jupyter.lab.toolbars": {
|
||||||
|
"Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
|
||||||
|
},
|
||||||
"title": "opencode_bridge",
|
"title": "opencode_bridge",
|
||||||
"description": "opencode_bridge settings.",
|
"description": "opencode_bridge settings.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {},
|
||||||
"opencodeServerUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server URL",
|
|
||||||
"description": "opencode serve 监听的 HTTP 地址。覆盖 OPENCODE_BRIDGE_URL 环境变量。",
|
|
||||||
"default": "http://127.0.0.1:4096"
|
|
||||||
},
|
|
||||||
"opencodeServerUser": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server 用户名",
|
|
||||||
"description": "HTTP Basic Auth 用户名。默认 'opencode'。可被 OPENCODE_BRIDGE_USER 环境变量覆盖。",
|
|
||||||
"default": "opencode"
|
|
||||||
},
|
|
||||||
"opencodeServerPassword": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Server 密码",
|
|
||||||
"description": "HTTP Basic Auth 密码。空字符串表示无认证。可被 OPENCODE_BRIDGE_PASSWORD 环境变量覆盖。",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"opencodeProvider": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Provider",
|
|
||||||
"description": "Provider id (e.g. 'anthropic' or 'openai'). Get available values from the browser console after activating this extension (logged on startup), or hit GET /opencode-bridge/providers directly. Leave empty to use OpenCode's default.",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"opencodeModel": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "OpenCode Model",
|
|
||||||
"description": "Model id (e.g. 'claude-sonnet-4-20250514'). Get available values from the browser console after activation. Leave empty to use the provider's default.",
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"requestTimeoutSeconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "请求超时(秒)",
|
|
||||||
"default": 120,
|
|
||||||
"minimum": 5,
|
|
||||||
"maximum": 600
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { DEFAULT_OPENCODE_SETTINGS, readOpenCodeSettings } from '../types';
|
|
||||||
|
|
||||||
describe('opencode_bridge types', () => {
|
|
||||||
it('returns defaults when given empty composite', () => {
|
|
||||||
const s = readOpenCodeSettings({});
|
|
||||||
expect(s).toEqual(DEFAULT_OPENCODE_SETTINGS);
|
|
||||||
expect(s.opencodeProvider).toBe('');
|
|
||||||
expect(s.opencodeModel).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('overrides defaults with user values', () => {
|
|
||||||
const s = readOpenCodeSettings({
|
|
||||||
opencodeServerUrl: 'http://x:1',
|
|
||||||
opencodeProvider: 'anthropic',
|
|
||||||
opencodeModel: 'claude-sonnet-4-20250514'
|
|
||||||
});
|
|
||||||
expect(s.opencodeServerUrl).toBe('http://x:1');
|
|
||||||
expect(s.opencodeProvider).toBe('anthropic');
|
|
||||||
expect(s.opencodeModel).toBe('claude-sonnet-4-20250514');
|
|
||||||
expect(s.opencodeServerUser).toBe(
|
|
||||||
DEFAULT_OPENCODE_SETTINGS.opencodeServerUser
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats empty string provider/model as "use default"', () => {
|
|
||||||
const s = readOpenCodeSettings({
|
|
||||||
opencodeProvider: '',
|
|
||||||
opencodeModel: ''
|
|
||||||
});
|
|
||||||
expect(s.opencodeProvider).toBe('');
|
|
||||||
expect(s.opencodeModel).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt
|
||||||
|
* (inline input panel inside the cell). v3-final: no settings — the model
|
||||||
|
* picker is fully dynamic; default selection is the first provider/model.
|
||||||
|
*
|
||||||
|
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||||
|
* test boundary so the real ESM packages are not loaded.
|
||||||
|
*/
|
||||||
|
jest.mock('@jupyterlab/cells', () => ({
|
||||||
|
CodeCell: class CodeCell {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@jupyterlab/apputils', () => ({
|
||||||
|
Notification: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
success: jest.fn(),
|
||||||
|
warning: jest.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@lumino/widgets', () => {
|
||||||
|
class Widget {
|
||||||
|
public node: HTMLElement = document.createElement('div');
|
||||||
|
public id = '';
|
||||||
|
public parent: Widget | null = null;
|
||||||
|
private _isDisposed = false;
|
||||||
|
public get isDisposed(): boolean {
|
||||||
|
return this._isDisposed;
|
||||||
|
}
|
||||||
|
public addClass(cls: string): void {
|
||||||
|
this.node.classList.add(cls);
|
||||||
|
}
|
||||||
|
public dispose(): void {
|
||||||
|
this._isDisposed = true;
|
||||||
|
if (this.node.parentNode) {
|
||||||
|
this.node.parentNode.removeChild(this.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static attach(widget: Widget, host: HTMLElement): void {
|
||||||
|
host.appendChild(widget.node);
|
||||||
|
}
|
||||||
|
public onAfterAttach(_msg: any): void {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { Widget };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('marked', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the network-touching API module so jsdom tests don't try to hit
|
||||||
|
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||||
|
// from the network). The cell_actions tests focus on widget behavior.
|
||||||
|
jest.mock('../api/opencode_client', () => ({
|
||||||
|
callOpenCodeEdit: jest.fn(),
|
||||||
|
callOpenCodeProviders: jest.fn(),
|
||||||
|
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeServerSettings
|
||||||
|
} from '../components/opencode_cell_actions';
|
||||||
|
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
||||||
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
|
||||||
|
function makeFakeOutputs(items: any[]): any {
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return items.length;
|
||||||
|
},
|
||||||
|
get(i: number) {
|
||||||
|
return items[i];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeCell(
|
||||||
|
source: string,
|
||||||
|
errorOutputs: any[] = [],
|
||||||
|
notebookPath = 'foo.ipynb',
|
||||||
|
cellIndex = 0,
|
||||||
|
totalCells = 1
|
||||||
|
): CodeCell {
|
||||||
|
const model: any = {
|
||||||
|
id: 'cell-test',
|
||||||
|
type: 'code',
|
||||||
|
sharedModel: {
|
||||||
|
getSource: () => source,
|
||||||
|
setSource: jest.fn()
|
||||||
|
},
|
||||||
|
outputs: makeFakeOutputs(errorOutputs),
|
||||||
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||||
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||||
|
};
|
||||||
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
|
(cell as any).model = model;
|
||||||
|
(cell as any).node = document.createElement('div');
|
||||||
|
|
||||||
|
const notebook: any = { widgets: [] as CodeCell[] };
|
||||||
|
const panel: any = {
|
||||||
|
context: { path: notebookPath },
|
||||||
|
content: notebook
|
||||||
|
};
|
||||||
|
for (let i = 0; i < cellIndex; i++) {
|
||||||
|
notebook.widgets.push({
|
||||||
|
model: { sharedModel: { getSource: () => '' } }
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
notebook.widgets.push(cell);
|
||||||
|
for (let i = cellIndex + 1; i < totalCells; i++) {
|
||||||
|
notebook.widgets.push({
|
||||||
|
model: { sharedModel: { getSource: () => '' } }
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(cell, 'parent', {
|
||||||
|
value: notebook,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(notebook, 'parent', {
|
||||||
|
value: panel,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return cell as CodeCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenCodeCellActions', () => {
|
||||||
|
it('allows server settings to be injected', () => {
|
||||||
|
setOpenCodeServerSettings({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single AI button', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btns = actions.node.querySelectorAll('button');
|
||||||
|
expect(btns.length).toBe(1);
|
||||||
|
expect(btns[0].textContent).toContain('AI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the button when notebook panel cannot be resolved', () => {
|
||||||
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
|
(cell as any).model = {
|
||||||
|
id: 'orphan',
|
||||||
|
type: 'code',
|
||||||
|
sharedModel: { getSource: () => 'x = 1', setSource: jest.fn() },
|
||||||
|
outputs: makeFakeOutputs([]),
|
||||||
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||||
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||||
|
};
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const prompt = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt'
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(prompt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the button twice detaches the inline prompt', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
btn.click();
|
||||||
|
expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects model signals on dispose', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
actions.dispose();
|
||||||
|
const model = (cell as any).model;
|
||||||
|
expect(model.contentChanged.disconnect).toHaveBeenCalled();
|
||||||
|
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders provider and model selects; model default uses default[provider] when it matches', () => {
|
||||||
|
setOpenCodeProviders({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'anthropic',
|
||||||
|
name: 'Anthropic',
|
||||||
|
models: {
|
||||||
|
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
|
||||||
|
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ id: 'openai', models: { 'gpt-5': { id: 'gpt-5' } } }
|
||||||
|
],
|
||||||
|
default: {
|
||||||
|
anthropic: 'claude-sonnet-4-20250514',
|
||||||
|
openai: 'gpt-5'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const prompt = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt'
|
||||||
|
) as HTMLElement;
|
||||||
|
const providerSel = prompt.querySelector(
|
||||||
|
'.opencode-provider-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const modelSel = prompt.querySelector(
|
||||||
|
'.opencode-model-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(providerSel).not.toBeNull();
|
||||||
|
expect(modelSel).not.toBeNull();
|
||||||
|
|
||||||
|
// Provider: first provider selected by default.
|
||||||
|
expect(providerSel.options.length).toBe(2);
|
||||||
|
expect(providerSel.options[0].value).toBe('anthropic');
|
||||||
|
expect(providerSel.options[0].textContent).toContain('anthropic');
|
||||||
|
expect(providerSel.options[0].textContent).toContain('Anthropic');
|
||||||
|
expect(providerSel.value).toBe('anthropic');
|
||||||
|
|
||||||
|
// Model: options are that provider's model keys, and the default
|
||||||
|
// matches default['anthropic'] = 'claude-sonnet-4-20250514'.
|
||||||
|
expect(modelSel.options.length).toBe(2);
|
||||||
|
expect(modelSel.options[0].value).toBe('claude-sonnet-4-20250514');
|
||||||
|
expect(modelSel.options[1].value).toBe('claude-haiku-4-5');
|
||||||
|
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||||
|
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the first model when default[provider] is not in models', () => {
|
||||||
|
setOpenCodeProviders({
|
||||||
|
providers: [
|
||||||
|
{ id: 'bailian', models: { 'kimi/kimi-k2.7-code': { id: 'kimi/kimi-k2.7-code' } } }
|
||||||
|
],
|
||||||
|
// default['bailian'] references a modelID not present in models.
|
||||||
|
default: { bailian: 'qwen3.7-plus' }
|
||||||
|
});
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const modelSel = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt .opencode-model-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(modelSel.value).toBe('kimi/kimi-k2.7-code');
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds the model select when the provider select changes', () => {
|
||||||
|
setOpenCodeProviders({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'anthropic',
|
||||||
|
models: { 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai',
|
||||||
|
models: { 'gpt-5': { id: 'gpt-5' } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
default: { openai: 'gpt-5' }
|
||||||
|
});
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
const prompt = cell.node.querySelector(
|
||||||
|
'.opencode-inline-prompt'
|
||||||
|
) as HTMLElement;
|
||||||
|
const providerSel = prompt.querySelector(
|
||||||
|
'.opencode-provider-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const modelSel = prompt.querySelector(
|
||||||
|
'.opencode-model-select'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
|
// Initial: anthropic, its only model.
|
||||||
|
expect(providerSel.value).toBe('anthropic');
|
||||||
|
expect(modelSel.options.length).toBe(1);
|
||||||
|
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||||
|
|
||||||
|
// Switch to openai: model select rebuilds with openai's model and
|
||||||
|
// default['openai'] = 'gpt-5' is in openai's models so it matches.
|
||||||
|
providerSel.value = 'openai';
|
||||||
|
providerSel.dispatchEvent(new Event('change'));
|
||||||
|
expect(modelSel.options.length).toBe(1);
|
||||||
|
expect(modelSel.options[0].value).toBe('gpt-5');
|
||||||
|
expect(modelSel.value).toBe('gpt-5');
|
||||||
|
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the selector when providers are not cached', () => {
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
cell.node.querySelector('.opencode-inline-prompt .opencode-provider-select')
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
cell.node.querySelector('.opencode-inline-prompt .opencode-model-select')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
// Click once to create the inline prompt (so the history refresh
|
||||||
|
// has a target and the cell source check has a baseline).
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
// The history refresh on show is async; spy on _refreshHistory to
|
||||||
|
// confirm it gets called (without depending on the network).
|
||||||
|
const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
|
||||||
|
|
||||||
|
(actions as any)._handleResponse({
|
||||||
|
ok: true,
|
||||||
|
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
|
||||||
|
sessionId: 'ses-abc',
|
||||||
|
notebookPath: 'foo.ipynb'
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cell source must remain unchanged.
|
||||||
|
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
||||||
|
// A history refresh was triggered (to pick up the new assistant msg).
|
||||||
|
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenCodeInlinePrompt', () => {
|
||||||
|
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||||
|
// 2 buttons: 发送 and 取消 (no more output-area close button; the
|
||||||
|
// scrollable history area is the display).
|
||||||
|
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
const history = prompt.node.querySelector(
|
||||||
|
'.opencode-inline-prompt .opencode-inline-history'
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(history).not.toBeNull();
|
||||||
|
// Initially empty (no messages yet).
|
||||||
|
expect(history.children.length).toBe(0);
|
||||||
|
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'user', content: 'fix the bug' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '# Here you go\n\n```python\nx = 1\n```'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(history.children.length).toBe(2);
|
||||||
|
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
|
||||||
|
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
|
||||||
|
expect(userMsg).not.toBeNull();
|
||||||
|
// User messages are plain text (not rendered as HTML).
|
||||||
|
expect(userMsg.textContent).toBe('fix the bug');
|
||||||
|
expect(userMsg.innerHTML).not.toContain('<');
|
||||||
|
// Assistant messages are rendered as markdown via marked (mocked).
|
||||||
|
expect(asstMsg).not.toBeNull();
|
||||||
|
expect(asstMsg.innerHTML).toContain('<mock>');
|
||||||
|
expect(asstMsg.innerHTML).toContain('# Here you go');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for OpenCodeCellFooter DOM rendering and button disabled states.
|
|
||||||
*
|
|
||||||
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
|
||||||
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
|
|
||||||
* transform the hoisted @jupyterlab/* packages out of the box).
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Mock the heavy JupyterLab modules BEFORE importing the component.
|
|
||||||
// (jest.mock is hoisted by Jest, but listing it before imports is clearer.)
|
|
||||||
jest.mock('@jupyterlab/cells', () => ({
|
|
||||||
CodeCell: class CodeCell {}
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@jupyterlab/apputils', () => ({
|
|
||||||
Notification: {
|
|
||||||
info: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
success: jest.fn(),
|
|
||||||
warning: jest.fn()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@lumino/widgets', () => {
|
|
||||||
class Widget {
|
|
||||||
public node: HTMLElement = document.createElement('div');
|
|
||||||
public addClass(cls: string): void {
|
|
||||||
this.node.classList.add(cls);
|
|
||||||
}
|
|
||||||
public id: string = '';
|
|
||||||
public parent: Widget | null = null;
|
|
||||||
protected onAfterAttach(_msg: unknown): void {
|
|
||||||
/* overridden by subclass */
|
|
||||||
}
|
|
||||||
protected onBeforeDetach(_msg: unknown): void {
|
|
||||||
/* overridden by subclass */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { Widget };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { OpenCodeCellFooter } from '../components/opencode_cell_footer';
|
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
|
||||||
|
|
||||||
function makeFakeOutputs(items: any[]): any {
|
|
||||||
return {
|
|
||||||
get length() {
|
|
||||||
return items.length;
|
|
||||||
},
|
|
||||||
get(i: number) {
|
|
||||||
return items[i];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFakeCell(
|
|
||||||
source: string,
|
|
||||||
errorOutputs: any[] = [],
|
|
||||||
notebookPath = 'foo.ipynb',
|
|
||||||
cellIndex = 0,
|
|
||||||
totalCells = 1
|
|
||||||
): CodeCell {
|
|
||||||
const model: any = {
|
|
||||||
id: 'cell-test',
|
|
||||||
type: 'code',
|
|
||||||
sharedModel: {
|
|
||||||
getSource: () => source
|
|
||||||
},
|
|
||||||
outputs: makeFakeOutputs(errorOutputs),
|
|
||||||
// Signal stubs so the component can connect/disconnect without crashing.
|
|
||||||
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
|
||||||
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
|
||||||
};
|
|
||||||
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
|
||||||
(cell as any).model = model;
|
|
||||||
|
|
||||||
// Build a fake parent chain: cell -> notebook -> panel.
|
|
||||||
const notebook: any = { widgets: [] as CodeCell[] };
|
|
||||||
const panel: any = {
|
|
||||||
context: { path: notebookPath },
|
|
||||||
content: notebook
|
|
||||||
};
|
|
||||||
for (let i = 0; i < cellIndex; i++) {
|
|
||||||
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
|
|
||||||
}
|
|
||||||
notebook.widgets.push(cell);
|
|
||||||
for (let i = cellIndex + 1; i < totalCells; i++) {
|
|
||||||
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The footer's helper walks `cell.parent` looking for a widget with
|
|
||||||
// `context` + `content.widgets`. The cell's grandparent (notebook) is what
|
|
||||||
// matches in real JupyterLab. We mirror that here.
|
|
||||||
Object.defineProperty(cell, 'parent', { value: notebook, configurable: true });
|
|
||||||
Object.defineProperty(notebook, 'parent', { value: panel, configurable: true });
|
|
||||||
|
|
||||||
return cell as CodeCell;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('OpenCodeCellFooter', () => {
|
|
||||||
it('renders 3 buttons', () => {
|
|
||||||
const footer = new OpenCodeCellFooter();
|
|
||||||
const btns = footer.node.querySelectorAll('button');
|
|
||||||
expect(btns.length).toBe(3);
|
|
||||||
expect(btns[0].textContent).toBe('✨ 优化');
|
|
||||||
expect(btns[1].textContent).toBe('🐛 排错');
|
|
||||||
expect(btns[2].textContent).toBe('🪄 编辑');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables all buttons when no cell is attached', () => {
|
|
||||||
const footer = new OpenCodeCellFooter();
|
|
||||||
const btns = footer.node.querySelectorAll('button');
|
|
||||||
btns.forEach((b: HTMLButtonElement) => {
|
|
||||||
expect(b.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enables optimize and edit when a cell is attached; fix stays disabled without an error', () => {
|
|
||||||
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
|
|
||||||
const footer = new OpenCodeCellFooter();
|
|
||||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
|
||||||
(footer as any).onAfterAttach({} as any);
|
|
||||||
|
|
||||||
const btns = footer.node.querySelectorAll('button');
|
|
||||||
const optimize = btns[0] as HTMLButtonElement;
|
|
||||||
const fix = btns[1] as HTMLButtonElement;
|
|
||||||
const edit = btns[2] as HTMLButtonElement;
|
|
||||||
expect(optimize.disabled).toBe(false);
|
|
||||||
expect(fix.disabled).toBe(true);
|
|
||||||
expect(edit.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enables the fix button when the attached cell has an error output', () => {
|
|
||||||
const cell = makeFakeCell(
|
|
||||||
'1/0',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
ename: 'ZeroDivisionError',
|
|
||||||
evalue: 'div by zero',
|
|
||||||
traceback: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'foo.ipynb',
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
const footer = new OpenCodeCellFooter();
|
|
||||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
|
||||||
(footer as any).onAfterAttach({} as any);
|
|
||||||
|
|
||||||
const btns = footer.node.querySelectorAll('button');
|
|
||||||
const fix = btns[1] as HTMLButtonElement;
|
|
||||||
expect(fix.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,7 +64,6 @@ function mockResponse(overrides: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sampleRequest: OpenCodeRequest = {
|
const sampleRequest: OpenCodeRequest = {
|
||||||
mode: 'edit',
|
|
||||||
prompt: 'add type hints',
|
prompt: 'add type hints',
|
||||||
context: {
|
context: {
|
||||||
notebookPath: 'foo.ipynb',
|
notebookPath: 'foo.ipynb',
|
||||||
@@ -86,8 +85,7 @@ describe('callOpenCodeEdit', () => {
|
|||||||
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
|
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
|
||||||
const respBody = {
|
const respBody = {
|
||||||
ok: true,
|
ok: true,
|
||||||
mode: 'edit',
|
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
|
||||||
finalSource: 'def foo(x: int) -> int: return x',
|
|
||||||
sessionId: 'sid',
|
sessionId: 'sid',
|
||||||
notebookPath: 'foo.ipynb',
|
notebookPath: 'foo.ipynb',
|
||||||
};
|
};
|
||||||
@@ -109,7 +107,7 @@ describe('callOpenCodeEdit', () => {
|
|||||||
);
|
);
|
||||||
expect(resp.ok).toBe(true);
|
expect(resp.ok).toBe(true);
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
expect(resp.finalSource).toContain('int');
|
expect(resp.markdown).toContain('int');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { URLExt } from '@jupyterlab/coreutils';
|
import { URLExt } from '@jupyterlab/coreutils';
|
||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
|
||||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
|
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
||||||
@@ -54,6 +54,37 @@ export async function callOpenCodeEdit(
|
|||||||
return parsed as OpenCodeResponse;
|
return parsed as OpenCodeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call GET /opencode-bridge/session-messages?notebook=<path>. Returns the
|
||||||
|
* current session's messages (projected to {role, content}[]) for the
|
||||||
|
* given notebook, or an empty list if no session exists yet.
|
||||||
|
*/
|
||||||
|
export async function callOpenCodeSessionMessages(
|
||||||
|
notebookPath: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeMessagesResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(serverSettings.baseUrl, 'opencode-bridge', 'session-messages') +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath);
|
||||||
|
|
||||||
|
const init: RequestInit = { method: 'GET' };
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`network error calling opencode-bridge/session-messages: ${(error as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`opencode-bridge/session-messages failed: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as OpenCodeMessagesResponse;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
|
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
|
||||||
* Throws on network error or non-2xx status.
|
* Throws on network error or non-2xx status.
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
||||||
|
* (top-right of the active cell). v2: a single icon button. Clicking it
|
||||||
|
* toggles an OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||||
|
*
|
||||||
|
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||||
|
*
|
||||||
|
* v3-final: the frontend does NO semantic processing. It gathers the cell's
|
||||||
|
* source / outputs / error, attaches the user's freeform instruction, and
|
||||||
|
* POSTs to the server. 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, callOpenCodeSessionMessages } from '../api/opencode_client';
|
||||||
|
import { extractCellContext } from '../context/cell_context';
|
||||||
|
import type {
|
||||||
|
CellContext,
|
||||||
|
OpenCodeProvidersResponse,
|
||||||
|
OpenCodeRequest,
|
||||||
|
OpenCodeResponse
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
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._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,
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
|
void this._onSubmit(text, providerId, modelId);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this._hidePrompt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Widget.attach(prompt, this._cell.node);
|
||||||
|
this._prompt = prompt;
|
||||||
|
|
||||||
|
// Fetch the current session's message history and render it into
|
||||||
|
// the prompt's scrollable history area. Empty list (no session yet)
|
||||||
|
// is a normal no-op render.
|
||||||
|
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) {
|
||||||
|
// Non-fatal: the history is a convenience. The user can still
|
||||||
|
// send new prompts; just the scrollback won't update.
|
||||||
|
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: OpenCodeRequest = {
|
||||||
|
prompt: text,
|
||||||
|
context: this._context,
|
||||||
|
providerId,
|
||||||
|
modelId
|
||||||
|
};
|
||||||
|
|
||||||
|
this._status = 'loading';
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||||
|
this._handleResponse(resp);
|
||||||
|
} catch (e) {
|
||||||
|
this._status = 'idle';
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(false);
|
||||||
|
}
|
||||||
|
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleResponse(resp: OpenCodeResponse): void {
|
||||||
|
this._status = 'idle';
|
||||||
|
if (resp.ok) {
|
||||||
|
// Refetch the (now-updated) session history and render the new
|
||||||
|
// assistant message as the last item in the scrollable history.
|
||||||
|
// The cell source is NOT replaced.
|
||||||
|
const notebookPath = this._context?.notebookPath;
|
||||||
|
if (notebookPath) {
|
||||||
|
void this._refreshHistory(notebookPath);
|
||||||
|
}
|
||||||
|
Notification.info(
|
||||||
|
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(false);
|
||||||
|
}
|
||||||
|
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom Cell.ContentFactory that adds our OpenCodeCellFooter to every cell.
|
|
||||||
*
|
|
||||||
* One factory instance per notebook; install via `installOpenCodeInNotebook`.
|
|
||||||
*/
|
|
||||||
import { ICellFooter } from '@jupyterlab/cells';
|
|
||||||
import { Notebook } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import { OpenCodeCellFooter } from './opencode_cell_footer';
|
|
||||||
|
|
||||||
export class OpenCodeCellContentFactory extends Notebook.ContentFactory {
|
|
||||||
createCellFooter(): ICellFooter {
|
|
||||||
return new OpenCodeCellFooter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-cell footer widget: renders 3 buttons (optimize / fix / edit) and
|
|
||||||
* wires them to the opencode-bridge server.
|
|
||||||
*
|
|
||||||
* Resolves its owning CodeCell at onAfterAttach via `this.parent`.
|
|
||||||
* Subscribes to the cell model's contentChanged and stateChanged signals
|
|
||||||
* to keep the context fresh.
|
|
||||||
*/
|
|
||||||
import { 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 } from '../api/opencode_client';
|
|
||||||
import { extractCellContext } from '../context/cell_context';
|
|
||||||
import type {
|
|
||||||
CellContext,
|
|
||||||
OpenCodeMode,
|
|
||||||
OpenCodeRequest,
|
|
||||||
OpenCodeResponse,
|
|
||||||
OpenCodeSettings,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
// Module-level runtime injection (set by index.ts after settings load).
|
|
||||||
let _settings: OpenCodeSettings | null = null;
|
|
||||||
let _serverSettings: ServerConnection.ISettings | null = null;
|
|
||||||
|
|
||||||
export function setOpenCodeRuntime(args: {
|
|
||||||
settings: OpenCodeSettings;
|
|
||||||
serverSettings: ServerConnection.ISettings;
|
|
||||||
}): void {
|
|
||||||
_settings = args.settings;
|
|
||||||
_serverSettings = args.serverSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Status = 'idle' | 'loading' | 'error';
|
|
||||||
|
|
||||||
export class OpenCodeCellFooter extends Widget {
|
|
||||||
private _cell: CodeCell | null = null;
|
|
||||||
private _context: CellContext | null = null;
|
|
||||||
private _status: Status = 'idle';
|
|
||||||
private _errorMessage: string | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.addClass('opencode-cell-footer');
|
|
||||||
this._render();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onAfterAttach(_msg: unknown): void {
|
|
||||||
const parent = this.parent;
|
|
||||||
if (parent instanceof CodeCell) {
|
|
||||||
this._cell = parent;
|
|
||||||
this._cell.model.contentChanged.connect(this._onModelChange, this);
|
|
||||||
this._cell.model.stateChanged.connect(this._onModelChange, this);
|
|
||||||
this._onModelChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onBeforeDetach(_msg: unknown): void {
|
|
||||||
if (this._cell) {
|
|
||||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
|
||||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
|
||||||
}
|
|
||||||
this._cell = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onModelChange(): void {
|
|
||||||
this._context = this._cell ? extractCellContextFromCell(this._cell) : null;
|
|
||||||
this._render();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _render(): void {
|
|
||||||
const node = this.node;
|
|
||||||
node.textContent = '';
|
|
||||||
|
|
||||||
const hasError = this._context?.error != null;
|
|
||||||
const loading = this._status === 'loading';
|
|
||||||
const baseDisabled = !this._context || loading;
|
|
||||||
|
|
||||||
const mkBtn = (
|
|
||||||
label: string,
|
|
||||||
mode: OpenCodeMode,
|
|
||||||
disabled: boolean
|
|
||||||
): HTMLButtonElement => {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.className = `opencode-btn opencode-btn-${mode}`;
|
|
||||||
b.textContent = label;
|
|
||||||
b.disabled = disabled;
|
|
||||||
b.title = disabled
|
|
||||||
? loading
|
|
||||||
? 'OpenCode: 请求中…'
|
|
||||||
: 'OpenCode: 等待 cell 上下文…'
|
|
||||||
: label;
|
|
||||||
b.addEventListener('click', () => {
|
|
||||||
void this._onClick(mode);
|
|
||||||
});
|
|
||||||
return b;
|
|
||||||
};
|
|
||||||
|
|
||||||
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
|
|
||||||
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
|
|
||||||
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
|
|
||||||
|
|
||||||
if (this._status === 'error' && this._errorMessage) {
|
|
||||||
const err = document.createElement('span');
|
|
||||||
err.className = 'opencode-error';
|
|
||||||
err.textContent = this._errorMessage;
|
|
||||||
err.title = this._errorMessage;
|
|
||||||
node.appendChild(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _onClick(mode: OpenCodeMode): Promise<void> {
|
|
||||||
if (!this._context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_settings || !_serverSettings) {
|
|
||||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prompt = '';
|
|
||||||
if (mode === 'edit') {
|
|
||||||
const input = window.prompt('请输入编辑指令:');
|
|
||||||
if (input === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prompt = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: OpenCodeRequest = {
|
|
||||||
mode,
|
|
||||||
prompt,
|
|
||||||
context: this._context,
|
|
||||||
providerId: _settings.opencodeProvider || undefined,
|
|
||||||
modelId: _settings.opencodeModel || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this._status = 'loading';
|
|
||||||
this._errorMessage = null;
|
|
||||||
this._render();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
|
||||||
this._handleResponse(resp);
|
|
||||||
} catch (e) {
|
|
||||||
this._status = 'error';
|
|
||||||
this._errorMessage = (e as Error).message;
|
|
||||||
this._render();
|
|
||||||
Notification.error(`OpenCode ${mode} 失败: ${this._errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleResponse(resp: OpenCodeResponse): void {
|
|
||||||
if (resp.ok) {
|
|
||||||
this._status = 'idle';
|
|
||||||
this._render();
|
|
||||||
const len = resp.finalSource.length;
|
|
||||||
Notification.info(
|
|
||||||
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
|
|
||||||
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this._status = 'error';
|
|
||||||
this._errorMessage = resp.error;
|
|
||||||
this._render();
|
|
||||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
|
||||||
* then build the CellContext. The footer uses this rather than `Widget.findParent`
|
|
||||||
* because that helper was removed in @lumino/widgets 2.x.
|
|
||||||
*/
|
|
||||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
|
||||||
let node: Widget | null = cell.parent;
|
|
||||||
let notebookPanel: NotebookPanel | null = null;
|
|
||||||
while (node) {
|
|
||||||
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||||
|
* Renders two <select> boxes (provider + model), a textarea, send/cancel
|
||||||
|
* buttons, and a scrollable history area that shows the current session's
|
||||||
|
* messages (user + assistant). The model select's default for the chosen
|
||||||
|
* provider is `default[providerID]` from the /config/providers response.
|
||||||
|
*
|
||||||
|
* v3-final: no settings — picks come from the OpenCode Serve providers
|
||||||
|
* cache; history comes from /session-messages; cell source is NOT replaced
|
||||||
|
* (the AI reply is rendered as markdown inside this history area).
|
||||||
|
*/
|
||||||
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
|
||||||
|
|
||||||
|
export interface IOpenCodeInlinePromptOptions {
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
providers: OpenCodeProvidersResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatProvider {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
models: { [modelID: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProviders(
|
||||||
|
providers: OpenCodeProvidersResponse | null
|
||||||
|
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
||||||
|
const out: FlatProvider[] = [];
|
||||||
|
if (!providers || !providers.providers) {
|
||||||
|
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
||||||
|
}
|
||||||
|
for (const p of providers.providers) {
|
||||||
|
if (!p.models || typeof p.models !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ id: p.id, name: p.name, models: p.models });
|
||||||
|
}
|
||||||
|
return { providers: out, defaultMap: providers.default || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelKeys(models: { [modelID: string]: unknown }): string[] {
|
||||||
|
return Object.keys(models);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDefaultModelId(
|
||||||
|
providerId: string,
|
||||||
|
models: { [modelID: string]: unknown },
|
||||||
|
defaultMap: { [pid: string]: string }
|
||||||
|
): string | undefined {
|
||||||
|
const keys = modelKeys(models);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const fromDefault = defaultMap[providerId];
|
||||||
|
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
||||||
|
return fromDefault;
|
||||||
|
}
|
||||||
|
return keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
|
private _textarea: HTMLTextAreaElement;
|
||||||
|
private _sendBtn: HTMLButtonElement;
|
||||||
|
private _cancelBtn: HTMLButtonElement;
|
||||||
|
private _providerSelect: HTMLSelectElement | null = null;
|
||||||
|
private _modelSelect: HTMLSelectElement | null = null;
|
||||||
|
private _providerModels: FlatProvider[];
|
||||||
|
private _defaultMap: { [pid: string]: string };
|
||||||
|
private _historyEl: HTMLDivElement;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
_cell: CodeCell,
|
||||||
|
options: IOpenCodeInlinePromptOptions
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
|
const flat = flattenProviders(options.providers);
|
||||||
|
this._providerModels = flat.providers;
|
||||||
|
this._defaultMap = flat.defaultMap;
|
||||||
|
|
||||||
|
if (this._providerModels.length > 0) {
|
||||||
|
this._providerSelect = document.createElement('select');
|
||||||
|
this._providerSelect.className = 'opencode-provider-select';
|
||||||
|
for (const p of this._providerModels) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = p.id;
|
||||||
|
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
|
||||||
|
this._providerSelect.appendChild(o);
|
||||||
|
}
|
||||||
|
this._providerSelect.value = this._providerModels[0].id;
|
||||||
|
this._providerSelect.addEventListener('change', () => {
|
||||||
|
this._rebuildModelSelect(this._providerSelect!.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._providerModels.length > 0) {
|
||||||
|
this._modelSelect = document.createElement('select');
|
||||||
|
this._modelSelect.className = 'opencode-model-select';
|
||||||
|
this._rebuildModelSelect(this._providerSelect?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._textarea = document.createElement('textarea');
|
||||||
|
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
||||||
|
this._textarea.rows = 3;
|
||||||
|
this._sendBtn = document.createElement('button');
|
||||||
|
this._sendBtn.className = 'opencode-btn-send';
|
||||||
|
this._sendBtn.textContent = '发送';
|
||||||
|
this._sendBtn.disabled = options.disabled;
|
||||||
|
this._sendBtn.addEventListener('click', () => {
|
||||||
|
const text = this._textarea.value;
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const providerId = this._providerSelect?.value;
|
||||||
|
const modelId = this._modelSelect?.value || undefined;
|
||||||
|
options.onSubmit(text, providerId, modelId);
|
||||||
|
});
|
||||||
|
this._cancelBtn = document.createElement('button');
|
||||||
|
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||||
|
this._cancelBtn.textContent = '取消';
|
||||||
|
this._cancelBtn.addEventListener('click', () => {
|
||||||
|
options.onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'opencode-inline-actions';
|
||||||
|
actions.appendChild(this._sendBtn);
|
||||||
|
actions.appendChild(this._cancelBtn);
|
||||||
|
|
||||||
|
if (this._providerSelect || this._modelSelect) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'opencode-prompt-providers';
|
||||||
|
if (this._providerSelect) {
|
||||||
|
const pLabel = document.createElement('label');
|
||||||
|
const pSpan = document.createElement('span');
|
||||||
|
pSpan.textContent = 'Provider:';
|
||||||
|
pLabel.appendChild(pSpan);
|
||||||
|
pLabel.appendChild(this._providerSelect);
|
||||||
|
row.appendChild(pLabel);
|
||||||
|
}
|
||||||
|
if (this._modelSelect) {
|
||||||
|
const mLabel = document.createElement('label');
|
||||||
|
const mSpan = document.createElement('span');
|
||||||
|
mSpan.textContent = 'Model:';
|
||||||
|
mLabel.appendChild(mSpan);
|
||||||
|
mLabel.appendChild(this._modelSelect);
|
||||||
|
row.appendChild(mLabel);
|
||||||
|
}
|
||||||
|
this.node.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable history of the current session's messages. Empty until
|
||||||
|
// setMessages() is called by the owning cell action.
|
||||||
|
this._historyEl = document.createElement('div');
|
||||||
|
this._historyEl.className = 'opencode-inline-history';
|
||||||
|
this.node.appendChild(this._historyEl);
|
||||||
|
|
||||||
|
this.node.appendChild(this._textarea);
|
||||||
|
this.node.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rebuildModelSelect(providerId: string | undefined): void {
|
||||||
|
if (!this._modelSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (this._modelSelect.firstChild) {
|
||||||
|
this._modelSelect.removeChild(this._modelSelect.firstChild);
|
||||||
|
}
|
||||||
|
if (!providerId) {
|
||||||
|
this._modelSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = this._providerModels.find(p => p.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
|
this._modelSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keys = modelKeys(provider.models);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
this._modelSelect.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const k of keys) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = k;
|
||||||
|
o.textContent = k;
|
||||||
|
this._modelSelect.appendChild(o);
|
||||||
|
}
|
||||||
|
this._modelSelect.disabled = false;
|
||||||
|
this._modelSelect.value =
|
||||||
|
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabled(disabled: boolean): void {
|
||||||
|
this._sendBtn.disabled = disabled;
|
||||||
|
if (this._providerSelect) {
|
||||||
|
this._providerSelect.disabled = disabled;
|
||||||
|
}
|
||||||
|
if (this._modelSelect) {
|
||||||
|
this._modelSelect.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the current session's messages into the scrollable history
|
||||||
|
* area. User messages are plain text; assistant messages are rendered
|
||||||
|
* as markdown via marked. Auto-scrolls to the bottom so the most
|
||||||
|
* recent message is visible.
|
||||||
|
*/
|
||||||
|
setMessages(messages: OpenCodeMessage[]): void {
|
||||||
|
this._historyEl.textContent = '';
|
||||||
|
for (const msg of messages) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = `opencode-msg opencode-msg-${msg.role}`;
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
wrap.textContent = msg.content;
|
||||||
|
} else {
|
||||||
|
wrap.innerHTML = marked.parse(msg.content) as string;
|
||||||
|
}
|
||||||
|
this._historyEl.appendChild(wrap);
|
||||||
|
}
|
||||||
|
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Install the OpenCode cell factory on every notebook tracked by INotebookTracker.
|
|
||||||
*
|
|
||||||
* For already-open notebooks: swap their contentFactory in place. Cells created
|
|
||||||
* after this point will use the new factory. (Cells already created retain their
|
|
||||||
* old factory — that's a known limitation; users must reload the notebook to see
|
|
||||||
* the footer in cells that were created before activation.)
|
|
||||||
*
|
|
||||||
* For future notebooks: listen to `tracker.widgetAdded` and swap on addition.
|
|
||||||
*/
|
|
||||||
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import { OpenCodeCellContentFactory } from './opencode_cell_factory';
|
|
||||||
|
|
||||||
export function installOpenCodeInNotebook(panel: NotebookPanel): void {
|
|
||||||
if (panel.content.contentFactory instanceof OpenCodeCellContentFactory) {
|
|
||||||
return; // already installed
|
|
||||||
}
|
|
||||||
const existing = panel.content.contentFactory;
|
|
||||||
const editorFactory = (existing as any).editorFactory;
|
|
||||||
const factory = new OpenCodeCellContentFactory({ editorFactory });
|
|
||||||
// contentFactory is readonly in the typings but writable at runtime.
|
|
||||||
(panel.content as any).contentFactory = factory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installOpenCodeEverywhere(tracker: INotebookTracker): void {
|
|
||||||
// Patch already-open notebooks.
|
|
||||||
tracker.forEach(panel => {
|
|
||||||
installOpenCodeInNotebook(panel);
|
|
||||||
});
|
|
||||||
// Patch future notebooks.
|
|
||||||
tracker.widgetAdded.connect((_, panel) => {
|
|
||||||
installOpenCodeInNotebook(panel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+54
-44
@@ -3,69 +3,79 @@ import {
|
|||||||
JupyterFrontEndPlugin
|
JupyterFrontEndPlugin
|
||||||
} from '@jupyterlab/application';
|
} from '@jupyterlab/application';
|
||||||
|
|
||||||
import { INotebookTracker } from '@jupyterlab/notebook';
|
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||||
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import { installOpenCodeEverywhere } from './components/opencode_installer';
|
import {
|
||||||
import { setOpenCodeRuntime } from './components/opencode_cell_footer';
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeServerSettings
|
||||||
|
} from './components/opencode_cell_actions';
|
||||||
import { requestAPI } from './request';
|
import { requestAPI } from './request';
|
||||||
import { readOpenCodeSettings } from './types';
|
|
||||||
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.
|
||||||
|
*
|
||||||
|
* v3-final: no JupyterLab plugin settings. Connection config is read from
|
||||||
|
* environment variables by the server extension; the model 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,
|
||||||
optional: [ISettingRegistry],
|
optional: [IToolbarWidgetRegistry],
|
||||||
requires: [INotebookTracker],
|
|
||||||
activate: (
|
activate: (
|
||||||
app: JupyterFrontEnd,
|
app: JupyterFrontEnd,
|
||||||
tracker: INotebookTracker,
|
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||||
settingRegistry: ISettingRegistry | null
|
|
||||||
) => {
|
) => {
|
||||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||||
|
|
||||||
// Install the per-cell footer factory on every notebook.
|
// Push the Jupyter server settings into the actions module so that
|
||||||
installOpenCodeEverywhere(tracker);
|
// callOpenCodeEdit can build the right base URL.
|
||||||
|
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
||||||
|
|
||||||
// Load settings + push into toolbar module.
|
// Register the per-cell AI actions into the native Cell toolbar
|
||||||
if (settingRegistry) {
|
// (top-right of the active cell, next to move up/down). Non-code
|
||||||
void settingRegistry
|
// cells get an empty widget so they show no AI buttons.
|
||||||
.load(plugin.id)
|
if (toolbarRegistry) {
|
||||||
.then(settings => {
|
toolbarRegistry.addFactory<Cell>(
|
||||||
const bridge = readOpenCodeSettings(settings.composite);
|
'Cell',
|
||||||
setOpenCodeRuntime({
|
'opencode-cell-actions',
|
||||||
settings: bridge,
|
(cell: Cell) => {
|
||||||
serverSettings: app.serviceManager.serverSettings
|
if (cell instanceof CodeCell) {
|
||||||
});
|
return new OpenCodeCellActions(cell);
|
||||||
console.log('opencode_bridge settings loaded:', bridge);
|
}
|
||||||
|
return new Widget();
|
||||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
}
|
||||||
.then(data => {
|
);
|
||||||
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
|
|
||||||
for (const p of data.providers) {
|
|
||||||
const models = p.models.map(m => m.id).join(', ');
|
|
||||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(lines.join('\n'));
|
|
||||||
})
|
|
||||||
.catch(reason => {
|
|
||||||
// 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?):',
|
|
||||||
reason
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(reason => {
|
|
||||||
console.error('Failed to load settings for opencode_bridge.', reason);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.then(data => {
|
||||||
|
setOpenCodeProviders(data);
|
||||||
|
const lines: string[] = [
|
||||||
|
'[opencode_bridge] Available OpenCode providers:'
|
||||||
|
];
|
||||||
|
for (const p of data.providers) {
|
||||||
|
const models = Object.values(p.models).map(m => m.id).join(', ');
|
||||||
|
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(lines.join('\n'));
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
// 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?):',
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
console.log('hello endpoint:', data);
|
console.log('hello endpoint:', data);
|
||||||
|
|||||||
+26
-36
@@ -3,6 +3,10 @@
|
|||||||
*
|
*
|
||||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
||||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
||||||
|
*
|
||||||
|
* v3-final: the JupyterLab plugin settings have been removed entirely.
|
||||||
|
* All connection config lives in startup environment variables (see
|
||||||
|
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ErrorOutput {
|
export interface ErrorOutput {
|
||||||
@@ -22,10 +26,7 @@ export interface CellContext {
|
|||||||
error: ErrorOutput | null;
|
error: ErrorOutput | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenCodeMode = 'optimize' | 'fix' | 'edit';
|
|
||||||
|
|
||||||
export interface OpenCodeRequest {
|
export interface OpenCodeRequest {
|
||||||
mode: OpenCodeMode;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
context: CellContext;
|
context: CellContext;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
@@ -34,8 +35,9 @@ export interface OpenCodeRequest {
|
|||||||
|
|
||||||
export interface OpenCodeSuccess {
|
export interface OpenCodeSuccess {
|
||||||
ok: true;
|
ok: true;
|
||||||
mode: OpenCodeMode;
|
/** The AI's reply as markdown (code in ```fences```, optional explanation).
|
||||||
finalSource: string;
|
* Render with marked. The cell source is NOT replaced. */
|
||||||
|
markdown: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
notebookPath: string;
|
notebookPath: string;
|
||||||
}
|
}
|
||||||
@@ -47,46 +49,34 @@ export interface OpenCodeFailure {
|
|||||||
|
|
||||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||||
|
|
||||||
/** Frontend view of the 4 schema/plugin.json fields. */
|
/** Response from GET /opencode-bridge/session-messages?notebook=... .
|
||||||
export interface OpenCodeSettings {
|
* Projected from OpenCode's {info, parts}[] by the server into a
|
||||||
opencodeServerUrl: string;
|
* frontend-friendly {role, content}[]. */
|
||||||
opencodeServerUser: string;
|
export interface OpenCodeMessage {
|
||||||
opencodeServerPassword: string;
|
role: "user" | "assistant";
|
||||||
requestTimeoutSeconds: number;
|
content: string;
|
||||||
opencodeProvider: string;
|
|
||||||
opencodeModel: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
export interface OpenCodeMessagesResponse {
|
||||||
opencodeServerUrl: 'http://127.0.0.1:4096',
|
messages: OpenCodeMessage[];
|
||||||
opencodeServerUser: 'opencode',
|
}
|
||||||
opencodeServerPassword: '',
|
|
||||||
requestTimeoutSeconds: 120,
|
|
||||||
opencodeProvider: '',
|
|
||||||
opencodeModel: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||||
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||||
return {
|
export interface OpenCodeModel {
|
||||||
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl,
|
id: string;
|
||||||
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser,
|
name?: string;
|
||||||
opencodeServerPassword: c.opencodeServerPassword || '',
|
[k: string]: unknown;
|
||||||
requestTimeoutSeconds:
|
|
||||||
typeof c.requestTimeoutSeconds === 'number'
|
|
||||||
? c.requestTimeoutSeconds
|
|
||||||
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
|
|
||||||
opencodeProvider: c.opencodeProvider || '',
|
|
||||||
opencodeModel: c.opencodeModel || '',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/providers. */
|
|
||||||
export interface OpenCodeProvider {
|
export interface OpenCodeProvider {
|
||||||
id: string;
|
id: string;
|
||||||
models: { id: string; [k: string]: unknown }[];
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
models: { [modelID: string]: OpenCodeModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeProvidersResponse {
|
export interface OpenCodeProvidersResponse {
|
||||||
providers: OpenCodeProvider[];
|
providers: OpenCodeProvider[];
|
||||||
|
default?: { [providerID: string]: string };
|
||||||
}
|
}
|
||||||
|
|||||||
+163
@@ -3,3 +3,166 @@
|
|||||||
|
|
||||||
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* Single AI action button inside the native Cell toolbar (active cell, top-right). */
|
||||||
|
.opencode-cell-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
line-height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn:hover:enabled {
|
||||||
|
background: var(--jp-layout-color2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-cell-actions .opencode-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
||||||
|
.opencode-inline-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
border: 1px solid var(--jp-border-color2, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--jp-layout-color1, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-prompt-providers {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-prompt-providers label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-provider-select,
|
||||||
|
.opencode-inline-prompt .opencode-model-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
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-provider-select:disabled,
|
||||||
|
.opencode-inline-prompt .opencode-model-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable history of the current session's messages. The owning
|
||||||
|
cell action populates it via setMessages(). max-height keeps the
|
||||||
|
inline prompt compact; the user scrolls to see older messages. */
|
||||||
|
.opencode-inline-prompt .opencode-inline-history {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid var(--jp-border-color1, #c0c0c0);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--jp-layout-color2, #f7f7f7);
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-msg {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-msg:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-msg-user {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--jp-layout-color3, #eaeaea);
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-msg-assistant {
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--jp-ui-font-color0, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-history pre {
|
||||||
|
background: var(--jp-layout-color1, #fff);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-history code {
|
||||||
|
font-family: var(--jp-code-font-family, monospace);
|
||||||
|
font-size: var(--jp-code-font-size, 13px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-history p {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-history h1,
|
||||||
|
.opencode-inline-prompt .opencode-inline-history h2,
|
||||||
|
.opencode-inline-prompt .opencode-inline-history h3 {
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
font-size: var(--jp-ui-font-size2, 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--jp-code-font-family, monospace);
|
||||||
|
font-size: var(--jp-code-font-size, 13px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-actions button {
|
||||||
|
border: 1px solid var(--jp-border-color2, #ccc);
|
||||||
|
background: var(--jp-layout-color2, #f7f7f7);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-actions button:hover:enabled {
|
||||||
|
background: var(--jp-layout-color3, #eaeaea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-inline-actions button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user