Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89cf707a2 | ||
|
|
d8399c312f | ||
|
|
20d2f809af | ||
|
|
c00384cf1a | ||
|
|
cadc589875 | ||
|
|
a8dc089716 | ||
|
|
e631d35fd0 | ||
|
|
267735d0bd |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 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
@@ -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)"></></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)"></></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('Couldn’t 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('Couldn’t 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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 text→image
|
||||
(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
|
||||
Reference in New Issue
Block a user