Changeset 0.11.0 (#62)
This commit is contained in:
@@ -369,7 +369,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -f server/Dockerfile \
|
docker build -f server/Dockerfile \
|
||||||
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
|
||||||
./server
|
.
|
||||||
|
|
||||||
# ── Build Frontend Image ─────────────────────
|
# ── Build Frontend Image ─────────────────────
|
||||||
- name: Build frontend image
|
- name: Build frontend image
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Architecture — Chat Switchboard v0.9
|
# Architecture — Chat Switchboard v0.11
|
||||||
|
|
||||||
## Deployment Modes
|
## Deployment Modes
|
||||||
|
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -2,6 +2,92 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
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
|
## [0.10.5] — 2026-02-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@@ -34,6 +34,20 @@ RUN npm pack dompurify@3.2.4 && \
|
|||||||
cp package/dist/purify.min.js purify.min.js && \
|
cp package/dist/purify.min.js purify.min.js && \
|
||||||
rm -rf package dompurify-3.2.4.tgz
|
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 ─────────────────────
|
# ── Stage 3: Production ─────────────────────
|
||||||
FROM nginx:1-alpine
|
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/)
|
# 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/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/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
|
# nginx config
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -16,11 +16,18 @@
|
|||||||
# Stage 1: Download vendor libs (vendor/ is gitignored)
|
# Stage 1: Download vendor libs (vendor/ is gitignored)
|
||||||
FROM node:20-alpine AS vendor
|
FROM node:20-alpine AS vendor
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
RUN npm pack marked@16.3.0 dompurify@3.2.4 2>/dev/null && \
|
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 && \
|
mkdir -p /vendor/mermaid /vendor/katex/fonts && \
|
||||||
tar xzf marked-*.tgz -C /tmp && cp /tmp/package/lib/marked.umd.js /vendor/marked.min.js && \
|
tar xzf marked-*.tgz -C /tmp && cp /tmp/package/lib/marked.umd.js /vendor/marked.min.js && \
|
||||||
rm -rf /tmp/package && \
|
rm -rf /tmp/package && \
|
||||||
tar xzf dompurify-*.tgz -C /tmp && cp /tmp/package/dist/purify.min.js /vendor/purify.min.js && \
|
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
|
rm -rf /tmp/package /tmp/*.tgz
|
||||||
|
|
||||||
# Stage 2: nginx + entrypoint
|
# Stage 2: nginx + entrypoint
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Chat Switchboard — Extension System Specification
|
# Chat Switchboard — Extension System Specification
|
||||||
|
|
||||||
**Version:** 0.2
|
**Version:** 0.3
|
||||||
**Status:** Design (pre-implementation)
|
**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)
|
**See also:** [ARCHITECTURE.md](ARCHITECTURE.md), [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -21,10 +21,14 @@ docker compose up -d
|
|||||||
- **Model Catalog**: Three-state visibility (enabled/disabled/team-only) with admin controls
|
- **Model Catalog**: Three-state visibility (enabled/disabled/team-only) with admin controls
|
||||||
- **Message Trees**: Edit and regenerate with full conversation forking — navigate sibling branches
|
- **Message Trees**: Edit and regenerate with full conversation forking — navigate sibling branches
|
||||||
- **Notes**: Markdown notes with full-text search, folders, and tags
|
- **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
|
- **Audit Log**: All admin operations logged with actor, action, and resource details
|
||||||
- **Security**: JWT + refresh tokens, optional mTLS, optional OIDC/Keycloak, environment classification banners
|
- **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), no CDN required
|
- **Airgapped**: Local vendor files (marked.js, DOMPurify, KaTeX, mermaid.js), no CDN required
|
||||||
- **Mobile**: Responsive design, PWA manifest
|
- **Mobile**: Responsive design, PWA manifest
|
||||||
|
- **Real-time**: WebSocket event bus for tool bridge, live updates
|
||||||
|
|
||||||
## Architecture
|
## 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
|
## Configuration
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ All configuration via environment variables. See `server/.env.example` for the f
|
|||||||
| `JWT_SECRET` | (required) | Token signing key |
|
| `JWT_SECRET` | (required) | Token signing key |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
|
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
|
| `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` |
|
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -91,8 +96,8 @@ docker compose --profile dev up # include Adminer DB UI
|
|||||||
Build and push both images:
|
Build and push both images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.2 server/
|
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.9.2 .
|
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Backend deployment:**
|
**Backend deployment:**
|
||||||
@@ -100,7 +105,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.2 .
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: your-registry/switchboard-api:0.9.2
|
image: your-registry/switchboard-api:0.11.0
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
env:
|
env:
|
||||||
@@ -120,7 +125,7 @@ containers:
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: your-registry/switchboard-fe:0.9.2
|
image: your-registry/switchboard-fe:0.11.0
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
@@ -140,7 +145,7 @@ Set `BASE_PATH=/chat` to serve the application under a subpath. The frontend rea
|
|||||||
|
|
||||||
### Airgapped / Disconnected
|
### 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
|
## Database
|
||||||
|
|
||||||
@@ -161,18 +166,20 @@ scripts/db-migrate.sh
|
|||||||
scripts/db-validate.sh
|
scripts/db-validate.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Tables (v0.9)
|
### Key Tables (v0.11)
|
||||||
|
|
||||||
| Table | Purpose |
|
| 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) |
|
| `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) |
|
| `personas` | Model presets (scope: global/team/personal) |
|
||||||
| `channels` | Conversations with type (direct/group/channel) |
|
| `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 |
|
| `teams` / `team_members` | Organizational units |
|
||||||
| `notes` | Markdown notes with full-text search |
|
| `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 |
|
| `audit_log` | Admin action audit trail |
|
||||||
| `user_model_settings` | Per-user model visibility and sort preferences |
|
| `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/PUT /admin/settings/:key` — Global settings and policies
|
||||||
- `GET/POST /admin/configs` — Global provider configs
|
- `GET/POST /admin/configs` — Global provider configs
|
||||||
- `GET/POST /admin/models/fetch` — Model catalog sync
|
- `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 /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
|
## Development
|
||||||
|
|
||||||
|
|||||||
102
ROADMAP.md
102
ROADMAP.md
@@ -15,35 +15,29 @@ No compatibility guarantees before 1.0.
|
|||||||
Features have real dependencies. This ordering respects them.
|
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
|
v0.12.0 File Handling v0.13.0 Web Search
|
||||||
Foundation + Vision
|
+ Vision + Tools Expansion
|
||||||
(browser tier) │
|
|
||||||
│ │
|
|
||||||
├── Custom renderers ├── Doc gen outputs
|
|
||||||
├── Image gen tool ├── KB documents
|
|
||||||
├── STT/TTS │
|
|
||||||
│ │
|
|
||||||
├───────────────────────┤
|
|
||||||
│ │
|
|
||||||
v0.13.0 Web Search + Tools Expansion
|
|
||||||
│
|
│
|
||||||
v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
|
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.
|
The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
|
||||||
|
|
||||||
**Core Loader** (EXTENSIONS.md §4, §7)
|
**Core Loader** (EXTENSIONS.md §4, §7)
|
||||||
- [ ] `extensions.js` — loader, registry, scoped context (~200 lines)
|
- [x] `extensions.js` — loader, registry, scoped context, renderer pipeline (~400 lines)
|
||||||
- [ ] `Extensions.register()` API with permission enforcement
|
- [x] `Extensions.register()` API with permission-aware scoped contexts
|
||||||
- [ ] Manifest format parser and validator
|
- [x] Manifest format: `_script` inline or `entry` file, permissions, settings schema
|
||||||
- [ ] `extensions` + `extension_user_settings` tables
|
- [x] `extensions` + `extension_user_settings` tables (migration 006)
|
||||||
- [ ] Admin endpoints: install, uninstall, enable/disable
|
- [x] Admin endpoints: CRUD, enable/disable, asset serving
|
||||||
- [ ] Asset serving: `/api/v1/extensions/:id/assets/*path`
|
- [x] `/api/v1/extensions/:id/assets/*path` — public (no auth, `<script>` tag compatible)
|
||||||
- [ ] Extension settings UI in Settings modal
|
- [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)
|
**Browser Tool Bridge** (EXTENSIONS.md §5)
|
||||||
- [ ] `ctx.tools.handle()` API for browser tool registration
|
- [x] `ctx.tools.handle()` API for browser tool registration
|
||||||
- [ ] Tool schema collection: merge built-in + extension tools
|
- [x] Tool schema collection: merge server + extension tools in completions
|
||||||
- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
|
- [x] `tool.call.*` / `tool.result.*` WebSocket routing via EventBus hub
|
||||||
- [ ] 30-second timeout with LLM error recovery
|
- [x] 30-second timeout with error recovery
|
||||||
- [ ] Tool execution via EventBus `WaitFor` pattern
|
- [x] `Events.waitFor()` pattern for request/response tool calls
|
||||||
|
|
||||||
**Custom Renderer API** (EXTENSIONS.md Appendix A)
|
**Custom Renderer API** (EXTENSIONS.md Appendix A)
|
||||||
- [ ] `ctx.renderers.register()` — pattern + render function
|
- [x] `ctx.renderers.register()` — block, post, and inline types with pattern/match
|
||||||
- [ ] Renderer pipeline in message display (after markdown, before DOM insert)
|
- [x] Renderer pipeline: block renderers intercept code fences, post renderers process DOM
|
||||||
- [ ] First-party renderers: collapsible code, HTML preview, mermaid, LaTeX
|
- [x] Case-insensitive language matching (LLMs capitalize fence tags)
|
||||||
(migrate core v0.9.2 implementations to extension format)
|
- [x] Nested ```` ```markdown ```` fence unwrapping (LLMs wrap responses in markdown fences)
|
||||||
|
- [x] Extension-owned styles via `_injectStyles()` / `destroy()` lifecycle
|
||||||
|
|
||||||
**First Extensions**
|
**Server Tools** (auto-registered via `init()`)
|
||||||
- [ ] Image generation tool (browser or sidecar, uses generation model role)
|
- [x] `datetime` — current date/time/timezone/day/unix/ISO-week (models hallucinate dates)
|
||||||
- [ ] STT/TTS (browser extension, Web Speech API — personal use case)
|
- [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
|
## 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)
|
- [ ] `web_search` tool: search provider abstraction (SearXNG, Brave, DuckDuckGo)
|
||||||
- [ ] `url_fetch` tool: retrieve and extract content from URLs
|
- [ ] `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
|
Items that are real but don't yet have a version assignment. Pull left
|
||||||
based on need.
|
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**
|
**Extension System — Server Tiers**
|
||||||
- Starlark runtime integration (Tier 1 — server sandbox)
|
- Starlark runtime integration (Tier 1 — server sandbox)
|
||||||
- Sidecar HTTP tool protocol (Tier 2 — container isolation)
|
- Sidecar HTTP tool protocol (Tier 2 — container isolation)
|
||||||
|
|||||||
12
extensions/builtin/csv-table/manifest.json
Normal file
12
extensions/builtin/csv-table/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
254
extensions/builtin/csv-table/script.js
Normal file
254
extensions/builtin/csv-table/script.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
12
extensions/builtin/diff-viewer/manifest.json
Normal file
12
extensions/builtin/diff-viewer/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
166
extensions/builtin/diff-viewer/script.js
Normal file
166
extensions/builtin/diff-viewer/script.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
27
extensions/builtin/js-sandbox/manifest.json
Normal file
27
extensions/builtin/js-sandbox/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
202
extensions/builtin/js-sandbox/script.js
Normal file
202
extensions/builtin/js-sandbox/script.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
12
extensions/builtin/katex-renderer/manifest.json
Normal file
12
extensions/builtin/katex-renderer/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
297
extensions/builtin/katex-renderer/script.js
Normal file
297
extensions/builtin/katex-renderer/script.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
12
extensions/builtin/mermaid-renderer/manifest.json
Normal file
12
extensions/builtin/mermaid-renderer/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
209
extensions/builtin/mermaid-renderer/script.js
Normal file
209
extensions/builtin/mermaid-renderer/script.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
36
extensions/builtin/regex-tester/manifest.json
Normal file
36
extensions/builtin/regex-tester/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
102
extensions/builtin/regex-tester/script.js
Normal file
102
extensions/builtin/regex-tester/script.js
Normal 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() {}
|
||||||
|
});
|
||||||
12
extensions/mermaid-renderer/manifest.json
Normal file
12
extensions/mermaid-renderer/manifest.json
Normal 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": {}
|
||||||
|
}
|
||||||
507
extensions/mermaid-renderer/script.js
Normal file
507
extensions/mermaid-renderer/script.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -46,15 +46,16 @@ spec:
|
|||||||
number: 8080
|
number: 8080
|
||||||
# Health check → backend
|
# Health check → backend
|
||||||
- path: ${BASE_PATH}/health
|
- path: ${BASE_PATH}/health
|
||||||
pathType: Exact
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: switchboard-be${DEPLOY_SUFFIX}
|
name: switchboard-be${DEPLOY_SUFFIX}
|
||||||
port:
|
port:
|
||||||
number: 8080
|
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
|
- path: ${BASE_PATH}/ws
|
||||||
pathType: Exact
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: switchboard-be${DEPLOY_SUFFIX}
|
name: switchboard-be${DEPLOY_SUFFIX}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Chat Switchboard - Backend Dockerfile
|
# Chat Switchboard - Backend Dockerfile
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Standalone Go API server for cluster deployment.
|
# Standalone Go API server for cluster deployment.
|
||||||
# Build context is ./server (not repo root).
|
# Build context is the repo root (not ./server).
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
@@ -13,11 +13,11 @@ ARG APP_VERSION=dev
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy module files and download deps
|
# Copy module files and download deps
|
||||||
COPY go.mod go.sum* ./
|
COPY server/go.mod server/go.sum* ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source and tidy (resolves any go.sum gaps)
|
# Copy source and tidy (resolves any go.sum gaps)
|
||||||
COPY . .
|
COPY server/ .
|
||||||
RUN go mod tidy
|
RUN go mod tidy
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
|
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 /bin/switchboard /usr/local/bin/switchboard
|
||||||
COPY --from=builder /app/database/migrations /app/database/migrations
|
COPY --from=builder /app/database/migrations /app/database/migrations
|
||||||
|
|
||||||
|
# Builtin extensions (seeded into DB on startup)
|
||||||
|
COPY extensions/builtin/ /app/extensions/builtin/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
41
server/database/migrations/006_extensions.sql
Normal file
41
server/database/migrations/006_extensions.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -3,6 +3,7 @@ package events
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bus is a labeled publish/subscribe event bus.
|
// 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.*"
|
prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*"
|
||||||
return strings.HasPrefix(label, prefix)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatch(t *testing.T) {
|
func TestMatch(t *testing.T) {
|
||||||
@@ -112,6 +113,12 @@ func TestRouteFor(t *testing.T) {
|
|||||||
{"unknown.event", DirLocal}, // default
|
{"unknown.event", DirLocal}, // default
|
||||||
{"ping", DirFromClient},
|
{"ping", DirFromClient},
|
||||||
{"pong", DirToClient},
|
{"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 {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ var routeTable = map[string]Direction{
|
|||||||
"internal.": DirLocal,
|
"internal.": DirLocal,
|
||||||
"db.": 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
|
// Heartbeat
|
||||||
"ping": DirFromClient,
|
"ping": DirFromClient,
|
||||||
"pong": DirToClient,
|
"pong": DirToClient,
|
||||||
|
|||||||
@@ -118,6 +118,42 @@ func (h *Hub) ConnsByUser(userID string) []*Conn {
|
|||||||
return result
|
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.
|
// removeConn cleans up a connection.
|
||||||
func (h *Hub) removeConn(conn *Conn) {
|
func (h *Hub) removeConn(conn *Conn) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"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/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
@@ -39,11 +40,12 @@ type completionRequest struct {
|
|||||||
type CompletionHandler struct {
|
type CompletionHandler struct {
|
||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
|
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCompletionHandler creates a new handler.
|
// NewCompletionHandler creates a new handler.
|
||||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
|
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
|
||||||
return &CompletionHandler{vault: vault, stores: stores}
|
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat Completion ─────────────────────────
|
// ── 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
|
// Attach tool definitions if model supports tool calling and tools are available
|
||||||
if caps.ToolCalling && tools.HasTools() {
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||||
provReq.Tools = h.buildToolDefs()
|
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||||
|
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine streaming
|
// Determine streaming
|
||||||
@@ -189,19 +192,56 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
// 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()
|
allTools := tools.AllDefinitions()
|
||||||
defs := make([]providers.ToolDef, len(allTools))
|
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||||
for i, t := range allTools {
|
for _, t := range allTools {
|
||||||
defs[i] = providers.ToolDef{
|
defs = append(defs, providers.ToolDef{
|
||||||
Type: "function",
|
Type: "function",
|
||||||
Function: providers.FunctionDef{
|
Function: providers.FunctionDef{
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Parameters: t.Parameters,
|
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
|
return defs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +256,7 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, userID, model, configID, providerScope string,
|
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
|
// Persist assistant response
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
|
|||||||
346
server/handlers/extensions.go
Normal file
346
server/handlers/extensions.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
protected.POST("/channels", channels.CreateChannel)
|
protected.POST("/channels", channels.CreateChannel)
|
||||||
|
|
||||||
// Completions
|
// Completions
|
||||||
completions := NewCompletionHandler(nil, stores)
|
completions := NewCompletionHandler(nil, stores, nil)
|
||||||
protected.POST("/chat/completions", completions.Complete)
|
protected.POST("/chat/completions", completions.Complete)
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"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/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||||
@@ -59,11 +60,12 @@ type cursorRequest struct {
|
|||||||
type MessageHandler struct {
|
type MessageHandler struct {
|
||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
|
hub *events.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler creates a new message handler.
|
// NewMessageHandler creates a new message handler.
|
||||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
|
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
|
||||||
return &MessageHandler{vault: vault, stores: stores}
|
return &MessageHandler{vault: vault, stores: stores, hub: hub}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List Messages (flat, all branches) ──────
|
// ── List Messages (flat, all branches) ──────
|
||||||
@@ -358,7 +360,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Resolve model + provider ──
|
// ── Resolve model + provider ──
|
||||||
|
|
||||||
comp := NewCompletionHandler(h.vault, h.stores)
|
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
|
||||||
|
|
||||||
var presetSystemPrompt string
|
var presetSystemPrompt string
|
||||||
model := req.Model
|
model := req.Model
|
||||||
@@ -449,13 +451,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach tool definitions (same as normal completion)
|
// Attach tool definitions (same as normal completion)
|
||||||
if caps.ToolCalling && tools.HasTools() {
|
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||||
provReq.Tools = comp.buildToolDefs()
|
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||||
|
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
// ── 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
|
// Persist as sibling (regen) with tool activity
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
|
|||||||
178
server/handlers/seed_extensions.go
Normal file
178
server/handlers/seed_extensions.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||||
)
|
)
|
||||||
@@ -40,6 +44,7 @@ func streamWithToolLoop(
|
|||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, userID, channelID string,
|
model, userID, channelID string,
|
||||||
|
hub *events.Hub,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
@@ -171,8 +176,25 @@ func streamWithToolLoop(
|
|||||||
Arguments: tc.Function.Arguments,
|
Arguments: tc.Function.Arguments,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
var toolResult tools.ToolResult
|
||||||
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
|
||||||
|
// 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
|
// Notify client about tool result
|
||||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||||
@@ -214,3 +236,68 @@ func streamWithToolLoop(
|
|||||||
|
|
||||||
return result
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ func main() {
|
|||||||
|
|
||||||
// Seed additional users from env (dev/test only, skipped in production)
|
// Seed additional users from env (dev/test only, skipped in production)
|
||||||
handlers.SeedUsers(cfg, stores)
|
handlers.SeedUsers(cfg, stores)
|
||||||
|
|
||||||
|
// Seed builtin extensions from disk (idempotent, version-aware)
|
||||||
|
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
@@ -126,6 +129,12 @@ func main() {
|
|||||||
authGroup.POST("/logout", auth.Logout)
|
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 routes ────────────────────
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.Auth(cfg))
|
protected.Use(middleware.Auth(cfg))
|
||||||
@@ -139,7 +148,7 @@ func main() {
|
|||||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
msgs := handlers.NewMessageHandler(keyResolver, stores)
|
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
|
||||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||||
|
|
||||||
@@ -151,7 +160,7 @@ func main() {
|
|||||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||||
|
|
||||||
// Chat Completions
|
// Chat Completions
|
||||||
comp := handlers.NewCompletionHandler(keyResolver, stores)
|
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
|
||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
|
|
||||||
// Summarize & Continue
|
// Summarize & Continue
|
||||||
@@ -258,6 +267,12 @@ func main() {
|
|||||||
// Public global settings (non-admin users can read safe subset)
|
// Public global settings (non-admin users can read safe subset)
|
||||||
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
|
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
|
||||||
protected.GET("/settings/public", adm.PublicSettings)
|
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 ────────────────────────
|
// ── Admin routes ────────────────────────
|
||||||
@@ -336,6 +351,13 @@ func main() {
|
|||||||
admin.GET("/pricing", usageH.ListPricing)
|
admin.GET("/pricing", usageH.ListPricing)
|
||||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -536,6 +536,49 @@ func (m *JSONMap) Scan(src interface{}) error {
|
|||||||
return nil
|
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 {
|
func NullString(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Stores struct {
|
|||||||
GlobalConfig GlobalConfigStore
|
GlobalConfig GlobalConfigStore
|
||||||
Usage UsageStore
|
Usage UsageStore
|
||||||
Pricing PricingStore
|
Pricing PricingStore
|
||||||
|
Extensions ExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -316,6 +317,29 @@ type PricingStore interface {
|
|||||||
Delete(ctx context.Context, providerConfigID, modelID string) error
|
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
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
194
server/store/postgres/extension.go
Normal file
194
server/store/postgres/extension.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -25,5 +25,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
GlobalConfig: NewGlobalConfigStore(),
|
GlobalConfig: NewGlobalConfigStore(),
|
||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
|
Extensions: NewExtensionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
384
server/tools/calculator.go
Normal file
384
server/tools/calculator.go
Normal 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
64
server/tools/datetime.go
Normal 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
|
||||||
|
}
|
||||||
@@ -570,6 +570,12 @@ a:hover { text-decoration: underline; }
|
|||||||
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
|
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Extension-rendered blocks ──────── */
|
||||||
|
|
||||||
|
.ext-rendered {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* HTML preview */
|
/* HTML preview */
|
||||||
/* ── Side Panel (Preview + Notes) ──────── */
|
/* ── 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);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 1rem; margin-bottom: 1rem;
|
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 {
|
.admin-user-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
|||||||
@@ -565,6 +565,7 @@
|
|||||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||||
<button class="admin-tab" data-tab="roles">Roles</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="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="stats">Stats</button>
|
||||||
<button class="admin-tab" data-tab="audit">Audit</button>
|
<button class="admin-tab" data-tab="audit">Audit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -738,6 +739,33 @@
|
|||||||
<div id="adminPricingTable"></div>
|
<div id="adminPricingTable"></div>
|
||||||
</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="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||||
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
||||||
<div class="admin-toolbar" style="margin-bottom:8px">
|
<div class="admin-toolbar" style="margin-bottom:8px">
|
||||||
@@ -786,6 +814,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer debug-footer">
|
<div class="modal-footer debug-footer">
|
||||||
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
|
<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">
|
<div style="margin-left:auto;display:flex;gap:0.5rem">
|
||||||
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
|
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
|
||||||
<button class="btn-small" onclick="copyDebugLog()">Copy</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/debug.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/events.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/api.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/ui-format.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>
|
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
|
||||||
|
|||||||
635
src/js/__tests__/extensions-builtin.test.js
Normal file
635
src/js/__tests__/extensions-builtin.test.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
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('<script>'));
|
||||||
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
_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('<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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
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('<script>'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
491
src/js/__tests__/extensions.test.js
Normal file
491
src/js/__tests__/extensions.test.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
},
|
||||||
|
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('<script>'));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -501,4 +501,171 @@ function _initAdminListeners() {
|
|||||||
await UI.loadTeamMembers(UI._teamEditId);
|
await UI.loadTeamMembers(UI._teamEditId);
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ async function startApp() {
|
|||||||
hideSplash();
|
hideSplash();
|
||||||
UI.restoreSidebar();
|
UI.restoreSidebar();
|
||||||
await loadSettings();
|
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 loadChats();
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ const DebugLog = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extensions state
|
||||||
|
if (typeof Extensions !== 'undefined') {
|
||||||
|
snap.extensions = Extensions.debug();
|
||||||
|
}
|
||||||
|
|
||||||
return snap;
|
return snap;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -355,6 +360,44 @@ const DebugLog = {
|
|||||||
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
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.log('DIAG', '── Diagnostics complete ──');
|
||||||
this.render();
|
this.render();
|
||||||
},
|
},
|
||||||
@@ -576,5 +619,42 @@ function runDebugDiagnostics() {
|
|||||||
DebugLog.runDiagnostics();
|
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
|
// Initialize ASAP — before app.js init() so we capture everything
|
||||||
DebugLog.init();
|
DebugLog.init();
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ const Events = {
|
|||||||
|
|
||||||
// Add auth token as query param (WebSocket doesn't support headers)
|
// Add auth token as query param (WebSocket doesn't support headers)
|
||||||
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
|
const tokens = JSON.parse(localStorage.getItem(typeof _storageKey !== 'undefined' ? _storageKey : 'sb_auth') || '{}');
|
||||||
if (tokens.access) {
|
if (tokens.accessToken) {
|
||||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.accessToken)}`;
|
||||||
} else {
|
} else {
|
||||||
console.warn('[EventBus] No auth token — skipping WebSocket');
|
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||||
return;
|
return;
|
||||||
|
|||||||
404
src/js/extensions.js
Normal file
404
src/js/extensions.js
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -235,7 +235,9 @@ function _showNoteReadMode() {
|
|||||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||||
|
|
||||||
// Rendered markdown content — reuse the chat formatter
|
// 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
|
// Show/hide delete
|
||||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
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>`));
|
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||||
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
|
||||||
|
|
||||||
document.getElementById('noteReadMode').style.display = '';
|
document.getElementById('noteReadMode').style.display = '';
|
||||||
document.getElementById('noteEditMode').style.display = 'none';
|
document.getElementById('noteEditMode').style.display = 'none';
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ Object.assign(UI, {
|
|||||||
|
|
||||||
switchTeamTab(tab) {
|
switchTeamTab(tab) {
|
||||||
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
||||||
console.log('switchTeamTab:', tab, 'found tabs:', tabs.length);
|
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
t.classList.remove('active');
|
t.classList.remove('active');
|
||||||
if (t.dataset.ttab === tab) t.classList.add('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 === 'settings') await this.loadAdminSettings();
|
||||||
if (tab === 'roles') await this.loadAdminRoles();
|
if (tab === 'roles') await this.loadAdminRoles();
|
||||||
if (tab === 'usage') await this.loadAdminUsage();
|
if (tab === 'usage') await this.loadAdminUsage();
|
||||||
|
if (tab === 'extensions') await this.loadAdminExtensions();
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadAdminUsers(quiet) {
|
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,
|
_auditPage: 1,
|
||||||
_auditPerPage: 30,
|
_auditPerPage: 30,
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ const UI = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
||||||
this._scrollToBottom(true);
|
this._scrollToBottom(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ function formatMessage(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _formatMarked(text) {
|
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
|
// Close any unclosed code fences to prevent raw HTML injection during
|
||||||
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
||||||
// to close a fence. An odd number of ``` markers means one is unclosed.
|
// 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) => {
|
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
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
|
// Count lines for collapse decision
|
||||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||||
@@ -143,6 +170,56 @@ function _formatBasic(text) {
|
|||||||
return html;
|
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?
|
// Heuristic: does this code block look like HTML?
|
||||||
function _looksLikeHTML(code) {
|
function _looksLikeHTML(code) {
|
||||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||||
@@ -351,3 +428,13 @@ function _relativeTime(dateStr) {
|
|||||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user