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