Add Canvas command surface
This commit is contained in:
parent
cf723141a4
commit
8b6c810f4a
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Upstream version:** v0.51.118
|
**Upstream version:** v0.51.118
|
||||||
**Plugin version:** 0.5.0
|
**Plugin version:** 0.5.0
|
||||||
**Total dependencies:** 39 (29 public API · 0 forced internal · 10 frontend)
|
**Total dependencies:** 61 (40 public API · 0 forced internal · 21 frontend)
|
||||||
|
|
||||||
> **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.**
|
> **Auto-generated by `scripts/ast-connection-map.py`. Do not hand-edit.**
|
||||||
> To change a justification, edit the `# CONNECTION:` comment above the
|
> To change a justification, edit the `# CONNECTION:` comment above the
|
||||||
@ -23,16 +23,27 @@
|
|||||||
| `plugin.py:43` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` |
|
| `plugin.py:43` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/adwright.js")` |
|
||||||
| `plugin.py:45` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` |
|
| `plugin.py:45` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")` |
|
||||||
| `plugin.py:46` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` |
|
| `plugin.py:46` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")` |
|
||||||
| `plugin.py:48` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/umbrella_inline.css")` |
|
| `plugin.py:48` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/canvas.css")` |
|
||||||
| `plugin.py:49` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/umbrella_inline.js")` |
|
| `plugin.py:49` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/canvas.js")` |
|
||||||
| `plugin.py:51` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/cortex-os/runtime-health/runtime_health.c` |
|
| `plugin.py:51` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/umbrella_inline.css")` |
|
||||||
| `plugin.py:52` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/cortex-os/runtime-health/runtime_health.js")` |
|
| `plugin.py:52` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/umbrella_inline.js")` |
|
||||||
|
| `plugin.py:54` | `api.inject_stylesheet` | `api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/cortex-os/runtime-health/runtime_health.c` |
|
||||||
|
| `plugin.py:55` | `api.inject_script` | `api.inject_script(f"/plugins/{STATIC_PREFIX}/cortex-os/runtime-health/runtime_health.js")` |
|
||||||
| `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
|
| `routes/adwright.py:68` | `api.logger` | `log = api.logger("svrnty.routes.adwright")` |
|
||||||
| `routes/adwright.py:69` | `api.register_route` | `api.register_route(` |
|
| `routes/adwright.py:69` | `api.register_route` | `api.register_route(` |
|
||||||
| `routes/adwright.py:71` | `api.register_route` | `api.register_route(` |
|
| `routes/adwright.py:71` | `api.register_route` | `api.register_route(` |
|
||||||
| `routes/bte_proxy.py:90` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` |
|
| `routes/bte_proxy.py:90` | `api.logger` | `log = api.logger("svrnty.routes.bte_proxy")` |
|
||||||
| `routes/bte_proxy.py:91` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _handle_proxy)` |
|
| `routes/bte_proxy.py:91` | `api.register_route` | `api.register_route("/api/bte/proxy", "GET", _handle_proxy)` |
|
||||||
| `routes/bte_proxy.py:92` | `api.register_route` | `api.register_route("/api/bte/proxy", "POST", _handle_proxy)` |
|
| `routes/bte_proxy.py:92` | `api.register_route` | `api.register_route("/api/bte/proxy", "POST", _handle_proxy)` |
|
||||||
|
| `routes/canvas.py:51` | `api.logger` | `log = api.logger("svrnty.routes.canvas")` |
|
||||||
|
| `routes/canvas.py:52` | `api.register_route` | `api.register_route("/api/canvas/status", "GET", _handle_status)` |
|
||||||
|
| `routes/canvas.py:53` | `api.register_route` | `api.register_route("/api/canvas/tools", "GET", _handle_tools)` |
|
||||||
|
| `routes/canvas.py:54` | `api.register_route` | `api.register_route("/api/canvas/proxy", "GET", _handle_proxy)` |
|
||||||
|
| `routes/canvas.py:55` | `api.register_route` | `api.register_route("/api/canvas/proxy", "POST", _handle_proxy)` |
|
||||||
|
| `routes/canvas.py:56` | `api.register_route` | `api.register_route("/api/canvas/proxy", "PUT", _handle_proxy)` |
|
||||||
|
| `routes/canvas.py:57` | `api.register_route` | `api.register_route("/api/canvas/command", "POST", _handle_command)` |
|
||||||
|
| `routes/canvas.py:58` | `api.register_route` | `api.register_route("/api/canvas/design-context", "GET", _handle_design_context)` |
|
||||||
|
| `routes/canvas.py:59` | `api.register_route` | `api.register_route("/api/canvas/events", "GET", _handle_events)` |
|
||||||
| `routes/cortex_os_runtime_health.py:26` | `api.logger` | `log = api.logger("svrnty.routes.cortex_os_runtime_health")` |
|
| `routes/cortex_os_runtime_health.py:26` | `api.logger` | `log = api.logger("svrnty.routes.cortex_os_runtime_health")` |
|
||||||
| `routes/cortex_os_runtime_health.py:27` | `api.register_route` | `api.register_route(ROUTE_PATH, ROUTE_METHOD, _handle_runtime_health)` |
|
| `routes/cortex_os_runtime_health.py:27` | `api.register_route` | `api.register_route(ROUTE_PATH, ROUTE_METHOD, _handle_runtime_health)` |
|
||||||
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
|
| `routes/transcribe.py:37` | `api.logger` | `log = api.logger("svrnty.routes.transcribe")` |
|
||||||
@ -58,6 +69,17 @@ _None. Plugin uses only the public API._ ✓
|
|||||||
|
|
||||||
| File | Line | URL |
|
| File | Line | URL |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `static/canvas.js` | 136 | `/api/canvas/status` |
|
||||||
|
| `static/canvas.js` | 137 | `/api/canvas/tools` |
|
||||||
|
| `static/canvas.js` | 162 | `/api/canvas/events` |
|
||||||
|
| `static/canvas.js` | 252 | `/api/canvas/command` |
|
||||||
|
| `static/canvas.js` | 307 | `/api/v1/projects` |
|
||||||
|
| `static/canvas.js` | 310 | `/api/v1/projects` |
|
||||||
|
| `static/canvas.js` | 323 | `/api/v1/projects/` |
|
||||||
|
| `static/canvas.js` | 339 | `/api/v1/projects/` |
|
||||||
|
| `static/canvas.js` | 476 | `/api/v1/projects/` |
|
||||||
|
| `static/canvas.js` | 516 | `/api/v1/projects/` |
|
||||||
|
| `static/canvas.js` | 542 | `/api/v1/projects/` |
|
||||||
| `static/bte.js` | 365 | `/api/command/requestPhotoshoot` |
|
| `static/bte.js` | 365 | `/api/command/requestPhotoshoot` |
|
||||||
| `static/bte.js` | 396 | `/api/query/assetDtos` |
|
| `static/bte.js` | 396 | `/api/query/assetDtos` |
|
||||||
| `static/bte.js` | 408 | `/api/assets/` |
|
| `static/bte.js` | 408 | `/api/assets/` |
|
||||||
|
|||||||
@ -33,11 +33,13 @@ assets:
|
|||||||
- /plugins/svrnty/svrnty_nav.js
|
- /plugins/svrnty/svrnty_nav.js
|
||||||
- /plugins/svrnty/adwright.js
|
- /plugins/svrnty/adwright.js
|
||||||
- /plugins/svrnty/bte.js
|
- /plugins/svrnty/bte.js
|
||||||
|
- /plugins/svrnty/canvas.js
|
||||||
- /plugins/svrnty/umbrella_inline.js
|
- /plugins/svrnty/umbrella_inline.js
|
||||||
stylesheets:
|
stylesheets:
|
||||||
- /plugins/svrnty/app.css
|
- /plugins/svrnty/app.css
|
||||||
- /plugins/svrnty/adwright.css
|
- /plugins/svrnty/adwright.css
|
||||||
- /plugins/svrnty/bte.css
|
- /plugins/svrnty/bte.css
|
||||||
|
- /plugins/svrnty/canvas.css
|
||||||
- /plugins/svrnty/umbrella_inline.css
|
- /plugins/svrnty/umbrella_inline.css
|
||||||
|
|
||||||
# Routes this plugin registers at load time (declarative cross-check vs runtime).
|
# Routes this plugin registers at load time (declarative cross-check vs runtime).
|
||||||
@ -49,6 +51,14 @@ routes:
|
|||||||
- { path: /api/adwright/provision-creds, method: POST, file: routes/adwright.py, status: live }
|
- { path: /api/adwright/provision-creds, method: POST, file: routes/adwright.py, status: live }
|
||||||
- { path: /api/bte/proxy, method: GET, file: routes/bte_proxy.py, status: live }
|
- { path: /api/bte/proxy, method: GET, file: routes/bte_proxy.py, status: live }
|
||||||
- { path: /api/bte/proxy, method: POST, file: routes/bte_proxy.py, status: live }
|
- { path: /api/bte/proxy, method: POST, file: routes/bte_proxy.py, status: live }
|
||||||
|
- { path: /api/canvas/status, method: GET, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/tools, method: GET, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/proxy, method: GET, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/proxy, method: POST, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/proxy, method: PUT, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/command, method: POST, file: routes/canvas.py, status: live }
|
||||||
|
- { path: /api/canvas/design-context, method: GET, file: routes/canvas.py, status: seed }
|
||||||
|
- { path: /api/canvas/events, method: GET, file: routes/canvas.py, status: seed }
|
||||||
|
|
||||||
# Audio-attachment processors (called by streaming.py before agent receives message).
|
# Audio-attachment processors (called by streaming.py before agent receives message).
|
||||||
audio_processors:
|
audio_processors:
|
||||||
|
|||||||
@ -44,6 +44,9 @@ def register(api):
|
|||||||
# BTE Command Center panel — same pattern (main.svrnty-showing-bte).
|
# BTE Command Center panel — same pattern (main.svrnty-showing-bte).
|
||||||
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")
|
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/bte.css")
|
||||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")
|
api.inject_script(f"/plugins/{STATIC_PREFIX}/bte.js")
|
||||||
|
# Sovereign Canvas panel — Stitch-like live design command surface.
|
||||||
|
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/canvas.css")
|
||||||
|
api.inject_script(f"/plugins/{STATIC_PREFIX}/canvas.js")
|
||||||
# Inline Umbrella graph for the Hermes Workspace right panel.
|
# Inline Umbrella graph for the Hermes Workspace right panel.
|
||||||
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/umbrella_inline.css")
|
api.inject_stylesheet(f"/plugins/{STATIC_PREFIX}/umbrella_inline.css")
|
||||||
api.inject_script(f"/plugins/{STATIC_PREFIX}/umbrella_inline.js")
|
api.inject_script(f"/plugins/{STATIC_PREFIX}/umbrella_inline.js")
|
||||||
@ -79,6 +82,7 @@ def _phase2_routes():
|
|||||||
"vault_status", # P2.B — vault connections status ✓
|
"vault_status", # P2.B — vault connections status ✓
|
||||||
"adwright", # P2.C — Adwright tool panel routes (PRD §5+§6) ✓
|
"adwright", # P2.C — Adwright tool panel routes (PRD §5+§6) ✓
|
||||||
"bte_proxy", # P2.D — BTE Command Center same-origin proxy (PRD §3) ✓
|
"bte_proxy", # P2.D — BTE Command Center same-origin proxy (PRD §3) ✓
|
||||||
|
"canvas", # Sovereign Stitch-like design canvas proxy + event seed
|
||||||
"umbrella", # P2.E — cortex-os umbrella graph viz (UMBRELLA-VIZ-PRD) ✓
|
"umbrella", # P2.E — cortex-os umbrella graph viz (UMBRELLA-VIZ-PRD) ✓
|
||||||
"cortex_os_runtime_health", # S23.0-I4 — Cortex OS Runtime Health read-only slice ✓
|
"cortex_os_runtime_health", # S23.0-I4 — Cortex OS Runtime Health read-only slice ✓
|
||||||
]
|
]
|
||||||
|
|||||||
796
routes/canvas.py
Normal file
796
routes/canvas.py
Normal file
@ -0,0 +1,796 @@
|
|||||||
|
"""Canvas command-center routes.
|
||||||
|
|
||||||
|
These routes are intentionally small: Hermes WebUI owns the command surface,
|
||||||
|
while the existing canva-editor Go service owns canonical project/screen state.
|
||||||
|
The proxy keeps browser calls same-origin and whitelisted so the first Stitch
|
||||||
|
slice can move without coupling WebUI to the design service internals.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_CANVA_BASE = "http://localhost:8080"
|
||||||
|
_PLANB_BRAND_ID = "607740e8-8b76-4455-9c4f-eadbe9b4168c"
|
||||||
|
_BRAND_ALIASES = {
|
||||||
|
"planb": _PLANB_BRAND_ID,
|
||||||
|
"plan-b": _PLANB_BRAND_ID,
|
||||||
|
"goutez-planb": _PLANB_BRAND_ID,
|
||||||
|
}
|
||||||
|
_DEFAULT_CMO_DB_PATHS = (
|
||||||
|
"~/.hermes/profiles/cmo-planb/cmo.db",
|
||||||
|
"~/.hermes/cmo-planb/cmo.db",
|
||||||
|
"~/.hermes/cmo/cmo.db",
|
||||||
|
)
|
||||||
|
_ALLOWED_EXACT = frozenset({
|
||||||
|
"/health",
|
||||||
|
"/api/v1/capabilities",
|
||||||
|
"/api/v1/projects",
|
||||||
|
"/api/v1/generate",
|
||||||
|
"/api/v1/generate/from-image",
|
||||||
|
})
|
||||||
|
_ALLOWED_PATTERN = re.compile(
|
||||||
|
r"^/api/v1/projects/[A-Za-z0-9_\-]+/(screens|design-system|variants|prototype-edges|export)"
|
||||||
|
r"(/[A-Za-z0-9_\-]+)?$"
|
||||||
|
)
|
||||||
|
_EVENT_LOCK = threading.Lock()
|
||||||
|
_EVENTS = []
|
||||||
|
_EVENT_CURSOR = 0
|
||||||
|
_MAX_EVENTS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def register(api):
|
||||||
|
"""Wire the Canvas proxy, health, design-context, and SSE seed routes."""
|
||||||
|
log = api.logger("svrnty.routes.canvas")
|
||||||
|
api.register_route("/api/canvas/status", "GET", _handle_status)
|
||||||
|
api.register_route("/api/canvas/tools", "GET", _handle_tools)
|
||||||
|
api.register_route("/api/canvas/proxy", "GET", _handle_proxy)
|
||||||
|
api.register_route("/api/canvas/proxy", "POST", _handle_proxy)
|
||||||
|
api.register_route("/api/canvas/proxy", "PUT", _handle_proxy)
|
||||||
|
api.register_route("/api/canvas/command", "POST", _handle_command)
|
||||||
|
api.register_route("/api/canvas/design-context", "GET", _handle_design_context)
|
||||||
|
api.register_route("/api/canvas/events", "GET", _handle_events)
|
||||||
|
log.info("canvas endpoints registered")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_status(handler, _parsed):
|
||||||
|
base = _base_url()
|
||||||
|
ok = False
|
||||||
|
detail = "unreachable"
|
||||||
|
capabilities = None
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(base + "/health", method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
ok = 200 <= resp.status < 300
|
||||||
|
detail = "ok" if ok else f"status {resp.status}"
|
||||||
|
if ok:
|
||||||
|
caps_req = urllib.request.Request(base + "/api/v1/capabilities", method="GET")
|
||||||
|
with urllib.request.urlopen(caps_req, timeout=2) as caps_resp:
|
||||||
|
if 200 <= caps_resp.status < 300:
|
||||||
|
capabilities = json.loads(caps_resp.read().decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e)
|
||||||
|
|
||||||
|
return _send_json(handler, {
|
||||||
|
"ok": ok,
|
||||||
|
"service": "canva-editor",
|
||||||
|
"base_url": base,
|
||||||
|
"detail": detail,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"boundary": "Hermes WebUI plugin -> canva-editor HTTP API",
|
||||||
|
"entrypoint": "/plugins/svrnty/canvas.html",
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_tools(handler, _parsed):
|
||||||
|
return _send_json(handler, {
|
||||||
|
"ok": True,
|
||||||
|
"tool_surface": "cmo-canvas-v0",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "canvas.status",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/canvas/status",
|
||||||
|
"purpose": "Check canva-editor reachability from Hermes WebUI.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.create_project",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects",
|
||||||
|
"purpose": "Create an owned design project.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.generate_screen",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/command",
|
||||||
|
"purpose": "Ask the Canvas bridge to generate and persist a screen with live progress events.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.command",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/command",
|
||||||
|
"purpose": "CMO-facing command endpoint for prompt-to-canvas generation, variants, and event replay.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.generate_from_image",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/generate/from-image",
|
||||||
|
"purpose": "Generate a structured screen spec from image input.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.save_screen",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects/{project_id}/screens",
|
||||||
|
"purpose": "Persist generated screen specs as canonical canva-editor state.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.update_screen",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects/{project_id}/screens/{screen_id}&method=PUT",
|
||||||
|
"purpose": "Persist simple structured edits to an existing screen spec.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.design_context",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/canvas/design-context",
|
||||||
|
"purpose": "Seed brand and memory context for CMO canvas work.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.record_variant",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects/{id}/variants",
|
||||||
|
"purpose": "Record parent/child lineage for generated design variations.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.connect_prototype_edge",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects/{id}/prototype-edges",
|
||||||
|
"purpose": "Record a navigable relationship between two screens.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.export_project",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/canvas/proxy?path=/api/v1/projects/{id}/export",
|
||||||
|
"purpose": "Export project JSON with screens, variants, prototype edges, and design system.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canvas.events",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/canvas/events",
|
||||||
|
"purpose": "SSE contract seed for live canvas updates.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"non_goals": [
|
||||||
|
"full-canva-parity",
|
||||||
|
"full-figma-parity",
|
||||||
|
"publishing-without-jp-approval",
|
||||||
|
"upstream-hermes-webui-edits",
|
||||||
|
],
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_design_context(handler, parsed):
|
||||||
|
qs = urllib.parse.parse_qs(parsed.query or "")
|
||||||
|
brand_id = (qs.get("brand_id") or ["planb"])[0].strip() or "planb"
|
||||||
|
context = _load_design_context(brand_id)
|
||||||
|
return _send_json(handler, context, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_events(handler, _parsed):
|
||||||
|
"""Return replayable Canvas events.
|
||||||
|
|
||||||
|
The canva-editor service does not expose a live event bus yet. This route
|
||||||
|
establishes the browser/backend contract now. The Canvas panel polls JSON
|
||||||
|
deltas, while SSE callers still receive a valid seed stream plus replay.
|
||||||
|
"""
|
||||||
|
qs = urllib.parse.parse_qs(getattr(_parsed, "query", "") or "")
|
||||||
|
if "since" in qs or (qs.get("format") or [""])[0] == "json":
|
||||||
|
since = _safe_int((qs.get("since") or ["0"])[0], 0)
|
||||||
|
events = _events_since(since)
|
||||||
|
cursor = events[-1]["id"] if events else _current_event_cursor()
|
||||||
|
return _send_json(handler, {"ok": True, "cursor": cursor, "events": events}, 200)
|
||||||
|
|
||||||
|
replay = _events_since(0)[-20:]
|
||||||
|
chunks = [
|
||||||
|
"event: canvas.connected\n"
|
||||||
|
"data: {\"ok\":true,\"source\":\"svrnty-hermes-webui-plugin\"}\n\n"
|
||||||
|
]
|
||||||
|
for event in replay:
|
||||||
|
event_name = event.get("type", "canvas.event")
|
||||||
|
chunks.append(f"event: {event_name}\n")
|
||||||
|
chunks.append("data: " + json.dumps(event) + "\n\n")
|
||||||
|
body = "".join(chunks).encode("utf-8")
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.send_header("Connection", "close")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(body)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_command(handler, _parsed):
|
||||||
|
"""Run a CMO-style prompt-to-canvas command through canva-editor."""
|
||||||
|
try:
|
||||||
|
body = _read_json_body(handler)
|
||||||
|
except ValueError as e:
|
||||||
|
return _send_json(handler, {"ok": False, "error": str(e)}, 400)
|
||||||
|
|
||||||
|
prompt = str(body.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return _send_json(handler, {"ok": False, "error": "prompt is required"}, 400)
|
||||||
|
|
||||||
|
variants = max(1, min(6, _safe_int(body.get("variants"), 1)))
|
||||||
|
brand_id = str(body.get("brand_id") or body.get("brandId") or "planb").strip() or "planb"
|
||||||
|
project_name = str(body.get("project_name") or body.get("projectName") or "Hermes Canvas").strip() or "Hermes Canvas"
|
||||||
|
parent_screen_id = str(body.get("parent_screen_id") or body.get("parentScreenId") or "").strip()
|
||||||
|
command_id = f"canvas-{int(time.time() * 1000)}"
|
||||||
|
first_screen_id = parent_screen_id
|
||||||
|
screens = []
|
||||||
|
emitted_start = _current_event_cursor()
|
||||||
|
design_context = _load_design_context(brand_id)
|
||||||
|
|
||||||
|
_emit_event("canvas.command.accepted", {
|
||||||
|
"command_id": command_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
"variants": variants,
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"design_context_status": design_context.get("status"),
|
||||||
|
"design_context_version": design_context.get("source_version"),
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
project = _ensure_project(project_name)
|
||||||
|
project_id = _record_id(project)
|
||||||
|
if not project_id:
|
||||||
|
raise RuntimeError("canva-editor project id missing")
|
||||||
|
_emit_event("canvas.project.ready", {
|
||||||
|
"command_id": command_id,
|
||||||
|
"project": project,
|
||||||
|
})
|
||||||
|
|
||||||
|
for idx in range(variants):
|
||||||
|
user_variant_prompt = prompt if variants == 1 else (
|
||||||
|
f"{prompt}\nVariant {idx + 1} of {variants}: choose a distinct composition."
|
||||||
|
)
|
||||||
|
variant_prompt = _compose_generation_prompt(user_variant_prompt, design_context)
|
||||||
|
_emit_event("canvas.variant.started", {
|
||||||
|
"command_id": command_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"variant_index": idx + 1,
|
||||||
|
"variants": variants,
|
||||||
|
"design_context_status": design_context.get("status"),
|
||||||
|
})
|
||||||
|
generated = _canva_json("/api/v1/generate", "POST", {"prompt": variant_prompt}, timeout=90)
|
||||||
|
spec = generated.get("screenSpec") or generated.get("screen_spec") or generated
|
||||||
|
screen = _canva_json(
|
||||||
|
f"/api/v1/projects/{urllib.parse.quote(project_id)}/screens",
|
||||||
|
"POST",
|
||||||
|
{"name": prompt[:42] or "Generated screen", "spec": spec},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
screen_id = _record_id(screen)
|
||||||
|
if idx == 0 and not first_screen_id:
|
||||||
|
first_screen_id = screen_id
|
||||||
|
elif first_screen_id and screen_id:
|
||||||
|
_canva_json(
|
||||||
|
f"/api/v1/projects/{urllib.parse.quote(project_id)}/variants",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"parentScreenId": first_screen_id,
|
||||||
|
"screenId": screen_id,
|
||||||
|
"prompt": user_variant_prompt,
|
||||||
|
"aspects": ["layout", "copy", "composition"],
|
||||||
|
"metadata": {"commandId": command_id, "variantIndex": idx + 1},
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"screen": screen,
|
||||||
|
"screenSpec": spec,
|
||||||
|
"variant_index": idx + 1,
|
||||||
|
"prompt": user_variant_prompt,
|
||||||
|
"designContext": _compact_design_context(design_context),
|
||||||
|
}
|
||||||
|
screens.append(item)
|
||||||
|
_emit_event("canvas.screen.persisted", {
|
||||||
|
"command_id": command_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
**item,
|
||||||
|
})
|
||||||
|
|
||||||
|
_emit_event("canvas.command.completed", {
|
||||||
|
"command_id": command_id,
|
||||||
|
"project": project,
|
||||||
|
"screens": len(screens),
|
||||||
|
})
|
||||||
|
emitted = _events_since(emitted_start)
|
||||||
|
return _send_json(handler, {
|
||||||
|
"ok": True,
|
||||||
|
"command_id": command_id,
|
||||||
|
"project": project,
|
||||||
|
"screens": screens,
|
||||||
|
"design_context": _compact_design_context(design_context),
|
||||||
|
"events": emitted,
|
||||||
|
"cursor": emitted[-1]["id"] if emitted else _current_event_cursor(),
|
||||||
|
}, 200)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||||
|
_emit_event("canvas.command.failed", {"command_id": command_id, "error": detail})
|
||||||
|
return _send_json(handler, {"ok": False, "command_id": command_id, "error": detail}, e.code)
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
detail = f"canva-editor unreachable: {e.reason}"
|
||||||
|
_emit_event("canvas.command.failed", {"command_id": command_id, "error": detail})
|
||||||
|
return _send_json(handler, {"ok": False, "command_id": command_id, "error": detail}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e)
|
||||||
|
_emit_event("canvas.command.failed", {"command_id": command_id, "error": detail})
|
||||||
|
return _send_json(handler, {"ok": False, "command_id": command_id, "error": detail}, 500)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_proxy(handler, parsed):
|
||||||
|
qs = urllib.parse.parse_qs(parsed.query or "")
|
||||||
|
target_path = (qs.get("path") or [""])[0].strip()
|
||||||
|
method_override = (qs.get("method") or [""])[0].strip().upper()
|
||||||
|
if not target_path.startswith(("/health", "/api/v1/")):
|
||||||
|
return _send_json(handler, {"ok": False, "error": "missing or invalid path"}, 400)
|
||||||
|
if not _is_allowed(target_path):
|
||||||
|
return _send_json(handler, {"ok": False, "error": f"path not allowed: {target_path}"}, 403)
|
||||||
|
|
||||||
|
method = handler.command
|
||||||
|
if method == "POST" and method_override in {"PUT"}:
|
||||||
|
method = method_override
|
||||||
|
body = b""
|
||||||
|
if method in ("POST", "PUT", "PATCH"):
|
||||||
|
length = int(handler.headers.get("Content-Length", "0") or 0)
|
||||||
|
if length > 0:
|
||||||
|
body = handler.rfile.read(length)
|
||||||
|
|
||||||
|
target_url = _base_url() + target_path
|
||||||
|
req = urllib.request.Request(target_url, data=body if body else None, method=method)
|
||||||
|
if body:
|
||||||
|
req.add_header("Content-Type", handler.headers.get("Content-Type", "application/json"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||||
|
resp_body = resp.read()
|
||||||
|
resp_ctype = resp.headers.get("Content-Type", "application/json")
|
||||||
|
resp_status = resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
resp_body = e.read() or b""
|
||||||
|
resp_ctype = e.headers.get("Content-Type", "application/json") if e.headers else "application/json"
|
||||||
|
resp_status = e.code
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return _send_json(handler, {"ok": False, "error": f"canva-editor unreachable: {e.reason}"}, 502)
|
||||||
|
except Exception as e:
|
||||||
|
return _send_json(handler, {"ok": False, "error": f"proxy error: {e}"}, 500)
|
||||||
|
|
||||||
|
handler.send_response(resp_status)
|
||||||
|
handler.send_header("Content-Type", resp_ctype)
|
||||||
|
handler.send_header("Content-Length", str(len(resp_body)))
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(resp_body)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed(path: str) -> bool:
|
||||||
|
return path in _ALLOWED_EXACT or bool(_ALLOWED_PATTERN.match(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
return os.environ.get("CANVA_EDITOR_BASE_URL", _DEFAULT_CANVA_BASE).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_design_context(brand_id: str) -> dict:
|
||||||
|
resolved_brand_id = _resolve_brand_id(brand_id)
|
||||||
|
base_context = {
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"resolved_brand_id": resolved_brand_id,
|
||||||
|
"source": "cmo.brand_profile_cache",
|
||||||
|
"status": "missing",
|
||||||
|
"brand": None,
|
||||||
|
"brand_guideline_text": "",
|
||||||
|
"design_md": "",
|
||||||
|
"palette_tokens": [],
|
||||||
|
"typography_tokens": [],
|
||||||
|
"spacing_tokens": [],
|
||||||
|
"voice_rules": [],
|
||||||
|
"memory_hints": [],
|
||||||
|
"secondbrain": {"status": "not_checked", "hints": []},
|
||||||
|
"deferred_ports": ["secondbrain-writeback", "svrnty-vision"],
|
||||||
|
}
|
||||||
|
|
||||||
|
db_path = _cmo_db_path()
|
||||||
|
base_context["cmo_db_path"] = db_path
|
||||||
|
if not db_path:
|
||||||
|
base_context["error"] = "CMO database not found"
|
||||||
|
return base_context
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = _read_brand_cache(db_path, resolved_brand_id)
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
base_context["status"] = "error"
|
||||||
|
base_context["error"] = f"CMO database read failed: {e}"
|
||||||
|
return base_context
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
base_context["error"] = f"brand_profile_cache row not found for {resolved_brand_id}"
|
||||||
|
return base_context
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob = json.loads(row["json"] or "{}")
|
||||||
|
except ValueError as e:
|
||||||
|
base_context["status"] = "error"
|
||||||
|
base_context["error"] = f"brand_profile_cache JSON invalid: {e}"
|
||||||
|
return base_context
|
||||||
|
|
||||||
|
brand = blob.get("brand") if isinstance(blob.get("brand"), dict) else None
|
||||||
|
guideline = str(blob.get("brand_guideline") or "")
|
||||||
|
design_md = str(blob.get("design_md") or "")
|
||||||
|
palette = _normalise_token_list(blob.get("palette"))
|
||||||
|
typography = _normalise_token_list(blob.get("typography"))
|
||||||
|
spacing = _normalise_token_list(blob.get("spacing"))
|
||||||
|
voice_rules = _extract_voice_rules(blob.get("voice"), guideline)
|
||||||
|
memory_hints = _build_memory_hints(brand, guideline, palette, typography, spacing, voice_rules)
|
||||||
|
secondbrain = _load_secondbrain_hints(brand, resolved_brand_id)
|
||||||
|
memory_hints.extend(secondbrain.get("hints") or [])
|
||||||
|
|
||||||
|
base_context.update({
|
||||||
|
"status": "ready",
|
||||||
|
"cache_brand_id": row["brand_id"],
|
||||||
|
"source_version": row["version"],
|
||||||
|
"fetched_at": row["fetched_at"],
|
||||||
|
"brand": brand,
|
||||||
|
"brand_guideline_text": guideline,
|
||||||
|
"brand_guideline_excerpt": _excerpt(guideline, 1800),
|
||||||
|
"design_md": design_md,
|
||||||
|
"design_md_excerpt": _excerpt(design_md, 1400),
|
||||||
|
"palette_tokens": palette,
|
||||||
|
"typography_tokens": typography,
|
||||||
|
"spacing_tokens": spacing,
|
||||||
|
"voice_rules": voice_rules,
|
||||||
|
"memory_hints": memory_hints[:14],
|
||||||
|
"secondbrain": secondbrain,
|
||||||
|
"tokens_imported": bool(blob.get("tokens_imported") or palette or typography or spacing),
|
||||||
|
})
|
||||||
|
return base_context
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_brand_id(brand_id: str) -> str:
|
||||||
|
value = (brand_id or "").strip()
|
||||||
|
return _BRAND_ALIASES.get(value.lower(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmo_db_path() -> str:
|
||||||
|
candidates = []
|
||||||
|
for env_name in ("CANVAS_CMO_DB", "CMO_DB"):
|
||||||
|
env_path = os.environ.get(env_name)
|
||||||
|
if env_path:
|
||||||
|
candidates.append(env_path)
|
||||||
|
candidates.extend(_DEFAULT_CMO_DB_PATHS)
|
||||||
|
for path in candidates:
|
||||||
|
expanded = os.path.expanduser(path)
|
||||||
|
if os.path.exists(expanded):
|
||||||
|
return expanded
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_brand_cache(db_path: str, brand_id: str):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""SELECT brand_id, json, version, fetched_at
|
||||||
|
FROM brand_profile_cache
|
||||||
|
WHERE brand_id = ?""",
|
||||||
|
(brand_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_token_list(raw_tokens) -> list:
|
||||||
|
if not isinstance(raw_tokens, list):
|
||||||
|
return []
|
||||||
|
tokens = []
|
||||||
|
for item in raw_tokens[:80]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
token = {
|
||||||
|
"path": item.get("path") or item.get("name") or item.get("key"),
|
||||||
|
"type": item.get("type"),
|
||||||
|
"description": item.get("description"),
|
||||||
|
}
|
||||||
|
value = item.get("value")
|
||||||
|
if value is None:
|
||||||
|
value = item.get("valueJson")
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
token["value"] = value
|
||||||
|
tokens.append({k: v for k, v in token.items() if v not in (None, "")})
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_voice_rules(voice, guideline: str) -> list:
|
||||||
|
rules = []
|
||||||
|
if isinstance(voice, dict):
|
||||||
|
for key in ("tone", "voice", "dos", "donts", "rules", "pillars", "description"):
|
||||||
|
value = voice.get(key) or voice.get(key[0].upper() + key[1:])
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
rules.append(value.strip())
|
||||||
|
elif isinstance(value, list):
|
||||||
|
rules.extend(str(item).strip() for item in value if str(item).strip())
|
||||||
|
elif isinstance(voice, str) and voice.strip():
|
||||||
|
rules.append(voice.strip())
|
||||||
|
|
||||||
|
if not rules and guideline:
|
||||||
|
for line in guideline.splitlines():
|
||||||
|
clean = line.strip(" -\t")
|
||||||
|
if clean and any(word in clean.lower() for word in ("voice", "tone", "avoid", "must", "never")):
|
||||||
|
rules.append(clean)
|
||||||
|
if len(rules) >= 8:
|
||||||
|
break
|
||||||
|
return rules[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_memory_hints(brand, guideline, palette, typography, spacing, voice_rules) -> list:
|
||||||
|
hints = []
|
||||||
|
if isinstance(brand, dict):
|
||||||
|
name = brand.get("displayName") or brand.get("name")
|
||||||
|
description = brand.get("description")
|
||||||
|
if name:
|
||||||
|
hints.append(f"Brand: {name}")
|
||||||
|
if description:
|
||||||
|
hints.append(f"Brand description: {_excerpt(str(description), 260)}")
|
||||||
|
if palette:
|
||||||
|
colors = []
|
||||||
|
for token in palette:
|
||||||
|
value = token.get("value")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
colors.extend(str(v) for v in value.values() if str(v).startswith("#"))
|
||||||
|
elif isinstance(value, str) and value.startswith("#"):
|
||||||
|
colors.append(value)
|
||||||
|
if colors:
|
||||||
|
hints.append("Use BTE palette tokens: " + ", ".join(dict.fromkeys(colors[:8])))
|
||||||
|
if typography:
|
||||||
|
type_paths = [str(t.get("path")) for t in typography if t.get("path")]
|
||||||
|
if type_paths:
|
||||||
|
hints.append("Typography context: " + ", ".join(type_paths[:8]))
|
||||||
|
if spacing:
|
||||||
|
hints.append("Respect BTE spacing tokens and density rhythm.")
|
||||||
|
if voice_rules:
|
||||||
|
hints.append("Voice constraints: " + " | ".join(_excerpt(rule, 180) for rule in voice_rules[:3]))
|
||||||
|
if guideline:
|
||||||
|
hints.append("Use BTE brand guideline as primary public-plane creative truth.")
|
||||||
|
return hints[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_secondbrain_hints(brand, brand_id: str) -> dict:
|
||||||
|
"""Read a few relevant knowledge capsules through local psql when configured."""
|
||||||
|
password = os.environ.get("SECONDBRAIN_DB_PASSWORD") or os.environ.get("PGPASSWORD")
|
||||||
|
database_url = os.environ.get("SECONDBRAIN_DATABASE_URL")
|
||||||
|
if not database_url and not password:
|
||||||
|
return {"status": "unconfigured", "hints": []}
|
||||||
|
|
||||||
|
psql = os.environ.get("PSQL_BIN", "psql")
|
||||||
|
sql = """
|
||||||
|
SELECT COALESCE(json_agg(row_to_json(t))::text, '[]')
|
||||||
|
FROM (
|
||||||
|
SELECT id, title, summary, sector, created_at
|
||||||
|
FROM knowledge_capsules
|
||||||
|
WHERE sector IN ('svrnty', 'planb', 'personal')
|
||||||
|
AND (
|
||||||
|
title ILIKE '%design%' OR summary ILIKE '%design%' OR content ILIKE '%design%' OR
|
||||||
|
title ILIKE '%brand%' OR summary ILIKE '%brand%' OR content ILIKE '%brand%' OR
|
||||||
|
title ILIKE '%canvas%' OR summary ILIKE '%canvas%' OR content ILIKE '%canvas%' OR
|
||||||
|
title ILIKE '%stitch%' OR summary ILIKE '%stitch%' OR content ILIKE '%stitch%'
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 4
|
||||||
|
) AS t;
|
||||||
|
""".strip()
|
||||||
|
cmd = [psql]
|
||||||
|
env = os.environ.copy()
|
||||||
|
if database_url:
|
||||||
|
cmd.append(database_url)
|
||||||
|
else:
|
||||||
|
env["PGPASSWORD"] = password
|
||||||
|
cmd.extend([
|
||||||
|
"-h", os.environ.get("SECONDBRAIN_DB_HOST", "localhost"),
|
||||||
|
"-p", os.environ.get("SECONDBRAIN_DB_PORT", "5435"),
|
||||||
|
"-U", os.environ.get("SECONDBRAIN_DB_USER", "svrnty"),
|
||||||
|
"-d", os.environ.get("SECONDBRAIN_DB_NAME", "secondbrain"),
|
||||||
|
])
|
||||||
|
cmd.extend(["-tA", "-c", sql])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2.5,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"status": "psql_missing", "hints": []}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"status": "timeout", "hints": []}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "hints": [], "error": str(e)}
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {"status": "error", "hints": [], "error": _excerpt(result.stderr, 240)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = json.loads((result.stdout or "[]").strip() or "[]")
|
||||||
|
except ValueError as e:
|
||||||
|
return {"status": "error", "hints": [], "error": f"invalid secondbrain JSON: {e}"}
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
capsules = []
|
||||||
|
brand_name = ""
|
||||||
|
if isinstance(brand, dict):
|
||||||
|
brand_name = brand.get("displayName") or brand.get("name") or ""
|
||||||
|
for row in rows[:4]:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
title = str(row.get("title") or "Untitled capsule")
|
||||||
|
summary = _excerpt(str(row.get("summary") or ""), 260)
|
||||||
|
capsule = {
|
||||||
|
"id": row.get("id"),
|
||||||
|
"title": title,
|
||||||
|
"sector": row.get("sector"),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
}
|
||||||
|
capsules.append({k: v for k, v in capsule.items() if v not in (None, "")})
|
||||||
|
hint = f"Secondbrain capsule {row.get('id')}: {title}"
|
||||||
|
if summary:
|
||||||
|
hint += f" — {summary}"
|
||||||
|
hints.append(hint)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"source": "secondbrain.knowledge_capsules",
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"brand_name": brand_name,
|
||||||
|
"capsules": capsules,
|
||||||
|
"hints": hints,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _excerpt(text: str, limit: int) -> str:
|
||||||
|
cleaned = " ".join((text or "").split())
|
||||||
|
if len(cleaned) <= limit:
|
||||||
|
return cleaned
|
||||||
|
return cleaned[: max(0, limit - 3)].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_design_context(context: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"brand_id": context.get("brand_id"),
|
||||||
|
"resolved_brand_id": context.get("resolved_brand_id"),
|
||||||
|
"status": context.get("status"),
|
||||||
|
"source": context.get("source"),
|
||||||
|
"source_version": context.get("source_version"),
|
||||||
|
"fetched_at": context.get("fetched_at"),
|
||||||
|
"tokens_imported": context.get("tokens_imported"),
|
||||||
|
"memory_hints": context.get("memory_hints") or [],
|
||||||
|
"secondbrain_status": (context.get("secondbrain") or {}).get("status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_generation_prompt(prompt: str, context: dict) -> str:
|
||||||
|
if not context or context.get("status") != "ready":
|
||||||
|
return prompt
|
||||||
|
pieces = [prompt.strip()]
|
||||||
|
brand = context.get("brand") or {}
|
||||||
|
brand_name = brand.get("displayName") or brand.get("name")
|
||||||
|
if brand_name:
|
||||||
|
pieces.append(f"Brand context: {brand_name}.")
|
||||||
|
hints = context.get("memory_hints") or []
|
||||||
|
if hints:
|
||||||
|
pieces.append("BTE/CMO design constraints:\n- " + "\n- ".join(str(h) for h in hints[:8]))
|
||||||
|
guideline = context.get("brand_guideline_excerpt") or context.get("design_md_excerpt") or ""
|
||||||
|
if guideline:
|
||||||
|
pieces.append("Brand guideline excerpt:\n" + _excerpt(guideline, 1600))
|
||||||
|
pieces.append("Generate draft-only canvas output for Hermes WebUI; do not publish or imply approval.")
|
||||||
|
return "\n\n".join(piece for piece in pieces if piece)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_body(handler) -> dict:
|
||||||
|
length = int(handler.headers.get("Content-Length", "0") or 0)
|
||||||
|
if length <= 0:
|
||||||
|
return {}
|
||||||
|
raw = handler.rfile.read(length)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"invalid JSON: {e}")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("body must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_project(name: str) -> dict:
|
||||||
|
projects = _canva_json("/api/v1/projects", "GET", None, timeout=15)
|
||||||
|
if isinstance(projects, list) and projects:
|
||||||
|
return projects[0]
|
||||||
|
return _canva_json("/api/v1/projects", "POST", {"name": name}, timeout=15)
|
||||||
|
|
||||||
|
|
||||||
|
def _canva_json(path: str, method: str = "GET", payload=None, timeout: int = 45):
|
||||||
|
data = None
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(_base_url() + path, data=data, method=method)
|
||||||
|
if data is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _record_id(record) -> str:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return ""
|
||||||
|
return str(record.get("id") or record.get("ID") or record.get("uuid") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_event(event_type: str, payload: dict) -> dict:
|
||||||
|
global _EVENT_CURSOR
|
||||||
|
with _EVENT_LOCK:
|
||||||
|
_EVENT_CURSOR += 1
|
||||||
|
event = {
|
||||||
|
"id": _EVENT_CURSOR,
|
||||||
|
"type": event_type,
|
||||||
|
"at": time.time(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
_EVENTS.append(event)
|
||||||
|
del _EVENTS[:-_MAX_EVENTS]
|
||||||
|
return dict(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _events_since(cursor: int) -> list:
|
||||||
|
with _EVENT_LOCK:
|
||||||
|
return [dict(event) for event in _EVENTS if event["id"] > cursor]
|
||||||
|
|
||||||
|
|
||||||
|
def _current_event_cursor() -> int:
|
||||||
|
with _EVENT_LOCK:
|
||||||
|
return _EVENT_CURSOR
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value, fallback: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _send_json(handler, payload: dict, status: int) -> bool:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(body)
|
||||||
|
return True
|
||||||
@ -28,10 +28,19 @@ PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
|||||||
SMOKE = [
|
SMOKE = [
|
||||||
{"path": "/health", "expect": [200], "kind": "vanilla"},
|
{"path": "/health", "expect": [200], "kind": "vanilla"},
|
||||||
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
|
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/status", "expect": [200, 503, 401, 403], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/tools", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/proxy?path=/api/v1/capabilities", "expect": [200, 401, 403, 502], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/command", "method": "POST", "body": b"{}", "expect": [400, 401, 403], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/design-context", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
|
{"path": "/api/canvas/events", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
{"path": "/api/umbrella", "expect": [200, 401, 403], "kind": "plugin"},
|
{"path": "/api/umbrella", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
{"path": "/api/umbrella/doc?path=sot/README.md", "expect": [200, 401, 403], "kind": "plugin"},
|
{"path": "/api/umbrella/doc?path=sot/README.md", "expect": [200, 401, 403], "kind": "plugin"},
|
||||||
{"path": "/plugins/svrnty/app.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
{"path": "/plugins/svrnty/app.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
{"path": "/plugins/svrnty/app.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
{"path": "/plugins/svrnty/app.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
|
{"path": "/plugins/svrnty/canvas.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
|
{"path": "/plugins/svrnty/canvas.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
|
{"path": "/plugins/svrnty/canvas.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
{"path": "/plugins/svrnty/umbrella.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
{"path": "/plugins/svrnty/umbrella.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
{"path": "/plugins/svrnty/umbrella.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
{"path": "/plugins/svrnty/umbrella.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
{"path": "/plugins/svrnty/umbrella.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
{"path": "/plugins/svrnty/umbrella.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||||
@ -61,10 +70,16 @@ def _wait_for(url, timeout=20):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _hit(base, path):
|
def _hit(base, spec):
|
||||||
|
path = spec["path"]
|
||||||
url = base.rstrip("/") + path
|
url = base.rstrip("/") + path
|
||||||
|
method = spec.get("method", "GET")
|
||||||
|
body = spec.get("body")
|
||||||
try:
|
try:
|
||||||
with urlopen(url, timeout=5) as r:
|
req = Request(url, data=body, method=method)
|
||||||
|
if body is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
with urlopen(req, timeout=5) as r:
|
||||||
return r.status, r.read()[:200]
|
return r.status, r.read()[:200]
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
if hasattr(e, "code"):
|
if hasattr(e, "code"):
|
||||||
@ -78,7 +93,7 @@ def smoke(base):
|
|||||||
rows = []
|
rows = []
|
||||||
failed = 0
|
failed = 0
|
||||||
for s in SMOKE:
|
for s in SMOKE:
|
||||||
status, _body = _hit(base, s["path"])
|
status, _body = _hit(base, s)
|
||||||
ok = status in s["expect"]
|
ok = status in s["expect"]
|
||||||
rows.append({"path": s["path"], "status": status, "kind": s["kind"], "ok": ok})
|
rows.append({"path": s["path"], "status": status, "kind": s["kind"], "ok": ok})
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|||||||
339
scripts/canvas-visual-smoke.py
Executable file
339
scripts/canvas-visual-smoke.py
Executable file
@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Browser smoke for the Hermes Canvas command-center loop.
|
||||||
|
|
||||||
|
Starts canva-editor in mock mode, starts hermes-webui with the Svrnty plugin,
|
||||||
|
opens the real WebUI shell, switches to Canvas, generates variants, captures a
|
||||||
|
screenshot, and writes a compact JSON proof.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
HERMES_REPO = PLUGIN_REPO.parent
|
||||||
|
WEBUI_REPO = HERMES_REPO / "hermes-webui"
|
||||||
|
AGENT_REPO = HERMES_REPO / "hermes-agent"
|
||||||
|
CANVA_REPO = HERMES_REPO.parent / "cortex" / "L2-svrnty.tool-canva-editor"
|
||||||
|
CMO_REPO = HERMES_REPO / "cmo"
|
||||||
|
DEFAULT_OUT = HERMES_REPO / ".scratch" / "canvas-visual-proof.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for(url: str, timeout: int = 30) -> bool:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=1) as res:
|
||||||
|
if res.status < 500:
|
||||||
|
return True
|
||||||
|
except URLError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pixel_stats(path: Path) -> dict:
|
||||||
|
img = Image.open(path).convert("RGB")
|
||||||
|
width, height = img.size
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
pixels = list(img.getdata())
|
||||||
|
total = len(pixels)
|
||||||
|
bright = sum(1 for r, g, b in pixels if max(r, g, b) > 150)
|
||||||
|
distinct = len(set(pixels[:: max(1, total // 20000)]))
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"bright_ratio": round(bright / total, 4),
|
||||||
|
"sampled_distinct_colors": distinct,
|
||||||
|
"nonblank": bright > 1000 and distinct > 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _start_canva(port: int, db_path: Path) -> subprocess.Popen:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PATH"] = f"/home/svrnty/sdk/go1.26.0/bin{os.pathsep}{env.get('PATH', '')}"
|
||||||
|
env["PORT"] = str(port)
|
||||||
|
env["DB_PATH"] = str(db_path)
|
||||||
|
return subprocess.Popen(
|
||||||
|
["go", "run", "./cmd/server", "--mock"],
|
||||||
|
cwd=CANVA_REPO,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_webui(port: int, state_dir: Path, canva_base: str, plugin_module: str) -> subprocess.Popen:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("HERMES_WEBUI_PASSWORD", None)
|
||||||
|
env["CANVA_EDITOR_BASE_URL"] = canva_base
|
||||||
|
env["HERMES_WEBUI_PYTHON_PLUGIN"] = plugin_module
|
||||||
|
if plugin_module == "svrnty_hermes_webui_refactor_plugin":
|
||||||
|
env["SVRNTY_WEBUI_DEVELOPMENT_INCLUDE_PROD"] = "1"
|
||||||
|
env["HERMES_WEBUI_PORT"] = str(port)
|
||||||
|
env["HERMES_WEBUI_STATE_DIR"] = str(state_dir)
|
||||||
|
env["HERMES_WEBUI_AGENT_DIR"] = str(AGENT_REPO)
|
||||||
|
env["HERMES_REPO_ROOT"] = str(HERMES_REPO)
|
||||||
|
env["PYTHONPATH"] = (
|
||||||
|
f"{PLUGIN_REPO}{os.pathsep}{HERMES_REPO / 'webui-plugin-development'}{os.pathsep}{AGENT_REPO}"
|
||||||
|
if not env.get("PYTHONPATH")
|
||||||
|
else f"{PLUGIN_REPO}{os.pathsep}{HERMES_REPO / 'webui-plugin-development'}{os.pathsep}{AGENT_REPO}{os.pathsep}{env['PYTHONPATH']}"
|
||||||
|
)
|
||||||
|
py = WEBUI_REPO / "venv" / "bin" / "python"
|
||||||
|
agent_py = AGENT_REPO / "venv" / "bin" / "python"
|
||||||
|
python_exe = str(py) if py.exists() else str(agent_py) if agent_py.exists() else "python3"
|
||||||
|
return subprocess.Popen(
|
||||||
|
[python_exe, str(WEBUI_REPO / "server.py")],
|
||||||
|
cwd=AGENT_REPO if AGENT_REPO.exists() else WEBUI_REPO,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(proc: subprocess.Popen | None) -> str:
|
||||||
|
if proc is None:
|
||||||
|
return ""
|
||||||
|
output = ""
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if proc.stdout:
|
||||||
|
try:
|
||||||
|
output = proc.stdout.read(4000)
|
||||||
|
except Exception:
|
||||||
|
output = ""
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
|
||||||
|
parser.add_argument("--screenshot-dir", default=str(HERMES_REPO / ".scratch" / "screenshots" / "canvas"))
|
||||||
|
parser.add_argument("--plugin-module", default="svrnty_hermes_webui_plugin")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not CANVA_REPO.exists():
|
||||||
|
raise SystemExit(f"missing canva-editor repo: {CANVA_REPO}")
|
||||||
|
|
||||||
|
out_json = Path(args.output_json)
|
||||||
|
screenshot_dir = Path(args.screenshot_dir)
|
||||||
|
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
canva_port = _free_port()
|
||||||
|
webui_port = _free_port()
|
||||||
|
canva_base = f"http://127.0.0.1:{canva_port}"
|
||||||
|
webui_base = f"http://127.0.0.1:{webui_port}"
|
||||||
|
canva_proc: subprocess.Popen | None = None
|
||||||
|
webui_proc: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="hermes-canvas-webui-") as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
canva_proc = _start_canva(canva_port, tmp_path / "canva-editor.db")
|
||||||
|
try:
|
||||||
|
if not _wait_for(f"{canva_base}/health", timeout=45):
|
||||||
|
raise RuntimeError(f"canva-editor did not become healthy at {canva_base}/health")
|
||||||
|
|
||||||
|
webui_proc = _start_webui(webui_port, tmp_path / "webui-state", canva_base, args.plugin_module)
|
||||||
|
if not _wait_for(f"{webui_base}/health", timeout=45):
|
||||||
|
raise RuntimeError(f"webui did not become healthy at {webui_base}/health")
|
||||||
|
|
||||||
|
proof = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"webui_base": webui_base,
|
||||||
|
"canva_base": canva_base,
|
||||||
|
"plugin_module": args.plugin_module,
|
||||||
|
}
|
||||||
|
cmo_helper = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(CMO_REPO / "canvas_command.py"),
|
||||||
|
"command",
|
||||||
|
"Create a CMO-triggered draft promo card for Plan B",
|
||||||
|
"--variants",
|
||||||
|
"1",
|
||||||
|
"--brand",
|
||||||
|
"planb",
|
||||||
|
"--project",
|
||||||
|
"Hermes CMO Canvas Smoke",
|
||||||
|
"--wait",
|
||||||
|
],
|
||||||
|
env={**os.environ, "HERMES_WEBUI_URL": webui_base},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cmo_helper_payload = json.loads(cmo_helper.stdout or "{}")
|
||||||
|
except ValueError:
|
||||||
|
cmo_helper_payload = {"parse_error": cmo_helper.stdout}
|
||||||
|
proof["cmo_helper"] = {
|
||||||
|
"returncode": cmo_helper.returncode,
|
||||||
|
"ok": cmo_helper.returncode == 0 and bool(cmo_helper_payload.get("ok")),
|
||||||
|
"command_id": cmo_helper_payload.get("command_id"),
|
||||||
|
"screens": len(cmo_helper_payload.get("screens") or []),
|
||||||
|
"event_wait_ok": bool((cmo_helper_payload.get("event_wait") or {}).get("ok")),
|
||||||
|
"design_context_status": (cmo_helper_payload.get("design_context") or {}).get("status"),
|
||||||
|
"secondbrain_status": (cmo_helper_payload.get("design_context") or {}).get("secondbrain_status"),
|
||||||
|
"stderr": cmo_helper.stderr[-1000:],
|
||||||
|
}
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
executable_path="/usr/bin/google-chrome" if Path("/usr/bin/google-chrome").exists() else None,
|
||||||
|
args=["--no-sandbox"],
|
||||||
|
)
|
||||||
|
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||||
|
page.goto(webui_base, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => window.SvrntyCanvas && window.SvrntyNav && typeof window.switchPanel === 'function'",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
slash_registered = page.evaluate(
|
||||||
|
"() => typeof COMMANDS !== 'undefined' && COMMANDS.some((cmd) => cmd && cmd.name === 'canvas')"
|
||||||
|
)
|
||||||
|
page.evaluate("() => window.switchPanel('canvas')")
|
||||||
|
page.wait_for_selector("#svrntyCanvasOverlay:not([hidden])", timeout=10000)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => document.querySelector('#svrntyCanvasStatus')?.textContent.includes('online')",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
page.evaluate(
|
||||||
|
"""() => window.SvrntyCanvas.request({
|
||||||
|
prompt: 'Create a polished CMO launch dashboard with campaign KPIs',
|
||||||
|
variants: 2,
|
||||||
|
brand_id: 'planb',
|
||||||
|
source: 'visual-smoke'
|
||||||
|
})"""
|
||||||
|
)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => document.querySelectorAll('.svrnty-canvas-artboard').length >= 2",
|
||||||
|
timeout=60000,
|
||||||
|
)
|
||||||
|
editable = page.locator('.svrnty-canvas-artboard:first-child .svrnty-canvas-node[contenteditable="true"]').first
|
||||||
|
editable.fill("Edited KPI panel")
|
||||||
|
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="save"]')
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => !!window.SvrntyCanvas.state.lastEditPersisted",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
debug_state = page.evaluate(
|
||||||
|
"""() => ({
|
||||||
|
artboards: window.SvrntyCanvas.state.artboards.map((b) => ({id:b.id,title:b.title,dirty:b.dirty,badge:b.badge})),
|
||||||
|
logs: window.SvrntyCanvas.state.log.map((row) => row.message),
|
||||||
|
editableCount: document.querySelectorAll('.svrnty-canvas-node[contenteditable="true"]').length,
|
||||||
|
saveButtonCount: document.querySelectorAll('[data-canvas-action="save"]').length
|
||||||
|
})"""
|
||||||
|
)
|
||||||
|
print(json.dumps({"canvas_edit_debug": debug_state}, indent=2), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="prototype-source"]')
|
||||||
|
page.click('.svrnty-canvas-artboard:nth-child(2) [data-canvas-action="prototype-link"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => !!window.SvrntyCanvas.state.lastPrototypeEdge",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="export"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => window.SvrntyCanvas.state.exportSummary && window.SvrntyCanvas.state.exportSummary.screens.length >= 2",
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
|
state = page.evaluate(
|
||||||
|
"""(slashRegistered) => ({
|
||||||
|
connected: window.SvrntyCanvas.state.connected,
|
||||||
|
artboards: window.SvrntyCanvas.state.artboards.length,
|
||||||
|
slashCommandRegistered: slashRegistered,
|
||||||
|
editPersisted: !!window.SvrntyCanvas.state.lastEditPersisted,
|
||||||
|
prototypeLinked: !!window.SvrntyCanvas.state.lastPrototypeEdge,
|
||||||
|
exportScreens: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.screens.length : 0,
|
||||||
|
exportPrototypeEdges: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.prototypeEdges.length : 0,
|
||||||
|
designContextReady: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.status === 'ready' : false,
|
||||||
|
designContextSource: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source : '',
|
||||||
|
designContextVersion: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source_version : '',
|
||||||
|
secondbrainStatus: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.secondbrain_status : '',
|
||||||
|
memoryHintCount: window.SvrntyCanvas.state.designContext && window.SvrntyCanvas.state.designContext.memory_hints ? window.SvrntyCanvas.state.designContext.memory_hints.length : 0,
|
||||||
|
logs: window.SvrntyCanvas.state.log.map((row) => row.message).slice(0, 8),
|
||||||
|
status: document.querySelector('#svrntyCanvasStatus')?.textContent || '',
|
||||||
|
mainClass: document.querySelector('main.main')?.className || ''
|
||||||
|
})""",
|
||||||
|
slash_registered,
|
||||||
|
)
|
||||||
|
shot = screenshot_dir / "canvas-desktop-proof.png"
|
||||||
|
page.screenshot(path=str(shot), full_page=True)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
proof["state"] = state
|
||||||
|
proof["pixel_stats"] = _pixel_stats(shot)
|
||||||
|
proof["pass"] = bool(
|
||||||
|
state["connected"]
|
||||||
|
and state["artboards"] >= 2
|
||||||
|
and state["slashCommandRegistered"]
|
||||||
|
and state["editPersisted"]
|
||||||
|
and state["prototypeLinked"]
|
||||||
|
and state["exportScreens"] >= 2
|
||||||
|
and state["exportPrototypeEdges"] >= 1
|
||||||
|
and state["designContextReady"]
|
||||||
|
and state["designContextSource"] == "cmo.brand_profile_cache"
|
||||||
|
and state["secondbrainStatus"] in ("ready", "unconfigured")
|
||||||
|
and state["memoryHintCount"] >= 1
|
||||||
|
and proof["cmo_helper"]["ok"]
|
||||||
|
and proof["cmo_helper"]["screens"] >= 1
|
||||||
|
and proof["cmo_helper"]["event_wait_ok"]
|
||||||
|
and proof["cmo_helper"]["design_context_status"] == "ready"
|
||||||
|
and "svrnty-showing-canvas" in state["mainClass"]
|
||||||
|
and proof["pixel_stats"]["nonblank"]
|
||||||
|
)
|
||||||
|
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_json.write_text(json.dumps(proof, indent=2), encoding="utf-8")
|
||||||
|
print(json.dumps(proof, indent=2))
|
||||||
|
return 0 if proof["pass"] else 1
|
||||||
|
finally:
|
||||||
|
webui_log = _stop(webui_proc)
|
||||||
|
canva_log = _stop(canva_proc)
|
||||||
|
if webui_log:
|
||||||
|
print(webui_log, file=sys.stderr)
|
||||||
|
if canva_log:
|
||||||
|
print(canva_log, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
247
scripts/cmo-natural-canvas-smoke.py
Executable file
247
scripts/cmo-natural-canvas-smoke.py
Executable file
@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke a natural CMO Hermes turn into the WebUI Canvas bridge.
|
||||||
|
|
||||||
|
This proves the runtime handoff JP cares about:
|
||||||
|
CMO profile -> terminal helper -> Hermes WebUI plugin -> canva-editor -> replayable
|
||||||
|
Canvas events. The canva-editor is mocked here so the proof isolates routing; the
|
||||||
|
real Spark qwen3.6 generator is covered by canva-editor's real-model smoke.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
HERMES_REPO = PLUGIN_REPO.parent
|
||||||
|
WEBUI_REPO = HERMES_REPO / "hermes-webui"
|
||||||
|
AGENT_REPO = HERMES_REPO / "hermes-agent"
|
||||||
|
CANVA_REPO = HERMES_REPO.parent / "cortex" / "L2-svrnty.tool-canva-editor"
|
||||||
|
CMO_REPO = HERMES_REPO / "cmo"
|
||||||
|
DEFAULT_OUT = HERMES_REPO / ".scratch" / "cmo-natural-canvas-proof.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for(url: str, timeout: int = 45) -> bool:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=1) as res:
|
||||||
|
if res.status < 500:
|
||||||
|
return True
|
||||||
|
except URLError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(url: str, timeout: float = 8.0) -> dict:
|
||||||
|
with urlopen(url, timeout=timeout) as res:
|
||||||
|
return json.loads(res.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _start_canva(port: int, db_path: Path) -> subprocess.Popen:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PATH"] = f"/home/svrnty/sdk/go1.26.0/bin{os.pathsep}{env.get('PATH', '')}"
|
||||||
|
env["PORT"] = str(port)
|
||||||
|
env["DB_PATH"] = str(db_path)
|
||||||
|
return subprocess.Popen(
|
||||||
|
["go", "run", "./cmd/server", "--mock"],
|
||||||
|
cwd=CANVA_REPO,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_webui(port: int, state_dir: Path, canva_base: str) -> subprocess.Popen:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("HERMES_WEBUI_PASSWORD", None)
|
||||||
|
env["CANVA_EDITOR_BASE_URL"] = canva_base
|
||||||
|
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
|
||||||
|
env["HERMES_WEBUI_PORT"] = str(port)
|
||||||
|
env["HERMES_WEBUI_STATE_DIR"] = str(state_dir)
|
||||||
|
env["HERMES_WEBUI_AGENT_DIR"] = str(AGENT_REPO)
|
||||||
|
env["HERMES_REPO_ROOT"] = str(HERMES_REPO)
|
||||||
|
env["PYTHONPATH"] = (
|
||||||
|
f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}"
|
||||||
|
if not env.get("PYTHONPATH")
|
||||||
|
else f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}{os.pathsep}{env['PYTHONPATH']}"
|
||||||
|
)
|
||||||
|
py = WEBUI_REPO / "venv" / "bin" / "python"
|
||||||
|
agent_py = AGENT_REPO / "venv" / "bin" / "python"
|
||||||
|
python_exe = str(py) if py.exists() else str(agent_py) if agent_py.exists() else "python3"
|
||||||
|
return subprocess.Popen(
|
||||||
|
[python_exe, str(WEBUI_REPO / "server.py")],
|
||||||
|
cwd=AGENT_REPO if AGENT_REPO.exists() else WEBUI_REPO,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(proc: subprocess.Popen | None) -> str:
|
||||||
|
if proc is None:
|
||||||
|
return ""
|
||||||
|
output = ""
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if proc.stdout:
|
||||||
|
try:
|
||||||
|
output = proc.stdout.read(4000)
|
||||||
|
except Exception:
|
||||||
|
output = ""
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cmo_turn(webui_base: str, timeout_s: int) -> subprocess.CompletedProcess:
|
||||||
|
prompt = f"""
|
||||||
|
You are CMO for Plan B. This is a routing smoke for the Canvas design command center.
|
||||||
|
|
||||||
|
Use the terminal tool to run exactly this command:
|
||||||
|
python3 "{CMO_REPO / "canvas_command.py"}" command "Create one CMO-routed Plan B promo card for a family meal campaign" --variants 1 --brand planb --project "Hermes CMO Natural Smoke" --wait
|
||||||
|
|
||||||
|
After the terminal command returns, reply with a compact summary containing command_id, project_id, number of screens, design_context.status, and secondbrain_status. Do not call canva-editor directly.
|
||||||
|
""".strip()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HERMES_WEBUI_URL"] = webui_base
|
||||||
|
env["CMO_LIB"] = str(CMO_REPO)
|
||||||
|
env["HERMES_INFERENCE_PROVIDER"] = "vllm"
|
||||||
|
env["HERMES_INFERENCE_MODEL"] = "qwen3.6-35b-a3b"
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
"hermes",
|
||||||
|
"-p",
|
||||||
|
"cmo-planb",
|
||||||
|
"--provider",
|
||||||
|
"vllm",
|
||||||
|
"--model",
|
||||||
|
"qwen3.6-35b-a3b",
|
||||||
|
"-z",
|
||||||
|
prompt,
|
||||||
|
"--toolsets",
|
||||||
|
"terminal",
|
||||||
|
"--accept-hooks",
|
||||||
|
"--yolo",
|
||||||
|
],
|
||||||
|
cwd=HERMES_REPO,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canvas_event_summary(webui_base: str) -> dict:
|
||||||
|
payload = _fetch_json(f"{webui_base}/api/canvas/events?format=json&since=0")
|
||||||
|
events = payload.get("events") or []
|
||||||
|
completed = [event for event in events if event.get("type") == "canvas.command.completed"]
|
||||||
|
persisted = [event for event in events if event.get("type") == "canvas.screen.persisted"]
|
||||||
|
failed = [event for event in events if event.get("type") == "canvas.command.failed"]
|
||||||
|
command_ids = sorted({event.get("command_id") for event in completed if event.get("command_id")})
|
||||||
|
return {
|
||||||
|
"cursor": payload.get("cursor"),
|
||||||
|
"event_count": len(events),
|
||||||
|
"completed_count": len(completed),
|
||||||
|
"persisted_count": len(persisted),
|
||||||
|
"failed_count": len(failed),
|
||||||
|
"command_ids": command_ids,
|
||||||
|
"last_completed": completed[-1] if completed else None,
|
||||||
|
"last_persisted": persisted[-1] if persisted else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
|
||||||
|
parser.add_argument("--timeout", type=int, default=180)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out_json = Path(args.output_json)
|
||||||
|
canva_port = _free_port()
|
||||||
|
webui_port = _free_port()
|
||||||
|
canva_base = f"http://127.0.0.1:{canva_port}"
|
||||||
|
webui_base = f"http://127.0.0.1:{webui_port}"
|
||||||
|
canva_proc: subprocess.Popen | None = None
|
||||||
|
webui_proc: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="hermes-cmo-canvas-") as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
proof = {
|
||||||
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"webui_base": webui_base,
|
||||||
|
"canva_base": canva_base,
|
||||||
|
"profile": "cmo-planb",
|
||||||
|
"provider": "vllm",
|
||||||
|
"model": "qwen3.6-35b-a3b",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
canva_proc = _start_canva(canva_port, tmp_path / "canva-editor.db")
|
||||||
|
if not _wait_for(f"{canva_base}/health"):
|
||||||
|
raise RuntimeError(f"canva-editor did not become healthy at {canva_base}/health")
|
||||||
|
|
||||||
|
webui_proc = _start_webui(webui_port, tmp_path / "webui-state", canva_base)
|
||||||
|
if not _wait_for(f"{webui_base}/health"):
|
||||||
|
raise RuntimeError(f"webui did not become healthy at {webui_base}/health")
|
||||||
|
|
||||||
|
result = _run_cmo_turn(webui_base, args.timeout)
|
||||||
|
events = _canvas_event_summary(webui_base)
|
||||||
|
proof["cmo_turn"] = {
|
||||||
|
"returncode": result.returncode,
|
||||||
|
"stdout": result.stdout[-4000:],
|
||||||
|
"stderr": result.stderr[-2000:],
|
||||||
|
}
|
||||||
|
proof["events"] = events
|
||||||
|
proof["pass"] = bool(
|
||||||
|
result.returncode == 0
|
||||||
|
and events["completed_count"] >= 1
|
||||||
|
and events["persisted_count"] >= 1
|
||||||
|
and events["failed_count"] == 0
|
||||||
|
and events["command_ids"]
|
||||||
|
)
|
||||||
|
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_json.write_text(json.dumps(proof, indent=2), encoding="utf-8")
|
||||||
|
print(json.dumps(proof, indent=2))
|
||||||
|
return 0 if proof["pass"] else 1
|
||||||
|
finally:
|
||||||
|
webui_log = _stop(webui_proc)
|
||||||
|
canva_log = _stop(canva_proc)
|
||||||
|
if webui_log:
|
||||||
|
print(webui_log, file=sys.stderr)
|
||||||
|
if canva_log:
|
||||||
|
print(canva_log, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
254
static/canvas.css
Normal file
254
static/canvas.css
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
.svrnty-canvas-overlay {
|
||||||
|
display: none;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
background: var(--bg, #0b0d10);
|
||||||
|
color: var(--text, #f5f7fb);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.svrnty-canvas-overlay {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.main.svrnty-showing-canvas > .main-view {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.main.svrnty-showing-canvas > .svrnty-canvas-overlay {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border2, #252a31);
|
||||||
|
background: color-mix(in srgb, var(--bg, #0b0d10) 92%, #ffffff 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted, #9aa4b2);
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-prompt {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border2, #252a31);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--input-bg, #11151b);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-stepper,
|
||||||
|
.svrnty-canvas-brand {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border2, #252a31);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--input-bg, #11151b);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-btn {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border2, #252a31);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--button-bg, #171c23);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-btn.primary {
|
||||||
|
border-color: #007c57;
|
||||||
|
background: #007c57;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-btn:disabled {
|
||||||
|
opacity: .55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-stage {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 28px;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
|
||||||
|
#0f1318;
|
||||||
|
background-size: 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-artboards {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-artboard {
|
||||||
|
width: 360px;
|
||||||
|
min-height: 640px;
|
||||||
|
border: 1px solid #2b333d;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f7f8fb;
|
||||||
|
color: #12161c;
|
||||||
|
box-shadow: 0 18px 45px rgba(0,0,0,.32);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-artboard-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #d8dde5;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-badge {
|
||||||
|
color: #667085;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-artboard-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e7ef;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-mini-btn {
|
||||||
|
min-height: 26px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-mini-btn:hover {
|
||||||
|
border-color: #007c57;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-render {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node {
|
||||||
|
border: 1px solid #dde3ec;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node[contenteditable="true"] {
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node[contenteditable="true"]:focus {
|
||||||
|
outline-color: #007c57;
|
||||||
|
background: #eefcf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node.button {
|
||||||
|
display: inline-flex;
|
||||||
|
background: #007c57;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #007c57;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-node.text {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-empty {
|
||||||
|
color: #a8b3c2;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px dashed #3a4350;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 520px;
|
||||||
|
background: rgba(0,0,0,.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-rail {
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 1px solid var(--border2, #252a31);
|
||||||
|
background: color-mix(in srgb, var(--bg, #0b0d10) 94%, #ffffff 6%);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-rail h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-log {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svrnty-canvas-log-row {
|
||||||
|
border: 1px solid var(--border2, #252a31);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted, #9aa4b2);
|
||||||
|
background: rgba(255,255,255,.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.svrnty-canvas-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.svrnty-canvas-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.svrnty-canvas-rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
static/canvas.html
Normal file
39
static/canvas.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sovereign Canvas</title>
|
||||||
|
<link rel="stylesheet" href="/plugins/svrnty/canvas.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="svrnty-canvas-overlay">
|
||||||
|
<div class="svrnty-canvas-toolbar">
|
||||||
|
<div class="svrnty-canvas-title">Sovereign Canvas</div>
|
||||||
|
<div id="standaloneCanvasStatus" class="svrnty-canvas-status">Checking...</div>
|
||||||
|
</div>
|
||||||
|
<div class="svrnty-canvas-body">
|
||||||
|
<div class="svrnty-canvas-stage">
|
||||||
|
<div class="svrnty-canvas-empty">Open Hermes WebUI and select Canvas for the live command-center panel.</div>
|
||||||
|
</div>
|
||||||
|
<aside class="svrnty-canvas-rail">
|
||||||
|
<h3>Bridge status</h3>
|
||||||
|
<pre id="standaloneCanvasTools" class="svrnty-canvas-log-row">Loading...</pre>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/canvas/status").then((r) => r.json()),
|
||||||
|
fetch("/api/canvas/tools").then((r) => r.json())
|
||||||
|
]).then(([status, tools]) => {
|
||||||
|
document.getElementById("standaloneCanvasStatus").textContent =
|
||||||
|
status.ok ? "canva-editor online" : "canva-editor offline";
|
||||||
|
document.getElementById("standaloneCanvasTools").textContent =
|
||||||
|
JSON.stringify({ status: status, tools: tools }, null, 2);
|
||||||
|
}).catch((e) => {
|
||||||
|
document.getElementById("standaloneCanvasStatus").textContent = String(e);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
638
static/canvas.js
Normal file
638
static/canvas.js
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
// svrnty-canvas: Hermes-native shell for the sovereign Stitch-like design loop.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
if (window.__svrntyCanvasLoaded) return;
|
||||||
|
window.__svrntyCanvasLoaded = true;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
connected: false,
|
||||||
|
project: null,
|
||||||
|
artboards: [],
|
||||||
|
log: [],
|
||||||
|
eventSource: null,
|
||||||
|
eventCursor: 0,
|
||||||
|
eventPoll: null,
|
||||||
|
prototypeSourceId: null,
|
||||||
|
exportSummary: null,
|
||||||
|
lastEditPersisted: null,
|
||||||
|
lastPrototypeEdge: null,
|
||||||
|
designContext: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.SvrntyCanvas = {
|
||||||
|
state,
|
||||||
|
refresh: _refreshStatus,
|
||||||
|
generate: _generate,
|
||||||
|
request: _request,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _init() {
|
||||||
|
window.addEventListener("svrnty:panel-switch", (ev) => {
|
||||||
|
const name = ev && ev.detail && ev.detail.name;
|
||||||
|
if (name === "canvas") _openOverlay();
|
||||||
|
else _closeOverlay();
|
||||||
|
});
|
||||||
|
document.addEventListener("svrnty:canvas-generate", (ev) => _request((ev && ev.detail) || {}));
|
||||||
|
_registerSlashCommand();
|
||||||
|
const main = document.querySelector("main.main");
|
||||||
|
if (main && main.classList.contains("svrnty-showing-canvas")) _openOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _registerSlashCommand() {
|
||||||
|
const install = () => {
|
||||||
|
try {
|
||||||
|
if (typeof COMMANDS === "undefined" || !Array.isArray(COMMANDS)) return false;
|
||||||
|
if (COMMANDS.some((cmd) => cmd && cmd.name === "canvas")) return true;
|
||||||
|
COMMANDS.push({
|
||||||
|
name: "canvas",
|
||||||
|
desc: "Generate on the sovereign Canvas",
|
||||||
|
arg: "prompt",
|
||||||
|
fn: (args) => {
|
||||||
|
const prompt = String(args || "").trim();
|
||||||
|
if (!prompt) {
|
||||||
|
_log("Usage: /canvas <prompt>");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_request({ prompt, source: "slash-command" });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (install()) return;
|
||||||
|
let attempts = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
attempts += 1;
|
||||||
|
if (install() || attempts > 40) clearInterval(timer);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openOverlay() {
|
||||||
|
const mount = document.querySelector("main.main") || document.body;
|
||||||
|
let overlay = document.getElementById("svrntyCanvasOverlay");
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = _buildOverlay();
|
||||||
|
mount.appendChild(overlay);
|
||||||
|
} else if (overlay.parentElement !== mount && mount.tagName === "MAIN") {
|
||||||
|
mount.appendChild(overlay);
|
||||||
|
}
|
||||||
|
overlay.hidden = false;
|
||||||
|
_refreshStatus();
|
||||||
|
_connectEvents();
|
||||||
|
_startEventPolling();
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _closeOverlay() {
|
||||||
|
const overlay = document.getElementById("svrntyCanvasOverlay");
|
||||||
|
if (overlay) overlay.hidden = true;
|
||||||
|
if (state.eventSource) {
|
||||||
|
state.eventSource.close();
|
||||||
|
state.eventSource = null;
|
||||||
|
}
|
||||||
|
if (state.eventPoll) {
|
||||||
|
clearInterval(state.eventPoll);
|
||||||
|
state.eventPoll = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildOverlay() {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = "svrntyCanvasOverlay";
|
||||||
|
root.className = "svrnty-canvas-overlay";
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="svrnty-canvas-toolbar">' +
|
||||||
|
'<div class="svrnty-canvas-title">Sovereign Canvas</div>' +
|
||||||
|
'<div id="svrntyCanvasStatus" class="svrnty-canvas-status">Checking...</div>' +
|
||||||
|
'<select id="svrntyCanvasBrand" class="svrnty-canvas-brand">' +
|
||||||
|
'<option value="planb">Plan B</option><option value="svrnty">Svrnty</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'<input id="svrntyCanvasPrompt" class="svrnty-canvas-prompt" placeholder="Describe the screen CMO should create">' +
|
||||||
|
'<input id="svrntyCanvasVariants" class="svrnty-canvas-stepper" type="number" min="1" max="6" value="3" title="Variants">' +
|
||||||
|
'<button id="svrntyCanvasGenerate" class="svrnty-canvas-btn primary" type="button">Generate</button>' +
|
||||||
|
'<button id="svrntyCanvasRefresh" class="svrnty-canvas-btn" type="button">Refresh</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="svrnty-canvas-body">' +
|
||||||
|
'<div class="svrnty-canvas-stage"><div id="svrntyCanvasArtboards" class="svrnty-canvas-artboards"></div></div>' +
|
||||||
|
'<aside class="svrnty-canvas-rail"><h3>Live loop</h3><div id="svrntyCanvasTools"></div><div id="svrntyCanvasLog" class="svrnty-canvas-log"></div></aside>' +
|
||||||
|
'</div>';
|
||||||
|
root.querySelector("#svrntyCanvasGenerate").addEventListener("click", _generate);
|
||||||
|
root.querySelector("#svrntyCanvasRefresh").addEventListener("click", _refreshStatus);
|
||||||
|
root.querySelector("#svrntyCanvasPrompt").addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") _generate();
|
||||||
|
});
|
||||||
|
root.querySelector("#svrntyCanvasArtboards").addEventListener("click", _handleArtboardClick);
|
||||||
|
root.querySelector("#svrntyCanvasArtboards").addEventListener("input", _handleArtboardInput);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _refreshStatus() {
|
||||||
|
const brand = document.getElementById("svrntyCanvasBrand");
|
||||||
|
const brandId = brand ? brand.value : "planb";
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/canvas/status").then((r) => r.json()),
|
||||||
|
fetch("/api/canvas/tools").then((r) => r.json()),
|
||||||
|
fetch("/api/canvas/design-context?brand_id=" + encodeURIComponent(brandId)).then((r) => r.json()),
|
||||||
|
]).then(([status, tools, context]) => {
|
||||||
|
state.connected = !!(status && status.ok);
|
||||||
|
state.designContext = context || null;
|
||||||
|
_setText("svrntyCanvasStatus", state.connected ? "canva-editor online" : "canva-editor offline");
|
||||||
|
_renderTools(tools.tools || []);
|
||||||
|
if (status && status.capabilities) _log("Capabilities: " + _capabilitySummary(status.capabilities));
|
||||||
|
if (context && context.status === "ready") {
|
||||||
|
_log("BTE context ready: " + (context.source_version || context.fetched_at || context.resolved_brand_id));
|
||||||
|
} else if (context && context.deferred_ports) {
|
||||||
|
_log("Context degraded: " + context.deferred_ports.join(", "));
|
||||||
|
}
|
||||||
|
_log(state.connected ? "Canvas bridge online" : "Canvas bridge waiting for canva-editor");
|
||||||
|
if (state.connected) _ensureProject().catch((e) => _log("Project load failed: " + _message(e)));
|
||||||
|
}).catch((e) => {
|
||||||
|
state.connected = false;
|
||||||
|
_setText("svrntyCanvasStatus", "canvas bridge error");
|
||||||
|
_log("Status failed: " + _message(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _connectEvents() {
|
||||||
|
if (!window.EventSource || state.eventSource) return;
|
||||||
|
try {
|
||||||
|
state.eventSource = new EventSource("/api/canvas/events");
|
||||||
|
state.eventSource.addEventListener("canvas.connected", () => _log("Live event stream connected"));
|
||||||
|
state.eventSource.onerror = () => {
|
||||||
|
if (state.eventSource) {
|
||||||
|
state.eventSource.close();
|
||||||
|
state.eventSource = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startEventPolling() {
|
||||||
|
if (state.eventPoll) return;
|
||||||
|
_pollEvents();
|
||||||
|
state.eventPoll = setInterval(_pollEvents, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pollEvents() {
|
||||||
|
fetch("/api/canvas/events?format=json&since=" + encodeURIComponent(state.eventCursor))
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const events = body && Array.isArray(body.events) ? body.events : [];
|
||||||
|
events.forEach(_applyEvent);
|
||||||
|
if (body && body.cursor != null) state.eventCursor = Math.max(state.eventCursor, body.cursor);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyEvent(ev) {
|
||||||
|
if (!ev || !ev.type) return;
|
||||||
|
if (ev.id != null) state.eventCursor = Math.max(state.eventCursor, ev.id);
|
||||||
|
if (ev.type === "canvas.command.accepted") _log("CMO command accepted: " + (ev.prompt || "Canvas command"));
|
||||||
|
else if (ev.type === "canvas.project.ready") _log("Project ready: " + ((ev.project && ev.project.name) || "Hermes Canvas"));
|
||||||
|
else if (ev.type === "canvas.variant.started") _log("Generating variant " + ev.variant_index + "/" + ev.variants);
|
||||||
|
else if (ev.type === "canvas.screen.persisted") {
|
||||||
|
_ingestScreenEvent(ev);
|
||||||
|
_log("Artboard persisted from command");
|
||||||
|
} else if (ev.type === "canvas.command.completed") _log("Canvas command completed");
|
||||||
|
else if (ev.type === "canvas.command.failed") _log("Canvas command failed: " + (ev.error || "unknown error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _generate() {
|
||||||
|
const input = document.getElementById("svrntyCanvasPrompt");
|
||||||
|
const prompt = input ? input.value.trim() : "";
|
||||||
|
if (!prompt) {
|
||||||
|
_log("Prompt required before generation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById("svrntyCanvasGenerate");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
_log("CMO request queued: " + prompt);
|
||||||
|
const variantsInput = document.getElementById("svrntyCanvasVariants");
|
||||||
|
const variants = Math.max(1, Math.min(6, parseInt((variantsInput && variantsInput.value) || "1", 10)));
|
||||||
|
const brand = document.getElementById("svrntyCanvasBrand");
|
||||||
|
const brandId = brand ? brand.value : "planb";
|
||||||
|
_runCommand(prompt, variants, brandId).finally(() => {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _request(payload) {
|
||||||
|
const prompt = String((payload && payload.prompt) || "").trim();
|
||||||
|
if (!prompt) {
|
||||||
|
_log("Canvas request ignored: prompt required");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof window.switchPanel === "function") {
|
||||||
|
window.switchPanel("canvas", { fromRailClick: true });
|
||||||
|
} else {
|
||||||
|
_openOverlay();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_openOverlay();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById("svrntyCanvasPrompt");
|
||||||
|
const variantsInput = document.getElementById("svrntyCanvasVariants");
|
||||||
|
const brand = document.getElementById("svrntyCanvasBrand");
|
||||||
|
if (input) input.value = prompt;
|
||||||
|
if (variantsInput && payload.variants) variantsInput.value = String(payload.variants);
|
||||||
|
if (brand && (payload.brand_id || payload.brandId)) brand.value = payload.brand_id || payload.brandId;
|
||||||
|
_log("Canvas request from " + (payload.source || "Hermes") + ": " + prompt);
|
||||||
|
_generate();
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _runCommand(prompt, variants, brandId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/canvas/command", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt, variants, brand_id: brandId }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (!r.ok) throw new Error(body && body.error ? body.error : "HTTP " + r.status);
|
||||||
|
if (body.project) state.project = body.project;
|
||||||
|
if (body.design_context) state.designContext = body.design_context;
|
||||||
|
(body.events || []).forEach(_applyEvent);
|
||||||
|
(body.screens || []).forEach((item) => _ingestScreenEvent({
|
||||||
|
screen: item.screen,
|
||||||
|
screenSpec: item.screenSpec,
|
||||||
|
prompt: item.prompt || prompt,
|
||||||
|
variant_index: item.variant_index,
|
||||||
|
}));
|
||||||
|
_render();
|
||||||
|
} catch (e) {
|
||||||
|
_log("Command failed: " + _message(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _generateVariants(prompt, variants) {
|
||||||
|
for (let i = 0; i < variants; i++) {
|
||||||
|
const variantPrompt = variants === 1 ? prompt : prompt + "\nVariant " + (i + 1) + " of " + variants + ": choose a distinct composition.";
|
||||||
|
try {
|
||||||
|
_log("Generating variant " + (i + 1) + "/" + variants);
|
||||||
|
const r = await fetch("/api/canvas/proxy?path=/api/v1/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt: variantPrompt }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (!r.ok) throw new Error(body && body.error ? JSON.stringify(body.error) : "HTTP " + r.status);
|
||||||
|
const spec = body.screenSpec || body.screen_spec || body;
|
||||||
|
const saved = await _saveScreen(prompt, spec).catch((e) => {
|
||||||
|
_log("Generated but not persisted: " + _message(e));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
state.artboards.unshift(_artboardFromSpec(
|
||||||
|
saved,
|
||||||
|
spec,
|
||||||
|
prompt.slice(0, 42) || "Generated screen",
|
||||||
|
variants === 1 ? "generated" : "variant " + (i + 1),
|
||||||
|
));
|
||||||
|
_log("Artboard generated");
|
||||||
|
_render();
|
||||||
|
} catch (e) {
|
||||||
|
_log("Generation failed: " + _message(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _ensureProject() {
|
||||||
|
if (state.project) return state.project;
|
||||||
|
const projects = await _canvasFetch("/api/v1/projects", { method: "GET" });
|
||||||
|
state.project = Array.isArray(projects) && projects.length
|
||||||
|
? projects[0]
|
||||||
|
: await _canvasFetch("/api/v1/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: { name: "Hermes Canvas" },
|
||||||
|
});
|
||||||
|
_log("Project loaded: " + (state.project.name || state.project.id || "Hermes Canvas"));
|
||||||
|
await _loadScreens();
|
||||||
|
return state.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadScreens() {
|
||||||
|
if (!state.project) return;
|
||||||
|
const projectId = state.project.id || state.project.ID || state.project.uuid;
|
||||||
|
if (!projectId) return;
|
||||||
|
const screens = await _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/screens", { method: "GET" });
|
||||||
|
state.artboards = (Array.isArray(screens) ? screens : []).map((screen, idx) =>
|
||||||
|
_artboardFromSpec(
|
||||||
|
screen,
|
||||||
|
screen.spec || screen.Spec || screen.screenSpec || {},
|
||||||
|
screen.name || screen.Name || "Screen " + (idx + 1),
|
||||||
|
"saved",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveScreen(prompt, spec) {
|
||||||
|
await _ensureProject();
|
||||||
|
const projectId = state.project.id || state.project.ID || state.project.uuid;
|
||||||
|
if (!projectId) throw new Error("project id missing");
|
||||||
|
return _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/screens", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: prompt.slice(0, 42) || "Generated screen",
|
||||||
|
spec,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _canvasFetch(path, options) {
|
||||||
|
const upstreamMethod = (options && options.method) || "GET";
|
||||||
|
const bridgeMethod = upstreamMethod === "PUT" ? "POST" : upstreamMethod;
|
||||||
|
const opts = {
|
||||||
|
method: bridgeMethod,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
if (options && options.body !== undefined) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
const methodQuery = upstreamMethod === "PUT" ? "&method=PUT" : "";
|
||||||
|
const r = await fetch("/api/canvas/proxy?path=" + encodeURIComponent(path) + methodQuery, opts);
|
||||||
|
const text = await r.text();
|
||||||
|
const body = text ? JSON.parse(text) : {};
|
||||||
|
if (!r.ok) throw new Error(body && body.error ? JSON.stringify(body.error) : "HTTP " + r.status);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _artboardFromSpec(record, spec, title, badge) {
|
||||||
|
return {
|
||||||
|
id: record && (record.id || record.ID || record.uuid),
|
||||||
|
title,
|
||||||
|
badge: record && (record.id || record.ID || record.uuid) ? badge + " · persisted" : badge,
|
||||||
|
spec,
|
||||||
|
dirty: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ingestScreenEvent(ev) {
|
||||||
|
const screen = ev.screen || {};
|
||||||
|
const spec = ev.screenSpec || screen.spec || screen.Spec || {};
|
||||||
|
const id = screen.id || screen.ID || screen.uuid;
|
||||||
|
if (id && state.artboards.some((board) => board.id === id)) return;
|
||||||
|
state.artboards.unshift(_artboardFromSpec(
|
||||||
|
screen,
|
||||||
|
spec,
|
||||||
|
screen.name || screen.Name || (ev.prompt || "Generated screen").slice(0, 42),
|
||||||
|
ev.variant_index ? "command variant " + ev.variant_index : "command",
|
||||||
|
));
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _render() {
|
||||||
|
const target = document.getElementById("svrntyCanvasArtboards");
|
||||||
|
if (!target) return;
|
||||||
|
if (!state.artboards.length) {
|
||||||
|
target.innerHTML = '<div class="svrnty-canvas-empty">Prompt CMO to generate a screen. Artboards, variants, edits, prototype links, and exports will accumulate here.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.innerHTML = state.artboards.map((board, idx) =>
|
||||||
|
'<article class="svrnty-canvas-artboard">' +
|
||||||
|
'<div class="svrnty-canvas-artboard-head"><strong>' + _esc(board.title) + '</strong><span class="svrnty-canvas-badge">' + _esc(board.dirty ? "edited" : board.badge) + '</span></div>' +
|
||||||
|
'<div class="svrnty-canvas-artboard-actions">' +
|
||||||
|
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="save" data-board-index="' + idx + '">Save edits</button>' +
|
||||||
|
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="prototype-source" data-board-index="' + idx + '">Start</button>' +
|
||||||
|
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="prototype-link" data-board-index="' + idx + '">Link</button>' +
|
||||||
|
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="export" data-board-index="' + idx + '">Export</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="svrnty-canvas-render">' + _renderSpec(board.spec) + '</div>' +
|
||||||
|
'</article>'
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSpec(spec) {
|
||||||
|
if (!spec) return '<div class="svrnty-canvas-node">Empty spec</div>';
|
||||||
|
const root = _rootRef(spec);
|
||||||
|
return _renderNode(root.node, 0, root.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderNode(node, depth, path) {
|
||||||
|
if (!node || typeof node !== "object" || depth > 12) return "";
|
||||||
|
const type = String(node.type || node.component || "container").toLowerCase();
|
||||||
|
const field = _editableField(node);
|
||||||
|
const text = field ? _editableValue(node, field) : (node.text || node.label || node.title || node.name || (node.props && (node.props.data || node.props.label || node.props.title)) || type);
|
||||||
|
const children = node.children || node.items || [];
|
||||||
|
const childKey = Array.isArray(node.children) ? "children" : Array.isArray(node.items) ? "items" : "";
|
||||||
|
const childHtml = Array.isArray(children)
|
||||||
|
? children.map((child, idx) => _renderNode(child, depth + 1, path.concat([childKey, String(idx)]))).join("")
|
||||||
|
: "";
|
||||||
|
const editAttrs = field
|
||||||
|
? ' contenteditable="true" spellcheck="false" data-canvas-path="' + _esc(path.join(".")) + '" data-canvas-field="' + _esc(field) + '"'
|
||||||
|
: "";
|
||||||
|
return '<div class="svrnty-canvas-node ' + _esc(type) + '"' + editAttrs + '>' + _esc(text) + childHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleArtboardInput(ev) {
|
||||||
|
const el = ev.target && ev.target.closest && ev.target.closest("[data-canvas-path]");
|
||||||
|
if (!el) return;
|
||||||
|
const boardEl = el.closest(".svrnty-canvas-artboard");
|
||||||
|
const boardIndex = Array.prototype.indexOf.call(boardEl.parentElement.children, boardEl);
|
||||||
|
const board = state.artboards[boardIndex];
|
||||||
|
if (!board) return;
|
||||||
|
const field = el.getAttribute("data-canvas-field");
|
||||||
|
const node = _nodeAtPath(board.spec, (el.getAttribute("data-canvas-path") || "").split(".").filter(Boolean));
|
||||||
|
if (!node || !field) return;
|
||||||
|
_setEditableValue(node, field, el.childNodes.length ? Array.prototype.filter.call(el.childNodes, (n) => n.nodeType === Node.TEXT_NODE).map((n) => n.nodeValue).join("").trim() || el.textContent.trim() : el.textContent.trim());
|
||||||
|
board.dirty = true;
|
||||||
|
const badge = boardEl.querySelector(".svrnty-canvas-badge");
|
||||||
|
if (badge) badge.textContent = "edited";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleArtboardClick(ev) {
|
||||||
|
const btn = ev.target && ev.target.closest && ev.target.closest("[data-canvas-action]");
|
||||||
|
if (!btn) return;
|
||||||
|
const board = state.artboards[parseInt(btn.getAttribute("data-board-index") || "-1", 10)];
|
||||||
|
if (!board) return;
|
||||||
|
const action = btn.getAttribute("data-canvas-action");
|
||||||
|
if (action === "save") _persistBoard(board);
|
||||||
|
else if (action === "prototype-source") _setPrototypeSource(board);
|
||||||
|
else if (action === "prototype-link") _linkPrototype(board);
|
||||||
|
else if (action === "export") _exportProject();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistBoard(board) {
|
||||||
|
_log("Saving simple edit: " + (board.title || board.id || "artboard"));
|
||||||
|
if (!board.id) {
|
||||||
|
_log("Save skipped: artboard has no screen id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _ensureProject();
|
||||||
|
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
|
||||||
|
if (!projectId) {
|
||||||
|
_log("Save failed: project id missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = await _canvasFetch(
|
||||||
|
"/api/v1/projects/" + encodeURIComponent(projectId) + "/screens/" + encodeURIComponent(board.id),
|
||||||
|
{ method: "PUT", body: { spec: board.spec } },
|
||||||
|
);
|
||||||
|
board.spec = saved.spec || saved.Spec || board.spec;
|
||||||
|
board.dirty = false;
|
||||||
|
board.badge = "edited · persisted";
|
||||||
|
state.lastEditPersisted = { screenId: board.id, at: Date.now() };
|
||||||
|
_log("Simple edit persisted: " + (board.title || board.id));
|
||||||
|
_render();
|
||||||
|
} catch (e) {
|
||||||
|
_log("Save failed: " + _message(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setPrototypeSource(board) {
|
||||||
|
if (!board.id) {
|
||||||
|
_log("Prototype start needs a persisted screen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.prototypeSourceId = board.id;
|
||||||
|
_log("Prototype start set: " + (board.title || board.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _linkPrototype(board) {
|
||||||
|
if (!state.prototypeSourceId) {
|
||||||
|
_setPrototypeSource(board);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!board.id || board.id === state.prototypeSourceId) {
|
||||||
|
_log("Choose a different persisted screen to link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _ensureProject();
|
||||||
|
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
|
||||||
|
if (!projectId) {
|
||||||
|
_log("Prototype link failed: project id missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const edge = await _canvasFetch(
|
||||||
|
"/api/v1/projects/" + encodeURIComponent(projectId) + "/prototype-edges",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
sourceScreenId: state.prototypeSourceId,
|
||||||
|
targetScreenId: board.id,
|
||||||
|
action: "navigate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.lastPrototypeEdge = edge;
|
||||||
|
_log("Prototype link saved: " + (edge.sourceScreenId || state.prototypeSourceId) + " -> " + (edge.targetScreenId || board.id));
|
||||||
|
state.prototypeSourceId = board.id;
|
||||||
|
} catch (e) {
|
||||||
|
_log("Prototype link failed: " + _message(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _exportProject() {
|
||||||
|
await _ensureProject();
|
||||||
|
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
|
||||||
|
if (!projectId) {
|
||||||
|
_log("Export failed: project id missing");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const exported = await _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/export", { method: "GET" });
|
||||||
|
state.exportSummary = exported;
|
||||||
|
const screens = (exported.screens || []).length;
|
||||||
|
const variants = (exported.variants || []).length;
|
||||||
|
const edges = (exported.prototypeEdges || []).length;
|
||||||
|
_log("Export ready: " + screens + " screens, " + variants + " variants, " + edges + " prototype links");
|
||||||
|
return exported;
|
||||||
|
} catch (e) {
|
||||||
|
_log("Export failed: " + _message(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rootRef(spec) {
|
||||||
|
if (spec && spec.screen && spec.screen.root) return { node: spec.screen.root, path: ["screen", "root"] };
|
||||||
|
if (spec && spec.root) return { node: spec.root, path: ["root"] };
|
||||||
|
if (spec && spec.widget) return { node: spec.widget, path: ["widget"] };
|
||||||
|
return { node: spec, path: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _nodeAtPath(root, path) {
|
||||||
|
return path.reduce((node, part) => node && node[part], root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _editableField(node) {
|
||||||
|
if (!node || typeof node !== "object") return "";
|
||||||
|
if (Array.isArray(node.children) || Array.isArray(node.items)) return "";
|
||||||
|
const direct = ["text", "label", "title", "name"].find((field) => typeof node[field] === "string");
|
||||||
|
if (direct) return direct;
|
||||||
|
if (node.props && typeof node.props === "object") {
|
||||||
|
const prop = ["data", "label", "title", "subtitle", "description"].find((field) => typeof node.props[field] === "string");
|
||||||
|
if (prop) return "props." + prop;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _editableValue(node, field) {
|
||||||
|
if (field.indexOf(".") === -1) return node[field];
|
||||||
|
return field.split(".").reduce((cur, key) => cur && cur[key], node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setEditableValue(node, field, value) {
|
||||||
|
const parts = field.split(".");
|
||||||
|
const key = parts.pop();
|
||||||
|
const target = parts.reduce((cur, part) => cur && cur[part], node);
|
||||||
|
if (target && key) target[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderTools(tools) {
|
||||||
|
const target = document.getElementById("svrntyCanvasTools");
|
||||||
|
if (!target) return;
|
||||||
|
target.innerHTML = (tools || []).slice(0, 6).map((tool) =>
|
||||||
|
'<div class="svrnty-canvas-log-row"><strong>' + _esc(tool.name) + '</strong><br>' + _esc(tool.purpose || "") + '</div>'
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _log(message) {
|
||||||
|
state.log.unshift({ message, at: new Date().toLocaleTimeString() });
|
||||||
|
state.log = state.log.slice(0, 8);
|
||||||
|
const target = document.getElementById("svrntyCanvasLog");
|
||||||
|
if (!target) return;
|
||||||
|
target.innerHTML = state.log.map((row) =>
|
||||||
|
'<div class="svrnty-canvas-log-row"><strong>' + _esc(row.at) + '</strong><br>' + _esc(row.message) + '</div>'
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setText(id, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _message(e) {
|
||||||
|
return e && e.message ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s == null ? "" : s).replace(/[<>&"]/g, (c) => ({
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
'"': """,
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _capabilitySummary(caps) {
|
||||||
|
const advanced = ["variants", "promptEdit", "prototypeGraph", "liveEvents"]
|
||||||
|
.filter((key) => caps[key])
|
||||||
|
.join(", ");
|
||||||
|
return advanced || "base Stitch loop";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", _init);
|
||||||
|
} else {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -22,10 +22,13 @@
|
|||||||
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
|
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
|
||||||
bte:
|
bte:
|
||||||
'<path d="M12 2l1.8 5.6L19.4 9.4l-4.5 3.3 1.7 5.7L12 15l-4.6 3.4 1.7-5.7L4.6 9.4l5.6-1.8L12 2z"/>',
|
'<path d="M12 2l1.8 5.6L19.4 9.4l-4.5 3.3 1.7 5.7L12 15l-4.6 3.4 1.7-5.7L4.6 9.4l5.6-1.8L12 2z"/>',
|
||||||
|
canvas:
|
||||||
|
'<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 3v18M16 3v18M3 8h18M3 16h18"/>',
|
||||||
};
|
};
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "adwright", label: "Adwright", tooltip: "Adwright — marketing intelligence" },
|
{ id: "adwright", label: "Adwright", tooltip: "Adwright — marketing intelligence" },
|
||||||
{ id: "bte", label: "BTE", tooltip: "BTE — brand creative studio" },
|
{ id: "bte", label: "BTE", tooltip: "BTE — brand creative studio" },
|
||||||
|
{ id: "canvas", label: "Canvas", tooltip: "Canvas — sovereign UI design" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function _svg(iconPath, size, stroke) {
|
function _svg(iconPath, size, stroke) {
|
||||||
|
|||||||
@ -5,8 +5,10 @@ contract still holds after upstream changes. Minimal by design (per protocol
|
|||||||
decision Q3): catch gross breakage, evolve as issues surface.
|
decision Q3): catch gross breakage, evolve as issues surface.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
def test_eval_loader_contract_unchanged():
|
def test_eval_loader_contract_unchanged():
|
||||||
@ -78,6 +80,43 @@ def test_eval_brand_skin_url_contract():
|
|||||||
plugin.register(api)
|
plugin.register(api)
|
||||||
api.inject_stylesheet.assert_any_call("/plugins/svrnty/app.css")
|
api.inject_stylesheet.assert_any_call("/plugins/svrnty/app.css")
|
||||||
api.inject_script.assert_any_call("/plugins/svrnty/app.js")
|
api.inject_script.assert_any_call("/plugins/svrnty/app.js")
|
||||||
|
api.inject_stylesheet.assert_any_call("/plugins/svrnty/canvas.css")
|
||||||
|
api.inject_script.assert_any_call("/plugins/svrnty/canvas.js")
|
||||||
|
|
||||||
|
|
||||||
|
def test_eval_canvas_tool_surface_contract():
|
||||||
|
"""Canvas bridge exposes the first CMO-facing Stitch-like tool contract."""
|
||||||
|
import json
|
||||||
|
from routes import canvas
|
||||||
|
|
||||||
|
class _H:
|
||||||
|
def __init__(self):
|
||||||
|
self.body = b""
|
||||||
|
self.headers = {}
|
||||||
|
|
||||||
|
def send_response(self, c): self.status = c
|
||||||
|
def send_header(self, k, v): self.headers[k] = v
|
||||||
|
def end_headers(self): pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wfile(self):
|
||||||
|
h = self
|
||||||
|
class _W:
|
||||||
|
def write(self_, b): h.body += b
|
||||||
|
return _W()
|
||||||
|
|
||||||
|
h = _H()
|
||||||
|
canvas._handle_tools(h, None)
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
names = {tool["name"] for tool in payload["tools"]}
|
||||||
|
assert "canvas.create_project" in names
|
||||||
|
assert "canvas.command" in names
|
||||||
|
assert "canvas.generate_screen" in names
|
||||||
|
assert "canvas.design_context" in names
|
||||||
|
assert "canvas.update_screen" in names
|
||||||
|
assert "canvas.record_variant" in names
|
||||||
|
assert "canvas.connect_prototype_edge" in names
|
||||||
|
assert "canvas.export_project" in names
|
||||||
|
|
||||||
|
|
||||||
def test_eval_connection_map_has_no_forced_internals():
|
def test_eval_connection_map_has_no_forced_internals():
|
||||||
|
|||||||
@ -60,6 +60,14 @@ def test_loader_register_wires_our_plugin(loader, monkeypatch):
|
|||||||
# Core routes registered, including the /umbrella graph API pair.
|
# Core routes registered, including the /umbrella graph API pair.
|
||||||
assert ("POST", "/api/transcribe") in loader._ROUTES
|
assert ("POST", "/api/transcribe") in loader._ROUTES
|
||||||
assert ("GET", "/api/vault/status") in loader._ROUTES
|
assert ("GET", "/api/vault/status") in loader._ROUTES
|
||||||
|
assert ("GET", "/api/canvas/status") in loader._ROUTES
|
||||||
|
assert ("GET", "/api/canvas/tools") in loader._ROUTES
|
||||||
|
assert ("GET", "/api/canvas/proxy") in loader._ROUTES
|
||||||
|
assert ("POST", "/api/canvas/proxy") in loader._ROUTES
|
||||||
|
assert ("PUT", "/api/canvas/proxy") in loader._ROUTES
|
||||||
|
assert ("POST", "/api/canvas/command") in loader._ROUTES
|
||||||
|
assert ("GET", "/api/canvas/design-context") in loader._ROUTES
|
||||||
|
assert ("GET", "/api/canvas/events") in loader._ROUTES
|
||||||
assert ("GET", "/api/umbrella") in loader._ROUTES
|
assert ("GET", "/api/umbrella") in loader._ROUTES
|
||||||
assert ("GET", "/api/umbrella/doc") in loader._ROUTES
|
assert ("GET", "/api/umbrella/doc") in loader._ROUTES
|
||||||
assert ("GET", "/api/cortex-os/runtime-health") in loader._ROUTES
|
assert ("GET", "/api/cortex-os/runtime-health") in loader._ROUTES
|
||||||
@ -67,6 +75,8 @@ def test_loader_register_wires_our_plugin(loader, monkeypatch):
|
|||||||
assert "svrnty" in loader._STATIC
|
assert "svrnty" in loader._STATIC
|
||||||
assert "/plugins/svrnty/app.css" in loader._STYLESHEETS
|
assert "/plugins/svrnty/app.css" in loader._STYLESHEETS
|
||||||
assert "/plugins/svrnty/app.js" in loader._SCRIPTS
|
assert "/plugins/svrnty/app.js" in loader._SCRIPTS
|
||||||
|
assert "/plugins/svrnty/canvas.css" in loader._STYLESHEETS
|
||||||
|
assert "/plugins/svrnty/canvas.js" in loader._SCRIPTS
|
||||||
assert "/plugins/svrnty/umbrella_inline.css" in loader._STYLESHEETS
|
assert "/plugins/svrnty/umbrella_inline.css" in loader._STYLESHEETS
|
||||||
assert "/plugins/svrnty/umbrella_inline.js" in loader._SCRIPTS
|
assert "/plugins/svrnty/umbrella_inline.js" in loader._SCRIPTS
|
||||||
assert "/plugins/svrnty/cortex-os/runtime-health/runtime_health.css" in loader._STYLESHEETS
|
assert "/plugins/svrnty/cortex-os/runtime-health/runtime_health.css" in loader._STYLESHEETS
|
||||||
|
|||||||
327
tests/unit/test_canvas.py
Normal file
327
tests/unit/test_canvas.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
"""Unit tests for the Canvas bridge route."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from routes import canvas
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler:
|
||||||
|
command = "GET"
|
||||||
|
|
||||||
|
def __init__(self, body=b""):
|
||||||
|
self.status = None
|
||||||
|
self.headers_out = {}
|
||||||
|
self.body = b""
|
||||||
|
self.headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": str(len(body)),
|
||||||
|
}
|
||||||
|
self.rfile = _Reader(body)
|
||||||
|
|
||||||
|
def send_response(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def send_header(self, key, value):
|
||||||
|
self.headers_out[key] = value
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wfile(self):
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _W:
|
||||||
|
def write(self, body):
|
||||||
|
outer.body += body
|
||||||
|
|
||||||
|
return _W()
|
||||||
|
|
||||||
|
|
||||||
|
class _Reader:
|
||||||
|
def __init__(self, body):
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def read(self, _length):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed(query):
|
||||||
|
return SimpleNamespace(query=query)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_wires_canvas_routes():
|
||||||
|
api = MagicMock()
|
||||||
|
canvas.register(api)
|
||||||
|
calls = {(c.args[1], c.args[0]) for c in api.register_route.call_args_list}
|
||||||
|
assert ("GET", "/api/canvas/status") in calls
|
||||||
|
assert ("GET", "/api/canvas/proxy") in calls
|
||||||
|
assert ("POST", "/api/canvas/proxy") in calls
|
||||||
|
assert ("PUT", "/api/canvas/proxy") in calls
|
||||||
|
assert ("POST", "/api/canvas/command") in calls
|
||||||
|
assert ("GET", "/api/canvas/tools") in calls
|
||||||
|
assert ("GET", "/api/canvas/design-context") in calls
|
||||||
|
assert ("GET", "/api/canvas/events") in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_blocks_unlisted_path():
|
||||||
|
h = _Handler()
|
||||||
|
assert canvas._handle_proxy(h, _parsed("path=/api/v1/admin")) is True
|
||||||
|
assert h.status == 403
|
||||||
|
assert json.loads(h.body)["error"] == "path not allowed: /api/v1/admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_forwards_allowed_projects_path():
|
||||||
|
h = _Handler()
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value.status = 200
|
||||||
|
response.__enter__.return_value.headers = {"Content-Type": "application/json"}
|
||||||
|
response.__enter__.return_value.read.return_value = b"[]"
|
||||||
|
with patch("routes.canvas.urllib.request.urlopen", return_value=response) as urlopen:
|
||||||
|
assert canvas._handle_proxy(h, _parsed("path=/api/v1/projects")) is True
|
||||||
|
assert h.status == 200
|
||||||
|
assert h.body == b"[]"
|
||||||
|
assert urlopen.call_args.args[0].full_url.endswith("/api/v1/projects")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_forwards_capabilities_path():
|
||||||
|
h = _Handler()
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value.status = 200
|
||||||
|
response.__enter__.return_value.headers = {"Content-Type": "application/json"}
|
||||||
|
response.__enter__.return_value.read.return_value = b'{"projects":true}'
|
||||||
|
with patch("routes.canvas.urllib.request.urlopen", return_value=response) as urlopen:
|
||||||
|
assert canvas._handle_proxy(h, _parsed("path=/api/v1/capabilities")) is True
|
||||||
|
assert h.status == 200
|
||||||
|
assert h.body == b'{"projects":true}'
|
||||||
|
assert urlopen.call_args.args[0].full_url.endswith("/api/v1/capabilities")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_post_method_override_forwards_put_without_upstream_put_route():
|
||||||
|
h = _Handler(b'{"spec":{"schemaVersion":1}}')
|
||||||
|
h.command = "POST"
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value.status = 200
|
||||||
|
response.__enter__.return_value.headers = {"Content-Type": "application/json"}
|
||||||
|
response.__enter__.return_value.read.return_value = b'{"id":"screen-1"}'
|
||||||
|
with patch("routes.canvas.urllib.request.urlopen", return_value=response) as urlopen:
|
||||||
|
assert canvas._handle_proxy(
|
||||||
|
h,
|
||||||
|
_parsed("path=/api/v1/projects/project-1/screens/screen-1&method=PUT"),
|
||||||
|
) is True
|
||||||
|
assert h.status == 200
|
||||||
|
req = urlopen.call_args.args[0]
|
||||||
|
assert req.full_url.endswith("/api/v1/projects/project-1/screens/screen-1")
|
||||||
|
assert req.get_method() == "PUT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_allows_variant_and_prototype_contract_paths():
|
||||||
|
assert canvas._is_allowed("/api/v1/projects/project-1/variants")
|
||||||
|
assert canvas._is_allowed("/api/v1/projects/project-1/prototype-edges")
|
||||||
|
assert canvas._is_allowed("/api/v1/projects/project-1/export")
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_includes_canva_editor_capabilities():
|
||||||
|
h = _Handler()
|
||||||
|
health = MagicMock()
|
||||||
|
health.__enter__.return_value.status = 200
|
||||||
|
health.__enter__.return_value.headers = {"Content-Type": "application/json"}
|
||||||
|
health.__enter__.return_value.read.return_value = b'{"status":"ok"}'
|
||||||
|
caps = MagicMock()
|
||||||
|
caps.__enter__.return_value.status = 200
|
||||||
|
caps.__enter__.return_value.headers = {"Content-Type": "application/json"}
|
||||||
|
caps.__enter__.return_value.read.return_value = b'{"projects":true,"variants":false}'
|
||||||
|
|
||||||
|
with patch("routes.canvas.urllib.request.urlopen", side_effect=[health, caps]):
|
||||||
|
assert canvas._handle_status(h, None) is True
|
||||||
|
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
assert h.status == 200
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["capabilities"] == {"projects": True, "variants": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_context_returns_seed_contract():
|
||||||
|
h = _Handler()
|
||||||
|
with patch("routes.canvas._load_secondbrain_hints", return_value={"status": "unconfigured", "hints": []}):
|
||||||
|
assert canvas._handle_design_context(h, _parsed("brand_id=planb")) is True
|
||||||
|
assert h.status == 200
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
assert payload["brand_id"] == "planb"
|
||||||
|
assert payload["resolved_brand_id"] == "607740e8-8b76-4455-9c4f-eadbe9b4168c"
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_context_reads_cmo_brand_profile_cache(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "cmo.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE brand_profile_cache (
|
||||||
|
brand_id TEXT PRIMARY KEY,
|
||||||
|
json TEXT,
|
||||||
|
version TEXT,
|
||||||
|
fetched_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO brand_profile_cache (brand_id, json, version, fetched_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"607740e8-8b76-4455-9c4f-eadbe9b4168c",
|
||||||
|
json.dumps({
|
||||||
|
"brand": {"name": "planb", "displayName": "Plan B Brand", "description": "Dark mode brand."},
|
||||||
|
"brand_guideline": "Use confident fr-CA food copy. Never imply industrial food.",
|
||||||
|
"design_md": "# DESIGN\nUse Montserrat.",
|
||||||
|
"palette": [
|
||||||
|
{"path": "color.primary", "type": "color", "valueJson": "{\"dark\":\"#FD5000\"}"},
|
||||||
|
],
|
||||||
|
"typography": [{"path": "font.family.body", "valueJson": "\"Montserrat\""}],
|
||||||
|
"spacing": [{"path": "spacing.4", "valueJson": "4"}],
|
||||||
|
"voice": {"dos": ["Be specific"], "donts": ["No scarcity"]},
|
||||||
|
"tokens_imported": True,
|
||||||
|
}),
|
||||||
|
"v1",
|
||||||
|
"2026-05-27T12:00:00Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
monkeypatch.setenv("CANVAS_CMO_DB", str(db_path))
|
||||||
|
|
||||||
|
h = _Handler()
|
||||||
|
with patch("routes.canvas._load_secondbrain_hints", return_value={
|
||||||
|
"status": "ready",
|
||||||
|
"hints": ["Secondbrain capsule 42: Use calm operator UI patterns."],
|
||||||
|
}):
|
||||||
|
assert canvas._handle_design_context(h, _parsed("brand_id=planb")) is True
|
||||||
|
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
assert h.status == 200
|
||||||
|
assert payload["status"] == "ready"
|
||||||
|
assert payload["source"] == "cmo.brand_profile_cache"
|
||||||
|
assert payload["cache_brand_id"] == "607740e8-8b76-4455-9c4f-eadbe9b4168c"
|
||||||
|
assert payload["source_version"] == "v1"
|
||||||
|
assert payload["brand"]["displayName"] == "Plan B Brand"
|
||||||
|
assert payload["brand_guideline_text"].startswith("Use confident")
|
||||||
|
assert payload["palette_tokens"][0]["value"]["dark"] == "#FD5000"
|
||||||
|
assert "Be specific" in payload["voice_rules"]
|
||||||
|
assert any("BTE brand guideline" in hint for hint in payload["memory_hints"])
|
||||||
|
assert any("Secondbrain capsule 42" in hint for hint in payload["memory_hints"])
|
||||||
|
assert payload["secondbrain"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_context_reports_missing_cache_without_inventing_context(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "cmo.db"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE brand_profile_cache (
|
||||||
|
brand_id TEXT PRIMARY KEY,
|
||||||
|
json TEXT,
|
||||||
|
version TEXT,
|
||||||
|
fetched_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
monkeypatch.setenv("CANVAS_CMO_DB", str(db_path))
|
||||||
|
|
||||||
|
h = _Handler()
|
||||||
|
assert canvas._handle_design_context(h, _parsed("brand_id=missing-brand")) is True
|
||||||
|
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
assert payload["status"] == "missing"
|
||||||
|
assert payload["brand_guideline_text"] == ""
|
||||||
|
assert payload["memory_hints"] == []
|
||||||
|
assert "missing-brand" in payload["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_returns_sse_seed():
|
||||||
|
h = _Handler()
|
||||||
|
assert canvas._handle_events(h, None) is True
|
||||||
|
assert h.status == 200
|
||||||
|
assert h.headers_out["Content-Type"].startswith("text/event-stream")
|
||||||
|
assert b"canvas.connected" in h.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_generates_persists_and_replays_events():
|
||||||
|
body = json.dumps({"prompt": "Plan B dashboard", "variants": 2}).encode("utf-8")
|
||||||
|
h = _Handler(body)
|
||||||
|
h.command = "POST"
|
||||||
|
|
||||||
|
with patch("routes.canvas._ensure_project", return_value={"id": "project-1", "name": "Hermes Canvas"}), \
|
||||||
|
patch("routes.canvas._load_design_context", return_value={
|
||||||
|
"brand_id": "planb",
|
||||||
|
"resolved_brand_id": "607740e8-8b76-4455-9c4f-eadbe9b4168c",
|
||||||
|
"status": "ready",
|
||||||
|
"source": "cmo.brand_profile_cache",
|
||||||
|
"source_version": "v1",
|
||||||
|
"fetched_at": "2026-05-27T12:00:00Z",
|
||||||
|
"brand": {"displayName": "Plan B Brand"},
|
||||||
|
"memory_hints": ["Use BTE palette tokens: #FD5000"],
|
||||||
|
"design_md_excerpt": "Use Montserrat.",
|
||||||
|
"tokens_imported": True,
|
||||||
|
}), \
|
||||||
|
patch("routes.canvas._canva_json") as canva_json:
|
||||||
|
canva_json.side_effect = [
|
||||||
|
{"screenSpec": {"root": {"type": "text", "text": "A"}}},
|
||||||
|
{"id": "screen-1", "name": "Plan B dashboard", "spec": {"root": {"type": "text", "text": "A"}}},
|
||||||
|
{"screenSpec": {"root": {"type": "text", "text": "B"}}},
|
||||||
|
{"id": "screen-2", "name": "Plan B dashboard", "spec": {"root": {"type": "text", "text": "B"}}},
|
||||||
|
{"id": "variant-1"},
|
||||||
|
]
|
||||||
|
assert canvas._handle_command(h, None) is True
|
||||||
|
|
||||||
|
payload = json.loads(h.body)
|
||||||
|
assert h.status == 200
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["design_context"]["status"] == "ready"
|
||||||
|
assert len(payload["screens"]) == 2
|
||||||
|
assert any(event["type"] == "canvas.screen.persisted" for event in payload["events"])
|
||||||
|
first_generate_payload = canva_json.call_args_list[0].args[2]
|
||||||
|
assert "BTE/CMO design constraints" in first_generate_payload["prompt"]
|
||||||
|
assert "#FD5000" in first_generate_payload["prompt"]
|
||||||
|
|
||||||
|
events = _Handler()
|
||||||
|
assert canvas._handle_events(events, _parsed("format=json&since=0")) is True
|
||||||
|
replay = json.loads(events.body)
|
||||||
|
assert replay["ok"] is True
|
||||||
|
assert any(event["type"] == "canvas.command.completed" for event in replay["events"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_generation_prompt_keeps_prompt_plain_when_context_missing():
|
||||||
|
assert canvas._compose_generation_prompt("Make a landing page", {"status": "missing"}) == "Make a landing page"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secondbrain_hints_query_uses_local_psql(monkeypatch):
|
||||||
|
monkeypatch.delenv("SECONDBRAIN_DATABASE_URL", raising=False)
|
||||||
|
monkeypatch.setenv("SECONDBRAIN_DB_PASSWORD", "test-password")
|
||||||
|
completed = SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout=json.dumps([
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "Headless everything for personal AI",
|
||||||
|
"summary": "Keep the command center sovereign and web-native.",
|
||||||
|
"sector": "svrnty",
|
||||||
|
"created_at": "2026-05-27T12:00:00Z",
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("routes.canvas.subprocess.run", return_value=completed) as run:
|
||||||
|
result = canvas._load_secondbrain_hints({"displayName": "Plan B"}, "brand-1")
|
||||||
|
|
||||||
|
assert result["status"] == "ready"
|
||||||
|
assert result["source"] == "secondbrain.knowledge_capsules"
|
||||||
|
assert result["capsules"][0]["id"] == 42
|
||||||
|
assert "command center" in result["hints"][0]
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert cmd[0] == "psql"
|
||||||
|
assert "-tA" in cmd
|
||||||
77
tests/unit/test_canvas_route.py
Normal file
77
tests/unit/test_canvas_route.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from routes import canvas
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler:
|
||||||
|
command = "GET"
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.body = b""
|
||||||
|
self.status = None
|
||||||
|
self.sent_headers = {}
|
||||||
|
self.rfile = BytesIO()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wfile(self):
|
||||||
|
handler = self
|
||||||
|
|
||||||
|
class _Writer:
|
||||||
|
def write(self, data):
|
||||||
|
handler.body += data
|
||||||
|
|
||||||
|
return _Writer()
|
||||||
|
|
||||||
|
def send_response(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def send_header(self, key, value):
|
||||||
|
self.sent_headers[key] = value
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_manifest_exposes_cmo_canvas_surface():
|
||||||
|
handler = _Handler()
|
||||||
|
|
||||||
|
assert canvas._handle_tools(handler, None) is True
|
||||||
|
|
||||||
|
payload = json.loads(handler.body)
|
||||||
|
names = {tool["name"] for tool in payload["tools"]}
|
||||||
|
assert handler.status == 200
|
||||||
|
assert payload["tool_surface"] == "cmo-canvas-v0"
|
||||||
|
assert "canvas.generate_screen" in names
|
||||||
|
assert "canvas.command" in names
|
||||||
|
assert "canvas.generate_from_image" in names
|
||||||
|
assert "canvas.update_screen" in names
|
||||||
|
assert "canvas.record_variant" in names
|
||||||
|
assert "canvas.connect_prototype_edge" in names
|
||||||
|
assert "canvas.export_project" in names
|
||||||
|
assert "upstream-hermes-webui-edits" in payload["non_goals"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_proxy_rejects_unlisted_paths():
|
||||||
|
handler = _Handler()
|
||||||
|
parsed = type("Parsed", (), {"query": "path=/api/admin/secrets"})()
|
||||||
|
|
||||||
|
assert canvas._handle_proxy(handler, parsed) is True
|
||||||
|
|
||||||
|
payload = json.loads(handler.body)
|
||||||
|
assert handler.status == 400
|
||||||
|
assert payload["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_status_reports_offline_service_without_failing_request():
|
||||||
|
handler = _Handler()
|
||||||
|
|
||||||
|
with patch("routes.canvas.urllib.request.urlopen", side_effect=OSError("offline")):
|
||||||
|
assert canvas._handle_status(handler, None) is True
|
||||||
|
|
||||||
|
payload = json.loads(handler.body)
|
||||||
|
assert handler.status == 200
|
||||||
|
assert payload["ok"] is False
|
||||||
|
assert payload["service"] == "canva-editor"
|
||||||
39
tests/unit/test_canvas_static.py
Normal file
39
tests/unit/test_canvas_static.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Static checks for the Canvas panel assets."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CANVAS_JS = ROOT / "static" / "canvas.js"
|
||||||
|
CANVAS_CSS = ROOT / "static" / "canvas.css"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_assets_exist():
|
||||||
|
assert CANVAS_JS.is_file()
|
||||||
|
assert CANVAS_CSS.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_js_is_idempotent_and_uses_plugin_routes():
|
||||||
|
src = CANVAS_JS.read_text()
|
||||||
|
assert "__svrntyCanvasLoaded" in src
|
||||||
|
assert "request: _request" in src
|
||||||
|
assert "svrnty:canvas-generate" in src
|
||||||
|
assert 'name: "canvas"' in src
|
||||||
|
assert "/api/canvas/status" in src
|
||||||
|
assert "/api/canvas/tools" in src
|
||||||
|
assert "/api/canvas/proxy" in src
|
||||||
|
assert "/api/canvas/command" in src
|
||||||
|
assert "/api/canvas/events" in src
|
||||||
|
assert "/api/canvas/design-context" in src
|
||||||
|
assert "designContext" in src
|
||||||
|
assert "BTE context ready" in src
|
||||||
|
assert "data-canvas-action=\"save\"" in src
|
||||||
|
assert "data-canvas-action=\"prototype-link\"" in src
|
||||||
|
assert "/prototype-edges" in src
|
||||||
|
assert "/export" in src
|
||||||
|
assert "status.capabilities" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_css_is_panel_scoped():
|
||||||
|
src = CANVAS_CSS.read_text()
|
||||||
|
assert ".svrnty-canvas-" in src
|
||||||
|
assert ".svrnty-canvas-overlay" in src
|
||||||
@ -16,3 +16,10 @@ def test_svrnty_tabs_participate_in_panel_switching():
|
|||||||
src = NAV_JS.read_text()
|
src = NAV_JS.read_text()
|
||||||
assert "const TABS = [" in src
|
assert "const TABS = [" in src
|
||||||
assert "const OUR_IDS = TABS.map((t) => t.id)" in src
|
assert "const OUR_IDS = TABS.map((t) => t.id)" in src
|
||||||
|
assert 'id: "canvas"' in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_tab_is_registered():
|
||||||
|
src = NAV_JS.read_text()
|
||||||
|
assert '{ id: "canvas"' in src
|
||||||
|
assert "svrnty-showing-\" + id" in src
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user