feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.
Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
immediately with {ok, sessionId, notebookPath}; the LLM reply is no
longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
text/event-stream. Server forwards ALL events; the client filters.
A too-eager server-side ?session= filter was silently dropping events
the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
replies (once/always/reject and freeform answer) back to OpenCode
Serve at /session/:sid/permissions/:permId and
/session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
(async generator over the SSE feed via tornado streaming_callback),
reply_permission() and reply_question(). Legacy send_message_sync
removed.
Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
token injected from serverSettings); AbortController-backed close()
is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
UI: text delta -> assistant message (re-rendered as markdown on
every delta so the user sees formatted <pre><code> blocks in
real-time, not raw fence source); reasoning/tool/permission/question
get collapsible details blocks. session.idle resets stream pointers
and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
buttons (once/always/reject) for permission, a text input + submit
for question. Click handlers post through the new API routes and
show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
x {session.idle, session.status/idle}) inside the prompt; cell
action reacts via onStreamEnd callback rather than re-parsing
event types.
- System / workspace / pty / lsp / mcp / installation events AND
session-level control events (agent.switched, model.switched,
file.edited) are not rendered in the frontend.
Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)
design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
* (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', ...).
|
||||
*
|
||||
* 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).
|
||||
* 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';
|
||||
@@ -16,13 +22,18 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
|
||||
import {
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
callOpenCodeSessionMessages,
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse
|
||||
OpenCodeProvidersResponse
|
||||
} from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
@@ -50,6 +61,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
private _context: CellContext | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||
|
||||
constructor(cell: CodeCell) {
|
||||
super();
|
||||
@@ -65,6 +77,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._closeSse();
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
this._hidePrompt();
|
||||
@@ -119,14 +132,61 @@ export class OpenCodeCellActions extends Widget {
|
||||
},
|
||||
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();
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Fetch the current session's message history and render it into
|
||||
// the prompt's scrollable history area. Empty list (no session yet)
|
||||
// is a normal no-op render.
|
||||
// Fetch the current session's static history and render it before
|
||||
// streaming begins. Empty list is a normal no-op.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
@@ -144,8 +204,6 @@ export class OpenCodeCellActions extends Widget {
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
// Non-fatal: the history is a convenience. The user can still
|
||||
// send new prompts; just the scrollback won't update.
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +228,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
const request = {
|
||||
prompt: text,
|
||||
context: this._context,
|
||||
providerId,
|
||||
@@ -180,41 +238,55 @@ export class OpenCodeCellActions extends Widget {
|
||||
this._status = 'loading';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(true);
|
||||
this._prompt.setSessionId(null);
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
this._handleResponse(resp);
|
||||
resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
} catch (e) {
|
||||
this._status = 'idle';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(false);
|
||||
}
|
||||
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 _handleResponse(resp: OpenCodeResponse): void {
|
||||
this._status = 'idle';
|
||||
if (resp.ok) {
|
||||
// Refetch the (now-updated) session history and render the new
|
||||
// assistant message as the last item in the scrollable history.
|
||||
// The cell source is NOT replaced.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
// Clear the input so the user can type a follow-up message.
|
||||
this._prompt?.clearInput();
|
||||
Notification.info(
|
||||
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} else {
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(false);
|
||||
}
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
private _closeSse(): void {
|
||||
if (this._sseSub) {
|
||||
this._sseSub.close();
|
||||
this._sseSub = null;
|
||||
}
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders two <select> boxes (provider + model), a textarea, send/cancel
|
||||
* buttons, and a scrollable history area that shows the current session's
|
||||
* messages (user + assistant). The model select's default for the chosen
|
||||
* provider is `default[providerID]` from the /config/providers response.
|
||||
*
|
||||
* v3-final: no settings — picks come from the OpenCode Serve providers
|
||||
* cache; history comes from /session-messages; cell source is NOT replaced
|
||||
* (the AI reply is rendered as markdown inside this history area).
|
||||
* Renders two <select> boxes (provider + model), a textarea, send/cancel
|
||||
* buttons, and a scrollable history area. The history area serves two modes:
|
||||
*
|
||||
* 1. **Static** — `setMessages(messages)` renders the session's stored
|
||||
* history (user/assistant markdown) when the panel is first opened.
|
||||
* 2. **Streaming** — after the user submits a prompt, the owning cell
|
||||
* action subscribes to `/opencode-bridge/events` and forwards every
|
||||
* `OpenCodeEvent` to `applyEvent(event)`, which dispatches to the
|
||||
* appropriate DOM helper (text deltas, reasoning blocks, tool blocks,
|
||||
* permission/question prompts, system lines). On `session.idle` the
|
||||
* stream pointers reset.
|
||||
*
|
||||
* The model select's default for the chosen provider is `default[providerID]`
|
||||
* from the /config/providers response.
|
||||
*
|
||||
* v4: cell source is NOT replaced. The AI reply is rendered into this
|
||||
* history area. The user can still Copy / Insert / Replace individual
|
||||
* code blocks from a finished assistant message.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
OpenCodeMessage,
|
||||
OpenCodeProvidersResponse
|
||||
} from '../types';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
onCancel: () => void;
|
||||
disabled: boolean;
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
/**
|
||||
* Respond to a `permission.asked` event. Returns a promise that resolves
|
||||
* to true on success, false on failure (the prompt updates the UI in
|
||||
* both cases).
|
||||
*/
|
||||
onPermissionReply?: (
|
||||
permissionId: string,
|
||||
response: 'once' | 'always' | 'reject'
|
||||
) => Promise<boolean>;
|
||||
/** Reply to a `question.asked` event with the user's freeform answer. */
|
||||
onQuestionReply?: (questionId: string, answer: string) => Promise<boolean>;
|
||||
/**
|
||||
* Fired when the prompt detects the agent has finished its turn
|
||||
* (any of: `session.idle`, `session.status` with `status.type === 'idle'`,
|
||||
* or the same shapes nested under `payload`). Centralizing this in
|
||||
* the prompt keeps the cell action from re-parsing event types.
|
||||
*/
|
||||
onStreamEnd?: () => void;
|
||||
}
|
||||
|
||||
interface FlatProvider {
|
||||
@@ -65,8 +98,14 @@ function pickDefaultModelId(
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
interface BlockEntry {
|
||||
details: HTMLDetailsElement;
|
||||
content: HTMLDivElement;
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _cell: CodeCell;
|
||||
private _options: IOpenCodeInlinePromptOptions;
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
private _cancelBtn: HTMLButtonElement;
|
||||
@@ -76,12 +115,28 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
private _defaultMap: { [pid: string]: string };
|
||||
private _historyEl: HTMLDivElement;
|
||||
|
||||
// Streaming state. _sessionId is set by the cell action after a successful
|
||||
// /edit response; applyEvent() drops events that don't belong to it.
|
||||
private _sessionId: string | null = null;
|
||||
// The current assistant message DOM node (re-rendered as markdown on
|
||||
// every text delta so the user sees a properly formatted response in
|
||||
// real-time, not raw ```fence``` source).
|
||||
private _currentAssistantEl: HTMLDivElement | null = null;
|
||||
// Accumulator for the current assistant turn's raw markdown source.
|
||||
// Each text delta appends here; the whole string is then re-rendered
|
||||
// through marked.parse to refresh the DOM. Using the raw source as
|
||||
// truth (not innerHTML) avoids lossy round-trips through partial HTML.
|
||||
private _currentAssistantText: string = '';
|
||||
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
||||
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
||||
|
||||
constructor(
|
||||
cell: CodeCell,
|
||||
options: IOpenCodeInlinePromptOptions
|
||||
) {
|
||||
super();
|
||||
this._cell = cell;
|
||||
this._options = options;
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
const flat = flattenProviders(options.providers);
|
||||
@@ -125,8 +180,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
const modelId = this._modelSelect?.value || undefined;
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
// Chat-input convention: Enter submits, Shift+Enter / Ctrl+Enter
|
||||
// insert a newline (textarea default).
|
||||
this._textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
@@ -167,8 +220,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this.node.appendChild(row);
|
||||
}
|
||||
|
||||
// Scrollable history of the current session's messages. Empty until
|
||||
// setMessages() is called by the owning cell action.
|
||||
// Scrollable history (static + streaming). Empty until setMessages() /
|
||||
// applyEvent() populate it.
|
||||
this._historyEl = document.createElement('div');
|
||||
this._historyEl.className = 'opencode-inline-history';
|
||||
this.node.appendChild(this._historyEl);
|
||||
@@ -219,20 +272,27 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the user input textarea (called by the cell action after a
|
||||
* successful response, so the user can type a follow-up message
|
||||
* without manually deleting the previous prompt).
|
||||
*/
|
||||
/** Clear the user input textarea. */
|
||||
clearInput(): void {
|
||||
this._textarea.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's messages into the scrollable history
|
||||
* area. User messages are plain text; assistant messages are rendered
|
||||
* as markdown via marked. Auto-scrolls to the bottom so the most
|
||||
* recent message is visible.
|
||||
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
||||
* `applyEvent` for a different session are dropped. Pass null to clear.
|
||||
*/
|
||||
setSessionId(sessionId: string | null): void {
|
||||
this._sessionId = sessionId;
|
||||
if (sessionId) {
|
||||
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
||||
this._resetStreamPointers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's static messages (loaded once from
|
||||
* /session-messages when the panel opens). Subsequent assistant turns
|
||||
* during the same session are appended live via applyEvent().
|
||||
*/
|
||||
setMessages(messages: OpenCodeMessage[]): void {
|
||||
this._historyEl.textContent = '';
|
||||
@@ -242,74 +302,501 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
if (msg.role === 'user') {
|
||||
wrap.textContent = msg.content;
|
||||
} else {
|
||||
// Assistant: render the markdown, then for every fenced code
|
||||
// block (<pre>) attach a small toolbar with Copy / Insert /
|
||||
// Replace buttons that act on THAT block's code (not the whole
|
||||
// message). Messages without code fences get no toolbar.
|
||||
const rendered = document.createElement('div');
|
||||
rendered.className = 'opencode-msg-content';
|
||||
rendered.innerHTML = marked.parse(msg.content) as string;
|
||||
|
||||
const codeBlocks = rendered.querySelectorAll('pre');
|
||||
codeBlocks.forEach(pre => {
|
||||
const code = pre.querySelector('code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
// Snapshot the code text before we mutate the DOM so the
|
||||
// buttons act on the exact code inside this block (no
|
||||
// surrounding ```fences```, no toolbar label text).
|
||||
const codeText = code.textContent ?? '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'opencode-code-block';
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'opencode-code-toolbar';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
title: string,
|
||||
onClick: () => void
|
||||
) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'opencode-code-btn';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
};
|
||||
toolbar.appendChild(
|
||||
mkBtn('📋 复制', '复制代码', () => {
|
||||
void this._copyToClipboard(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('↪ 插入', '在光标位置插入', () => {
|
||||
this._insertAtCursor(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('⟳ 替换', '替换单元格内容', () => {
|
||||
this._replaceCell(codeText);
|
||||
})
|
||||
);
|
||||
wrap.appendChild(toolbar);
|
||||
// Move the original <pre> into the wrapper.
|
||||
pre.parentNode!.insertBefore(wrap, pre);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
|
||||
wrap.appendChild(rendered);
|
||||
this._renderAssistantMarkdown(wrap, msg.content);
|
||||
}
|
||||
this._historyEl.appendChild(wrap);
|
||||
}
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the given text to the clipboard. Prefers navigator.clipboard
|
||||
* (available in secure contexts incl. JupyterLab). Falls back to a
|
||||
* hidden textarea + execCommand for older / non-secure contexts.
|
||||
* Apply one OpenCode SSE event to the streaming UI. Mirrors
|
||||
* demo.html's handleGlobalEvent / processSessionEvent dispatch.
|
||||
*/
|
||||
applyEvent(event: OpenCodeEvent): void {
|
||||
const type = event.type || (event.payload && event.payload.type) || '';
|
||||
const props =
|
||||
event.properties ||
|
||||
(event.payload && event.payload.properties) ||
|
||||
{};
|
||||
const sessionID =
|
||||
props.sessionID ||
|
||||
props.sessionId ||
|
||||
(event.syncEvent && event.syncEvent.aggregateID) ||
|
||||
(event.payload &&
|
||||
event.payload.syncEvent &&
|
||||
event.payload.syncEvent.aggregateID);
|
||||
|
||||
// Drop events for other sessions (defensive; matches demo.html
|
||||
// handleGlobalEvent). Only drop when the event has an explicit
|
||||
// sessionID — events without one (e.g. system events) are still
|
||||
// processed and may be dropped by _isSystemEvent below.
|
||||
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System / workspace / pty / etc. events are not displayed.
|
||||
if (this._isSystemEvent(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For text/reasoning/tool/idle/permission/question we expect to match.
|
||||
// If none of the branches below fire, log a warning so the missing
|
||||
// event type can be diagnosed in DevTools.
|
||||
const handled = this._dispatchContentEvent(event, type, props);
|
||||
if (!handled) {
|
||||
console.warn(
|
||||
'opencode_bridge: unrecognized SSE event type (dropped):',
|
||||
type,
|
||||
event
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatchContentEvent(
|
||||
event: OpenCodeEvent,
|
||||
type: string,
|
||||
props: { [k: string]: any }
|
||||
): boolean {
|
||||
|
||||
if (type === 'permission.asked') {
|
||||
const permId = String(props.id || props.permissionID || '');
|
||||
this._createPermissionBlock(
|
||||
permId,
|
||||
String(
|
||||
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'question.asked') {
|
||||
const qId = String(props.id || props.questionID || '');
|
||||
this._createQuestionBlock(
|
||||
qId,
|
||||
String(props.question || props.title || 'Agent 正在等待确认…')
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'message.part.delta' &&
|
||||
props.field === 'text' &&
|
||||
typeof props.delta === 'string'
|
||||
) {
|
||||
this._appendToAssistantText(props.delta);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(type === 'message.part.delta' && props.field === 'reasoning') ||
|
||||
type === 'session.next.reasoning.delta'
|
||||
) {
|
||||
this._appendToBlock(
|
||||
'reasoning',
|
||||
'🧠 思考与推理过程',
|
||||
String(props.delta || props.text || '')
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.text.delta' && typeof props.delta === 'string') {
|
||||
this._appendToAssistantText(props.delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === 'session.next.reasoning.started') {
|
||||
this._getOrCreateBlock('reasoning', '🧠 思考与推理过程');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'session.next.tool.input.started' ||
|
||||
type === 'session.next.tool.called'
|
||||
) {
|
||||
const toolName = String(props.name || props.tool || '未知工具');
|
||||
this._getOrCreateBlock('tool', `🛠️ 调用工具: ${toolName}`);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.input.delta') {
|
||||
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.progress') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行中…',
|
||||
`\n[Progress]: ${String(props.message || '')}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.success') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行完毕',
|
||||
`\n[执行成功结果]: ${JSON.stringify(props.result || {}, null, 2)}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.failed') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行失败',
|
||||
`\n[执行失败异常]: ${String(props.error || 'Unknown')}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._isIdleEvent(event, type, props)) {
|
||||
this._resetStreamPointers();
|
||||
this._options.onStreamEnd?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "agent is now idle" across the multiple shapes OpenCode has
|
||||
* emitted across versions: top-level `session.idle`, top-level
|
||||
* `session.status` with `status.type === 'idle'`, and the same shapes
|
||||
* nested under `payload`.
|
||||
*/
|
||||
private _isIdleEvent(
|
||||
event: OpenCodeEvent,
|
||||
topType: string,
|
||||
topProps: { [k: string]: any }
|
||||
): boolean {
|
||||
const check = (t: string, p: { [k: string]: any }): boolean => {
|
||||
if (t === 'session.idle') {
|
||||
return true;
|
||||
}
|
||||
if (t === 'session.status' && p && p.status && p.status.type === 'idle') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (check(topType, topProps)) {
|
||||
return true;
|
||||
}
|
||||
const payload = event.payload;
|
||||
if (payload) {
|
||||
return check(
|
||||
payload.type || topType,
|
||||
payload.properties || topProps
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- Private streaming helpers ----------
|
||||
|
||||
private _isSystemEvent(type: string): boolean {
|
||||
return (
|
||||
type.startsWith('server.') ||
|
||||
type.startsWith('workspace.') ||
|
||||
type.startsWith('worktree.') ||
|
||||
type.startsWith('lsp.') ||
|
||||
type.startsWith('mcp.') ||
|
||||
type.startsWith('pty.') ||
|
||||
type.startsWith('installation.') ||
|
||||
type === 'global.disposed' ||
|
||||
// Session-level control events are not part of the assistant reply
|
||||
// and would be confusing as separate lines in the history.
|
||||
type === 'session.next.agent.switched' ||
|
||||
type === 'session.next.model.switched' ||
|
||||
type === 'file.edited'
|
||||
);
|
||||
}
|
||||
|
||||
private _appendToAssistantText(text: string): void {
|
||||
if (!this._currentAssistantEl) {
|
||||
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
||||
}
|
||||
// Accumulate the raw markdown source, then re-render the whole
|
||||
// message through marked.parse. This way the user sees properly
|
||||
// formatted output (code blocks as <pre><code>, headings, lists)
|
||||
// in real-time as text deltas arrive — NOT the raw ```fence```
|
||||
// source that an append-only .textContent would show.
|
||||
this._currentAssistantText += text;
|
||||
this._renderAssistantMarkdown(
|
||||
this._currentAssistantEl,
|
||||
this._currentAssistantText
|
||||
);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private _getOrCreateBlock(key: string, title: string): BlockEntry {
|
||||
let entry = this._activeBlocks[key];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.className = `opencode-msg-block opencode-msg-block-${key}`;
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content';
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
entry = { details, content };
|
||||
this._activeBlocks[key] = entry;
|
||||
this._scrollToBottom();
|
||||
return entry;
|
||||
}
|
||||
|
||||
private _appendToBlock(
|
||||
key: string,
|
||||
defaultTitle: string,
|
||||
text: string
|
||||
): void {
|
||||
const block = this._getOrCreateBlock(key, defaultTitle);
|
||||
block.content.textContent = (block.content.textContent || '') + text;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private _createPermissionBlock(permId: string, description: string): void {
|
||||
if (!permId) {
|
||||
return;
|
||||
}
|
||||
const domId = `opencode-perm-${permId}`;
|
||||
if (this._historyEl.querySelector('#' + domId)) {
|
||||
return;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '🔐 权限请求等待处理';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content opencode-perm-body';
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'opencode-perm-desc';
|
||||
desc.textContent = `请求内容:${description}`;
|
||||
content.appendChild(desc);
|
||||
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'opencode-perm-buttons';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
cls: string,
|
||||
resp: 'once' | 'always' | 'reject'
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = cls;
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', () => {
|
||||
this._handlePermissionResponse(permId, resp, btnGroup, details);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
btnGroup.appendChild(
|
||||
mkBtn('允许一次 (Once)', 'opencode-perm-btn-allow', 'once')
|
||||
);
|
||||
btnGroup.appendChild(
|
||||
mkBtn('总是允许 (Always)', 'opencode-perm-btn-allow', 'always')
|
||||
);
|
||||
btnGroup.appendChild(
|
||||
mkBtn('拒绝 (Reject)', 'opencode-perm-btn-reject', 'reject')
|
||||
);
|
||||
content.appendChild(btnGroup);
|
||||
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private async _handlePermissionResponse(
|
||||
permId: string,
|
||||
response: 'once' | 'always' | 'reject',
|
||||
btnGroup: HTMLElement,
|
||||
details: HTMLElement
|
||||
): Promise<void> {
|
||||
if (!this._options.onPermissionReply) {
|
||||
btnGroup.textContent = '(未配置 onPermissionReply 回调)';
|
||||
return;
|
||||
}
|
||||
btnGroup.textContent = '正在提交…';
|
||||
try {
|
||||
const ok = await this._options.onPermissionReply(permId, response);
|
||||
const labels: Record<string, string> = {
|
||||
once: '✓ 已授权 (仅一次)',
|
||||
always: '✓ 已授权 (总是允许)',
|
||||
reject: '✕ 已拒绝操作'
|
||||
};
|
||||
const color = response === 'reject' ? '#ef4444' : '#10b981';
|
||||
btnGroup.innerHTML = `<span style="color: ${color}; font-weight: bold;">${
|
||||
labels[response]
|
||||
}${ok ? '' : ' (后端失败)'}</span>`;
|
||||
} catch (e) {
|
||||
btnGroup.innerHTML = `<span style="color: #ef4444; font-size: 11px;">提交失败: ${(e as Error).message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
private _createQuestionBlock(questionId: string, questionText: string): void {
|
||||
if (!questionId) {
|
||||
return;
|
||||
}
|
||||
const domId = `opencode-q-${questionId}`;
|
||||
if (this._historyEl.querySelector('#' + domId)) {
|
||||
return;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '❓ Agent 提问等待回复';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content opencode-q-body';
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'opencode-q-desc';
|
||||
desc.textContent = `问题:${questionText}`;
|
||||
content.appendChild(desc);
|
||||
|
||||
const inputRow = document.createElement('div');
|
||||
inputRow.className = 'opencode-q-input-row';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'opencode-q-input';
|
||||
input.placeholder = '请输入你的回答…';
|
||||
const submit = document.createElement('button');
|
||||
submit.type = 'button';
|
||||
submit.className = 'opencode-q-submit';
|
||||
submit.textContent = '提交回答';
|
||||
submit.addEventListener('click', () => {
|
||||
void this._handleQuestionResponse(
|
||||
questionId,
|
||||
input,
|
||||
inputRow,
|
||||
details
|
||||
);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit.click();
|
||||
}
|
||||
});
|
||||
inputRow.appendChild(input);
|
||||
inputRow.appendChild(submit);
|
||||
content.appendChild(inputRow);
|
||||
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
this._scrollToBottom();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
private async _handleQuestionResponse(
|
||||
questionId: string,
|
||||
input: HTMLInputElement,
|
||||
inputRow: HTMLElement,
|
||||
_details: HTMLElement
|
||||
): Promise<void> {
|
||||
const answer = input.value.trim();
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
if (!this._options.onQuestionReply) {
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||
return;
|
||||
}
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
||||
try {
|
||||
const ok = await this._options.onQuestionReply(questionId, answer);
|
||||
const color = ok ? '#10b981' : '#ef4444';
|
||||
const text = ok ? `✓ 已回答:${answer}` : `提交失败 (后端 ok=false)`;
|
||||
inputRow.innerHTML = `<span style="color: ${color}; font-weight: bold;">${text}</span>`;
|
||||
} catch (e) {
|
||||
inputRow.innerHTML = `<span style="color: #ef4444;">提交失败: ${(e as Error).message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetStreamPointers(): void {
|
||||
this._currentAssistantEl = null;
|
||||
this._currentAssistantText = '';
|
||||
this._activeBlocks = {};
|
||||
}
|
||||
|
||||
// ---------- Static-message rendering (used by setMessages + streaming) ----------
|
||||
|
||||
private _renderAssistantMarkdown(wrap: HTMLElement, content: string): void {
|
||||
// Idempotent: clear existing content first so this can be called
|
||||
// repeatedly with the same `wrap` element (used by streaming —
|
||||
// every text delta re-renders the whole message).
|
||||
wrap.textContent = '';
|
||||
const rendered = document.createElement('div');
|
||||
rendered.className = 'opencode-msg-content';
|
||||
rendered.innerHTML = marked.parse(content) as string;
|
||||
const codeBlocks = rendered.querySelectorAll('pre');
|
||||
codeBlocks.forEach(pre => {
|
||||
const code = pre.querySelector('code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const codeText = code.textContent ?? '';
|
||||
const blockWrap = document.createElement('div');
|
||||
blockWrap.className = 'opencode-code-block';
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'opencode-code-toolbar';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
title: string,
|
||||
onClick: () => void
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'opencode-code-btn';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
};
|
||||
toolbar.appendChild(
|
||||
mkBtn('📋 复制', '复制代码', () => {
|
||||
void this._copyToClipboard(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('↪ 插入', '在光标位置插入', () => {
|
||||
this._insertAtCursor(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('⟳ 替换', '替换单元格内容', () => {
|
||||
this._replaceCell(codeText);
|
||||
})
|
||||
);
|
||||
blockWrap.appendChild(toolbar);
|
||||
pre.parentNode!.insertBefore(blockWrap, pre);
|
||||
blockWrap.appendChild(pre);
|
||||
});
|
||||
wrap.appendChild(rendered);
|
||||
}
|
||||
|
||||
private _createMessageElement(role: string, text: string): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
div.className = `opencode-msg opencode-msg-${role}`;
|
||||
div.textContent = text;
|
||||
this._historyEl.appendChild(div);
|
||||
this._scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
private _scrollToBottom(): void {
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------- Cell-edit actions (Copy / Insert / Replace) ----------
|
||||
|
||||
private async _copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
@@ -335,11 +822,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at the current editor cursor position. If the cell has
|
||||
* no editor (e.g. a non-Code cell) or the cursor can't be read, falls
|
||||
* back to appending at the end of the cell.
|
||||
*/
|
||||
private _insertAtCursor(text: string): void {
|
||||
const cell = this._cell;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
@@ -367,7 +849,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
offset += col;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to append on any error reading the cursor.
|
||||
offset = source.length;
|
||||
}
|
||||
const newSource = source.slice(0, offset) + text + source.slice(offset);
|
||||
@@ -375,7 +856,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
Notification.info('已插入到光标位置');
|
||||
}
|
||||
|
||||
/** Replace the entire cell source with the given text. */
|
||||
private _replaceCell(text: string): void {
|
||||
const cell = this._cell;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
|
||||
Reference in New Issue
Block a user