213 lines
5.7 KiB
TypeScript
213 lines
5.7 KiB
TypeScript
/**
|
|
* 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 } 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;
|
|
}
|
|
|
|
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) {
|
|
this._cell.model.sharedModel.setSource(resp.finalSource);
|
|
Notification.info(
|
|
`OpenCode 完成 (${resp.finalSource.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
|
);
|
|
this._hidePrompt();
|
|
} 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);
|
|
}
|