Initial commit: opencode_bridge JupyterLab extension (Slices 1-3.5)
A JupyterLab extension that bridges the cell UI to a local OpenCode Serve process. The extension is a dual package: a Python server extension exposed under /opencode-bridge/*, plus a TypeScript frontend that registers per-cell toolbars. Backend (Python, tornado) - Slice 1: config + auth + OpenCode HTTP client (tornado.httpclient, no aiohttp). 4 settings in schema/plugin.json (url, user, password, request timeout). - Slice 2: handlers for /hello, /health, /providers, /edit. - Slice 2.1 (correction): SessionManager with 1 notebook = 1 session mapping, async-safe via per-path locks, 404 recovery via invalidate(). Two new endpoints: GET /sessions, DELETE /session?notebook=<path>. - 32 pytest tests pass. Frontend (TypeScript, JupyterLab 4.6) - src/types.ts: CellContext, OpenCodeRequest/Response, OpenCodeSettings. - src/context/cell_context.ts: extract CellContext from a CodeCell + its parent NotebookPanel, structured error collection. - src/api/opencode_client.ts: callOpenCodeEdit, callOpenCodeProviders. - src/components/opencode_cell_footer.ts: OpenCodeCellFooter Widget implementing ICellFooter with 3 buttons (optimize / fix / edit), resolved via this.parent instanceof CodeCell. NOT cellToolbar (does not exist in JL 4.6) and NOT Widget.findParent (removed in @lumino/widgets 2.x). - src/components/opencode_cell_factory.ts: Cell.ContentFactory subclass returning the OpenCodeCellFooter. - src/components/opencode_installer.ts: installOpenCodeEverywhere patches every notebook (existing + new) to use the custom factory. - src/index.ts: registers the factory, loads settings, fetches /providers on activation and logs the list to the console. - 23 jest tests pass (mocked JupyterLab boundary, pnpm path safe). Settings - 6 fields: 3 auth (url/user/password) + 1 timeout + 2 model selection (provider/model). Provider list is fetched at startup from /opencode-bridge/providers and printed to the browser console so users can copy values into Settings Editor. Docs - design.md: 6 sections covering architecture, UI flow, API contract, TS skeletons, session management (v0.2.1 correction), and provider/model selection (v0.2.2 addition). - CLAUDE.md: agent guidance for working in this repo. - TODO.md: remaining work for Slices 4-7 + v0.4+ backlog. CI - Gitea release workflow at .github/workflows/build.yml. - Bark notification helper (non-fatal on failure). Generated artefacts ignored: opencode_bridge/labextension/, _version.py, *.tsbuildinfo, junit.xml, test.ipynb scratch notebook.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Async HTTP client for the local OpenCode server."""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
from tornado.httputil import HTTPHeaders
|
||||
|
||||
from .config import OpenCodeConfig
|
||||
|
||||
|
||||
class OpenCodeError(Exception):
|
||||
"""Raised when an OpenCode API request fails."""
|
||||
|
||||
|
||||
class OpenCodeClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenCodeConfig,
|
||||
http_client: Optional[AsyncHTTPClient] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._http_client = http_client or AsyncHTTPClient()
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
return await self._request("GET", "/global/health")
|
||||
|
||||
async def list_providers(self) -> list[dict[str, Any]]:
|
||||
return await self._request("GET", "/config/providers")
|
||||
|
||||
async def create_session(self, title: str) -> dict[str, Any]:
|
||||
return await self._request("POST", "/session", {"title": title})
|
||||
|
||||
async def send_message_sync(
|
||||
self,
|
||||
session_id: str,
|
||||
parts: list[dict[str, Any]],
|
||||
provider_id: Optional[str] = None,
|
||||
model_id: Optional[str] = None,
|
||||
system: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"parts": parts}
|
||||
if provider_id is not None and model_id is not None:
|
||||
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||
if system is not None:
|
||||
body["system"] = system
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/session/%s/message" % session_id,
|
||||
body,
|
||||
)
|
||||
|
||||
async def abort(self, session_id: str) -> bool:
|
||||
result = await self._request("POST", "/session/%s/abort" % session_id)
|
||||
return result is not None
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||
return result is not None
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
return self._config.url
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[Any]:
|
||||
url = self._config.url + path
|
||||
headers = HTTPHeaders(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"method": method,
|
||||
"headers": headers,
|
||||
"request_timeout": self._config.request_timeout_seconds,
|
||||
}
|
||||
if body is not None:
|
||||
request_kwargs["body"] = json.dumps(body).encode("utf-8")
|
||||
|
||||
request = HTTPRequest(url, **request_kwargs)
|
||||
|
||||
fetch_kwargs: dict[str, str] = {}
|
||||
if self._config.auth is not None:
|
||||
fetch_kwargs["auth_username"] = self._config.auth[0]
|
||||
fetch_kwargs["auth_password"] = self._config.auth[1]
|
||||
|
||||
response = await self._http_client.fetch(request, **fetch_kwargs)
|
||||
|
||||
if 200 <= response.code < 300:
|
||||
if not response.body:
|
||||
return True
|
||||
return json.loads(response.body.decode("utf-8"))
|
||||
if response.code == 404:
|
||||
return None
|
||||
|
||||
response_body = response.body.decode("utf-8") if response.body else ""
|
||||
raise OpenCodeError(
|
||||
"OpenCode request %s %s failed with status %s: %s"
|
||||
% (method, url, response.code, response_body)
|
||||
)
|
||||
Reference in New Issue
Block a user