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,17 @@
|
||||
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
||||
_commit: v4.6.2
|
||||
_src_path: https://github.com/jupyterlab/extension-template
|
||||
advanced: true
|
||||
author_email: ''
|
||||
author_name: taochen
|
||||
has_ai_rules: false
|
||||
has_binder: false
|
||||
has_settings: true
|
||||
kind: frontend-and-server
|
||||
labextension_name: opencode_bridge
|
||||
project_short_description: A JupyterLab extension.
|
||||
python_name: opencode_bridge
|
||||
repository: http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git
|
||||
test: true
|
||||
yarn_linker: pnpm
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# bark-notify.sh — push a notification to the local Bark server.
|
||||
#
|
||||
# Usage: bark-notify.sh <title> <body>
|
||||
#
|
||||
# The Bark endpoint, device_key, and group are pinned in this script
|
||||
# (it is a deployment detail, not a secret). The group is "Gitea" —
|
||||
# change it here if you want to sort notifications by category.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 <title> <body>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TITLE="$1"
|
||||
BODY="$2"
|
||||
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"device_key": "6ZFVsxM95cuAjYoNLWSWaN",
|
||||
"title": "${TITLE}",
|
||||
"body": "${BODY}",
|
||||
"group": "Gitea",
|
||||
"isArchive": 1,
|
||||
"ttl": 3600
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
curl -s \
|
||||
-X POST http://101.43.40.124:81/push \
|
||||
-H "Content-Type: application/json; charset=utf-8" \
|
||||
-d "${PAYLOAD}"
|
||||
|
||||
echo "bark notified: ${TITLE}"
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-release.sh — create a Gitea release and attach dist/ assets.
|
||||
#
|
||||
# Required env vars (set by the workflow):
|
||||
# GITEA_TOKEN Gitea API token with release write scope
|
||||
# TAG_NAME tag name (e.g. v0.0.1-beta)
|
||||
# API_URL Gitea API base URL (e.g. http://gitea.local/api/v1)
|
||||
# REPOSITORY owner/repo
|
||||
# SHA commit SHA the tag points at
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"tag_name": "${TAG_NAME}",
|
||||
"target_commitish": "${SHA}",
|
||||
"name": "Release ${TAG_NAME}",
|
||||
"body": "Auto-generated release assets.",
|
||||
"draft": false,
|
||||
"prerelease": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Creating Gitea release for tag ${TAG_NAME}..."
|
||||
|
||||
RELEASE_RESPONSE=$(curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases" \
|
||||
-H "accept: application/json" \
|
||||
-H "authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}")
|
||||
|
||||
RELEASE_ID=$(printf '%s' "${RELEASE_RESPONSE}" | grep -oP '"id":\s*\K[0-9]+' | head -n 1)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "ERROR: failed to create release. Response:"
|
||||
echo "${RELEASE_RESPONSE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release created with ID ${RELEASE_ID}"
|
||||
|
||||
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
|
||||
if [ -f "${asset}" ]; then
|
||||
echo "Uploading ${asset}..."
|
||||
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "accept: application/json" \
|
||||
-H "authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${asset}"
|
||||
echo
|
||||
else
|
||||
echo "Skipping ${asset} (not found)"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,64 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches:
|
||||
# - main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI
|
||||
# Standard GitHub-hosted ubuntu runner. uv is installed via the
|
||||
# astral-sh/setup-uv action (one extra step vs. using a self-hosted
|
||||
# runner with uv pre-installed, but matches what GitHub-hosted CI
|
||||
# provides out of the box).
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
# Use the Tsinghua PyPI mirror for build-environment resolution;
|
||||
# the runner is in China and the default pypi.org is too slow
|
||||
# / unreliable for build dependency install.
|
||||
UV_DEFAULT_INDEX: "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||
run: uv build
|
||||
|
||||
# Only create a release + attach dist/ assets when pushing a v* tag.
|
||||
# On branch pushes / pull requests this step is skipped entirely.
|
||||
# The release logic is in a separate script to avoid embedding a JSON
|
||||
# heredoc inside a YAML block scalar (the previous version of this
|
||||
# step was rejected by Gitea's YAML parser at line 51).
|
||||
- name: Upload release assets via Gitea API
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
API_URL: ${{ github.api_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: ./.github/scripts/create-release.sh
|
||||
|
||||
# Always run, regardless of job status. The script is a side-effect;
|
||||
# if the notification itself fails we swallow the error so CI does
|
||||
# not turn red on a Bark outage.
|
||||
- name: Notify Bark
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
TITLE="✅ CI: ${{ github.repository }}@${{ github.ref_name }}"
|
||||
BODY="Build OK for ${{ github.sha }} by ${{ github.actor }}"
|
||||
else
|
||||
TITLE="❌ CI: ${{ github.repository }}@${{ github.ref_name }}"
|
||||
BODY="Build FAILED for ${{ github.sha }} by ${{ github.actor }}"
|
||||
fi
|
||||
./.github/scripts/bark-notify.sh "$TITLE" "$BODY" || echo "Bark notify failed (non-fatal)"
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
*.bundle.*
|
||||
lib/
|
||||
node_modules/
|
||||
*.log
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
*.egg-info/
|
||||
.ipynb_checkpoints
|
||||
*.tsbuildinfo
|
||||
opencode_bridge/labextension
|
||||
# Version file is handled by hatchling
|
||||
opencode_bridge/_version.py
|
||||
|
||||
# Integration tests
|
||||
ui-tests/test-results/
|
||||
ui-tests/playwright-report/
|
||||
|
||||
# Test artefacts
|
||||
junit.xml
|
||||
test.ipynb
|
||||
# Local scratch / scratchpad notebooks that aren't part of the project.
|
||||
# Project-level fixtures belong under opencode_bridge/tests/.
|
||||
|
||||
# Created by https://www.gitignore.io/api/python
|
||||
# Edit at https://www.gitignore.io/?templates=python
|
||||
|
||||
### Python ###
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# End of https://www.gitignore.io/api/python
|
||||
|
||||
# OSX files
|
||||
.DS_Store
|
||||
|
||||
# Yarn cache
|
||||
.yarn/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/lib
|
||||
**/package.json
|
||||
!/package.json
|
||||
opencode_bridge
|
||||
eslint.config.mjs
|
||||
.venv
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: false
|
||||
|
||||
nodeLinker: pnpm
|
||||
|
||||
packageExtensions:
|
||||
"@module-federation/sdk@*":
|
||||
dependencies:
|
||||
process: ^0.11.10
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
<!-- <START NEW CHANGELOG ENTRY> -->
|
||||
|
||||
<!-- <END NEW CHANGELOG ENTRY> -->
|
||||
@@ -0,0 +1,92 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
`opencode_bridge` is a JupyterLab extension that acts as a thin bridge between the JupyterLab UI and a locally-running **OpenCode Serve** process (an AI coding assistant). The architecture, UI flow, and reference TypeScript skeletons are documented in `design.md` — read it before changing the interaction model. The high-level shape is: floating toolbar injected into each `CodeCell` → context provider (cell source, error traceback, neighbours) → REST/SSE call to the Jupyter server extension → server extension forwards to OpenCode Serve over local socket/HTTP.
|
||||
|
||||
This is a dual-package extension (one Python package + one NPM package) scaffolded from the [JupyterLab extension-template](https://github.com/jupyterlab/extension-template) v4.6.2 via Copier (see `.copier-answers.yml`). Do not hand-edit files marked "NEVER EDIT MANUALLY" in that file.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/` | TypeScript frontend (JupyterLab plugin lives in `src/index.ts`, REST helper in `src/request.ts`) |
|
||||
| `opencode_bridge/` | Python server extension package; routes registered in `opencode_bridge/routes.py` |
|
||||
| `schema/plugin.json` | Frontend settings schema (currently empty `properties: {}`) |
|
||||
| `style/` | CSS entry points; `base.css` is empty, only `index.css` imports it |
|
||||
| `lib/` | Build output of `src/` (gitignored) |
|
||||
| `opencode_bridge/labextension/` | Build output of the full labextension bundle (gitignored) |
|
||||
| `ui-tests/` | Playwright + Galata integration tests (separate `package.json` with its own `jlpm install`) |
|
||||
| `jupyter-config/server-config/opencode_bridge.json` | Auto-enables the server extension on install |
|
||||
| `design.md` | Architecture, UI flow, and reference TS skeletons — source of truth for the design |
|
||||
| `.github/workflows/build.yml` | CI: `uv build` with Tsinghua PyPI mirror, then Gitea release + Bark notify |
|
||||
|
||||
The Python and JS package share the name `opencode_bridge` but live in different namespaces: the server extension is mounted at the URL prefix `opencode-bridge/...` (note the hyphen), while the Python/JS module name uses the underscore. Frontend → backend calls must hit `<baseUrl>/opencode-bridge/<endpoint>`.
|
||||
|
||||
## Common commands
|
||||
|
||||
All JS commands go through `jlpm` (JupyterLab's pinned yarn). The yarn setup uses **pnpm as the node linker** (see `.yarnrc.yml`) and disables `enableScripts`; do not run plain `npm install` on the root.
|
||||
|
||||
### Install / dev setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install --editable ".[dev,test]"
|
||||
jupyter-builder develop . --overwrite # link frontend
|
||||
jupyter server extension enable opencode_bridge # server ext must be enabled manually in dev
|
||||
jlpm install
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
jlpm build # tsc + jupyter-builder (dev), fast iteration
|
||||
jlpm build:prod # full clean prod build for distribution
|
||||
jlpm watch # tsc -w + jupyter-builder watch in parallel
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
jlpm test # jest unit tests (src/__tests__/*) with coverage
|
||||
pytest # python tests (opencode_bridge/tests/*) — uses pytest-jupyter
|
||||
# Integration (Playwright + Galata):
|
||||
(cd ui-tests && jlpm install && jlpm playwright install) # once
|
||||
jlpm build:prod # required before UI tests
|
||||
(cd ui-tests && jlpm playwright test) # run
|
||||
(cd ui-tests && jlpm playwright test -u) # update snapshots
|
||||
```
|
||||
|
||||
Jest config (`jest.config.js`) extends `@jupyterlab/testutils/lib/jest-config`; tests match `src/.*/.*.spec.ts[x]?$`. ESLint ignores `**/*.js`, `**/*.d.ts`, `tests`, `__tests__`, and `ui-tests` — TS files outside `ui-tests` are the only ones linted.
|
||||
|
||||
### Lint / format
|
||||
|
||||
```bash
|
||||
jlpm lint # stylelint + prettier + eslint (--fix variants)
|
||||
jlpm lint:check # CI variant, no --fix
|
||||
```
|
||||
|
||||
Prettier is configured for single quotes, no trailing commas, no arrow parens (see `package.json` overrides). Stylelint enforces kebab-case class selectors.
|
||||
|
||||
### Release
|
||||
|
||||
`RELEASE.md` documents the manual flow (`hatch version`, `jlpm clean:all`, `python -m build`). The CI workflow auto-builds and creates a Gitea release on `v*` tags, then pushes a Bark notification (`.github/scripts/bark-notify.sh`) — Bark failures are non-fatal by design.
|
||||
|
||||
## Conventions specific to this repo
|
||||
|
||||
- **Server extension is enabled in two places**: `jupyter-config/server-config/opencode_bridge.json` (auto, on pip install) and must be enabled manually in dev mode. The pytest config (`conftest.py`) re-enables it via the `jp_server_config` fixture.
|
||||
- **URL prefix vs. package name**: REST endpoints are mounted under `opencode-bridge/` (hyphen), even though the Python and JS packages are named `opencode_bridge` (underscore). `src/request.ts` hardcodes the hyphenated prefix; keep them in sync if you add endpoints.
|
||||
- **Endpoint handlers must decorate every verb method with `@tornado.web.authenticated`** (see `opencode_bridge/routes.py`); the existing `HelloRouteHandler` only implements `get` — if you add POST/PATCH/PUT/DELETE, decorate each one.
|
||||
- **Settings**: `schema/plugin.json` currently has no properties; the frontend loads it via `ISettingRegistry` in `src/index.ts`. When you add a setting, update `schema/plugin.json`, the `settingRegistry.load(...)` consumer, and document defaults.
|
||||
- **Dev install of UI tests is a separate subproject**: `ui-tests/package.json` is not part of the root workspace. Run `jlpm install` inside `ui-tests/` once; do not move those deps to the root.
|
||||
- **Generated artefacts**: `opencode_bridge/_version.py` is generated by hatch (gitignored) and `opencode_bridge/labextension/` is generated by the labextension build (gitignored). If you see them missing locally, you haven't run `jlpm build` yet.
|
||||
- **PyPI mirror**: `pyproject.toml` pins the Tsinghua mirror for `uv`/`pip`, and CI sets `UV_DEFAULT_INDEX` to the same. Other users on slow/non-China networks may need to override this.
|
||||
|
||||
## Where to start when changing…
|
||||
|
||||
- **Adding a new REST endpoint**: add the handler in `opencode_bridge/routes.py` (with `@tornado.web.authenticated` on every verb), register the route under `opencode-bridge/<name>` in `setup_route_handlers`, then call it from the frontend via the `requestAPI` helper in `src/request.ts`.
|
||||
- **Changing the cell UI / toolbar / inline prompt box**: `design.md` has working reference skeletons for `src/context_provider.ts` and `src/components/cell_toolbar.ts` — implement them; do not invent a new interaction model without updating the design doc.
|
||||
- **Adding a JupyterLab setting**: `schema/plugin.json` + consume via `ISettingRegistry` in `src/index.ts`.
|
||||
- **Packaging / release**: `RELEASE.md` + `.github/workflows/build.yml`; remember CI uses Gitea, not GitHub Releases.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# Contributing
|
||||
|
||||
## Development install
|
||||
|
||||
Note: You will need Node.js to build the extension package.
|
||||
You may install it from [nodejs.org](https://nodejs.org/en/download). We
|
||||
recommend using the latest LTS version of Node.js.
|
||||
|
||||
The `jlpm` command is JupyterLab's pinned version of
|
||||
[yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
|
||||
`yarn` or `npm` in lieu of `jlpm` below.
|
||||
|
||||
```bash
|
||||
# Clone the repo to your local environment
|
||||
# Change directory to the opencode_bridge directory
|
||||
|
||||
# Set up a virtual environment and install package in development mode
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --editable ".[dev,test]"
|
||||
|
||||
# Link your development version of the extension with JupyterLab
|
||||
jupyter-builder develop . --overwrite
|
||||
# Server extension must be manually installed in develop mode
|
||||
jupyter server extension enable opencode_bridge
|
||||
|
||||
# Rebuild extension Typescript source after making changes
|
||||
# IMPORTANT: Unlike the steps above which are performed only once, do this step
|
||||
# every time you make a change.
|
||||
jlpm build
|
||||
```
|
||||
|
||||
You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
|
||||
|
||||
```bash
|
||||
# Watch the source directory in one terminal, automatically rebuilding when needed
|
||||
jlpm watch
|
||||
# Run JupyterLab in another terminal
|
||||
jupyter lab
|
||||
```
|
||||
|
||||
With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt).
|
||||
|
||||
By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command:
|
||||
|
||||
```bash
|
||||
jupyter lab build --minimize=False
|
||||
```
|
||||
|
||||
## Development uninstall
|
||||
|
||||
```bash
|
||||
# Server extension must be manually disabled in develop mode
|
||||
jupyter server extension disable opencode_bridge
|
||||
pip uninstall opencode_bridge
|
||||
```
|
||||
|
||||
In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
|
||||
command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
|
||||
folder is located. Then you can remove the symlink named `opencode_bridge` within that folder.
|
||||
|
||||
## Testing the extension
|
||||
|
||||
### Server tests
|
||||
|
||||
This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
|
||||
|
||||
Install test dependencies (needed only once):
|
||||
|
||||
```sh
|
||||
pip install -e ".[test]"
|
||||
# Each time you install the Python package, you need to restore the front-end extension link
|
||||
jupyter-builder develop . --overwrite
|
||||
```
|
||||
|
||||
To execute them, run:
|
||||
|
||||
```sh
|
||||
pytest -vv -r ap --cov opencode_bridge
|
||||
```
|
||||
|
||||
#### Frontend tests
|
||||
|
||||
This extension is using [Jest](https://jestjs.io/) for JavaScript code testing.
|
||||
|
||||
To execute them, execute:
|
||||
|
||||
```sh
|
||||
jlpm
|
||||
jlpm test
|
||||
```
|
||||
|
||||
### Integration tests
|
||||
|
||||
This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests).
|
||||
More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab.
|
||||
|
||||
More information is provided within the [ui-tests](./ui-tests/README.md) README.
|
||||
|
||||
## Packaging the extension
|
||||
|
||||
See [RELEASE](RELEASE.md)
|
||||
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, taochen
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,49 @@
|
||||
# opencode_bridge
|
||||
|
||||
[](http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git/actions/workflows/build.yml)
|
||||
|
||||
A JupyterLab extension.
|
||||
|
||||
This extension is composed of a Python package named `opencode_bridge`
|
||||
for the server extension and a NPM package named `opencode_bridge`
|
||||
for the frontend extension.
|
||||
|
||||
## Requirements
|
||||
|
||||
- JupyterLab >= 4.0.0
|
||||
|
||||
## Install
|
||||
|
||||
To install the extension, execute:
|
||||
|
||||
```bash
|
||||
pip install opencode_bridge
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
To remove the extension, execute:
|
||||
|
||||
```bash
|
||||
pip uninstall opencode_bridge
|
||||
```
|
||||
|
||||
## Troubleshoot
|
||||
|
||||
If you are seeing the frontend extension, but it is not working, check
|
||||
that the server extension is enabled:
|
||||
|
||||
```bash
|
||||
jupyter server extension list
|
||||
```
|
||||
|
||||
If the server extension is installed and enabled, but you are not seeing
|
||||
the frontend extension, check the frontend extension is installed:
|
||||
|
||||
```bash
|
||||
jupyter labextension list
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
If you would like to contribute to this extension, please refer to the [Contributing Guide](CONTRIBUTING.md).
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Making a new release of opencode_bridge
|
||||
|
||||
The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser).
|
||||
|
||||
## Manual release
|
||||
|
||||
### Python package
|
||||
|
||||
This extension can be distributed as Python packages. All of the Python
|
||||
packaging instructions are in the `pyproject.toml` file to wrap your extension in a
|
||||
Python package. Before generating a package, you first need to install some tools:
|
||||
|
||||
```bash
|
||||
pip install build twine hatch
|
||||
```
|
||||
|
||||
Bump the version using `hatch`. By default this will create a tag.
|
||||
See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details.
|
||||
|
||||
```bash
|
||||
hatch version <new-version>
|
||||
```
|
||||
|
||||
Make sure to clean up all the development files before building the package:
|
||||
|
||||
```bash
|
||||
jlpm clean:all
|
||||
```
|
||||
|
||||
You could also clean up the local git repository:
|
||||
|
||||
```bash
|
||||
git clean -dfX
|
||||
```
|
||||
|
||||
To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do:
|
||||
|
||||
```bash
|
||||
python -m build
|
||||
```
|
||||
|
||||
> `python setup.py sdist bdist_wheel` is deprecated and will not work for this package.
|
||||
|
||||
Then to upload the package to PyPI, do:
|
||||
|
||||
```bash
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
### NPM package
|
||||
|
||||
To publish the frontend part of the extension as a NPM package, do:
|
||||
|
||||
```bash
|
||||
npm login
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
## Automated releases with the Jupyter Releaser
|
||||
|
||||
The extension repository should already be compatible with the Jupyter Releaser. But
|
||||
the GitHub repository and the package managers need to be properly set up. Please
|
||||
follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html).
|
||||
|
||||
For the release workflows in this repository, make sure GitHub is configured with:
|
||||
|
||||
- a `release` environment
|
||||
- an `APP_PRIVATE_KEY` secret
|
||||
- an `APP_ID` repository variable
|
||||
|
||||
When using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers), `NPM_TOKEN` is not required (and trusted publishing is recommended). Configure `NPM_TOKEN` only if you are publishing without trusted publishers.
|
||||
|
||||
Here is a summary of the steps to cut a new release:
|
||||
|
||||
- Go to the Actions panel
|
||||
- Run the "Step 1: Prep Release" workflow
|
||||
- Check the draft changelog
|
||||
- Run the "Step 2: Publish Release" workflow
|
||||
|
||||
> [!NOTE]
|
||||
> Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html)
|
||||
> for more information.
|
||||
|
||||
## Publishing to `conda-forge`
|
||||
|
||||
If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html
|
||||
|
||||
Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically.
|
||||
@@ -0,0 +1,105 @@
|
||||
# TODO
|
||||
|
||||
剩余开发任务。按依赖顺序切片,每步可独立验证。
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — Diff 面板 + Yjs 写入
|
||||
|
||||
**目标**:把 `OpenCodeCellFooter._handleResponse` 里的 `Notification.info(...)` 替换成真正的 Diff 面板,支持 Accept / Reject / 微调,写入走 Yjs 事务保护。
|
||||
|
||||
**未决问题先回答再写代码**:
|
||||
1. Accept 后是否自动执行 cell?(建议:否,让用户自己决定;slice 5 再说)
|
||||
2. 多个 cell 同时打开 Diff 面板时如何管理?(每个 footer 实例一个面板 vs 全局单例——建议每 cell 一个)
|
||||
3. Diff 视图用什么库?JL 自带 `IDiffModel`?还是简单 `diff-match-patch`?**零新依赖**优先
|
||||
|
||||
**任务**:
|
||||
- [ ] 在 `src/components/diff_panel.ts` 新建 `DiffPanel` widget(继承 `ReactWidget` 或纯 Lumino `Widget`)
|
||||
- [ ] 接受 → `cell.model.sharedModel.transact(() => cell.model.sharedModel.setSource(finalSource), 'opencode-bridge-accept')`
|
||||
- [ ] 拒绝 → 关闭面板
|
||||
- [ ] 微调 → 进入 textarea 编辑模式
|
||||
- [ ] 在 `_handleResponse` 里替换 `Notification.info` 调用,改为打开 `DiffPanel`
|
||||
- [ ] 新增 `src/__tests__/diff_panel.spec.ts` 测试 accept/reject 路径
|
||||
|
||||
**验收**:
|
||||
- `pytest opencode_bridge/tests/` 仍 32/32 pass
|
||||
- `jest src/__tests__/` 仍 23/23+ pass(+ diff_panel 新测试)
|
||||
- `tsc --noEmit` 干净
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — Inline prompt + Edit flow
|
||||
|
||||
**目标**:替换 `opencode_cell_footer.ts:127` 里的 `window.prompt(...)`,做内嵌 prompt 框,跟 design.md §2 一致。
|
||||
|
||||
**任务**:
|
||||
- [ ] 在 `opencode_cell_footer.ts` 加内嵌 prompt 状态机:`closed → open → streaming → done / error`
|
||||
- [ ] `🪄 编辑` 按钮点击 → 状态变 `open` → footer 下方滑出 textarea + 发送/取消按钮
|
||||
- [ ] 提交 → 状态变 `streaming` → 显示进度(用现有 loading 状态) → 完成后切到 `done` → Diff 面板出现
|
||||
- [ ] 取消 → 状态回到 `closed`
|
||||
- [ ] 多个 cell 同时 open 的 prompt 互相独立(每个 footer 自己的状态)
|
||||
|
||||
**验收**:
|
||||
- 所有现有测试 pass
|
||||
- 新增 1 个状态机集成测试
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Fix flow 完整闭环
|
||||
|
||||
**目标**:🐛 排错按钮从"灰着"变成真能用——点击后调用 `/edit` 带 `mode: 'fix'`,自动注入 traceback 到 context(后端 `_build_request_body` 已支持,前端只发就行)。
|
||||
|
||||
**任务**:
|
||||
- [ ] 验证 cell 报错后 → fix 按钮可点(已实现)
|
||||
- [ ] 点击 → 后端在 `_build_request_body` 里用 `error` 字段拼 traceback(已实现)
|
||||
- [ ] 流式回包后 → Diff 面板显示修复版代码(Slice 4 完成后顺带支持)
|
||||
- [ ] 验证 acceptance 后 `cell.model.sharedModel.setSource(...)` 后用户能直接重跑
|
||||
|
||||
**验收**:
|
||||
- `pytest opencode_bridge/tests/` 加一个 fix-mode 的 test(mock OpenCode client 验证 traceback 被拼进 parts)
|
||||
|
||||
---
|
||||
|
||||
## Slice 7 — 边界场景
|
||||
|
||||
**目标**:把所有 v0.2/v0.2.1/v0.2.2 设计里"未解决"和"边界"集中处理。
|
||||
|
||||
**任务**:
|
||||
- [ ] **OpenCode 不可达**:前端 `HealthHandler` 返回 503 → 3 个按钮全 disabled + Notification 显示
|
||||
- [ ] **401/403**:OpenCode 拒绝 Basic Auth → Notification 提示检查 `opencodeServerPassword` setting
|
||||
- [ ] **session 失效**:OpenCode 返回 404 → 后端 `EditHandler` 调用 `sm.invalidate(notebook_path)` → 下次 `get_or_create` 重建(**后端逻辑已在 v0.2.1 实现**,补前端 Notification 提示)
|
||||
- [ ] **空 notebook path**:`EditHandler` 400 + `{error: "missing context.notebookPath"}`(已实现,前端检查)
|
||||
- [ ] **长 prompt 截断**:超 4000 chars 的 prompt 提示用户
|
||||
- [ ] **provider/model 拼错**:`/config/providers` 返回 200 但 provider 不存在 → OpenCode 端报错 → 后端 `EditHandler` 502 + 错误信息透传
|
||||
- [ ] **取消进行中请求**:用户点 ✨ 之后能取消 → AbortController + `client.abort(session_id)` 调 OpenCode `/session/:id/abort`
|
||||
|
||||
**验收**:
|
||||
- 每个边界场景有一个对应的 test(pytest 或 jest,看发生在哪层)
|
||||
- Notification 文案一致
|
||||
|
||||
---
|
||||
|
||||
## v0.4+(不在本轮迭代范围内)
|
||||
|
||||
来自 design.md §5.6:
|
||||
- TTL reaper:notebook 关闭后 30 分钟 idle 自动 release session
|
||||
- 多 server 横向扩展:in-memory state → Redis / sticky session
|
||||
- 启动清理:Jupyter 重启后调 `GET /session` 列出 OpenCode 端孤儿删除
|
||||
- 并发 LLM 调用:`asyncio.Semaphore(per_session=1)` 显式控制
|
||||
- 流式 UX:v0.3 砍掉流式(同步版),v0.4 加 `prompt_async` + `/event` SSE 多路复用
|
||||
- Provider/model 选值 UX:升级为 JupyterLab command + QuickPick,替代 console.log
|
||||
|
||||
来自 design.md §6.2:
|
||||
- Settings UI dynamic enum:等 JupyterLab 支持后把 console.log 替换成真正的 settings 渲染器
|
||||
|
||||
---
|
||||
|
||||
## 当前统计
|
||||
|
||||
- **后端**:32 pytest tests(config + client + routes + session_manager)
|
||||
- **前端**:23 jest tests(types + cell_context + opencode_client + opencode_cell_footer)
|
||||
- **Schema 字段**:6(3 auth + 1 timeout + 2 provider/model)
|
||||
- **设计文档**:`design.md` v0.2.2,6 节
|
||||
- **CLAUDE.md** 已建
|
||||
|
||||
总进度:Slices 1–3.5 完成,4–7 待开发。
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@jupyterlab/testutils/lib/babel.config');
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
pytest_plugins = ("pytest_jupyter.jupyter_server", )
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jp_server_config(jp_server_config):
|
||||
return {"ServerApp": {"jpserver_extensions": {"opencode_bridge": True}}}
|
||||
@@ -0,0 +1,337 @@
|
||||
## 📑 1. 系统总体架构
|
||||
|
||||
采用 **前端 UI 注入 + 本地 Server Extension 透传** 的轻量双层架构:
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ JupyterLab / Notebook v7 (前端 TS) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Cell Component │ │
|
||||
│ │ [CodeEditor] ─── (挂载) ───► [Cell Action Toolbar] │ │
|
||||
│ │ │ (点击 🪄 智能编辑) │
|
||||
│ │ ▼ │
|
||||
│ │ [Inline Prompt Box] │ │
|
||||
│ └───────────────────────────────────┬────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ Context Provider 收集 │
|
||||
│ (Target Cell, Error, Neighbors) │
|
||||
│ │ │
|
||||
└───────────────────────────────────────┼────────────────────────────────┘
|
||||
│ REST / SSE Stream
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Jupyter Server Extension (后端 Python) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ OpenCode Bridge Handler │ │
|
||||
│ │ - 接收 Context & Prompt │ │
|
||||
│ │ - 拼装标准 OpenCode API Payload │ │
|
||||
│ │ - SSE 流式透传代理 (Stream Proxy) │ │
|
||||
│ └───────────────────────────────────┬────────────────────────────┘ │
|
||||
└───────────────────────────────────────┼────────────────────────────────┘
|
||||
│ Local Socket / HTTP
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ OpenCode Serve 进程 │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 2. 交互与UI流程设计
|
||||
|
||||
1. **悬浮 Toolbar(常驻)**:在每个 `CodeCell` 顶部渲染微型操作栏:
|
||||
* `✨ 优化`:自动提炼代码规范与性能。
|
||||
* `🐛 智能排错`:(**仅当 Cell 有 Error Output 时亮起**)自动抓取 Traceback 并修复。
|
||||
* `🪄 智能编辑`:展开内嵌 Prompt 输入框。
|
||||
|
||||
|
||||
2. **Inline Prompt 框(按需展开)**:
|
||||
* 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮)。
|
||||
|
||||
|
||||
3. **流式替换/Diff 预览**:
|
||||
* 点击发送后,支持**打字机实时替换**或在 Cell 下方出现 `[ Accept ]` / `[ Reject ]` 临时 Diff 比对面板。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔌 3. 接口契约设计 (API Contract)
|
||||
|
||||
### 接口:`POST /opencode/v1/generate` (支持 SSE 流式返回)
|
||||
|
||||
**Request Payload (前端 Context Provider 打包发给 Server Extension)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "custom_edit", // custom_edit | fix_error | optimize
|
||||
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
|
||||
"context": {
|
||||
"notebook_path": "analysis/demo.ipynb",
|
||||
"target_cell": {
|
||||
"index": 3,
|
||||
"code": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
|
||||
"error_output": "ModuleNotFoundError: No module named 'matplotlib'"
|
||||
},
|
||||
"surrounding_cells": [
|
||||
{ "index": 2, "code": "import pandas as pd" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Response (Server Extension 流式透传 OpenCode Serve 的 SSE 响应)**:
|
||||
|
||||
```text
|
||||
event: chunk
|
||||
data: {"text": "import plotly.express as px\n"}
|
||||
|
||||
event: chunk
|
||||
data: {"text": "fig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()"}
|
||||
|
||||
event: done
|
||||
data: {"status": "success"}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
|
||||
|
||||
以下是前端实现“Toolbar 注入”和“上下文抓取”的伪代码骨架,可直接作为开发参考:
|
||||
|
||||
### `src/context_provider.ts` (上下文感知模块)
|
||||
|
||||
```typescript
|
||||
import { Notebook, NotebookTracker } from '@jupyterlab/notebook';
|
||||
import { Cell } from '@jupyterlab/cells';
|
||||
|
||||
export interface CellContext {
|
||||
notebookPath: string;
|
||||
targetCode: string;
|
||||
errorOutput?: string;
|
||||
surroundingCells: string[];
|
||||
}
|
||||
|
||||
export function extractCellContext(tracker: NotebookTracker, targetCell: Cell): CellContext | null {
|
||||
const currentWidget = tracker.currentWidget;
|
||||
if (!currentWidget) return null;
|
||||
|
||||
const notebook: Notebook = currentWidget.content;
|
||||
const cells = notebook.widgets;
|
||||
const targetIndex = cells.findIndex(c => c === targetCell);
|
||||
|
||||
// 1. 抓取当前 Cell 的错误输出
|
||||
let errorMsg = '';
|
||||
if (targetCell.model.type === 'code') {
|
||||
const outputs = (targetCell.model as any).outputs;
|
||||
for (let i = 0; i < outputs.length; i++) {
|
||||
const out = outputs.get(i);
|
||||
if (out.type === 'error') {
|
||||
errorMsg += `${out.ename}: ${out.evalue}\n${(out.traceback || []).join('\n')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 抓取前一个 Cell 的代码(上下文)
|
||||
const prevCode = targetIndex > 0 ? cells[targetIndex - 1].model.sharedModel.getSource() : '';
|
||||
|
||||
return {
|
||||
notebookPath: currentWidget.context.path,
|
||||
targetCode: targetCell.model.sharedModel.getSource(),
|
||||
errorOutput: errorMsg,
|
||||
surroundingCells: [prevCode]
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### `src/components/cell_toolbar.ts` (工具栏与内嵌框注入)
|
||||
|
||||
```typescript
|
||||
import { Cell } from '@jupyterlab/cells';
|
||||
|
||||
export function injectOpenCodeActionBox(cell: Cell, onSendPrompt: (prompt: string) => void) {
|
||||
// 避免重复注入
|
||||
if (cell.node.querySelector('.opencode-toolbar')) return;
|
||||
|
||||
// 1. 创建 Toolbar 容器
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'opencode-toolbar';
|
||||
toolbar.innerHTML = `
|
||||
<button class="opencode-btn edit-btn">🪄 智能编辑</button>
|
||||
`;
|
||||
|
||||
// 2. 绑定展开事件
|
||||
toolbar.querySelector('.edit-btn')!.addEventListener('click', () => {
|
||||
let promptBox = cell.node.querySelector('.opencode-inline-box') as HTMLDivElement;
|
||||
if (promptBox) {
|
||||
promptBox.style.display = promptBox.style.display === 'none' ? 'block' : 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态创建 Inline Prompt 输入框
|
||||
promptBox = document.createElement('div');
|
||||
promptBox.className = 'opencode-inline-box';
|
||||
promptBox.innerHTML = `
|
||||
<textarea placeholder="输入提示词(例如:重构这段代码或添加注释...)"></textarea>
|
||||
<div class="actions">
|
||||
<button class="submit-btn">🚀 发送给 OpenCode</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
promptBox.querySelector('.submit-btn')!.addEventListener('click', () => {
|
||||
const input = promptBox.querySelector('textarea')!.value;
|
||||
if (input.trim()) onSendPrompt(input);
|
||||
});
|
||||
|
||||
cell.node.appendChild(promptBox);
|
||||
});
|
||||
|
||||
cell.node.insertBefore(toolbar, cell.node.firstChild);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 5. Session 管理策略(v0.2.1 修正)
|
||||
|
||||
> **2026-07-22 修正**:原 v0.2 设计 `EditHandler` 采用"每请求一个 session + finally 清理"——这是错的。OpenCode 的 session 是有状态的对话上下文,每个 notebook 必须复用同一个 session,否则用户先优化 cell1、再编辑 cell2 时,AI 看不到前一次的结果。
|
||||
|
||||
### 5.1 核心规则
|
||||
|
||||
| 维度 | 决策 |
|
||||
|---|---|
|
||||
| 映射关系 | `notebookPath`(1 个)→ OpenCode `sessionID`(1 个) |
|
||||
| 生命周期 | 首次 `EditHandler` 调用时 lazy create;显式 `DELETE /session` 才释放 |
|
||||
| 并发 | 同 notebook 的并发请求用 `asyncio.Lock` 串行化 session 创建;消息发送并发无锁(OpenCode 端自己排队) |
|
||||
| 失效恢复 | 当 `send_message_sync` 抛 `OpenCodeError` 且消息含 404/410 → 自动 invalidate 缓存,下次请求重新 create |
|
||||
| 清理 | **不做自动释放**。前端 notebook 关闭时**不**调 release(避免两个 tab 共享 session 时误杀)。用户可手动点"重置对话"触发 release |
|
||||
| 跨重启 | Jupyter server 重启 → in-memory map 丢失 → OpenCode 端孤立 session 接受泄漏(v0.4 加启动清理) |
|
||||
|
||||
### 5.2 SessionManager 接口
|
||||
|
||||
```python
|
||||
class SessionManager:
|
||||
def __init__(self, client_factory: Callable[[], OpenCodeClient]): ...
|
||||
|
||||
async def get_or_create(self, notebook_path: str) -> str:
|
||||
"""Return existing sessionID, or create one. Async-safe via per-path lock."""
|
||||
|
||||
async def release(self, notebook_path: str) -> bool:
|
||||
"""Delete session and remove from map. Returns True if session existed."""
|
||||
|
||||
def has_session(self, notebook_path: str) -> bool: ...
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
"""For debug: [{notebookPath, sessionId}, ...]"""
|
||||
```
|
||||
|
||||
**关键不变量**:
|
||||
- `get_or_create` 永远不返回 None 或抛 "not found"
|
||||
- 重复调用 `get_or_create(same_path)` → 同一 sessionID
|
||||
- `release(path)` 后再 `get_or_create(path)` → 新 sessionID
|
||||
|
||||
### 5.3 HTTP 端点
|
||||
|
||||
| Method | Path | 作用 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/opencode-bridge/sessions` | 列出所有 active session | debug 用 |
|
||||
| `DELETE` | `/opencode-bridge/session?notebook=<path>` | 显式释放指定 notebook 的 session | URL-encode path |
|
||||
|
||||
**为什么用 query param 而不是 path param**:
|
||||
- notebook path 可能含 `/`、`空格`、`中文`
|
||||
- `url_path_join` + regex 捕获在含 `/` 的路径上行为不可控
|
||||
- query string 是 RFC 3986 标准做法,tornado 自动 decode
|
||||
|
||||
### 5.4 EditHandler 改造
|
||||
|
||||
**改造前(错的)**:
|
||||
```
|
||||
create_session → send_message_sync → delete_session # 每个请求都这样
|
||||
```
|
||||
|
||||
**改造后(对的)**:
|
||||
```
|
||||
session_id = session_manager.get_or_create(notebook_path) # 首次创建,复用后续
|
||||
send_message_sync(session_id, ...) # 不管 session
|
||||
# 没有 delete — session 留着给下次
|
||||
```
|
||||
|
||||
### 5.5 错误恢复
|
||||
|
||||
`send_message_sync` 失败时:
|
||||
1. 检查 `OpenCodeError` 消息是否含 "404" 或 "session not found"
|
||||
2. 是 → `session_manager.invalidate(notebook_path)`,下次 `get_or_create` 会重新创建
|
||||
3. 否 → 透传错误给前端
|
||||
|
||||
v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
|
||||
|
||||
### 5.6 未解决问题(v0.4+)
|
||||
|
||||
1. **TTL reaper**:notebook 关闭后 session 永远不释放 → 改用 mtime 追踪 + 30 分钟 idle 自动 release
|
||||
2. **多 server 横向扩展**:in-memory state 不能 scale → 改成 Redis 或 sticky session
|
||||
3. **启动清理**:Jupyter 重启后调 `GET /session` 列出 OpenCode 端的孤儿,逐个 delete
|
||||
4. **并发 LLM 调用**:当前两次 `send_message_sync` 到同一 session 是串行(OpenCode 端排队)— 可加 `asyncio.Semaphore(per_session=1)` 显式控制
|
||||
|
||||
---
|
||||
|
||||
## 🎛 6. Provider/Model 选择(v0.2.2 新增)
|
||||
|
||||
> **2026-07-22**:用户要求能在 settings 里选 OpenCode 的 provider 和 model,可选值从 `GET /config/providers` 动态获取。
|
||||
|
||||
### 6.1 Schema 扩展
|
||||
|
||||
`schema/plugin.json` 加 2 个 string 字段:
|
||||
|
||||
```json
|
||||
"opencodeProvider": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Provider",
|
||||
"description": "Provider id (如 'anthropic' / 'openai')。可选项由启动时从 /opencode-bridge/providers 拉取并打到 console 列出。留空 = 用 OpenCode 默认。",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeModel": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Model",
|
||||
"description": "Model id (如 'claude-sonnet-4-20250514')。可选项见 console 日志或 /opencode-bridge/providers。留空 = 用 provider 默认。",
|
||||
"default": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 选值如何给用户
|
||||
|
||||
JupyterLab settings 是静态 JSON schema,**没有原生 dynamic enum**。v0.2.2 用最简方案:
|
||||
- 启动时 fetch `/opencode-bridge/providers` → 把 provider/model 列表 `console.log` 出来
|
||||
- 用户在 Settings Editor 里手填
|
||||
- 后续 v0.4 考虑用 QuickPick 命令做交互式选择
|
||||
|
||||
### 6.3 数据流
|
||||
|
||||
```
|
||||
Settings (JupyterLab)
|
||||
│
|
||||
├── opencodeProvider = "anthropic"
|
||||
└── opencodeModel = "claude-sonnet-4-20250514"
|
||||
│
|
||||
▼ setOpenCodeRuntime()
|
||||
Frontend (opencode_cell_footer.ts)
|
||||
│
|
||||
▼ build request body
|
||||
POST /opencode-bridge/edit
|
||||
{ mode, prompt, context, providerId, modelId }
|
||||
│
|
||||
▼ send_message_sync
|
||||
OpenCode Serve 接受 model = { providerID, modelID }
|
||||
```
|
||||
|
||||
### 6.4 不变量
|
||||
|
||||
- 留空字段("")→ 客户端不发 `providerId`/`modelId` → server 也不加 `model` 字段到 OpenCode 请求 → OpenCode 用默认
|
||||
- 用户在 settings 里设的值**不会自动回写到 OpenCode**——只是 request 级别的覆盖
|
||||
- 改 settings 立即生效(下次 edit 请求就用新值);不需要重启 Jupyter
|
||||
@@ -0,0 +1,68 @@
|
||||
import js from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import jupyterPlugin from '@jupyter/eslint-plugin';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'coverage',
|
||||
'**/*.js',
|
||||
'**/*.d.ts',
|
||||
'.venv',
|
||||
'tests',
|
||||
'**/__tests__',
|
||||
'ui-tests'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
jupyterPlugin.configs.recommended,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
plugins: {
|
||||
jupyter: jupyterPlugin
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2015,
|
||||
...globals.node
|
||||
},
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'interface',
|
||||
format: ['PascalCase'],
|
||||
custom: {
|
||||
regex: '^I[A-Z]',
|
||||
match: true
|
||||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'@typescript-eslint/quotes': [
|
||||
'error',
|
||||
'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
curly: ['error', 'all'],
|
||||
eqeqeq: 'error',
|
||||
'prefer-arrow-callback': 'error'
|
||||
}
|
||||
},
|
||||
prettierRecommended
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"packageManager": "python",
|
||||
"packageName": "opencode_bridge",
|
||||
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package opencode_bridge"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config');
|
||||
|
||||
const esModules = [
|
||||
'@codemirror',
|
||||
'@jupyter/ydoc',
|
||||
'@jupyterlab/',
|
||||
'lib0',
|
||||
'nanoid',
|
||||
'vscode-ws-jsonrpc',
|
||||
'y-protocols',
|
||||
'y-websocket',
|
||||
'yjs'
|
||||
].join('|');
|
||||
|
||||
const baseConfig = jestJupyterLab(__dirname);
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
automock: false,
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/.ipynb_checkpoints/*'
|
||||
],
|
||||
coverageReporters: ['lcov', 'text'],
|
||||
testRegex: 'src/.*/.*.spec.ts[x]?$',
|
||||
transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`]
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ServerApp": {
|
||||
"jpserver_extensions": {
|
||||
"opencode_bridge": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ImportError:
|
||||
# Fallback when using the package in dev mode without installing
|
||||
# in editable mode with pip. It is highly recommended to install
|
||||
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
||||
import warnings
|
||||
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
|
||||
__version__ = "dev"
|
||||
from .routes import setup_route_handlers
|
||||
|
||||
|
||||
def _jupyter_labextension_paths():
|
||||
return [{
|
||||
"src": "labextension",
|
||||
"dest": "opencode_bridge"
|
||||
}]
|
||||
|
||||
|
||||
def _jupyter_server_extension_points():
|
||||
return [{
|
||||
"module": "opencode_bridge"
|
||||
}]
|
||||
|
||||
|
||||
def _load_jupyter_server_extension(server_app):
|
||||
"""Registers the API handler to receive HTTP requests from the frontend extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server_app: jupyterlab.labapp.LabApp
|
||||
JupyterLab application instance
|
||||
"""
|
||||
setup_route_handlers(server_app.web_app)
|
||||
name = "opencode_bridge"
|
||||
server_app.log.info(f"Registered {name} server extension")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Configuration resolution for the opencode-bridge extension.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. jupyter settings dict (from schema/plugin.json)
|
||||
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD)
|
||||
3. Built-in defaults
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
|
||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
|
||||
|
||||
DEFAULT_URL = "http://127.0.0.1:4096"
|
||||
DEFAULT_USER = "opencode"
|
||||
DEFAULT_REQUEST_TIMEOUT = 120
|
||||
|
||||
|
||||
class OpenCodeConfig(NamedTuple):
|
||||
url: str
|
||||
user: str
|
||||
password: str
|
||||
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT
|
||||
|
||||
@property
|
||||
def auth(self) -> Optional[Tuple[str, str]]:
|
||||
"""Return (user, password) for HTTP Basic Auth, or None if no password set."""
|
||||
if not self.password:
|
||||
return None
|
||||
return (self.user, self.password)
|
||||
|
||||
|
||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
||||
"""Resolve OpenCode connection config from jupyter settings + env + defaults."""
|
||||
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
|
||||
|
||||
return OpenCodeConfig(
|
||||
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||
password=(
|
||||
bridge.get("opencodeServerPassword")
|
||||
or os.environ.get(ENV_PASSWORD)
|
||||
or ""
|
||||
),
|
||||
request_timeout_seconds=int(
|
||||
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT)
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
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")
|
||||
|
||||
|
||||
MODE_SYSTEM_PROMPTS = {
|
||||
"optimize": (
|
||||
"你是一个代码优化专家。请基于用户提供的代码上下文,"
|
||||
"返回只包含优化后代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
"fix": (
|
||||
"你是一个 Python 排错专家。用户给出了一段产生错误的代码和 traceback,"
|
||||
"请返回只包含修复后代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
"edit": (
|
||||
"你是一个代码编辑助手。基于用户的指令修改给定代码,"
|
||||
"返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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(mode: str, prompt: str, context: dict) -> dict:
|
||||
"""Build full request body for OpenCode POST /session/:id/message.
|
||||
|
||||
Returns dict with 'parts' (list) and 'system' (str) keys.
|
||||
"""
|
||||
system = MODE_SYSTEM_PROMPTS[mode]
|
||||
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 mode == "edit" and prompt:
|
||||
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
||||
|
||||
return {"parts": parts, "system": system}
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
"""Strip ```language ... ``` fences from LLM output."""
|
||||
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)
|
||||
mode = body["mode"]
|
||||
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(mode, 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"
|
||||
]
|
||||
final_source = _strip_code_fence("\n".join(text_parts).strip())
|
||||
|
||||
self.finish(json.dumps({
|
||||
"ok": True,
|
||||
"mode": mode,
|
||||
"finalSource": final_source,
|
||||
"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,
|
||||
}))
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Per-notebook session manager for OpenCode.
|
||||
|
||||
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
|
||||
Async-safe via per-path asyncio.Lock. No automatic cleanup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from .opencode_client import OpenCodeClient
|
||||
|
||||
log = logging.getLogger("opencode_bridge.session_manager")
|
||||
|
||||
ClientFactory = Callable[[], OpenCodeClient]
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Tracks one OpenCode session per notebook path.
|
||||
|
||||
Threading/async model:
|
||||
- Multiple coroutines may call get_or_create for the same notebook.
|
||||
- First call creates; subsequent calls return the same sessionID.
|
||||
- Per-notebook asyncio.Lock prevents double-create under concurrency.
|
||||
- Locks remain in the map; they may be needed again for the same path.
|
||||
"""
|
||||
|
||||
def __init__(self, client_factory: ClientFactory) -> None:
|
||||
self._client_factory = client_factory
|
||||
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
|
||||
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
|
||||
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
|
||||
|
||||
async def get_or_create(self, notebook_path: str) -> str:
|
||||
"""Return session ID for the notebook, creating one if needed.
|
||||
|
||||
Idempotent for the same path. Different paths get different sessions.
|
||||
"""
|
||||
existing = self._sessions.get(notebook_path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
||||
async with lock:
|
||||
existing = self._sessions.get(notebook_path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
client = self._client_factory()
|
||||
session = await client.create_session(
|
||||
title="jupyter:%s" % notebook_path
|
||||
)
|
||||
sid = session["id"]
|
||||
self._sessions[notebook_path] = sid
|
||||
self._titles[notebook_path] = notebook_path
|
||||
log.info("created opencode session %s for %s", sid, notebook_path)
|
||||
return sid
|
||||
|
||||
async def release(self, notebook_path: str) -> bool:
|
||||
"""Delete session and remove from map. Returns True if a session existed."""
|
||||
sid = self._sessions.pop(notebook_path, None)
|
||||
self._titles.pop(notebook_path, None)
|
||||
self._locks.pop(notebook_path, None)
|
||||
if sid is None:
|
||||
return False
|
||||
try:
|
||||
client = self._client_factory()
|
||||
return await client.delete_session(sid)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"failed to delete opencode session %s for %s", sid, notebook_path
|
||||
)
|
||||
return False
|
||||
|
||||
def invalidate(self, notebook_path: str) -> bool:
|
||||
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
|
||||
|
||||
Use this when an upstream error indicates the session is dead (e.g., 404).
|
||||
"""
|
||||
sid = self._sessions.pop(notebook_path, None)
|
||||
self._titles.pop(notebook_path, None)
|
||||
return sid is not None
|
||||
|
||||
def has_session(self, notebook_path: str) -> bool:
|
||||
return notebook_path in self._sessions
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
return [
|
||||
{"notebookPath": path, "sessionId": sid}
|
||||
for path, sid in sorted(self._sessions.items())
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Python unit tests for opencode_bridge."""
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for opencode_bridge.opencode_client."""
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
import tornado.httpclient
|
||||
import tornado.httputil
|
||||
|
||||
from opencode_bridge.config import OpenCodeConfig
|
||||
from opencode_bridge.opencode_client import OpenCodeClient, OpenCodeError
|
||||
|
||||
|
||||
class MockHTTPClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
self.responses: list[tuple[int, object]] = []
|
||||
|
||||
async def fetch(self, request, **kwargs) -> tornado.httpclient.HTTPResponse:
|
||||
self.calls.append({"request": request, "kwargs": kwargs})
|
||||
status, body = self.responses.pop(0)
|
||||
buffer = BytesIO(json.dumps(body).encode("utf-8"))
|
||||
return tornado.httpclient.HTTPResponse(
|
||||
request=request,
|
||||
code=status,
|
||||
headers=tornado.httputil.HTTPHeaders(),
|
||||
buffer=buffer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_config() -> OpenCodeConfig:
|
||||
return OpenCodeConfig(
|
||||
url="http://127.0.0.1:4096",
|
||||
user="opencode",
|
||||
password="",
|
||||
request_timeout_seconds=120,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(config: OpenCodeConfig, responses: list[tuple[int, object]]) -> tuple[OpenCodeClient, MockHTTPClient]:
|
||||
mock = MockHTTPClient()
|
||||
mock.responses = responses
|
||||
return OpenCodeClient(config, http_client=mock), mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_gets_global_health(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
|
||||
result = await client.health()
|
||||
assert result == {"status": "ok"}
|
||||
assert len(mock.calls) == 1
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/global/health"
|
||||
assert call["request"].method == "GET"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_posts_title(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"id": "s1"})])
|
||||
result = await client.create_session("foo")
|
||||
assert result == {"id": "s1"}
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session"
|
||||
assert call["request"].method == "POST"
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body == {"title": "foo"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_in_fetch_kwargs_when_password_set(base_config: OpenCodeConfig) -> None:
|
||||
config = base_config._replace(password="secret")
|
||||
client, mock = _make_client(config, [(200, {"status": "ok"})])
|
||||
await client.health()
|
||||
call = mock.calls[0]
|
||||
assert call["kwargs"].get("auth_username") == "opencode"
|
||||
assert call["kwargs"].get("auth_password") == "secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_not_in_fetch_kwargs_when_password_empty(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
|
||||
await client.health()
|
||||
call = mock.calls[0]
|
||||
assert "auth_username" not in call["kwargs"]
|
||||
assert "auth_password" not in call["kwargs"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_includes_model_when_provider_and_model_given(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||
parts = [{"type": "text", "text": "hello"}]
|
||||
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
|
||||
assert result == {"done": True}
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body["parts"] == parts
|
||||
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||
parts = [{"type": "text", "text": "hello"}]
|
||||
result = await client.send_message_sync("s1", parts)
|
||||
assert result == {"done": True}
|
||||
call = mock.calls[0]
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body["parts"] == parts
|
||||
assert "model" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||
"""system param is forwarded into the request body when not None."""
|
||||
config = OpenCodeConfig(url="http://x:1", user="u", password="")
|
||||
mock = MockHTTPClient()
|
||||
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||
client = OpenCodeClient(config, http_client=mock)
|
||||
|
||||
await client.send_message_sync("sid", [{"type": "text", "text": "hi"}], system="be brief")
|
||||
|
||||
assert len(mock.calls) == 1
|
||||
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
|
||||
assert body["system"] == "be brief"
|
||||
assert body["parts"] == [{"type": "text", "text": "hi"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, True), (404, False)])
|
||||
assert await client.delete_session("s1") is True
|
||||
assert await client.delete_session("s1") is False
|
||||
assert len(mock.calls) == 2
|
||||
assert mock.calls[0]["request"].method == "DELETE"
|
||||
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
||||
with pytest.raises(OpenCodeError) as exc_info:
|
||||
await client.health()
|
||||
assert "boom" in str(exc_info.value)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for opencode_bridge.config."""
|
||||
|
||||
from opencode_bridge.config import OpenCodeConfig, resolve_config
|
||||
|
||||
|
||||
def test_all_defaults() -> None:
|
||||
config = resolve_config({})
|
||||
assert config.url == "http://127.0.0.1:4096"
|
||||
assert config.user == "opencode"
|
||||
assert config.password == ""
|
||||
assert config.request_timeout_seconds == 120
|
||||
|
||||
|
||||
def test_env_url_overrides_default(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||
config = resolve_config({})
|
||||
assert config.url == "http://x:1"
|
||||
|
||||
|
||||
def test_jupyter_settings_override_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
|
||||
config = resolve_config(settings)
|
||||
assert config.url == "http://y:2"
|
||||
|
||||
|
||||
def test_all_env_vars(monkeypatch) -> None:
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
|
||||
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
|
||||
config = resolve_config({})
|
||||
assert config.url == "http://x:1"
|
||||
assert config.user == "u"
|
||||
assert config.password == "p"
|
||||
|
||||
|
||||
def test_auth_none_when_password_empty() -> None:
|
||||
config = resolve_config({})
|
||||
assert config.auth is None
|
||||
|
||||
|
||||
def test_auth_when_password_set() -> None:
|
||||
config = OpenCodeConfig(
|
||||
url="http://127.0.0.1:4096",
|
||||
user="opencode",
|
||||
password="secret",
|
||||
)
|
||||
assert config.auth == ("opencode", "secret")
|
||||
|
||||
|
||||
def test_request_timeout_from_settings() -> None:
|
||||
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}}
|
||||
config = resolve_config(settings)
|
||||
assert config.request_timeout_seconds == 300
|
||||
@@ -0,0 +1,198 @@
|
||||
import json
|
||||
|
||||
|
||||
class FakeOpenCodeClient:
|
||||
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.session_id = "fake-session-123"
|
||||
self.health_response = {"healthy": True, "version": "0.1.0"}
|
||||
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
|
||||
self.message_response = {
|
||||
"info": {"id": "msg-1"},
|
||||
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
||||
}
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
return "http://fake-opencode"
|
||||
|
||||
async def health(self):
|
||||
self.calls.append(("health",))
|
||||
return self.health_response
|
||||
|
||||
async def list_providers(self):
|
||||
self.calls.append(("list_providers",))
|
||||
return self.providers_response
|
||||
|
||||
async def create_session(self, title):
|
||||
self.calls.append(("create_session", title))
|
||||
return {"id": self.session_id, "title": title}
|
||||
|
||||
async def send_message_sync(self, session_id, parts, provider_id=None, model_id=None, system=None):
|
||||
self.calls.append(("send_message_sync", session_id, parts, system))
|
||||
return self.message_response
|
||||
|
||||
async def delete_session(self, session_id):
|
||||
self.calls.append(("delete_session", session_id))
|
||||
return True
|
||||
|
||||
|
||||
class FakeSessionManager:
|
||||
"""Drop-in replacement for SessionManager with recording."""
|
||||
|
||||
def __init__(self, session_id: str = "fake-session-123") -> None:
|
||||
self._session_id = session_id
|
||||
self.calls: list = []
|
||||
|
||||
async def get_or_create(self, notebook_path: str) -> str:
|
||||
self.calls.append(("get_or_create", notebook_path))
|
||||
return self._session_id
|
||||
|
||||
async def release(self, notebook_path: str) -> bool:
|
||||
self.calls.append(("release", notebook_path))
|
||||
return True
|
||||
|
||||
def list_sessions(self) -> list:
|
||||
return [
|
||||
{"notebookPath": path, "sessionId": sid}
|
||||
for path, sid in sorted(self._sessions.items())
|
||||
]
|
||||
|
||||
def invalidate(self, notebook_path: str) -> bool:
|
||||
self.calls.append(("invalidate", notebook_path))
|
||||
return True
|
||||
|
||||
|
||||
async def test_hello(jp_fetch):
|
||||
# When
|
||||
response = await jp_fetch("opencode-bridge", "hello")
|
||||
|
||||
# Then
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"data": (
|
||||
"Hello, world!"
|
||||
" This is the '/opencode-bridge/hello' endpoint."
|
||||
" Try visiting me in your browser!"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def test_health_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch("opencode-bridge", "health")
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
assert payload["version"] == "0.1.0"
|
||||
assert ("health",) in fake.calls
|
||||
|
||||
|
||||
async def test_providers_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch("opencode-bridge", "providers")
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert "providers" in payload
|
||||
assert payload["providers"][0]["id"] == "anthropic"
|
||||
assert ("list_providers",) in fake.calls
|
||||
|
||||
|
||||
async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
body = json.dumps({
|
||||
"mode": "edit",
|
||||
"prompt": "Add type hints",
|
||||
"context": {
|
||||
"notebookPath": "test.ipynb",
|
||||
"cellId": "cell-1",
|
||||
"language": "python",
|
||||
"cellIndex": 0,
|
||||
"totalCells": 1,
|
||||
"source": "def foo(): return 42\n",
|
||||
"previousCode": None,
|
||||
"error": None,
|
||||
},
|
||||
})
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "edit",
|
||||
method="POST",
|
||||
body=body,
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
assert payload["finalSource"] == "def foo():\n return 42"
|
||||
assert payload["sessionId"] == "fake-session-123"
|
||||
assert payload["notebookPath"] == "test.ipynb"
|
||||
|
||||
# Session manager was used, not direct create/delete on client
|
||||
call_names = [c[0] for c in fake.calls]
|
||||
assert "create_session" not in call_names
|
||||
assert "delete_session" not in call_names
|
||||
assert "send_message_sync" in call_names
|
||||
|
||||
# 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]
|
||||
|
||||
# SessionManager.get_or_create was called with the notebook path
|
||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||
assert sm_call_names == ["get_or_create"]
|
||||
assert fake_sm.calls[0][1] == "test.ipynb"
|
||||
|
||||
|
||||
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||
fake_sm = FakeSessionManager()
|
||||
fake_sm._sessions = {
|
||||
"foo.ipynb": "sid-1",
|
||||
"bar.ipynb": "sid-2",
|
||||
} # direct injection
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch("opencode-bridge", "sessions")
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert "sessions" in payload
|
||||
paths = {s["notebookPath"] for s in payload["sessions"]}
|
||||
assert paths == {"foo.ipynb", "bar.ipynb"}
|
||||
|
||||
|
||||
async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager()
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "session",
|
||||
method="DELETE",
|
||||
params={"notebook": "foo.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
assert payload["notebookPath"] == "foo.ipynb"
|
||||
assert payload["deleted"] is True
|
||||
assert ("release", "foo.ipynb") in fake_sm.calls
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for SessionManager — no Jupyter server fixture."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from opencode_bridge.session_manager import SessionManager
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Mimics OpenCodeClient just enough for SessionManager."""
|
||||
def __init__(self, session_id: str = "sid-from-fake") -> None:
|
||||
self._session_id = session_id
|
||||
self.calls: list = []
|
||||
|
||||
async def create_session(self, title: str) -> dict:
|
||||
self.calls.append(("create_session", title))
|
||||
return {"id": self._session_id, "title": title}
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
self.calls.append(("delete_session", session_id))
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_first_call_creates_session():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
sid = await sm.get_or_create("foo.ipynb")
|
||||
assert sid == "sid-from-fake"
|
||||
assert sm.has_session("foo.ipynb")
|
||||
assert client.calls == [("create_session", "jupyter:foo.ipynb")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_second_call_returns_same_sid():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
sid1 = await sm.get_or_create("foo.ipynb")
|
||||
sid2 = await sm.get_or_create("foo.ipynb")
|
||||
assert sid1 == sid2
|
||||
# Only ONE create_session call total
|
||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
||||
assert len(create_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_notebooks_get_different_sessions():
|
||||
client1 = FakeClient(session_id="sid-1")
|
||||
client2 = FakeClient(session_id="sid-2")
|
||||
factories = [client1, client2]
|
||||
sm = SessionManager(lambda: factories.pop(0) if factories else client1)
|
||||
sid1 = await sm.get_or_create("foo.ipynb")
|
||||
sid2 = await sm.get_or_create("bar.ipynb")
|
||||
assert sid1 == "sid-1"
|
||||
assert sid2 == "sid-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_returns_true_and_clears_session():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
await sm.get_or_create("foo.ipynb")
|
||||
deleted = await sm.release("foo.ipynb")
|
||||
assert deleted is True
|
||||
assert not sm.has_session("foo.ipynb")
|
||||
assert ("delete_session", "sid-from-fake") in client.calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_returns_false_when_no_session():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
deleted = await sm.release("never-existed.ipynb")
|
||||
assert deleted is False
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_after_release_creates_new_session():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
sid1 = await sm.get_or_create("foo.ipynb")
|
||||
await sm.release("foo.ipynb")
|
||||
sid2 = await sm.get_or_create("foo.ipynb")
|
||||
# Same fake client returns same ID, but two create_session calls happened
|
||||
assert sid1 == sid2
|
||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
||||
assert len(create_calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_drops_cached_sid_without_calling_opencode():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
await sm.get_or_create("foo.ipynb")
|
||||
calls_before = len(client.calls)
|
||||
removed = sm.invalidate("foo.ipynb")
|
||||
assert removed is True
|
||||
assert not sm.has_session("foo.ipynb")
|
||||
# No additional calls to client
|
||||
assert len(client.calls) == calls_before
|
||||
|
||||
|
||||
def test_list_sessions_empty():
|
||||
sm = SessionManager(lambda: FakeClient())
|
||||
assert sm.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_returns_all_mappings():
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
await sm.get_or_create("a.ipynb")
|
||||
# Manually inject a second to test listing
|
||||
sm._sessions["b.ipynb"] = "sid-b"
|
||||
listing = sm.list_sessions()
|
||||
paths = {s["notebookPath"] for s in listing}
|
||||
assert paths == {"a.ipynb", "b.ipynb"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_get_or_create_does_not_double_create():
|
||||
"""Two concurrent calls for the same path must share one session."""
|
||||
import asyncio
|
||||
|
||||
client = FakeClient()
|
||||
sm = SessionManager(lambda: client)
|
||||
sids = await asyncio.gather(
|
||||
sm.get_or_create("foo.ipynb"),
|
||||
sm.get_or_create("foo.ipynb"),
|
||||
sm.get_or_create("foo.ipynb"),
|
||||
)
|
||||
assert sids[0] == sids[1] == sids[2]
|
||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
||||
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"name": "opencode_bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "A JupyterLab extension.",
|
||||
"keywords": [
|
||||
"jupyter",
|
||||
"jupyterlab",
|
||||
"jupyterlab-extension"
|
||||
],
|
||||
"homepage": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git",
|
||||
"bugs": {
|
||||
"url": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git/issues"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "taochen",
|
||||
"files": [
|
||||
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
|
||||
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
|
||||
"src/**/*.{ts,tsx}",
|
||||
"schema/*.json"
|
||||
],
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"style": "style/index.css",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "jlpm build:lib && jlpm build:labextension:dev",
|
||||
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
|
||||
"build:labextension": "jupyter-builder build .",
|
||||
"build:labextension:dev": "jupyter-builder build --development True .",
|
||||
"build:lib": "tsc --sourceMap",
|
||||
"build:lib:prod": "tsc",
|
||||
"clean": "jlpm clean:lib",
|
||||
"clean:lib": "rimraf lib tsconfig.tsbuildinfo",
|
||||
"clean:lintcache": "rimraf .eslintcache .stylelintcache",
|
||||
"clean:labextension": "rimraf opencode_bridge/labextension opencode_bridge/_version.py",
|
||||
"clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
|
||||
"eslint": "jlpm eslint:check --fix",
|
||||
"eslint:check": "eslint . --cache",
|
||||
"install:extension": "jlpm build",
|
||||
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
|
||||
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
|
||||
"prettier": "jlpm prettier:base --write --list-different",
|
||||
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
|
||||
"prettier:check": "jlpm prettier:base --check",
|
||||
"stylelint": "jlpm stylelint:check --fix",
|
||||
"stylelint:check": "stylelint --cache \"style/**/*.css\"",
|
||||
"test": "jest --coverage",
|
||||
"watch": "run-p watch:src watch:labextension",
|
||||
"watch:src": "tsc -w --sourceMap",
|
||||
"watch:labextension": "jupyter-builder watch ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@jupyterlab/application": "^4.0.0",
|
||||
"@jupyterlab/apputils": "^4.0.0",
|
||||
"@jupyterlab/cells": "^4.0.0",
|
||||
"@jupyterlab/coreutils": "^6.0.0",
|
||||
"@jupyterlab/notebook": "^4.0.0",
|
||||
"@jupyterlab/services": "^7.0.0",
|
||||
"@jupyterlab/settingregistry": "^4.0.0",
|
||||
"@lumino/widgets": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@jupyter/builder": "^1.0.0",
|
||||
"@jupyter/eslint-plugin": "^0.0.5",
|
||||
"@jupyterlab/core-meta": "^4.6.0-beta.0",
|
||||
"@jupyterlab/testing": "^4.6.1",
|
||||
"@jupyterlab/testutils": "^4.0.0",
|
||||
"@module-federation/runtime-tools": "^2.0.0",
|
||||
"@types/jest": "^29.2.0",
|
||||
"@types/json-schema": "^7.0.11",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-addons-linked-state-mixin": "^0.14.22",
|
||||
"css-loader": "^6.7.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"globals": "^15.0.0",
|
||||
"jest": "^29.2.0",
|
||||
"jest-junit": "^17.0.0",
|
||||
"mkdirp": "^1.0.3",
|
||||
"npm-run-all2": "^7.0.1",
|
||||
"prettier": "^3.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"source-map-loader": "^1.0.2",
|
||||
"style-loader": "^3.3.1",
|
||||
"stylelint": "^15.10.1",
|
||||
"stylelint-config-recommended": "^13.0.0",
|
||||
"stylelint-config-standard": "^34.0.0",
|
||||
"stylelint-csstree-validator": "^3.0.0",
|
||||
"stylelint-prettier": "^4.0.0",
|
||||
"ts-jest": "^29.4.11",
|
||||
"typescript": "~5.5.4",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"yjs": "^13.5.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"lib0": "0.2.111",
|
||||
"webpack": "5.106.0"
|
||||
},
|
||||
"sideEffects": [
|
||||
"style/*.css",
|
||||
"style/index.js"
|
||||
],
|
||||
"styleModule": "style/index.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"jupyterlab": {
|
||||
"discovery": {
|
||||
"server": {
|
||||
"managers": [
|
||||
"pip"
|
||||
],
|
||||
"base": {
|
||||
"name": "opencode_bridge"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extension": true,
|
||||
"outputDir": "opencode_bridge/labextension",
|
||||
"schemaDir": "schema"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "package.json",
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": [
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-standard",
|
||||
"stylelint-prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"stylelint-csstree-validator"
|
||||
],
|
||||
"rules": {
|
||||
"csstree/validator": true,
|
||||
"property-no-vendor-prefix": null,
|
||||
"selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
|
||||
"selector-no-vendor-prefix": null,
|
||||
"value-no-vendor-prefix": null
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9965
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.5.0", "hatch-nodejs-version>=0.3.2", "jupyter-builder>=1.0.0,<2"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "opencode_bridge"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Framework :: Jupyter",
|
||||
"Framework :: Jupyter :: JupyterLab",
|
||||
"Framework :: Jupyter :: JupyterLab :: 4",
|
||||
"Framework :: Jupyter :: JupyterLab :: Extensions",
|
||||
"Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"jupyter_server>=2.4.0,<3",
|
||||
"jupyterlab>=4.6.2",
|
||||
]
|
||||
dynamic = ["version", "description", "authors", "urls", "keywords"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"jupyterlab>=4",
|
||||
"jupyter-builder>=1.0.0",
|
||||
]
|
||||
test = [
|
||||
"coverage",
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-cov",
|
||||
"pytest-jupyter[server]>=0.6.0"
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||
default = true
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "nodejs"
|
||||
|
||||
[tool.hatch.metadata.hooks.nodejs]
|
||||
fields = ["description", "authors", "urls", "keywords"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
artifacts = ["opencode_bridge/labextension"]
|
||||
exclude = [".github", "binder"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.shared-data]
|
||||
"opencode_bridge/labextension" = "share/jupyter/labextensions/opencode_bridge"
|
||||
"install.json" = "share/jupyter/labextensions/opencode_bridge/install.json"
|
||||
"jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d"
|
||||
|
||||
[tool.hatch.build.hooks.version]
|
||||
path = "opencode_bridge/_version.py"
|
||||
|
||||
[tool.hatch.build.hooks.jupyter-builder]
|
||||
dependencies = ["hatch-jupyter-builder>=0.5"]
|
||||
build-function = "hatch_jupyter_builder.npm_builder"
|
||||
ensured-targets = [
|
||||
"opencode_bridge/labextension/static/style.js",
|
||||
"opencode_bridge/labextension/package.json",
|
||||
]
|
||||
skip-if-exists = ["opencode_bridge/labextension/static/style.js"]
|
||||
|
||||
[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
|
||||
build_cmd = "build:prod"
|
||||
npm = ["jlpm"]
|
||||
|
||||
[tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs]
|
||||
build_cmd = "install:extension"
|
||||
npm = ["jlpm"]
|
||||
source_dir = "src"
|
||||
build_dir = "opencode_bridge/labextension"
|
||||
|
||||
[tool.jupyter-releaser.options]
|
||||
version_cmd = "hatch version"
|
||||
|
||||
[tool.jupyter-releaser.hooks]
|
||||
before-build-npm = [
|
||||
"python -m pip install 'jupyter-builder>=1.0.0,<2'",
|
||||
"jlpm",
|
||||
"jlpm build:prod"
|
||||
]
|
||||
before-build-python = ["jlpm clean:all"]
|
||||
|
||||
[tool.check-wheel-contents]
|
||||
ignore = ["W002"]
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"jupyter.lab.shortcuts": [],
|
||||
"title": "opencode_bridge",
|
||||
"description": "opencode_bridge settings.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opencodeServerUrl": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server URL",
|
||||
"description": "opencode serve 监听的 HTTP 地址。覆盖 OPENCODE_BRIDGE_URL 环境变量。",
|
||||
"default": "http://127.0.0.1:4096"
|
||||
},
|
||||
"opencodeServerUser": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server 用户名",
|
||||
"description": "HTTP Basic Auth 用户名。默认 'opencode'。可被 OPENCODE_BRIDGE_USER 环境变量覆盖。",
|
||||
"default": "opencode"
|
||||
},
|
||||
"opencodeServerPassword": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Server 密码",
|
||||
"description": "HTTP Basic Auth 密码。空字符串表示无认证。可被 OPENCODE_BRIDGE_PASSWORD 环境变量覆盖。",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeProvider": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Provider",
|
||||
"description": "Provider id (e.g. 'anthropic' or 'openai'). Get available values from the browser console after activating this extension (logged on startup), or hit GET /opencode-bridge/providers directly. Leave empty to use OpenCode's default.",
|
||||
"default": ""
|
||||
},
|
||||
"opencodeModel": {
|
||||
"type": "string",
|
||||
"title": "OpenCode Model",
|
||||
"description": "Model id (e.g. 'claude-sonnet-4-20250514'). Get available values from the browser console after activation. Leave empty to use the provider's default.",
|
||||
"default": ""
|
||||
},
|
||||
"requestTimeoutSeconds": {
|
||||
"type": "integer",
|
||||
"title": "请求超时(秒)",
|
||||
"default": 120,
|
||||
"minimum": 5,
|
||||
"maximum": 600
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_OPENCODE_SETTINGS, readOpenCodeSettings } from '../types';
|
||||
|
||||
describe('opencode_bridge types', () => {
|
||||
it('returns defaults when given empty composite', () => {
|
||||
const s = readOpenCodeSettings({});
|
||||
expect(s).toEqual(DEFAULT_OPENCODE_SETTINGS);
|
||||
expect(s.opencodeProvider).toBe('');
|
||||
expect(s.opencodeModel).toBe('');
|
||||
});
|
||||
|
||||
it('overrides defaults with user values', () => {
|
||||
const s = readOpenCodeSettings({
|
||||
opencodeServerUrl: 'http://x:1',
|
||||
opencodeProvider: 'anthropic',
|
||||
opencodeModel: 'claude-sonnet-4-20250514'
|
||||
});
|
||||
expect(s.opencodeServerUrl).toBe('http://x:1');
|
||||
expect(s.opencodeProvider).toBe('anthropic');
|
||||
expect(s.opencodeModel).toBe('claude-sonnet-4-20250514');
|
||||
expect(s.opencodeServerUser).toBe(
|
||||
DEFAULT_OPENCODE_SETTINGS.opencodeServerUser
|
||||
);
|
||||
});
|
||||
|
||||
it('treats empty string provider/model as "use default"', () => {
|
||||
const s = readOpenCodeSettings({
|
||||
opencodeProvider: '',
|
||||
opencodeModel: ''
|
||||
});
|
||||
expect(s.opencodeProvider).toBe('');
|
||||
expect(s.opencodeModel).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Unit tests for OpenCodeCellFooter DOM rendering and button disabled states.
|
||||
*
|
||||
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
||||
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
|
||||
* transform the hoisted @jupyterlab/* packages out of the box).
|
||||
*/
|
||||
|
||||
// Mock the heavy JupyterLab modules BEFORE importing the component.
|
||||
// (jest.mock is hoisted by Jest, but listing it before imports is clearer.)
|
||||
jest.mock('@jupyterlab/cells', () => ({
|
||||
CodeCell: class CodeCell {}
|
||||
}));
|
||||
|
||||
jest.mock('@jupyterlab/apputils', () => ({
|
||||
Notification: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
warning: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock('@lumino/widgets', () => {
|
||||
class Widget {
|
||||
public node: HTMLElement = document.createElement('div');
|
||||
public addClass(cls: string): void {
|
||||
this.node.classList.add(cls);
|
||||
}
|
||||
public id: string = '';
|
||||
public parent: Widget | null = null;
|
||||
protected onAfterAttach(_msg: unknown): void {
|
||||
/* overridden by subclass */
|
||||
}
|
||||
protected onBeforeDetach(_msg: unknown): void {
|
||||
/* overridden by subclass */
|
||||
}
|
||||
}
|
||||
return { Widget };
|
||||
});
|
||||
|
||||
import { OpenCodeCellFooter } from '../components/opencode_cell_footer';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
|
||||
function makeFakeOutputs(items: any[]): any {
|
||||
return {
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
get(i: number) {
|
||||
return items[i];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeCell(
|
||||
source: string,
|
||||
errorOutputs: any[] = [],
|
||||
notebookPath = 'foo.ipynb',
|
||||
cellIndex = 0,
|
||||
totalCells = 1
|
||||
): CodeCell {
|
||||
const model: any = {
|
||||
id: 'cell-test',
|
||||
type: 'code',
|
||||
sharedModel: {
|
||||
getSource: () => source
|
||||
},
|
||||
outputs: makeFakeOutputs(errorOutputs),
|
||||
// Signal stubs so the component can connect/disconnect without crashing.
|
||||
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
||||
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
||||
};
|
||||
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||
(cell as any).model = model;
|
||||
|
||||
// Build a fake parent chain: cell -> notebook -> panel.
|
||||
const notebook: any = { widgets: [] as CodeCell[] };
|
||||
const panel: any = {
|
||||
context: { path: notebookPath },
|
||||
content: notebook
|
||||
};
|
||||
for (let i = 0; i < cellIndex; i++) {
|
||||
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
|
||||
}
|
||||
notebook.widgets.push(cell);
|
||||
for (let i = cellIndex + 1; i < totalCells; i++) {
|
||||
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
|
||||
}
|
||||
|
||||
// The footer's helper walks `cell.parent` looking for a widget with
|
||||
// `context` + `content.widgets`. The cell's grandparent (notebook) is what
|
||||
// matches in real JupyterLab. We mirror that here.
|
||||
Object.defineProperty(cell, 'parent', { value: notebook, configurable: true });
|
||||
Object.defineProperty(notebook, 'parent', { value: panel, configurable: true });
|
||||
|
||||
return cell as CodeCell;
|
||||
}
|
||||
|
||||
describe('OpenCodeCellFooter', () => {
|
||||
it('renders 3 buttons', () => {
|
||||
const footer = new OpenCodeCellFooter();
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
expect(btns.length).toBe(3);
|
||||
expect(btns[0].textContent).toBe('✨ 优化');
|
||||
expect(btns[1].textContent).toBe('🐛 排错');
|
||||
expect(btns[2].textContent).toBe('🪄 编辑');
|
||||
});
|
||||
|
||||
it('disables all buttons when no cell is attached', () => {
|
||||
const footer = new OpenCodeCellFooter();
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
btns.forEach((b: HTMLButtonElement) => {
|
||||
expect(b.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('enables optimize and edit when a cell is attached; fix stays disabled without an error', () => {
|
||||
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
|
||||
const footer = new OpenCodeCellFooter();
|
||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
||||
(footer as any).onAfterAttach({} as any);
|
||||
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
const optimize = btns[0] as HTMLButtonElement;
|
||||
const fix = btns[1] as HTMLButtonElement;
|
||||
const edit = btns[2] as HTMLButtonElement;
|
||||
expect(optimize.disabled).toBe(false);
|
||||
expect(fix.disabled).toBe(true);
|
||||
expect(edit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the fix button when the attached cell has an error output', () => {
|
||||
const cell = makeFakeCell(
|
||||
'1/0',
|
||||
[
|
||||
{
|
||||
type: 'error',
|
||||
ename: 'ZeroDivisionError',
|
||||
evalue: 'div by zero',
|
||||
traceback: []
|
||||
}
|
||||
],
|
||||
'foo.ipynb',
|
||||
0,
|
||||
1
|
||||
);
|
||||
const footer = new OpenCodeCellFooter();
|
||||
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
|
||||
(footer as any).onAfterAttach({} as any);
|
||||
|
||||
const btns = footer.node.querySelectorAll('button');
|
||||
const fix = btns[1] as HTMLButtonElement;
|
||||
expect(fix.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
|
||||
import type { OpenCodeRequest } from '../types';
|
||||
|
||||
jest.mock('@jupyterlab/services', () => {
|
||||
const actual = jest.requireActual('@jupyterlab/services');
|
||||
return {
|
||||
...actual,
|
||||
ServerConnection: {
|
||||
...actual.ServerConnection,
|
||||
makeRequest: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockedMakeRequest = ServerConnection.makeRequest as jest.MockedFunction<
|
||||
typeof ServerConnection.makeRequest
|
||||
>;
|
||||
|
||||
function mockServerSettings(): ServerConnection.ISettings {
|
||||
return {
|
||||
baseUrl: 'http://localhost:8888',
|
||||
wsUrl: 'ws://localhost:8888',
|
||||
token: 'test',
|
||||
init: { headers: { 'X-Test': '1' } },
|
||||
fetch: {} as unknown as typeof fetch,
|
||||
RequestClass: {} as unknown as typeof Request,
|
||||
Headers: {} as unknown as typeof Headers,
|
||||
appendToken: false,
|
||||
pageUrl: '',
|
||||
settings: {} as unknown as ServerConnection.ISettings,
|
||||
displayName: 'JupyterLab',
|
||||
handleError: () => undefined,
|
||||
requestHeaders: {},
|
||||
wsHeaders: {},
|
||||
userSettings: {},
|
||||
} as unknown as ServerConnection.ISettings;
|
||||
}
|
||||
|
||||
function mockResponse(overrides: {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
text: string;
|
||||
}): Response {
|
||||
return {
|
||||
ok: overrides.ok,
|
||||
status: overrides.status,
|
||||
statusText: overrides.statusText,
|
||||
text: async () => overrides.text,
|
||||
headers: {} as unknown as Headers,
|
||||
json: async () => JSON.parse(overrides.text),
|
||||
url: '',
|
||||
redirected: false,
|
||||
type: 'basic',
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
clone: () => mockResponse(overrides),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const sampleRequest: OpenCodeRequest = {
|
||||
mode: 'edit',
|
||||
prompt: 'add type hints',
|
||||
context: {
|
||||
notebookPath: 'foo.ipynb',
|
||||
cellId: 'cell-1',
|
||||
language: 'python',
|
||||
cellIndex: 0,
|
||||
totalCells: 1,
|
||||
source: 'def foo(x): return x',
|
||||
previousCode: null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('callOpenCodeEdit', () => {
|
||||
beforeEach(() => {
|
||||
mockedMakeRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
|
||||
const respBody = {
|
||||
ok: true,
|
||||
mode: 'edit',
|
||||
finalSource: 'def foo(x: int) -> int: return x',
|
||||
sessionId: 'sid',
|
||||
notebookPath: 'foo.ipynb',
|
||||
};
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({ ok: true, status: 200, statusText: 'OK', text: JSON.stringify(respBody) })
|
||||
);
|
||||
|
||||
const settings = mockServerSettings();
|
||||
const resp = await callOpenCodeEdit(sampleRequest, settings);
|
||||
|
||||
expect(mockedMakeRequest).toHaveBeenCalledWith(
|
||||
'http://localhost:8888/opencode-bridge/edit',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(sampleRequest),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
settings
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.finalSource).toContain('int');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns failure response when server returns ok: false', async () => {
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
text: JSON.stringify({ ok: false, error: 'opencode down' }),
|
||||
})
|
||||
);
|
||||
|
||||
const resp = await callOpenCodeEdit(sampleRequest, mockServerSettings());
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error).toBe('opencode down');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on network error', async () => {
|
||||
mockedMakeRequest.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(
|
||||
callOpenCodeEdit(sampleRequest, mockServerSettings())
|
||||
).rejects.toThrow(/network error/);
|
||||
});
|
||||
|
||||
it('throws on empty response body', async () => {
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({ ok: true, status: 200, statusText: 'OK', text: '' })
|
||||
);
|
||||
await expect(
|
||||
callOpenCodeEdit(sampleRequest, mockServerSettings())
|
||||
).rejects.toThrow(/empty response/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callOpenCodeProviders', () => {
|
||||
beforeEach(() => {
|
||||
mockedMakeRequest.mockReset();
|
||||
});
|
||||
|
||||
it('GETs /opencode-bridge/providers and returns parsed JSON', async () => {
|
||||
const respBody = {
|
||||
providers: [
|
||||
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
|
||||
{ id: 'openai', models: [{ id: 'gpt-4' }, { id: 'gpt-3.5-turbo' }] }
|
||||
]
|
||||
};
|
||||
mockedMakeRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify(respBody),
|
||||
json: async () => respBody,
|
||||
headers: {} as any,
|
||||
url: '',
|
||||
redirected: false,
|
||||
type: 'basic',
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
clone: function () { return this; }
|
||||
} as any);
|
||||
|
||||
const settings = mockServerSettings();
|
||||
const resp = await callOpenCodeProviders(settings);
|
||||
|
||||
expect(mockedMakeRequest).toHaveBeenCalledWith(
|
||||
'http://localhost:8888/opencode-bridge/providers',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
settings
|
||||
);
|
||||
expect(resp.providers).toHaveLength(2);
|
||||
expect(resp.providers[0].id).toBe('anthropic');
|
||||
expect(resp.providers[1].models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws on non-2xx response', async () => {
|
||||
mockedMakeRequest.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
text: async () => '',
|
||||
json: async () => null,
|
||||
headers: {} as any,
|
||||
url: '',
|
||||
redirected: false,
|
||||
type: 'basic',
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
clone: function () { return this; }
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
callOpenCodeProviders(mockServerSettings())
|
||||
).rejects.toThrow(/502/);
|
||||
});
|
||||
|
||||
it('throws on network error', async () => {
|
||||
mockedMakeRequest.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(
|
||||
callOpenCodeProviders(mockServerSettings())
|
||||
).rejects.toThrow(/network error/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* HTTP client for the opencode-bridge server extension.
|
||||
*/
|
||||
import { URLExt } from '@jupyterlab/coreutils';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
||||
* Throws on network error or non-JSON response.
|
||||
*/
|
||||
export async function callOpenCodeEdit(
|
||||
request: OpenCodeRequest,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeResponse> {
|
||||
const url = URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'edit'
|
||||
);
|
||||
|
||||
const init: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`network error calling opencode-bridge: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error(
|
||||
`empty response from opencode-bridge (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`non-JSON response from opencode-bridge (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as OpenCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
|
||||
* Throws on network error or non-2xx status.
|
||||
*/
|
||||
export async function callOpenCodeProviders(
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeProvidersResponse> {
|
||||
const url = URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'providers'
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await ServerConnection.makeRequest(url, { method: 'GET' }, serverSettings);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`network error calling opencode-bridge/providers: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`opencode-bridge/providers failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenCodeProvidersResponse;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Custom Cell.ContentFactory that adds our OpenCodeCellFooter to every cell.
|
||||
*
|
||||
* One factory instance per notebook; install via `installOpenCodeInNotebook`.
|
||||
*/
|
||||
import { ICellFooter } from '@jupyterlab/cells';
|
||||
import { Notebook } from '@jupyterlab/notebook';
|
||||
|
||||
import { OpenCodeCellFooter } from './opencode_cell_footer';
|
||||
|
||||
export class OpenCodeCellContentFactory extends Notebook.ContentFactory {
|
||||
createCellFooter(): ICellFooter {
|
||||
return new OpenCodeCellFooter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Per-cell footer widget: renders 3 buttons (optimize / fix / edit) and
|
||||
* wires them to the opencode-bridge server.
|
||||
*
|
||||
* Resolves its owning CodeCell at onAfterAttach via `this.parent`.
|
||||
* Subscribes to the cell model's contentChanged and stateChanged signals
|
||||
* to keep the context fresh.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import { callOpenCodeEdit } from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
OpenCodeMode,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse,
|
||||
OpenCodeSettings,
|
||||
} from '../types';
|
||||
|
||||
// Module-level runtime injection (set by index.ts after settings load).
|
||||
let _settings: OpenCodeSettings | null = null;
|
||||
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||
|
||||
export function setOpenCodeRuntime(args: {
|
||||
settings: OpenCodeSettings;
|
||||
serverSettings: ServerConnection.ISettings;
|
||||
}): void {
|
||||
_settings = args.settings;
|
||||
_serverSettings = args.serverSettings;
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'loading' | 'error';
|
||||
|
||||
export class OpenCodeCellFooter extends Widget {
|
||||
private _cell: CodeCell | null = null;
|
||||
private _context: CellContext | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _errorMessage: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addClass('opencode-cell-footer');
|
||||
this._render();
|
||||
}
|
||||
|
||||
protected onAfterAttach(_msg: unknown): void {
|
||||
const parent = this.parent;
|
||||
if (parent instanceof CodeCell) {
|
||||
this._cell = parent;
|
||||
this._cell.model.contentChanged.connect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.connect(this._onModelChange, this);
|
||||
this._onModelChange();
|
||||
}
|
||||
}
|
||||
|
||||
protected onBeforeDetach(_msg: unknown): void {
|
||||
if (this._cell) {
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
}
|
||||
this._cell = null;
|
||||
}
|
||||
|
||||
private _onModelChange(): void {
|
||||
this._context = this._cell ? extractCellContextFromCell(this._cell) : null;
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const node = this.node;
|
||||
node.textContent = '';
|
||||
|
||||
const hasError = this._context?.error != null;
|
||||
const loading = this._status === 'loading';
|
||||
const baseDisabled = !this._context || loading;
|
||||
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
mode: OpenCodeMode,
|
||||
disabled: boolean
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.className = `opencode-btn opencode-btn-${mode}`;
|
||||
b.textContent = label;
|
||||
b.disabled = disabled;
|
||||
b.title = disabled
|
||||
? loading
|
||||
? 'OpenCode: 请求中…'
|
||||
: 'OpenCode: 等待 cell 上下文…'
|
||||
: label;
|
||||
b.addEventListener('click', () => {
|
||||
void this._onClick(mode);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
|
||||
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
|
||||
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
|
||||
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
|
||||
|
||||
if (this._status === 'error' && this._errorMessage) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'opencode-error';
|
||||
err.textContent = this._errorMessage;
|
||||
err.title = this._errorMessage;
|
||||
node.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async _onClick(mode: OpenCodeMode): Promise<void> {
|
||||
if (!this._context) {
|
||||
return;
|
||||
}
|
||||
if (!_settings || !_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化,请检查 settings');
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt = '';
|
||||
if (mode === 'edit') {
|
||||
const input = window.prompt('请输入编辑指令:');
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
prompt = input;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
mode,
|
||||
prompt,
|
||||
context: this._context,
|
||||
providerId: _settings.opencodeProvider || undefined,
|
||||
modelId: _settings.opencodeModel || undefined
|
||||
};
|
||||
|
||||
this._status = 'loading';
|
||||
this._errorMessage = null;
|
||||
this._render();
|
||||
|
||||
try {
|
||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
this._handleResponse(resp);
|
||||
} catch (e) {
|
||||
this._status = 'error';
|
||||
this._errorMessage = (e as Error).message;
|
||||
this._render();
|
||||
Notification.error(`OpenCode ${mode} 失败: ${this._errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleResponse(resp: OpenCodeResponse): void {
|
||||
if (resp.ok) {
|
||||
this._status = 'idle';
|
||||
this._render();
|
||||
const len = resp.finalSource.length;
|
||||
Notification.info(
|
||||
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
|
||||
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} else {
|
||||
this._status = 'error';
|
||||
this._errorMessage = resp.error;
|
||||
this._render();
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||
* then build the CellContext. The footer uses this rather than `Widget.findParent`
|
||||
* because that helper was removed in @lumino/widgets 2.x.
|
||||
*/
|
||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||
let node: Widget | null = cell.parent;
|
||||
let notebookPanel: NotebookPanel | null = null;
|
||||
while (node) {
|
||||
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
|
||||
const candidate = node as any;
|
||||
if (
|
||||
candidate.context &&
|
||||
candidate.content &&
|
||||
Array.isArray(candidate.content.widgets)
|
||||
) {
|
||||
notebookPanel = candidate;
|
||||
break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
if (!notebookPanel) {
|
||||
return null;
|
||||
}
|
||||
return extractCellContext(cell, notebookPanel);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Install the OpenCode cell factory on every notebook tracked by INotebookTracker.
|
||||
*
|
||||
* For already-open notebooks: swap their contentFactory in place. Cells created
|
||||
* after this point will use the new factory. (Cells already created retain their
|
||||
* old factory — that's a known limitation; users must reload the notebook to see
|
||||
* the footer in cells that were created before activation.)
|
||||
*
|
||||
* For future notebooks: listen to `tracker.widgetAdded` and swap on addition.
|
||||
*/
|
||||
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
||||
|
||||
import { OpenCodeCellContentFactory } from './opencode_cell_factory';
|
||||
|
||||
export function installOpenCodeInNotebook(panel: NotebookPanel): void {
|
||||
if (panel.content.contentFactory instanceof OpenCodeCellContentFactory) {
|
||||
return; // already installed
|
||||
}
|
||||
const existing = panel.content.contentFactory;
|
||||
const editorFactory = (existing as any).editorFactory;
|
||||
const factory = new OpenCodeCellContentFactory({ editorFactory });
|
||||
// contentFactory is readonly in the typings but writable at runtime.
|
||||
(panel.content as any).contentFactory = factory;
|
||||
}
|
||||
|
||||
export function installOpenCodeEverywhere(tracker: INotebookTracker): void {
|
||||
// Patch already-open notebooks.
|
||||
tracker.forEach(panel => {
|
||||
installOpenCodeInNotebook(panel);
|
||||
});
|
||||
// Patch future notebooks.
|
||||
tracker.widgetAdded.connect((_, panel) => {
|
||||
installOpenCodeInNotebook(panel);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
JupyterFrontEnd,
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
import { INotebookTracker } from '@jupyterlab/notebook';
|
||||
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
||||
|
||||
import { installOpenCodeEverywhere } from './components/opencode_installer';
|
||||
import { setOpenCodeRuntime } from './components/opencode_cell_footer';
|
||||
import { requestAPI } from './request';
|
||||
import { readOpenCodeSettings } from './types';
|
||||
import { callOpenCodeProviders } from './api/opencode_client';
|
||||
|
||||
/**
|
||||
* Initialization data for the opencode_bridge extension.
|
||||
*/
|
||||
const plugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'opencode_bridge:plugin',
|
||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||
autoStart: true,
|
||||
optional: [ISettingRegistry],
|
||||
requires: [INotebookTracker],
|
||||
activate: (
|
||||
app: JupyterFrontEnd,
|
||||
tracker: INotebookTracker,
|
||||
settingRegistry: ISettingRegistry | null
|
||||
) => {
|
||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||
|
||||
// Install the per-cell footer factory on every notebook.
|
||||
installOpenCodeEverywhere(tracker);
|
||||
|
||||
// Load settings + push into toolbar module.
|
||||
if (settingRegistry) {
|
||||
void settingRegistry
|
||||
.load(plugin.id)
|
||||
.then(settings => {
|
||||
const bridge = readOpenCodeSettings(settings.composite);
|
||||
setOpenCodeRuntime({
|
||||
settings: bridge,
|
||||
serverSettings: app.serviceManager.serverSettings
|
||||
});
|
||||
console.log('opencode_bridge settings loaded:', bridge);
|
||||
|
||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
|
||||
for (const p of data.providers) {
|
||||
const models = p.models.map(m => m.id).join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
})
|
||||
.catch(reason => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||
reason
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error('Failed to load settings for opencode_bridge.', reason);
|
||||
});
|
||||
}
|
||||
|
||||
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
console.log('hello endpoint:', data);
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error(
|
||||
`The opencode_bridge server extension appears to be missing.\n${reason}`
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { URLExt } from '@jupyterlab/coreutils';
|
||||
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
/**
|
||||
* Call the server extension
|
||||
*
|
||||
* @param endPoint API REST end point for the extension
|
||||
* @param serverSettings The server settings to use for the request
|
||||
* @param init Initial values for the request
|
||||
* @returns The response body interpreted as JSON
|
||||
*/
|
||||
export async function requestAPI<T>(
|
||||
endPoint: string,
|
||||
serverSettings: ServerConnection.ISettings,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Make request to Jupyter API
|
||||
const requestUrl = URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge', // our server extension's API namespace
|
||||
endPoint
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await ServerConnection.makeRequest(
|
||||
requestUrl,
|
||||
init,
|
||||
serverSettings
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ServerConnection.NetworkError(error as any);
|
||||
}
|
||||
|
||||
let data: any = await response.text();
|
||||
|
||||
if (data.length > 0) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.log('Not a JSON response body.', response);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServerConnection.ResponseError(response, data.message || data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Shared types for the opencode-bridge frontend.
|
||||
*
|
||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
||||
*/
|
||||
|
||||
export interface ErrorOutput {
|
||||
ename: string;
|
||||
evalue: string;
|
||||
traceback: string[];
|
||||
}
|
||||
|
||||
export interface CellContext {
|
||||
notebookPath: string;
|
||||
cellId: string;
|
||||
language: string;
|
||||
cellIndex: number;
|
||||
totalCells: number;
|
||||
source: string;
|
||||
previousCode: string | null;
|
||||
error: ErrorOutput | null;
|
||||
}
|
||||
|
||||
export type OpenCodeMode = 'optimize' | 'fix' | 'edit';
|
||||
|
||||
export interface OpenCodeRequest {
|
||||
mode: OpenCodeMode;
|
||||
prompt: string;
|
||||
context: CellContext;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeSuccess {
|
||||
ok: true;
|
||||
mode: OpenCodeMode;
|
||||
finalSource: string;
|
||||
sessionId: string;
|
||||
notebookPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||
|
||||
/** Frontend view of the 4 schema/plugin.json fields. */
|
||||
export interface OpenCodeSettings {
|
||||
opencodeServerUrl: string;
|
||||
opencodeServerUser: string;
|
||||
opencodeServerPassword: string;
|
||||
requestTimeoutSeconds: number;
|
||||
opencodeProvider: string;
|
||||
opencodeModel: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
|
||||
opencodeServerUrl: 'http://127.0.0.1:4096',
|
||||
opencodeServerUser: 'opencode',
|
||||
opencodeServerPassword: '',
|
||||
requestTimeoutSeconds: 120,
|
||||
opencodeProvider: '',
|
||||
opencodeModel: '',
|
||||
};
|
||||
|
||||
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
|
||||
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
|
||||
return {
|
||||
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl,
|
||||
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser,
|
||||
opencodeServerPassword: c.opencodeServerPassword || '',
|
||||
requestTimeoutSeconds:
|
||||
typeof c.requestTimeoutSeconds === 'number'
|
||||
? c.requestTimeoutSeconds
|
||||
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
|
||||
opencodeProvider: c.opencodeProvider || '',
|
||||
opencodeModel: c.opencodeModel || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/providers. */
|
||||
export interface OpenCodeProvider {
|
||||
id: string;
|
||||
models: { id: string; [k: string]: unknown }[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProvidersResponse {
|
||||
providers: OpenCodeProvider[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
See the JupyterLab Developer Guide for useful CSS Patterns:
|
||||
|
||||
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
||||
*/
|
||||
@@ -0,0 +1 @@
|
||||
@import url('base.css');
|
||||
@@ -0,0 +1 @@
|
||||
import './base.css';
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": true,
|
||||
"jsx": "react",
|
||||
"lib": ["DOM", "ES2018", "ES2020.Intl"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmitOnError": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"preserveWatchOutput": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"target": "ES2018",
|
||||
"types": ["jest"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# Integration Testing
|
||||
|
||||
This folder contains the integration tests of the extension.
|
||||
|
||||
They are defined using [Playwright](https://playwright.dev/docs/intro) test runner
|
||||
and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper.
|
||||
|
||||
The Playwright configuration is defined in [playwright.config.js](./playwright.config.js).
|
||||
|
||||
The JupyterLab server configuration to use for the integration test is defined
|
||||
in [jupyter_server_test_config.py](./jupyter_server_test_config.py).
|
||||
|
||||
The default configuration will produce video for failing tests and an HTML report.
|
||||
|
||||
> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0).
|
||||
|
||||
## Run the tests
|
||||
|
||||
> All commands are assumed to be executed from the root directory
|
||||
|
||||
To run the tests, you need to:
|
||||
|
||||
1. Compile the extension:
|
||||
|
||||
```sh
|
||||
jlpm install
|
||||
jlpm build:prod
|
||||
```
|
||||
|
||||
> Check the extension is installed in JupyterLab.
|
||||
|
||||
2. Install test dependencies (needed only once):
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm install
|
||||
jlpm playwright install
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. Execute the [Playwright](https://playwright.dev/docs/intro) tests:
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm playwright test
|
||||
```
|
||||
|
||||
Test results will be shown in the terminal. In case of any test failures, the test report
|
||||
will be opened in your browser at the end of the tests execution; see
|
||||
[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter)
|
||||
for configuring that behavior.
|
||||
|
||||
## Update the tests snapshots
|
||||
|
||||
> All commands are assumed to be executed from the root directory
|
||||
|
||||
If you are comparing snapshots to validate your tests, you may need to update
|
||||
the reference snapshots stored in the repository. To do that, you need to:
|
||||
|
||||
1. Compile the extension:
|
||||
|
||||
```sh
|
||||
jlpm install
|
||||
jlpm build:prod
|
||||
```
|
||||
|
||||
> Check the extension is installed in JupyterLab.
|
||||
|
||||
2. Install test dependencies (needed only once):
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm install
|
||||
jlpm playwright install
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. Execute the [Playwright](https://playwright.dev/docs/intro) command:
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm playwright test -u
|
||||
```
|
||||
|
||||
> Some discrepancy may occurs between the snapshots generated on your computer and
|
||||
> the one generated on the CI. To ease updating the snapshots on a PR, you can
|
||||
> type `please update playwright snapshots` to trigger the update by a bot on the CI.
|
||||
> Once the bot has computed new snapshots, it will commit them to the PR branch.
|
||||
|
||||
## Create tests
|
||||
|
||||
> All commands are assumed to be executed from the root directory
|
||||
|
||||
To create tests, the easiest way is to use the code generator tool of playwright:
|
||||
|
||||
1. Compile the extension:
|
||||
|
||||
```sh
|
||||
jlpm install
|
||||
jlpm build:prod
|
||||
```
|
||||
|
||||
> Check the extension is installed in JupyterLab.
|
||||
|
||||
2. Install test dependencies (needed only once):
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm install
|
||||
jlpm playwright install
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm start
|
||||
```
|
||||
|
||||
4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**:
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm playwright codegen localhost:8888
|
||||
```
|
||||
|
||||
## Debug tests
|
||||
|
||||
> All commands are assumed to be executed from the root directory
|
||||
|
||||
To debug tests, a good way is to use the inspector tool of playwright:
|
||||
|
||||
1. Compile the extension:
|
||||
|
||||
```sh
|
||||
jlpm install
|
||||
jlpm build:prod
|
||||
```
|
||||
|
||||
> Check the extension is installed in JupyterLab.
|
||||
|
||||
2. Install test dependencies (needed only once):
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm install
|
||||
jlpm playwright install
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug):
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm playwright test --debug
|
||||
```
|
||||
|
||||
## Upgrade Playwright and the browsers
|
||||
|
||||
To update the web browser versions, you must update the package `@playwright/test`:
|
||||
|
||||
```sh
|
||||
cd ./ui-tests
|
||||
jlpm up "@playwright/test"
|
||||
jlpm playwright install
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Server configuration for integration tests.
|
||||
|
||||
!! Never use this configuration in production because it
|
||||
opens the server to the world and provide access to JupyterLab
|
||||
JavaScript objects through the global window variable.
|
||||
"""
|
||||
from jupyterlab.galata import configure_jupyter_server
|
||||
|
||||
configure_jupyter_server(c)
|
||||
|
||||
# Uncomment to set server log level to debug level
|
||||
# c.ServerApp.log_level = "DEBUG"
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "opencode_bridge-ui-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "JupyterLab opencode_bridge Integration Tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "jupyter lab --config jupyter_server_test_config.py",
|
||||
"test": "jlpm playwright test",
|
||||
"test:update": "jlpm playwright test --update-snapshots"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jupyterlab/galata": "^5.0.5",
|
||||
"@playwright/test": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Configuration for Playwright using default from @jupyterlab/galata
|
||||
*/
|
||||
const baseConfig = require('@jupyterlab/galata/lib/playwright-config');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
webServer: {
|
||||
command: 'jlpm start',
|
||||
url: 'http://localhost:8888/lab',
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from '@jupyterlab/galata';
|
||||
|
||||
/**
|
||||
* Don't load JupyterLab webpage before running the tests.
|
||||
* This is required to ensure we capture all log messages.
|
||||
*/
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test('should emit an activation console message', async ({ page }) => {
|
||||
const logs: string[] = [];
|
||||
|
||||
page.on('console', message => {
|
||||
logs.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto();
|
||||
|
||||
expect(
|
||||
logs.filter(s => s === 'JupyterLab extension opencode_bridge is activated!')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
Reference in New Issue
Block a user