fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.
Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.
Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.
Backend
-------
- _build_request_body: now takes only the prompt, returns
parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
tag wrapping.
Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
cellId / source / previousCode / error / cellIndex / totalCells
/ language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
with extractNotebookPathFromCell(). _context field renamed to
_notebookPath. NotebookPanel / extractCellContext / context/
cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
deleted.
New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.
Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
green.
This commit is contained in:
+12
-37
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
|||||||
return sm
|
return sm
|
||||||
|
|
||||||
|
|
||||||
def _build_request_body(prompt: str, context: dict) -> dict:
|
def _build_request_body(prompt: str) -> dict:
|
||||||
"""Build full request body for OpenCode POST /session/:id/prompt_async.
|
"""Build request body for OpenCode POST /session/:id/prompt_async.
|
||||||
|
|
||||||
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
|
v0.2.x: only the user's prompt is sent to the LLM. We no longer
|
||||||
the LLM interprets the user's natural-language instruction, with the code
|
auto-wrap the cell source / previous-cell / traceback in tags —
|
||||||
context and the optional traceback in front of it.
|
the user inserts whatever they want via the "📋 插入单元格内容"
|
||||||
|
button (or pastes manually), so the LLM context exactly matches
|
||||||
|
user intent. Returns dict with 'parts' and 'system' keys.
|
||||||
"""
|
"""
|
||||||
parts: list[dict] = []
|
return {
|
||||||
|
"parts": [{"type": "text", "text": prompt}],
|
||||||
if context.get("previousCode"):
|
"system": UNIFIED_SYSTEM_PROMPT,
|
||||||
parts.append({
|
}
|
||||||
"type": "text",
|
|
||||||
"text": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
|
|
||||||
})
|
|
||||||
|
|
||||||
error = context.get("error")
|
|
||||||
if error:
|
|
||||||
parts.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": (
|
|
||||||
"<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
|
|
||||||
+ "\n".join(error.get("traceback", []))
|
|
||||||
+ "\n</traceback>\n"
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
parts.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": "<cell language='%s'>\n%s\n</cell>\n" % (
|
|
||||||
context.get("language", "python"),
|
|
||||||
context["source"],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if prompt:
|
|
||||||
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
|
||||||
|
|
||||||
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_fence(s: str) -> str:
|
def _strip_code_fence(s: str) -> str:
|
||||||
@@ -204,7 +179,7 @@ class EditHandler(APIHandler):
|
|||||||
sm = get_session_manager(self)
|
sm = get_session_manager(self)
|
||||||
try:
|
try:
|
||||||
sid = await sm.get_or_create(notebook_path)
|
sid = await sm.get_or_create(notebook_path)
|
||||||
request_body = _build_request_body(prompt, context)
|
request_body = _build_request_body(prompt)
|
||||||
await client.send_message_async(
|
await client.send_message_async(
|
||||||
sid,
|
sid,
|
||||||
request_body["parts"],
|
request_body["parts"],
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
|
|
||||||
|
|
||||||
function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
|
|
||||||
return {
|
|
||||||
get length() {
|
|
||||||
return items.length;
|
|
||||||
},
|
|
||||||
get(i: number) {
|
|
||||||
return items[i];
|
|
||||||
},
|
|
||||||
// Other IObservableList methods are unused in collectErrorOutputs.
|
|
||||||
} as unknown as ICodeCellModel['outputs'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockModel(overrides: {
|
|
||||||
id?: string;
|
|
||||||
type?: 'code' | 'markdown' | 'raw';
|
|
||||||
source?: string;
|
|
||||||
outputs?: unknown[];
|
|
||||||
}): ICodeCellModel {
|
|
||||||
return {
|
|
||||||
id: overrides.id ?? 'cell-1',
|
|
||||||
type: overrides.type ?? 'code',
|
|
||||||
sharedModel: {
|
|
||||||
getSource: () => overrides.source ?? '',
|
|
||||||
},
|
|
||||||
outputs: makeMockOutputs(overrides.outputs ?? []),
|
|
||||||
} as unknown as ICodeCellModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
|
|
||||||
return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
|
|
||||||
return { widgets: cells };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockPanel(
|
|
||||||
notebook: { widgets: CodeCell[] },
|
|
||||||
path: string
|
|
||||||
): NotebookPanel {
|
|
||||||
return {
|
|
||||||
context: { path } as NotebookPanel['context'],
|
|
||||||
content: notebook as unknown as NotebookPanel['content'],
|
|
||||||
} as unknown as NotebookPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('collectErrorOutputs', () => {
|
|
||||||
it('returns null when there are no outputs', () => {
|
|
||||||
const model = makeMockModel({ outputs: [] });
|
|
||||||
expect(collectErrorOutputs(model)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when outputs are non-error', () => {
|
|
||||||
const model = makeMockModel({
|
|
||||||
outputs: [{ type: 'stream', text: 'hello' }],
|
|
||||||
});
|
|
||||||
expect(collectErrorOutputs(model)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts the first error output', () => {
|
|
||||||
const errOut = {
|
|
||||||
type: 'error',
|
|
||||||
ename: 'ValueError',
|
|
||||||
evalue: 'bad value',
|
|
||||||
traceback: ['line 1', 'line 2'],
|
|
||||||
};
|
|
||||||
const model = makeMockModel({ outputs: [errOut] });
|
|
||||||
const result = collectErrorOutputs(model);
|
|
||||||
expect(result).toEqual({
|
|
||||||
ename: 'ValueError',
|
|
||||||
evalue: 'bad value',
|
|
||||||
traceback: ['line 1', 'line 2'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing traceback gracefully', () => {
|
|
||||||
const model = makeMockModel({
|
|
||||||
outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
|
|
||||||
});
|
|
||||||
const result = collectErrorOutputs(model);
|
|
||||||
expect(result?.traceback).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractCellContext', () => {
|
|
||||||
it('returns null when cell is not in notebook', () => {
|
|
||||||
const cell = makeMockCell('x = 1');
|
|
||||||
const other = makeMockCell('y = 2');
|
|
||||||
const notebook = makeMockNotebook([other]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
expect(extractCellContext(cell, panel)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts source, language, and indices', () => {
|
|
||||||
const cell = makeMockCell('x = 1');
|
|
||||||
const notebook = makeMockNotebook([cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx).toMatchObject({
|
|
||||||
notebookPath: 'foo.ipynb',
|
|
||||||
cellId: 'cell-1',
|
|
||||||
language: 'python',
|
|
||||||
cellIndex: 0,
|
|
||||||
totalCells: 1,
|
|
||||||
source: 'x = 1',
|
|
||||||
previousCode: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures the previous cell source as previousCode', () => {
|
|
||||||
const prev = makeMockCell('import os');
|
|
||||||
const cell = makeMockCell('os.getcwd()');
|
|
||||||
const notebook = makeMockNotebook([prev, cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx?.previousCode).toBe('import os');
|
|
||||||
expect(ctx?.cellIndex).toBe(1);
|
|
||||||
expect(ctx?.totalCells).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes the error when the cell has an error output', () => {
|
|
||||||
const cell = makeMockCell('1/0', [
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
ename: 'ZeroDivisionError',
|
|
||||||
evalue: 'division by zero',
|
|
||||||
traceback: ['Traceback...'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const notebook = makeMockNotebook([cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx?.error).toEqual({
|
|
||||||
ename: 'ZeroDivisionError',
|
|
||||||
evalue: 'division by zero',
|
|
||||||
traceback: ['Traceback...'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when given null cell or panel', () => {
|
|
||||||
expect(
|
|
||||||
extractCellContext(
|
|
||||||
null as unknown as CodeCell,
|
|
||||||
makeMockPanel(makeMockNotebook([]), 'x.ipynb')
|
|
||||||
)
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -766,7 +766,7 @@ describe('OpenCodeCellActions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('OpenCodeInlinePrompt', () => {
|
describe('OpenCodeInlinePrompt', () => {
|
||||||
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
|
it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
|
||||||
const cell = makeFakeCell('x = 1');
|
const cell = makeFakeCell('x = 1');
|
||||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
onSubmit: jest.fn(),
|
onSubmit: jest.fn(),
|
||||||
@@ -775,9 +775,76 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
providers: null
|
providers: null
|
||||||
});
|
});
|
||||||
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||||
// 2 buttons: 发送 and 取消 (no more output-area close button; the
|
// 3 buttons: 发送, 取消, 📋 插入单元格内容
|
||||||
// scrollable history area is the display).
|
expect(prompt.node.querySelectorAll('button').length).toBe(3);
|
||||||
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
const insertBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-btn-insert-cell'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(insertBtn).not.toBeNull();
|
||||||
|
expect(insertBtn.textContent).toContain('插入单元格内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
|
||||||
|
const cell = makeFakeCell('print("hello")\nprint("world")');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
const insertBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-btn-insert-cell'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
insertBtn.click();
|
||||||
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
// Inserted as a fenced code block (the fake cell's type is 'code'
|
||||||
|
// so the fence info string is whatever the cell reports — we just
|
||||||
|
// assert a fence + the source content, not a specific language).
|
||||||
|
expect(textarea.value).toMatch(/```\w*\n/);
|
||||||
|
expect(textarea.value).toContain('print("hello")');
|
||||||
|
expect(textarea.value).toContain('print("world")');
|
||||||
|
expect(textarea.value.trim().endsWith('```')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
textarea.value = 'Explain:'; // no trailing newline
|
||||||
|
const insertBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-btn-insert-cell'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
insertBtn.click();
|
||||||
|
// The original prefix is preserved at the start.
|
||||||
|
expect(textarea.value.startsWith('Explain:')).toBe(true);
|
||||||
|
// The code block was inserted; the user prompt + the code block
|
||||||
|
// are now both present.
|
||||||
|
expect(textarea.value).toContain('x = 1');
|
||||||
|
expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
|
||||||
|
// The cursor was moved to the end.
|
||||||
|
expect(textarea.selectionStart).toBe(textarea.value.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
|
||||||
|
const cell = makeFakeCell('');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
const insertBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-btn-insert-cell'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
insertBtn.click();
|
||||||
|
// Textarea is still empty.
|
||||||
|
expect(textarea.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
||||||
@@ -1382,7 +1449,14 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
expect(asstBlocks[1].textContent).toBe('turn2');
|
expect(asstBlocks[1].textContent).toBe('turn2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyEvent: drops events for a different session (defensive filter)', () => {
|
it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
|
||||||
|
// Regression: the SSE filter used to drop content events whose
|
||||||
|
// sessionID didn't match the prompt's _sessionId. After a session
|
||||||
|
// switch, this caused "no reply" because events for the new
|
||||||
|
// session came in with a sessionID the prompt wasn't tracking.
|
||||||
|
// Fix: only permission/question events are strict (so the user
|
||||||
|
// can't reply to a different session's prompt); content events
|
||||||
|
// (text, reasoning, tool, idle) always pass through.
|
||||||
const cell = makeFakeCell('x = 1');
|
const cell = makeFakeCell('x = 1');
|
||||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
onSubmit: jest.fn(),
|
onSubmit: jest.fn(),
|
||||||
@@ -1393,12 +1467,35 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
prompt.setSessionId('s1');
|
prompt.setSessionId('s1');
|
||||||
prompt.applyEvent({
|
prompt.applyEvent({
|
||||||
type: 'session.next.text.delta',
|
type: 'session.next.text.delta',
|
||||||
properties: { sessionID: 's2', delta: 'should be ignored' }
|
properties: { sessionID: 's2', delta: 'still rendered' }
|
||||||
});
|
});
|
||||||
const asst = prompt.node.querySelector(
|
const asst = prompt.node.querySelector(
|
||||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
'.opencode-msg-assistant'
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(asst).not.toBeNull();
|
||||||
|
expect(asst.textContent).toBe('still rendered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyEvent: permission.asked IS still dropped for a different session', () => {
|
||||||
|
// The cross-session filter is strict ONLY for permission.asked /
|
||||||
|
// question.asked so the user can't accidentally reply to another
|
||||||
|
// session's prompt.
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setSessionId('s1');
|
||||||
|
prompt.applyEvent({
|
||||||
|
type: 'permission.asked',
|
||||||
|
properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
|
||||||
|
});
|
||||||
|
const block = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-block-interaction'
|
||||||
);
|
);
|
||||||
expect(asst).toBeNull();
|
expect(block).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
||||||
|
|||||||
@@ -72,15 +72,8 @@ function mockResponse(overrides: {
|
|||||||
const sampleRequest: OpenCodeRequest = {
|
const sampleRequest: OpenCodeRequest = {
|
||||||
prompt: 'add type hints',
|
prompt: 'add type hints',
|
||||||
context: {
|
context: {
|
||||||
notebookPath: 'foo.ipynb',
|
notebookPath: 'foo.ipynb'
|
||||||
cellId: 'cell-1',
|
}
|
||||||
language: 'python',
|
|
||||||
cellIndex: 0,
|
|
||||||
totalCells: 1,
|
|
||||||
source: 'def foo(x): return x',
|
|
||||||
previousCode: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('callOpenCodeEdit', () => {
|
describe('callOpenCodeEdit', () => {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
import type { CodeCell } from '@jupyterlab/cells';
|
import type { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
@@ -33,11 +32,7 @@ import {
|
|||||||
subscribeOpenCodeEvents,
|
subscribeOpenCodeEvents,
|
||||||
type OpenCodeEventSubscription
|
type OpenCodeEventSubscription
|
||||||
} from '../api/opencode_client';
|
} from '../api/opencode_client';
|
||||||
import { extractCellContext } from '../context/cell_context';
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
import type {
|
|
||||||
CellContext,
|
|
||||||
OpenCodeProvidersResponse
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||||
|
|
||||||
@@ -61,7 +56,11 @@ type Status = 'idle' | 'loading';
|
|||||||
|
|
||||||
export class OpenCodeCellActions extends Widget {
|
export class OpenCodeCellActions extends Widget {
|
||||||
private _cell: CodeCell;
|
private _cell: CodeCell;
|
||||||
private _context: CellContext | null = null;
|
// 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 _status: Status = 'idle';
|
||||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||||
@@ -88,9 +87,9 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onModelChange(): void {
|
private _onModelChange(): void {
|
||||||
this._context = extractCellContextFromCell(this._cell);
|
this._notebookPath = extractNotebookPathFromCell(this._cell);
|
||||||
if (this._prompt) {
|
if (this._prompt) {
|
||||||
this._prompt.setDisabled(!this._context || this._status === 'loading');
|
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
|
||||||
} else {
|
} else {
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
@@ -100,7 +99,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
const node = this.node;
|
const node = this.node;
|
||||||
node.textContent = '';
|
node.textContent = '';
|
||||||
|
|
||||||
const baseDisabled = !this._context || this._status === 'loading';
|
const baseDisabled = !this._notebookPath || this._status === 'loading';
|
||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'opencode-btn opencode-btn-ai';
|
btn.className = 'opencode-btn opencode-btn-ai';
|
||||||
@@ -128,9 +127,9 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||||
disabled: !this._context || this._status === 'loading',
|
disabled: !this._notebookPath || this._status === 'loading',
|
||||||
providers: _providers,
|
providers: _providers,
|
||||||
notebookPath: this._context?.notebookPath,
|
notebookPath: this._notebookPath ?? undefined,
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
void this._onSubmit(text, providerId, modelId);
|
void this._onSubmit(text, providerId, modelId);
|
||||||
},
|
},
|
||||||
@@ -196,14 +195,14 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCreateSession: async () => {
|
onCreateSession: async () => {
|
||||||
if (!_serverSettings || !this._context?.notebookPath) {
|
if (!_serverSettings || !this._notebookPath) {
|
||||||
throw new Error('notebook not ready');
|
throw new Error('notebook not ready');
|
||||||
}
|
}
|
||||||
// Tear down any in-flight SSE first — the old session is
|
// Tear down any in-flight SSE first — the old session is
|
||||||
// being replaced.
|
// being replaced.
|
||||||
this._closeSse();
|
this._closeSse();
|
||||||
const r = await callOpenCodeCreateNotebookSession(
|
const r = await callOpenCodeCreateNotebookSession(
|
||||||
this._context.notebookPath,
|
this._notebookPath,
|
||||||
undefined,
|
undefined,
|
||||||
_serverSettings
|
_serverSettings
|
||||||
);
|
);
|
||||||
@@ -215,7 +214,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
return r.sessionId;
|
return r.sessionId;
|
||||||
},
|
},
|
||||||
onSwitchSession: async (sessionId: string) => {
|
onSwitchSession: async (sessionId: string) => {
|
||||||
if (!_serverSettings || !this._context?.notebookPath) {
|
if (!_serverSettings || !this._notebookPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Closing the SSE first ensures the old session's events stop
|
// Closing the SSE first ensures the old session's events stop
|
||||||
@@ -227,7 +226,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
// user just picked from the global list, we need to bind
|
// user just picked from the global list, we need to bind
|
||||||
// first — calling set_active before bind returns 400.)
|
// first — calling set_active before bind returns 400.)
|
||||||
const r = await callOpenCodeBindExistingSession(
|
const r = await callOpenCodeBindExistingSession(
|
||||||
this._context.notebookPath,
|
this._notebookPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
_serverSettings
|
_serverSettings
|
||||||
);
|
);
|
||||||
@@ -239,8 +238,8 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onReloadHistory: async () => {
|
onReloadHistory: async () => {
|
||||||
if (!this._context?.notebookPath) return;
|
if (!this._notebookPath) return;
|
||||||
await this._refreshHistory(this._context.notebookPath);
|
await this._refreshHistory(this._notebookPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Widget.attach(prompt, this._cell.node);
|
Widget.attach(prompt, this._cell.node);
|
||||||
@@ -248,7 +247,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
|
|
||||||
// Fetch the current session's static history and render it before
|
// Fetch the current session's static history and render it before
|
||||||
// streaming begins. Empty list is a normal no-op.
|
// streaming begins. Empty list is a normal no-op.
|
||||||
const notebookPath = this._context?.notebookPath;
|
const notebookPath = this._notebookPath;
|
||||||
if (notebookPath) {
|
if (notebookPath) {
|
||||||
void this._refreshHistory(notebookPath);
|
void this._refreshHistory(notebookPath);
|
||||||
}
|
}
|
||||||
@@ -286,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
providerId?: string,
|
providerId?: string,
|
||||||
modelId?: string
|
modelId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this._context) {
|
if (!this._notebookPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_serverSettings) {
|
if (!_serverSettings) {
|
||||||
@@ -296,7 +295,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
prompt: text,
|
prompt: text,
|
||||||
context: this._context,
|
context: { notebookPath: this._notebookPath },
|
||||||
providerId,
|
providerId,
|
||||||
modelId
|
modelId
|
||||||
};
|
};
|
||||||
@@ -357,26 +356,23 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
* Walk the parent chain to find the cell's parent NotebookPanel and
|
||||||
* then build the CellContext.
|
* return its content path. Returns null if the cell is detached
|
||||||
|
* (no notebook parent).
|
||||||
*/
|
*/
|
||||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
function extractNotebookPathFromCell(cell: CodeCell): string | null {
|
||||||
let node: Widget | null = cell.parent;
|
let node: Widget | null = cell.parent;
|
||||||
let notebookPanel: NotebookPanel | null = null;
|
|
||||||
while (node) {
|
while (node) {
|
||||||
const candidate = node as any;
|
const candidate = node as any;
|
||||||
if (
|
if (
|
||||||
candidate.context &&
|
candidate.context &&
|
||||||
|
typeof candidate.context.path === 'string' &&
|
||||||
candidate.content &&
|
candidate.content &&
|
||||||
Array.isArray(candidate.content.widgets)
|
Array.isArray(candidate.content.widgets)
|
||||||
) {
|
) {
|
||||||
notebookPanel = candidate;
|
return candidate.context.path;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
node = node.parent;
|
node = node.parent;
|
||||||
}
|
}
|
||||||
if (!notebookPanel) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return extractCellContext(cell, notebookPanel);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
private _cancelBtn: HTMLButtonElement;
|
private _cancelBtn: HTMLButtonElement;
|
||||||
|
private _insertCellBtn: HTMLButtonElement;
|
||||||
private _providerSelect: HTMLSelectElement | null = null;
|
private _providerSelect: HTMLSelectElement | null = null;
|
||||||
private _modelSelect: HTMLSelectElement | null = null;
|
private _modelSelect: HTMLSelectElement | null = null;
|
||||||
private _providerModels: FlatProvider[];
|
private _providerModels: FlatProvider[];
|
||||||
@@ -238,8 +239,22 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
options.onCancel();
|
options.onCancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "📋 插入单元格内容" button: appends the current cell's source
|
||||||
|
// (wrapped in a markdown code fence) to the textarea. Lets the
|
||||||
|
// user explicitly opt-in to including the cell content in the
|
||||||
|
// LLM prompt — the cell is no longer auto-injected.
|
||||||
|
this._insertCellBtn = document.createElement('button');
|
||||||
|
this._insertCellBtn.type = 'button';
|
||||||
|
this._insertCellBtn.className = 'opencode-btn-insert-cell';
|
||||||
|
this._insertCellBtn.textContent = '📋 插入单元格内容';
|
||||||
|
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
||||||
|
this._insertCellBtn.addEventListener('click', () => {
|
||||||
|
this._insertCellSource();
|
||||||
|
});
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'opencode-inline-actions';
|
actions.className = 'opencode-inline-actions';
|
||||||
|
actions.appendChild(this._insertCellBtn);
|
||||||
actions.appendChild(this._sendBtn);
|
actions.appendChild(this._sendBtn);
|
||||||
actions.appendChild(this._cancelBtn);
|
actions.appendChild(this._cancelBtn);
|
||||||
|
|
||||||
@@ -598,6 +613,42 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
this._textarea.value = '';
|
this._textarea.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "📋 插入单元格内容" button: append the current cell's source as a
|
||||||
|
* markdown code block to the textarea. The LLM only sees what the
|
||||||
|
* user explicitly chose to include — the cell source is NEVER
|
||||||
|
* auto-injected (v0.2.x change).
|
||||||
|
*/
|
||||||
|
private _insertCellSource(): void {
|
||||||
|
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
|
||||||
|
Notification.info('当前 cell 没有内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = this._cell.model.sharedModel.getSource() as string;
|
||||||
|
if (!source || !source.trim()) {
|
||||||
|
Notification.info('当前 cell 为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use the cell's language as the fence info string when it
|
||||||
|
// looks like a real language identifier; otherwise use a plain
|
||||||
|
// fence so markdown stays portable.
|
||||||
|
const langRaw =
|
||||||
|
(this._cell.model as any).sharedModel && (this._cell.model as any).type
|
||||||
|
? ((this._cell.model as any).type as string)
|
||||||
|
: 'python';
|
||||||
|
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
|
||||||
|
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
|
||||||
|
const before = this._textarea.value;
|
||||||
|
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||||
|
const insertion = (needsLeadingNewline ? '\n' : '') + block;
|
||||||
|
this._textarea.value = before + insertion;
|
||||||
|
// Move cursor to end so the user can continue typing.
|
||||||
|
this._textarea.focus();
|
||||||
|
const end = this._textarea.value.length;
|
||||||
|
this._textarea.setSelectionRange(end, end);
|
||||||
|
this._scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
||||||
* `applyEvent` for a different session are dropped. Pass null to clear.
|
* `applyEvent` for a different session are dropped. Pass null to clear.
|
||||||
@@ -670,16 +721,25 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
event.payload.syncEvent &&
|
event.payload.syncEvent &&
|
||||||
event.payload.syncEvent.aggregateID);
|
event.payload.syncEvent.aggregateID);
|
||||||
|
|
||||||
// Drop events for other sessions (defensive; matches demo.html
|
// System / workspace / pty / etc. events are not displayed.
|
||||||
// handleGlobalEvent). Only drop when the event has an explicit
|
if (this._isSystemEvent(type)) {
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// System / workspace / pty / etc. events are not displayed.
|
// Cross-session filtering: only strict for permission/question
|
||||||
if (this._isSystemEvent(type)) {
|
// events (the user must NOT be able to reply to a permission
|
||||||
|
// prompt that belongs to a different session). Content events
|
||||||
|
// (text delta, reasoning, tool, idle) pass through regardless of
|
||||||
|
// sessionID match — the user is looking at the active session's
|
||||||
|
// panel, and we don't want to drop their reply because of a
|
||||||
|
// sessionID extraction edge case. The server already forwards
|
||||||
|
// ALL events; the SSE handler routes by URL ?session= param.
|
||||||
|
if (
|
||||||
|
(type === 'permission.asked' || type === 'question.asked') &&
|
||||||
|
sessionID &&
|
||||||
|
this._sessionId &&
|
||||||
|
sessionID !== this._sessionId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
|
|
||||||
*
|
|
||||||
* Pure functions, no DOM, no signals — testable with plain object mocks.
|
|
||||||
*/
|
|
||||||
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import type { CellContext, ErrorOutput } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first error output in a code cell's outputs.
|
|
||||||
* Returns null if the cell has no error output or is not a code cell.
|
|
||||||
*/
|
|
||||||
export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
|
|
||||||
const outputs = model.outputs;
|
|
||||||
for (let i = 0; i < outputs.length; i++) {
|
|
||||||
const out = outputs.get(i);
|
|
||||||
if (out && out.type === 'error') {
|
|
||||||
const e = out as {
|
|
||||||
ename?: unknown;
|
|
||||||
evalue?: unknown;
|
|
||||||
traceback?: unknown;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
ename: String(e.ename ?? ''),
|
|
||||||
evalue: String(e.evalue ?? ''),
|
|
||||||
traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a CellContext from a CodeCell and its parent NotebookPanel.
|
|
||||||
* Returns null if essential data is missing.
|
|
||||||
*
|
|
||||||
* @param cell The code cell to inspect.
|
|
||||||
* @param panel The notebook panel that contains the cell.
|
|
||||||
*/
|
|
||||||
export function extractCellContext(
|
|
||||||
cell: CodeCell,
|
|
||||||
panel: NotebookPanel
|
|
||||||
): CellContext | null {
|
|
||||||
if (!cell || !panel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cells = panel.content.widgets;
|
|
||||||
const cellIndex = cells ? cells.indexOf(cell) : -1;
|
|
||||||
if (cellIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCells = cells ? cells.length : 0;
|
|
||||||
const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
|
|
||||||
const previousCode = previousCell
|
|
||||||
? previousCell.model.sharedModel.getSource()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Language: use 'python' as default for code cells.
|
|
||||||
// Future improvement: read from kernel info_reply.
|
|
||||||
const language = 'python';
|
|
||||||
|
|
||||||
let error: ErrorOutput | null = null;
|
|
||||||
if (cell.model.type === 'code') {
|
|
||||||
error = collectErrorOutputs(cell.model as ICodeCellModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
notebookPath: panel.context.path,
|
|
||||||
cellId: cell.model.id,
|
|
||||||
language,
|
|
||||||
cellIndex,
|
|
||||||
totalCells,
|
|
||||||
source: cell.model.sharedModel.getSource(),
|
|
||||||
previousCode,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+11
-13
@@ -10,21 +10,19 @@
|
|||||||
* a JSON object with a `type` string; consumers route on `type`.
|
* a JSON object with a `type` string; consumers route on `type`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ErrorOutput {
|
/**
|
||||||
ename: string;
|
* Minimal context carried with each edit request. Only the
|
||||||
evalue: string;
|
* notebookPath is needed (so the server can pick the right OpenCode
|
||||||
traceback: string[];
|
* session). The cell source, previous-cell, and traceback are NO
|
||||||
}
|
* LONGER auto-injected into the LLM prompt — the user inserts them
|
||||||
|
* manually via the "📋 插入单元格内容" button (or pastes anything
|
||||||
|
* else they want) so the LLM only sees what they explicitly chose
|
||||||
|
* to send. v0.1.x used to pack a lot more here; that auto-wrap
|
||||||
|
* made the LLM context hard to reason about and made the user's
|
||||||
|
* intent ambiguous.
|
||||||
|
*/
|
||||||
export interface CellContext {
|
export interface CellContext {
|
||||||
notebookPath: string;
|
notebookPath: string;
|
||||||
cellId: string;
|
|
||||||
language: string;
|
|
||||||
cellIndex: number;
|
|
||||||
totalCells: number;
|
|
||||||
source: string;
|
|
||||||
previousCode: string | null;
|
|
||||||
error: ErrorOutput | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeRequest {
|
export interface OpenCodeRequest {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-inline-prompt .opencode-inline-actions button {
|
.opencode-inline-prompt .opencode-inline-actions button {
|
||||||
@@ -211,6 +212,19 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
|
||||||
|
send/cancel so the user can tell at a glance it's a "modifier"
|
||||||
|
rather than a "submit/cancel" action. */
|
||||||
|
.opencode-inline-prompt .opencode-btn-insert-cell {
|
||||||
|
margin-right: auto; /* push to the left; send/cancel stay on the right */
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
|
||||||
|
background: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
|
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|||||||
Reference in New Issue
Block a user