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,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