Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d412130612 | ||
|
|
c0eba7e0e6 |
+529
-645
File diff suppressed because it is too large
Load Diff
@@ -1,378 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
|
||||||
* (top-right of the active cell). Clicking it toggles an
|
|
||||||
* OpenCodeInlinePrompt panel attached to the cell's DOM.
|
|
||||||
*
|
|
||||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
|
||||||
*
|
|
||||||
* v4 message flow (matches demo.html):
|
|
||||||
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
|
|
||||||
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
|
|
||||||
* sessionId
|
|
||||||
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
|
|
||||||
* forward each event to the prompt's applyEvent() dispatcher
|
|
||||||
* 4. On session.idle we close the stream and re-enable the input
|
|
||||||
*
|
|
||||||
* The frontend does NO semantic processing of the LLM reply. 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 { ServerConnection } from '@jupyterlab/services';
|
|
||||||
import { Widget } from '@lumino/widgets';
|
|
||||||
|
|
||||||
import {
|
|
||||||
callOpenCodeBindExistingSession,
|
|
||||||
callOpenCodeCreateNotebookSession,
|
|
||||||
callOpenCodeEdit,
|
|
||||||
callOpenCodeListAllSessions,
|
|
||||||
callOpenCodeReplyPermission,
|
|
||||||
callOpenCodeReplyQuestion,
|
|
||||||
callOpenCodeSessionMessages,
|
|
||||||
subscribeOpenCodeEvents,
|
|
||||||
type OpenCodeEventSubscription
|
|
||||||
} from '../api/opencode_client';
|
|
||||||
import type { OpenCodeProvidersResponse } 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;
|
|
||||||
// The cell's notebookPath is the only context we need for the
|
|
||||||
// /edit request. The cell source, error traceback, and previous
|
|
||||||
// cell are no longer auto-injected — the user attaches whatever
|
|
||||||
// they want via the "📋 插入单元格内容" button.
|
|
||||||
private _notebookPath: string | null = null;
|
|
||||||
private _status: Status = 'idle';
|
|
||||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
|
||||||
private _sseSub: OpenCodeEventSubscription | 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._closeSse();
|
|
||||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
|
||||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
|
||||||
this._hidePrompt();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onModelChange(): void {
|
|
||||||
this._notebookPath = extractNotebookPathFromCell(this._cell);
|
|
||||||
if (this._prompt) {
|
|
||||||
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
|
|
||||||
} else {
|
|
||||||
this._render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _render(): void {
|
|
||||||
const node = this.node;
|
|
||||||
node.textContent = '';
|
|
||||||
|
|
||||||
const baseDisabled = !this._notebookPath || 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._notebookPath || this._status === 'loading',
|
|
||||||
providers: _providers,
|
|
||||||
notebookPath: this._notebookPath ?? undefined,
|
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
|
||||||
void this._onSubmit(text, providerId, modelId);
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
this._hidePrompt();
|
|
||||||
},
|
|
||||||
onPermissionReply: async (permId, response) => {
|
|
||||||
if (!_serverSettings) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
|
||||||
if (!sessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeReplyPermission(
|
|
||||||
sessionId,
|
|
||||||
permId,
|
|
||||||
response,
|
|
||||||
_serverSettings
|
|
||||||
);
|
|
||||||
return r.ok;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: permission reply failed', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onQuestionReply: async (qId, answer) => {
|
|
||||||
if (!_serverSettings) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
|
||||||
if (!sessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeReplyQuestion(
|
|
||||||
sessionId,
|
|
||||||
qId,
|
|
||||||
answer,
|
|
||||||
_serverSettings
|
|
||||||
);
|
|
||||||
return r.ok;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: question reply failed', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStreamEnd: () => {
|
|
||||||
// Centralized: the prompt detected "idle" (any shape) and tells
|
|
||||||
// us to close the SSE stream + restore the input. We no longer
|
|
||||||
// parse event types here.
|
|
||||||
this._closeSse();
|
|
||||||
},
|
|
||||||
onListSessions: async () => {
|
|
||||||
if (!_serverSettings) return [];
|
|
||||||
try {
|
|
||||||
const r = await callOpenCodeListAllSessions(_serverSettings);
|
|
||||||
return r.sessions || [];
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('opencode_bridge: list all sessions failed', e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreateSession: async () => {
|
|
||||||
if (!_serverSettings || !this._notebookPath) {
|
|
||||||
throw new Error('notebook not ready');
|
|
||||||
}
|
|
||||||
// Tear down any in-flight SSE first — the old session is
|
|
||||||
// being replaced.
|
|
||||||
this._closeSse();
|
|
||||||
const r = await callOpenCodeCreateNotebookSession(
|
|
||||||
this._notebookPath,
|
|
||||||
undefined,
|
|
||||||
_serverSettings
|
|
||||||
);
|
|
||||||
if (!r.ok || !r.sessionId) {
|
|
||||||
throw new Error(r.error || 'create_session failed');
|
|
||||||
}
|
|
||||||
// The prompt's _handleCreateSession calls setSessionId
|
|
||||||
// internally; no need to set it here too.
|
|
||||||
return r.sessionId;
|
|
||||||
},
|
|
||||||
onSwitchSession: async (sessionId: string) => {
|
|
||||||
if (!_serverSettings || !this._notebookPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Closing the SSE first ensures the old session's events stop
|
|
||||||
// coming in while we transition.
|
|
||||||
this._closeSse();
|
|
||||||
// PUT /sessions/notebook binds AND sets active in one call.
|
|
||||||
// (The separate PUT /sessions/active is only for switching
|
|
||||||
// between sessions that are ALREADY bound; for a session the
|
|
||||||
// user just picked from the global list, we need to bind
|
|
||||||
// first — calling set_active before bind returns 400.)
|
|
||||||
const r = await callOpenCodeBindExistingSession(
|
|
||||||
this._notebookPath,
|
|
||||||
sessionId,
|
|
||||||
_serverSettings
|
|
||||||
);
|
|
||||||
if (!r.ok) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// The prompt's _handleSwitchSession calls setSessionId
|
|
||||||
// internally; no need to set it here too.
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onReloadHistory: async () => {
|
|
||||||
if (!this._notebookPath) return;
|
|
||||||
await this._refreshHistory(this._notebookPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Widget.attach(prompt, this._cell.node);
|
|
||||||
this._prompt = prompt;
|
|
||||||
|
|
||||||
// Fetch the current session's static history and render it before
|
|
||||||
// streaming begins. Empty list is a normal no-op.
|
|
||||||
const notebookPath = this._notebookPath;
|
|
||||||
if (notebookPath) {
|
|
||||||
void this._refreshHistory(notebookPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate the session selector dropdown (loads OpenCode's global
|
|
||||||
// session list async; if the host didn't wire up the callbacks,
|
|
||||||
// the row is simply not rendered).
|
|
||||||
prompt.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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._notebookPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_serverSettings) {
|
|
||||||
Notification.error('OpenCode 运行时未初始化');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
prompt: text,
|
|
||||||
context: { notebookPath: this._notebookPath },
|
|
||||||
providerId,
|
|
||||||
modelId
|
|
||||||
};
|
|
||||||
|
|
||||||
this._status = 'loading';
|
|
||||||
if (this._prompt) {
|
|
||||||
this._prompt.setDisabled(true);
|
|
||||||
this._prompt.setSessionId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await callOpenCodeEdit(request, _serverSettings);
|
|
||||||
} catch (e) {
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async edit accepted. Bind the prompt to the returned session, clear
|
|
||||||
// the input, then subscribe to the SSE event stream for this session.
|
|
||||||
this._prompt?.setSessionId(resp.sessionId);
|
|
||||||
this._prompt?.clearInput();
|
|
||||||
this._sseSub = subscribeOpenCodeEvents(
|
|
||||||
resp.sessionId,
|
|
||||||
_serverSettings,
|
|
||||||
{
|
|
||||||
onEvent: ev => {
|
|
||||||
this._prompt?.applyEvent(ev);
|
|
||||||
// Stream-end is detected by the prompt and signalled via
|
|
||||||
// onStreamEnd (see _showPrompt); no idle parsing here.
|
|
||||||
},
|
|
||||||
onError: err => {
|
|
||||||
console.warn('opencode_bridge: SSE error', err);
|
|
||||||
// Don't flip status here: the server may just have hiccuped.
|
|
||||||
// session.idle (if it comes through) will re-enable the input.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closeSse(): void {
|
|
||||||
if (this._sseSub) {
|
|
||||||
this._sseSub.close();
|
|
||||||
this._sseSub = null;
|
|
||||||
}
|
|
||||||
this._status = 'idle';
|
|
||||||
this._prompt?.setDisabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk the parent chain to find the cell's parent NotebookPanel and
|
|
||||||
* return its content path. Returns null if the cell is detached
|
|
||||||
* (no notebook parent).
|
|
||||||
*/
|
|
||||||
function extractNotebookPathFromCell(cell: CodeCell): string | null {
|
|
||||||
let node: Widget | null = cell.parent;
|
|
||||||
while (node) {
|
|
||||||
const candidate = node as any;
|
|
||||||
if (
|
|
||||||
candidate.context &&
|
|
||||||
typeof candidate.context.path === 'string' &&
|
|
||||||
candidate.content &&
|
|
||||||
Array.isArray(candidate.content.widgets)
|
|
||||||
) {
|
|
||||||
return candidate.context.path;
|
|
||||||
}
|
|
||||||
node = node.parent;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Global floating AI button + panel, mounted on document.body. Replaces
|
||||||
|
* the per-cell toolbar AI button (formerly OpenCodeCellActions). Click
|
||||||
|
* the round bottom-right button to expand the OpenCodeInlinePrompt; click
|
||||||
|
* again to collapse it.
|
||||||
|
*
|
||||||
|
* The panel operates on the JupyterLab shell's currently-active
|
||||||
|
* NotebookPanel — the (notebookPath, activeCell) pair is resolved at
|
||||||
|
* panel-open time from `app.shell.currentWidget`, and re-resolved when
|
||||||
|
* `app.shell.currentChanged` fires while the panel is open.
|
||||||
|
*
|
||||||
|
* All session/SSE/history state that used to live on
|
||||||
|
* OpenCodeCellActions now lives here. The prompt is created lazily on
|
||||||
|
* the first panel open and disposed on close (recreated on every
|
||||||
|
* re-open so the user always sees a fresh history fetch).
|
||||||
|
*/
|
||||||
|
import { JupyterFrontEnd } from '@jupyterlab/application';
|
||||||
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
|
import type { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
|
import {
|
||||||
|
callOpenCodeBindExistingSession,
|
||||||
|
callOpenCodeCreateNotebookSession,
|
||||||
|
callOpenCodeEdit,
|
||||||
|
callOpenCodeListAllSessions,
|
||||||
|
callOpenCodeReplyPermission,
|
||||||
|
callOpenCodeReplyQuestion,
|
||||||
|
callOpenCodeSessionMessages,
|
||||||
|
subscribeOpenCodeEvents,
|
||||||
|
type OpenCodeEventSubscription
|
||||||
|
} from '../api/opencode_client';
|
||||||
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
|
|
||||||
|
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||||
|
|
||||||
|
export interface IOpenCodeFloatingPanelOptions {
|
||||||
|
app: JupyterFrontEnd;
|
||||||
|
serverSettings: ServerConnection.ISettings;
|
||||||
|
providers: OpenCodeProvidersResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = 'idle' | 'loading';
|
||||||
|
|
||||||
|
export class OpenCodeFloatingPanel extends Widget {
|
||||||
|
private _app: JupyterFrontEnd;
|
||||||
|
private _serverSettings: ServerConnection.ISettings | null;
|
||||||
|
private _providers: OpenCodeProvidersResponse | null;
|
||||||
|
private _button: HTMLButtonElement;
|
||||||
|
private _panelEl: HTMLDivElement;
|
||||||
|
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||||
|
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||||
|
private _notebookPath: string | null = null;
|
||||||
|
private _status: Status = 'idle';
|
||||||
|
|
||||||
|
constructor(options: IOpenCodeFloatingPanelOptions) {
|
||||||
|
super();
|
||||||
|
this._app = options.app;
|
||||||
|
this._serverSettings = options.serverSettings;
|
||||||
|
this._providers = options.providers;
|
||||||
|
this.addClass('opencode-floating-root');
|
||||||
|
this.node.style.position = 'fixed';
|
||||||
|
this.node.style.bottom = '24px';
|
||||||
|
this.node.style.right = '24px';
|
||||||
|
this.node.style.zIndex = '1000';
|
||||||
|
|
||||||
|
this._button = document.createElement('button');
|
||||||
|
this._button.className = 'opencode-floating-button';
|
||||||
|
this._button.textContent = '🪄';
|
||||||
|
this._button.title = 'OpenCode AI';
|
||||||
|
this._button.addEventListener('click', () => this.toggle());
|
||||||
|
this.node.appendChild(this._button);
|
||||||
|
|
||||||
|
this._panelEl = document.createElement('div');
|
||||||
|
this._panelEl.className = 'opencode-floating-panel';
|
||||||
|
this._panelEl.hidden = true;
|
||||||
|
this.node.appendChild(this._panelEl);
|
||||||
|
|
||||||
|
// JupyterFrontEnd.shell is typed as `JupyterShell | null` in some
|
||||||
|
// JupyterLab versions; cast through any to satisfy strict null checks
|
||||||
|
// while keeping the public API simple.
|
||||||
|
(this._app.shell as any).currentChanged.connect(this._onShellChanged, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(this._app.shell as any).currentChanged.disconnect(
|
||||||
|
this._onShellChanged,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
this._closeSse();
|
||||||
|
this._hidePrompt();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the providers payload after the initial async fetch. */
|
||||||
|
setProviders(p: OpenCodeProvidersResponse | null): void {
|
||||||
|
this._providers = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the panel open/closed. */
|
||||||
|
toggle(): void {
|
||||||
|
if (this._panelEl.hidden) {
|
||||||
|
this._showPrompt();
|
||||||
|
} else {
|
||||||
|
this._hidePrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onShellChanged = (): void => {
|
||||||
|
// The active notebook changed. If the panel is open, refresh the
|
||||||
|
// underlying prompt so the (provider, model) selection, history,
|
||||||
|
// and active-cell context all reflect the new notebook.
|
||||||
|
//
|
||||||
|
// Implemented as an arrow-function class field (not a method) so
|
||||||
|
// `this` is bound regardless of how the signal's slot invokes us.
|
||||||
|
if (!this._panelEl.hidden) {
|
||||||
|
this._hidePrompt();
|
||||||
|
this._showPrompt();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _resolveNotebookContext(): {
|
||||||
|
notebookPath: string | null;
|
||||||
|
getActiveCell: () => CodeCell | null;
|
||||||
|
} {
|
||||||
|
const widget = this._app.shell.currentWidget as any;
|
||||||
|
const notebookPath =
|
||||||
|
widget && widget.context && typeof widget.context.path === 'string'
|
||||||
|
? (widget.context.path as string)
|
||||||
|
: null;
|
||||||
|
const getActiveCell = (): CodeCell | null => {
|
||||||
|
const cur = this._app.shell.currentWidget as any;
|
||||||
|
if (!cur || !cur.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const active = cur.content.activeCell;
|
||||||
|
return (active as CodeCell) || null;
|
||||||
|
};
|
||||||
|
return { notebookPath, getActiveCell };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showPrompt(): void {
|
||||||
|
if (this._prompt) {
|
||||||
|
this._panelEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { notebookPath, getActiveCell } = this._resolveNotebookContext();
|
||||||
|
this._notebookPath = notebookPath;
|
||||||
|
const prompt = new OpenCodeInlinePrompt({
|
||||||
|
disabled: !this._notebookPath || this._status === 'loading',
|
||||||
|
providers: this._providers,
|
||||||
|
notebookPath: this._notebookPath ?? undefined,
|
||||||
|
getActiveCell,
|
||||||
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
|
void this._onSubmit(text, providerId, modelId);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this._hidePrompt();
|
||||||
|
},
|
||||||
|
onPermissionReply: async (permId, response) => {
|
||||||
|
if (!this._serverSettings) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||||
|
if (!sessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeReplyPermission(
|
||||||
|
sessionId,
|
||||||
|
permId,
|
||||||
|
response,
|
||||||
|
this._serverSettings
|
||||||
|
);
|
||||||
|
return r.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: permission reply failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onQuestionReply: async (qId, answer) => {
|
||||||
|
if (!this._serverSettings) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||||
|
if (!sessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeReplyQuestion(
|
||||||
|
sessionId,
|
||||||
|
qId,
|
||||||
|
answer,
|
||||||
|
this._serverSettings
|
||||||
|
);
|
||||||
|
return r.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: question reply failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStreamEnd: () => {
|
||||||
|
// Centralized: the prompt detected "idle" and signals us to
|
||||||
|
// close the SSE stream + restore the input.
|
||||||
|
this._closeSse();
|
||||||
|
},
|
||||||
|
onListSessions: async () => {
|
||||||
|
if (!this._serverSettings) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeListAllSessions(this._serverSettings);
|
||||||
|
return r.sessions || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: list all sessions failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreateSession: async () => {
|
||||||
|
if (!this._serverSettings || !this._notebookPath) {
|
||||||
|
throw new Error('notebook not ready');
|
||||||
|
}
|
||||||
|
this._closeSse();
|
||||||
|
const r = await callOpenCodeCreateNotebookSession(
|
||||||
|
this._notebookPath,
|
||||||
|
undefined,
|
||||||
|
this._serverSettings
|
||||||
|
);
|
||||||
|
if (!r.ok || !r.sessionId) {
|
||||||
|
throw new Error(r.error || 'create_session failed');
|
||||||
|
}
|
||||||
|
return r.sessionId;
|
||||||
|
},
|
||||||
|
onSwitchSession: async (sessionId: string) => {
|
||||||
|
if (!this._serverSettings || !this._notebookPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._closeSse();
|
||||||
|
// PUT /sessions/notebook binds AND sets active in one call.
|
||||||
|
const r = await callOpenCodeBindExistingSession(
|
||||||
|
this._notebookPath,
|
||||||
|
sessionId,
|
||||||
|
this._serverSettings
|
||||||
|
);
|
||||||
|
return !!r.ok;
|
||||||
|
},
|
||||||
|
onReloadHistory: async () => {
|
||||||
|
if (!this._notebookPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this._refreshHistory(this._notebookPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Use Widget.attach (not raw appendChild) so Lumino sends the
|
||||||
|
// before-attach / after-attach messages — the prompt relies on
|
||||||
|
// these for isAttached/isVisible tracking. The defensive DOM
|
||||||
|
// cleanup in _hidePrompt handles the case where dispose() leaves
|
||||||
|
// a residual node behind (the panel host is an HTMLElement, not a
|
||||||
|
// Widget, so the Lumino parent stays null and dispose() can't
|
||||||
|
// always clean up the DOM on its own).
|
||||||
|
Widget.attach(prompt, this._panelEl);
|
||||||
|
this._prompt = prompt;
|
||||||
|
|
||||||
|
// Load the current session's static history before streaming starts.
|
||||||
|
if (this._notebookPath) {
|
||||||
|
void this._refreshHistory(this._notebookPath);
|
||||||
|
}
|
||||||
|
prompt.initialize();
|
||||||
|
this._panelEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||||
|
if (!this._serverSettings || !this._prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await callOpenCodeSessionMessages(
|
||||||
|
notebookPath,
|
||||||
|
this._serverSettings
|
||||||
|
);
|
||||||
|
this._prompt.setMessages(resp.messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hidePrompt(): void {
|
||||||
|
this._closeSse();
|
||||||
|
if (this._prompt) {
|
||||||
|
const node = this._prompt.node;
|
||||||
|
this._prompt.dispose();
|
||||||
|
// Defensive DOM cleanup: the prompt is attached via raw DOM
|
||||||
|
// (appendChild/Widget.attach with an HTMLElement host), so its
|
||||||
|
// Lumino parent is null. In some scenarios — for example when
|
||||||
|
// the dispose path runs synchronously while another show+hide
|
||||||
|
// cycle is already in flight — the dispose() call does not
|
||||||
|
// remove the node from this._panelEl, leaving a residual node
|
||||||
|
// that the next _showPrompt appends to. Without this defensive
|
||||||
|
// removeChild, the user would see the panel content multiply
|
||||||
|
// with every click.
|
||||||
|
if (node.parentNode === this._panelEl) {
|
||||||
|
this._panelEl.removeChild(node);
|
||||||
|
}
|
||||||
|
this._prompt = null;
|
||||||
|
}
|
||||||
|
this._panelEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onSubmit(
|
||||||
|
text: string,
|
||||||
|
providerId?: string,
|
||||||
|
modelId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this._notebookPath) {
|
||||||
|
Notification.info('请先打开一个 notebook');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._serverSettings) {
|
||||||
|
Notification.error('OpenCode 运行时未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
prompt: text,
|
||||||
|
context: { notebookPath: this._notebookPath },
|
||||||
|
providerId,
|
||||||
|
modelId
|
||||||
|
};
|
||||||
|
|
||||||
|
this._status = 'loading';
|
||||||
|
if (this._prompt) {
|
||||||
|
this._prompt.setDisabled(true);
|
||||||
|
this._prompt.setSessionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await callOpenCodeEdit(request, this._serverSettings);
|
||||||
|
} catch (e) {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._prompt?.setSessionId(resp.sessionId);
|
||||||
|
this._prompt?.clearInput();
|
||||||
|
this._sseSub = subscribeOpenCodeEvents(
|
||||||
|
resp.sessionId,
|
||||||
|
this._serverSettings,
|
||||||
|
{
|
||||||
|
onEvent: ev => {
|
||||||
|
this._prompt?.applyEvent(ev);
|
||||||
|
},
|
||||||
|
onError: err => {
|
||||||
|
console.warn('opencode_bridge: SSE error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closeSse(): void {
|
||||||
|
if (this._sseSub) {
|
||||||
|
this._sseSub.close();
|
||||||
|
this._sseSub = null;
|
||||||
|
}
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* history area. The user can still Copy / Insert / Replace individual
|
* history area. The user can still Copy / Insert / Replace individual
|
||||||
* code blocks from a finished assistant message.
|
* code blocks from a finished assistant message.
|
||||||
*/
|
*/
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
import type { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
@@ -31,10 +31,7 @@ import type {
|
|||||||
OpenCodeProvidersResponse,
|
OpenCodeProvidersResponse,
|
||||||
OpenCodeSessionMeta
|
OpenCodeSessionMeta
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import { loadModelSelection, saveModelSelection } from './model_selection';
|
||||||
loadModelSelection,
|
|
||||||
saveModelSelection
|
|
||||||
} from './model_selection';
|
|
||||||
|
|
||||||
export interface IOpenCodeInlinePromptOptions {
|
export interface IOpenCodeInlinePromptOptions {
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||||
@@ -48,6 +45,16 @@ export interface IOpenCodeInlinePromptOptions {
|
|||||||
* to the default selection (first provider / `default[pid]` model).
|
* to the default selection (first provider / `default[pid]` model).
|
||||||
*/
|
*/
|
||||||
notebookPath?: string;
|
notebookPath?: string;
|
||||||
|
/**
|
||||||
|
* Return the currently active CodeCell, used by the cell-edit actions
|
||||||
|
* ("📋 插入单元格内容" / "插入到光标" / "替换单元格"). When the host is
|
||||||
|
* decoupled from a specific cell (e.g. a global floating panel that
|
||||||
|
* targets whatever cell the user last focused), it passes a lambda
|
||||||
|
* that resolves the active cell from the JupyterLab shell at click
|
||||||
|
* time. Returns null if no cell is currently selectable — the prompt
|
||||||
|
* then surfaces a "请先选中一个 cell" notification instead of acting.
|
||||||
|
*/
|
||||||
|
getActiveCell?: () => CodeCell | null;
|
||||||
/**
|
/**
|
||||||
* Respond to a `permission.asked` event. Returns a promise that resolves
|
* Respond to a `permission.asked` event. Returns a promise that resolves
|
||||||
* to true on success, false on failure (the prompt updates the UI in
|
* to true on success, false on failure (the prompt updates the UI in
|
||||||
@@ -81,19 +88,22 @@ export interface IOpenCodeInlinePromptOptions {
|
|||||||
onReloadHistory?: () => Promise<void>;
|
onReloadHistory?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface FlatProvider {
|
interface FlatProvider {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
models: { [modelID: string]: unknown };
|
models: { [modelID: string]: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenProviders(
|
function flattenProviders(providers: OpenCodeProvidersResponse | null): {
|
||||||
providers: OpenCodeProvidersResponse | null
|
providers: FlatProvider[];
|
||||||
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
defaultMap: { [pid: string]: string };
|
||||||
|
} {
|
||||||
const out: FlatProvider[] = [];
|
const out: FlatProvider[] = [];
|
||||||
if (!providers || !providers.providers) {
|
if (!providers || !providers.providers) {
|
||||||
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
return {
|
||||||
|
providers: out,
|
||||||
|
defaultMap: (providers && providers.default) || {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
for (const p of providers.providers) {
|
for (const p of providers.providers) {
|
||||||
if (!p.models || typeof p.models !== 'object') {
|
if (!p.models || typeof p.models !== 'object') {
|
||||||
@@ -118,7 +128,10 @@ function pickDefaultModelId(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const fromDefault = defaultMap[providerId];
|
const fromDefault = defaultMap[providerId];
|
||||||
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
if (
|
||||||
|
fromDefault &&
|
||||||
|
Object.prototype.hasOwnProperty.call(models, fromDefault)
|
||||||
|
) {
|
||||||
return fromDefault;
|
return fromDefault;
|
||||||
}
|
}
|
||||||
return keys[0];
|
return keys[0];
|
||||||
@@ -130,7 +143,6 @@ interface BlockEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeInlinePrompt extends Widget {
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
private _cell: CodeCell;
|
|
||||||
private _options: IOpenCodeInlinePromptOptions;
|
private _options: IOpenCodeInlinePromptOptions;
|
||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
@@ -161,12 +173,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
||||||
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
||||||
|
|
||||||
constructor(
|
constructor(options: IOpenCodeInlinePromptOptions) {
|
||||||
cell: CodeCell,
|
|
||||||
options: IOpenCodeInlinePromptOptions
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
this._cell = cell;
|
|
||||||
this._options = options;
|
this._options = options;
|
||||||
this.addClass('opencode-inline-prompt');
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
@@ -226,7 +234,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
const modelId = this._modelSelect?.value || undefined;
|
const modelId = this._modelSelect?.value || undefined;
|
||||||
options.onSubmit(text, providerId, modelId);
|
options.onSubmit(text, providerId, modelId);
|
||||||
});
|
});
|
||||||
this._textarea.addEventListener('keydown', (e) => {
|
this._textarea.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._sendBtn.click();
|
this._sendBtn.click();
|
||||||
@@ -247,7 +255,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
this._insertCellBtn.type = 'button';
|
this._insertCellBtn.type = 'button';
|
||||||
this._insertCellBtn.className = 'opencode-btn-insert-cell';
|
this._insertCellBtn.className = 'opencode-btn-insert-cell';
|
||||||
this._insertCellBtn.textContent = '📋 插入单元格内容';
|
this._insertCellBtn.textContent = '📋 插入单元格内容';
|
||||||
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
this._insertCellBtn.title =
|
||||||
|
'把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
||||||
this._insertCellBtn.addEventListener('click', () => {
|
this._insertCellBtn.addEventListener('click', () => {
|
||||||
this._insertCellSource();
|
this._insertCellSource();
|
||||||
});
|
});
|
||||||
@@ -348,6 +357,15 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
|
|
||||||
this.node.appendChild(this._textarea);
|
this.node.appendChild(this._textarea);
|
||||||
this.node.appendChild(actions);
|
this.node.appendChild(actions);
|
||||||
|
|
||||||
|
// Apply the initial disabled state to ALL interactive elements
|
||||||
|
// (provider/model selects, send button, AND the insert-cell
|
||||||
|
// button — the last one depends on `getActiveCell()` which only
|
||||||
|
// resolves correctly here in the constructor). Without this, the
|
||||||
|
// insert button would stay enabled until the first external
|
||||||
|
// setDisabled() call, which is too late for the user's first
|
||||||
|
// click.
|
||||||
|
this.setDisabled(options.disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _rebuildModelSelect(
|
private _rebuildModelSelect(
|
||||||
@@ -606,6 +624,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// closes the SSE on switch.
|
// closes the SSE on switch.
|
||||||
this._sessionSelect.disabled = false;
|
this._sessionSelect.disabled = false;
|
||||||
}
|
}
|
||||||
|
// The "📋 插入单元格内容" button is only meaningful when there is
|
||||||
|
// an active cell. Reflect availability in its disabled state so
|
||||||
|
// the user can see at a glance whether the action will work, and
|
||||||
|
// the click handler can still no-op gracefully via Notification.
|
||||||
|
if (this._insertCellBtn) {
|
||||||
|
const hasActiveCell = !!this._options.getActiveCell?.();
|
||||||
|
this._insertCellBtn.disabled = disabled || !hasActiveCell;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the user input textarea. */
|
/** Clear the user input textarea. */
|
||||||
@@ -618,13 +644,18 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
* markdown code block to the textarea. The LLM only sees what the
|
* markdown code block to the textarea. The LLM only sees what the
|
||||||
* user explicitly chose to include — the cell source is NEVER
|
* user explicitly chose to include — the cell source is NEVER
|
||||||
* auto-injected (v0.2.x change).
|
* auto-injected (v0.2.x change).
|
||||||
|
*
|
||||||
|
* The cell is resolved at click time through the host-provided
|
||||||
|
* `getActiveCell` callback, so the prompt itself stays decoupled
|
||||||
|
* from any specific CodeCell instance.
|
||||||
*/
|
*/
|
||||||
private _insertCellSource(): void {
|
private _insertCellSource(): void {
|
||||||
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
|
const cell = this._options.getActiveCell?.() ?? null;
|
||||||
Notification.info('当前 cell 没有内容');
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
|
Notification.info('请先选中一个 cell');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const source = this._cell.model.sharedModel.getSource() as string;
|
const source = cell.model.sharedModel.getSource() as string;
|
||||||
if (!source || !source.trim()) {
|
if (!source || !source.trim()) {
|
||||||
Notification.info('当前 cell 为空');
|
Notification.info('当前 cell 为空');
|
||||||
return;
|
return;
|
||||||
@@ -632,9 +663,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// Use the cell's language as the fence info string when it
|
// Use the cell's language as the fence info string when it
|
||||||
// looks like a real language identifier; otherwise use a plain
|
// looks like a real language identifier; otherwise use a plain
|
||||||
// fence so markdown stays portable.
|
// fence so markdown stays portable.
|
||||||
const langRaw =
|
const langRaw = (cell.model as any).type
|
||||||
(this._cell.model as any).sharedModel && (this._cell.model as any).type
|
? ((cell.model as any).type as string)
|
||||||
? ((this._cell.model as any).type as string)
|
|
||||||
: 'python';
|
: 'python';
|
||||||
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
|
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
|
||||||
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
|
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
|
||||||
@@ -710,9 +740,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
applyEvent(event: OpenCodeEvent): void {
|
applyEvent(event: OpenCodeEvent): void {
|
||||||
const type = event.type || (event.payload && event.payload.type) || '';
|
const type = event.type || (event.payload && event.payload.type) || '';
|
||||||
const props =
|
const props =
|
||||||
event.properties ||
|
event.properties || (event.payload && event.payload.properties) || {};
|
||||||
(event.payload && event.payload.properties) ||
|
|
||||||
{};
|
|
||||||
const sessionID =
|
const sessionID =
|
||||||
props.sessionID ||
|
props.sessionID ||
|
||||||
props.sessionId ||
|
props.sessionId ||
|
||||||
@@ -761,13 +789,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
type: string,
|
type: string,
|
||||||
props: { [k: string]: any }
|
props: { [k: string]: any }
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
||||||
if (type === 'permission.asked') {
|
if (type === 'permission.asked') {
|
||||||
const permId = String(props.id || props.permissionID || '');
|
const permId = String(props.id || props.permissionID || '');
|
||||||
this._createPermissionBlock(
|
this._createPermissionBlock(
|
||||||
permId,
|
permId,
|
||||||
String(
|
String(
|
||||||
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
|
props.description ||
|
||||||
|
props.title ||
|
||||||
|
'系统请求执行权限(命令行/文件修改)'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -819,7 +848,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (type === 'session.next.tool.input.delta') {
|
if (type === 'session.next.tool.input.delta') {
|
||||||
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
|
this._appendToBlock(
|
||||||
|
'tool',
|
||||||
|
'🛠️ 工具输入与参数',
|
||||||
|
String(props.delta || '')
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (type === 'session.next.tool.progress') {
|
if (type === 'session.next.tool.progress') {
|
||||||
@@ -881,10 +914,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
if (payload) {
|
if (payload) {
|
||||||
return check(
|
return check(payload.type || topType, payload.properties || topProps);
|
||||||
payload.type || topType,
|
|
||||||
payload.properties || topProps
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -970,8 +1000,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.id = domId;
|
details.id = domId;
|
||||||
details.className =
|
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
||||||
'opencode-msg-block opencode-msg-block-interaction';
|
|
||||||
details.open = true;
|
details.open = true;
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = '🔐 权限请求等待处理';
|
summary.textContent = '🔐 权限请求等待处理';
|
||||||
@@ -1053,8 +1082,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.id = domId;
|
details.id = domId;
|
||||||
details.className =
|
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
||||||
'opencode-msg-block opencode-msg-block-interaction';
|
|
||||||
details.open = true;
|
details.open = true;
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = '❓ Agent 提问等待回复';
|
summary.textContent = '❓ Agent 提问等待回复';
|
||||||
@@ -1077,14 +1105,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
submit.className = 'opencode-q-submit';
|
submit.className = 'opencode-q-submit';
|
||||||
submit.textContent = '提交回答';
|
submit.textContent = '提交回答';
|
||||||
submit.addEventListener('click', () => {
|
submit.addEventListener('click', () => {
|
||||||
void this._handleQuestionResponse(
|
void this._handleQuestionResponse(questionId, input, inputRow, details);
|
||||||
questionId,
|
|
||||||
input,
|
|
||||||
inputRow,
|
|
||||||
details
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit.click();
|
submit.click();
|
||||||
@@ -1112,7 +1135,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this._options.onQuestionReply) {
|
if (!this._options.onQuestionReply) {
|
||||||
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
inputRow.innerHTML =
|
||||||
|
'<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
||||||
@@ -1242,8 +1266,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _insertAtCursor(text: string): void {
|
private _insertAtCursor(text: string): void {
|
||||||
const cell = this._cell;
|
const cell = this._options.getActiveCell?.() ?? null;
|
||||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
|
Notification.info('请先选中一个 cell');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sharedModel = cell.model.sharedModel;
|
const sharedModel = cell.model.sharedModel;
|
||||||
@@ -1251,9 +1276,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
let offset = source.length;
|
let offset = source.length;
|
||||||
try {
|
try {
|
||||||
const editor = (cell as any).editor;
|
const editor = (cell as any).editor;
|
||||||
const pos = editor && editor.getCursorPosition
|
const pos =
|
||||||
? editor.getCursorPosition()
|
editor && editor.getCursorPosition ? editor.getCursorPosition() : null;
|
||||||
: null;
|
|
||||||
if (pos) {
|
if (pos) {
|
||||||
const lines = source.split('\n');
|
const lines = source.split('\n');
|
||||||
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
||||||
@@ -1276,8 +1300,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _replaceCell(text: string): void {
|
private _replaceCell(text: string): void {
|
||||||
const cell = this._cell;
|
const cell = this._options.getActiveCell?.() ?? null;
|
||||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
|
Notification.info('请先选中一个 cell');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cell.model.sharedModel.setSource(text);
|
cell.model.sharedModel.setSource(text);
|
||||||
|
|||||||
+23
-41
@@ -3,74 +3,56 @@ import {
|
|||||||
JupyterFrontEndPlugin
|
JupyterFrontEndPlugin
|
||||||
} from '@jupyterlab/application';
|
} from '@jupyterlab/application';
|
||||||
|
|
||||||
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
|
||||||
import { Cell, CodeCell } from '@jupyterlab/cells';
|
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import {
|
import { OpenCodeFloatingPanel } from './components/opencode_floating_panel';
|
||||||
OpenCodeCellActions,
|
|
||||||
setOpenCodeProviders,
|
|
||||||
setOpenCodeServerSettings
|
|
||||||
} from './components/opencode_cell_actions';
|
|
||||||
import { requestAPI } from './request';
|
import { requestAPI } from './request';
|
||||||
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
|
* v5-floating: the per-cell AI button is gone. A single
|
||||||
* environment variables by the server extension; the model is picked
|
* OpenCodeFloatingPanel is attached to document.body and operates
|
||||||
* dynamically in the inline prompt.
|
* against the JupyterLab shell's currently-active notebook. 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: [IToolbarWidgetRegistry],
|
activate: (app: JupyterFrontEnd) => {
|
||||||
activate: (
|
|
||||||
app: JupyterFrontEnd,
|
|
||||||
toolbarRegistry: IToolbarWidgetRegistry | null
|
|
||||||
) => {
|
|
||||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||||
|
|
||||||
// Push the Jupyter server settings into the actions module so that
|
// Mount the floating AI button on the body. The panel itself is
|
||||||
// callOpenCodeEdit can build the right base URL.
|
// hidden until the user clicks the button.
|
||||||
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
const floating = new OpenCodeFloatingPanel({
|
||||||
|
app,
|
||||||
|
serverSettings: app.serviceManager.serverSettings,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
Widget.attach(floating, document.body);
|
||||||
|
|
||||||
// Register the per-cell AI actions into the native Cell toolbar
|
// Fetch available providers once at activation and cache them for
|
||||||
// (top-right of the active cell, next to move up/down). Non-code
|
// the inline prompt's model picker. Failure is non-fatal (the
|
||||||
// cells get an empty widget so they show no AI buttons.
|
// picker hides). Also push the payload into the floating panel so
|
||||||
if (toolbarRegistry) {
|
// the prompt can render selects without an extra round trip.
|
||||||
toolbarRegistry.addFactory<Cell>(
|
|
||||||
'Cell',
|
|
||||||
'opencode-cell-actions',
|
|
||||||
(cell: Cell) => {
|
|
||||||
if (cell instanceof CodeCell) {
|
|
||||||
return new OpenCodeCellActions(cell);
|
|
||||||
}
|
|
||||||
return new Widget();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setOpenCodeProviders(data);
|
floating.setProviders(data);
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'[opencode_bridge] Available OpenCode providers:'
|
'[opencode_bridge] Available OpenCode providers:'
|
||||||
];
|
];
|
||||||
for (const p of data.providers) {
|
for (const p of data.providers) {
|
||||||
const models = Object.values(p.models).map(m => m.id).join(', ');
|
const models = Object.values(p.models)
|
||||||
|
.map(m => m.id)
|
||||||
|
.join(', ');
|
||||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(lines.join('\n'));
|
console.log(lines.join('\n'));
|
||||||
})
|
})
|
||||||
.catch(reason => {
|
.catch(reason => {
|
||||||
// eslint-disable-next-line no-console
|
console.warn(
|
||||||
+ console.warn(
|
|
||||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
|
|||||||
+57
-15
@@ -4,30 +4,72 @@
|
|||||||
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). */
|
/* Floating AI launcher (replaces the per-cell toolbar button).
|
||||||
.opencode-cell-actions {
|
Mounted on document.body with position: fixed so it sits above the
|
||||||
|
JupyterLab layout regardless of which dock is active. The panel
|
||||||
|
(when open) renders above the button via column flex. */
|
||||||
|
.opencode-floating-root {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1000; /* above JupyterLab's main UI (z-index tops out around 100) */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-cell-actions .opencode-btn {
|
.opencode-floating-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: var(--jp-brand-color1, #1976d2);
|
||||||
padding: 0 6px;
|
color: white;
|
||||||
font-size: var(--jp-ui-font-size1);
|
font-size: 20px;
|
||||||
line-height: 20px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.opencode-floating-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.opencode-floating-button:focus {
|
||||||
|
outline: 2px solid var(--jp-brand-color2, #42a5f5);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-cell-actions .opencode-btn:hover:enabled {
|
.opencode-floating-panel {
|
||||||
background: var(--jp-layout-color2);
|
width: 480px;
|
||||||
border-radius: 2px;
|
max-width: calc(100vw - 48px);
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--jp-layout-color1, #fff);
|
||||||
|
border: 1px solid var(--jp-border-color2, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.opencode-floating-panel[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-cell-actions .opencode-btn:disabled {
|
/* The inline prompt inside the floating panel should fill it: drop
|
||||||
opacity: 0.4;
|
the standalone panel's own margin/border, and let the panel's
|
||||||
cursor: default;
|
scrollable region take over. */
|
||||||
|
.opencode-floating-panel .opencode-inline-prompt {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.opencode-floating-panel .opencode-inline-history {
|
||||||
|
max-height: 40vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
||||||
|
|||||||
Reference in New Issue
Block a user