Files
tao.chenandClaude Fable 5 ce59502e97 feat: inline prompt history area — scrollable session messages
The inline prompt now shows the current session's full message
history (user + assistant), scrollable, replacing the single-output
display from the previous commit. The user can scroll back through
prior exchanges in the same notebook's OpenCode session.

Server (opencode_bridge/):
  - New route: GET /opencode-bridge/session-messages?notebook=<path>
    - Resolves notebook to session via SessionManager.peek (no create).
    - If no session: {"messages": []} (no OpenCode call).
    - Else: GET /session/{id}/message on OpenCode Serve, project the
      raw {info, parts}[] into a frontend-friendly {role, content}[].
  - OpenCodeClient.list_session_messages(sid).
  - SessionManager.peek(notebook_path) -> Optional[str] (read without
    creating, to avoid spawning a session just to report emptiness).
  - Wired the new route in setup_route_handlers.

Client (src/):
  - types.ts: OpenCodeMessage { role, content } + OpenCodeMessagesResponse.
  - api/opencode_client.ts: callOpenCodeSessionMessages(notebook, serverSettings).
  - components/opencode_inline_prompt.ts:
    - Replaces the single .opencode-inline-output area with a
      scrollable .opencode-inline-history (max-height 320px, overflow-y
      auto, auto-scrolls to bottom on update).
    - setMessages(messages): renders user messages as plain text,
      assistant messages via marked.parse. No more setOutput/hideOutput.
  - components/opencode_cell_actions.ts:
    - _showPrompt now fires a _refreshHistory(notebookPath) which
      fetches and calls prompt.setMessages.
    - _handleResponse on success also calls _refreshHistory (the new
      assistant message appears as the last item in the history).
    - Cell source is still NOT replaced.
  - api/opencode_client module is mocked in the cell_actions test to
    avoid jsdom network calls.

style/base.css:
  - .opencode-inline-output* rules replaced with .opencode-inline-history
    (max-height 320px, overflow-y auto, border, padding) and
    .opencode-msg / .opencode-msg-user / .opencode-msg-assistant.
  - pre/code/p/h1-3 content styling scoped under .opencode-inline-history.

Tests:
  - pytest 37/37: FakeOpenCodeClient.list_session_messages + 3 new
    session_messages route tests (no session, projects messages, 400).
  - jest 29/29: setMessages renders user/assistant + history area tests.
  - FakeSessionManager.peek (returns None when session_id is falsy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:45:03 +08:00

319 lines
11 KiB
Python

import json
import logging
from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
import tornado
from .config import resolve_config
from .opencode_client import OpenCodeClient, OpenCodeError
from .session_manager import SessionManager
log = logging.getLogger("opencode_bridge.routes")
# 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),"
"按照用户的指令修改代码。"
"可附简短说明。"
)
def make_client(handler: APIHandler) -> OpenCodeClient:
"""Factory for OpenCodeClient. Tests monkey-patch this."""
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
def get_session_manager(handler: APIHandler) -> SessionManager:
"""Return the SessionManager singleton for this web app, creating on first use.
Stored in handler.settings["opencode_bridge_session_manager"] so it survives
across requests but is per-server-instance. Tests monkey-patch this.
"""
sm = handler.settings.get("opencode_bridge_session_manager")
if sm is None:
def client_factory() -> OpenCodeClient:
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
sm = SessionManager(client_factory)
handler.settings["opencode_bridge_session_manager"] = sm
return sm
def _build_request_body(prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/message.
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
the LLM interprets the user's natural-language instruction, with the code
context and the optional traceback in front of it.
"""
parts: list[dict] = []
if context.get("previousCode"):
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:
"""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")
if lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
return "\n".join(lines).strip()
return s
class HelloRouteHandler(APIHandler):
# The following decorator should be present on all verb methods (head, get, post,
# patch, put, delete, options) to ensure only authorized user can request the
# Jupyter server
@tornado.web.authenticated
def get(self):
self.finish(json.dumps({
"data": (
"Hello, world!"
" This is the '/opencode-bridge/hello' endpoint."
" Try visiting me in your browser!"
),
}))
class HealthHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
data = await client.health()
self.finish(json.dumps({
"ok": data.get("healthy", False),
"version": data.get("version"),
"endpoint": client.endpoint,
}))
except Exception as e:
log.exception("health check failed")
self.set_status(503)
self.finish(json.dumps({
"ok": False,
"error": str(e),
"endpoint": make_client(self).endpoint,
}))
class ProvidersHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
data = await make_client(self).list_providers()
self.finish(json.dumps(data))
except Exception as e:
log.exception("providers list failed")
self.set_status(502)
self.finish(json.dumps({"error": str(e)}))
class EditHandler(APIHandler):
@tornado.web.authenticated
async def post(self):
try:
body = json.loads(self.request.body)
prompt = body.get("prompt", "")
context = body["context"]
provider_id = body.get("providerId") or None
model_id = body.get("modelId") or None
notebook_path = context.get("notebookPath", "")
except (KeyError, json.JSONDecodeError) as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing context.notebookPath"}))
return
client = make_client(self)
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
result = await client.send_message_sync(
sid,
request_body["parts"],
provider_id=provider_id,
model_id=model_id,
system=request_body["system"],
)
text_parts = [
p.get("text", "")
for p in result.get("parts", [])
if p.get("type") == "text"
]
# 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,
"markdown": markdown,
"sessionId": sid,
"notebookPath": notebook_path,
}))
except OpenCodeError as e:
# If session is invalid on OpenCode side, invalidate cache.
# 404 / 410 / "session not found" -> next get_or_create will recreate.
if "404" in str(e) or "not found" in str(e).lower():
sm.invalidate(notebook_path)
log.warning("invalidated dead session for %s", notebook_path)
log.exception("edit failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("edit failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
# NO finally delete — session is reused per notebook.
class SessionListHandler(APIHandler):
"""List all active notebook -> session mappings. Debug endpoint."""
@tornado.web.authenticated
def get(self):
sm = get_session_manager(self)
self.finish(json.dumps({"sessions": sm.list_sessions()}))
class SessionReleaseHandler(APIHandler):
"""Release the OpenCode session for a specific notebook.
Query param: notebook=<notebook path, URL-encoded>
"""
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
deleted = await sm.release(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"deleted": deleted,
}))
class SessionMessagesHandler(APIHandler):
"""List the current session's messages for a notebook (scrollable history).
Query param: notebook=<notebook path, URL-encoded>
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
If no session exists for the notebook yet, returns { messages: [] }
(does NOT create a session just to report emptiness).
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sid = sm.peek(notebook_path)
if sid is None:
self.finish(json.dumps({"messages": []}))
return
try:
client = make_client(self)
raw = await client.list_session_messages(sid)
except OpenCodeError as e:
if "404" in str(e) or "not found" in str(e).lower():
sm.invalidate(notebook_path)
log.warning("invalidated dead session for %s", notebook_path)
log.exception("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
# Project OpenCode's {info, parts}[] into a frontend-friendly
# {role, content}[] by joining the text parts.
messages = []
for m in raw or []:
info = m.get("info") or {}
role = info.get("role") or "assistant"
parts = m.get("parts") or []
content = "\n".join(
p.get("text", "") for p in parts if p.get("type") == "text"
).strip()
messages.append({"role": role, "content": content})
self.finish(json.dumps({"messages": messages}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
handlers = [
(url_path_join(base_url, "opencode-bridge", "hello"), HelloRouteHandler),
(url_path_join(base_url, "opencode-bridge", "health"), HealthHandler),
(url_path_join(base_url, "opencode-bridge", "providers"), ProvidersHandler),
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
]
web_app.add_handlers(host_pattern, handlers)