docs: v3 implementation plan for startup env + model selector
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
12c0f4c618
commit
8b51eda2ff
@@ -0,0 +1,612 @@
|
|||||||
|
# Cell Toolbar Actions v3 — 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** 把 4 个 server 连接字段(URL/User/Password/Timeout)从 JupyterLab Settings 移到启动环境变量;Settings 只剩 `opencodeProvider`/`opencodeModel`;在 inline 输入框增加模型选择器。
|
||||||
|
|
||||||
|
**Architecture:** `config.py` 4 字段全走 `os.environ.get(ENV) or DEFAULT`(不再 `bridge.get(...)` 兜底);`schema/plugin.json` 删 4 个 property;`src/types.ts` `OpenCodeSettings` 缩为 `{opencodeProvider, opencodeModel}`;前端 `index.ts` 把 `callOpenCodeProviders` 结果通过 `setOpenCodeProviders` 写到 `opencode_cell_actions.ts` 模块级缓存;`OpenCodeInlinePrompt` 加 `providers/defaultProviderId/defaultModelId` 构造参数,渲染 `<select>`,`onSubmit(text, providerId, modelId)` 带选中的 provider/model;`OpenCodeCellActions` 用它覆盖 `OpenCodeRequest` 的 `providerId`/`modelId`。
|
||||||
|
|
||||||
|
**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` **只**删 `opencodeServerUrl` / `opencodeServerUser` / `opencodeServerPassword` / `requestTimeoutSeconds` 4 个 property;`opencodeProvider` / `opencodeModel` 保留。
|
||||||
|
- `src/types.ts` `OpenCodeSettings` 缩为 `{ opencodeProvider: string; opencodeModel: string }`,`DEFAULT_OPENCODE_SETTINGS` 同步;`readOpenCodeSettings` 同步(其余字段不再读)。
|
||||||
|
- `opencode_bridge/config.py` 新增 `ENV_TIMEOUT = "OPENCODE_BRIDGE_TIMEOUT"` 与 `DEFAULT_REQUEST_TIMEOUT = 120`;`resolve_config` 4 个字段(`url`/`user`/`password`/`request_timeout_seconds`)全走 env+default,**不再** `bridge.get(...)`。
|
||||||
|
- 模型选择器 value 格式: `providerId|modelId`(`|` 分隔,`split("|", 2)` 解析)。
|
||||||
|
- 默认选中: 若 settings 的 `opencodeProvider`/`opencodeModel` 能在 providers 列表里找到对应项 → 选中它;否则选中第一项;若 providers 为 `null`/空 → 不渲染 select。
|
||||||
|
- Providers 缓存模块级: `let _providers: OpenCodeProvidersResponse | null = null; export function setOpenCodeProviders(p) { _providers = p; }` 放在 `opencode_cell_actions.ts`(`setOpenCodeRuntime` 同文件),`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 兜底
|
||||||
Reference in New Issue
Block a user