feat: fully dynamic model picker in inline prompt
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt
|
* Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt
|
||||||
* (inline input panel inside the cell).
|
* (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
|
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||||
* test boundary so the real ESM packages are not loaded.
|
* test boundary so the real ESM packages are not loaded.
|
||||||
*/
|
*/
|
||||||
jest.mock('@jupyterlab/cells', () => ({
|
jest.mock('@jupyterlab/cells', () => ({
|
||||||
CodeCell: class CodeCell { public node = document.createElement('div'); }
|
CodeCell: class CodeCell {}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@jupyterlab/apputils', () => ({
|
jest.mock('@jupyterlab/apputils', () => ({
|
||||||
@@ -32,21 +33,25 @@ jest.mock('@lumino/widgets', () => {
|
|||||||
}
|
}
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this._isDisposed = true;
|
this._isDisposed = true;
|
||||||
if (this.node.parentElement) {
|
if (this.node.parentNode) {
|
||||||
this.node.parentElement.removeChild(this.node);
|
this.node.parentNode.removeChild(this.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public onAfterAttach(msg: any): void {
|
|
||||||
// no-op for tests
|
|
||||||
}
|
|
||||||
public static attach(widget: Widget, host: HTMLElement): void {
|
public static attach(widget: Widget, host: HTMLElement): void {
|
||||||
host.appendChild(widget.node);
|
host.appendChild(widget.node);
|
||||||
}
|
}
|
||||||
|
public onAfterAttach(_msg: any): void {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { Widget };
|
return { Widget };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { OpenCodeCellActions } from '../components/opencode_cell_actions';
|
import {
|
||||||
|
OpenCodeCellActions,
|
||||||
|
setOpenCodeProviders,
|
||||||
|
setOpenCodeServerSettings
|
||||||
|
} from '../components/opencode_cell_actions';
|
||||||
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@ function makeFakeCell(
|
|||||||
};
|
};
|
||||||
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
(cell as any).model = model;
|
(cell as any).model = model;
|
||||||
|
(cell as any).node = document.createElement('div');
|
||||||
|
|
||||||
const notebook: any = { widgets: [] as CodeCell[] };
|
const notebook: any = { widgets: [] as CodeCell[] };
|
||||||
const panel: any = {
|
const panel: any = {
|
||||||
@@ -112,7 +118,11 @@ function makeFakeCell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('OpenCodeCellActions', () => {
|
describe('OpenCodeCellActions', () => {
|
||||||
it('renders a single AI button (not 3 buttons)', () => {
|
it('allows server settings to be injected', () => {
|
||||||
|
setOpenCodeServerSettings({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single AI button', () => {
|
||||||
const cell = makeFakeCell('x = 1');
|
const cell = makeFakeCell('x = 1');
|
||||||
const actions = new OpenCodeCellActions(cell);
|
const actions = new OpenCodeCellActions(cell);
|
||||||
const btns = actions.node.querySelectorAll('button');
|
const btns = actions.node.querySelectorAll('button');
|
||||||
@@ -138,7 +148,6 @@ describe('OpenCodeCellActions', () => {
|
|||||||
it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => {
|
it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => {
|
||||||
const cell = makeFakeCell('x = 1');
|
const cell = makeFakeCell('x = 1');
|
||||||
const actions = new OpenCodeCellActions(cell);
|
const actions = new OpenCodeCellActions(cell);
|
||||||
// Attach actions to cell so it can resolve parent chain
|
|
||||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
(actions as any).onAfterAttach({} as any);
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
@@ -160,8 +169,7 @@ describe('OpenCodeCellActions', () => {
|
|||||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
btn.click();
|
btn.click();
|
||||||
btn.click();
|
btn.click();
|
||||||
const prompt = cell.node.querySelector('.opencode-inline-prompt');
|
expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull();
|
||||||
expect(prompt).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disconnects model signals on dispose', () => {
|
it('disconnects model signals on dispose', () => {
|
||||||
@@ -172,15 +180,58 @@ describe('OpenCodeCellActions', () => {
|
|||||||
expect(model.contentChanged.disconnect).toHaveBeenCalled();
|
expect(model.contentChanged.disconnect).toHaveBeenCalled();
|
||||||
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a model selector with the first option selected 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');
|
||||||
|
// v3-final: default = first option (no settings).
|
||||||
|
expect(select.value).toBe('anthropic|claude-sonnet-4-20250514');
|
||||||
|
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-model-select')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OpenCodeInlinePrompt', () => {
|
describe('OpenCodeInlinePrompt', () => {
|
||||||
it('renders a textarea, a send and a cancel button', () => {
|
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
|
||||||
const cell = makeFakeCell('x = 1');
|
const cell = makeFakeCell('x = 1');
|
||||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
onSubmit: jest.fn(),
|
onSubmit: jest.fn(),
|
||||||
onCancel: jest.fn(),
|
onCancel: jest.fn(),
|
||||||
disabled: false
|
disabled: false,
|
||||||
|
providers: null
|
||||||
});
|
});
|
||||||
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||||
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
*
|
*
|
||||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||||
*
|
*
|
||||||
* The frontend does NO semantic processing: it gathers the cell's source /
|
* v3-final: the frontend does NO semantic processing. It gathers the cell's
|
||||||
* outputs / error, attaches the user's freeform instruction, and POSTs to
|
* source / outputs / error, attaches the user's freeform instruction, and
|
||||||
* the server. The server (and the LLM it forwards to) decides what to do.
|
* POSTs to the server. The chosen provider/model comes from the inline
|
||||||
|
* picker (no settings-based default).
|
||||||
*/
|
*/
|
||||||
import type { CodeCell } from '@jupyterlab/cells';
|
import type { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
@@ -19,23 +20,27 @@ import { callOpenCodeEdit } from '../api/opencode_client';
|
|||||||
import { extractCellContext } from '../context/cell_context';
|
import { extractCellContext } from '../context/cell_context';
|
||||||
import type {
|
import type {
|
||||||
CellContext,
|
CellContext,
|
||||||
|
OpenCodeProvidersResponse,
|
||||||
OpenCodeRequest,
|
OpenCodeRequest,
|
||||||
OpenCodeResponse,
|
OpenCodeResponse
|
||||||
OpenCodeSettings
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||||
|
|
||||||
// Module-level runtime injection (set by index.ts after settings load).
|
// Module-level runtime injection (set by index.ts after activation).
|
||||||
let _settings: OpenCodeSettings | null = null;
|
|
||||||
let _serverSettings: ServerConnection.ISettings | null = null;
|
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||||
|
let _providers: OpenCodeProvidersResponse | null = null;
|
||||||
|
|
||||||
export function setOpenCodeRuntime(args: {
|
export function setOpenCodeServerSettings(
|
||||||
settings: OpenCodeSettings;
|
serverSettings: ServerConnection.ISettings
|
||||||
serverSettings: ServerConnection.ISettings;
|
): void {
|
||||||
}): void {
|
_serverSettings = serverSettings;
|
||||||
_settings = args.settings;
|
}
|
||||||
_serverSettings = args.serverSettings;
|
|
||||||
|
export function setOpenCodeProviders(
|
||||||
|
p: OpenCodeProvidersResponse | null
|
||||||
|
): void {
|
||||||
|
_providers = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status = 'idle' | 'loading';
|
type Status = 'idle' | 'loading';
|
||||||
@@ -108,8 +113,9 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||||
disabled: !this._context || this._status === 'loading',
|
disabled: !this._context || this._status === 'loading',
|
||||||
onSubmit: (text: string) => {
|
providers: _providers,
|
||||||
void this._onSubmit(text);
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
|
void this._onSubmit(text, providerId, modelId);
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
this._hidePrompt();
|
this._hidePrompt();
|
||||||
@@ -126,20 +132,24 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onSubmit(text: string): Promise<void> {
|
private async _onSubmit(
|
||||||
|
text: string,
|
||||||
|
providerId?: string,
|
||||||
|
modelId?: string
|
||||||
|
): Promise<void> {
|
||||||
if (!this._context) {
|
if (!this._context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_settings || !_serverSettings) {
|
if (!_serverSettings) {
|
||||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
Notification.error('OpenCode 运行时未初始化');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: OpenCodeRequest = {
|
const request: OpenCodeRequest = {
|
||||||
prompt: text,
|
prompt: text,
|
||||||
context: this._context,
|
context: this._context,
|
||||||
providerId: _settings.opencodeProvider || undefined,
|
providerId,
|
||||||
modelId: _settings.opencodeModel || undefined
|
modelId
|
||||||
};
|
};
|
||||||
|
|
||||||
this._status = 'loading';
|
this._status = 'loading';
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||||
* Renders a textarea + send/cancel buttons. The owning cell is passed so
|
* Renders a model selector, a textarea, and send/cancel buttons. The owning
|
||||||
* we can resolve the cell context at submit time. submit/cancel are
|
* cell is passed so we can resolve the cell context at submit time. submit
|
||||||
* callbacks owned by OpenCodeCellActions.
|
* and cancel are callbacks owned by OpenCodeCellActions.
|
||||||
|
*
|
||||||
|
* v3-final: the model selector is fully dynamic — it lists every
|
||||||
|
* (provider, model) pair from the providers cache and defaults to the first
|
||||||
|
* option. No settings-based default.
|
||||||
*/
|
*/
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
|
|
||||||
export interface IOpenCodeInlinePromptOptions {
|
export interface IOpenCodeInlinePromptOptions {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
providers: OpenCodeProvidersResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeInlinePrompt extends Widget {
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
private _cancelBtn: HTMLButtonElement;
|
private _cancelBtn: HTMLButtonElement;
|
||||||
|
private _select: HTMLSelectElement | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
_cell: CodeCell,
|
_cell: CodeCell,
|
||||||
@@ -24,6 +32,21 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.addClass('opencode-inline-prompt');
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// No settings-based default — pick the first available option.
|
||||||
|
this._select.value = this._select.options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
this._textarea = document.createElement('textarea');
|
this._textarea = document.createElement('textarea');
|
||||||
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
this._textarea.placeholder = '向 AI 描述你想要的修改…';
|
||||||
this._textarea.rows = 3;
|
this._textarea.rows = 3;
|
||||||
@@ -33,9 +56,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
this._sendBtn.disabled = options.disabled;
|
this._sendBtn.disabled = options.disabled;
|
||||||
this._sendBtn.addEventListener('click', () => {
|
this._sendBtn.addEventListener('click', () => {
|
||||||
const text = this._textarea.value;
|
const text = this._textarea.value;
|
||||||
if (text.trim()) {
|
if (!text.trim()) {
|
||||||
options.onSubmit(text);
|
return;
|
||||||
}
|
}
|
||||||
|
const [providerId, modelId] = this._readSelection();
|
||||||
|
options.onSubmit(text, providerId, modelId);
|
||||||
});
|
});
|
||||||
this._cancelBtn = document.createElement('button');
|
this._cancelBtn = document.createElement('button');
|
||||||
this._cancelBtn.className = 'opencode-btn-cancel';
|
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||||
@@ -49,11 +74,49 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
actions.appendChild(this._sendBtn);
|
actions.appendChild(this._sendBtn);
|
||||||
actions.appendChild(this._cancelBtn);
|
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(this._textarea);
|
||||||
this.node.appendChild(actions);
|
this.node.appendChild(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisabled(disabled: boolean): void {
|
setDisabled(disabled: boolean): void {
|
||||||
this._sendBtn.disabled = disabled;
|
this._sendBtn.disabled = disabled;
|
||||||
|
if (this._select) {
|
||||||
|
this._select.disabled = disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _readSelection(): [string | undefined, string | undefined] {
|
||||||
|
if (!this._select) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
const parts = this._select.value.split('|', 2);
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return [undefined, undefined];
|
||||||
|
}
|
||||||
|
return [parts[0], parts[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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user