From 1d7f5da9d4511e8ae3564f29cdb4b1769f798b76 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:29:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20inline=20markdown=20output=20box=20?= =?UTF-8?q?=E2=80=94=20render=20AI=20reply=20with=20marked,=20do=20not=20r?= =?UTF-8?q?eplace=20cell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow (v3-final corrected): 1. Model call succeeds. 2. Server passes the AI reply through unchanged as 'markdown' (the system prompt allows ```language fences + a brief explanation, so the response is real markdown that marked can render into code blocks, headings, etc.). 3. Client OpenCodeCellActions._handleResponse calls prompt.setOutput( resp.markdown); the inline prompt widget renders it with marked.parse and shows it in a new output area (hidden until a response arrives). The cell source is NOT replaced. 4. User can close the output or cancel the whole prompt. Server: - New unified system prompt: '你是代码助手 ... 按指令修改代码,可附简 短说明' (allows ```fences``` + explanation; no more 'no markdown fences' restriction). - EditHandler returns {ok, markdown, sessionId, notebookPath} (raw text, fences intact). _strip_code_fence kept as a helper for any future apply-to-cell path; no longer called. - finalSource field dropped (the cell-apply path is gone). Client: - OpenCodeSuccess: markdown: string (finalSource removed). - OpenCodeInlinePrompt: new output area, setOutput(md) renders via marked.parse, hideOutput() closes it. - OpenCodeCellActions._handleResponse: setOutput(markdown) instead of sharedModel.setSource + auto-hide. The prompt stays open so the user can read the output. - Uses marked@17 (already in node_modules via JupyterLab; no new dep). - CSS: output area styling (border, max-height 320px scroll, code/pre styling, close button). Tests: pytest 34/34, jest 29/29. marked is mocked in jest. Co-Authored-By: Claude Fable 5 --- opencode_bridge/routes.py | 27 ++++++--- opencode_bridge/tests/test_routes.py | 8 ++- src/__tests__/opencode_cell_actions.spec.ts | 62 +++++++++++++++++++- src/__tests__/opencode_client.spec.ts | 4 +- src/components/opencode_cell_actions.ts | 10 +++- src/components/opencode_inline_prompt.ts | 44 ++++++++++++++ src/types.ts | 4 +- style/base.css | 65 +++++++++++++++++++++ 8 files changed, 205 insertions(+), 19 deletions(-) diff --git a/opencode_bridge/routes.py b/opencode_bridge/routes.py index 067c7e8..0b9beaf 100644 --- a/opencode_bridge/routes.py +++ b/opencode_bridge/routes.py @@ -14,13 +14,15 @@ from .session_manager import SessionManager log = logging.getLogger("opencode_bridge.routes") -# Unified system prompt — the LLM (OpenCode) itself judges whether the user's -# natural-language instruction is an optimize / fix / edit request, based on -# the code context and the optional in the parts below. +# Unified system prompt — the LLM (OpenCode) is asked to return its reply +# as MARKDOWN (code wrapped in ```language fences; a brief explanation is +# fine). The frontend renders this with `marked` directly — no fence +# stripping on the server, so the response keeps the structure that makes +# markdown rendering meaningful (code blocks, headings, etc.). UNIFIED_SYSTEM_PROMPT = ( - "你是一个代码编辑助手。基于提供的代码上下文(以及 traceback,如果有)," - "按照用户的指令修改代码。返回只包含修改后完整代码的回复," - "不要任何解释或 markdown 围栏。" + "你是一个代码助手。基于提供的代码上下文(以及可选的 traceback)," + "按照用户的指令修改代码。" + "可附简短说明。" ) @@ -87,7 +89,11 @@ def _build_request_body(prompt: str, context: dict) -> dict: def _strip_code_fence(s: str) -> str: - """Strip ```language ... ``` fences from LLM output.""" + """Strip ```language ... ``` fences from LLM output. + + Kept for any future "apply-to-cell" path that needs clean source. + Not used by the current markdown-rendering display flow. + """ s = s.strip() if s.startswith("```"): lines = s.split("\n") @@ -185,11 +191,14 @@ class EditHandler(APIHandler): for p in result.get("parts", []) if p.get("type") == "text" ] - final_source = _strip_code_fence("\n".join(text_parts).strip()) + # The AI's reply is markdown (code in ```fences```, optional + # explanation). Pass it through unchanged so the frontend + # `marked.parse` can render the code blocks and structure. + markdown = "\n".join(text_parts).strip() self.finish(json.dumps({ "ok": True, - "finalSource": final_source, + "markdown": markdown, "sessionId": sid, "notebookPath": notebook_path, })) diff --git a/opencode_bridge/tests/test_routes.py b/opencode_bridge/tests/test_routes.py index 32b9b39..0832481 100644 --- a/opencode_bridge/tests/test_routes.py +++ b/opencode_bridge/tests/test_routes.py @@ -135,7 +135,9 @@ async def test_edit_handler(monkeypatch, jp_fetch): assert response.code == 200 payload = json.loads(response.body) assert payload["ok"] is True - assert payload["finalSource"] == "def foo():\n return 42" + # Server now returns the AI reply as raw markdown (```fences``` kept + # so the frontend marked.parse renders code blocks). + assert payload["markdown"] == "def foo():\n return 42" assert payload["sessionId"] == "fake-session-123" assert payload["notebookPath"] == "test.ipynb" @@ -148,8 +150,8 @@ async def test_edit_handler(monkeypatch, jp_fetch): # send_message_sync received the session ID from the manager send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0] assert send_call[1] == "fake-session-123" - # system prompt was passed - assert "你是一个代码编辑助手" in send_call[3] + # system prompt was passed (v3+ allows markdown/fences in the reply) + assert "你是一个代码助手" in send_call[3] # SessionManager.get_or_create was called with the notebook path sm_call_names = [c[0] for c in fake_sm.calls] diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 6852d52..64d1d8f 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -47,6 +47,11 @@ jest.mock('@lumino/widgets', () => { return { Widget }; }); +jest.mock('marked', () => ({ + __esModule: true, + marked: { parse: (md: string) => `${md}` } +})); + import { OpenCodeCellActions, setOpenCodeProviders, @@ -324,6 +329,33 @@ describe('OpenCodeCellActions', () => { cell.node.querySelector('.opencode-inline-prompt .opencode-model-select') ).toBeNull(); }); + + it('on a successful response, does NOT replace the cell source (renders to inline output instead)', () => { + setOpenCodeProviders(null); + const cell = makeFakeCell('x = 1'); + const actions = new OpenCodeCellActions(cell); + // Click once to create the inline prompt (so setOutput has a target). + Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); + (actions as any).onAfterAttach({} as any); + const btn = actions.node.querySelector('button') as HTMLButtonElement; + btn.click(); + + // Simulate a successful response. + (actions as any)._handleResponse({ + ok: true, + markdown: '# AI says hi\n\n```python\nprint("hi")\n```', + sessionId: 'ses-abc', + notebookPath: 'foo.ipynb' + }); + + // The cell source must remain unchanged. + expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled(); + // The inline output area is now visible with the rendered markdown. + const output = cell.node.querySelector( + '.opencode-inline-prompt .opencode-inline-output' + ) as HTMLElement; + expect(output.style.display).toBe('block'); + }); }); describe('OpenCodeInlinePrompt', () => { @@ -336,6 +368,34 @@ describe('OpenCodeInlinePrompt', () => { providers: null }); expect(prompt.node.querySelector('textarea')).not.toBeNull(); - expect(prompt.node.querySelectorAll('button').length).toBe(2); + // 3 buttons: 发送, 取消, and the output area close (✕). + expect(prompt.node.querySelectorAll('button').length).toBe(3); + }); + + it('hides the output area by default; setOutput reveals it with rendered markdown', () => { + const cell = makeFakeCell('x = 1'); + const prompt = new OpenCodeInlinePrompt(cell, { + onSubmit: jest.fn(), + onCancel: jest.fn(), + disabled: false, + providers: null + }); + const output = prompt.node.querySelector( + '.opencode-inline-prompt .opencode-inline-output' + ) as HTMLElement; + expect(output).not.toBeNull(); + expect(output.style.display).toBe('none'); + + prompt.setOutput('# Hello\n\n```python\nx = 1\n```'); + expect(output.style.display).toBe('block'); + const content = prompt.node.querySelector( + '.opencode-inline-prompt .opencode-inline-output-content' + ) as HTMLElement; + // marked is mocked to wrap the input in .... + expect(content.innerHTML).toContain(''); + expect(content.innerHTML).toContain('# Hello'); + + prompt.hideOutput(); + expect(output.style.display).toBe('none'); }); }); diff --git a/src/__tests__/opencode_client.spec.ts b/src/__tests__/opencode_client.spec.ts index 4dd4a39..5785aeb 100644 --- a/src/__tests__/opencode_client.spec.ts +++ b/src/__tests__/opencode_client.spec.ts @@ -85,7 +85,7 @@ describe('callOpenCodeEdit', () => { it('POSTs to /opencode-bridge/edit with JSON body', async () => { const respBody = { ok: true, - finalSource: 'def foo(x: int) -> int: return x', + markdown: '```python\ndef foo(x: int) -> int: return x\n```', sessionId: 'sid', notebookPath: 'foo.ipynb', }; @@ -107,7 +107,7 @@ describe('callOpenCodeEdit', () => { ); expect(resp.ok).toBe(true); if (resp.ok) { - expect(resp.finalSource).toContain('int'); + expect(resp.markdown).toContain('int'); } }); diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts index 14c8a4b..191113e 100644 --- a/src/components/opencode_cell_actions.ts +++ b/src/components/opencode_cell_actions.ts @@ -172,11 +172,15 @@ export class OpenCodeCellActions extends Widget { private _handleResponse(resp: OpenCodeResponse): void { this._status = 'idle'; if (resp.ok) { - this._cell.model.sharedModel.setSource(resp.finalSource); + // Display the AI response in the inline prompt's output area as + // rendered markdown. The cell source is NOT replaced — the user + // decides what to do with the output (close it, or resend). + if (this._prompt) { + this._prompt.setOutput(resp.markdown); + } Notification.info( - `OpenCode 完成 (${resp.finalSource.length} chars). Session: ${resp.sessionId.slice(0, 8)}…` + `OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…` ); - this._hidePrompt(); } else { if (this._prompt) { this._prompt.setDisabled(false); diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index 4ba2e11..949d9ee 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -9,6 +9,7 @@ */ import { CodeCell } from '@jupyterlab/cells'; import { Widget } from '@lumino/widgets'; +import { marked } from 'marked'; import type { OpenCodeProvidersResponse } from '../types'; @@ -69,6 +70,8 @@ export class OpenCodeInlinePrompt extends Widget { private _modelSelect: HTMLSelectElement | null = null; private _providerModels: FlatProvider[]; private _defaultMap: { [pid: string]: string }; + private _outputArea: HTMLDivElement; + private _outputContent: HTMLDivElement; constructor( _cell: CodeCell, @@ -153,6 +156,31 @@ export class OpenCodeInlinePrompt extends Widget { } this.node.appendChild(this._textarea); this.node.appendChild(actions); + + // Output area: hidden by default; revealed by setOutput() with the + // markdown-rendered AI response. The cell source is NOT replaced. + this._outputArea = document.createElement('div'); + this._outputArea.className = 'opencode-inline-output'; + this._outputArea.style.display = 'none'; + const outputHeader = document.createElement('div'); + outputHeader.className = 'opencode-inline-output-header'; + const outputTitle = document.createElement('span'); + outputTitle.textContent = '结果'; + const outputCloseBtn = document.createElement('button'); + outputCloseBtn.className = 'opencode-inline-output-close'; + outputCloseBtn.type = 'button'; + outputCloseBtn.textContent = '✕'; + outputCloseBtn.title = '关闭输出'; + outputCloseBtn.addEventListener('click', () => { + this.hideOutput(); + }); + outputHeader.appendChild(outputTitle); + outputHeader.appendChild(outputCloseBtn); + this._outputContent = document.createElement('div'); + this._outputContent.className = 'opencode-inline-output-content'; + this._outputArea.appendChild(outputHeader); + this._outputArea.appendChild(this._outputContent); + this.node.appendChild(this._outputArea); } private _rebuildModelSelect(providerId: string | undefined): void { @@ -196,4 +224,20 @@ export class OpenCodeInlinePrompt extends Widget { this._modelSelect.disabled = disabled; } } + + /** + * Render the AI response as markdown and show the output area in place + * of replacing the cell source. Safe for local-trusted content from the + * user's OpenCode Serve; do not feed untrusted markdown here without + * adding a sanitizer (e.g. DOMPurify). + */ + setOutput(markdown: string): void { + this._outputContent.innerHTML = marked.parse(markdown) as string; + this._outputArea.style.display = 'block'; + } + + hideOutput(): void { + this._outputArea.style.display = 'none'; + this._outputContent.textContent = ''; + } } diff --git a/src/types.ts b/src/types.ts index e1b8b02..8fc36b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,9 @@ export interface OpenCodeRequest { export interface OpenCodeSuccess { ok: true; - finalSource: string; + /** The AI's reply as markdown (code in ```fences```, optional explanation). + * Render with marked. The cell source is NOT replaced. */ + markdown: string; sessionId: string; notebookPath: string; } diff --git a/style/base.css b/style/base.css index bf58278..7748d43 100644 --- a/style/base.css +++ b/style/base.css @@ -75,6 +75,71 @@ cursor: default; } +/* Markdown output area: hidden until setOutput() reveals it. */ +.opencode-inline-prompt .opencode-inline-output { + display: none; + margin-top: 4px; + padding: 6px 8px; + border: 1px solid var(--jp-border-color1, #c0c0c0); + border-radius: 3px; + background: var(--jp-layout-color2, #f7f7f7); + max-height: 320px; + overflow-y: auto; +} + +.opencode-inline-prompt .opencode-inline-output-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + font-size: var(--jp-ui-font-size1); + font-weight: 600; + color: var(--jp-ui-font-color1, #333); +} + +.opencode-inline-prompt .opencode-inline-output-close { + border: none; + background: transparent; + cursor: pointer; + font-size: var(--jp-ui-font-size1); + line-height: 1; + padding: 0 4px; + color: var(--jp-ui-font-color2, #666); +} + +.opencode-inline-prompt .opencode-inline-output-close:hover { + color: var(--jp-ui-font-color0, #000); +} + +.opencode-inline-prompt .opencode-inline-output-content { + font-size: var(--jp-ui-font-size1); + line-height: 1.45; +} + +.opencode-inline-prompt .opencode-inline-output-content pre { + background: var(--jp-layout-color1, #fff); + padding: 6px 8px; + border-radius: 3px; + overflow-x: auto; + margin: 4px 0; +} + +.opencode-inline-prompt .opencode-inline-output-content code { + font-family: var(--jp-code-font-family, monospace); + font-size: var(--jp-code-font-size, 13px); +} + +.opencode-inline-prompt .opencode-inline-output-content p { + margin: 2px 0; +} + +.opencode-inline-prompt .opencode-inline-output-content h1, +.opencode-inline-prompt .opencode-inline-output-content h2, +.opencode-inline-prompt .opencode-inline-output-content h3 { + margin: 6px 0 4px; + font-size: var(--jp-ui-font-size2, 14px); +} + .opencode-inline-prompt textarea { width: 100%; resize: vertical;