Changeset 0.11.0 (#62)

This commit is contained in:
2026-02-25 13:29:15 +00:00
parent d2ec55b16d
commit c9d8e9457e
56 changed files with 5664 additions and 91 deletions

View File

@@ -369,7 +369,7 @@ jobs:
run: |
docker build -f server/Dockerfile \
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
./server
.
# ── Build Frontend Image ─────────────────────
- name: Build frontend image

View File

@@ -1,4 +1,4 @@
# Architecture — Chat Switchboard v0.9
# Architecture — Chat Switchboard v0.11
## Deployment Modes

View File

@@ -2,6 +2,92 @@
All notable changes to Chat Switchboard.
## [0.11.0] — 2026-02-25
### Added
- **Browser extension system.** Full lifecycle: manifest registration, script
injection, scoped context (`ctx.renderers`, `ctx.tools`, `ctx.events`,
`ctx.storage`, `ctx.ui`), permission-aware API proxying. Extensions self-
register via `Extensions.register()` and receive isolated contexts during
`initAll()`. Admin CRUD endpoints, asset serving (public, no auth needed
for `<script>` tag loading), auto-seeder for builtin extensions.
- **Custom renderer pipeline.** Block renderers intercept code fences by
language tag, post renderers process the DOM after insertion. Case-
insensitive language matching handles LLM capitalization (`Mermaid`,
`Diff`, `CSV`). Nested ` ```markdown ``` ` fence unwrapping prevents the
common LLM pattern of wrapping responses in markdown fences from breaking
inner code blocks.
- **Browser tool bridge.** Extensions register tool handlers via
`ctx.tools.handle()`. Server collects browser tool schemas alongside
server tools, includes them in LLM completions. Tool calls route through
WebSocket EventBus (`tool.call.*` → browser → `tool.result.*` → server).
30-second timeout with error recovery.
- **Server tools: calculator and datetime.** Auto-register via `init()`,
zero wiring changes. Calculator: recursive-descent evaluator with
arithmetic, 17 math functions, constants. Datetime: current date/time/
timezone/unix/ISO-week in any IANA timezone.
- **6 built-in browser extensions** (self-contained, own CSS via
`_injectStyles()`/`destroy()` lifecycle):
- **Mermaid**: block + post renderer, SVG diagrams, dark mode detection,
local vendor with CDN fallback, max-height constraint with scroll
- **KaTeX**: block renderer (` ```latex/math/tex ```) + post renderer
(inline `$...$` and `$$...$$` in text nodes)
- **CSV Table**: block renderer, RFC 4180 parser with quoted fields,
sortable columns (click headers), numeric-aware sorting
- **Diff Viewer**: block renderer, red/green syntax highlighting for
unified diffs, stats badge (+N/-N), hunk/file headers
- **JS Sandbox**: browser tool (`js_eval`), sandboxed iframe
(`allow-scripts` only), console capture, 10s timeout
- **Regex Tester**: browser tool (`regex_test`), multi-input matching,
full match details with named groups and indices
- **Admin extension editor.** Edit button on each extension in Admin →
Extensions. Inline editor shows name, description, manifest JSON, and
script source with tab-key support. System extensions show overwrite
warning.
- **Enhanced diagnostics.** Test 5: browser extensions (loaded, active,
renderers, tool handlers). Test 6: Service Worker cache (registration,
scope, state, cache names, entry counts). Purge Cache button in Debug
Log modal footer.
- **Extension-owned styles.** All 4 rendering extensions (mermaid, katex,
csv, diff) inject their own `<style>` tags during `init()` and remove
them during `destroy()`. Global stylesheet only keeps `.ext-rendered`
wrapper. User-created extensions can bring their own CSS without
touching the global stylesheet.
- **Notes extension rendering.** `runExtensionPostRender()` called in both
note read mode and preview mode. Mermaid diagrams and KaTeX math now
render correctly in notes.
- **Database migration 006_extensions.sql.** `extensions` and
`extension_user_settings` tables.
### Fixed
- **WebSocket token field mismatch.** API saved `{ accessToken: '...' }`
but EventBus read `tokens.access`. Tool bridge was dead on arrival.
- **Extension asset 401.** Asset route was behind auth middleware, but
`<script>` tags don't send Authorization headers. Moved to public group.
- **`runExtensionPostRender is not defined`.** Stale Service Worker cache
served old `ui-format.js` without the function. Added `typeof` guard.
- **Extension init order.** Extensions loaded after `loadChats()` — block
renderers not registered when messages first rendered. Moved extension
loading before chat loading.
- **K8s Ingress WebSocket routing.** Traefik `pathType: Exact` doesn't
reliably win over `Prefix` rules in the same Ingress resource. Changed
`/ws` and `/health` to `Prefix` for longest-prefix-wins routing.
- **Mermaid SVG oversized.** Added `max-height: 600px` on diagram
container with overflow scroll, removed mermaid.js hardcoded `height`
attribute from SVGs so CSS constraints apply.
### Changed
- Extension CSS removed from global `styles.css`. Each extension owns its
styles via `_injectStyles()` in `init()` with `destroy()` cleanup.
Idempotent injection guards prevent duplicates.
- Markdown fence language extraction lowercased at the point of extraction
in `ui-format.js`, making all block renderer pattern matches
case-insensitive without per-extension workarounds.
- `_unwrapMarkdownFence()` pre-processor strips outer ` ```markdown ``` `
wrappers when they contain nested fences. Handles think-block
placeholders and trailing explanation text. Only triggers when nested
fences are present — plain markdown code blocks still render normally.
## [0.10.5] — 2026-02-24
### Added

View File

@@ -34,6 +34,20 @@ RUN npm pack dompurify@3.2.4 && \
cp package/dist/purify.min.js purify.min.js && \
rm -rf package dompurify-3.2.4.tgz
RUN npm pack mermaid@11.4.1 && \
tar xzf mermaid-11.4.1.tgz && \
mkdir -p mermaid && \
cp package/dist/mermaid.min.js mermaid/mermaid.min.js && \
rm -rf package mermaid-11.4.1.tgz
RUN npm pack katex@0.16.11 && \
tar xzf katex-0.16.11.tgz && \
mkdir -p katex && \
cp package/dist/katex.min.js katex/katex.min.js && \
cp package/dist/katex.min.css katex/katex.min.css && \
cp -r package/dist/fonts katex/fonts && \
rm -rf package katex-0.16.11.tgz
# ── Stage 3: Production ─────────────────────
FROM nginx:1-alpine
@@ -54,6 +68,13 @@ RUN APP_VERSION=$(cat /VERSION | tr -d '[:space:]') && \
# Vendor libs (overwrite any existing copies in src/vendor/)
COPY --from=vendor /vendor/marked.min.js /usr/share/nginx/html/vendor/marked.min.js
COPY --from=vendor /vendor/purify.min.js /usr/share/nginx/html/vendor/purify.min.js
COPY --from=vendor /vendor/mermaid/mermaid.min.js /usr/share/nginx/html/vendor/mermaid/mermaid.min.js
COPY --from=vendor /vendor/katex/katex.min.js /usr/share/nginx/html/vendor/katex/katex.min.js
COPY --from=vendor /vendor/katex/katex.min.css /usr/share/nginx/html/vendor/katex/katex.min.css
COPY --from=vendor /vendor/katex/fonts/ /usr/share/nginx/html/vendor/katex/fonts/
# Builtin extensions (seeded into DB on startup by backend)
COPY extensions/builtin/ /app/extensions/builtin/
# nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -16,11 +16,18 @@
# Stage 1: Download vendor libs (vendor/ is gitignored)
FROM node:20-alpine AS vendor
WORKDIR /tmp
RUN npm pack marked@16.3.0 dompurify@3.2.4 2>/dev/null && \
mkdir -p /vendor && \
RUN npm pack marked@16.3.0 dompurify@3.2.4 mermaid@11.4.1 katex@0.16.11 2>/dev/null && \
mkdir -p /vendor/mermaid /vendor/katex/fonts && \
tar xzf marked-*.tgz -C /tmp && cp /tmp/package/lib/marked.umd.js /vendor/marked.min.js && \
rm -rf /tmp/package && \
tar xzf dompurify-*.tgz -C /tmp && cp /tmp/package/dist/purify.min.js /vendor/purify.min.js && \
rm -rf /tmp/package && \
tar xzf mermaid-*.tgz -C /tmp && cp /tmp/package/dist/mermaid.min.js /vendor/mermaid/mermaid.min.js && \
rm -rf /tmp/package && \
tar xzf katex-*.tgz -C /tmp && \
cp /tmp/package/dist/katex.min.js /vendor/katex/katex.min.js && \
cp /tmp/package/dist/katex.min.css /vendor/katex/katex.min.css && \
cp -r /tmp/package/dist/fonts/* /vendor/katex/fonts/ && \
rm -rf /tmp/package /tmp/*.tgz
# Stage 2: nginx + entrypoint

View File

@@ -1,7 +1,7 @@
# Chat Switchboard — Extension System Specification
**Version:** 0.2
**Status:** Design (pre-implementation)
**Version:** 0.3
**Status:** Browser tier (Tier 0) implemented in v0.11.0. Starlark and Sidecar tiers planned.
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md)
---

View File

@@ -21,10 +21,14 @@ docker compose up -d
- **Model Catalog**: Three-state visibility (enabled/disabled/team-only) with admin controls
- **Message Trees**: Edit and regenerate with full conversation forking — navigate sibling branches
- **Notes**: Markdown notes with full-text search, folders, and tags
- **Extensions**: Browser extension system with custom renderers, tool bridge, and self-contained styling
- Built-in: Mermaid diagrams, KaTeX math, CSV tables, diff viewer, JS sandbox, regex tester
- **Server Tools**: Calculator and datetime tools (auto-registered, zero config)
- **Audit Log**: All admin operations logged with actor, action, and resource details
- **Security**: JWT + refresh tokens, optional mTLS, optional OIDC/Keycloak, environment classification banners
- **Airgapped**: Local vendor files (marked.js, DOMPurify), no CDN required
- **Security**: JWT + refresh tokens, AES-256-GCM API key encryption, optional mTLS, optional OIDC/Keycloak, environment classification banners
- **Airgapped**: Local vendor files (marked.js, DOMPurify, KaTeX, mermaid.js), no CDN required
- **Mobile**: Responsive design, PWA manifest
- **Real-time**: WebSocket event bus for tool bridge, live updates
## Architecture
@@ -49,9 +53,9 @@ docker compose up -d
└───────────────────┘
```
**Go backend** (vanilla, no framework beyond Gin) with a store layer abstracting all database access. Providers package handles LLM API calls. Capabilities package resolves model features from catalog data, known model tables, and heuristic inference.
**Go backend** (vanilla, no framework beyond Gin) with a store layer abstracting all database access. Providers package handles LLM API calls. Capabilities package resolves model features from catalog data, known model tables, and heuristic inference. Server tools (calculator, datetime) auto-register via `init()`. EventBus + WebSocket hub routes tool calls between backend and browser extensions.
**Frontend** is vanilla JavaScript — no build step, no bundler. Five files: `api.js` (HTTP client), `app.js` (state + logic), `ui.js` (DOM rendering), `events.js` (event bus + WebSocket), `debug.js` (admin debug panel).
**Frontend** is vanilla JavaScript — no build step, no bundler. 15 files organized by domain: `api.js` (HTTP client), `app.js` (state + init), `chat.js` (send, regen, edit, branch), `events.js` (event bus + WebSocket), `extensions.js` (loader, registry, renderer pipeline, tool bridge), `ui-core.js` (DOM rendering + streaming), `ui-format.js` (markdown, code blocks), `ui-primitives.js` (shared components), `ui-settings.js` / `ui-admin.js` (settings and admin panels), `notes.js`, `tokens.js`, `debug.js`, `settings-handlers.js`, `admin-handlers.js`.
## Configuration
@@ -66,6 +70,7 @@ All configuration via environment variables. See `server/.env.example` for the f
| `JWT_SECRET` | (required) | Token signing key |
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) |
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
## Deployment
@@ -91,8 +96,8 @@ docker compose --profile dev up # include Adminer DB UI
Build and push both images:
```bash
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.2 server/
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.2 .
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.11.0 server/
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 .
```
**Backend deployment:**
@@ -100,7 +105,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.2 .
```yaml
containers:
- name: api
image: your-registry/switchboard-api:0.9.2
image: your-registry/switchboard-api:0.11.0
ports:
- containerPort: 8080
env:
@@ -120,7 +125,7 @@ containers:
```yaml
containers:
- name: frontend
image: your-registry/switchboard-fe:0.9.2
image: your-registry/switchboard-fe:0.11.0
ports:
- containerPort: 80
env:
@@ -140,7 +145,7 @@ Set `BASE_PATH=/chat` to serve the application under a subpath. The frontend rea
### Airgapped / Disconnected
The Docker build bakes in `marked.js` and `DOMPurify` from npm during the vendor stage. No CDN calls at runtime. The `src/vendor/` directory contains local copies as fallback for development without Docker.
The Docker build bakes in `marked.js`, `DOMPurify`, and `KaTeX` (JS + CSS + fonts) from npm during the vendor stage. Mermaid.js loads dynamically from local vendor with CDN fallback. No CDN calls at runtime in airgapped deployments. The `src/vendor/` directory contains local copies as fallback for development without Docker.
## Database
@@ -161,18 +166,20 @@ scripts/db-migrate.sh
scripts/db-validate.sh
```
### Key Tables (v0.9)
### Key Tables (v0.11)
| Table | Purpose |
|-------|---------|
| `users` | Accounts with role, avatar, settings |
| `users` | Accounts with role, avatar, settings, encrypted vault (UEK) |
| `provider_configs` | API provider configurations (scope: global/team/personal) |
| `model_catalog` | Synced model list with capabilities and visibility |
| `model_catalog` | Synced model list with capabilities, visibility, and type |
| `personas` | Model presets (scope: global/team/personal) |
| `channels` | Conversations with type (direct/group/channel) |
| `messages` | Message tree with parent_id for forking |
| `messages` | Message tree with parent_id for forking, tool_calls JSONB |
| `teams` / `team_members` | Organizational units |
| `notes` | Markdown notes with full-text search |
| `extensions` / `extension_user_settings` | Browser extension registry and per-user config |
| `usage_log` / `model_pricing` | Token usage tracking and cost calculation |
| `audit_log` | Admin action audit trail |
| `user_model_settings` | Per-user model visibility and sort preferences |
@@ -214,7 +221,17 @@ All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer <token
- `GET/PUT /admin/settings/:key` — Global settings and policies
- `GET/POST /admin/configs` — Global provider configs
- `GET/POST /admin/models/fetch` — Model catalog sync
- `GET/PUT /admin/roles/:role` — Model role configuration
- `GET /admin/usage` — Usage dashboard
- `GET /admin/audit` — Audit log
- `GET/POST/PUT/DELETE /admin/extensions` — Extension management
### Extensions
- `GET /extensions?tier=browser` — List enabled browser extensions
- `GET /extensions/:id/assets/*path` — Serve extension assets (public, no auth)
### WebSocket
- `GET /ws?token=<jwt>` — EventBus WebSocket for real-time events and tool bridge
## Development

View File

@@ -15,35 +15,29 @@ No compatibility guarantees before 1.0.
Features have real dependencies. This ordering respects them.
```
v0.9.x Stability + Quick UX Wins
v0.9.x Stability + Quick UX Wins
v0.9.3 Content Visibility & Block Controls
v0.9.3 Content Visibility & Block Controls
v0.9.4 API Key Encryption + Vault
v0.9.4 API Key Encryption + Vault
v0.10.0 Model Roles (utility + embedding) + Usage Tracking
v0.10.0 Model Roles (utility + embedding)
+ Usage Tracking
v0.10.2 Summarize & Continue (first utility role consumer)
v0.10.2 Summarize & Continue
v0.10.3 Frontend Refactor (no features, code health)
v0.10.3 Frontend Refactor
v0.10.4 Model Type Pipeline + Role Fixes
v0.10.4 Model Type Pipeline + Role Fixes
v0.10.5 UI Primitives + Extension Surfaces
v0.10.5 UI Primitives + Extension Surfaces
v0.11.0 Extension Foundation (browser tier) ✅
┌───────┴──────────────┐
│ │
v0.11.0 Extension v0.12.0 File Handling
Foundation + Vision
(browser tier) │
│ │
├── Custom renderers ├── Doc gen outputs
├── Image gen tool ├── KB documents
├── STT/TTS │
│ │
├───────────────────────┤
│ │
v0.13.0 Web Search + Tools Expansion
v0.12.0 File Handling v0.13.0 Web Search
+ Vision + Tools Expansion
v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
@@ -525,35 +519,58 @@ the `ctx.*` extension API surface in v0.11.0 with no rewrite needed.
---
## v0.11.0 — Extension Foundation (Browser Tier)
## v0.11.0 — Extension Foundation (Browser Tier)
The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
**Core Loader** (EXTENSIONS.md §4, §7)
- [ ] `extensions.js` — loader, registry, scoped context (~200 lines)
- [ ] `Extensions.register()` API with permission enforcement
- [ ] Manifest format parser and validator
- [ ] `extensions` + `extension_user_settings` tables
- [ ] Admin endpoints: install, uninstall, enable/disable
- [ ] Asset serving: `/api/v1/extensions/:id/assets/*path`
- [ ] Extension settings UI in Settings modal
- [x] `extensions.js` — loader, registry, scoped context, renderer pipeline (~400 lines)
- [x] `Extensions.register()` API with permission-aware scoped contexts
- [x] Manifest format: `_script` inline or `entry` file, permissions, settings schema
- [x] `extensions` + `extension_user_settings` tables (migration 006)
- [x] Admin endpoints: CRUD, enable/disable, asset serving
- [x] `/api/v1/extensions/:id/assets/*path` — public (no auth, `<script>` tag compatible)
- [x] Auto-seeder: scans `extensions/builtin/*/` on startup, upserts manifests
- [x] Admin Extensions tab with Edit button (view/modify manifest + script inline)
**Browser Tool Bridge** (EXTENSIONS.md §5)
- [ ] `ctx.tools.handle()` API for browser tool registration
- [ ] Tool schema collection: merge built-in + extension tools
- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
- [ ] 30-second timeout with LLM error recovery
- [ ] Tool execution via EventBus `WaitFor` pattern
- [x] `ctx.tools.handle()` API for browser tool registration
- [x] Tool schema collection: merge server + extension tools in completions
- [x] `tool.call.*` / `tool.result.*` WebSocket routing via EventBus hub
- [x] 30-second timeout with error recovery
- [x] `Events.waitFor()` pattern for request/response tool calls
**Custom Renderer API** (EXTENSIONS.md Appendix A)
- [ ] `ctx.renderers.register()`pattern + render function
- [ ] Renderer pipeline in message display (after markdown, before DOM insert)
- [ ] First-party renderers: collapsible code, HTML preview, mermaid, LaTeX
(migrate core v0.9.2 implementations to extension format)
- [x] `ctx.renderers.register()`block, post, and inline types with pattern/match
- [x] Renderer pipeline: block renderers intercept code fences, post renderers process DOM
- [x] Case-insensitive language matching (LLMs capitalize fence tags)
- [x] Nested ```` ```markdown ```` fence unwrapping (LLMs wrap responses in markdown fences)
- [x] Extension-owned styles via `_injectStyles()` / `destroy()` lifecycle
**First Extensions**
- [ ] Image generation tool (browser or sidecar, uses generation model role)
- [ ] STT/TTS (browser extension, Web Speech API — personal use case)
**Server Tools** (auto-registered via `init()`)
- [x] `datetime` — current date/time/timezone/day/unix/ISO-week (models hallucinate dates)
- [x] `calculator` — recursive-descent evaluator: arithmetic, 17 functions, constants
**Built-in Browser Extensions** (6 total, self-contained with own CSS)
- [x] **Mermaid** — block + post renderer, SVG diagrams, dark mode, local vendor + CDN fallback
- [x] **KaTeX** — block renderer (` ```latex/math/tex ```) + post renderer (inline `$...$` / `$$...$$`)
- [x] **CSV Table** — block renderer, RFC 4180 parser, sortable columns, numeric-aware sorting
- [x] **Diff Viewer** — block renderer, red/green syntax highlighting, stats badge
- [x] **JS Sandbox** — browser tool (`js_eval`), sandboxed iframe, 10s timeout, console capture
- [x] **Regex Tester** — browser tool (`regex_test`), multi-input matching, named groups
**Bugfixes**
- [x] WebSocket token field mismatch (`tokens.access``tokens.accessToken`)
- [x] Extension asset 401 (moved asset route to public group — `<script>` tags can't send auth headers)
- [x] `runExtensionPostRender is not defined` (stale SW cache, added `typeof` guard)
- [x] Extension init order (moved `loadAll()`/`initAll()` before `loadChats()`)
- [x] K8s Ingress WebSocket routing (`pathType: Exact``Prefix` for Traefik longest-prefix-wins)
- [x] Notes missing post-render call (mermaid/KaTeX wouldn't render in note view)
**Diagnostics**
- [x] Test 5: Browser extensions (loaded count, active/inactive, renderers, tool handlers)
- [x] Test 6: Service Worker cache (registration, scope, state, cache names, entry counts)
- [x] Purge Cache button in Debug Log modal (deletes all SW caches, unregisters workers)
---
@@ -579,7 +596,7 @@ File input into chat — table stakes for serious use.
## v0.13.0 — Web Search + Tools Expansion
Built-in tools that use the tool framework shipped in v0.7.x.
Built-in tools that extend the tool framework shipped in v0.11.0.
- [ ] `web_search` tool: search provider abstraction (SearXNG, Brave, DuckDuckGo)
- [ ] `url_fetch` tool: retrieve and extract content from URLs
@@ -773,6 +790,11 @@ Depends on: workflow engine (v0.21.0).
Items that are real but don't yet have a version assignment. Pull left
based on need.
**Extension System — Additional Extensions**
- Image generation tool (browser or sidecar, provider-agnostic)
- STT/TTS (browser extension, Web Speech API — personal use case)
- Code execution sandbox (server-side, container isolation)
**Extension System — Server Tiers**
- Starlark runtime integration (Tier 1 — server sandbox)
- Sidecar HTTP tool protocol (Tier 2 — container isolation)

View File

@@ -1 +1 @@
0.10.5
0.11.0

View File

@@ -0,0 +1,12 @@
{
"id": "csv-table",
"name": "CSV Table Viewer",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,254 @@
// ==========================================
// CSV Table Viewer — Browser Extension
// ==========================================
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
// No external dependencies.
// ==========================================
Extensions.register({
id: 'csv-table',
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('csv-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'csv' || l === 'tsv';
},
render(lang, code, container) {
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
const rows = self._parseCSV(code.trim(), delimiter);
if (rows.length === 0) {
container.innerHTML = '<div class="csv-empty">No data</div>';
return;
}
const headers = rows[0];
const data = rows.slice(1);
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
<div class="csv-toolbar">
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
</div>
<div class="csv-table-scroll">
<table class="csv-table">
<thead>
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
</thead>
<tbody>
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
<details class="csv-source">
<summary>📄 View raw</summary>
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
// Attach sort handlers + copy
self._attachHandlers(container, tableId, code.trim());
}
});
},
_attachHandlers(container, tableId, rawCSV) {
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
if (!block) return;
// ── Column sorting ──
const ths = block.querySelectorAll('th[data-col]');
let sortCol = -1;
let sortAsc = true;
ths.forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const col = parseInt(th.dataset.col, 10);
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
const tbody = block.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const cellA = (a.children[col]?.textContent || '').trim();
const cellB = (b.children[col]?.textContent || '').trim();
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
// Numeric sort if both are numbers
if (!isNaN(numA) && !isNaN(numB)) {
return sortAsc ? numA - numB : numB - numA;
}
// String sort
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
return sortAsc ? cmp : -cmp;
});
rows.forEach(row => tbody.appendChild(row));
// Update sort icons
ths.forEach(h => {
const icon = h.querySelector('.csv-sort-icon');
if (icon) {
const hCol = parseInt(h.dataset.col, 10);
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
}
});
});
});
// ── Copy button ──
const copyBtn = block.querySelector('.csv-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(rawCSV).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {
copyBtn.textContent = '✗ Failed';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
});
});
}
},
/**
* Simple CSV parser that handles quoted fields.
*/
_parseCSV(text, delimiter) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
// Peek ahead: escaped quote or end of quoted field
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
inQuotes = false;
i++;
}
} else {
field += ch;
i++;
}
} else {
if (ch === '"' && field === '') {
inQuotes = true;
i++;
} else if (ch === delimiter) {
row.push(field.trim());
field = '';
i++;
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
row = [];
field = '';
i += (ch === '\r') ? 2 : 1;
} else {
field += ch;
i++;
}
}
}
// Last field/row
row.push(field.trim());
if (row.some(cell => cell !== '')) rows.push(row);
// Normalize: pad short rows to header length
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
rows.forEach(r => {
while (r.length < maxCols) r.push('');
});
}
return rows;
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-csv-table')) return;
const style = document.createElement('style');
style.id = 'ext-style-csv-table';
style.textContent = `
.csv-table-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.csv-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3);
}
.csv-info { flex: 1; }
.csv-copy-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
}
.csv-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
.csv-table-scroll { overflow-x: auto; }
.csv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
font-family: var(--mono);
}
.csv-table th {
background: var(--bg-3); color: var(--text-2); font-weight: 600;
padding: 6px 12px; text-align: left; white-space: nowrap;
border-bottom: 2px solid var(--border); user-select: none;
}
.csv-table th:hover { color: var(--text-1); }
.csv-sort-icon { font-size: 11px; opacity: 0.7; }
.csv-table td {
padding: 4px 12px; border-bottom: 1px solid var(--border);
white-space: nowrap; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
}
.csv-table tr:hover td { background: var(--bg-3); }
.csv-source, .csv-empty { border-top: 1px solid var(--border); font-size: 12px; }
.csv-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.csv-source summary:hover { color: var(--text-2); }
.csv-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-csv-table')?.remove();
}
});

View File

@@ -0,0 +1,12 @@
{
"id": "diff-viewer",
"name": "Diff Viewer",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,166 @@
// ==========================================
// Diff Viewer — Browser Extension
// ==========================================
// Renders ```diff code blocks with syntax-highlighted
// additions, deletions, hunk headers, and context lines.
// No external dependencies.
// ==========================================
Extensions.register({
id: 'diff-viewer',
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
ctx.renderers.register('diff-block', {
type: 'block',
priority: 10,
pattern: 'diff',
render(lang, code, container) {
const lines = code.split('\n');
let stats = { added: 0, removed: 0 };
let currentFile = '';
const rendered = lines.map(line => {
const escaped = self._escapeHtml(line);
// File headers
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
if (line.startsWith('+++ ')) {
currentFile = line.slice(4).trim();
}
return `<div class="diff-line diff-file-header">${escaped}</div>`;
}
// Hunk headers
if (line.startsWith('@@')) {
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@\s*(.*)/);
const context = hunkMatch ? hunkMatch[3] : '';
return `<div class="diff-line diff-hunk">${escaped}</div>`;
}
// Additions
if (line.startsWith('+')) {
stats.added++;
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Deletions
if (line.startsWith('-')) {
stats.removed++;
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Context (lines starting with space or no prefix)
if (line.startsWith(' ')) {
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${self._escapeHtml(line.slice(1))}</div>`;
}
// Other (commit info, etc)
return `<div class="diff-line diff-meta">${escaped}</div>`;
}).join('');
const fileLabel = currentFile ? `<span class="diff-filename" title="${self._escapeHtml(currentFile)}">${self._escapeHtml(currentFile)}</span>` : '';
container.innerHTML = `
<div class="diff-block ext-rendered">
<div class="diff-toolbar">
${fileLabel}
<span class="diff-stats">
<span class="diff-stat-add">+${stats.added}</span>
<span class="diff-stat-del">-${stats.removed}</span>
</span>
<button class="diff-copy-btn" title="Copy diff">📋 Copy</button>
</div>
<div class="diff-content">${rendered}</div>
<details class="diff-source-toggle">
<summary>📝 View raw</summary>
<pre><code>${self._escapeHtml(code)}</code></pre>
</details>
</div>
`;
// Copy handler
const copyBtn = container.querySelector('.diff-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(code).then(() => {
copyBtn.textContent = '✓ Copied';
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
}).catch(() => {});
});
}
}
});
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-diff-viewer')) return;
const style = document.createElement('style');
style.id = 'ext-style-diff-viewer';
style.textContent = `
.diff-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.diff-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3);
}
.diff-filename {
flex: 1; font-family: var(--mono); font-weight: 600;
color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.diff-stats { display: flex; gap: 8px; font-family: var(--mono); font-weight: 600; }
.diff-stat-add { color: var(--success, #27ae60); }
.diff-stat-del { color: var(--error, #e74c3c); }
.diff-copy-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 2px 8px; font-size: 12px; cursor: pointer; color: var(--text-3);
}
.diff-copy-btn:hover { color: var(--text-2); border-color: var(--text-3); }
.diff-content {
font-family: var(--mono); font-size: 13px; line-height: 1.5;
overflow-x: auto;
}
.diff-line { padding: 1px 12px; white-space: pre; min-height: 1.5em; }
.diff-indicator {
display: inline-block; width: 1.2em; text-align: center;
opacity: 0.7; user-select: none;
}
.diff-add { background: rgba(39,174,96,0.12); color: var(--success, #27ae60); }
.diff-del { background: rgba(231,76,60,0.12); color: var(--error, #e74c3c); }
.diff-hunk {
background: rgba(52,152,219,0.08); color: var(--info, #3498db);
font-style: italic; border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border); margin: 2px 0;
}
.diff-file-header { background: var(--bg-3); color: var(--text-2); font-weight: 600; }
.diff-ctx { color: var(--text-3); }
.diff-meta { color: var(--text-3); font-style: italic; }
.diff-source-toggle { border-top: 1px solid var(--border); font-size: 12px; }
.diff-source-toggle summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.diff-source-toggle summary:hover { color: var(--text-2); }
.diff-source-toggle pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-diff-viewer')?.remove();
}
});

View File

@@ -0,0 +1,27 @@
{
"id": "js-sandbox",
"name": "JavaScript Sandbox",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
"permissions": [],
"tools": [
{
"name": "js_eval",
"description": "Execute JavaScript code in a secure browser sandbox. Use this to: verify calculations, run algorithms, transform data, test code snippets, parse/format strings, or any computation. Console output (log/warn/error) is captured. The last expression's value is returned as the result. Has no network access, no DOM access, no access to the parent page. Timeout: 10 seconds.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "JavaScript code to execute. Use console.log() for output. The value of the last expression is returned automatically."
}
},
"required": ["code"]
}
}
],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,202 @@
// ==========================================
// JavaScript Sandbox — Browser Extension
// ==========================================
// Executes JavaScript in a sandboxed iframe.
// Registers the js_eval tool so the LLM can run code,
// verify calculations, and transform data.
//
// Security: The iframe has sandbox="allow-scripts" only.
// No allow-same-origin, no network, no DOM access.
// ==========================================
Extensions.register({
id: 'js-sandbox',
_iframe: null,
_pendingCallbacks: new Map(), // messageId → { resolve, reject, timer }
_messageCounter: 0,
async init(ctx) {
const self = this;
// Create the sandboxed iframe on first use (lazy)
ctx.tools.handle('js_eval', async (args) => {
return self._execute(args.code || '');
});
},
async _execute(code) {
if (!code.trim()) {
return { error: 'No code provided' };
}
// Ensure iframe is ready
await this._ensureIframe();
const messageId = 'eval-' + (++this._messageCounter);
const TIMEOUT_MS = 10000;
return new Promise((resolve) => {
// Set up timeout
const timer = setTimeout(() => {
this._pendingCallbacks.delete(messageId);
resolve({
success: false,
error: 'Execution timed out (10s)',
output: [],
result: null,
});
}, TIMEOUT_MS);
this._pendingCallbacks.set(messageId, { resolve, timer });
// Send code to sandbox
this._iframe.contentWindow.postMessage({
type: 'eval',
id: messageId,
code: code,
}, '*');
});
},
_ensureIframe() {
if (this._iframe) return Promise.resolve();
return new Promise((resolve) => {
// Build sandbox HTML as a blob URL
const sandboxHTML = this._buildSandboxHTML();
const blob = new Blob([sandboxHTML], { type: 'text/html' });
const blobURL = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;';
iframe.src = blobURL;
iframe.onload = () => {
this._iframe = iframe;
URL.revokeObjectURL(blobURL);
resolve();
};
// Listen for messages from sandbox
window.addEventListener('message', (event) => {
// Only accept messages from our sandbox iframe
if (!this._iframe || event.source !== this._iframe.contentWindow) return;
const data = event.data;
if (data?.type !== 'eval-result') return;
const pending = this._pendingCallbacks.get(data.id);
if (!pending) return;
clearTimeout(pending.timer);
this._pendingCallbacks.delete(data.id);
pending.resolve({
success: !data.error,
result: data.result !== undefined ? data.result : null,
output: data.output || [],
error: data.error || null,
});
});
document.body.appendChild(iframe);
});
},
_buildSandboxHTML() {
// This HTML runs INSIDE the sandboxed iframe.
// It has no access to the parent page, no network, no cookies.
return `<!DOCTYPE html>
<html><head><title>Sandbox</title></head>
<body><script>
(function() {
'use strict';
// Listen for eval messages from parent
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || msg.type !== 'eval') return;
var output = [];
var result = undefined;
var error = null;
// Override console methods to capture output
var origLog = console.log;
var origWarn = console.warn;
var origError = console.error;
console.log = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'log', text: args.map(stringify).join(' ') });
};
console.warn = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'warn', text: args.map(stringify).join(' ') });
};
console.error = function() {
var args = Array.prototype.slice.call(arguments);
output.push({ level: 'error', text: args.map(stringify).join(' ') });
};
try {
// Use indirect eval for global scope
result = (0, eval)(msg.code);
} catch (e) {
error = (e && e.message) ? e.message : String(e);
}
// Restore console
console.log = origLog;
console.warn = origWarn;
console.error = origError;
// Stringify result for safe transfer
var resultStr;
try {
resultStr = stringify(result);
} catch (e2) {
resultStr = '[unserializable]';
}
// Post result back to parent
event.source.postMessage({
type: 'eval-result',
id: msg.id,
result: resultStr,
output: output,
error: error,
}, '*');
});
function stringify(val) {
if (val === undefined) return 'undefined';
if (val === null) return 'null';
if (typeof val === 'function') return val.toString();
if (typeof val === 'object') {
try { return JSON.stringify(val, null, 2); }
catch(e) { return String(val); }
}
return String(val);
}
})();
</` + `script></body></html>`;
},
destroy() {
// Clean up pending callbacks
for (const [id, pending] of this._pendingCallbacks) {
clearTimeout(pending.timer);
pending.resolve({ success: false, error: 'Extension destroyed', output: [], result: null });
}
this._pendingCallbacks.clear();
// Remove iframe
if (this._iframe) {
this._iframe.remove();
this._iframe = null;
}
}
});

View File

@@ -0,0 +1,12 @@
{
"id": "katex-renderer",
"name": "KaTeX Math",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,297 @@
// ==========================================
// KaTeX Math Renderer — Browser Extension
// ==========================================
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
// Loads KaTeX dynamically on first use.
// ==========================================
Extensions.register({
id: 'katex-renderer',
_katexReady: false,
_katexLoading: null,
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```latex or ```math ──
ctx.renderers.register('katex-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
},
render(lang, code, container) {
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="katex-block ext-rendered" data-katex-id="${id}">
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
<div class="katex-loading">
<span class="katex-spinner"></span> Rendering math…
</div>
</div>
<details class="katex-source">
<summary>𝑓(𝑥) View source</summary>
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: render KaTeX blocks + find inline math ──
ctx.renderers.register('katex-post', {
type: 'post',
priority: 10,
render(container) {
// Render code blocks with data-katex-src
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
if (blocks.length > 0) {
blocks.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderBlock(el);
});
}
// Process inline math in text content
self._processInlineMath(container);
}
});
// Pre-load KaTeX
this._loadKaTeX();
},
async _renderBlock(el) {
const code = decodeURIComponent(el.dataset.katexSrc);
const displayMode = el.dataset.katexDisplay === 'true';
try {
await this._loadKaTeX();
el.innerHTML = katex.renderToString(code, {
displayMode,
throwOnError: false,
trust: false,
strict: false,
});
el.dataset.rendered = 'true';
} catch (e) {
el.innerHTML = `
<div class="katex-error">
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
async _processInlineMath(container) {
// Skip if no $ signs present at all
if (!container.textContent || !container.textContent.includes('$')) return;
try {
await this._loadKaTeX();
} catch {
return;
}
// Walk text nodes, skip code/pre/katex-already-rendered
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
// Skip code, pre, already-rendered katex, and script elements
const tag = parent.tagName;
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
tag === 'TEXTAREA' || tag === 'STYLE') {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
return NodeFilter.FILTER_REJECT;
}
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
const textNodes = [];
let node;
while (node = walker.nextNode()) textNodes.push(node);
for (const textNode of textNodes) {
this._replaceInlineMath(textNode);
}
},
_replaceInlineMath(textNode) {
const text = textNode.textContent;
// Match $$...$$ (display) and $...$ (inline), non-greedy
// Avoid matching escaped \$ or empty $$
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
let match;
const parts = [];
let lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
// Text before the match
if (match.index > lastIndex) {
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
const displayMode = !!match[1]; // $$ ... $$
const expr = match[1] || match[2];
try {
const html = katex.renderToString(expr.trim(), {
displayMode,
throwOnError: false,
trust: false,
strict: false,
});
parts.push({ type: 'katex', value: html, displayMode });
} catch {
// Leave as-is on error
parts.push({ type: 'text', value: match[0] });
}
lastIndex = match.index + match[0].length;
}
if (parts.length === 0) return; // No math found
// Remaining text after last match
if (lastIndex < text.length) {
parts.push({ type: 'text', value: text.slice(lastIndex) });
}
// Replace text node with a span containing the rendered parts
const span = document.createElement('span');
span.className = 'katex-inline-container';
for (const part of parts) {
if (part.type === 'text') {
span.appendChild(document.createTextNode(part.value));
} else {
const wrapper = document.createElement('span');
wrapper.className = part.displayMode ? 'katex-display' : 'katex-inline';
wrapper.innerHTML = part.value;
span.appendChild(wrapper);
}
}
textNode.parentNode.replaceChild(span, textNode);
},
_loadKaTeX() {
if (this._katexReady) return Promise.resolve();
if (this._katexLoading) return this._katexLoading;
this._katexLoading = new Promise((resolve, reject) => {
if (typeof katex !== 'undefined') {
this._katexReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localCSS = `${base}/vendor/katex/katex.min.css`;
const localJS = `${base}/vendor/katex/katex.min.js`;
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
// Load CSS first
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = localCSS;
link.onerror = () => {
link.href = cdnCSS;
};
document.head.appendChild(link);
// Load JS
const script = document.createElement('script');
script.src = localJS;
script.onload = () => {
this._katexReady = true;
resolve();
};
script.onerror = () => {
console.warn('[KaTeX] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnJS;
cdn.onload = () => {
this._katexReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[KaTeX] Failed to load from both local and CDN');
reject(new Error('Failed to load KaTeX'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._katexLoading;
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
_injectStyles() {
if (document.getElementById('ext-style-katex-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-katex-renderer';
style.textContent = `
.katex-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.katex-display-container {
padding: 16px; text-align: center; min-height: 40px;
}
.katex-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.katex-spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: katex-spin 0.8s linear infinite;
}
@keyframes katex-spin { to { transform: rotate(360deg); } }
.katex-error {
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
.katex-source { border-top: 1px solid var(--border); font-size: 12px; }
.katex-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.katex-source summary:hover { color: var(--text-2); }
.katex-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
.katex-inline-container { display: inline; }
.katex-inline .katex { font-size: 1.05em; }
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
document.head.appendChild(style);
},
destroy() {
document.getElementById('ext-style-katex-renderer')?.remove();
}
});

View File

@@ -0,0 +1,12 @@
{
"id": "mermaid-renderer",
"name": "Mermaid Diagrams",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```mermaid code blocks as SVG diagrams using mermaid.js",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,209 @@
// ==========================================
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as SVG diagrams.
// Loads mermaid.js dynamically on first use.
//
// Install: Admin → Extensions → paste manifest + this script.
// ==========================================
Extensions.register({
id: 'mermaid-renderer',
_mermaidReady: false,
_mermaidLoading: null,
_renderQueue: [],
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```mermaid, output placeholder ──
ctx.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram…
</div>
</div>
<details class="mermaid-source">
<summary>📊 View source</summary>
<pre><code class="language-mermaid">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: find placeholders, render with mermaid.js ──
ctx.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
if (diagrams.length === 0) return;
diagrams.forEach(el => {
// Skip already-rendered diagrams
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderDiagram(el);
});
}
});
// Pre-load mermaid library
this._loadMermaid();
},
async _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
try {
await this._loadMermaid();
const id = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(id, code);
el.innerHTML = svg;
el.dataset.rendered = 'true';
// Styling handled by .mermaid-diagram svg in CSS
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
}
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
_loadMermaid() {
if (this._mermaidReady) return Promise.resolve();
if (this._mermaidLoading) return this._mermaidLoading;
this._mermaidLoading = new Promise((resolve, reject) => {
// Check if already loaded (e.g. by another script)
if (typeof mermaid !== 'undefined') {
this._initMermaid();
this._mermaidReady = true;
resolve();
return;
}
// Try local vendor first, fall back to CDN
const base = (window.__BASE__ || '');
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
const script = document.createElement('script');
script.src = localSrc;
script.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
script.onerror = () => {
// Local failed — try CDN
console.warn('[Mermaid] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnSrc;
cdn.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[Mermaid] Failed to load from both local and CDN');
reject(new Error('Failed to load mermaid.js'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._mermaidLoading;
},
_initMermaid() {
if (typeof mermaid === 'undefined') return;
// Detect dark mode
const isDark = document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
},
_injectStyles() {
if (document.getElementById('ext-style-mermaid-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-mermaid-renderer';
style.textContent = `
.mermaid-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
.mermaid-diagram {
padding: 16px; text-align: center; min-height: 60px;
max-height: 600px; overflow: auto;
}
.mermaid-diagram svg {
max-width: 100%; max-height: 560px;
height: auto; width: auto;
}
.mermaid-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.mermaid-spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: mmd-spin 0.8s linear infinite;
}
@keyframes mmd-spin { to { transform: rotate(360deg); } }
.mermaid-error {
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
.mermaid-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
}
.mermaid-source summary:hover { color: var(--text-2); }
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
document.head.appendChild(style);
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
destroy() {
document.getElementById('ext-style-mermaid-renderer')?.remove();
}
});

View File

@@ -0,0 +1,36 @@
{
"id": "regex-tester",
"name": "Regex Tester",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
"permissions": [],
"tools": [
{
"name": "regex_test",
"description": "Test a regular expression against one or more input strings. Returns match details including captured groups, indices, and whether each string matched. Use this to verify regex patterns instead of guessing.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The regular expression pattern (without delimiters), e.g. '^\\d{3}-\\d{4}$'"
},
"flags": {
"type": "string",
"description": "Regex flags, e.g. 'gi' for global+case-insensitive. Default: '' (no flags)"
},
"inputs": {
"type": "array",
"items": { "type": "string" },
"description": "Array of test strings to match against the pattern"
}
},
"required": ["pattern", "inputs"]
}
}
],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,102 @@
// ==========================================
// Regex Tester — Browser Extension
// ==========================================
// Tests regular expressions against input strings.
// Registers the regex_test tool so the LLM can verify
// patterns instead of guessing.
// ==========================================
Extensions.register({
id: 'regex-tester',
async init(ctx) {
const self = this;
ctx.tools.handle('regex_test', async (args) => {
return self._test(args.pattern, args.flags || '', args.inputs || []);
});
},
_test(pattern, flags, inputs) {
// Validate pattern
let regex;
try {
regex = new RegExp(pattern, flags);
} catch (e) {
return {
success: false,
error: `Invalid regex: ${e.message}`,
pattern,
flags,
};
}
const results = inputs.map(input => {
// Reset lastIndex for global/sticky patterns
regex.lastIndex = 0;
const isGlobal = regex.global;
const matches = [];
if (isGlobal) {
// Collect all matches for global flag
let match;
while ((match = regex.exec(input)) !== null) {
matches.push(this._formatMatch(match));
// Prevent infinite loops on zero-length matches
if (match[0].length === 0) regex.lastIndex++;
}
} else {
const match = regex.exec(input);
if (match) {
matches.push(this._formatMatch(match));
}
}
return {
input,
matched: matches.length > 0,
matchCount: matches.length,
matches,
};
});
const totalMatched = results.filter(r => r.matched).length;
return {
success: true,
pattern,
flags,
summary: `${totalMatched}/${inputs.length} inputs matched`,
results,
};
},
_formatMatch(match) {
const result = {
fullMatch: match[0],
index: match.index,
length: match[0].length,
};
// Named groups (ES2018)
if (match.groups) {
result.namedGroups = {};
for (const [name, value] of Object.entries(match.groups)) {
result.namedGroups[name] = value;
}
}
// Numbered capture groups
if (match.length > 1) {
result.groups = [];
for (let i = 1; i < match.length; i++) {
result.groups.push(match[i] !== undefined ? match[i] : null);
}
}
return result;
},
destroy() {}
});

View File

@@ -0,0 +1,12 @@
{
"id": "mermaid-renderer",
"name": "Mermaid Diagrams",
"version": "1.0.0",
"tier": "browser",
"author": "switchboard",
"description": "Renders ```mermaid code blocks as SVG diagrams using mermaid.js",
"permissions": [],
"tools": [],
"surfaces": [],
"settings": {}
}

View File

@@ -0,0 +1,507 @@
// ==========================================
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as interactive SVG diagrams.
// Features: zoom/pan viewport, SVG/PNG export, source copy.
// Loads mermaid.js dynamically on first use.
// ==========================================
Extensions.register({
id: 'mermaid-renderer',
_mermaidReady: false,
_mermaidLoading: null,
async init(ctx) {
const self = this;
// ── Inject styles ──
this._injectStyles();
// ── Block renderer: match ```mermaid, output placeholder ──
ctx.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">📊 Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out"></button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">⊡</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram…
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>📋 View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── Post renderer: render diagrams + wire interactivity ──
ctx.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
if (diagrams.length === 0) return;
diagrams.forEach(el => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
self._renderDiagram(el);
});
// Wire toolbar buttons (event delegation on the container)
self._wireToolbar(container);
}
});
// Pre-load mermaid library
this._loadMermaid();
},
// ── Zoom/Pan State ──────────────────────
_getState(id) {
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return null;
if (!vp._mmdState) {
vp._mmdState = { scale: 1, panX: 0, panY: 0, dragging: false, startX: 0, startY: 0 };
}
return vp._mmdState;
},
_applyTransform(id) {
const state = this._getState(id);
if (!state) return;
const diagram = document.querySelector(`[data-diagram="${id}"]`);
if (!diagram) return;
diagram.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
const label = document.querySelector(`[data-zoom-label="${id}"]`);
if (label) label.textContent = Math.round(state.scale * 100) + '%';
},
_zoom(id, delta) {
const state = this._getState(id);
if (!state) return;
state.scale = Math.max(0.1, Math.min(5, state.scale * (1 + delta)));
this._applyTransform(id);
},
_zoomReset(id) {
const state = this._getState(id);
if (!state) return;
state.scale = 1; state.panX = 0; state.panY = 0;
this._applyTransform(id);
},
_zoomFit(id) {
const state = this._getState(id);
if (!state) return;
const vp = document.querySelector(`[data-viewport="${id}"]`);
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!vp || !svg) return;
// Reset to measure natural size
state.scale = 1; state.panX = 0; state.panY = 0;
diagram.style.transform = '';
requestAnimationFrame(() => {
const vpRect = vp.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
const scaleX = (vpRect.width - 32) / svgRect.width;
const scaleY = (vpRect.height - 32) / svgRect.height;
state.scale = Math.min(scaleX, scaleY, 2); // Cap at 2x
this._applyTransform(id);
});
},
// ── Toolbar Wiring ──────────────────────
_wireToolbar(container) {
const self = this;
// Skip if already wired
if (container._mmdWired) return;
container._mmdWired = true;
// Button clicks (delegation)
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.target;
if (!id) return;
switch (action) {
case 'zoom-in': self._zoom(id, 0.25); break;
case 'zoom-out': self._zoom(id, -0.2); break;
case 'zoom-reset': self._zoomReset(id); break;
case 'zoom-fit': self._zoomFit(id); break;
case 'export-svg': self._exportSVG(id); break;
case 'export-png': self._exportPNG(id); break;
case 'copy-src': self._copySource(id, btn); break;
}
});
// Wire each viewport for mouse/touch interaction
container.querySelectorAll('.mermaid-viewport').forEach(vp => {
if (vp._mmdWired) return;
vp._mmdWired = true;
const id = vp.dataset.viewport;
// Mouse wheel zoom
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
self._zoom(id, delta);
}, { passive: false });
// Pan: mousedown → mousemove → mouseup
vp.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const state = self._getState(id);
if (!state) return;
state.dragging = true;
state.startX = e.clientX - state.panX;
state.startY = e.clientY - state.panY;
vp.classList.add('mmd-grabbing');
e.preventDefault();
});
vp.addEventListener('mousemove', (e) => {
const state = self._getState(id);
if (!state?.dragging) return;
state.panX = e.clientX - state.startX;
state.panY = e.clientY - state.startY;
self._applyTransform(id);
});
const endDrag = () => {
const state = self._getState(id);
if (!state) return;
state.dragging = false;
vp.classList.remove('mmd-grabbing');
};
vp.addEventListener('mouseup', endDrag);
vp.addEventListener('mouseleave', endDrag);
// Touch: single-finger pan, two-finger pinch zoom
let lastTouchDist = 0;
vp.addEventListener('touchstart', (e) => {
const state = self._getState(id);
if (!state) return;
if (e.touches.length === 1) {
state.dragging = true;
state.startX = e.touches[0].clientX - state.panX;
state.startY = e.touches[0].clientY - state.panY;
} else if (e.touches.length === 2) {
lastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
}, { passive: true });
vp.addEventListener('touchmove', (e) => {
const state = self._getState(id);
if (!state) return;
if (e.touches.length === 1 && state.dragging) {
state.panX = e.touches[0].clientX - state.startX;
state.panY = e.touches[0].clientY - state.startY;
self._applyTransform(id);
e.preventDefault();
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
if (lastTouchDist > 0) {
self._zoom(id, (dist - lastTouchDist) / lastTouchDist);
}
lastTouchDist = dist;
e.preventDefault();
}
}, { passive: false });
vp.addEventListener('touchend', () => {
const state = self._getState(id);
if (state) state.dragging = false;
lastTouchDist = 0;
}, { passive: true });
});
},
// ── Export ───────────────────────────────
_exportSVG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
this._download(blob, `diagram-${id}.svg`);
},
_exportPNG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const self = this;
const img = new Image();
img.onload = () => {
const scale = 2; // 2x for retina
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth * scale;
canvas.height = img.naturalHeight * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) self._download(blob, `diagram-${id}.png`);
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
console.error('[Mermaid] PNG export failed');
};
img.src = url;
},
_download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
},
_copySource(id, btn) {
const code = document.querySelector(`[data-source="${id}"]`);
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => { btn.textContent = orig; }, 1500);
});
},
// ── Diagram Rendering ───────────────────
async _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
try {
await this._loadMermaid();
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(svgId, code);
el.innerHTML = svg;
el.dataset.rendered = 'true';
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
svgEl.style.maxWidth = 'none'; // Viewport handles sizing
}
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
},
// ── Mermaid Library Loading ──────────────
_loadMermaid() {
if (this._mermaidReady) return Promise.resolve();
if (this._mermaidLoading) return this._mermaidLoading;
this._mermaidLoading = new Promise((resolve, reject) => {
if (typeof mermaid !== 'undefined') {
this._initMermaid();
this._mermaidReady = true;
resolve();
return;
}
const base = (window.__BASE__ || '');
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
const script = document.createElement('script');
script.src = localSrc;
script.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
script.onerror = () => {
console.warn('[Mermaid] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnSrc;
cdn.onload = () => {
this._initMermaid();
this._mermaidReady = true;
resolve();
};
cdn.onerror = () => {
console.error('[Mermaid] Failed to load from both local and CDN');
reject(new Error('Failed to load mermaid.js'));
};
document.head.appendChild(cdn);
};
document.head.appendChild(script);
});
return this._mermaidLoading;
},
_initMermaid() {
if (typeof mermaid === 'undefined') return;
const isDark = document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
},
// ── Styles ──────────────────────────────
_injectStyles() {
if (document.getElementById('ext-style-mermaid-renderer')) return;
const style = document.createElement('style');
style.id = 'ext-style-mermaid-renderer';
style.textContent = `
.mermaid-block {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
/* Toolbar */
.mermaid-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3); flex-wrap: wrap;
}
.mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; }
.mermaid-zoom-label {
font-family: var(--mono); font-size: 11px; min-width: 36px;
text-align: center; color: var(--text-3);
}
.mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; }
.mmd-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 1px 7px; font-size: 11px; cursor: pointer;
color: var(--text-3); font-family: var(--mono); line-height: 1.6;
}
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
/* Viewport: zoom/pan container */
.mermaid-viewport {
overflow: hidden; position: relative;
min-height: 80px; max-height: 600px;
cursor: grab; user-select: none;
}
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
/* Diagram: the transform target */
.mermaid-diagram {
padding: 16px; text-align: center; min-height: 60px;
transform-origin: center center;
will-change: transform;
}
.mermaid-diagram svg { height: auto; }
/* Loading / Error */
.mermaid-loading {
color: var(--text-3); font-size: 13px; padding: 20px;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.mermaid-spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: mmd-spin 0.8s linear infinite;
}
@keyframes mmd-spin { to { transform: rotate(360deg); } }
.mermaid-error {
color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1));
padding: 12px 16px; border-radius: 4px; font-size: 13px;
font-family: var(--mono); white-space: pre-wrap;
}
/* Source panel */
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
.mermaid-source summary {
padding: 6px 12px; cursor: pointer; color: var(--text-3);
user-select: none; display: flex; align-items: center; gap: 8px;
}
.mermaid-source summary:hover { color: var(--text-2); }
.mermaid-source summary span { flex: 1; }
.mmd-copy-src { font-size: 11px; }
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
document.head.appendChild(style);
},
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
destroy() {
document.getElementById('ext-style-mermaid-renderer')?.remove();
}
});

View File

@@ -46,15 +46,16 @@ spec:
number: 8080
# Health check → backend
- path: ${BASE_PATH}/health
pathType: Exact
pathType: Prefix
backend:
service:
name: switchboard-be${DEPLOY_SUFFIX}
port:
number: 8080
# WebSocket → backend (traefik handles upgrade automatically)
# WebSocket → backend (must be Prefix, not Exact — Traefik's Exact
# doesn't reliably win over Prefix rules in the same Ingress resource)
- path: ${BASE_PATH}/ws
pathType: Exact
pathType: Prefix
backend:
service:
name: switchboard-be${DEPLOY_SUFFIX}

View File

@@ -2,7 +2,7 @@
# Chat Switchboard - Backend Dockerfile
# ==========================================
# Standalone Go API server for cluster deployment.
# Build context is ./server (not repo root).
# Build context is the repo root (not ./server).
# ==========================================
# Build stage
@@ -13,11 +13,11 @@ ARG APP_VERSION=dev
WORKDIR /app
# Copy module files and download deps
COPY go.mod go.sum* ./
COPY server/go.mod server/go.sum* ./
RUN go mod download
# Copy source and tidy (resolves any go.sum gaps)
COPY . .
COPY server/ .
RUN go mod tidy
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
@@ -31,6 +31,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations
# Builtin extensions (seeded into DB on startup)
COPY extensions/builtin/ /app/extensions/builtin/
WORKDIR /app
EXPOSE 8080

View File

@@ -0,0 +1,41 @@
-- Migration 006: Extension system tables
--
-- Extensions are installable plugins that add renderers, tools, surfaces,
-- and event handlers. Three tiers: browser (JS), starlark (sandbox),
-- sidecar (container). Browser tier ships first (v0.11.0).
--
-- See EXTENSIONS.md for full spec.
-- ── Extension registry ──────────────────────────
CREATE TABLE IF NOT EXISTS extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id (e.g. "collapsible-code")
name VARCHAR(200) NOT NULL, -- human label
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser | starlark | sidecar
description TEXT NOT NULL DEFAULT '',
author VARCHAR(200) NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}', -- full manifest JSON
is_system BOOLEAN NOT NULL DEFAULT false, -- admin-pushed, users can't disable
is_enabled BOOLEAN NOT NULL DEFAULT true, -- global enable/disable
scope VARCHAR(20) NOT NULL DEFAULT 'global', -- global | team | personal
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- For bundled extensions that ship with core
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
-- ── Per-user extension settings ─────────────────
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}', -- user's config values
is_enabled BOOLEAN NOT NULL DEFAULT true, -- per-user toggle
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -3,6 +3,7 @@ package events
import (
"strings"
"sync"
"time"
)
// Bus is a labeled publish/subscribe event bus.
@@ -114,3 +115,28 @@ func match(label, pattern string) bool {
prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*"
return strings.HasPrefix(label, prefix)
}
// WaitFor subscribes to an exact label and blocks until an event arrives
// or the timeout elapses. Returns the event and true, or a zero Event and
// false on timeout. The subscription is automatically removed after.
//
// Used by the browser tool bridge: the server publishes tool.call.{id}
// to the client and then calls WaitFor("tool.result.{id}", 30s) to block
// until the browser returns the result.
func (b *Bus) WaitFor(label string, timeout time.Duration) (Event, bool) {
ch := make(chan Event, 1)
unsub := b.Subscribe(label, func(e Event) {
select {
case ch <- e:
default:
}
})
defer unsub()
select {
case e := <-ch:
return e, true
case <-time.After(timeout):
return Event{}, false
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"sync/atomic"
"testing"
"time"
)
func TestMatch(t *testing.T) {
@@ -112,6 +113,12 @@ func TestRouteFor(t *testing.T) {
{"unknown.event", DirLocal}, // default
{"ping", DirFromClient},
{"pong", DirToClient},
// Tool bridge routes
{"tool.call.abc123", DirToClient},
{"tool.result.abc123", DirFromClient},
// Extension lifecycle
{"extension.loaded", DirLocal},
{"extension.error", DirLocal},
}
for _, tt := range tests {
@@ -121,3 +128,84 @@ func TestRouteFor(t *testing.T) {
}
}
}
func TestWaitFor_Success(t *testing.T) {
bus := NewBus()
// Publish after a short delay
go func() {
time.Sleep(10 * time.Millisecond)
bus.Publish(Event{
Label: "tool.result.abc123",
Payload: json.RawMessage(`{"result":"hello"}`),
})
}()
event, ok := bus.WaitFor("tool.result.abc123", 1*time.Second)
if !ok {
t.Fatal("WaitFor timed out, expected success")
}
if event.Label != "tool.result.abc123" {
t.Errorf("expected label tool.result.abc123, got %s", event.Label)
}
if string(event.Payload) != `{"result":"hello"}` {
t.Errorf("unexpected payload: %s", string(event.Payload))
}
}
func TestWaitFor_Timeout(t *testing.T) {
bus := NewBus()
_, ok := bus.WaitFor("tool.result.never", 50*time.Millisecond)
if ok {
t.Error("WaitFor should have timed out")
}
}
func TestWaitFor_IgnoresOtherLabels(t *testing.T) {
bus := NewBus()
go func() {
time.Sleep(5 * time.Millisecond)
bus.Publish(Event{Label: "tool.result.other", Payload: json.RawMessage(`{}`)})
time.Sleep(5 * time.Millisecond)
bus.Publish(Event{Label: "tool.result.target", Payload: json.RawMessage(`{"ok":true}`)})
}()
event, ok := bus.WaitFor("tool.result.target", 1*time.Second)
if !ok {
t.Fatal("WaitFor timed out")
}
if event.Label != "tool.result.target" {
t.Errorf("got wrong label: %s", event.Label)
}
}
func TestWaitFor_CleansUpSubscription(t *testing.T) {
bus := NewBus()
// WaitFor with immediate timeout
bus.WaitFor("tool.result.cleanup", 1*time.Millisecond)
time.Sleep(5 * time.Millisecond)
// Verify no panic when publishing to the label after cleanup
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)})
}
func TestToolCallRouteToClient(t *testing.T) {
if !ShouldSendToClient("tool.call.abc123") {
t.Error("tool.call.* should be sent to client")
}
if ShouldAcceptFromClient("tool.call.abc123") {
t.Error("tool.call.* should NOT be accepted from client")
}
}
func TestToolResultRouteFromClient(t *testing.T) {
if !ShouldAcceptFromClient("tool.result.abc123") {
t.Error("tool.result.* should be accepted from client")
}
if ShouldSendToClient("tool.result.abc123") {
t.Error("tool.result.* should NOT be sent to client")
}
}

View File

@@ -58,6 +58,14 @@ var routeTable = map[string]Direction{
"internal.": DirLocal,
"db.": DirLocal,
// Tool execution (browser tools bridge)
"tool.call.": DirToClient, // Server → specific client (browser tool invocation)
"tool.result.": DirFromClient, // Client → server (browser tool result)
// Extension lifecycle
"extension.loaded": DirLocal, // Client-only
"extension.error": DirLocal,
// Heartbeat
"ping": DirFromClient,
"pong": DirToClient,

View File

@@ -118,6 +118,42 @@ func (h *Hub) ConnsByUser(userID string) []*Conn {
return result
}
// IsConnected returns true if the user has at least one active WebSocket.
func (h *Hub) IsConnected(userID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.conns {
if c.userID == userID {
return true
}
}
return false
}
// SendToUser sends an event directly to all WebSocket connections for a user,
// bypassing room filtering. Used for targeted tool.call delivery.
func (h *Hub) SendToUser(userID string, event Event) {
data, err := json.Marshal(event)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.conns {
if c.userID == userID {
select {
case c.send <- data:
default:
}
}
}
}
// GetBus returns the underlying event bus.
func (h *Hub) GetBus() *Bus {
return h.bus
}
// removeConn cleans up a connection.
func (h *Hub) removeConn(conn *Conn) {
h.mu.Lock()

View File

@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
@@ -39,11 +40,12 @@ type completionRequest struct {
type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
}
// ── Chat Completion ─────────────────────────
@@ -171,8 +173,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Attach tool definitions if model supports tool calling and tools are available
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = h.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// Determine streaming
@@ -189,19 +192,56 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// buildToolDefs converts registered tools to provider-format tool definitions.
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool) []providers.ToolDef {
allTools := tools.AllDefinitions()
defs := make([]providers.ToolDef, len(allTools))
for i, t := range allTools {
defs[i] = providers.ToolDef{
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
// Append browser-defined tool schemas from extensions
if includeBrowser {
exts, err := h.stores.Extensions.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
}
return defs
}
@@ -216,7 +256,7 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
// Persist assistant response
if result.Content != "" {

View File

@@ -0,0 +1,346 @@
package handlers
import (
"database/sql"
"encoding/json"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ExtensionHandler serves extension management endpoints.
type ExtensionHandler struct {
stores store.Stores
}
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
return &ExtensionHandler{stores: stores}
}
// ── User endpoints ──────────────────────────────
// ListUserExtensions returns all enabled extensions for the current user,
// with user-specific settings and enabled state merged in.
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier") // optional filter
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]models.UserExtension, 0)
for _, e := range exts {
if e.Tier == tier {
filtered = append(filtered, e)
}
}
exts = filtered
}
c.JSON(200, gin.H{"data": exts})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
extID := c.Param("id")
// Verify extension exists
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
// System extensions can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
enabled := true
if body.IsEnabled != nil {
if ext.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
enabled = *body.IsEnabled
}
settings := json.RawMessage("{}")
if body.Settings != nil {
settings = body.Settings
}
eus := &models.ExtensionUserSettings{
ExtensionID: extID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extensions (enabled and disabled).
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
c.JSON(200, gin.H{"data": exts})
}
// AdminInstallExtension installs an extension from a manifest.
// POST /api/v1/admin/extensions
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
var body struct {
ExtID string `json:"ext_id" binding:"required"`
Name string `json:"name" binding:"required"`
Version string `json:"version"`
Tier string `json:"tier"`
Description string `json:"description"`
Author string `json:"author"`
Manifest json.RawMessage `json:"manifest"`
IsSystem bool `json:"is_system"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
return
}
// Defaults
if body.Version == "" {
body.Version = "0.0.0"
}
if body.Tier == "" {
body.Tier = models.ExtTierBrowser
}
if body.Manifest == nil {
body.Manifest = json.RawMessage("{}")
}
// Check for duplicate ext_id
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
if err != nil && err != sql.ErrNoRows {
c.JSON(500, gin.H{"error": "failed to check existing extension"})
return
}
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
ext := &models.Extension{
ExtID: body.ExtID,
Name: body.Name,
Version: body.Version,
Tier: body.Tier,
Description: body.Description,
Author: body.Author,
Manifest: body.Manifest,
IsSystem: body.IsSystem,
IsEnabled: body.IsEnabled,
Scope: models.ScopeGlobal,
InstalledBy: &userID,
}
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
c.JSON(500, gin.H{"error": "failed to install extension"})
return
}
c.JSON(201, gin.H{"data": ext})
}
// AdminUpdateExtension updates an extension's config.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsSystem *bool `json:"is_system"`
IsEnabled *bool `json:"is_enabled"`
Manifest *json.RawMessage `json:"manifest"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
if body.Name != nil {
ext.Name = *body.Name
}
if body.Description != nil {
ext.Description = *body.Description
}
if body.IsSystem != nil {
ext.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
ext.IsEnabled = *body.IsEnabled
}
if body.Manifest != nil {
ext.Manifest = *body.Manifest
}
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": ext})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
//
// For the MVP, scripts are stored inline in manifest._script.
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
extID := c.Param("id")
// path := c.Param("path") // reserved for future multi-file support
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if !ext.IsEnabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
var manifest map[string]json.RawMessage
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
scriptRaw, ok := manifest["_script"]
if !ok {
c.JSON(404, gin.H{"error": "no script in manifest"})
return
}
// _script is a JSON string — unquote it
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
return
}
c.Header("Content-Type", "application/javascript; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(200, script)
}
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
extID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
c.JSON(200, gin.H{"data": ext.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// Used by the completion handler to include browser tools in LLM requests.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
}
if toolSchemas == nil {
toolSchemas = make([]json.RawMessage, 0)
}
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}

View File

@@ -134,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/channels", channels.CreateChannel)
// Completions
completions := NewCompletionHandler(nil, stores)
completions := NewCompletionHandler(nil, stores, nil)
protected.POST("/chat/completions", completions.Complete)
// Admin routes

View File

@@ -13,6 +13,7 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -59,11 +60,12 @@ type cursorRequest struct {
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores}
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub}
}
// ── List Messages (flat, all branches) ──────
@@ -358,7 +360,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores)
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
var presetSystemPrompt string
model := req.Model
@@ -449,13 +451,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
// Attach tool definitions (same as normal completion)
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = comp.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -0,0 +1,178 @@
package handlers
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// builtinManifest is the on-disk manifest.json shape.
type builtinManifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Tier string `json:"tier"`
Author string `json:"author"`
Description string `json:"description"`
Permissions json.RawMessage `json:"permissions"`
Tools json.RawMessage `json:"tools"`
Surfaces json.RawMessage `json:"surfaces"`
Settings json.RawMessage `json:"settings"`
}
// SeedBuiltinExtensions scans a directory for builtin extension bundles
// (each subdirectory contains manifest.json + script.js) and upserts them
// into the extensions table as system extensions.
//
// Layout:
//
// extensions/builtin/
// mermaid-renderer/
// manifest.json
// script.js
//
// Idempotent: skips extensions whose version matches what's already installed,
// updates those with a newer version, creates new ones.
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
if stores.Extensions == nil {
return
}
entries, err := os.ReadDir(extensionsDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf(" 📦 No builtin extensions directory at %s (skipping)", extensionsDir)
return
}
log.Printf("⚠ Failed to read extensions directory %s: %v", extensionsDir, err)
return
}
ctx := context.Background()
seeded, updated, skipped := 0, 0, 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
extDir := filepath.Join(extensionsDir, entry.Name())
manifestPath := filepath.Join(extDir, "manifest.json")
scriptPath := filepath.Join(extDir, "script.js")
// Read manifest
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
log.Printf("⚠ Skipping %s: no manifest.json: %v", entry.Name(), err)
continue
}
var manifest builtinManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
log.Printf("⚠ Skipping %s: invalid manifest.json: %v", entry.Name(), err)
continue
}
if manifest.ID == "" {
log.Printf("⚠ Skipping %s: manifest missing 'id'", entry.Name())
continue
}
// Read script (optional — some extensions might be manifest-only)
var script string
if scriptData, err := os.ReadFile(scriptPath); err == nil {
script = string(scriptData)
}
// Build the full manifest JSONB with _script inlined
fullManifest := buildFullManifest(manifestData, script)
// Check if already installed
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
if err == nil {
// Already exists — check version
if existing.Version == manifest.Version {
skipped++
continue
}
// Version changed — update
existing.Name = manifest.Name
existing.Version = manifest.Version
existing.Description = manifest.Description
existing.Author = manifest.Author
existing.Manifest = fullManifest
existing.IsSystem = true
existing.IsEnabled = true
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
continue
}
updated++
continue
}
// New extension — create
tier := manifest.Tier
if tier == "" {
tier = models.ExtTierBrowser
}
ext := &models.Extension{
ExtID: manifest.ID,
Name: manifest.Name,
Version: manifest.Version,
Tier: tier,
Description: manifest.Description,
Author: manifest.Author,
Manifest: fullManifest,
IsSystem: true,
IsEnabled: true,
Scope: "global",
}
if err := stores.Extensions.Create(ctx, ext); err != nil {
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
continue
}
seeded++
}
if seeded+updated > 0 {
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
} else if skipped > 0 {
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
}
}
// buildFullManifest merges the raw manifest JSON with an inline _script field.
func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
if script == "" {
return json.RawMessage(rawManifest)
}
// Parse manifest as generic map, add _script, re-serialize
var m map[string]json.RawMessage
if err := json.Unmarshal(rawManifest, &m); err != nil {
// Fallback: just return raw manifest
return json.RawMessage(rawManifest)
}
scriptJSON, err := json.Marshal(script)
if err != nil {
return json.RawMessage(rawManifest)
}
m["_script"] = json.RawMessage(scriptJSON)
result, err := json.Marshal(m)
if err != nil {
return json.RawMessage(rawManifest)
}
return json.RawMessage(result)
}

View File

@@ -1,13 +1,17 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -40,6 +44,7 @@ func streamWithToolLoop(
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID string,
hub *events.Hub,
) streamResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
@@ -171,8 +176,25 @@ func streamWithToolLoop(
Arguments: tc.Function.Arguments,
}
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
var toolResult tools.ToolResult
// Check if tool is registered locally (server-side)
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
} else if hub != nil && hub.IsConnected(userID) {
// Route to browser via WebSocket bridge
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
toolResult = executeBrowserTool(hub, userID, call)
} else {
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
toolResult = tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
@@ -214,3 +236,68 @@ func streamWithToolLoop(
return result
}
const browserToolTimeout = 30 * time.Second
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
b := make([]byte, 16)
rand.Read(b)
callID := hex.EncodeToString(b)
// Publish tool.call event to the user's browser
payload, _ := json.Marshal(map[string]interface{}{
"call_id": callID,
"tool": call.Name,
"arguments": json.RawMessage(call.Arguments),
})
hub.SendToUser(userID, events.Event{
Label: "tool.call." + callID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
// Block until browser sends tool.result.{callID} or timeout
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
if !ok {
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
IsError: true,
}
}
// Parse result from the browser event payload
var result struct {
CallID string `json:"call_id"`
Result string `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(event.Payload, &result); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid result from browser tool"}`,
IsError: true,
}
}
if result.Error != "" {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Error,
IsError: true,
}
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Result,
}
}

View File

@@ -67,6 +67,9 @@ func main() {
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
}
defer database.Close()
@@ -126,6 +129,12 @@ func main() {
authGroup.POST("/logout", auth.Logout)
}
// ── Public extension assets ────────────────
// Script tags can't send Authorization headers, so asset serving must be public.
// Extension JS is the same for all users — no user-specific data.
extH := handlers.NewExtensionHandler(stores)
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
@@ -139,7 +148,7 @@ func main() {
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores)
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -151,7 +160,7 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores)
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
protected.POST("/chat/completions", comp.Complete)
// Summarize & Continue
@@ -258,6 +267,12 @@ func main() {
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
// Extensions (user-facing)
protected.GET("/extensions", extH.ListUserExtensions)
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
}
// ── Admin routes ────────────────────────
@@ -336,6 +351,13 @@ func main() {
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
}
}

View File

@@ -536,6 +536,49 @@ func (m *JSONMap) Scan(src interface{}) error {
return nil
}
// ── Extensions ──────────────────────────────
// Extension tier constants
const (
ExtTierBrowser = "browser"
ExtTierStarlark = "starlark"
ExtTierSidecar = "sidecar"
)
// Extension represents an installed extension in the registry.
type Extension struct {
ID string `json:"id" db:"id"`
ExtID string `json:"ext_id" db:"ext_id"` // manifest id
Name string `json:"name" db:"name"`
Version string `json:"version" db:"version"`
Tier string `json:"tier" db:"tier"`
Description string `json:"description" db:"description"`
Author string `json:"author" db:"author"`
Manifest json.RawMessage `json:"manifest" db:"manifest"`
IsSystem bool `json:"is_system" db:"is_system"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ExtensionUserSettings stores per-user overrides for an extension.
type ExtensionUserSettings struct {
ExtensionID string `json:"extension_id" db:"extension_id"`
UserID string `json:"user_id" db:"user_id"`
Settings json.RawMessage `json:"settings" db:"settings"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
}
// UserExtension combines extension info with user-specific settings for API responses.
type UserExtension struct {
Extension
UserEnabled *bool `json:"user_enabled,omitempty"`
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
}
func NullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{}

View File

@@ -32,6 +32,7 @@ type Stores struct {
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
}
// =========================================
@@ -316,6 +317,29 @@ type PricingStore interface {
Delete(ctx context.Context, providerConfigID, modelID string) error
}
// =========================================
// EXTENSION STORE
// =========================================
type ExtensionStore interface {
// Admin CRUD
Create(ctx context.Context, ext *models.Extension) error
GetByID(ctx context.Context, id string) (*models.Extension, error)
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
Update(ctx context.Context, id string, ext *models.Extension) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Extension, error)
ListEnabled(ctx context.Context) ([]models.Extension, error)
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
// User settings
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -0,0 +1,194 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
return DB.QueryRowContext(ctx, `
INSERT INTO extensions (ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = $2, version = $3, description = $4, author = $5,
manifest = $6, is_system = $7, is_enabled = $8,
updated_at = now()
WHERE id = $1`,
id, ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = $1
WHERE e.is_enabled = true
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
&ue.CreatedAt, &ue.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -25,5 +25,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
}
}

384
server/tools/calculator.go Normal file
View File

@@ -0,0 +1,384 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
func init() {
Register(&CalculatorTool{})
}
// ═══════════════════════════════════════════
// calculator
// ═══════════════════════════════════════════
type CalculatorTool struct{}
func (t *CalculatorTool) Definition() ToolDef {
return ToolDef{
Name: "calculator",
Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.",
Parameters: JSONSchema(map[string]interface{}{
"expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"),
}, []string{"expression"}),
}
}
func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Expression string `json:"expression"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Expression == "" {
return "", fmt.Errorf("expression is required")
}
// Normalize: replace ^ with ** for power, then rewrite for Go parser
expr := normalizeExpr(args.Expression)
result, err := evalExpr(expr)
if err != nil {
return "", fmt.Errorf("evaluation error: %w", err)
}
// Format result nicely
var display string
if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) {
display = strconv.FormatFloat(result, 'f', 0, 64)
} else {
display = strconv.FormatFloat(result, 'g', 15, 64)
}
resp := map[string]interface{}{
"expression": args.Expression,
"result": result,
"display": display,
}
b, _ := json.Marshal(resp)
return string(b), nil
}
// ── Expression Normalization ────────────────
func normalizeExpr(s string) string {
// Replace common aliases
s = strings.ReplaceAll(s, "×", "*")
s = strings.ReplaceAll(s, "÷", "/")
s = strings.ReplaceAll(s, "**", "^")
return s
}
// ── Recursive Descent Evaluator ─────────────
// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants
type calcParser struct {
input string
pos int
}
func evalExpr(input string) (float64, error) {
p := &calcParser{input: strings.TrimSpace(input)}
result, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) {
return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos]))
}
return result, nil
}
func (p *calcParser) parseExpr() (float64, error) {
return p.parseAddSub()
}
func (p *calcParser) parseAddSub() (float64, error) {
left, err := p.parseMulDiv()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '+' && op != '-' {
break
}
p.pos++
right, err := p.parseMulDiv()
if err != nil {
return 0, err
}
if op == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *calcParser) parseMulDiv() (float64, error) {
left, err := p.parsePower()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
if p.pos >= len(p.input) {
break
}
op := p.input[p.pos]
if op != '*' && op != '/' && op != '%' {
break
}
p.pos++
right, err := p.parsePower()
if err != nil {
return 0, err
}
switch op {
case '*':
left *= right
case '/':
if right == 0 {
return 0, fmt.Errorf("division by zero")
}
left /= right
case '%':
if right == 0 {
return 0, fmt.Errorf("modulo by zero")
}
left = math.Mod(left, right)
}
}
return left, nil
}
func (p *calcParser) parsePower() (float64, error) {
base, err := p.parseUnary()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '^' {
p.pos++
// Right-associative: 2^3^2 = 2^(3^2)
exp, err := p.parsePower()
if err != nil {
return 0, err
}
return math.Pow(base, exp), nil
}
return base, nil
}
func (p *calcParser) parseUnary() (float64, error) {
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '-' {
p.pos++
val, err := p.parseUnary()
if err != nil {
return 0, err
}
return -val, nil
}
if p.pos < len(p.input) && p.input[p.pos] == '+' {
p.pos++
return p.parseUnary()
}
return p.parsePrimary()
}
func (p *calcParser) parsePrimary() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.input) {
return 0, fmt.Errorf("unexpected end of expression")
}
// Parenthesized expression
if p.input[p.pos] == '(' {
p.pos++
val, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return val, nil
}
// Identifier (function or constant)
if isAlpha(p.input[p.pos]) {
name := p.parseIdent()
return p.evalIdentifier(name)
}
// Number
return p.parseNumber()
}
func (p *calcParser) parseIdent() string {
start := p.pos
for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) {
p.pos++
}
return strings.ToLower(p.input[start:p.pos])
}
func (p *calcParser) evalIdentifier(name string) (float64, error) {
// Constants
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
case "inf":
return math.Inf(1), nil
}
// Functions (require parenthesized argument)
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != '(' {
return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name)
}
p.pos++ // consume '('
arg, err := p.parseExpr()
if err != nil {
return 0, err
}
// Check for second argument (e.g. pow(2, 3))
p.skipSpaces()
var arg2 float64
hasArg2 := false
if p.pos < len(p.input) && p.input[p.pos] == ',' {
p.pos++
arg2, err = p.parseExpr()
if err != nil {
return 0, err
}
hasArg2 = true
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return 0, fmt.Errorf("missing closing parenthesis for %s()", name)
}
p.pos++
switch name {
case "sqrt":
return math.Sqrt(arg), nil
case "abs":
return math.Abs(arg), nil
case "ceil":
return math.Ceil(arg), nil
case "floor":
return math.Floor(arg), nil
case "round":
return math.Round(arg), nil
case "log", "ln":
return math.Log(arg), nil
case "log2":
return math.Log2(arg), nil
case "log10":
return math.Log10(arg), nil
case "sin":
return math.Sin(arg), nil
case "cos":
return math.Cos(arg), nil
case "tan":
return math.Tan(arg), nil
case "asin":
return math.Asin(arg), nil
case "acos":
return math.Acos(arg), nil
case "atan":
return math.Atan(arg), nil
case "exp":
return math.Exp(arg), nil
case "pow":
if !hasArg2 {
return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)")
}
return math.Pow(arg, arg2), nil
case "max":
if !hasArg2 {
return 0, fmt.Errorf("max() requires two arguments")
}
return math.Max(arg, arg2), nil
case "min":
if !hasArg2 {
return 0, fmt.Errorf("min() requires two arguments")
}
return math.Min(arg, arg2), nil
default:
return 0, fmt.Errorf("unknown function %q", name)
}
}
func (p *calcParser) parseNumber() (float64, error) {
start := p.pos
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
}
if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) {
if p.pos > start {
// lone dot
return 0, fmt.Errorf("invalid number at position %d", start)
}
return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos]))
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
if p.pos < len(p.input) && p.input[p.pos] == '.' {
p.pos++
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
// Scientific notation
if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
p.pos++
if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
p.pos++
}
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
p.pos++
}
}
val, err := strconv.ParseFloat(p.input[start:p.pos], 64)
if err != nil {
return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos])
}
return val, nil
}
func (p *calcParser) skipSpaces() {
for p.pos < len(p.input) && p.input[p.pos] == ' ' {
p.pos++
}
}
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' }
func isDigit(b byte) bool { return b >= '0' && b <= '9' }

64
server/tools/datetime.go Normal file
View File

@@ -0,0 +1,64 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
)
func init() {
Register(&DateTimeTool{})
}
// ═══════════════════════════════════════════
// datetime
// ═══════════════════════════════════════════
type DateTimeTool struct{}
func (t *DateTimeTool) Definition() ToolDef {
return ToolDef{
Name: "datetime",
Description: "Get the current date, time, timezone, and day of week. Use this whenever you need to know the current date or time, calculate days between dates, or answer questions about what day it is. Do NOT guess dates — always call this tool.",
Parameters: JSONSchema(map[string]interface{}{
"timezone": Prop("string", "IANA timezone name, e.g. 'America/New_York', 'UTC', 'Europe/London'. Defaults to UTC."),
}, nil),
}
}
func (t *DateTimeTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Timezone string `json:"timezone"`
}
if argsJSON != "" {
_ = json.Unmarshal([]byte(argsJSON), &args)
}
loc := time.UTC
if args.Timezone != "" {
parsed, err := time.LoadLocation(args.Timezone)
if err != nil {
return "", fmt.Errorf("invalid timezone %q: %w", args.Timezone, err)
}
loc = parsed
}
now := time.Now().In(loc)
year, week := now.ISOWeek()
result := map[string]interface{}{
"datetime": now.Format(time.RFC3339),
"date": now.Format("2006-01-02"),
"time": now.Format("15:04:05"),
"day": now.Weekday().String(),
"timezone": loc.String(),
"unix": now.Unix(),
"iso_week": fmt.Sprintf("%d-W%02d", year, week),
"day_of_year": now.YearDay(),
}
b, _ := json.Marshal(result)
return string(b), nil
}

View File

@@ -570,6 +570,12 @@ a:hover { text-decoration: underline; }
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
}
/* ── Extension-rendered blocks ──────── */
.ext-rendered {
margin: 12px 0;
}
/* HTML preview */
/* ── Side Panel (Preview + Notes) ──────── */
@@ -1225,6 +1231,13 @@ button { font-family: var(--font); cursor: pointer; }
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1rem; margin-bottom: 1rem;
}
.ext-edit-form {
background: var(--bg); padding: 12px 0;
}
.ext-edit-form textarea {
resize: vertical; min-height: 60px;
line-height: 1.5; white-space: pre; overflow-wrap: normal; overflow-x: auto;
}
.admin-user-row {
display: flex; align-items: center; justify-content: space-between;

View File

@@ -565,6 +565,7 @@
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
@@ -738,6 +739,33 @@
<div id="adminPricingTable"></div>
</div>
<div class="admin-tab-content" id="adminExtensionsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminInstallExtBtn">+ Install Extension</button>
</div>
<div id="adminExtensionsList"></div>
<div id="adminInstallExtForm" style="display:none;margin-top:12px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Extension ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>
<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0" value="1.0.0"></div>
<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="extInstallDesc"></div>
<div class="form-group"><label>Manifest JSON <span class="text-muted">(optional)</span></label><textarea id="extInstallManifest" rows="4" placeholder='{"tools":[], "permissions":[]}'></textarea></div>
<div class="form-group"><label>Script <span class="text-muted">(JavaScript source)</span></label><textarea id="extInstallScript" rows="6" placeholder="Extensions.register({ id: 'my-ext', init(ctx) { ... } });"></textarea></div>
<div class="form-row">
<label class="toggle-label"><input type="checkbox" id="extInstallSystem"> System (users can't disable)</label>
<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>
</div>
<div class="form-actions">
<button class="btn-small" onclick="document.getElementById('adminInstallExtForm').style.display='none'">Cancel</button>
<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
@@ -786,6 +814,7 @@
</div>
<div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-small btn-danger" onclick="purgeCache()">Purge Cache</button>
<div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-small" onclick="copyDebugLog()">Copy</button>
@@ -820,6 +849,7 @@
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
<script src="js/events.js?v=%%APP_VERSION%%"></script>
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>

View File

@@ -0,0 +1,635 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const vm = require('vm');
/**
* Load Events + Extensions into a browser context.
*/
function loadExtensions(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'events.js');
loadSource(sandbox, 'extensions.js');
const ctx = vm.createContext(sandbox);
return {
Extensions: vm.runInContext('Extensions', ctx),
Events: vm.runInContext('Events', ctx),
sandbox,
};
}
// ═══════════════════════════════════════════
// KaTeX Renderer Extension
// ═══════════════════════════════════════════
describe('KaTeX renderer extension', () => {
function loadWithKaTeX() {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'katex-renderer',
_katexReady: false,
_katexLoading: null,
_escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
init(extCtx) {
const self = this;
extCtx.renderers.register('katex-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
},
render(lang, code, container) {
container.innerHTML = `
<div class="katex-block ext-rendered">
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
<div class="katex-loading">Rendering math…</div>
</div>
<details class="katex-source">
<summary>View source</summary>
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
extCtx.renderers.register('katex-post', {
type: 'post',
priority: 10,
render(container) {
container._katexPostRan = true;
}
});
},
destroy() {}
});
return ctx;
}
it('registers both block and post renderers', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
const info = ctx.Extensions.debug();
assert.strictEqual(info.extensions['katex-renderer'].active, true);
assert.strictEqual(info.extensions['katex-renderer'].renderers.length, 2);
});
it('matches latex, math, tex, and katex languages', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
for (const lang of ['latex', 'math', 'tex', 'katex']) {
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers(lang, 'E = mc^2', container);
assert.ok(handled, `should match language: ${lang}`);
assert.ok(container.innerHTML.includes('katex-block'), `should render katex block for: ${lang}`);
}
});
it('does not match other languages', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
for (const lang of ['python', 'javascript', 'mermaid', 'csv']) {
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers(lang, 'code', container);
assert.strictEqual(handled, false, `should not match language: ${lang}`);
}
});
it('includes encoded source in data attribute', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('latex', '\\frac{a}{b}', container);
assert.ok(container.innerHTML.includes(encodeURIComponent('\\frac{a}{b}')));
});
it('escapes HTML in source view', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('math', '<script>alert(1)</script>', container);
assert.ok(container.innerHTML.includes('&lt;script&gt;'));
assert.ok(!container.innerHTML.includes('<script>alert'));
});
it('post renderer runs on container', async () => {
const ctx = loadWithKaTeX();
await ctx.Extensions.initAll();
const container = {};
ctx.Extensions.runPostRenderers(container);
assert.strictEqual(container._katexPostRan, true);
});
});
// ═══════════════════════════════════════════
// CSV Table Extension
// ═══════════════════════════════════════════
describe('CSV Table extension', () => {
function loadWithCSV() {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'csv-table',
_escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
_parseCSV(text, delimiter) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"'; i += 2;
} else {
inQuotes = false; i++;
}
} else { field += ch; i++; }
} else {
if (ch === '"' && field === '') { inQuotes = true; i++; }
else if (ch === delimiter) { row.push(field.trim()); field = ''; i++; }
else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
row.push(field.trim());
if (row.some(c => c !== '')) rows.push(row);
row = []; field = '';
i += (ch === '\r') ? 2 : 1;
} else { field += ch; i++; }
}
}
row.push(field.trim());
if (row.some(c => c !== '')) rows.push(row);
if (rows.length > 0) {
const maxCols = Math.max(...rows.map(r => r.length));
rows.forEach(r => { while (r.length < maxCols) r.push(''); });
}
return rows;
},
init(extCtx) {
const self = this;
extCtx.renderers.register('csv-block', {
type: 'block',
priority: 10,
match(lang) {
const l = (lang || '').toLowerCase();
return l === 'csv' || l === 'tsv';
},
render(lang, code, container) {
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
const rows = self._parseCSV(code.trim(), delimiter);
if (rows.length === 0) {
container.innerHTML = '<div class="csv-empty">No data</div>';
return;
}
const headers = rows[0];
const data = rows.slice(1);
container.innerHTML = `
<div class="csv-table-block ext-rendered">
<div class="csv-toolbar">
<span class="csv-info">${data.length} rows × ${headers.length} cols</span>
</div>
<table class="csv-table">
<thead><tr>${headers.map((h, i) => `<th data-col="${i}">${self._escapeHtml(h)}</th>`).join('')}</tr></thead>
<tbody>${data.map(row => `<tr>${row.map(c => `<td>${self._escapeHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody>
</table>
</div>
`;
}
});
},
destroy() {}
});
return ctx;
}
it('matches csv and tsv languages', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
for (const lang of ['csv', 'tsv']) {
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers(lang, 'a,b\n1,2', container);
assert.ok(handled, `should match language: ${lang}`);
}
});
it('does not match other languages', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
const handled = ctx.Extensions.runBlockRenderers('json', '{}', { innerHTML: '' });
assert.strictEqual(handled, false);
});
it('renders headers and data rows', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('csv', 'Name,Age\nAlice,30\nBob,25', container);
assert.ok(container.innerHTML.includes('csv-table'));
assert.ok(container.innerHTML.includes('Name'));
assert.ok(container.innerHTML.includes('Alice'));
assert.ok(container.innerHTML.includes('30'));
assert.ok(container.innerHTML.includes('2 rows'));
});
it('handles quoted CSV fields', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('csv', 'Name,Note\n"Smith, John","Has a ""quote"""', container);
assert.ok(container.innerHTML.includes('Smith, John'));
assert.ok(container.innerHTML.includes('Has a "quote"'));
});
it('handles empty input', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('csv', '', container);
assert.ok(container.innerHTML.includes('csv-empty') || container.innerHTML.includes('No data'));
});
it('escapes HTML in cell content', async () => {
const ctx = loadWithCSV();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('csv', 'H1\n<img onerror=alert(1)>', container);
assert.ok(container.innerHTML.includes('&lt;img'));
assert.ok(!container.innerHTML.includes('<img onerror'));
});
it('parses CSV correctly', () => {
const ctx = loadWithCSV();
const ext = ctx.Extensions._registry.get('csv-table').def;
// Simple
assert.deepStrictEqual(ext._parseCSV('a,b\n1,2', ','), [['a','b'],['1','2']]);
// Quoted with embedded comma
assert.deepStrictEqual(ext._parseCSV('"hello, world",b\n1,2', ','), [['hello, world','b'],['1','2']]);
// Escaped quotes
assert.deepStrictEqual(ext._parseCSV('"a""b",c\n1,2', ','), [['a"b','c'],['1','2']]);
// TSV
assert.deepStrictEqual(ext._parseCSV('a\tb\n1\t2', '\t'), [['a','b'],['1','2']]);
});
});
// ═══════════════════════════════════════════
// Diff Viewer Extension
// ═══════════════════════════════════════════
describe('Diff Viewer extension', () => {
function loadWithDiff() {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'diff-viewer',
_escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
init(extCtx) {
const self = this;
extCtx.renderers.register('diff-block', {
type: 'block',
priority: 10,
pattern: 'diff',
render(lang, code, container) {
const lines = code.split('\n');
let stats = { added: 0, removed: 0 };
const rendered = lines.map(line => {
const escaped = self._escapeHtml(line);
if (line.startsWith('+++ ') || line.startsWith('--- ')) {
return `<div class="diff-line diff-file-header">${escaped}</div>`;
}
if (line.startsWith('@@')) {
return `<div class="diff-line diff-hunk">${escaped}</div>`;
}
if (line.startsWith('+')) {
stats.added++;
return `<div class="diff-line diff-add">${escaped}</div>`;
}
if (line.startsWith('-')) {
stats.removed++;
return `<div class="diff-line diff-del">${escaped}</div>`;
}
return `<div class="diff-line diff-ctx">${escaped}</div>`;
}).join('');
container.innerHTML = `
<div class="diff-block ext-rendered">
<div class="diff-toolbar">
<span class="diff-stat-add">+${stats.added}</span>
<span class="diff-stat-del">-${stats.removed}</span>
</div>
<div class="diff-content">${rendered}</div>
</div>
`;
}
});
},
destroy() {}
});
return ctx;
}
it('matches diff language', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers('diff', '+added', container);
assert.ok(handled);
});
it('does not match other languages', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
assert.strictEqual(ctx.Extensions.runBlockRenderers('python', 'x', { innerHTML: '' }), false);
});
it('classifies added lines', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff', '+new line', container);
assert.ok(container.innerHTML.includes('diff-add'));
assert.ok(container.innerHTML.includes('+1'));
});
it('classifies removed lines', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff', '-old line', container);
assert.ok(container.innerHTML.includes('diff-del'));
assert.ok(container.innerHTML.includes('-1'));
});
it('classifies hunk headers', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff', '@@ -1,3 +1,4 @@', container);
assert.ok(container.innerHTML.includes('diff-hunk'));
});
it('classifies file headers', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff', '--- a/file.txt\n+++ b/file.txt', container);
assert.ok(container.innerHTML.includes('diff-file-header'));
});
it('counts stats correctly', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff',
'--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,3 @@\n context\n+add1\n+add2\n-del1', container);
assert.ok(container.innerHTML.includes('+2'));
assert.ok(container.innerHTML.includes('-1'));
});
it('escapes HTML in diff content', async () => {
const ctx = loadWithDiff();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('diff', '+<script>alert(1)</script>', container);
assert.ok(container.innerHTML.includes('&lt;script&gt;'));
});
});
// ═══════════════════════════════════════════
// JS Sandbox Extension (Tool)
// ═══════════════════════════════════════════
describe('JS Sandbox extension', () => {
it('registers js_eval tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'js-sandbox',
init(extCtx) {
extCtx.tools.handle('js_eval', async (args) => {
return { success: true, result: 'mock' };
});
},
destroy() {}
});
await ctx.Extensions.initAll();
assert.ok(ctx.Extensions._toolHandlers.has('js_eval'));
});
it('routes tool calls through the bridge', async () => {
const ctx = loadExtensions();
let receivedCode = null;
ctx.Extensions.register({
id: 'js-sandbox',
init(extCtx) {
extCtx.tools.handle('js_eval', async (args) => {
receivedCode = args.code;
return { success: true, result: '42', output: [] };
});
},
destroy() {}
});
await ctx.Extensions.initAll();
let resultEmitted = null;
ctx.Events.on('tool.result.eval-1', (payload) => {
resultEmitted = payload;
});
ctx.Events.emit('tool.call.eval-1', {
call_id: 'eval-1',
tool: 'js_eval',
arguments: { code: '21 * 2' },
}, { localOnly: true });
await new Promise(r => setTimeout(r, 10));
assert.strictEqual(receivedCode, '21 * 2');
assert.ok(resultEmitted);
const parsed = JSON.parse(resultEmitted.result);
assert.strictEqual(parsed.success, true);
assert.strictEqual(parsed.result, '42');
});
});
// ═══════════════════════════════════════════
// Regex Tester Extension (Tool)
// ═══════════════════════════════════════════
describe('Regex Tester extension', () => {
function loadWithRegex() {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'regex-tester',
_formatMatch(match) {
const result = {
fullMatch: match[0],
index: match.index,
length: match[0].length,
};
if (match.groups) {
result.namedGroups = {};
for (const [name, value] of Object.entries(match.groups)) {
result.namedGroups[name] = value;
}
}
if (match.length > 1) {
result.groups = [];
for (let i = 1; i < match.length; i++) {
result.groups.push(match[i] !== undefined ? match[i] : null);
}
}
return result;
},
_test(pattern, flags, inputs) {
let regex;
try {
regex = new RegExp(pattern, flags);
} catch (e) {
return { success: false, error: `Invalid regex: ${e.message}`, pattern, flags };
}
const results = inputs.map(input => {
regex.lastIndex = 0;
const isGlobal = regex.global;
const matches = [];
if (isGlobal) {
let match;
while ((match = regex.exec(input)) !== null) {
matches.push(this._formatMatch(match));
if (match[0].length === 0) regex.lastIndex++;
}
} else {
const match = regex.exec(input);
if (match) matches.push(this._formatMatch(match));
}
return { input, matched: matches.length > 0, matchCount: matches.length, matches };
});
return {
success: true, pattern, flags,
summary: `${results.filter(r => r.matched).length}/${inputs.length} inputs matched`,
results,
};
},
init(extCtx) {
const self = this;
extCtx.tools.handle('regex_test', async (args) => {
return self._test(args.pattern, args.flags || '', args.inputs || []);
});
},
destroy() {}
});
return ctx;
}
it('registers regex_test tool handler', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
assert.ok(ctx.Extensions._toolHandlers.has('regex_test'));
});
it('matches simple patterns', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
const ext = ctx.Extensions._registry.get('regex-tester').def;
const result = ext._test('^\\d+$', '', ['123', 'abc', '456']);
assert.strictEqual(result.success, true);
assert.strictEqual(result.summary, '2/3 inputs matched');
assert.strictEqual(result.results[0].matched, true);
assert.strictEqual(result.results[1].matched, false);
assert.strictEqual(result.results[2].matched, true);
});
it('captures groups', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
const ext = ctx.Extensions._registry.get('regex-tester').def;
const result = ext._test('(\\d{3})-(\\d{4})', '', ['555-1234']);
assert.strictEqual(result.success, true);
assert.strictEqual(result.results[0].matches[0].fullMatch, '555-1234');
assert.deepStrictEqual(result.results[0].matches[0].groups, ['555', '1234']);
});
it('handles global flag with multiple matches', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
const ext = ctx.Extensions._registry.get('regex-tester').def;
const result = ext._test('\\d+', 'g', ['a1b2c3']);
assert.strictEqual(result.results[0].matchCount, 3);
assert.strictEqual(result.results[0].matches[0].fullMatch, '1');
assert.strictEqual(result.results[0].matches[1].fullMatch, '2');
assert.strictEqual(result.results[0].matches[2].fullMatch, '3');
});
it('returns error for invalid patterns', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
const ext = ctx.Extensions._registry.get('regex-tester').def;
const result = ext._test('[invalid', '', ['test']);
assert.strictEqual(result.success, false);
assert.ok(result.error.includes('Invalid regex'));
});
it('handles named groups', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
const ext = ctx.Extensions._registry.get('regex-tester').def;
const result = ext._test('(?<year>\\d{4})-(?<month>\\d{2})', '', ['2025-02']);
assert.strictEqual(result.results[0].matches[0].namedGroups.year, '2025');
assert.strictEqual(result.results[0].matches[0].namedGroups.month, '02');
});
it('routes through tool bridge', async () => {
const ctx = loadWithRegex();
await ctx.Extensions.initAll();
let resultEmitted = null;
ctx.Events.on('tool.result.regex-1', (payload) => {
resultEmitted = payload;
});
ctx.Events.emit('tool.call.regex-1', {
call_id: 'regex-1',
tool: 'regex_test',
arguments: { pattern: '^hello', flags: 'i', inputs: ['Hello world', 'goodbye'] },
}, { localOnly: true });
await new Promise(r => setTimeout(r, 10));
assert.ok(resultEmitted);
const parsed = JSON.parse(resultEmitted.result);
assert.strictEqual(parsed.success, true);
assert.strictEqual(parsed.summary, '1/2 inputs matched');
});
});

View File

@@ -0,0 +1,491 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const vm = require('vm');
/**
* Load Events + Extensions into a browser context.
* Returns an object with Events, Extensions, and the sandbox.
* Note: const declarations in vm contexts aren't sandbox properties,
* so we extract them via vm.runInContext.
*/
function loadExtensions(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'events.js');
loadSource(sandbox, 'extensions.js');
// Extract const globals from the vm context
const ctx = vm.createContext(sandbox); // idempotent on already-contextified sandbox
return {
Extensions: vm.runInContext('Extensions', ctx),
Events: vm.runInContext('Events', ctx),
sandbox,
};
}
// ── Registration ─────────────────────────────
describe('Extensions.register', () => {
it('registers an extension by id', () => {
const ctx = loadExtensions();
ctx.Extensions.register({ id: 'test-ext', init() {} });
assert.ok(ctx.Extensions._registry.has('test-ext'));
});
it('rejects duplicate registrations', () => {
const ctx = loadExtensions();
ctx.Extensions.register({ id: 'dup', init() {} });
ctx.Extensions.register({ id: 'dup', init() {} });
assert.strictEqual(ctx.Extensions._registry.size, 1);
});
it('rejects registration without id', () => {
const ctx = loadExtensions();
ctx.Extensions.register({ init() {} });
assert.strictEqual(ctx.Extensions._registry.size, 0);
});
});
// ── Lifecycle ────────────────────────────────
describe('Extensions.initAll', () => {
it('calls init on registered extensions', async () => {
const ctx = loadExtensions();
let initialized = false;
ctx.Extensions.register({
id: 'lifecycle-test',
init(extCtx) { initialized = true; },
});
await ctx.Extensions.initAll();
assert.ok(initialized);
});
it('marks extensions as active after init', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({ id: 'active-test', init() {} });
await ctx.Extensions.initAll();
const entry = ctx.Extensions._registry.get('active-test');
assert.strictEqual(entry.active, true);
});
it('survives init errors without crashing', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'bad-ext',
init() { throw new Error('init failed'); },
});
ctx.Extensions.register({
id: 'good-ext',
init() {},
});
await ctx.Extensions.initAll();
assert.strictEqual(ctx.Extensions._registry.get('bad-ext').active, false);
assert.strictEqual(ctx.Extensions._registry.get('good-ext').active, true);
});
});
describe('Extensions.destroyAll', () => {
it('calls destroy and deactivates extensions', async () => {
const ctx = loadExtensions();
let destroyed = false;
ctx.Extensions.register({
id: 'destroy-test',
init() {},
destroy() { destroyed = true; },
});
await ctx.Extensions.initAll();
ctx.Extensions.destroyAll();
assert.ok(destroyed);
assert.strictEqual(ctx.Extensions._registry.get('destroy-test').active, false);
});
});
// ── Context Object ──────────────────────────
describe('Extension context', () => {
it('provides scoped events', async () => {
const ctx = loadExtensions();
let receivedPayload = null;
ctx.Extensions.register({
id: 'events-test',
init(extCtx) {
extCtx.events.on('test.event', (payload) => {
receivedPayload = payload;
});
},
});
await ctx.Extensions.initAll();
ctx.Events.emit('test.event', { data: 42 }, { localOnly: true });
assert.deepStrictEqual(receivedPayload, { data: 42 });
});
it('provides scoped storage', async () => {
const ctx = loadExtensions();
let storage = null;
ctx.Extensions.register({
id: 'storage-test',
init(extCtx) {
extCtx.storage.set('key1', { foo: 'bar' });
storage = extCtx.storage.get('key1');
},
});
await ctx.Extensions.initAll();
assert.strictEqual(storage.foo, 'bar');
// Verify namespacing
assert.ok(ctx.sandbox.localStorage._data['ext::storage-test::key1']);
});
it('provides frozen settings from defaults', async () => {
const ctx = loadExtensions();
let settings = null;
ctx.Extensions.register({
id: 'settings-test',
manifest: {
settings: {
showInline: { type: 'boolean', default: true },
limit: { type: 'number', default: 100 },
},
},
init(extCtx) { settings = extCtx.settings; },
});
await ctx.Extensions.initAll();
assert.strictEqual(settings.showInline, true);
assert.strictEqual(settings.limit, 100);
// Frozen — assignment silently ignored in sloppy mode
settings.showInline = false;
assert.strictEqual(settings.showInline, true, 'frozen settings should not change');
});
});
// ── Tool Registration ───────────────────────
describe('ctx.tools.handle', () => {
it('registers a tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'tool-test',
init(extCtx) {
extCtx.tools.handle('my_tool', async (args) => {
return { result: args.input * 2 };
});
},
});
await ctx.Extensions.initAll();
assert.ok(ctx.Extensions._toolHandlers.has('my_tool'));
});
});
// ── Tool Bridge ─────────────────────────────
describe('Tool bridge', () => {
it('routes tool.call.* to registered handler and emits result', async () => {
const ctx = loadExtensions();
let resultEmitted = null;
ctx.Extensions.register({
id: 'bridge-test',
init(extCtx) {
extCtx.tools.handle('double', async (args) => {
return { value: args.n * 2 };
});
},
});
await ctx.Extensions.initAll();
// Listen for the result event
ctx.Events.on('tool.result.call-123', (payload) => {
resultEmitted = payload;
});
// Simulate server sending tool.call
ctx.Events.emit('tool.call.call-123', {
call_id: 'call-123',
tool: 'double',
arguments: { n: 21 },
}, { localOnly: true });
// Allow async handler to complete
await new Promise(r => setTimeout(r, 10));
assert.ok(resultEmitted, 'result event should have been emitted');
assert.strictEqual(resultEmitted.call_id, 'call-123');
const parsed = JSON.parse(resultEmitted.result);
assert.strictEqual(parsed.value, 42);
});
it('returns error for unknown tool', async () => {
const ctx = loadExtensions();
let resultEmitted = null;
await ctx.Extensions.initAll();
ctx.Events.on('tool.result.call-unknown', (payload) => {
resultEmitted = payload;
});
ctx.Events.emit('tool.call.call-unknown', {
call_id: 'call-unknown',
tool: 'nonexistent_tool',
arguments: {},
}, { localOnly: true });
await new Promise(r => setTimeout(r, 10));
assert.ok(resultEmitted);
assert.ok(resultEmitted.error, 'should have error field');
});
it('returns error when handler throws', async () => {
const ctx = loadExtensions();
let resultEmitted = null;
ctx.Extensions.register({
id: 'error-tool',
init(extCtx) {
extCtx.tools.handle('fail_tool', async () => {
throw new Error('something broke');
});
},
});
await ctx.Extensions.initAll();
ctx.Events.on('tool.result.call-fail', (payload) => {
resultEmitted = payload;
});
ctx.Events.emit('tool.call.call-fail', {
call_id: 'call-fail',
tool: 'fail_tool',
arguments: {},
}, { localOnly: true });
await new Promise(r => setTimeout(r, 10));
assert.ok(resultEmitted);
assert.ok(resultEmitted.error);
const parsed = JSON.parse(resultEmitted.error);
assert.strictEqual(parsed.error, 'something broke');
});
});
// ── Renderer Pipeline ───────────────────────
describe('Renderer pipeline', () => {
it('registers and runs block renderers', async () => {
const ctx = loadExtensions();
let rendered = false;
ctx.Extensions.register({
id: 'renderer-test',
init(extCtx) {
extCtx.renderers.register('test-renderer', {
type: 'block',
pattern: 'mermaid',
render(lang, code, container) { rendered = true; },
});
},
});
await ctx.Extensions.initAll();
const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', {});
assert.ok(handled);
assert.ok(rendered);
});
it('skips non-matching block renderers', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'skip-test',
init(extCtx) {
extCtx.renderers.register('python-only', {
type: 'block',
pattern: 'python',
render() {},
});
},
});
await ctx.Extensions.initAll();
const handled = ctx.Extensions.runBlockRenderers('javascript', 'code', {});
assert.strictEqual(handled, false);
});
it('runs post renderers on container', async () => {
const ctx = loadExtensions();
let postRan = false;
ctx.Extensions.register({
id: 'post-test',
init(extCtx) {
extCtx.renderers.register('post-proc', {
type: 'post',
render(container) { postRan = true; },
});
},
});
await ctx.Extensions.initAll();
ctx.Extensions.runPostRenderers({});
assert.ok(postRan);
});
it('respects renderer priority ordering', async () => {
const ctx = loadExtensions();
const order = [];
ctx.Extensions.register({
id: 'priority-test',
init(extCtx) {
extCtx.renderers.register('low', {
type: 'post',
priority: 90,
render() { order.push('low'); },
});
extCtx.renderers.register('high', {
type: 'post',
priority: 10,
render() { order.push('high'); },
});
},
});
await ctx.Extensions.initAll();
ctx.Extensions.runPostRenderers({});
assert.deepStrictEqual(order, ['high', 'low']);
});
});
// ── Debug ────────────────────────────────────
describe('Extensions.debug', () => {
it('returns extension info and renderer count', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
id: 'debug-ext',
init(extCtx) {
extCtx.renderers.register('r1', { type: 'post', render() {} });
},
});
await ctx.Extensions.initAll();
const info = ctx.Extensions.debug();
assert.ok(info.extensions['debug-ext']);
assert.strictEqual(info.extensions['debug-ext'].active, true);
assert.strictEqual(info.rendererCount, 1);
});
});
// ── Mermaid Extension ─────────────────────────────
describe('Mermaid renderer extension', () => {
function loadWithMermaid() {
const ctx = loadExtensions();
// Load the mermaid extension script into the same VM context
const fs = require('fs');
const path = require('path');
// The script lives alongside the test as a fixture, or in the repo root
// For CI, we inline the registration to avoid path issues
ctx.Extensions.register({
id: 'mermaid-renderer',
_mermaidReady: false,
_mermaidLoading: null,
_escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
init(extCtx) {
const self = this;
extCtx.renderers.register('mermaid', {
type: 'block',
pattern: 'mermaid',
priority: 10,
render(lang, code, container) {
const id = 'mmd-test';
container.innerHTML = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
<div class="mermaid-loading">Rendering diagram…</div>
</div>
<details class="mermaid-source">
<summary>View source</summary>
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
extCtx.renderers.register('mermaid-post', {
type: 'post',
priority: 10,
render(container) {
// In test, just mark that post-render ran
container._mermaidPostRan = true;
}
});
},
destroy() {}
});
return ctx;
}
it('registers both block and post renderers', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const info = ctx.Extensions.debug();
assert.strictEqual(info.extensions['mermaid-renderer'].active, true);
const renderers = info.extensions['mermaid-renderer'].renderers;
assert.strictEqual(renderers.length, 2);
assert.strictEqual(renderers[0], 'mermaid');
assert.strictEqual(renderers[1], 'mermaid-post');
assert.strictEqual(info.rendererCount, 2);
});
it('block renderer matches mermaid language', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', container);
assert.ok(handled);
assert.ok(container.innerHTML.includes('mermaid-block'));
assert.ok(container.innerHTML.includes('mermaid-diagram'));
assert.ok(container.innerHTML.includes(encodeURIComponent('graph LR; A-->B')));
});
it('block renderer does not match other languages', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
const handled = ctx.Extensions.runBlockRenderers('javascript', 'const x = 1;', container);
assert.strictEqual(handled, false);
assert.strictEqual(container.innerHTML, '');
});
it('block renderer includes source in details element', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('mermaid', 'sequenceDiagram\n A->>B: Hello', container);
assert.ok(container.innerHTML.includes('mermaid-source'));
assert.ok(container.innerHTML.includes('View source'));
assert.ok(container.innerHTML.includes('sequenceDiagram'));
});
it('block renderer escapes HTML in source view', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const container = { innerHTML: '' };
ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A["<script>alert(1)</script>"]-->B', container);
assert.ok(container.innerHTML.includes('&lt;script&gt;'));
assert.ok(!container.innerHTML.includes('<script>alert'));
});
it('post renderer runs on container', async () => {
const ctx = loadWithMermaid();
await ctx.Extensions.initAll();
const container = {};
ctx.Extensions.runPostRenderers(container);
assert.strictEqual(container._mermaidPostRan, true);
});
});

View File

@@ -501,4 +501,171 @@ function _initAdminListeners() {
await UI.loadTeamMembers(UI._teamEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// ── Extension management ────────────────────
document.getElementById('adminInstallExtBtn')?.addEventListener('click', () => {
document.getElementById('adminInstallExtForm').style.display = '';
});
document.getElementById('adminInstallExtSubmit')?.addEventListener('click', async () => {
const extId = document.getElementById('extInstallId').value.trim();
const name = document.getElementById('extInstallName').value.trim();
if (!extId || !name) return UI.toast('ID and Name are required', 'error');
// Build manifest: merge user-provided JSON with _script
let manifest = {};
const manifestRaw = document.getElementById('extInstallManifest').value.trim();
if (manifestRaw) {
try { manifest = JSON.parse(manifestRaw); }
catch { return UI.toast('Invalid manifest JSON', 'error'); }
}
const script = document.getElementById('extInstallScript').value;
if (script) manifest._script = script;
try {
await API._post('/api/v1/admin/extensions', {
ext_id: extId,
name: name,
version: document.getElementById('extInstallVersion').value.trim() || '1.0.0',
author: document.getElementById('extInstallAuthor').value.trim(),
description: document.getElementById('extInstallDesc').value.trim(),
manifest: manifest,
is_system: document.getElementById('extInstallSystem').checked,
is_enabled: document.getElementById('extInstallEnabled').checked,
});
document.getElementById('adminInstallExtForm').style.display = 'none';
// Clear form
['extInstallId','extInstallName','extInstallVersion','extInstallAuthor','extInstallDesc','extInstallManifest','extInstallScript'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = el.type === 'checkbox' ? '' : (el.tagName === 'TEXTAREA' ? '' : '');
});
document.getElementById('extInstallVersion').value = '1.0.0';
UI.toast('Extension installed');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
});
}
// ── Extension admin actions (global scope for onclick) ──
async function toggleAdminExtension(id, enabled) {
try {
await API._put(`/api/v1/admin/extensions/${id}`, { is_enabled: enabled });
UI.toast(enabled ? 'Extension enabled' : 'Extension disabled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminExtension(id, name) {
if (!await showConfirm(`Uninstall extension "${name}"? This cannot be undone.`, { danger: true })) return;
try {
await API._del(`/api/v1/admin/extensions/${id}`);
UI.toast('Extension uninstalled');
await UI.loadAdminExtensions();
} catch (e) { UI.toast(e.message, 'error'); }
}
function editAdminExtension(id) {
// Close any existing edit form
document.querySelectorAll('.ext-edit-form').forEach(el => el.remove());
const ext = (UI._adminExtensions || []).find(e => e.id === id);
if (!ext) return UI.toast('Extension not found', 'error');
// Extract manifest and _script separately
let manifest = {};
try { manifest = typeof ext.manifest === 'string' ? JSON.parse(ext.manifest) : (ext.manifest || {}); }
catch { manifest = {}; }
const script = manifest._script || '';
// Show manifest without _script for cleaner editing
const manifestClean = { ...manifest };
delete manifestClean._script;
const manifestJSON = JSON.stringify(manifestClean, null, 2);
const systemWarning = ext.is_system
? `<div style="background:var(--bg-3);border:1px solid var(--warning,#f39c12);border-radius:6px;padding:8px 12px;font-size:12px;margin-bottom:8px;color:var(--warning,#f39c12)">
⚠️ System extension — local edits will be overwritten on next deploy. Copy the code first if patching.
</div>`
: '';
const formHTML = `
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
${systemWarning}
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Name</label>
<input type="text" id="extEdit-name-${id}" value="${esc(ext.name)}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Description</label>
<input type="text" id="extEdit-desc-${id}" value="${esc(ext.description || '')}" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>
</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="this.closest('.ext-edit-form').remove()">Cancel</button>
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button>
</div>
</div>
`;
// Find the extension row and insert the form after it
const rows = document.querySelectorAll('#adminExtensionsList .admin-user-row');
for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);
// Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
}
break;
}
}
}
async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`);
const manifestEl = document.getElementById(`extEdit-manifest-${id}`);
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (!nameEl || !manifestEl || !scriptEl) return;
// Parse manifest and merge _script back in
let manifest;
try {
manifest = JSON.parse(manifestEl.value.trim() || '{}');
} catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
}
const script = scriptEl.value;
if (script.trim()) {
manifest._script = script;
}
try {
await API._put(`/api/v1/admin/extensions/${id}`, {
name: nameEl.value.trim(),
description: descEl.value.trim(),
manifest: manifest,
});
UI.toast('Extension updated — reload page to apply changes');
await UI.loadAdminExtensions();
} catch (e) {
UI.toast(e.message, 'error');
}
}

View File

@@ -161,6 +161,16 @@ async function startApp() {
hideSplash();
UI.restoreSidebar();
await loadSettings();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
await Extensions.loadAll(); // fetch manifests + inject <script> tags
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
} catch (e) {
console.warn('Extension init error:', e.message);
}
await loadChats();
await fetchModels();

View File

@@ -267,6 +267,11 @@ const DebugLog = {
};
}
// Extensions state
if (typeof Extensions !== 'undefined') {
snap.extensions = Extensions.debug();
}
return snap;
},
@@ -355,6 +360,44 @@ const DebugLog = {
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
}
// Test 5: Extensions
this.log('DIAG', 'Test 5: Browser extensions');
if (typeof Extensions !== 'undefined') {
const info = Extensions.debug();
const extNames = Object.keys(info.extensions);
this.log('DIAG', ` Loaded: ${extNames.length} extension(s)`);
for (const name of extNames) {
const ext = info.extensions[name];
this.log('DIAG', ` ${name}: ${ext.active ? '✅ active' : '❌ inactive'}, renderers: [${ext.renderers.join(', ')}]`);
}
this.log('DIAG', ` Total renderers: ${info.rendererCount}, tool handlers: ${Extensions._toolHandlers?.size || 0}`);
} else {
this.log('DIAG', ' Extensions not loaded');
}
// Test 6: Service Worker & Cache
this.log('DIAG', 'Test 6: Service Worker cache');
try {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
this.log('DIAG', ` SW registered: ${reg ? 'yes' : 'no'}${reg ? ` (scope: ${reg.scope})` : ''}`);
if (reg?.active) {
this.log('DIAG', ` SW state: ${reg.active.state}`);
}
} else {
this.log('DIAG', ' SW not supported');
}
const keys = await caches.keys();
this.log('DIAG', ` Caches (${keys.length}): ${keys.join(', ') || '(none)'}`);
for (const key of keys) {
const cache = await caches.open(key);
const entries = await cache.keys();
this.log('DIAG', ` ${key}: ${entries.length} entries`);
}
} catch (e) {
this.log('DIAG', ` Cache check error: ${e.message}`);
}
this.log('DIAG', '── Diagnostics complete ──');
this.render();
},
@@ -576,5 +619,42 @@ function runDebugDiagnostics() {
DebugLog.runDiagnostics();
}
async function purgeCache() {
if (!await showConfirm(
'Purge all cached files and reload?\n\n' +
'This clears the Service Worker cache and forces a fresh download of all assets. ' +
'Useful when the UI feels stale after a deploy.',
{ danger: true }
)) return;
DebugLog.log('DIAG', '🧹 Purging Service Worker caches...');
try {
// Delete all SW caches
const keys = await caches.keys();
const deleted = await Promise.all(keys.map(k => caches.delete(k)));
DebugLog.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`);
// Unregister all service workers
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
DebugLog.log('DIAG', ` Unregistered SW: ${reg.scope}`);
}
}
DebugLog.log('DIAG', ' ✅ Cache purged — reloading...');
// Brief delay so the user sees the log, then hard reload
setTimeout(() => {
location.reload();
}, 500);
} catch (e) {
DebugLog.log('DIAG', ` ❌ Purge failed: ${e.message}`);
if (typeof showToast === 'function') showToast('Cache purge failed: ' + e.message, 'error');
}
}
// Initialize ASAP — before app.js init() so we capture everything
DebugLog.init();

View File

@@ -192,8 +192,8 @@ const Events = {
// Add auth token as query param (WebSocket doesn't support headers)
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
if (tokens.access) {
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
if (tokens.accessToken) {
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.accessToken)}`;
} else {
console.warn('[EventBus] No auth token — skipping WebSocket');
return;

404
src/js/extensions.js Normal file
View File

@@ -0,0 +1,404 @@
// ==========================================
// Chat Switchboard Extension System
// ==========================================
// Loader, registry, scoped context, and renderer pipeline.
// Tier 0 (browser) extensions register here. The ctx object
// enforces permissions declared in the manifest.
//
// Usage:
// Extensions.register({
// id: 'my-ext',
// init(ctx) { ctx.renderers.register(...); },
// destroy() { /* cleanup */ }
// });
//
// Load order: events.js → extensions.js → [ext scripts] → api.js → app.js
// ==========================================
const Extensions = {
// ── State ────────────────────────────────
_registry: new Map(), // extId → { def, ctx, instance }
_renderers: [], // sorted by priority
_toolHandlers: new Map(), // toolName → { extId, handler }
_loaded: false,
_manifests: [], // loaded from server
// ── Script Loading ────────────────────────
/**
* Fetch enabled browser extensions from the server and inject their scripts.
* Called before app init so extensions can register before initAll().
*/
async loadAll() {
try {
const resp = await API._get('/api/v1/extensions?tier=browser');
const exts = resp.data || [];
this._manifests = exts;
for (const ext of exts) {
// Parse manifest to check for _script (inline) or entry (file)
const manifest = ext.manifest || {};
const extId = ext.ext_id;
if (manifest._script) {
// Inject script tag pointing at the asset endpoint
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/main.js`);
} else if (manifest.entry) {
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/${manifest.entry}`);
}
}
console.log(`[Extensions] Loaded ${exts.length} browser extension(s)`);
} catch (e) {
console.warn('[Extensions] Failed to load extensions:', e.message || e);
}
},
/**
* Inject a script tag and wait for it to load.
*/
_injectScript(extId, src) {
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = src;
script.dataset.extension = extId;
script.onload = () => { resolve(); };
script.onerror = () => {
console.error(`[Extensions] Failed to load script for ${extId}: ${src}`);
resolve(); // Don't block other extensions
};
document.head.appendChild(script);
});
},
// ── Registration ─────────────────────────
/**
* Register a browser extension. Called by extension scripts.
* @param {object} def — { id, init(ctx), destroy() }
*/
register(def) {
if (!def || !def.id) {
console.error('[Extensions] register() requires an id');
return;
}
if (this._registry.has(def.id)) {
console.warn(`[Extensions] ${def.id} already registered, skipping`);
return;
}
const entry = { def, ctx: null, instance: null, active: false };
this._registry.set(def.id, entry);
console.log(`[Extensions] Registered: ${def.id}`);
},
// ── Lifecycle ────────────────────────────
/**
* Initialize all registered extensions. Called once after app startup.
* Builds scoped ctx for each and calls init().
*/
async initAll() {
// Set up the tool bridge: listen for tool.call.* events from server
this._setupToolBridge();
for (const [id, entry] of this._registry) {
if (entry.active) continue;
try {
entry.ctx = this._buildContext(id, entry.def);
if (typeof entry.def.init === 'function') {
await entry.def.init.call(entry.def, entry.ctx);
}
entry.active = true;
console.log(`[Extensions] Initialized: ${id}`);
} catch (e) {
console.error(`[Extensions] Failed to init ${id}:`, e);
}
}
this._loaded = true;
Events.emit('extension.loaded', { count: this._registry.size }, { localOnly: true });
},
/**
* Destroy all extensions (logout/cleanup).
*/
destroyAll() {
for (const [id, entry] of this._registry) {
if (!entry.active) continue;
try {
if (typeof entry.def.destroy === 'function') {
entry.def.destroy.call(entry.def);
}
} catch (e) {
console.error(`[Extensions] Failed to destroy ${id}:`, e);
}
entry.active = false;
entry.ctx = null;
}
this._renderers = [];
this._loaded = false;
},
// ── Context Builder ──────────────────────
/**
* Build a scoped context object for an extension.
* Each extension gets its own ctx with permission-aware proxies.
*/
_buildContext(extId, def) {
const manifest = def.manifest || {};
const permissions = new Set(manifest.permissions || []);
return {
// Extension identity
id: extId,
// Event bus (scoped — could filter by permissions later)
events: {
on: (label, fn) => Events.on(label, fn),
once: (label, fn) => Events.once(label, fn),
off: (label, fn) => Events.off(label, fn),
emit: (label, payload) => Events.emit(label, payload, { localOnly: true }),
},
// Scoped localStorage namespace
storage: {
get: (key) => {
try {
return JSON.parse(localStorage.getItem(`ext::${extId}::${key}`));
} catch { return null; }
},
set: (key, value) => {
localStorage.setItem(`ext::${extId}::${key}`, JSON.stringify(value));
},
remove: (key) => {
localStorage.removeItem(`ext::${extId}::${key}`);
},
},
// Extension settings (read-only, from manifest defaults + user overrides)
settings: Object.freeze(Object.assign(
{},
Extensions._extractDefaults(manifest.settings || {}),
Extensions._getUserSettings(extId)
)),
// Renderer registration
renderers: {
register: (name, opts) => Extensions._registerRenderer(extId, name, opts),
},
// Tool registration (browser tool bridge)
tools: {
handle: (name, fn) => {
Extensions._toolHandlers.set(name, { extId, handler: fn });
console.log(`[Extensions] Tool registered: ${extId}:${name}`);
},
},
// UI injection points (v0.17.0 — stub for now)
ui: {
inject: (region, el) => {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
},
createMenu: (anchor, opts) => {
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
return null;
},
},
// Model info (resolved at call time)
get model() {
return {
id: typeof currentModelId !== 'undefined' ? currentModelId : null,
};
},
// User info
get user() {
return {
id: typeof API !== 'undefined' ? API.user?.id : null,
username: typeof API !== 'undefined' ? API.user?.username : null,
role: typeof API !== 'undefined' ? API.user?.role : null,
};
},
// Proxied API fetch
api: {
fetch: (path, opts) => {
if (typeof API !== 'undefined' && typeof API._fetch === 'function') {
return API._fetch(path, opts);
}
return fetch(path, opts);
},
},
};
},
// ── Renderer Pipeline ────────────────────
/**
* Register a custom renderer.
* @param {string} extId — owning extension
* @param {string} name — renderer name (unique within extension)
* @param {object} opts — { pattern, render, priority, type }
*
* Types:
* 'block' — operates on code blocks (receives lang, code, container)
* 'inline' — operates on the full message HTML (receives html string, returns html string)
* 'post' — operates on the rendered DOM (receives container element)
*/
_registerRenderer(extId, name, opts) {
if (!opts || !opts.render) {
console.error(`[Extensions] Renderer ${extId}:${name} requires a render function`);
return;
}
const renderer = {
extId,
name,
type: opts.type || 'block',
priority: opts.priority || 50,
pattern: opts.pattern || null,
render: opts.render,
match: opts.match || null, // function(lang, code) → bool
};
this._renderers.push(renderer);
this._renderers.sort((a, b) => a.priority - b.priority);
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
},
/**
* Run block renderers on a code block element.
* Called by ui-format.js after creating the code block DOM.
* Returns true if a renderer handled the block (caller should skip default).
*
* @param {string} lang — language tag
* @param {string} code — raw code content
* @param {HTMLElement} container — the code block wrapper element
*/
runBlockRenderers(lang, code, container) {
for (const r of this._renderers) {
if (r.type !== 'block') continue;
let matched = false;
if (r.match && typeof r.match === 'function') {
matched = r.match(lang, code);
} else if (r.pattern instanceof RegExp) {
matched = r.pattern.test(lang);
} else if (typeof r.pattern === 'string') {
matched = lang === r.pattern;
}
if (matched) {
try {
r.render(lang, code, container);
return true;
} catch (e) {
console.error(`[Extensions] Renderer ${r.extId}:${r.name} error:`, e);
}
}
}
return false;
},
/**
* Run post-render DOM processors on a message container.
* Called after the full message HTML is inserted into the DOM.
*
* @param {HTMLElement} container — the message content element
*/
runPostRenderers(container) {
for (const r of this._renderers) {
if (r.type !== 'post') continue;
try {
r.render(container);
} catch (e) {
console.error(`[Extensions] Post-renderer ${r.extId}:${r.name} error:`, e);
}
}
},
// ── Helpers ──────────────────────────────
_extractDefaults(settingsSchema) {
const defaults = {};
for (const [key, def] of Object.entries(settingsSchema)) {
if (def && 'default' in def) defaults[key] = def.default;
}
return defaults;
},
_getUserSettings(extId) {
// Find user settings from the manifest data loaded from API
const manifest = this._manifests.find(m => m.ext_id === extId);
if (manifest?.user_settings) {
try {
return typeof manifest.user_settings === 'string'
? JSON.parse(manifest.user_settings)
: manifest.user_settings;
} catch { return {}; }
}
return {};
},
/**
* Set up the WebSocket bridge for browser tool execution.
* Listens for tool.call.* events from the server, routes to
* the registered handler, and sends tool.result.* back.
*/
_setupToolBridge() {
Events.on('tool.call.*', async (payload, meta) => {
const { call_id, tool, arguments: args } = payload || {};
if (!call_id || !tool) return;
const registration = this._toolHandlers.get(tool);
if (!registration) {
// No handler registered — send error back
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: `no handler for tool: ${tool}` }),
});
return;
}
try {
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
const result = await registration.handler(parsedArgs);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
Events.emit(`tool.result.${call_id}`, {
call_id,
result: resultStr,
});
} catch (e) {
console.error(`[Extensions] Tool ${tool} error:`, e);
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: e.message || 'tool execution failed' }),
});
}
});
},
/**
* Check if any renderers are registered for a given type.
*/
hasRenderers(type) {
return this._renderers.some(r => r.type === type);
},
/**
* Get info about all registered extensions (for debug/admin).
*/
debug() {
const exts = {};
for (const [id, entry] of this._registry) {
exts[id] = {
active: entry.active,
renderers: this._renderers.filter(r => r.extId === id).map(r => r.name),
};
}
return { extensions: exts, rendererCount: this._renderers.length };
},
};

View File

@@ -235,7 +235,9 @@ function _showNoteReadMode() {
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
const noteReadEl = document.getElementById('noteReadContent');
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
@@ -270,6 +272,7 @@ function _showNotePreview() {
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';

View File

@@ -42,7 +42,6 @@ Object.assign(UI, {
switchTeamTab(tab) {
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
console.log('switchTeamTab:', tab, 'found tabs:', tabs.length);
tabs.forEach(t => {
t.classList.remove('active');
if (t.dataset.ttab === tab) t.classList.add('active');
@@ -76,6 +75,7 @@ Object.assign(UI, {
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
if (tab === 'extensions') await this.loadAdminExtensions();
},
async loadAdminUsers(quiet) {
@@ -223,6 +223,56 @@ Object.assign(UI, {
}
},
async loadAdminExtensions() {
const el = document.getElementById('adminExtensionsList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access
if (exts.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
return;
}
el.innerHTML = exts.map(ext => {
const statusBadge = ext.is_enabled
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
const systemBadge = ext.is_system
? ' <span class="badge-user" style="font-size:11px">system</span>'
: '';
return `
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
<div class="admin-user-info" style="flex:1">
<div style="display:flex;align-items:center;gap:8px">
<strong>${esc(ext.name)}</strong>
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
${statusBadge}${systemBadge}
</div>
<div class="text-muted" style="font-size:12px;margin-top:2px">
${esc(ext.description || 'No description')}
${ext.author ? ` · by ${esc(ext.author)}` : ''}
· v${esc(ext.version)} · ${ext.tier}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
Edit
</button>
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
Uninstall
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
}
},
_auditPage: 1,
_auditPerPage: 30,

View File

@@ -352,6 +352,7 @@ const UI = {
}
el.innerHTML = html;
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
this._scrollToBottom(true);
},

View File

@@ -48,6 +48,15 @@ function formatMessage(content) {
}
function _formatMarked(text) {
// ── Unwrap outer ```markdown wrappers ────────────────────────
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
// semantically "this content IS markdown". Standard markdown doesn't support
// nested fences with the same delimiter, so an inner ```mermaid ... ```
// prematurely closes the outer block, splitting the content into broken
// fragments. Fix: if the outer fence is ```markdown/```md and contains
// nested fences, strip the wrapper and render contents as markdown directly.
text = _unwrapMarkdownFence(text);
// Close any unclosed code fences to prevent raw HTML injection during
// streaming (closing ``` hasn't arrived yet) or when the model forgets
// to close a fence. An odd number of ``` markers means one is unclosed.
@@ -83,7 +92,25 @@ function _formatMarked(text) {
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
const langMatch = attrs.match(/class="language-(\w+)"/);
const lang = langMatch ? langMatch[1] : '';
const lang = langMatch ? langMatch[1].toLowerCase() : '';
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
// Extensions mark blocks with data-ext-render for post-render DOM processing.
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
try {
const fakeContainer = document.createElement('div');
const decoded = _decodeHTML(code);
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
if (handled && fakeContainer.innerHTML) {
return `<div class="ext-rendered" data-ext-block="${codeId}">${fakeContainer.innerHTML}</div>`;
}
if (handled && !fakeContainer.innerHTML) {
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
}
} catch (e) {
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
}
}
// Count lines for collapse decision
const lineCount = (code.match(/\n/g) || []).length + 1;
@@ -143,6 +170,56 @@ function _formatBasic(text) {
return html;
}
// ── Markdown Fence Unwrapper ──────────────────
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
// a no-op ("this markdown IS markdown") but breaks parsing when the content
// contains nested code fences — the inner closing ``` prematurely terminates
// the outer block, splitting the response into fragments.
//
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
// at least one nested ``` fence, strip the outer wrapper. If there are no
// nested fences, leave it alone (the user/LLM may genuinely want to show
// markdown source in a code block).
function _unwrapMarkdownFence(text) {
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
// We need to find the ```markdown fence after any placeholders/whitespace.
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
if (!openMatch) return text;
const prefix = openMatch[1]; // placeholders + whitespace before the fence
const fence = openMatch[2]; // the ```markdown\n itself
const afterOpen = prefix.length + fence.length;
// Find the LAST bare ``` on its own line — this is the outer closing fence.
// There may be trailing text (LLM explanations) after it.
let closeIdx = -1;
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
let m;
while ((m = searchRegex.exec(text)) !== null) {
closeIdx = m.index;
}
if (closeIdx < afterOpen) return text;
// Extract inner content and any trailing text after the close
const inner = text.slice(afterOpen, closeIdx);
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
// Only unwrap if the inner content contains nested fences —
// otherwise this might be intentional "show me the markdown source"
if (/^```/m.test(inner)) {
return prefix + inner + trailing;
}
return text;
}
// Decode HTML entities in code block content (for extension renderers that need raw text)
function _decodeHTML(html) {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
// Heuristic: does this code block look like HTML?
function _looksLikeHTML(code) {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
@@ -351,3 +428,13 @@ function _relativeTime(dateStr) {
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
/**
* Run extension post-renderers on a container after innerHTML is set.
* Safe to call even if extensions aren't loaded — no-ops gracefully.
*/
function runExtensionPostRender(container) {
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
Extensions.runPostRenderers(container);
}
}