Compare commits

...
8 Commits
Author SHA1 Message Date
Hanzo AI b89cf707a2 studio v0.18.3: monochrome shell chrome — coherent with the Home + @hanzo/gui design system
shell.css (chat sidebar · Developers dock · GPUs panel) was still on the old
charcoal palette; snap it to the canonical monochrome tokens the Home already
uses: page #000 · surface #0a0a0a · raised #1a1a1a · border #1f1f1f · text
#ededed · dim #888 · brand/CTA #fff. Semantic green (online) / red (error) kept.
The shared shell now matches the create experience + the chat's own empty-state
and friendly-error work already on main.
2026-07-25 10:17:15 -07:00
zandClaude Opus 4.8 d8399c312f refactor(studio/docs): decomplect documents AI-fill onto the ONE canonical gateway_auth
documents.py carried a parity copy of the gateway-auth allowlist (_jwt_alg,
_forwardable_bearer, _SECRET_PREFIXES) — and it DID drift: the same crafted-alg
hardening + exact-JWS tightening had to be applied to both copies. _ai_token now
delegates to gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV); the
~55 duplicated lines are deleted. One and only one fail-closed allowlist, so a
future fix can never land on one copy and miss the other. Tests repointed to the
_ai_token delegation surface (crafted-alg no-crash, HS256/secret refused, pk-/
asymmetric forwarded, server-key-first). 67 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:36:21 -07:00
zandClaude Opus 4.8 20d2f809af fix(studio/create): RED findings — dedup double-bill, dim type-confusion, alg over-match, sub-caption XSS (v0.18.2)
- F1 (MED): _dedup_key now keys on stable identity (org|kind|prompt|refs|uploads),
  not the random-tagged output_prefix, so two identical creates collapse to one
  dispatch (the anti-double-bill guard actually fires now).
- F2 (LOW): _image_graph/_video_graph raise DispatchError on non-numeric dims (clean 400, not 500).
- F3 (LOW): alg allowlist tightened to the exact JWS set in gateway_auth + documents.
- F4 (INFO): esc() the gallery sub-caption (crafted-upload filename_prefix XSS).
Red verified /v1/library/image cross-org/traversal/secret-exfil fail closed. +8 tests, 65 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:31:48 -07:00
zandClaude Opus 4.8 c00384cf1a fix(studio/auth): harden canonical gateway_auth against a crafted non-string JWT alg
gateway_auth.py (the new canonical fail-closed allowlist, now on the live copilot
path) had the same crafted-alg gap RED found in documents.py's copy: jwt_alg
returned a non-string alg verbatim and forwardable_bearer did alg[:2]/startswith
with no isinstance guard → a JWT header {alg:null} 500s the copilot. Guarded at
both points (+ regression test). FLAG: documents.py still has a private copy of
this allowlist — decomplect it onto gateway_auth (one way) as the follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:11:42 -07:00
zeekayandClaude Opus 4.8 cadc589875 studio v0.18.1: text-to-image default create + fail-closed gateway auth + UX fixes
P1 — the primary Create now produces something, and Chat reaches the gateway:
- middleware/gateway_auth.py: ONE fail-closed allowlist for the browser-edge
  gateway bearer — a server key first, else pk-/asymmetric-JWT only; the HS256
  hanzo.id session token and sk-/hk-/rk- secrets are NEVER forwarded (the old
  "unsupported signing method: HS256" 502). copilot._resolve_target adopts it.
- Z-Image-Turbo text-to-image lane: _image_graph + dispatch_image + POST
  /v1/library/image. The composer's default up-arrow (words, no photo) now
  generates an image instead of the old "attach a photo first" dead end.
- composer defaults to an IMAGE model (hz_create_model, decoupled from the chat
  model); routes by modality (assistant -> Chat via window.HanzoChat, else
  image/video/fix/compose); create() ALWAYS shows a generating state + friendly
  retry, never a silent no-op.
- shell.js: copilot errors render one friendly line (never raw JSON); chat empty
  state (assistant identity + 3 starters); one public HanzoChat.send hook.

P2 — friction on the common path:
- first-class Download on every tile AND in the viewer (PNG / JPG / WebP)
- opaque fixed header + sticky live-bar (content no longer scrolls under them)
- ARCHIVED badge only on truly deleted/flagged items (active work is unbadged)
- mobile: ~30 filter pills collapse to one scrollable row; tab-bar scroll fade

P3 — composer pill tooltips, per-model descriptions, code model hidden, and the
</> request-preview gated behind Developers mode.

Tests: gateway_auth_test (allowlist refuses HS256/sk-, accepts pk-/asymmetric),
copilot_test (never forwards the HS256 session token), create_image_test
(Z-Image graph shape + served-HTML invariants: default-model-is-image,
Create-always-shows-a-state, first-class Download). Green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:04:03 -07:00
zandClaude Opus 4.8 a8dc089716 fix(studio/docs): RED findings — XLSX formula-injection guard, C0 control-char strip, crafted-JWT-alg hardening
Red review of the office-document subsystem (0 crit/high, 1 med, 2 low):
- MED: _sheet_xlsx now neutralizes formula-trigger text cells (=,+,-,@,tab,cr →
  leading ') so shared XLSX (budgets/expense reports) can't fire DDE/HYPERLINK.
- LOW: _coerce strips C0 control chars (keep \t\n\r) at the one boundary, so a
  NUL in a field can't 500/poison the docx/xlsx OOXML writers.
- LOW: _jwt_alg + _forwardable_bearer guard a non-string JWT alg (no 500).
- INFO: _ID_RE uses \Z not $ (no trailing-newline bypass).
Tenancy + secret-containment + escaping were RED-verified airtight. +3 regression
tests (formula inert, control-char valid-OOXML, crafted-alg no-crash). 150 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:42:21 -07:00
zeekayandClaude Opus 4.8 e631d35fd0 test(documents): skip a format when its wheel is absent (portable unit gate)
pytest tests-unit runs in the GitHub-mirror unit workflow across an
ubuntu/windows/macos matrix. PDF (built-in fallback) and Markdown are stdlib and
always render; DOCX/XLSX/PPTX now skip gracefully when python-docx/openpyxl/
python-pptx isn't importable in that env instead of erroring. The canonical gate
(hanzo.yml installs requirements.txt) still exercises every format for real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:18:20 -07:00
zeekayandClaude Opus 4.8 267735d0bd studio: office documents — a non-GPU document pipeline (PDF/DOCX/XLSX/PPTX/MD)
Add a first-class office-document capability ALONGSIDE the ComfyUI/GPU creative
pipeline: structured content (typed template fields, filled by a person or the
AI) → a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md. No graph, no
GPU. Orthogonal subsystem — not bolted onto graphs.

- middleware/doc_render.py: one neutral IR per archetype (doc/sheet/deck) and
  exactly one renderer per format (DRY). PDF via WeasyPrint (full-CSS monochrome
  print style) with a self-contained pure-Python PDF fallback (valid %PDF, zero
  native deps — so CI/model-less pods still render). DOCX/XLSX/PPTX via
  python-docx/openpyxl/python-pptx.
- middleware/documents.py: one declarative CATALOG of 22 common templates
  (Documents 15, Spreadsheets 4, Presentations 3). Each template's typed fields
  drive the form, the AI-fill JSON, and the renderer inputs (one schema). Per-org
  store (orgs/<org>/documents, atomic index.json + <id>/doc.json), rendered on
  demand. Endpoints (/v1 house style): GET /v1/documents, POST
  /v1/documents/generate, GET /v1/documents/library, GET|PATCH|DELETE
  /v1/documents/{id}, GET /v1/documents/{id}/download?format=. Registered via the
  same injected tenancy resolvers as stacks_store.
- AI-fill is a layered enhancement — the form path renders with NO LLM, so a
  broken/unauthorized gateway never blocks creation (422 needFields → prefilled
  form). Reuses the shipped Chat's working token: the browser sends window.HZ.pk
  (publishable pk- key) as the bearer; the backend forwards it fail-closed —
  server key, else a gateway-verifiable pk-/RS-JWT; an HS256 session token or
  sk-/hk- secret is NEVER forwarded. Model default enso (window.HZ.model).
- studio_home.html: a "Documents" Home tab (searchable catalog + your documents
  with Download PDF/Word/Excel/PowerPoint) + describe/fill dialog; replaced the
  dead doc/sheet/paper composer placeholders with one "Office documents" card.
- deps: weasyprint/python-docx/openpyxl/python-pptx (requirements.txt) + pango
  native libs (Dockerfile). CI builds the image; not built locally.
- tests-unit/documents_test: catalog invariants, full template×format render
  matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist,
  and the REST surface incl. per-org isolation (44 tests, green).
- version 0.17.24 → 0.18.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 08:14:55 -07:00
18 changed files with 3514 additions and 103 deletions
+7
View File
@@ -5,6 +5,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgl1 \
libglib2.0-0 \
# WeasyPrint native stack (office-document PDF renderer, middleware/doc_render.py):
# pango/cairo/gdk-pixbuf + a base font. Absent → the built-in PDF fallback is used.
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+101 -3
View File
@@ -219,10 +219,108 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
## Office documents (the non-GPU pipeline)
Studio's creative side is ComfyUI graphs → GPU. Office documents are the **orthogonal**
subsystem: structured content (a template's typed fields, filled by a person OR by the AI)
→ a pure-Python renderer → a real `.pdf/.docx/.xlsx/.pptx/.md`. No graph, no model, no GPU.
NOT bolted onto graphs; its own module, store, endpoints, and Home tab.
- **Renderers** (`middleware/doc_render.py`, pure — no HTTP/storage/org): one neutral IR
per archetype (`doc` prose blocks · `sheet` sheets · `deck` slides) and EXACTLY ONE
renderer per format. Adding a template = one `build(fields)->(kind,ir)`; adding a format =
one renderer. Deps imported lazily so the module loads stdlib-only. **PDF** prefers
WeasyPrint (full-CSS, monochrome print stylesheet); when its native pango/cairo stack is
absent (the light CI unit venv, model-less pods) a **built-in pure-Python PDF writer**
(base-14 Helvetica, WinAnsi/cp1252, auto-pagination, `/Info` title) produces a valid
`%PDF` — so every format renders with just the wheels. **DOCX/XLSX/PPTX** = python-docx /
openpyxl / python-pptx (real, always). Native libs in the `Dockerfile` (libpango/…).
- **Catalog + AI-fill + store + REST** (`middleware/documents.py`): ONE declarative `CATALOG`
of 22 common templates (Documents 15 → pdf/docx/md · Spreadsheets 4 → xlsx/pdf/docx/md ·
Presentations 3 → pptx/pdf/md). Each template's typed `fields` drive the form, the AI-fill
JSON, AND the renderer inputs (one schema, three uses). Registered from within
`add_studio_home_routes` with the SAME injected tenancy resolvers as `stacks_store`
(`org_of`/`owner_of`). Per-org store `orgs/<org>/documents/` (atomic `index.json` +
`<id>/doc.json`), doc.json is the source of truth, files rendered on demand at download.
Endpoints (`/v1` house style): `GET /v1/documents` (grouped catalog),
`POST /v1/documents/generate` `{templateId, description?|fields?}`,
`GET /v1/documents/library`, `GET|PATCH|DELETE /v1/documents/{id}`,
`GET /v1/documents/{id}/download?format=`.
- **AI-fill is a LAYERED ENHANCEMENT** — the structured-form path renders a valid document
with NO LLM, so a broken/unauthorized gateway can NEVER block creation (`422 needFields`
→ the UI reveals the prefilled form). It reuses the SAME working token the shipped Chat
uses: the browser sends `window.HZ.pk` (the publishable `pk-` key `_publishable`/
`_hz_config_script` emit) as the bearer to `/v1/documents/generate`; the backend forwards
it, fail-closed, in order: (1) a **server-side gateway key** (`STUDIO_DOC_TOKEN`→
`STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`); (2) else the caller's own token, forwarded
ONLY when gateway-verifiable — a publishable `pk-` key or an asymmetric/JWKS JWT
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
gateway rejects it: "unsupported signing method: HS256"), nor any secret `sk-`/`hk-` key.
Model = `STUDIO_DOC_MODEL`→`STUDIO_CHAT_MODEL`→`enso` (matches window.HZ.model).
- **UI** (`middleware/studio_home.html`, vanilla JS, monochrome/Geist): a **📄 Documents**
Home tab — searchable server-driven catalog + "Your documents" grid with Download
PDF/Word/Excel/PowerPoint. The `docdlg` dialog: describe in one line → Generate with AI
(sends window.HZ.pk), or fill the short typed form → Generate; edit fields before/after;
re-open a saved doc to edit (PATCH). The dead `doc/sheet/paper` composer KIND placeholders
were replaced with one "Office documents" card that opens the tab.
## Create composer + gateway auth (v0.18.1 — the intuitive first Create)
The "What should we create?" composer must PRODUCE something on the first ↑, and Chat must
reach the gateway. Both were fixed here.
- **Gateway auth is ONE fail-closed seam** (`middleware/gateway_auth.py`): the canonical
allowlist for handing a browser-edge bearer to api.hanzo.ai. `ai_bearer(request,
server_env=…)` = a server key first (never in page source), else the caller's own token
ONLY when the gateway can verify it — a publishable `pk-` key or an asymmetric/JWKS JWT
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
gateway rejects it → "unsupported signing method: HS256" → the old 502), nor any secret
`sk-`/`hk-`/`rk-` key. `copilot.py._resolve_target` now uses it
(`_SERVER_TOKEN_ENV=(STUDIO_COPILOT_TOKEN, STUDIO_COMMERCE_TOKEN)`), so the editor-ops
chat no longer 502s on the session token. The shipped **Chat sidebar** (`shell.js`) still
streams enso directly from the gateway with `window.HZ.pk` when present; its copilot
fallback now renders ONE friendly line on failure — never the raw backend error/JSON.
(documents.py's AI-fill has an equivalent private copy; it should adopt `gateway_auth`
once its concurrent review lands — the shared module is the going-forward one way.)
- **Text-to-image is the default create lane** (`studio_home.py`: `_image_graph` +
`dispatch_image` + `POST /v1/library/image`): the proven **Z-Image-Turbo** graph
(`z_image_turbo_bf16` / `qwen_3_4b` lumina2 / `ae` vae; EmptySD3LatentImage →
ModelSamplingAuraFlow shift 3 → KSampler res_multistep/simple 4 steps cfg 1.0 →
SaveImage), lifted from the shipped blueprint, dispatched through the ONE funnel like
video/fix. Words in → an image out; a model-less pod surfaces the real DispatchError, not
a silent nothing.
- **Composer routes ↑ by the selected model's modality** (`studio_home.html`): default
`MODEL='zen-image'` from `hz_create_model` — DECOUPLED from the chat model (`hz_model`),
so an image pick never breaks the assistant (and `shell.js chatModel()` ignores a
non-chat `hz_model`). `composerPlan()`: assistant model → hand to the ONE chat via
`window.HanzoChat.send` (exposed by shell.js); no photo → text-to-image; 1 photo → fix;
2+ → compose; video model/lane → video. `create()` ALWAYS pulses a "Preparing…"
live-bar state and, on failure, shows a friendly retry (prompt kept) — never a silent
no-op. The code model (`zen5-coder`) is hidden from the creative composer; each model has
a one-line description; the `</>` request-preview is gated behind Developers mode
(`body.hzdev`, set by the shell's Developers pill).
- **P2 friction fixes** (`studio_home.html` + `shell.css`): a first-class **Download** on
every tile AND in the viewer (PNG original · JPG/WebP client-side canvas convert, the
popover flips up near the viewport bottom); the fixed header + sticky live-bar are now
OPAQUE (were translucent → content scrolled under them); the **ARCHIVED** badge shows
ONLY for truly deleted/flagged items (active work is unbadged); on mobile the ~30 filter
pills collapse to one horizontally-scrollable row and the tab bar gets a scroll fade
affordance. The Chat empty state (shell.js) shows an assistant identity + one line + 3
tappable starters.
## Tests
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test,studio_home_test,documents_test}` —
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
tests-unit/app_test tests-unit/prompt_server_test tests-unit/studio_home_test
tests-unit/documents_test -q`. The auth/tenancy work is
covered by `middleware_test/iam_auth_test.py`,
`middleware_test/session_test.py` and
`folder_paths_test/org_scoping_test.py`.
`folder_paths_test/org_scoping_test.py`. Office documents:
`documents_test/test_documents.py` (catalog invariants, the full template×format render
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist, and the
REST surface incl. per-org isolation on a real aiohttp app). Create composer + gateway
auth: `middleware_test/gateway_auth_test.py` (the fail-closed allowlist — HS256/`sk-` refused,
`pk-`/asymmetric forwarded, server-key precedence, session-cookie path),
`middleware_test/copilot_test.py::test_resolve_target_never_forwards_hs256_session_token`,
and `studio_home_test/create_image_test.py` (the Z-Image-Turbo graph shape, `dispatch_image`
validation, and served-HTML invariants: default-model-is-image, text→image route,
Create-always-shows-a-state, first-class Download, no ARCHIVED badge on active items, opaque
bars, gated `</>`, collapsed mobile filters).
+2 -1
View File
@@ -24,7 +24,8 @@ test:
tests-unit/middleware_test \
tests-unit/app_test \
tests-unit/prompt_server_test \
tests-unit/studio_home_test
tests-unit/studio_home_test \
tests-unit/documents_test
# Deploy: roll the freshly-built image onto the studio Service CR.
deploy:
+16 -9
View File
@@ -62,6 +62,11 @@ _OP_REQUIRED = {
_GATEWAY_URL = "https://api.hanzo.ai/v1/chat/completions"
_LOCAL_URL = "http://127.0.0.1:1234/v1/chat/completions"
# Server-side gateway keys, in precedence order (never in page source). When none is set
# the caller's own token is forwarded ONLY when the gateway can verify it (pk-/asymmetric
# JWT) — see gateway_auth. This is why a browser HS256 session token no longer 502s.
_SERVER_TOKEN_ENV = ("STUDIO_COPILOT_TOKEN", "STUDIO_COMMERCE_TOKEN")
# Single tool: the model calls it to both reply and edit the graph.
_EDIT_TOOL = {
"type": "function",
@@ -142,17 +147,19 @@ class CopilotError(Exception):
def _resolve_target(request) -> tuple[str, str, str]:
"""(url, model, token) for this request."""
"""(url, model, bearer_token) for this request.
The bearer is chosen by the ONE fail-closed gateway allowlist (``gateway_auth``): a
server-side key first, else the caller's own token ONLY when the gateway can verify it
(a publishable ``pk-`` key or an asymmetric/JWKS JWT). A symmetric HS256 hanzo.id
session token is NEVER forwarded — the gateway rejects it ("unsupported signing method:
HS256"), which was the 502 that made Chat show a raw error. ``token`` is the bare value
(no ``Bearer `` prefix) since ``_post_chat`` adds it; "" when none is usable."""
from middleware import gateway_auth
url = os.environ.get("STUDIO_COPILOT_URL", "").strip() or _DEFAULT_URL
model = os.environ.get("STUDIO_COPILOT_MODEL", "").strip() or "enso"
token = (
os.environ.get("STUDIO_COPILOT_TOKEN", "").strip()
or os.environ.get("STUDIO_COMMERCE_TOKEN", "").strip()
)
if not token:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
bearer = gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV)
token = bearer[7:] if bearer[:7].lower() == "bearer " else bearer
return url, model, token
+887
View File
@@ -0,0 +1,887 @@
"""Office-document renderers — the NON-GPU pipeline.
Studio's creative side is ComfyUI graphs → GPU. Office documents are the opposite:
structured content (a template's typed fields, filled by a person or by the AI) →
a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md file. No graph, no model,
no GPU.
One and only one way, kept DRY by a neutral intermediate representation (IR):
* a template maps its fields to ONE of three archetype IRs —
- ``doc`` : a list of prose blocks (title/heading/paragraph/table/…) → PDF · DOCX · MD
- ``sheet`` : a list of sheets (columns + rows + totals) → XLSX · PDF · DOCX · MD
- ``deck`` : a list of slides (title + bullets + notes) → PPTX · PDF · MD
* each output format has EXACTLY ONE renderer that consumes the IR.
So adding a template = write one ``build(fields) -> (kind, ir)``; adding a format =
write one renderer. The renderers never know about templates, HTTP, storage, or org
scoping — they are pure ``(ir) -> bytes``.
Dependencies are imported LAZILY inside each renderer so this module imports with only
the standard library present (unit tests, light pods). PDF prefers WeasyPrint (full CSS
fidelity); when its native stack (pango/cairo) is absent it falls back to a self-contained
pure-Python PDF writer that still emits a valid, readable, monochrome document — the
``weasyprint``-absent fallback. DOCX/XLSX/PPTX are pure-Python (python-docx / openpyxl /
python-pptx) and always render for real.
"""
from __future__ import annotations
import html as _html
import io
import zlib
from typing import Any
# ── Public surface ────────────────────────────────────────────────────────────────
FORMATS = ("pdf", "docx", "xlsx", "pptx", "md")
CONTENT_TYPE = {
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"md": "text/markdown; charset=utf-8",
}
# Which formats each archetype can produce. The catalog's per-template ``formats`` is
# always a subset of its archetype's capability — the render dispatch enforces both.
KIND_FORMATS = {
"doc": ("pdf", "docx", "md"),
"sheet": ("xlsx", "pdf", "docx", "md"),
"deck": ("pptx", "pdf", "md"),
}
def content_type(fmt: str) -> str:
return CONTENT_TYPE[fmt]
def render(kind: str, ir: dict, fmt: str) -> bytes:
"""Render an archetype IR to ``fmt``, returning the file bytes.
Raises ``ValueError`` for an unsupported (kind, fmt) pair — the one place the
dispatch table lives, so a caller can never silently get the wrong renderer.
"""
if fmt not in KIND_FORMATS.get(kind, ()): # pragma: no cover - guarded by callers
raise ValueError(f"{kind!r} cannot render {fmt!r}")
if kind == "doc":
return _render_doc(ir, fmt)
if kind == "sheet":
return _render_sheet(ir, fmt)
if kind == "deck":
return _render_deck(ir, fmt)
raise ValueError(f"unknown archetype {kind!r}") # pragma: no cover
def weasyprint_available() -> bool:
"""True when the high-fidelity PDF path (WeasyPrint + native pango/cairo) is usable.
Diagnostic only — the renderer falls back automatically."""
return _weasy_pdf("<html><body>probe</body></html>") is not None
# ── IR block constructors — the shared vocabulary of the ``doc`` archetype ─────────
# Blocks are plain dicts (JSON-friendly, no classes to import). A template's build()
# assembles a list of them; four renderers consume the same list.
def title(text: str, sub: str | None = None) -> dict:
return {"t": "title", "text": text or "", "sub": sub or ""}
def heading(text: str, level: int = 2) -> dict:
return {"t": "h", "text": text or "", "level": max(1, min(3, int(level)))}
def para(text: str) -> dict:
return {"t": "p", "text": text or ""}
def keyvals(pairs: list, cols: int = 1) -> dict:
return {"t": "kv", "pairs": [[str(k), str(v)] for k, v in pairs if v not in (None, "")], "cols": cols}
def party(left: dict, right: dict | None = None) -> dict:
"""Two facing address/identity blocks (invoice from/to, letter sender/recipient)."""
def _side(s):
s = s or {}
return {"title": str(s.get("title", "")), "lines": [str(x) for x in (s.get("lines") or []) if str(x).strip()]}
return {"t": "party", "left": _side(left), "right": _side(right)}
def table(headers: list, rows: list, align: list | None = None, foot: list | None = None) -> dict:
return {
"t": "table",
"headers": [str(h) for h in headers],
"rows": [[("" if c is None else str(c)) for c in r] for r in rows],
"align": align or [],
"foot": [[("" if c is None else str(c)) for c in r] for r in (foot or [])],
}
def bullets(items: list) -> dict:
return {"t": "ul", "items": [str(i) for i in items if str(i).strip()]}
def rule() -> dict:
return {"t": "hr"}
def spacer(h: int = 12) -> dict:
return {"t": "sp", "h": int(h)}
def signature(label: str) -> dict:
return {"t": "sign", "label": label or ""}
def doc_ir(title_text: str, blocks: list, *, meta: dict | None = None) -> dict:
return {"title": title_text or "Document", "blocks": [b for b in blocks if b], "meta": meta or {}}
# ── doc archetype: PDF · DOCX · MD ────────────────────────────────────────────────
def _render_doc(ir: dict, fmt: str) -> bytes:
if fmt == "pdf":
html = _doc_html(ir)
pdf = _weasy_pdf(html)
return pdf if pdf is not None else _builtin_pdf(ir)
if fmt == "docx":
return _doc_docx(ir)
if fmt == "md":
return _doc_md(ir).encode("utf-8")
raise ValueError(fmt) # pragma: no cover
# Monochrome print stylesheet — Hanzo's design-system look on paper: near-black ink on
# white, a clean sans stack, thin hairline rules, generous margins. Deliberately light
# (this is the paper, previewed as paper even under a dark UI).
_PRINT_CSS = """
@page { size: Letter; margin: 22mm 20mm; }
* { box-sizing: border-box; }
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, "Segoe UI", sans-serif;
color: #16171a; font-size: 10.5pt; line-height: 1.5; margin: 0; }
h1.doctitle { font-size: 22pt; letter-spacing: -.01em; margin: 0 0 2pt; font-weight: 700; }
.docsub { color: #6b6f76; font-size: 10.5pt; margin: 0 0 14pt; }
h2 { font-size: 13pt; font-weight: 700; margin: 20pt 0 6pt; letter-spacing:-.005em; }
h3 { font-size: 11pt; font-weight: 700; margin: 14pt 0 4pt; }
h4 { font-size: 10.5pt; font-weight: 700; margin: 12pt 0 3pt; text-transform: uppercase; letter-spacing: .06em; color:#4a4d53; }
p { margin: 0 0 8pt; }
.hr { border: 0; border-top: 1px solid #d9dbdf; margin: 14pt 0; }
.kv { width: 100%; border-collapse: collapse; margin: 0 0 8pt; }
.kv td { padding: 2pt 0; vertical-align: top; }
.kv td.k { color: #6b6f76; width: 34%; padding-right: 10pt; }
.kv.two { }
.parties { display: flex; gap: 26pt; margin: 6pt 0 16pt; }
.parties .side { flex: 1; }
.parties .side .ttl { text-transform: uppercase; letter-spacing: .06em; font-size: 8.5pt; color: #6b6f76; margin: 0 0 3pt; }
.parties .side .ln { margin: 0; }
table.grid { width: 100%; border-collapse: collapse; margin: 8pt 0 12pt; font-size: 10pt; }
table.grid th { text-align: left; font-weight: 700; border-bottom: 1.4px solid #16171a; padding: 6pt 8pt 6pt 0; }
table.grid td { border-bottom: 1px solid #e6e8eb; padding: 6pt 8pt 6pt 0; vertical-align: top; }
table.grid th.num, table.grid td.num { text-align: right; padding-right: 0; }
table.grid tfoot td { border-bottom: 0; border-top: 1.4px solid #16171a; font-weight: 700; padding-top: 7pt; }
ul { margin: 0 0 8pt; padding-left: 16pt; }
li { margin: 0 0 3pt; }
.sign { margin-top: 34pt; }
.sign .line { border-top: 1px solid #16171a; width: 58%; padding-top: 4pt; color: #6b6f76; font-size: 9pt; }
"""
def _doc_html(ir: dict) -> str:
"""The IR as one self-contained HTML document for WeasyPrint. Built with stdlib
``html.escape`` at every value boundary — no template engine, so there is no
template-injection surface and no extra dependency; the structural HTML is ours,
every field value is escaped."""
body = _doc_html_body(ir) # already-escaped, structural HTML
doc_title = _html.escape(ir.get("title") or "Document")
return (
"<!doctype html><html><head><meta charset='utf-8'><title>" + doc_title
+ "</title><style>" + _PRINT_CSS + "</style></head><body>" + body + "</body></html>"
)
def _e(s: Any) -> str:
return _html.escape("" if s is None else str(s))
def _doc_html_body(ir: dict) -> str:
out: list[str] = []
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
out.append(f"<h1 class='doctitle'>{_e(b['text'])}</h1>")
if b.get("sub"):
out.append(f"<div class='docsub'>{_e(b['sub'])}</div>")
elif t == "h":
tag = {1: "h2", 2: "h3", 3: "h4"}[b.get("level", 2)]
out.append(f"<{tag}>{_e(b['text'])}</{tag}>")
elif t == "p":
out.append(f"<p>{_e(b['text']).replace(chr(10), '<br>')}</p>")
elif t == "kv":
cls = "kv two" if b.get("cols") == 2 else "kv"
rows = "".join(f"<tr><td class='k'>{_e(k)}</td><td>{_e(v)}</td></tr>" for k, v in b["pairs"])
out.append(f"<table class='{cls}'>{rows}</table>")
elif t == "party":
def side(s):
lns = "".join(f"<p class='ln'>{_e(x)}</p>" for x in s["lines"])
ttl = f"<p class='ttl'>{_e(s['title'])}</p>" if s["title"] else ""
return f"<div class='side'>{ttl}{lns}</div>"
out.append(f"<div class='parties'>{side(b['left'])}{side(b['right'])}</div>")
elif t == "table":
out.append(_html_table(b))
elif t == "ul":
out.append("<ul>" + "".join(f"<li>{_e(i)}</li>" for i in b["items"]) + "</ul>")
elif t == "hr":
out.append("<hr class='hr'>")
elif t == "sp":
out.append(f"<div style='height:{int(b.get('h', 12))}pt'></div>")
elif t == "sign":
out.append(f"<div class='sign'><div class='line'>{_e(b['label'])}</div></div>")
return "".join(out)
def _num_cols(b: dict) -> set:
return {i for i, a in enumerate(b.get("align", [])) if a == "num"}
def _html_table(b: dict) -> str:
nums = _num_cols(b)
th = "".join(f"<th class='{'num' if i in nums else ''}'>{_e(h)}</th>" for i, h in enumerate(b["headers"]))
body = "".join(
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
for r in b["rows"]
)
foot = ""
if b.get("foot"):
foot = "<tfoot>" + "".join(
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
for r in b["foot"]
) + "</tfoot>"
return f"<table class='grid'><thead><tr>{th}</tr></thead><tbody>{body}</tbody>{foot}</table>"
_WEASY: Any = ... # unset sentinel; set to the module or False on first probe
def _weasyprint():
"""The weasyprint module if importable with its native stack, else False. Probed
once and cached — the import is expensive and, when pango/cairo are absent, prints
its own diagnostics, so we do it exactly once and swallow that chatter."""
global _WEASY
if _WEASY is ...:
import contextlib
import os as _os
try:
with open(_os.devnull, "w") as dn, contextlib.redirect_stderr(dn), contextlib.redirect_stdout(dn):
import weasyprint # type: ignore
_WEASY = weasyprint
except Exception:
_WEASY = False
return _WEASY
def _weasy_pdf(html: str) -> bytes | None:
"""WeasyPrint render, or None when the library or its native stack is unavailable —
the caller then uses the built-in fallback. Fully guarded so a pod without
pango/cairo simply falls back."""
wp = _weasyprint()
if not wp:
return None
try:
return wp.HTML(string=html).write_pdf()
except Exception: # pragma: no cover - native failure at render time
return None
# ── DOCX (python-docx) ────────────────────────────────────────────────────────────
def _doc_docx(ir: dict) -> bytes:
from docx import Document
from docx.shared import Pt, RGBColor
INK = RGBColor(0x16, 0x17, 0x1A)
DIM = RGBColor(0x6B, 0x6F, 0x76)
d = Document()
for s in d.styles:
try:
if s.font is not None and s.font.name is None:
s.font.name = "Helvetica"
except Exception:
pass
normal = d.styles["Normal"]
normal.font.name = "Helvetica"
normal.font.size = Pt(10.5)
normal.font.color.rgb = INK
def _runcolor(p, color=INK):
for r in p.runs:
r.font.color.rgb = color
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
p = d.add_paragraph()
run = p.add_run(b["text"])
run.bold = True
run.font.size = Pt(22)
run.font.color.rgb = INK
if b.get("sub"):
sp = d.add_paragraph()
sr = sp.add_run(b["sub"])
sr.font.size = Pt(10.5)
sr.font.color.rgb = DIM
elif t == "h":
p = d.add_paragraph()
run = p.add_run(b["text"])
run.bold = True
run.font.size = Pt({1: 13, 2: 11, 3: 10.5}[b.get("level", 2)])
run.font.color.rgb = INK if b.get("level", 2) < 3 else DIM
elif t == "p":
p = d.add_paragraph(b["text"])
_runcolor(p)
elif t == "kv":
tbl = d.add_table(rows=0, cols=2)
for k, v in b["pairs"]:
cells = tbl.add_row().cells
kr = cells[0].paragraphs[0].add_run(k)
kr.font.color.rgb = DIM
kr.font.size = Pt(10)
vr = cells[1].paragraphs[0].add_run(v)
vr.font.size = Pt(10)
vr.font.color.rgb = INK
elif t == "party":
tbl = d.add_table(rows=1, cols=2)
for cell, side in ((tbl.rows[0].cells[0], b["left"]), (tbl.rows[0].cells[1], b["right"])):
if side["title"]:
tr = cell.paragraphs[0].add_run(side["title"].upper())
tr.bold = True
tr.font.size = Pt(8.5)
tr.font.color.rgb = DIM
for ln in side["lines"]:
lp = cell.add_paragraph()
lr = lp.add_run(ln)
lr.font.size = Pt(10)
lr.font.color.rgb = INK
elif t == "table":
_docx_table(d, b, INK, DIM, Pt)
elif t == "ul":
for it in b["items"]:
p = d.add_paragraph(it, style="List Bullet")
_runcolor(p)
elif t == "hr":
d.add_paragraph("_" * 48).runs[0].font.color.rgb = DIM
elif t == "sp":
d.add_paragraph()
elif t == "sign":
d.add_paragraph()
p = d.add_paragraph("__________________________")
p.runs[0].font.color.rgb = INK
lp = d.add_paragraph()
lr = lp.add_run(b["label"])
lr.font.size = Pt(9)
lr.font.color.rgb = DIM
buf = io.BytesIO()
d.save(buf)
return buf.getvalue()
def _docx_table(d, b, INK, DIM, Pt) -> None:
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
nums = _num_cols(b)
from docx.enum.text import WD_ALIGN_PARAGRAPH
tbl = d.add_table(rows=1, cols=len(headers))
tbl.style = "Light Grid Accent 1" if "Light Grid Accent 1" in [s.name for s in d.styles] else "Table Grid"
for i, h in enumerate(headers):
cell = tbl.rows[0].cells[i]
run = cell.paragraphs[0].add_run(h)
run.bold = True
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
for r in rows:
cells = tbl.add_row().cells
for i, c in enumerate(r):
run = cells[i].paragraphs[0].add_run(c)
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
for r in foot:
cells = tbl.add_row().cells
for i, c in enumerate(r):
run = cells[i].paragraphs[0].add_run(c)
run.bold = True
run.font.size = Pt(9.5)
run.font.color.rgb = INK
if i in nums:
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
# ── Markdown ──────────────────────────────────────────────────────────────────────
def _doc_md(ir: dict) -> str:
out: list[str] = []
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
out.append(f"# {b['text']}")
if b.get("sub"):
out.append(f"*{b['sub']}*")
elif t == "h":
out.append(("#" * (b.get("level", 2) + 1)) + " " + b["text"])
elif t == "p":
out.append(b["text"])
elif t == "kv":
out.extend(f"- **{k}:** {v}" for k, v in b["pairs"])
elif t == "party":
for side in (b["left"], b["right"]):
if side["title"]:
out.append(f"**{side['title']}**")
out.extend(side["lines"])
out.append("")
elif t == "table":
out.append(_md_table(b["headers"], b["rows"] + (b.get("foot") or [])))
elif t == "ul":
out.extend(f"- {i}" for i in b["items"])
elif t == "hr":
out.append("---")
elif t == "sign":
out.append("")
out.append("\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_")
out.append(b["label"])
out.append("")
return "\n".join(out).strip() + "\n"
def _md_table(headers: list, rows: list) -> str:
def row(cells):
return "| " + " | ".join(str(c).replace("|", "\\|") for c in cells) + " |"
lines = [row(headers), "| " + " | ".join("---" for _ in headers) + " |"]
lines += [row(r + [""] * (len(headers) - len(r))) for r in rows]
return "\n".join(lines)
# ── Built-in PDF fallback — a valid, monochrome, paginated PDF with zero native deps ─
# Base-14 Helvetica (no embedding needed), WinAnsi text, absolute-positioned lines,
# automatic pagination and word wrap, simple heading/table/bullet layout. Not as rich
# as WeasyPrint, but a genuine, readable document that always passes %PDF validation.
_PAGE_W, _PAGE_H = 612.0, 792.0
_ML, _MR, _MT, _MB = 54.0, 54.0, 60.0, 56.0
_CONTENT_W = _PAGE_W - _ML - _MR
# Coarse Helvetica advance widths (per 1pt), enough to wrap without overflow.
_AVG = 0.50
def _text_w(s: str, size: float, bold: bool = False) -> float:
return len(s) * size * (_AVG + (0.02 if bold else 0.0))
def _wrap(text: str, size: float, width: float, bold: bool = False) -> list[str]:
lines: list[str] = []
for raw in str(text).split("\n"):
words, cur = raw.split(), ""
for w in words:
trial = w if not cur else cur + " " + w
if _text_w(trial, size, bold) <= width or not cur:
cur = trial
else:
lines.append(cur)
cur = w
lines.append(cur)
return lines or [""]
def _pdf_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _pdf_enc(s: str) -> str:
# WinAnsi/cp1252 so currency/quotes/dashes survive; unmappable → '?'.
return _pdf_escape(s.encode("cp1252", "replace").decode("cp1252"))
class _PDFPager:
"""Accumulates absolute-positioned text ops across auto-paginated pages."""
def __init__(self):
self.pages: list[list[str]] = [[]]
self.y = _PAGE_H - _MT
def _page(self) -> list[str]:
return self.pages[-1]
def _newpage(self):
self.pages.append([])
self.y = _PAGE_H - _MT
def space(self, h: float):
self.y -= h
if self.y < _MB:
self._newpage()
def line(self, text: str, size: float, *, bold=False, gray=False, indent=0.0, leading=1.42, gap=0.0):
if gap:
self.y -= gap
wrapped = _wrap(text, size, _CONTENT_W - indent, bold)
for ln in wrapped:
if self.y - size < _MB:
self._newpage()
font = "F2" if bold else "F1"
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
self._page().append(
f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + indent:.2f} {self.y - size:.2f} Tm ({_pdf_enc(ln)}) Tj ET\n"
)
self.y -= size * leading
def cols(self, cells: list[tuple[str, float, bool]], xs: list[float], size: float, *, bold=False, gray=False, gap=0.0):
"""One row of already-fitted cell strings at column x-offsets."""
if gap:
self.y -= gap
if self.y - size < _MB:
self._newpage()
font = "F2" if bold else "F1"
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
y = self.y - size
for (text, colw, right), x in zip(cells, xs):
s = _fit(text, size, colw, bold)
tx = x + (colw - _text_w(s, size, bold)) if right else x
self._page().append(f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + tx:.2f} {y:.2f} Tm ({_pdf_enc(s)}) Tj ET\n")
self.y -= size * 1.55
def hline(self, weight: float = 0.6, gray=True):
self.y -= 4
if self.y < _MB:
self._newpage()
col = "0.85 0.86 0.88 RG\n" if gray else "0.086 0.09 0.102 RG\n"
self._page().append(f"{col}{weight:.2f} w {_ML:.2f} {self.y:.2f} m {_PAGE_W - _MR:.2f} {self.y:.2f} l S\n")
self.y -= 6
def _fit(text: str, size: float, width: float, bold: bool) -> str:
s = str(text)
if _text_w(s, size, bold) <= width:
return s
while s and _text_w(s + "", size, bold) > width:
s = s[:-1]
return (s + "") if s else ""
def _builtin_pdf(ir: dict) -> bytes:
p = _PDFPager()
for b in ir.get("blocks", []):
t = b.get("t")
if t == "title":
p.line(b["text"], 21, bold=True, gap=2)
if b.get("sub"):
p.line(b["sub"], 10.5, gray=True, gap=1)
p.space(8)
elif t == "h":
size = {1: 13.5, 2: 11.5, 3: 10.5}[b.get("level", 2)]
p.line(b["text"], size, bold=True, gray=b.get("level", 2) == 3, gap=10)
p.space(2)
elif t == "p":
p.line(b["text"], 10.5, gap=2)
p.space(4)
elif t == "kv":
for k, v in b["pairs"]:
kw = _CONTENT_W * 0.32
p.cols([(k, kw, False), (v, _CONTENT_W - kw, False)], [0, kw], 10, gray=False)
elif t == "party":
half = _CONTENT_W / 2 - 8
left, right = b["left"], b["right"]
p.space(4)
if left["title"] or right["title"]:
p.cols([(left["title"].upper(), half, False), (right["title"].upper(), half, False)],
[0, _CONTENT_W / 2 + 8], 8.5, bold=True, gray=True)
for i in range(max(len(left["lines"]), len(right["lines"]))):
lt = left["lines"][i] if i < len(left["lines"]) else ""
rt = right["lines"][i] if i < len(right["lines"]) else ""
p.cols([(lt, half, False), (rt, half, False)], [0, _CONTENT_W / 2 + 8], 10)
p.space(8)
elif t == "table":
_builtin_table(p, b)
elif t == "ul":
for it in b["items"]:
for j, ln in enumerate(_wrap(it, 10.5, _CONTENT_W - 16)):
p.line(("" if j == 0 else " ") + ln, 10.5, indent=8, leading=1.35)
p.space(4)
elif t == "hr":
p.hline()
elif t == "sp":
p.space(int(b.get("h", 12)))
elif t == "sign":
p.space(28)
p.hline(weight=0.9, gray=False)
p.line(b["label"], 9, gray=True)
return _assemble_pdf(p.pages, ir.get("title") or "Document")
def _builtin_table(p: _PDFPager, b: dict) -> None:
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
nums = _num_cols(b)
n = len(headers)
# First column takes remaining width; numeric/short columns get a fixed slice.
fixed = min(_CONTENT_W * 0.22, 120)
other = fixed if n > 1 else _CONTENT_W
first = _CONTENT_W - other * (n - 1)
widths = [first] + [other] * (n - 1)
xs, acc = [], 0.0
for w in widths:
xs.append(acc)
acc += w
cells = [(h, widths[i] - 8, i in nums) for i, h in enumerate(headers)]
p.space(6)
p.cols(cells, xs, 9.5, bold=True, gap=2)
p.hline(weight=1.2, gray=False)
for r in rows:
cells = [((r[i] if i < len(r) else ""), widths[i] - 8, i in nums) for i in range(n)]
p.cols(cells, xs, 9.5)
if foot:
p.hline(weight=1.2, gray=False)
for fr in foot:
cells = [((fr[i] if i < len(fr) else ""), widths[i] - 8, i in nums) for i in range(n)]
p.cols(cells, xs, 9.5, bold=True)
p.space(6)
def _assemble_pdf(pages: list[list[str]], title_text: str) -> bytes:
"""Serialize positioned-text pages into a valid PDF 1.7 with an xref table.
Object numbers are planned up front so every cross-reference is known before a
single byte is written — no insert/renumber games:
1 = Catalog · 2 = Pages · 3 = F1 · 4 = F2 ·
then per page i: content = 5+2i, page = 6+2i.
"""
n_pages = max(1, len(pages))
page_nos = [6 + 2 * i for i in range(n_pages)]
content_nos = [5 + 2 * i for i in range(n_pages)]
objs: dict[int, bytes] = {}
objs[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{no} 0 R" for no in page_nos).encode()
objs[2] = b"<< /Type /Pages /Kids [" + kids + b"] /Count %d >>" % n_pages
objs[3] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
objs[4] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
for i in range(n_pages):
ops = pages[i] if i < len(pages) else []
# cp1252 so WinAnsi glyphs (€, curly quotes, en/em dash) survive as their byte
# values; the fonts declare /WinAnsiEncoding so those bytes map to the right glyph.
stream = ("".join(ops)).encode("cp1252", "replace")
comp = zlib.compress(stream)
objs[content_nos[i]] = (
b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(comp) + comp + b"\nendstream")
objs[page_nos[i]] = (
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] "
b"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>"
% (int(_PAGE_W), int(_PAGE_H), content_nos[i]))
info_no = max(objs) + 1
objs[info_no] = (b"<< /Title (" + _pdf_enc(title_text).encode("cp1252", "replace")
+ b") /Producer (Hanzo Studio) >>")
count = max(objs)
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = [0] * (count + 1)
for i in range(1, count + 1):
offsets[i] = len(out)
out += b"%d 0 obj\n" % i + objs[i] + b"\nendobj\n"
xref_pos = len(out)
out += b"xref\n0 %d\n" % (count + 1)
out += b"0000000000 65535 f \n"
for i in range(1, count + 1):
out += b"%010d 00000 n \n" % offsets[i]
out += b"trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
count + 1, info_no, xref_pos)
return bytes(out)
# ── sheet archetype: XLSX · PDF · DOCX · MD ───────────────────────────────────────
# SheetIR = {"title", "sheets": [{"name", "columns": [{"key","label","width","fmt"}],
# "rows": [dict], "totals": [dict]}]}. fmt ∈ {"money","int","pct","date",None}.
def sheet_ir(title_text: str, sheets: list) -> dict:
return {"title": title_text or "Spreadsheet", "sheets": sheets}
def _render_sheet(ir: dict, fmt: str) -> bytes:
if fmt == "xlsx":
return _sheet_xlsx(ir)
# Secondary formats reuse the doc archetype by turning each sheet into a table block.
d = _sheet_as_doc(ir)
return _render_doc(d, fmt)
_XLSX_FMT = {"money": "#,##0.00", "int": "#,##0", "pct": "0.0%", "date": "yyyy-mm-dd"}
def _xlsx_safe(v):
"""OWASP formula-injection guard (CWE-1236). openpyxl turns any string cell
starting with '=' into a LIVE formula; '+','-','@','\\t','\\r' are the other
DDE/HYPERLINK triggers. These XLSX outputs (budgets, expense reports) are made
to be shared, so a text cell that begins with a trigger char is coerced to text
with a leading apostrophe — inert in Excel/Sheets, invisible to the reader.
Numeric cells (int/float) are untouched: openpyxl only weaponizes strings."""
if isinstance(v, str) and v[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + v
return v
def _sheet_xlsx(ir: dict) -> bytes:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
wb = Workbook()
wb.remove(wb.active)
ink = "16171A"
head_fill = PatternFill("solid", fgColor="F0F1F3")
head_font = Font(name="Helvetica", bold=True, color=ink, size=10)
base_font = Font(name="Helvetica", color=ink, size=10)
bot = Border(bottom=Side(style="thin", color="16171A"))
thin = Border(bottom=Side(style="hair", color="D9DBDF"))
for sh in ir.get("sheets", []):
ws = wb.create_sheet(title=(sh.get("name") or "Sheet")[:31])
cols = sh.get("columns", [])
for ci, col in enumerate(cols, start=1):
c = ws.cell(row=1, column=ci, value=col.get("label", col.get("key", "")))
c.font = head_font
c.fill = head_fill
c.border = bot
c.alignment = Alignment(horizontal="right" if col.get("fmt") in ("money", "int", "pct") else "left")
ws.column_dimensions[c.column_letter].width = col.get("width", 18)
r = 2
for row in sh.get("rows", []):
for ci, col in enumerate(cols, start=1):
v = row.get(col["key"])
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
c.font = base_font
c.border = thin
nf = _XLSX_FMT.get(col.get("fmt"))
if nf:
c.number_format = nf
r += 1
for total in sh.get("totals", []):
for ci, col in enumerate(cols, start=1):
v = total.get(col["key"])
c = ws.cell(row=r, column=ci, value=_xlsx_safe(v))
c.font = Font(name="Helvetica", bold=True, color=ink, size=10)
c.border = bot
nf = _XLSX_FMT.get(col.get("fmt"))
if nf and isinstance(v, (int, float)):
c.number_format = nf
r += 1
ws.freeze_panes = "A2"
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _fmt_cell(v: Any, fmt: str | None) -> str:
if v is None or v == "":
return ""
if fmt == "money" and isinstance(v, (int, float)):
return f"{v:,.2f}"
if fmt == "int" and isinstance(v, (int, float)):
return f"{int(v):,}"
if fmt == "pct" and isinstance(v, (int, float)):
return f"{v * 100:.1f}%"
return str(v)
def _sheet_as_doc(ir: dict) -> dict:
blocks = [title(ir.get("title") or "Spreadsheet")]
for sh in ir.get("sheets", []):
cols = sh.get("columns", [])
if len(ir.get("sheets", [])) > 1:
blocks.append(heading(sh.get("name") or "Sheet", 2))
headers = [c.get("label", c.get("key", "")) for c in cols]
align = ["num" if c.get("fmt") in ("money", "int", "pct") else "" for c in cols]
rows = [[_fmt_cell(row.get(c["key"]), c.get("fmt")) for c in cols] for row in sh.get("rows", [])]
foot = [[_fmt_cell(t.get(c["key"]), c.get("fmt")) for c in cols] for t in sh.get("totals", [])]
blocks.append(table(headers, rows, align=align, foot=foot))
return doc_ir(ir.get("title") or "Spreadsheet", blocks)
# ── deck archetype: PPTX · PDF · MD ───────────────────────────────────────────────
# DeckIR = {"title", "slides": [{"layout": "title"|"bullets"|"section",
# "title", "subtitle", "bullets": [str], "notes"}]}.
def deck_ir(title_text: str, slides: list) -> dict:
return {"title": title_text or "Presentation", "slides": slides}
def _render_deck(ir: dict, fmt: str) -> bytes:
if fmt == "pptx":
return _deck_pptx(ir)
return _render_doc(_deck_as_doc(ir), fmt)
def _deck_pptx(ir: dict) -> bytes:
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
INK = RGBColor(0x16, 0x17, 0x1A)
DIM = RGBColor(0x6B, 0x6F, 0x76)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
def _tb(slide, left, top, width, height):
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
box.text_frame.word_wrap = True
return box.text_frame
for sd in ir.get("slides", []):
slide = prs.slides.add_slide(blank)
layout = sd.get("layout", "bullets")
if layout in ("title", "section"):
tf = _tb(slide, 0.9, 2.6 if layout == "title" else 2.9, 11.5, 2.0)
para0 = tf.paragraphs[0]
run = para0.add_run()
run.text = sd.get("title", "")
run.font.size = Pt(40 if layout == "title" else 30)
run.font.bold = True
run.font.color.rgb = INK
if sd.get("subtitle"):
sp = tf.add_paragraph()
sr = sp.add_run()
sr.text = sd["subtitle"]
sr.font.size = Pt(18)
sr.font.color.rgb = DIM
else:
tf = _tb(slide, 0.9, 0.7, 11.5, 1.1)
run = tf.paragraphs[0].add_run()
run.text = sd.get("title", "")
run.font.size = Pt(28)
run.font.bold = True
run.font.color.rgb = INK
body = _tb(slide, 0.9, 2.0, 11.5, 4.8)
first = True
for b in sd.get("bullets", []):
p = body.paragraphs[0] if first else body.add_paragraph()
first = False
r = p.add_run()
r.text = str(b)
r.font.size = Pt(18)
r.font.color.rgb = INK
p.space_after = Pt(8)
if sd.get("notes"):
slide.notes_slide.notes_text_frame.text = sd["notes"]
buf = io.BytesIO()
prs.save(buf)
return buf.getvalue()
def _deck_as_doc(ir: dict) -> dict:
blocks = [title(ir.get("title") or "Presentation")]
for i, sd in enumerate(ir.get("slides", []), start=1):
blocks.append(heading(f"{i}. {sd.get('title', '')}".strip(". "), 2))
if sd.get("subtitle"):
blocks.append(para(sd["subtitle"]))
if sd.get("bullets"):
blocks.append(bullets(sd["bullets"]))
if sd.get("notes"):
blocks.append(para(sd["notes"]))
return doc_ir(ir.get("title") or "Presentation", blocks)
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
"""Gateway auth — the ONE fail-closed allowlist for handing a browser-edge bearer to
the Hanzo AI gateway (api.hanzo.ai/v1/chat/completions).
The problem it closes: the browser session is a *symmetric* HS256 hanzo.id token. The
gateway verifies bearers against JWKS and refuses HS256 ("unsupported signing method:
HS256") — so forwarding the raw session token produces a 502, not a chat. Secret keys
(``sk-``/``hk-``/``rk-``) must never leave the server, and a publishable ``pk-`` key is
the one client-safe family the gateway accepts from the edge.
So the rule, fail-closed, in ONE place every gateway caller reuses:
1. a server-side gateway key (configured, never in page source) wins;
2. else the caller's own token — forwarded ONLY when the gateway can verify it: a
publishable ``pk-`` key, or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA);
3. an HS256 session token or any secret key is refused → "" (the caller then degrades
honestly instead of emitting a 502).
This is the same shape the office-documents AI-fill proved (documents.py); it lives here
so copilot and any future gateway caller share exactly one implementation.
"""
from __future__ import annotations
import base64
import json
import os
# Secret key families — must never reach the gateway from the browser edge, nor appear
# in page source. Matched case-insensitively on the bare token.
_SECRET_PREFIXES = ("sk-", "sk_", "hk-", "hk_", "rk-", "rk_")
def jwt_alg(tok: str) -> str:
"""The ``alg`` from a JWT header, or "" if not a decodable JWT. Distinguishes a
JWKS-verifiable asymmetric token (gateway-acceptable) from an HS256 session token."""
try:
h = tok.split(".", 1)[0]
h += "=" * (-len(h) % 4)
alg = json.loads(base64.urlsafe_b64decode(h.encode())).get("alg", "")
# A crafted header can make alg a non-string (null/list/number/dict); the
# contract is "the alg string or ''", so a non-string is not a usable alg
# (else `alg[:2]`/`alg.startswith` below would raise → a self-inflicted 500).
return alg if isinstance(alg, str) else ""
except Exception:
return ""
def forwardable_bearer(request) -> str:
"""The caller's own token to forward to the gateway, or "" — fail-closed.
Reuses the app's one token path (``gpu_dispatch._bearer``: Authorization header or the
browser session cookie), then applies the strict allowlist:
* a publishable ``pk-`` key — allowed;
* a JWT — allowed ONLY when asymmetric/JWKS-verifiable (RS/ES/PS/EdDSA); a symmetric
HS256 hanzo.id session token is refused (the gateway rejects it, so forwarding it
only produces log noise and a 502);
* a secret ``sk-``/``hk-``/``rk-`` key — refused outright.
So a secret can never reach the gateway from the browser edge and none is in page
source. Returns a full ``Bearer <tok>`` value, or "".
"""
from middleware.gpu_dispatch import _bearer # lazy: keeps this module import light
raw = _bearer(request)
if not raw:
return ""
tok = raw[7:].strip() if raw[:7].lower() == "bearer " else raw.strip()
if not tok or tok.lower().startswith(_SECRET_PREFIXES):
return ""
if tok.startswith(("pk-", "pk_")):
return "Bearer " + tok
if tok.count(".") == 2:
# Exact JWS asymmetric set — a prefix match ("RS"/"Ed") also admitted non-JWS
# names like RSA-OAEP / "Edwina"; `alg in <tuple>` is crash-safe for any type.
if jwt_alg(tok) in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
"PS256", "PS384", "PS512", "EdDSA"):
return "Bearer " + tok
return ""
def server_token(env_names) -> str:
"""The first configured server-side gateway key from ``env_names``, as a full
``Bearer <tok>`` value, or "". Server-side only — never emitted to page source."""
for env in env_names:
v = os.environ.get(env, "").strip()
if v:
return v if v[:7].lower() == "bearer " else "Bearer " + v
return ""
def ai_bearer(request, *, server_env) -> str:
"""The bearer to send to the gateway for this request, or "" when none is usable.
A server-side gateway key wins (the configured, working path); otherwise the caller's
own gateway-verifiable token via the fail-closed allowlist. Returns ``Bearer <tok>``
or "". Callers that need the bare token can strip the 7-char ``Bearer `` prefix."""
return server_token(server_env) or forwardable_bearer(request)
+46 -38
View File
@@ -6,50 +6,58 @@
flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);
font:400 14px/1.4 Inter,system-ui,-apple-system,sans-serif}
#hzbar button,#hzbar a{font:inherit}
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#131317;color:#ececf1;border:1px solid #232329;
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
.hz-pill:hover{border-color:#3a3a48}
.hz-pill:hover{border-color:#333333}
.hz-pill .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;flex:0 0 auto}
.hz-pill.on .hz-dot{background:#3ddc84}
.hz-pill.hz-chat{background:#f4f4f6;color:#111;border-color:#f4f4f6;font-weight:600}
.hz-topup{color:#6aa5ff;text-decoration:none;font-size:.82em;border-left:1px solid #232329;padding-left:7px}
.hz-pill.hz-chat{background:#ffffff;color:#111;border-color:#ffffff;font-weight:600}
.hz-topup{color:#888888;text-decoration:none;font-size:.82em;border-left:1px solid #1f1f1f;padding-left:7px}
.hz-topup:hover{text-decoration:underline}
/* right-docked chat sidebar (both views) */
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0e0e12;color:#ececf1;
border-left:1px solid #232329;z-index:2147483001;display:flex;flex-direction:column;
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0a0a0a;color:#ededed;
border-left:1px solid #1f1f1f;z-index:2147483001;display:flex;flex-direction:column;
transform:translateX(100%);transition:transform .22s ease;box-shadow:-14px 0 44px #0007}
#hzchat.on{transform:translateX(0)}
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #232329;flex-wrap:wrap}
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#26262e;display:flex;align-items:center;
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #1f1f1f;flex-wrap:wrap}
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#242424;display:flex;align-items:center;
justify-content:center;font-size:.72rem;font-weight:600;flex:0 0 auto}
#hzchat .hz-h .hz-who{font-size:.82rem;min-width:0}
#hzchat .hz-h .hz-who b{display:block;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#hzchat .hz-h .hz-who span{color:#8a8a95;font-size:.72rem}
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#8a8a95;font-size:1.3rem;line-height:1;background:none;border:none}
#hzchat .hz-h .hz-who span{color:#888888;font-size:.72rem}
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#888888;font-size:1.3rem;line-height:1;background:none;border:none}
#hzmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.hz-m{max-width:88%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.hz-m.u{align-self:flex-end;background:#f4f4f6;color:#111;border-bottom-right-radius:3px}
.hz-m.a{align-self:flex-start;background:#1a1a20;border:1px solid #232329;border-bottom-left-radius:3px}
.hz-m.sys{align-self:center;color:#8a8a95;font-size:.78rem;background:none}
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #232329}
#hzform input{flex:1;background:#0a0a0d;border:1px solid #232329;border-radius:9px;color:#ececf1;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
#hzform button{background:#f4f4f6;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
#hzchat .hz-pw{padding:8px 12px 11px;text-align:center;color:#6a6a75;font-size:.68rem;letter-spacing:.02em;border-top:1px solid #17171c}
#hzchat .hz-pw b{color:#8a8a95;font-weight:600}
.hz-m.a.streaming::after{content:"▍";color:#8a8a95;margin-left:1px;animation:hzblink 1s steps(1) infinite}
.hz-m.u{align-self:flex-end;background:#ffffff;color:#111;border-bottom-right-radius:3px}
.hz-m.a{align-self:flex-start;background:#1a1a1a;border:1px solid #1f1f1f;border-bottom-left-radius:3px}
.hz-m.sys{align-self:center;color:#888888;font-size:.78rem;background:none}
/* chat empty state — assistant identity + one line + 3 tappable starters */
.hz-empty{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;margin:auto 0;padding:8px 6px}
.hz-empty .hz-eav{width:40px;height:40px;border-radius:50%;background:#1a1a1a;border:1px solid #1f1f1f;display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:#ededed}
.hz-empty .hz-etitle{font-size:.95rem;font-weight:600;color:#ededed}
.hz-empty .hz-esub{font-size:.8rem;color:#888888;line-height:1.45;max-width:280px}
.hz-empty .hz-starters{display:flex;flex-direction:column;gap:7px;width:100%;margin-top:8px}
.hz-empty .hz-starter{background:#1a1a1a;color:#ededed;border:1px solid #1f1f1f;border-radius:10px;padding:9px 12px;font:inherit;font-size:.82rem;text-align:left;cursor:pointer}
.hz-empty .hz-starter:hover{border-color:#333333;background:#171717}
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #1f1f1f}
#hzform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
#hzform button{background:#ffffff;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
#hzchat .hz-pw{padding:8px 12px 11px;text-align:center;color:#666666;font-size:.68rem;letter-spacing:.02em;border-top:1px solid #171717}
#hzchat .hz-pw b{color:#888888;font-weight:600}
.hz-m.a.streaming::after{content:"▍";color:#888888;margin-left:1px;animation:hzblink 1s steps(1) infinite}
@keyframes hzblink{50%{opacity:0}}
/* GPUs panel (both views) */
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#131317;
color:#ececf1;border:1px solid #232329;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#1a1a1a;
color:#ededed;border:1px solid #1f1f1f;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
#hzgpus.on{display:block}
#hzgpus h4{font-size:.82rem;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:7px}
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1e1e24;font-size:.8rem}
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1f1f1f;font-size:.8rem}
.hz-node:first-of-type{border-top:none}
.hz-node .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;margin-top:5px;flex:0 0 auto}
.hz-node.on .hz-dot{background:#3ddc84}
.hz-node .hz-nn{flex:1;min-width:0}
.hz-node .hz-nn b{font-weight:500}
.hz-node .hz-nsub{color:#8a8a95;font-size:.72rem;margin-top:2px}
.hz-node .hz-nsub{color:#888888;font-size:.72rem;margin-top:2px}
/* Tablet: a touch narrower sidebar; the pill bar + panels already fit. */
@media (max-width:900px){#hzchat{width:340px}}
/* Phone: chat becomes a full-width bottom sheet (same pattern the old chatpanel
@@ -60,33 +68,33 @@
.hz-pill{padding:7px 11px}
.hz-pill .hz-lbl{display:none} /* icon-only Chat/GPUs → one line, clears content */
#hzchat{top:auto;bottom:0;width:100vw;max-width:100vw;height:85vh;
border-left:none;border-top:1px solid #232329;border-radius:16px 16px 0 0;
border-left:none;border-top:1px solid #1f1f1f;border-radius:16px 16px 0 0;
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
#hzchat.on{transform:translateY(0)}
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
}
/* Developers workbench — the bottom drawer (Queue · Logs · Shell), both views.
Sits UNDER the pill bar's z so the pills stay clickable above it. */
#hzwb{position:fixed;left:0;right:0;bottom:0;height:44vh;min-height:260px;background:#0e0e12;color:#ececf1;
border-top:1px solid #232329;z-index:2147482999;display:none;flex-direction:column;
#hzwb{position:fixed;left:0;right:0;bottom:0;height:44vh;min-height:260px;background:#0a0a0a;color:#ededed;
border-top:1px solid #1f1f1f;z-index:2147482999;display:none;flex-direction:column;
box-shadow:0 -14px 44px #0007;font:400 13px/1.45 Inter,system-ui,-apple-system,sans-serif}
#hzwb.on{display:flex}
.hzwb-h{display:flex;align-items:center;gap:4px;padding:7px 10px;border-bottom:1px solid #232329;flex:0 0 auto}
.hzwb-tab{background:none;border:none;color:#8a8a95;font:inherit;font-weight:600;padding:6px 11px;border-radius:8px;cursor:pointer}
.hzwb-tab:hover{color:#ececf1}
.hzwb-tab.on{background:#1a1a20;color:#ececf1}
.hzwb-h{display:flex;align-items:center;gap:4px;padding:7px 10px;border-bottom:1px solid #1f1f1f;flex:0 0 auto}
.hzwb-tab{background:none;border:none;color:#888888;font:inherit;font-weight:600;padding:6px 11px;border-radius:8px;cursor:pointer}
.hzwb-tab:hover{color:#ededed}
.hzwb-tab.on{background:#1a1a1a;color:#ededed}
.hzwb-sp{flex:1}
.hzwb-ic{background:none;border:none;color:#8a8a95;font:inherit;font-size:1.05rem;line-height:1;padding:5px 9px;cursor:pointer;border-radius:8px}
.hzwb-ic:hover{color:#ececf1;background:#1a1a20}
.hzwb-ic{background:none;border:none;color:#888888;font:inherit;font-size:1.05rem;line-height:1;padding:5px 9px;cursor:pointer;border-radius:8px}
.hzwb-ic:hover{color:#ededed;background:#1a1a1a}
.hzwb-b{flex:1;min-height:0;overflow:auto;padding:12px 14px}
.hzwb-row{padding:2px 0}
.hzwb-dim{color:#8a8a95}
.hzwb-cmd{color:#c9c9d4;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;margin-top:8px}
.hzwb-out{color:#ececf1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;
.hzwb-dim{color:#888888}
.hzwb-cmd{color:#a3a3a3;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;margin-top:8px}
.hzwb-out{color:#ededed;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;
white-space:pre-wrap;word-break:break-word;margin:4px 0 0;padding:0}
.hzwb-out.err{color:#ff7a70}
#hzwbform{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid #232329;flex:0 0 auto}
#hzwbform .hzwb-ps{color:#8a8a95;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
#hzwbform input{flex:1;background:#0a0a0d;border:1px solid #232329;border-radius:9px;color:#ececf1;padding:8px 11px;
#hzwbform{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid #1f1f1f;flex:0 0 auto}
#hzwbform .hzwb-ps{color:#888888;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
#hzwbform input{flex:1;background:#0a0a0a;border:1px solid #1f1f1f;border-radius:9px;color:#ededed;padding:8px 11px;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8rem;outline:none}
@media (max-width:600px){#hzwb{height:70vh}}
+53 -4
View File
@@ -25,7 +25,10 @@
var AI_BASE = (HZ.aiBase || "https://api.hanzo.ai").replace(/\/+$/, "");
var PK = HZ.pk || "";
var USE_STREAM = STUDIO && !!PK; // direct enso streaming on the home; the editor keeps the copilot (graph ops)
function chatModel() { try { return localStorage.hz_model || HZ.model || "enso"; } catch (_) { return HZ.model || "enso"; } }
// The Chat model is always an ASSISTANT model. The composer stores its CREATE model
// separately (hz_create_model), so an image pick can never leak here; and a stale
// hz_model that isn't a chat model is ignored (falls back to the enso assistant).
function chatModel() { try { var m = localStorage.hz_model; if (m && /^(enso|zen5|zen-vl)/.test(m)) return m; } catch (_) {} return HZ.model || "enso"; }
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, attrs, html) {
var e = document.createElement(tag);
@@ -71,18 +74,49 @@
document.body.appendChild(chat);
document.body.appendChild(wb);
wire(); wireWb();
setDev(devOn()); // developer controls (the composer's </>) follow this ONE flag
loadSession(); loadWallet(); loadGpus();
if (!STUDIO) editorBoot();
}
// Developers mode — a persistent, page-spanning flag. `body.hzdev` reveals dev-only
// controls (e.g. the composer's </> request preview) via CSS; opening the Developers
// workbench turns it on. One flag, no per-control JS coupling.
function devOn() { try { return localStorage.hz_dev === "1"; } catch (_) { return false; } }
function setDev(on) { try { if (on) localStorage.hz_dev = "1"; } catch (_) {} if (document.body) document.body.classList.toggle("hzdev", !!on); }
// ── chat: the ONE path — POST /v1/copilot/chat; ops applied via the editor's
// existing applier when present (no second chat mechanism) ────────────────────
var HISTORY = [];
function addMsg(role, text) {
var e = $("hzmsgs").querySelector(".hz-empty"); if (e) e.remove(); // first message clears the welcome
var m = el("div", { class: "hz-m " + role }, esc(text));
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
return m;
}
// Empty state: who the assistant is, one line on what it does, and 3 tappable starters —
// so an opened, empty chat is never a blank box. Editor vs home get fitting starters.
function chatStarters() {
return STUDIO
? ["A poster for a jazz night", "Ideas for a product launch image", "How do I make a logo transparent?"]
: ["What does this workflow do?", "Add a 4x upscale to the output", "Make the image sharper"];
}
function chatEmptyState() {
var box = $("hzmsgs"); if (!box || HISTORY.length || box.querySelector(".hz-empty")) return;
var wrap = el("div", { class: "hz-empty" });
wrap.innerHTML =
'<div class="hz-eav">✦</div><div class="hz-etitle">Hanzo&nbsp;Assistant</div>' +
'<div class="hz-esub">' + (STUDIO
? "Brainstorm ideas, sharpen a prompt, or ask how to make something."
: "Ask me to change your workflow, or how any node works.") + '</div>' +
'<div class="hz-starters">' + chatStarters().map(function (s) {
return '<button class="hz-starter" type="button">' + esc(s) + "</button>";
}).join("") + "</div>";
box.appendChild(wrap);
wrap.querySelectorAll(".hz-starter").forEach(function (b) {
b.onclick = function () { $("hzin").value = ""; send(b.textContent).catch(function () {}); };
});
}
function openChat() { chat.classList.add("on"); if (!HISTORY.length) chatEmptyState(); var i = $("hzin"); if (i) i.focus(); }
async function graphContext() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") return {};
@@ -166,8 +200,11 @@
var payload = Object.assign({ model: chatModel(), messages: HISTORY.slice(-20) }, await graphContext());
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
pending.remove();
var reply = (r.body && r.body.reply) || (r.body && r.body.error) || (r.ok ? "" : "chat failed (" + r.status + ")");
// Only ever render the model's reply. A backend error (e.g. the gateway unreachable)
// becomes ONE friendly line — never the raw error string or JSON in a chat bubble.
var reply = r.body && r.body.reply;
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
else if (!r.ok) { addMsg("sys", "Hanzo AI is unavailable right now — please try again in a moment."); }
var ops = (r.body && r.body.ops) || [];
if (ops.length && window.HanzoCopilot && typeof window.HanzoCopilot.applyOps === "function") {
try { window.HanzoCopilot.applyOps(ops); } catch (e) { addMsg("sys", "couldn't apply changes: " + e.message); }
@@ -283,7 +320,7 @@
}
}
function wireWb() {
devBtn.onclick = function () { wb.classList.contains("on") ? wbClose() : wbOpen(); };
devBtn.onclick = function () { setDev(true); wb.classList.contains("on") ? wbClose() : wbOpen(); };
$("hzwbx").onclick = wbClose;
$("hzwbre").onclick = function () { wbRender(); };
wb.querySelectorAll(".hzwb-tab").forEach(function (b) {
@@ -298,7 +335,7 @@
}
function wire() {
chatBtn.onclick = function () { chat.classList.toggle("on"); if (chat.classList.contains("on")) $("hzin").focus(); };
chatBtn.onclick = function () { if (chat.classList.contains("on")) chat.classList.remove("on"); else openChat(); };
$("hzclose").onclick = function () { chat.classList.remove("on"); };
$("hzform").onsubmit = function (e) {
e.preventDefault();
@@ -400,6 +437,18 @@
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
}
// The ONE public chat entry, so other surfaces (the composer's assistant-intent ↑) reuse
// this exact sidebar + transport instead of building a second chat. `send` opens the
// panel and sends; an optional assistant model is remembered for the sidebar.
window.HanzoChat = {
open: openChat,
send: function (text, model) {
if (model) { try { localStorage.hz_model = model; } catch (_) {} }
openChat();
if (text && text.trim()) send(text.trim()).catch(function () {});
},
};
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount);
})();
+293 -40
View File
@@ -15,8 +15,8 @@ body{background:var(--bg);color:var(--txt);font:400 1rem/1.5 Inter,system-ui,-ap
.serif{font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif}
a{color:inherit}
header{display:flex;align-items:center;gap:12px;padding:14px 26px;border-bottom:1px solid var(--line);
position:fixed;top:0;left:0;right:0;background:rgba(10,10,11,.82);backdrop-filter:blur(14px);z-index:30;
transition:transform .28s ease,opacity .28s ease}
position:fixed;top:0;left:0;right:0;background:var(--bg);z-index:30;
transition:transform .28s ease,opacity .28s ease} /* OPAQUE: content scrolls under the fixed header, never shows through */
/* immersive: header hides; a top hover-zone or mouse-near-top reveals it */
body.immersive header{transform:translateY(-100%);opacity:0;border-bottom-color:transparent}
body.immersive header.reveal{transform:translateY(0);opacity:1}
@@ -51,7 +51,10 @@ body.immersive{padding-top:0}
@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
#livebar .lb-q{color:var(--dim)} #livebar .lb-eta{color:var(--warn)} #livebar .sp{flex:1}
#livebar a.lb-more{color:var(--dim);text-decoration:none;font-size:.8rem;border:1px solid var(--line);border-radius:7px;padding:3px 10px;cursor:pointer}
#livebar.idle{opacity:.72}
/* Idle dims only the TEXT — the sticky bar itself stays OPAQUE so gallery content never
shows through it on scroll. A full-bleed backing covers the (empty) side gutters too. */
#livebar.idle .lb-q{opacity:.85}
#livebar::before{content:"";position:absolute;left:-50vw;right:-50vw;top:-8px;bottom:-8px;background:var(--bg);z-index:-1}
.hero{text-align:center;font-size:3rem;font-weight:500;margin:56px 0 26px;letter-spacing:-.01em}
/* prompt box */
.prompt{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 16px 12px;max-width:820px;margin:0 auto}
@@ -62,6 +65,9 @@ body.immersive{padding-top:0}
.pchip .k{color:var(--dim);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em}
.pchip .v{color:var(--txt);font-weight:500}
.pico{border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2);cursor:pointer;color:var(--dim);font-size:.9rem}
/* developer-only controls (the </> request preview) — revealed only in Developers mode
(the shell's Developers pill sets body.hzdev). Hidden for everyone else. */
body:not(.hzdev) .pico.dev{display:none}
.psend{margin-left:auto;width:38px;height:38px;border-radius:10px;border:none;background:var(--brand);color:#111;font-size:1.1rem;cursor:pointer}
/* templates */
.sec-l{color:var(--dim);font-size:.82rem;margin:40px 0 12px}
@@ -126,6 +132,10 @@ dialog::backdrop{background:rgba(0,0,0,.6)} dialog textarea{width:100%;backgroun
.vote.up.on{border-color:#3fb950;color:#3fb950;background:rgba(63,185,80,.12)}
.vote.down.on{border-color:#f85149;color:#f85149;background:rgba(248,81,73,.12)}
.vote.note.has{border-color:#4a9eff;color:#8fc3ff}
/* Download — first-class (getting work OUT is the #1 job): a touch more prominent than the
feedback votes, always visible on the tile and in the viewer. */
.vote.dl{border-color:var(--dim);color:var(--txt)} .vote.dl:hover{border-color:var(--brand);color:var(--brand)}
#zrate .vote.dl{white-space:nowrap;font-weight:600}
/* Rating bar on the full-image (zoom) view — same vote semantics as the cards, bigger tap targets for mobile. */
#zrate{position:absolute;bottom:max(22px,calc(env(safe-area-inset-bottom) + 14px));left:50%;transform:translateX(-50%);z-index:2;display:none;gap:8px;align-items:center;background:rgba(12,12,14,.82);border:1px solid var(--line);border-radius:14px;padding:8px 12px;backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px)}
#zrate .vote{font-size:1.05rem;padding:8px 15px;color:#cdd;min-height:44px;display:inline-flex;align-items:center}
@@ -205,6 +215,13 @@ body.immersive #stage{display:block}
.tabs{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
.tabs button{padding:8px 10px;font-size:.84rem}
.search{width:120px} .tabs .sp{display:none}
/* Filters: ONE horizontally-scrollable row instead of ~30 pills wrapping over the gallery. */
.chips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:3px}
.chips .chip{flex:0 0 auto}
/* Scroll affordance: a subtle right-edge fade + hidden scrollbar cue that the tab bar and
filter row scroll horizontally (they overflowed silently before). */
.tabs,.chips{scrollbar-width:none;-webkit-mask-image:linear-gradient(90deg,#000 93%,transparent);mask-image:linear-gradient(90deg,#000 93%,transparent)}
.tabs::-webkit-scrollbar,.chips::-webkit-scrollbar{height:0}
#livebar{flex-wrap:wrap;font-size:.78rem}
#bulkbar{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
@@ -468,6 +485,13 @@ body.immersive #stage{display:block}
.atable .upc{display:none}
.tpick-grid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
}
/* Office documents — describe/fill dialog + generated-doc cards (house monochrome) */
#docdlg{max-width:560px}
#docdlg .docl{display:block;font-size:.7rem;color:var(--dim);margin:10px 0 4px;text-transform:uppercase;letter-spacing:.04em}
#docform{max-height:44vh;overflow:auto;padding-right:4px}
#docform .docfield input,#docform .docfield textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit;font-size:.86rem;min-height:0;margin:0;resize:vertical}
#docform .docfield textarea{line-height:1.45}
#doclib .doc{flex-direction:column;gap:8px}
</style>
<header>
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
@@ -489,10 +513,10 @@ body.immersive #stage{display:block}
<div class="pctl">
<span class="pico" title="Attach a photo" onclick="attachPick()">+</span>
<input id="attachfile" type="file" accept="image/*" multiple hidden>
<span class="pchip" onclick="pickPreset(event)" title="Scope the gallery to a design system"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
<span class="pchip" onclick="laneChip(event)" title="Pick a template — or return to edit / compose"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
<span class="pico" title="Request preview (developer)" onclick="codeView(event)">&lt;/&gt;</span>
<span class="pchip" style="margin-left:auto" onclick="pickModel(event)" title="Choose the Hanzo AI model"><span class="k">Model</span><span class="v" id="model">Enso</span></span>
<span class="pchip" onclick="pickPreset(event)" title="Apply a visual style — the colors, type and mood of one of your design systems"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
<span class="pchip" onclick="laneChip(event)" title="Start from a ready-made recipe — or return to the default edit / compose"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
<span class="pico dev" title="Developer: preview the exact API request the ↑ will send" onclick="codeView(event)">&lt;/&gt;</span>
<span class="pchip" style="margin-left:auto" onclick="pickModel(event)" title="Pick what to create — image, video, or chat with the assistant"><span class="k">Model</span><span class="v" id="model">Zen Image</span></span>
<button class="psend" onclick="create()" title="Create"></button>
</div>
</div>
@@ -502,6 +526,7 @@ body.immersive #stage{display:block}
<div class="tabs">
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
<button data-tab="documents" onclick="tab('documents')" title="Create office documents — PDF, Word, Excel, PowerPoint"><span class="ic-slot" data-ic="doc"></span>Documents</button>
<button data-tab="templates" onclick="tab('templates')">Templates</button>
<button data-tab="designsys" onclick="tab('designsys')">Design systems</button>
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
@@ -579,6 +604,18 @@ body.immersive #stage{display:block}
<div class="sec-l">Start from a kind</div>
<div class="grid" id="tgall"></div>
</section>
<section id="documents" hidden>
<div class="row" style="justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:10px">
<div>
<div class="sec-l" style="margin:0">Create an office document</div>
<div style="color:var(--dim);font-size:.78rem;margin-top:3px">Pick a template, describe it in one line or fill a short form — download a PDF, Word, Excel or PowerPoint. No GPU needed.</div>
</div>
<input class="search" id="docq" placeholder="Search templates…" oninput="renderDocCatalog()">
</div>
<div id="docgall" class="grid" style="margin-top:16px"></div>
<div class="sec-l">Your documents</div>
<div id="doclib" class="grid"></div>
</section>
<section id="designsys" hidden></section>
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
<section id="runs" hidden>
@@ -674,6 +711,7 @@ body.immersive #stage{display:block}
<button class="vote up" id="zrup" title="Good result — 👍 teaches the model" aria-label="Upvote" onclick="zVote(1)">👍</button>
<button class="vote down" id="zrdown" title="Wrong result — 👎 teaches the model" aria-label="Downvote" onclick="zVote(-1)">👎</button>
<button class="vote note" id="zrnote" title="Critique this — the AI sees it, replies, and learns" aria-label="Add a comment" onclick="if(ZPATH)openFeedback(ZPATH)">💬 Comment</button>
<button class="vote dl" id="zrdl" title="Download — PNG, JPG or WebP" aria-label="Download this image" onclick="if(ZPATH)dlMenu(event,ZPATH)">⬇ Download</button>
</div>
</div>
<dialog id="fixdlg">
@@ -768,6 +806,25 @@ body.immersive #stage{display:block}
<div class="row" style="justify-content:flex-end;gap:6px;margin-top:8px"><button type="button" class="mini" onclick="fbdlg.close()">Close</button><button type="submit" class="mini hot">Send</button></div>
</form>
</dialog>
<dialog id="docdlg" aria-label="Create an office document">
<div class="row" style="justify-content:space-between;align-items:flex-start;gap:10px">
<h3 style="font-size:1rem" id="docdtitle">Document</h3>
<button class="mini" onclick="docdlg.close()">Close</button>
</div>
<p id="docddesc" style="color:var(--dim);font-size:.82rem;margin:4px 0 0"></p>
<label class="docl" id="docdesclabel">Describe it in one line</label>
<textarea id="docdesc" rows="2" placeholder="e.g. Invoice for Acme Corp, 3 line items totaling $5,000, net-30"></textarea>
<div class="row" style="justify-content:space-between;gap:8px">
<button class="mini" id="docformtoggle" onclick="docToggleForm()">Fill the form instead ▾</button>
<button class="mini hot" id="docgen" onclick="docGenerate('describe')">Generate with AI</button>
</div>
<div id="docform" hidden style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px"></div>
<div id="docformactions" hidden class="row" style="justify-content:flex-end;margin-top:10px">
<button class="mini hot" id="docformgo" onclick="docGenerate('form')">Generate document</button>
</div>
<div id="docderr" class="dlgerr"></div>
<div id="docresult" hidden style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px"></div>
</dialog>
<div id="live" class="sr-only" aria-live="polite" role="status"></div>
<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="2a8e455c-2b24-481b-860b-2499911ce2c2"></script>
<script type="module" src="https://unpkg.com/@google/model-viewer@3.5.0/dist/model-viewer.min.js"></script>
@@ -789,10 +846,10 @@ const TEMPLATES=[
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'megaphone', wf:'social', grp:'Marketing'},
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'mail', wf:'email', grp:'Marketing'},
{k:'deck', t:'Deck', d:'Sales · investor', ic:'presentation', wf:'deck', grp:'Marketing'},
// ── Documents ──
{k:'doc', t:'Document', d:'Word · guide', ic:'doc', wf:'doc', grp:'Documents'},
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'table', wf:'sheet', grp:'Documents'},
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'grad', wf:'paper', grp:'Documents'},
// ── Office documents (PDF · Word · Excel · PowerPoint) — the non-GPU document subsystem.
// One discovery card that opens the Documents tab; the real, searchable catalog of
// 22 templates is server-driven (GET /v1/documents). ──
{k:'office', t:'Office documents', d:'PDF · Word · Excel · deck', ic:'doc', grp:'Documents', office:true},
// ── Web ──
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'monitor', wf:'site', grp:'Web'},
];
@@ -868,14 +925,44 @@ const isVideo=p=>/\.(mp4|webm)$/i.test(p||'');
const is3d=p=>/\.glb$/i.test(p||'');
// The output-node in a graph, any medium — used to title runs by their real prefix.
const saver=g=>Object.values(g||{}).find(n=>n&&['SaveImage','SaveVideo','SaveWEBM'].includes(n.class_type));
function tcard(x){return `<button type="button" class="tcard" role="listitem" aria-label="${esc(x.t+' — '+x.d)}" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><span class="ic">${icon(x.ic)}</span><span class="tl">${esc(x.t)}</span><span class="td">${esc(x.d)}</span></button>`;}
// ── Download: getting work OUT is the #1 job — a visible control on every tile AND in the
// viewer, with a format choice. PNG/original downloads the file as-is; JPG/WebP convert
// client-side via <canvas> (same-origin, no re-render, no server round trip). ──────────
function _trigDownload(url,name){const a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();}
const _baseName=p=>String(p||'image').split('/').pop().replace(/\.[a-z0-9]+$/i,'')||'image';
async function downloadImage(path,fmt){
const src='/v1/library/file?path='+encodeURIComponent(path);
try{
if(fmt!=='jpg'&&fmt!=='jpeg'&&fmt!=='webp'){ // original bytes, no re-encode (PNG / video / 3D)
const b=await (await fetch(src)).blob(); const u=URL.createObjectURL(b);
_trigDownload(u,_baseName(path)+'.'+((path.split('.').pop()||'png').toLowerCase())); setTimeout(()=>URL.revokeObjectURL(u),5000); toast('Downloaded'); return;
}
const mime=fmt==='webp'?'image/webp':'image/jpeg';
const img=new Image(); img.crossOrigin='anonymous';
await new Promise((res,rej)=>{img.onload=res;img.onerror=()=>rej(new Error('load'));img.src=src;});
const c=document.createElement('canvas'); c.width=img.naturalWidth; c.height=img.naturalHeight;
const cx=c.getContext('2d'); if(mime==='image/jpeg'){cx.fillStyle='#fff';cx.fillRect(0,0,c.width,c.height);} cx.drawImage(img,0,0);
c.toBlob(b=>{ if(!b){toast('Couldnt convert — try PNG');return;} const u=URL.createObjectURL(b);
_trigDownload(u,_baseName(path)+'.'+(fmt==='webp'?'webp':'jpg')); setTimeout(()=>URL.revokeObjectURL(u),5000); toast('Downloaded '+(fmt==='webp'?'WebP':'JPG')); },mime,0.92);
}catch(e){ toast('Download failed — try again'); }
}
// Format chooser (the SAME monochrome popover as the composer pills). Images → PNG/JPG/WebP;
// video/3D → one download of the original file (no format menu).
function dlMenu(ev,path){ ev.stopPropagation(); const a=ev.currentTarget;
if(isVideo(path)||is3d(path)){ downloadImage(path,'orig'); return; }
openMenu(a,'Download',[{items:[
{v:'png',t:'PNG',d:'Original quality · transparent'},
{v:'jpg',t:'JPG',d:'Smaller · easy to share'},
{v:'webp',t:'WebP',d:'Smallest · modern web'}]}],'',v=>downloadImage(path,v)); }
function tcard(x){const oc=x.office?"tab('documents')":`pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')`;return `<button type="button" class="tcard" role="listitem" aria-label="${esc(x.t+' — '+x.d)}" onclick="${oc}"><span class="ic">${icon(x.ic)}</span><span class="tl">${esc(x.t)}</span><span class="td">${esc(x.d)}</span></button>`;}
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
// Leaving for another tab closes an open Stack detail (it lives over Assets).
if(CURSTACK){CURSTACK=null;if(SELMODE){SELMODE=false;SEL.clear();updateBulk();}}
$('stackview').hidden=true;
const sec=name==='deleted'?'assets':name; // Archive reuses the assets grid, filtered to archived (status==='deleted')
for(const s of ['assets','templates','pipelines','runs','designsys'])$(s).hidden=s!==sec;
for(const s of ['assets','documents','templates','pipelines','runs','designsys'])$(s).hidden=s!==sec;
if(name==='designsys')renderDesignSystems();
if(name==='documents')loadDocuments();
if(name==='deleted'){FILTER='deleted';}
else if(name==='assets'&&(FILTER==='deleted'||FILTER==='flagged')){FILTER='all';}
if(sec==='assets'){paintChips();render();updateBulk();}
@@ -905,6 +992,125 @@ async function renderTemplate(slug){
toast(r.ok?'Render queued → see Queue & History':'Failed: '+(errMsg(j)||('error '+r.status)));
}
function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeURIComponent(CTX.id); }
// ── Office documents — the non-GPU document pipeline (server catalog + AI-fill + render) ──
// Catalog is server-driven (/v1/documents); generated docs live per-org (/v1/documents/library).
// The describe path is a layered enhancement — the form alone renders a real file, so a
// broken/unauthorized LLM never blocks creating a document (422 needFields → reveal the form).
// AI-fill reuses the SAME token the shipped Chat uses: window.HZ.pk (publishable pk- key),
// sent as the bearer; the backend forwards it (pk- allowlist) or a server key, else falls back.
let DOCCAT=null, DOCLIB=[], DOCTMPL=null, DOCID=null;
const DOCFMT={pdf:'PDF',docx:'Word',xlsx:'Excel',pptx:'PowerPoint',md:'Markdown'};
function docHeaders(){ const h=genHeaders(); try{const pk=window.HZ&&window.HZ.pk; if(pk)h['Authorization']='Bearer '+pk;}catch(_){} return h; }
async function loadDocuments(){
if(!DOCCAT){ try{DOCCAT=await (await fetch('/v1/documents')).json();}catch(_){DOCCAT={categories:[]};} }
renderDocCatalog(); loadDocLib();
}
function docTmpl(id){ for(const c of (DOCCAT&&DOCCAT.categories)||[]) for(const t of c.templates) if(t.id===id) return t; return null; }
function renderDocCatalog(){
const q=(($('docq')&&$('docq').value)||'').toLowerCase();
let html='';
for(const c of (DOCCAT&&DOCCAT.categories)||[]){
const ts=c.templates.filter(t=>!q||(t.name+' '+t.description).toLowerCase().includes(q));
if(!ts.length)continue;
html+=`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${esc(c.name)}</div>`;
html+=ts.map(t=>`<button type="button" class="tcard" onclick="openDoc('${qq(t.id)}')"><span class="ic">${esc(t.icon)}</span><span class="tl">${esc(t.name)}</span><span class="td">${esc(t.description)}</span></button>`).join('');
}
const g=$('docgall'); if(g)g.innerHTML=html||'<div class="empty" style="grid-column:1/-1">No matching templates.</div>';
}
// Build the structured form from the template's typed fields. Complex fields (line items,
// rows, sections, slides) use a friendly "cell | cell" line editor, not raw JSON.
function docCellsToText(f,v){ return (Array.isArray(v)?v:[]).map(r=>f.of.map(s=>(r&&r[s.key]!=null?r[s.key]:'')).join(' | ')).join('\n'); }
function docField(f,val){
const id='df_'+f.key, req=f.required?' *':'';
const lab=`<label class="docl">${esc(f.label)}${req}</label>`;
if(f.of){ const ph=f.of.map(s=>s.label).join(' | ');
return lab+`<textarea id="${id}" rows="4" placeholder="${esc(ph)} (one per line)">${esc(docCellsToText(f,val))}</textarea>`; }
if(f.type==='textarea') return lab+`<textarea id="${id}" rows="3">${esc(val||'')}</textarea>`;
if(f.type==='list') return lab+`<textarea id="${id}" rows="3" placeholder="One per line">${esc(Array.isArray(val)?val.join('\n'):(val||''))}</textarea>`;
const typ=(f.type==='number'||f.type==='money')?'number':(f.type==='email'?'email':'text');
return lab+`<input id="${id}" type="${typ}" value="${esc(val==null?'':val)}">`;
}
function docBuildForm(t,values){ values=values||{}; $('docform').innerHTML=t.fields.map(f=>`<div class="docfield">${docField(f,values[f.key])}</div>`).join(''); }
function docReadForm(){
const out={};
for(const f of DOCTMPL.fields){ const el=$('df_'+f.key); if(!el)continue; const raw=el.value;
if(f.of){ out[f.key]=raw.split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{const c=l.split('|').map(x=>x.trim());const o={};f.of.forEach((s,i)=>o[s.key]=c[i]||'');return o;}); }
else if(f.type==='list'){ out[f.key]=raw.split('\n').map(x=>x.trim()).filter(Boolean); }
else { out[f.key]=raw; } }
return out;
}
function docResetDialog(){ $('docderr').style.display='none'; $('docresult').hidden=true; $('docresult').innerHTML='';
$('docform').hidden=true; $('docformactions').hidden=true; $('docformtoggle').style.display=''; $('docformtoggle').textContent='Fill the form instead ▾'; }
function openDoc(id){
const t=docTmpl(id); if(!t){toast('Template unavailable');return;}
DOCTMPL=t; DOCID=null;
$('docdtitle').textContent=(t.icon?t.icon+' ':'')+t.name;
$('docddesc').textContent=t.description;
$('docdesc').value=''; $('docdesclabel').style.display=''; $('docdesc').style.display=''; $('docgen').style.display='';
docResetDialog(); docBuildForm(t,t.sample||{});
$('docdlg').showModal(); setTimeout(()=>$('docdesc').focus(),40);
}
async function editDoc(id){
let doc; try{doc=await (await fetch('/v1/documents/'+encodeURIComponent(id))).json();}catch(_){doc=null;}
if(!doc||!doc.template){toast('Could not open document');return;}
DOCTMPL=doc.template; DOCID=id;
$('docdtitle').textContent=(doc.icon?doc.icon+' ':'')+(doc.title||doc.template.name);
$('docddesc').textContent='Edit the details, then re-generate.';
$('docdesclabel').style.display='none'; $('docdesc').style.display='none';
docResetDialog(); docBuildForm(DOCTMPL,doc.fields||{});
$('docform').hidden=false; $('docformactions').hidden=false; $('docformtoggle').style.display='none';
docShowResult({download:doc.download,formats:doc.formats},true);
$('docdlg').showModal();
}
function docToggleForm(){ const show=$('docform').hidden; $('docform').hidden=!show; $('docformactions').hidden=!show;
$('docformtoggle').textContent=show?'Hide the form ▴':'Fill the form instead ▾'; }
function docShowResult(j,quiet){
const dl=j.download||{}, formats=j.formats||(DOCTMPL&&DOCTMPL.formats)||[];
const btns=formats.map(f=>`<a class="mini hot" href="${dl[f]||('/v1/documents/'+DOCID+'/download?format='+f)}" style="text-decoration:none">⬇ ${DOCFMT[f]||f.toUpperCase()}</a>`).join(' ');
$('docresult').innerHTML=(quiet?'<div style="color:var(--dim);font-size:.8rem;margin-bottom:8px">Download:</div>'
:'<div style="color:var(--ok);font-size:.82rem;margin-bottom:8px">✓ Saved to your Documents — download:</div>')
+`<div class="row" style="gap:6px;flex-wrap:wrap">${btns}</div>`;
$('docresult').hidden=false;
}
async function docGenerate(mode){
if(!DOCTMPL)return;
const btn=mode==='describe'?$('docgen'):$('docformgo');
const body={templateId:DOCTMPL.id};
if(mode==='describe'){ const d=$('docdesc').value.trim(); if(d.length<3){toast('Describe the document first');return;} body.description=d; }
else { body.fields=docReadForm(); }
const err=$('docderr'); err.style.display='none';
const old=btn.textContent; btn.disabled=true; btn.textContent='Generating…';
try{
let r;
if(mode==='form' && DOCID){ r=await fetch('/v1/documents/'+encodeURIComponent(DOCID),{method:'PATCH',headers:docHeaders(),body:JSON.stringify(body)}); }
else { r=await fetch('/v1/documents/generate',{method:'POST',headers:docHeaders(),body:JSON.stringify(body)}); }
const j=await r.json().catch(()=>({}));
if(r.status===422 && j.needFields){ // AI unavailable — reveal the form so the user finishes it (never blocks)
docBuildForm(DOCTMPL,(j.template&&j.template.sample)||DOCTMPL.sample||{});
$('docform').hidden=false; $('docformactions').hidden=false; $('docformtoggle').textContent='Hide the form ▴';
err.textContent=(j.error||'AI fill unavailable')+' — fill the fields below and click Generate document.'; err.style.display='block'; return;
}
if(!r.ok){ err.textContent=errMsg(j)||('Error '+r.status); err.style.display='block'; return; }
if(j.id)DOCID=j.id;
if(j.note){ err.textContent=j.note; err.style.display='block'; }
if(j.fields){ docBuildForm(DOCTMPL,j.fields); }
$('docform').hidden=false; $('docformactions').hidden=false;
if($('docformtoggle').style.display!=='none')$('docformtoggle').textContent='Hide the form ▴';
docShowResult(j); loadDocLib(); track('document_generate',{template:DOCTMPL.id,mode});
}catch(e){ err.textContent='Network error — try again'; err.style.display='block'; }
finally{ btn.disabled=false; btn.textContent=old; }
}
async function loadDocLib(){ let d={documents:[]}; try{d=await (await fetch('/v1/documents/library')).json();}catch(_){}
DOCLIB=d.documents||[]; renderDocLib(); }
function docLibCard(d){
const dl=(d.formats||[]).map(f=>`<a class="mini" href="/v1/documents/${encodeURIComponent(d.id)}/download?format=${f}" style="text-decoration:none" title="Download ${DOCFMT[f]||f}">⬇ ${DOCFMT[f]||f.toUpperCase()}</a>`).join(' ');
const dt=d.createdAt?new Date(d.createdAt*1000).toLocaleDateString():'';
return `<div class="card"><div class="doc" onclick="editDoc('${qq(d.id)}')" style="cursor:pointer" title="Edit"><div style="font-size:1.7rem">${esc(d.icon||'📄')}</div><div style="color:var(--txt);font-size:.82rem;padding:0 8px">${esc(d.title)}</div></div>`
+`<div style="padding:8px"><div class="sub">${esc(d.category||'')}${dt?' · '+dt:''}</div><div class="row" style="gap:5px;margin-top:6px;flex-wrap:wrap">${dl}<button class="mini" onclick="delDoc('${qq(d.id)}')" title="Delete">🗑</button></div></div></div>`;
}
function renderDocLib(){ const el=$('doclib'); if(el)el.innerHTML=DOCLIB.length?DOCLIB.map(docLibCard).join(''):'<div class="empty" style="grid-column:1/-1">No documents yet — create one above.</div>'; }
async function delDoc(id){ if(!await ask('Delete this document? This cannot be undone.'))return; try{await fetch('/v1/documents/'+encodeURIComponent(id),{method:'DELETE',headers:genHeaders()});}catch(_){} loadDocLib(); }
// ── Product usage → insights.hanzo.ai. sendBeacon to the capture endpoint; the
// /v1 prefix is load-bearing (root /e/ is IAM-gated). The key is the project's
// public write token. Fails silent — telemetry must never break the product.
@@ -928,7 +1134,10 @@ function _popover(anchor,node){
closeMenu(); node.classList.add('pmenu'); node.setAttribute('role','menu'); document.body.appendChild(node);
const r=anchor.getBoundingClientRect(), mw=Math.min(node.offsetWidth||260,innerWidth-16);
let left=r.left+scrollX; if(left+mw>scrollX+innerWidth-8)left=scrollX+innerWidth-8-mw;
node.style.left=Math.max(scrollX+8,left)+'px'; node.style.top=(r.bottom+6+scrollY)+'px';
node.style.left=Math.max(scrollX+8,left)+'px';
const mh=node.offsetHeight||0; let top=r.bottom+6; // default: below the anchor…
if(top+mh>innerHeight-8 && r.top-6-mh>8) top=r.top-6-mh; // …flip ABOVE when below would clip the viewport bottom (e.g. the viewer's Download menu)
node.style.top=(top+scrollY)+'px';
anchor.classList.add('on');
const out=e=>{ if(!node.contains(e.target)&&e.target!==anchor&&!anchor.contains(e.target))closeMenu(); };
const key=e=>{ if(e.key==='Escape'){e.preventDefault();closeMenu();anchor.focus&&anchor.focus();} };
@@ -964,34 +1173,50 @@ function pickPreset(ev){ openDesignSystemPicker(ev.currentTarget); }
// Template → the KIND catalog, grouped. Pick sets the lane (pickTemplate); None
// returns to the neutral edit/compose composer (clearLane).
function laneChip(ev){ openTemplatePicker(ev.currentTarget); }
// Model → the REAL Hanzo catalog (api.hanzo.ai/v1/models), curated to the
// Studio-relevant set and shown ONLY where the id truly exists upstream. Choice
// persists (hz_model) and drives the composer + the Chat sidebar model.
let MODEL=(()=>{try{return localStorage.hz_model||'enso'}catch(_){return 'enso'}})(), MODELS=null;
// Model → the creative models the composer PRODUCES with. The composer creates
// (image / video); Chat is the separate assistant surface. Default is an IMAGE model
// so the headline "What should we create?" produces something visible on the first ↑.
// Choice persists as hz_create_model — DECOUPLED from the chat model (hz_model) so
// picking an image model here never breaks the assistant. Picking an Assistant model
// routes ↑ to Chat instead — one clear create-vs-chat intent split.
let MODEL=(()=>{try{return localStorage.hz_create_model||'zen-image'}catch(_){return 'zen-image'}})(), MODELS=null;
const MODEL_SETS=[
{label:'Assistant',ids:['enso','enso-flash','enso-ultra','zen5','zen5-pro','zen5-flash','zen5-mini','zen5-coder','zen-vl']},
{label:'Image', ids:['zen-image','stable-diffusion-3.5-large','openai-gpt-image-2','openai-gpt-image-1.5','openai-gpt-image-1']},
{label:'Image', ids:['zen-image','stable-diffusion-3.5-large','openai-gpt-image-2','openai-gpt-image-1.5']},
{label:'Video', ids:['zen-video','wan2-2-t2v-a14b']},
{label:'Audio', ids:['zen-music','zen-voice','zen-foley']},
{label:'Assistant',ids:['enso','enso-flash','enso-ultra','zen5','zen5-pro','zen5-flash','zen5-mini','zen-vl']},
];
const _MLABEL={'enso':'Enso','enso-flash':'Enso Flash','enso-ultra':'Enso Ultra','zen-vl':'Zen VL','zen-image':'Zen Image',
'zen-video':'Zen Video','zen-music':'Zen Music','zen-voice':'Zen Voice','zen-foley':'Zen Foley',
'stable-diffusion-3.5-large':'Stable Diffusion 3.5 L','wan2-2-t2v-a14b':'Wan 2.2 Video',
'openai-gpt-image-1':'GPT Image 1','openai-gpt-image-1.5':'GPT Image 1.5','openai-gpt-image-2':'GPT Image 2'};
'zen-video':'Zen Video','stable-diffusion-3.5-large':'Stable Diffusion 3.5 L','wan2-2-t2v-a14b':'Wan 2.2 Video',
'openai-gpt-image-1.5':'GPT Image 1.5','openai-gpt-image-2':'GPT Image 2',
'zen5':'Zen5','zen5-pro':'Zen5 Pro','zen5-flash':'Zen5 Flash','zen5-mini':'Zen5 Mini'};
// One plain-language line per model (same voice as the Design-system picker descriptions).
const _MDESC={'zen-image':'Text-to-image — fast, high quality','stable-diffusion-3.5-large':'Text-to-image — detailed, photographic',
'openai-gpt-image-2':'Text-to-image — strong prompt-following','openai-gpt-image-1.5':'Text-to-image — reliable all-rounder',
'zen-video':'Text-to-video — short clips','wan2-2-t2v-a14b':'Text-to-video — higher fidelity',
'enso':'Chat assistant — plan, brainstorm, refine','enso-flash':'Chat — fastest replies','enso-ultra':'Chat — deepest reasoning',
'zen-vl':'Chat — reads images you share','zen5':'Chat — general assistant','zen5-pro':'Chat — stronger reasoning',
'zen5-flash':'Chat — quick answers','zen5-mini':'Chat — lightweight'};
const _VIDEO_MODELS=new Set(['zen-video','wan2-2-t2v-a14b']);
const _CHAT_MODELS=new Set(['enso','enso-flash','enso-ultra','zen-vl','zen5','zen5-pro','zen5-flash','zen5-mini']);
// The composer routes ↑ by the selected model's modality: image → generate an image (or
// edit/compose when photos are attached), video → generate a video, assistant → Chat.
function modelModality(id){ return _VIDEO_MODELS.has(id)?'video':_CHAT_MODELS.has(id)?'assistant':'image'; }
const _CORE_MODELS=new Set(['zen-image','zen-video','enso']); // always offered, even if the gateway list is unreachable
function modelLabel(id){ return _MLABEL[id]||String(id).replace(/[-_]/g,' ').replace(/\b\w/g,c=>c.toUpperCase()); }
async function loadModels(){
if(MODELS)return MODELS;
let ids=new Set();
try{ const r=await fetch('https://api.hanzo.ai/v1/models',{headers:{Accept:'application/json'}}); // public list, no creds
const j=await r.json(); (Array.isArray(j)?j:j.data||[]).forEach(m=>{const id=m&&(m.id||m);if(id)ids.add(id);}); }catch(_){}
MODELS=MODEL_SETS.map(s=>({label:s.label,items:s.ids.filter(id=>ids.size?ids.has(id):id==='enso').map(id=>({v:id,t:modelLabel(id)}))})).filter(g=>g.items.length);
if(!MODELS.length)MODELS=[{label:'Assistant',items:[{v:'enso',t:'Enso'}]}]; // fail-safe
MODELS=MODEL_SETS.map(s=>({label:s.label,items:s.ids.filter(id=>!ids.size||ids.has(id)||_CORE_MODELS.has(id)).map(id=>({v:id,t:modelLabel(id),d:_MDESC[id]||''}))})).filter(g=>g.items.length);
if(!MODELS.length)MODELS=[{label:'Image',items:[{v:'zen-image',t:'Zen Image',d:_MDESC['zen-image']}]}]; // fail-safe: ALWAYS offer image create
return MODELS;
}
function initModel(){ $('model')&&($('model').textContent=modelLabel(MODEL)); loadModels(); }
function pickModel(ev){ const a=ev.currentTarget;
loadModels().then(groups=>openMenu(a,'Model — Hanzo AI',groups,MODEL,v=>{ MODEL=v; try{localStorage.hz_model=v}catch(_){}
$('model').textContent=modelLabel(v); toast('Model: '+modelLabel(v)); })); }
loadModels().then(groups=>openMenu(a,'Model — pick what to create',groups,MODEL,v=>{ MODEL=v; try{localStorage.hz_create_model=v}catch(_){}
$('model').textContent=modelLabel(v);
toast(modelModality(v)==='assistant'?('Chat: '+modelLabel(v)+' — ↑ opens Chat'):('Model: '+modelLabel(v))); })); }
// </> → the developer request preview: the EXACT POST the ↑ will send for the
// current prompt/attachments/lane, from the ONE planner create() uses (no drift).
function codeView(ev){ const p=composerPlan();
@@ -1004,13 +1229,22 @@ function composerPlan(){
const t=$('pin').value.trim();
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
const n=libs.length+ups.length;
const mod=modelModality(MODEL);
// Assistant model + no photos + no template lane → a CHAT intent, handed to the ONE
// chat (the shell sidebar) rather than a render. (With a photo/lane the create lanes win.)
if(mod==='assistant'&&!n&&KIND!=='video'&&KIND!=='model3d'){
if(t.length<2)return{error:'Ask the assistant anything'};
return{kind:'chat',body:{text:t}}; }
if(KIND==='model3d'){ if(n!==1)return{error:'Attach ONE photo with + to turn into 3D'};
return{kind:'model3d',url:'/v1/library/model3d',body:libs.length?{path:libs[0]}:{upload:ups[0]}}; }
if(KIND==='video'){ if(t.length<3)return{error:'Describe the video you want'};
if(KIND==='video'||(mod==='video'&&n<=1)){ if(t.length<3)return{error:'Describe the video you want'};
if(n>1)return{error:'Video takes at most one reference'};
const b={prompt:t}; if(libs.length)b.path=libs[0]; else if(ups.length)b.upload=ups[0];
return{kind:'video',url:'/v1/library/video',body:b}; }
if(!n)return{error:t.length<3?'Describe what to create, then attach a photo with +':'Attach a photo with + to edit or compose it'};
// Text only, no photo → text-to-image: the DEFAULT create. Words in, an image out —
// never the old "attach a photo first" dead end.
if(!n){ if(t.length<3)return{error:'Describe what you want to create'};
return{kind:'image',url:'/v1/library/image',body:{prompt:t}}; }
if(t.length<3)return{error:'Describe what to make from your photo'+(n>1?'s':'')};
if(n===1)return{kind:'fix',url:'/v1/library/fix',body:libs.length?{path:libs[0],instruction:t}:{upload:ups[0],instruction:t}};
return{kind:'compose',url:'/v1/library/compose',body:{paths:libs,uploads:ups,instruction:t}};
@@ -1023,19 +1257,37 @@ function composerPlan(){
// second a no-op so there is no double (billable) dispatch.
async function create(){
const plan=composerPlan();
if(plan.error){ toast(plan.error); return; }
if(plan.error){ toast(plan.error); $('pin').focus(); return; }
// Chat intent → hand the prompt to the ONE chat (the shell sidebar). No render, no gateway
// call here — the sidebar owns the chat transport (and its friendly errors).
if(plan.kind==='chat'){ const t=plan.body.text; $('pin').value='';
if(window.HanzoChat&&typeof window.HanzoChat.send==='function') window.HanzoChat.send(t,MODEL);
else toast('Open Chat (bottom-right) to talk to the assistant'); return; }
const btn=document.querySelector('.psend');
await enqueue(btn,'…',async()=>{
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
const r=await fetch(plan.url,{method:'POST',headers:genHeaders(),body:JSON.stringify(plan.body)});
const j=await r.json().catch(()=>({}));
pulseGenerating(plan); // ALWAYS show a generating state the instant ↑ fires — never a silent no-op
const online=await gpuOnline();
let r,j;
try{ r=await fetch(plan.url,{method:'POST',headers:genHeaders(),body:JSON.stringify(plan.body)}); }
catch(e){ createFailed('network error — check your connection'); return; }
j=await r.json().catch(()=>({}));
if(r.ok){
toast(plan.kind==='model3d'?'3D queued → see Queue & History':plan.kind==='video'?'Video queued → see Queue & History':'Queued → see Queue & History');
const noun=plan.kind==='model3d'?'3D model':plan.kind==='video'?'Video':'Image';
toast(online?(noun+' generating → Queue & History'):(noun+' queued — starts when a GPU connects'));
track(plan.kind,{from:'composer'}); $('pin').value=''; clearTray();
if(plan.kind==='model3d'||plan.kind==='video')clearLane();
} else if(!(await healStale(j))) toast('Failed: '+(errMsg(j)||('error '+r.status)));
updateLivebar().catch(()=>{}); // reflect the new job now, not on the next 5s poll
} else if(!(await healStale(j))) createFailed(errMsg(j)||('error '+r.status));
});
}
// The moment ↑ fires, flip the always-visible live bar to a "preparing" state so create
// always visibly takes hold (the real queue overwrites this on the next updateLivebar()).
function pulseGenerating(plan){ const st=$('lbstatus'),lb=$('livebar'); if(!st||!lb)return; lb.classList.remove('idle');
const noun=plan.kind==='model3d'?'3D model':plan.kind==='video'?'video':'image';
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Preparing your ${noun}…</span>`; }
// A create failure is never silent and never raw JSON: one friendly line, prompt kept for retry.
function createFailed(msg){ const lb=$('livebar'); if(lb)lb.classList.add('idle'); updateLivebar().catch(()=>{});
toast('Couldnt start — '+String(msg).slice(0,120)+' · tap ↑ to retry'); $('pin').focus(); }
async function load(){
// The core library fetch: on a transient failure show a clear retry message instead
// of a silent blank gallery (which reads as "all my assets vanished").
@@ -1116,7 +1368,7 @@ function render(){
const vid=isVideo(a.path);
const m3d=is3d(a.path);
const media=img||vid||m3d; // shares votes / archive / Re-run / Versions
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
const prov=a.prov&&a.prov.workflow?` · ${esc(a.prov.workflow)}`:''; // esc: workflow = an embedded (crafted-upload) filename_prefix
const picked=SEL.has(a.path);
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
const inTray=trayHasLib(a.path);
@@ -1134,12 +1386,13 @@ function render(){
:`<div class="doc">${esc((a.path||'').split('/').pop())}</div>`}
<div class="meta">
<span class="t">${esc((a.path||'').split('/').pop())}</span>
<span class="sub">${a.design||a.kind||''}${prov}</span>
<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':a.status}</span>${a.gate==='skipped'?`<span class="st" style="background:#7a6329;color:#ffd98a" title="Auto-judge could not sample this video — review manually">ungated</span>`:''}</div>
<span class="sub">${esc(a.design||a.kind||'')}${prov}</span>
${((a.status==='deleted'||a.status==='flagged')||a.gate==='skipped')?`<div class="row">${(a.status==='deleted'||a.status==='flagged')?`<span class="st ${a.status}">${a.status==='deleted'?'archived':'flagged'}</span>`:''}${a.gate==='skipped'?`<span class="st" style="background:#7a6329;color:#ffd98a" title="Auto-judge could not sample this video — review manually">ungated</span>`:''}</div>`:''}
${media&&!SELMODE?`<div class="fbrow" onclick="event.stopPropagation()">
<button class="vote up ${(RATINGS[a.path]||{}).vote===1?'on':''}" title="Good result — 👍 teaches the model" onclick="castVote('${pe}',1)">👍</button>
<button class="vote down ${(RATINGS[a.path]||{}).vote===-1?'on':''}" title="Wrong result — 👎 teaches the model" onclick="castVote('${pe}',-1)">👎</button>
<button class="vote note ${(RATINGS[a.path]||{}).notes?'has':''}" title="Critique this — the AI sees it, replies, and learns" onclick="openFeedback('${pe}')">💬</button>
<button class="vote dl" title="Download this ${is3d(a.path)?'3D model':isVideo(a.path)?'video':'image'}" aria-label="Download" onclick="dlMenu(event,'${pe}')"></button>
</div>`:''}
${SELMODE?'':`<div class="row tools">
${review?`<button class="mini" onclick="setSt(${i},'draft')">${showDel?'Recover':'Restore'}</button>${showDel?`<button class="mini hot" onclick="purgeOne('${pe}')" title="Permanently delete — cannot be undone">🗑 Delete forever</button>`:''}`:`
@@ -2041,7 +2294,7 @@ function svCard(path,idx){
:vid?`<div class="im" style="position:relative"><video src="${imgURL({path})}" poster="${thumb}" preload="none" muted playsinline style="width:100%;height:100%;object-fit:cover;display:block" ${SELMODE?'':`onclick="svOpen(${idx})"`}></video><span style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:1.6rem;color:#fff;text-shadow:0 2px 10px #000c;pointer-events:none"></span></div>`
:`<div class="doc">${esc(path.split('/').pop())}</div>`}
<div class="meta"><span class="t">${esc(path.split('/').pop())}</span>
${a?`<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':a.status}</span></div>`:'<span class="sub">unavailable — asset deleted</span>'}
${a?((a.status==='deleted'||a.status==='flagged')?`<div class="row"><span class="st ${a.status}">${a.status==='deleted'?'archived':'flagged'}</span></div>`:''):'<span class="sub">unavailable — asset deleted</span>'}
${SELMODE?'':`<div class="row tools">
<button class="mini" onclick="setCover('${pe}')">Set cover</button>
<button class="mini" onclick="svRemove(['${pe}'])" title="Return to gallery">Remove</button>
+111 -6
View File
@@ -85,6 +85,14 @@ _WAN_NEG = "色调艳丽,过曝,静态,细节模糊不清,字幕,风
_VIDEO_EXT = (".mp4", ".webm")
_MODEL3D_EXT = (".glb",) # Hunyuan3D SaveGLB output
_HY3D_CKPT = "hunyuan_3d_v2.1.safetensors" # bundled model+CLIP-vision+VAE (Comfy-Org repackaged)
# The Z-Image-Turbo text-to-image model set, by the EXACT filenames the shipped
# "Text to Image (Z-Image-Turbo)" blueprint names (unet / lumina2 text encoder / vae) —
# the same locality rule as the Qwen/Wan sets. Turbo → 4 steps, cfg 1.0. This is the
# composer's default "What should we create?" lane: words in, an image out.
_ZIMAGE_UNET = "z_image_turbo_bf16.safetensors"
_ZIMAGE_CLIP = "qwen_3_4b.safetensors"
_ZIMAGE_VAE = "ae.safetensors"
_SAVE_EXT = (".png",) + _VIDEO_EXT + _MODEL3D_EXT # every extension a saver writes
try:
from middleware.gpu_dispatch import SAVE_NODES as _SAVERS # the ONE output-node list
@@ -696,11 +704,14 @@ def _video_graph(prompt: str, prefix: str, *, ref_name: str | None = None,
AND its start_image key are omitted, for pure text-to-video (proof caveat). VAEDecode
CreateVideo(24 fps) SaveVideo(mp4/h264). Pure dict builder, same shape as _fix_graph;
cross-checked against zen-video/zen_wan_workflow.py build_prompt (not imported)."""
w = max(256, min(1280, int(width)))
try:
w = max(256, min(1280, int(width)))
h = max(256, min(1280, int(height)))
n = max(9, min(121, int(length)))
except (TypeError, ValueError):
raise DispatchError("width, height and length must be numbers") # → a clean 400
w -= w % 32 # snap to the 32-px grid (proof caveat)
h = max(256, min(1280, int(height)))
h -= h % 32
n = max(9, min(121, int(length)))
n = ((n - 1) // 4) * 4 + 1 # snap to 4n+1
pos = prompt.strip()
neg = _WAN_NEG
@@ -731,6 +742,46 @@ def _video_graph(prompt: str, prefix: str, *, ref_name: str | None = None,
return g
def _image_graph(prompt: str, prefix: str, *, width: int = 1024, height: int = 1024,
seed: int | None = None, lessons: list[str] | None = None) -> dict:
"""The proven Z-Image-Turbo text-to-image graph, lifted node-for-node from the shipped
"Text to Image (Z-Image-Turbo)" blueprint. UNETLoader(z_image_turbo_bf16)
ModelSamplingAuraFlow(shift 3) KSampler(res_multistep / simple, 4 steps, cfg 1.0,
denoise 1.0) over EmptySD3LatentImage; CLIPLoader(qwen_3_4b, type ``lumina2``) +
VAELoader(ae) feed the positive/negative CLIPTextEncode; VAEDecode SaveImage.
width/height clamp to [256,2048] and snap to the 16-px grid. Turbo means 4 steps at
cfg 1.0 (a negative prompt is inert at cfg 1, but the node is kept for graph symmetry
with the other builders and so lessons can steer via the positive). Pure dict builder,
same shape as _video_graph the text-to-image analogue the composer's default lane
dispatches. Unlike _fix_graph this DOES start from an EmptySD3LatentImage: it is a true
text-to-image create, not an edit of an existing asset."""
try:
w = max(256, min(2048, int(width)))
h = max(256, min(2048, int(height)))
except (TypeError, ValueError):
raise DispatchError("width and height must be numbers") # → a clean 400, not a 500
w -= w % 16
h -= h % 16
pos = prompt.strip()
if lessons:
pos += " Known mistakes on earlier versions — do not repeat: " + "; ".join(lessons) + "."
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": _ZIMAGE_UNET, "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": _ZIMAGE_CLIP, "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": _ZIMAGE_VAE}},
"4": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": pos}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": ""}},
"7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": w, "height": h, "batch_size": 1}},
"8": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "positive": ["5", 0],
"negative": ["6", 0], "latent_image": ["7", 0],
"seed": seed if seed is not None else random.randrange(2**31),
"steps": 4, "cfg": 1.0, "sampler_name": "res_multistep", "scheduler": "simple", "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
def _model3d_graph(ref_name: str, prefix: str, *, seed: int | None = None) -> dict:
"""The proven Hunyuan3D-2.1 image-to-mesh graph, lifted node-for-node from ComfyUI's
own 3d_hunyuan3d-v2.1 template. ImageOnlyCheckpointLoader loads MODEL+CLIP_VISION+VAE
@@ -1154,9 +1205,14 @@ _RECENT_DISPATCH: dict = {} # key -> (ts, prompt_id)
_DEDUP_WINDOW_S = 60
def _dedup_key(org: str, output_prefix: str, prompt: str, refs: list) -> str:
def _dedup_key(org: str, kind: str, prompt: str, refs: list, uploads: list) -> str:
# Identify a render by its STABLE inputs — NOT output_prefix, which every lane
# stamps with a random _job_tag() before dispatch (so keying on it made two
# identical submits always miss → the anti-double-bill guard never fired). This
# keys on what actually defines the render: org, lane, prompt, and its inputs.
import hashlib
raw = "|".join([org, output_prefix or "", (prompt or "").strip(), ",".join(sorted(refs or []))])
raw = "|".join([org, kind or "", (prompt or "").strip(),
",".join(sorted(refs or [])), ",".join(sorted(uploads or []))])
return hashlib.md5(raw.encode()).hexdigest()
@@ -1182,7 +1238,7 @@ async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt
params["lessons"] = lessons # what the user's notes taught THIS job to avoid
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
parents=parents, output_prefix=output_prefix, params=params)
key = _dedup_key(org, output_prefix, prompt, refs)
key = _dedup_key(org, kind, prompt, refs, uploads)
dup = _recent_dispatch(key)
if dup:
return dup # identical render already in flight — reuse it
@@ -1347,6 +1403,34 @@ async def dispatch_video(request: web.Request, server, org: str, prompt: str,
return {"ok": True, "prompt_id": pid, "output_prefix": f"video/{stem}"}
async def dispatch_image(request: web.Request, server, org: str, prompt: str,
width: int | None = None, height: int | None = None,
seed: int | None = None, stack: str | None = None) -> dict:
"""Generate an IMAGE from a text prompt — the proven Z-Image-Turbo text-to-image graph.
This is the composer's default "What should we create?" lane: no reference image (that
is fix/compose), pure text-to-image through the ONE dispatch funnel. Org is the
bearer-token claim (IAM middleware) ONLY an ``org`` field in the body/query is never
read. Like video/fix, a model-less pod surfaces the real DispatchError ("unet_name …
not in []" / "no GPU worker") rather than a silent nothing, so the composer can ALWAYS
show a queued/generating state or an honest error."""
prompt = (prompt or "").strip()
if not 3 <= len(prompt) <= 2000:
raise DispatchError("describe what to create (3-2000 chars)")
root = _library_root(org)
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", " ".join(prompt.split()[:8]))[:48] + f"_{_job_tag()}"
prefix = f"image/{stem}"
lessons = _lessons(org, root, [])
pid = await _dispatch(
request, server, org,
_image_graph(prompt, prefix, width=width or 1024, height=height or 1024,
seed=seed, lessons=lessons),
lessons=lessons, kind="image", prompt=prompt,
refs=[], uploads=[], parents=[], output_prefix=prefix)
if stack:
stacks_store.route_prefix(org, prefix, stack, added_by=_owner_of(request))
return {"ok": True, "prompt_id": pid, "output_prefix": prefix}
async def dispatch_model3d(request: web.Request, server, org: str,
rel: str | None = None, upload: str | None = None,
seed: int | None = None, stack: str | None = None) -> dict:
@@ -1853,6 +1937,12 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
routes, server, org_of=_org_of, owner_of=_owner_of,
workspace_of=_workspace_of, soft_delete_assets=_soft_delete_assets)
# Office documents — the non-GPU pipeline (catalog + AI-fill + renderers) — are
# their own orthogonal subsystem, registered here through the same injected tenancy
# resolvers so the module stays decoupled (identical seam to stacks_store).
from middleware.documents import add_documents_routes
add_documents_routes(routes, server, org_of=_org_of, owner_of=_owner_of)
@routes.get("/studio")
async def studio_home(request: web.Request):
# no-store: the home HTML changes every release; without this, browsers
@@ -2600,6 +2690,21 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/image")
async def image(request: web.Request):
"""Generate an image from {prompt, width?, height?, seed?, stack?} — the proven
Z-Image-Turbo text-to-image graph, the composer's default create lane. Org from the
bearer claim only; a bad request is {"error": } 400, never a silent nothing, so
the composer can always transition to a queued/generating state."""
body = await request.json()
try:
return web.json_response(await dispatch_image(
request, server, _org_of(request), body.get("prompt", ""),
width=body.get("width"), height=body.get("height"),
seed=body.get("seed"), stack=body.get("stack")))
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/v1/library/video")
async def video(request: web.Request):
"""Generate a video from {prompt, path?, upload?, width?, height?, length?, seed?,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.17.24"
version = "0.18.3"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
+9
View File
@@ -26,6 +26,15 @@ comfy-aimdo>=0.2.0
requests
PyJWT[crypto]>=2.7.0
# Office-document renderers (middleware/doc_render.py) — pure-Python, no GPU. WeasyPrint
# gives full-CSS PDFs when its native pango/cairo stack is present (see Dockerfile);
# where it is not (the light CI unit venv), the built-in PDF fallback takes over, so
# every format renders with just these wheels installed.
weasyprint>=63
python-docx>=1.1.0
openpyxl>=3.1.0
python-pptx>=1.0.0
#non essential dependencies:
kornia>=0.7.1
spandrel
+1 -1
View File
@@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.17.3"
__version__ = "0.18.2"
+500
View File
@@ -0,0 +1,500 @@
"""Office documents — the non-GPU document pipeline.
Covers the four concerns end to end:
* catalog every template well-formed, formats within archetype capability,
wire shape JSON-serializable (no build fn leaks).
* render schema -> IR -> real bytes for EVERY template in EVERY declared
format, asserted by magic numbers (%PDF, OOXML zip, markdown).
* AI-fill parse a mocked gateway completion -> coerced, typed fields; the
forwardable-bearer allowlist is fail-closed (JWT/pk- only).
* REST + tenancy generate/library/get/patch/delete/download over a real aiohttp app,
per-org isolation, honest 4xx, correct Content-Type/-Disposition.
Runs without a GPU, without torch, and without the WeasyPrint native stack (the built-in
PDF fallback produces a valid %PDF), so it is safe for the dependency-light CI unit gate.
"""
import importlib.util
import io
import json
import sys
import zipfile
from pathlib import Path
import pytest
# PDF (built-in fallback) and Markdown are pure-stdlib and always render. DOCX/XLSX/PPTX
# need their wheel; when it is absent (a bare env) we SKIP that format instead of failing —
# the canonical gate (requirements.txt installed) exercises every format for real.
_FMT_LIB = {"docx": "docx", "xlsx": "openpyxl", "pptx": "pptx"}
def _fmt_available(fmt: str) -> bool:
lib = _FMT_LIB.get(fmt)
return lib is None or importlib.util.find_spec(lib) is not None
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from aiohttp import web
import folder_paths
from middleware import documents as D
from middleware import doc_render as R
# ── helpers ────────────────────────────────────────────────────────────────────────
def _valid(fmt: str, b: bytes) -> bool:
if fmt == "pdf":
return b[:5] == b"%PDF-" and b"%%EOF" in b[-64:]
if fmt == "md":
try:
return len(b) > 10 and bool(b.decode("utf-8").strip()) # real, non-empty UTF-8 text
except UnicodeDecodeError:
return False
try:
return zipfile.ZipFile(io.BytesIO(b)).testzip() is None
except zipfile.BadZipFile:
return False
def _jwt(alg: str) -> str:
"""A JWT with the given header alg (payload/sig are inert — only the header matters
to the forwardable-bearer allowlist)."""
import base64
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
_OOXML_PART = {"docx": "word/document.xml", "xlsx": "xl/workbook.xml", "pptx": "ppt/presentation.xml"}
# ── catalog ─────────────────────────────────────────────────────────────────────────
def test_catalog_has_all_common_templates():
ids = {t["id"] for t in D.CATALOG}
expected = {
# Documents
"resume", "cover_letter", "business_letter", "memo", "report", "proposal", "sow",
"meeting_notes", "invoice", "receipt", "contract", "press_release", "newsletter",
"one_pager", "certificate",
# Spreadsheets
"budget", "expense_tracker", "timesheet", "project_plan",
# Presentations
"pitch_deck", "business_review", "project_update",
}
assert expected <= ids, f"missing templates: {expected - ids}"
assert len(D.CATALOG) == len(ids), "duplicate template ids"
def test_catalog_invariants():
for t in D.CATALOG:
assert t["category"] in D.CATEGORY_ORDER
assert t["formats"], f"{t['id']} has no formats"
# every template's declared formats are within its archetype's capability
kind, _ir = D.build_ir(t, t["sample"])
assert set(t["formats"]) <= set(R.KIND_FORMATS[kind]), \
f"{t['id']} ({kind}) declares formats outside {R.KIND_FORMATS[kind]}"
# the primary (first) format is the archetype's native format
assert t["formats"][0] == R.KIND_FORMATS[kind][0]
# fields typed and keyed
for f in t["fields"]:
assert f["key"] and f["label"] and f["type"]
def test_public_catalog_is_json_serializable_without_build_fn():
grouped = D.catalog_grouped()
blob = json.dumps({"categories": grouped}) # must not raise (build fn stripped)
assert '"build"' not in blob
names = [g["name"] for g in grouped]
assert names == [c for c in D.CATEGORY_ORDER] # grouped in the canonical order
total = sum(len(g["templates"]) for g in grouped)
assert total == len(D.CATALOG)
# ── render matrix: every template × every declared format ───────────────────────────
@pytest.mark.parametrize("tmpl", D.CATALOG, ids=[t["id"] for t in D.CATALOG])
def test_every_template_renders_every_format(tmpl):
for fmt in tmpl["formats"]:
if not _fmt_available(fmt):
continue # wheel not installed here; the canonical gate covers it
data, title = D.render_document(tmpl, tmpl["sample"], fmt)
assert isinstance(data, (bytes, bytearray)) and data, f"{tmpl['id']}/{fmt} produced no bytes"
assert _valid(fmt, data), f"{tmpl['id']}/{fmt} invalid magic bytes"
if fmt in _OOXML_PART:
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
assert _OOXML_PART[fmt] in names, f"{tmpl['id']}/{fmt} missing {_OOXML_PART[fmt]}"
assert title
def test_pdf_fallback_is_a_valid_pdf_without_weasyprint():
# The built-in writer must stand alone (CI/pods without pango). Assert directly.
ir = R.doc_ir("T", [R.title("Hello"), R.para("World " * 40),
R.table(["A", "B"], [["1", "2"], ["3", "4"]], foot=[["", "sum"]]),
R.bullets(["one", "two"]), R.signature("sig")])
pdf = R._builtin_pdf(ir)
assert pdf[:5] == b"%PDF-" and b"%%EOF" in pdf[-64:]
assert pdf.count(b" obj") == pdf.count(b"endobj") # balanced objects
assert b"/Root 1 0 R" in pdf and b"startxref" in pdf
def test_invoice_math_flows_into_the_document():
inv = D.get_template("invoice")
fields = dict(inv["sample"], tax_rate=10, items=[
{"description": "A", "qty": 2, "unit_price": 100}, # 200
{"description": "B", "qty": 1, "unit_price": 50}, # 50 -> subtotal 250, tax 25, total 275
])
md = D.render_document(inv, fields, "md")[0].decode()
assert "Subtotal" in md and "250.00" in md
assert "275.00" in md # total = subtotal + 10% tax
# ── AI-fill: parse + coercion + fail-closed bearer allowlist ────────────────────────
def test_parse_fill_from_tool_call_coerces_types():
inv = D.get_template("invoice")
completion = {"choices": [{"message": {"tool_calls": [{"function": {
"name": "fill_document",
"arguments": json.dumps({"fields": {
"number": "INV-9", "to_name": "Acme Corp", "tax_rate": "8.5",
"items": [{"description": "Consulting", "qty": "3", "unit_price": "$1,000"}],
"bogus_key": "dropped",
}}),
}}]}}]}
fields = D.parse_fill(completion, inv)
assert fields["number"] == "INV-9"
assert fields["tax_rate"] == 8.5 and isinstance(fields["tax_rate"], float)
assert fields["items"][0] == {"description": "Consulting", "qty": 3.0, "unit_price": 1000.0}
assert "bogus_key" not in fields # unknown keys dropped
assert "from_name" in fields # every declared field present
def test_parse_fill_from_embedded_json_content():
memo = D.get_template("memo")
completion = {"choices": [{"message": {"content":
'Here you go: {"fields": {"subject": "Policy", "body": "Text"}}'}}]}
fields = D.parse_fill(completion, memo)
assert fields["subject"] == "Policy" and fields["body"] == "Text"
def test_parse_fill_tolerates_garbage():
memo = D.get_template("memo")
assert D.parse_fill({}, memo) == {}
assert D.parse_fill({"choices": [{"message": {"content": "no json here"}}]}, memo) == {}
class _Req:
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
def test_forwardable_bearer_is_fail_closed(monkeypatch):
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
rs, hs = _jwt("RS256"), _jwt("HS256")
assert D._ai_token(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
assert D._ai_token(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
assert D._ai_token(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret keys refused
assert D._ai_token(_Req("Bearer hk-secret")) == ""
assert D._ai_token(_Req("Bearer rk-secret")) == ""
assert D._ai_token(_Req(None)) == "" # no token
assert D._ai_token(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
# session-cookie path (what the browser sends) is honored for a verifiable JWT
assert D._ai_token(_Req(None, {"access_token": rs})) == "Bearer " + rs
def test_ai_token_prefers_server_gateway_key(monkeypatch):
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
hs = _jwt("HS256")
# No server key + only an HS256 session token → no usable AI token (fail-closed → form).
assert D._ai_token(_Req("Bearer " + hs)) == ""
# A configured server gateway key (server-side only, never page source) is used and wins.
monkeypatch.setenv("STUDIO_DOC_TOKEN", "hk-server-key")
assert D._ai_token(_Req("Bearer " + hs)) == "Bearer hk-server-key"
# ── REST surface over a real aiohttp app ────────────────────────────────────────────
# Driven on a self-managed event loop so these are plain SYNC tests — no dependency on
# the pytest-asyncio / pytest-aiohttp async-mode configuration (which varies across
# environments). One loop per test; the app + client live and die with the fixture.
import asyncio
from aiohttp.test_utils import TestClient, TestServer
class _Resp:
"""A fully-materialized response (body read eagerly) with sync accessors."""
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self._body = body
def json(self):
return json.loads(self._body)
def read(self):
return self._body
def text(self):
return self._body.decode("utf-8", "replace")
class _SyncClient:
def __init__(self, tc, loop):
self._tc = tc
self._loop = loop
def _request(self, method, url, **kw):
async def go():
r = await self._tc.request(method, url, **kw)
body = await r.read()
return _Resp(r.status, dict(r.headers), body)
return self._loop.run_until_complete(go())
def get(self, url, **kw):
return self._request("GET", url, **kw)
def post(self, url, **kw):
return self._request("POST", url, **kw)
def patch(self, url, **kw):
return self._request("PATCH", url, **kw)
def delete(self, url, **kw):
return self._request("DELETE", url, **kw)
@pytest.fixture
def client(tmp_path, monkeypatch):
"""A live app with the documents routes and a tmp per-org tree. ``X-Test-Org`` header
selects the tenant (the injected org_of), so one client exercises multi-tenancy."""
def outd(org=None):
d = tmp_path / "orgs" / (org or "default") / "output"
d.mkdir(parents=True, exist_ok=True)
return str(d)
monkeypatch.setattr(folder_paths, "get_org_output_directory", outd)
def org_of(request):
return request.headers.get("X-Test-Org", "default")
def owner_of(request):
return request.headers.get("X-Test-User", "")
app = web.Application()
routes = web.RouteTableDef()
D.add_documents_routes(routes, object(), org_of=org_of, owner_of=owner_of)
app.add_routes(routes)
loop = asyncio.new_event_loop()
tc = TestClient(TestServer(app), loop=loop)
loop.run_until_complete(tc.start_server())
try:
yield _SyncClient(tc, loop)
finally:
loop.run_until_complete(tc.close())
loop.close()
def _gen(client, template_id, **body):
body["templateId"] = template_id
return client.post("/v1/documents/generate", json=body)
def test_catalog_endpoint(client):
r = client.get("/v1/documents")
assert r.status == 200
cats = {g["name"] for g in r.json()["categories"]}
assert {"Documents", "Spreadsheets", "Presentations"} <= cats
def test_generate_from_fields_then_download_pdf_and_docx(client):
"""The guarantee: a document renders end-to-end with NO LLM — form fields only."""
inv = D.get_template("invoice")
r = _gen(client, "invoice", fields=inv["sample"])
assert r.status == 200, r.text()
doc = r.json()
assert doc["id"] and doc["title"].startswith("Invoice")
assert set(doc["download"]) == set(inv["formats"])
fmts = [("pdf", "application/pdf")]
if _fmt_available("docx"):
fmts.append(("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
for fmt, want_ct in fmts:
dl = client.get(doc["download"][fmt])
assert dl.status == 200
assert dl.headers["Content-Type"] == want_ct
assert "attachment;" in dl.headers["Content-Disposition"]
assert dl.headers["Content-Disposition"].endswith(f'.{fmt}"')
assert _valid(fmt, dl.read())
def test_generate_each_archetype_native_format(client):
for tid, fmt in (("resume", "pdf"), ("budget", "xlsx"), ("pitch_deck", "pptx")):
if not _fmt_available(fmt):
continue
t = D.get_template(tid)
doc = _gen(client, tid, fields=t["sample"]).json()
dl = client.get(doc["download"][fmt])
assert dl.status == 200
assert dl.headers["Content-Type"] == R.content_type(fmt)
assert _valid(fmt, dl.read())
def test_generate_uses_ai_fill_when_description_given(client, monkeypatch):
# Mock the gateway call: description -> canned fields (no network).
async def fake_ai_fill(request, tmpl, description):
assert "Acme" in description
return D.coerce_fields(tmpl, {"to_name": "Acme Corp", "number": "INV-42",
"items": [{"description": "Work", "qty": 1, "unit_price": 5000}]})
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
doc = _gen(client, "invoice", description="Invoice for Acme Corp, $5,000, net-30").json()
assert doc["fields"]["to_name"] == "Acme Corp"
assert doc["title"] == "Invoice INV-42"
md = client.get(doc["download"]["md"]).read()
assert b"5,000.00" in md and b"Acme Corp" in md
def test_explicit_fields_override_ai(client, monkeypatch):
async def fake_ai_fill(request, tmpl, description):
return D.coerce_fields(tmpl, {"subject": "AI subject", "body": "AI body"})
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
doc = _gen(client, "memo", description="anything", fields={"subject": "Human subject"}).json()
assert doc["fields"]["subject"] == "Human subject" # explicit wins
assert doc["fields"]["body"] == "AI body" # AI fills the gap
def test_generate_fail_closed_without_token_or_fields(client, monkeypatch):
# description given, but no server key and no forwardable token -> AI unavailable, and
# no form fields to fall back to: honest 422 asking for fields, never a blank doc.
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
r = _gen(client, "invoice", description="Invoice for Acme")
assert r.status == 422
j = r.json()
assert j["needFields"] is True and j["template"]["id"] == "invoice"
def test_generate_rejects_unknown_template_and_empty_body(client):
assert _gen(client, "nope", fields={}).status == 404
assert client.post("/v1/documents/generate", json={"templateId": "memo"}).status == 400
def test_library_lists_generated_docs_newest_first(client):
_gen(client, "memo", fields={"subject": "One", "body": "b"})
_gen(client, "resume", fields=D.get_template("resume")["sample"])
docs = client.get("/v1/documents/library").json()["documents"]
assert len(docs) == 2
assert docs[0]["templateId"] == "resume" # newest first
assert {"id", "title", "templateId", "formats", "createdAt"} <= set(docs[0])
def test_get_patch_reflected_in_download(client):
doc_id = _gen(client, "memo", fields={"subject": "Before", "body": "b"}).json()["id"]
got = client.get(f"/v1/documents/{doc_id}").json()
assert got["fields"]["subject"] == "Before"
assert got["template"]["id"] == "memo"
assert client.patch(f"/v1/documents/{doc_id}", json={"fields": {"subject": "After"}}).status == 200
md = client.get(f"/v1/documents/{doc_id}/download?format=md").read()
assert b"After" in md and b"Before" not in md
def test_delete_removes_document(client):
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
assert client.delete(f"/v1/documents/{doc_id}").status == 200
assert client.get(f"/v1/documents/{doc_id}").status == 404
assert client.get("/v1/documents/library").json()["documents"] == []
def test_tenancy_isolation_between_orgs(client):
doc_id = client.post("/v1/documents/generate",
json={"templateId": "memo", "fields": {"subject": "secret"}},
headers={"X-Test-Org": "org-a"}).json()["id"]
# org-b cannot see or download org-a's document
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-b"}).status == 404
assert client.get(f"/v1/documents/{doc_id}/download?format=pdf",
headers={"X-Test-Org": "org-b"}).status == 404
assert client.get("/v1/documents/library", headers={"X-Test-Org": "org-b"}).json()["documents"] == []
# org-a still has it
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-a"}).status == 200
def test_download_rejects_bad_id_and_format(client):
assert client.get("/v1/documents/not-a-real-id/download").status == 404
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
assert client.get(f"/v1/documents/{doc_id}/download?format=xlsx").status == 400 # not in memo formats
# ── RED-review regressions (@e631d35f): XLSX formula injection, C0 control chars, crafted JWT alg ──
def test_xlsx_text_cell_formula_is_neutralized():
# the guard: a formula-trigger text cell becomes inert text; numbers are untouched.
assert R._xlsx_safe("=1+1") == "'=1+1"
assert R._xlsx_safe("+a") == "'+a" and R._xlsx_safe("-b") == "'-b" and R._xlsx_safe("@c") == "'@c"
assert R._xlsx_safe("plain") == "plain" and R._xlsx_safe(42) == 42 and R._xlsx_safe(3.5) == 3.5
if not _fmt_available("xlsx"):
pytest.skip("openpyxl not installed")
import openpyxl
tmpl = next(t for t in D.CATALOG if t["id"] == "budget")
fields = {k: (list(v) if isinstance(v, list) else v) for k, v in tmpl["sample"].items()}
rows = [dict(r) for r in fields.get("rows", [])] or [dict.fromkeys(("category", "planned", "actual"), "")]
first_key = next(iter(rows[0]), "category")
rows[0][first_key] = "=cmd|'/c calc.exe'!A1"
fields["rows"] = rows
data, _ = D.render_document(tmpl, fields, "xlsx")
ws = openpyxl.load_workbook(io.BytesIO(data)).worksheets[0]
cells = [c for row in ws.iter_rows() for c in row]
assert all(c.data_type != "f" for c in cells), "no cell may be a live formula"
hit = [c for c in cells if isinstance(c.value, str) and "calc.exe" in c.value]
assert hit and all(not c.value.startswith("=") for c in hit), "the =-leading value was neutralized to text"
def test_control_chars_stripped_and_ooxml_stays_valid():
# C0 controls (illegal in XML 1.0) are stripped at the coerce boundary; \t \n \r survive.
assert D._coerce("a\x00b\x0b\x1fc", "text") == "abc"
assert D._coerce("keep\ttab\nnl\rcr", "textarea") == "keep\ttab\nnl\rcr"
# end-to-end: a NUL/BEL in a field renders valid docx/pdf/md/xlsx — never a 500 / poison doc.
memo = next(t for t in D.CATALOG if t["id"] == "memo")
mf = dict(memo["sample"])
mf["body"] = "line1\x00\x07line2"
for fmt in [f for f in ("pdf", "docx", "md") if _fmt_available(f)]:
data, _ = D.render_document(memo, mf, fmt)
assert _valid(fmt, data), f"{fmt} must be valid after control-char strip"
if _fmt_available("xlsx"):
b = next(t for t in D.CATALOG if t["id"] == "budget")
bf = {k: (list(v) if isinstance(v, list) else v) for k, v in b["sample"].items()}
rows = [dict(r) for r in bf.get("rows", [])] or [{"category": "", "planned": 0, "actual": 0}]
rows[0][next(iter(rows[0]), "category")] = "x\x00y"
bf["rows"] = rows
assert _valid("xlsx", D.render_document(b, bf, "xlsx")[0])
def test_ai_token_delegates_to_canonical_gateway_auth(monkeypatch):
"""After the decomplect, documents._ai_token routes through the ONE gateway_auth
allowlist (no private copy they used to drift). Prove the fail-closed behavior via
the public _ai_token: a crafted non-string alg must not 500; HS256/secret refused;
pk-/asymmetric forwarded; a server key wins."""
for env in D._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
def jwt_with_alg(alg_value): # a JWT header carrying an arbitrary (maybe non-string) alg
import base64
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
for bad in (None, [], 123, {"x": 1}, True):
assert D._ai_token(_Req("Bearer " + jwt_with_alg(bad))) == "" # crafted alg → no 500
assert D._ai_token(_Req("Bearer " + jwt_with_alg("HS256"))) == "" # HS256 session token refused
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret refused
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer pk-x" # publishable forwarded
assert D._ai_token(_Req("Bearer " + jwt_with_alg("RS256"))) == "Bearer " + jwt_with_alg("RS256")
monkeypatch.setenv("STUDIO_DOC_TOKEN", "sk-server") # server key wins
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer sk-server"
@@ -5,6 +5,7 @@ node types are passed in as a set, so this stays as light as the other
middleware tests. Async paths run via asyncio.run to avoid a plugin dependency.
"""
import asyncio
import base64
import json
from middleware import copilot
@@ -12,6 +13,39 @@ from middleware import copilot
VALID = {"KSampler", "SaveImage", "UpscaleModelLoader", "ImageUpscaleWithModel", "CLIPTextEncode"}
def _jwt(alg: str) -> str:
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
class _Req:
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
# ------------------------------------------------- _resolve_target token allowlist
def test_resolve_target_never_forwards_hs256_session_token(monkeypatch):
"""The regression the whole task turns on: the copilot MUST NOT forward the browser's
HS256 session token (gateway "unsupported signing method: HS256" 502). It forwards
only a gateway-verifiable caller token, and a server key wins."""
for env in copilot._SERVER_TOKEN_ENV:
monkeypatch.delenv(env, raising=False)
hs, rs = _jwt("HS256"), _jwt("RS256")
# HS256 session token (header OR cookie) → NOT forwarded
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == ""
assert copilot._resolve_target(_Req(None, {"access_token": hs}))[2] == ""
# a JWKS-verifiable JWT → forwarded (bare token, no "Bearer " prefix — _post_chat adds it)
assert copilot._resolve_target(_Req("Bearer " + rs))[2] == rs
# a publishable pk- key → forwarded
assert copilot._resolve_target(_Req("Bearer pk-xyz"))[2] == "pk-xyz"
# a secret key → never forwarded
assert copilot._resolve_target(_Req("Bearer sk-secret"))[2] == ""
# a configured server gateway key wins, stripped to the bare token
monkeypatch.setenv("STUDIO_COPILOT_TOKEN", "hk-server")
assert copilot._resolve_target(_Req("Bearer " + hs))[2] == "hk-server"
# --------------------------------------------------------------- validate_ops
def test_valid_ops_pass_through():
ops = [
@@ -0,0 +1,107 @@
"""The ONE fail-closed gateway-bearer allowlist (middleware/gateway_auth.py).
This is the credential seam that keeps Chat + copilot reaching api.hanzo.ai instead of
502-ing on the browser's HS256 session token. The invariants pinned here:
* a symmetric HS256 hanzo.id session token is NEVER forwarded (the gateway rejects it
"unsupported signing method: HS256");
* a secret sk-/hk-/rk- key is refused outright (never leaves the server, never in page
source);
* only a publishable pk- key or an asymmetric/JWKS JWT (RS/ES/PS/EdDSA) is forwarded;
* a configured server-side gateway key wins over any caller token.
"""
import base64
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import gateway_auth as G
def _jwt(alg: str) -> str:
"""A JWT with the given header alg (payload/sig inert — only the header alg matters)."""
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
class _Req:
"""The 2 attributes gpu_dispatch._bearer reads: Authorization header + cookies."""
def __init__(self, auth=None, cookies=None):
self.headers = {"Authorization": auth} if auth else {}
self.cookies = cookies or {}
def test_forwardable_bearer_is_fail_closed():
rs, es, ps, ed, hs = _jwt("RS256"), _jwt("ES256"), _jwt("PS256"), _jwt("EdDSA"), _jwt("HS256")
# asymmetric / JWKS-verifiable JWTs — forwarded
assert G.forwardable_bearer(_Req("Bearer " + rs)) == "Bearer " + rs
assert G.forwardable_bearer(_Req("Bearer " + es)) == "Bearer " + es
assert G.forwardable_bearer(_Req("Bearer " + ps)) == "Bearer " + ps
assert G.forwardable_bearer(_Req("Bearer " + ed)) == "Bearer " + ed
# the HS256 hanzo.id session token — REFUSED (this is the 502 the fix closes)
assert G.forwardable_bearer(_Req("Bearer " + hs)) == ""
# publishable key — allowed
assert G.forwardable_bearer(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
# secret key families — refused outright, case-insensitively
for secret in ("sk-secret", "SK-Secret", "hk-secret", "rk-secret", "sk_secret", "hk_secret"):
assert G.forwardable_bearer(_Req("Bearer " + secret)) == ""
# no token / opaque non-JWT non-pk value
assert G.forwardable_bearer(_Req(None)) == ""
assert G.forwardable_bearer(_Req("Bearer notoken")) == ""
def test_forwardable_bearer_reads_session_cookie():
# The browser sends the session as a cookie, not an Authorization header. A verifiable
# JWT there is honored; an HS256 session cookie is still refused.
rs, hs = _jwt("RS256"), _jwt("HS256")
assert G.forwardable_bearer(_Req(None, {"access_token": rs})) == "Bearer " + rs
assert G.forwardable_bearer(_Req(None, {"hanzo_token": rs})) == "Bearer " + rs
assert G.forwardable_bearer(_Req(None, {"access_token": hs})) == ""
def test_server_token_precedence_and_normalization(monkeypatch):
envs = ("A_TOK", "B_TOK")
for e in envs:
monkeypatch.delenv(e, raising=False)
assert G.server_token(envs) == ""
monkeypatch.setenv("B_TOK", "hk-second")
assert G.server_token(envs) == "Bearer hk-second" # bare value gets a Bearer prefix
monkeypatch.setenv("A_TOK", "Bearer hk-first")
assert G.server_token(envs) == "Bearer hk-first" # first wins; already-prefixed kept as-is
def test_ai_bearer_server_key_wins_else_forwardable(monkeypatch):
envs = ("SRV_TOK",)
monkeypatch.delenv("SRV_TOK", raising=False)
hs, rs = _jwt("HS256"), _jwt("RS256")
# no server key + only an HS256 session → nothing usable (fail-closed → honest degrade)
assert G.ai_bearer(_Req("Bearer " + hs), server_env=envs) == ""
# no server key + a verifiable caller token → that token is forwarded
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer " + rs
# a configured server key wins over any caller token
monkeypatch.setenv("SRV_TOK", "hk-server-key")
assert G.ai_bearer(_Req("Bearer " + rs), server_env=envs) == "Bearer hk-server-key"
def test_forwardable_bearer_survives_crafted_non_string_alg():
"""A JWT header with a non-string alg (null/list/number/dict) must yield "" — never
a self-inflicted 500 from alg[:2]/alg.startswith. Mirrors the documents.py RED fix."""
def jwt_bad_alg(alg_value):
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
return f"{h}.eyJzdWIiOiIxIn0.sig"
for bad in (None, [], 123, {"x": 1}, True):
assert G.jwt_alg(jwt_bad_alg(bad)) == ""
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg(bad))) == ""
assert G.jwt_alg(jwt_bad_alg("RS256")) == "RS256" # real asymmetric alg still read
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg("RS256"))) == "Bearer " + jwt_bad_alg("RS256")
def test_alg_allowlist_is_the_exact_jws_set_not_a_prefix_match():
"""F3: a prefix match ("RS"/"Ed") admitted non-JWS names like RSA-OAEP / 'Edwina';
the allowlist is now the exact JWS asymmetric set."""
for good in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(good))) == "Bearer " + _jwt(good)
for bad in ("RSA-OAEP", "RSA1_5", "Edwina", "ES", "RS", "none", "HS256", "PSK"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(bad))) == ""
@@ -0,0 +1,139 @@
"""The composer's default create path — text-to-image — and the UX invariants the live
review demanded. Two concerns:
* backend _image_graph is a REAL text-to-image graph (EmptySD3LatentImage SaveImage,
the Z-Image-Turbo recipe), and dispatch_image validates before any dispatch.
* frontend the shipped page defaults the composer to an IMAGE model, routes textimage
(no "attach a photo first" dead end), ALWAYS shows a generating state on
Create, puts Download first-class on the tile AND the viewer, and stops
badging active work as ARCHIVED. Asserted statically against the served HTML
so the intuitiveness fixes can't silently regress.
"""
import asyncio
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
_HTML = (Path(__file__).resolve().parents[2] / "middleware" / "studio_home.html").read_text()
# ── backend: the text-to-image graph ────────────────────────────────────────────────
def test_image_graph_is_a_real_text_to_image_create():
g = sh._image_graph("a poster for a jazz night", "image/test")
classes = {n["class_type"] for n in g.values()}
# a true text-to-image CREATE starts from an empty latent (not an edit of a source)…
assert "EmptySD3LatentImage" in classes
# …and produces a visible saved image through the full load→sample→decode→save chain.
assert {"UNETLoader", "CLIPLoader", "VAELoader", "CLIPTextEncode", "KSampler",
"VAEDecode", "SaveImage"} <= classes
# the user's words land in the positive prompt (node 5), negative (node 6) stays empty.
assert "jazz" in g["5"]["inputs"]["text"]
assert g["6"]["inputs"]["text"] == ""
# the exact model set the shipped Z-Image-Turbo blueprint names.
assert g["1"]["inputs"]["unet_name"] == sh._ZIMAGE_UNET
assert g["2"]["inputs"]["clip_name"] == sh._ZIMAGE_CLIP and g["2"]["inputs"]["type"] == "lumina2"
assert g["3"]["inputs"]["vae_name"] == sh._ZIMAGE_VAE
# turbo recipe: 4 steps, cfg 1.0, res_multistep/simple, denoise 1.0 (a fresh render).
ks = next(n for n in g.values() if n["class_type"] == "KSampler")["inputs"]
assert ks["steps"] == 4 and ks["cfg"] == 1.0 and ks["denoise"] == 1.0
assert ks["sampler_name"] == "res_multistep" and ks["scheduler"] == "simple"
def test_image_graph_dims_clamp_and_snap():
g = sh._image_graph("x" * 5, "p", width=1000, height=770)
lat = g["7"]["inputs"]
assert lat["width"] % 16 == 0 and lat["height"] % 16 == 0 # snapped to the 16-px grid
g2 = sh._image_graph("x" * 5, "p", width=50, height=9000)
assert 256 <= g2["7"]["inputs"]["width"] <= 2048 # clamped low
assert 256 <= g2["7"]["inputs"]["height"] <= 2048 # clamped high
def test_dispatch_image_rejects_bad_prompt_before_dispatch():
# A too-short/empty prompt fails fast with a human reason — never a silent nothing,
# never a network call. (No server/GPU needed: the guard is the first statement.)
for bad in ("", "hi", " "):
with pytest.raises(sh.DispatchError):
asyncio.run(sh.dispatch_image(None, None, "org", bad))
# ── frontend: the served page's intuitiveness invariants ─────────────────────────────
def test_composer_defaults_to_an_image_model():
# default is Zen Image (an IMAGE model), NOT a chat model, so the first ↑ produces
# something visible. Decoupled from the chat model (its own storage key).
assert "hz_create_model||'zen-image'" in _HTML
assert "hz_create_model" in _HTML and "localStorage.hz_model=v" not in _HTML
# the Image group leads the picker; the code model is hidden from the creative composer.
assert _HTML.index("{label:'Image'") < _HTML.index("{label:'Assistant'")
assert "zen5-coder" not in _HTML
def test_text_only_prompt_routes_to_text_to_image():
# the DEFAULT create (words, no photo) hits the new /v1/library/image route — the old
# "attach a photo first" dead end is gone.
assert "url:'/v1/library/image'" in _HTML
assert "Describe what to create, then attach a photo with +" not in _HTML
def test_create_always_shows_a_state_and_fails_friendly():
assert "function pulseGenerating(" in _HTML and "pulseGenerating(plan)" in _HTML
assert "function createFailed(" in _HTML and "tap ↑ to retry" in _HTML
def test_download_is_first_class_on_tile_and_viewer():
assert "function downloadImage(" in _HTML and "function dlMenu(" in _HTML
assert "dlMenu(event,'" in _HTML # a per-tile download control
assert 'id="zrdl"' in _HTML # a download control in the image viewer
# PNG/JPG/WebP format choice is exposed at download.
assert "downloadImage(path,'jpg')" in _HTML or "downloadImage(path,v)" in _HTML
def test_active_items_are_not_badged_archived():
# the old unconditional status badge (which read every active item as its status /
# "archived") is gone; a badge now renders ONLY for deleted/flagged items.
assert "'archived':a.status}" not in _HTML
assert "a.status==='deleted'||a.status==='flagged'" in _HTML
def test_bars_are_opaque_and_dev_control_is_gated():
assert "background:var(--bg);z-index:30" in _HTML # opaque fixed header
assert "#livebar::before" in _HTML # full-bleed opaque backing behind the sticky status bar
assert "rgba(10,10,11,.82)" not in _HTML # the old translucent header is gone
assert "body:not(.hzdev) .pico.dev" in _HTML # </> hidden unless Developers mode
assert 'class="pico dev"' in _HTML
def test_mobile_collapses_the_filter_pills():
assert ".chips{flex-wrap:nowrap;overflow-x:auto" in _HTML
# ── RED create-lane regressions (@c00384cf): F1 dedup identity, F2 dim coercion ──
def test_dedup_key_is_stable_identity_not_the_random_prefix():
# F1: the key must be the STABLE render identity (org|kind|prompt|refs|uploads) —
# NOT output_prefix, which each lane stamps with a random _job_tag() so two
# identical submits used to miss and double-bill.
k = sh._dedup_key("acme", "image", "a jazz poster", [], [])
assert k == sh._dedup_key("acme", "image", "a jazz poster", [], []), "identical creates share a key"
assert k != sh._dedup_key("acme", "image", "a DIFFERENT poster", [], []), "different prompt → different key"
assert k != sh._dedup_key("other", "image", "a jazz poster", [], []), "different org → different key"
assert k != sh._dedup_key("acme", "video", "a jazz poster", [], []), "different lane → different key"
assert k != sh._dedup_key("acme", "image", "a jazz poster", ["ref.png"], []), "different refs → different key"
def test_image_and_video_graphs_reject_non_numeric_dims():
# F2: a non-numeric width/height/length raises DispatchError (→ clean 400), never a bare ValueError (→ 500).
for bad in ("abc", "10px", [], {}):
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", width=bad)
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", height=bad)
with pytest.raises(sh.DispatchError):
sh._video_graph("a valid prompt", "video/x", width=bad)
# numeric strings still coerce and render
g = sh._image_graph("a valid prompt", "image/x", width="512", height="512")
assert g["7"]["inputs"]["width"] == 512 and g["7"]["inputs"]["height"] == 512