shell.css (chat sidebar · Developers dock · GPUs panel) was still on the old
charcoal palette; snap it to the canonical monochrome tokens the Home already
uses: page #000 · surface #0a0a0a · raised #1a1a1a · border #1f1f1f · text
#ededed · dim #888 · brand/CTA #fff. Semantic green (online) / red (error) kept.
The shared shell now matches the create experience + the chat's own empty-state
and friendly-error work already on main.
The Studio Home 'What should we create?' surface, fully monochrome and DRY on
the canonical design tokens + icon()/_popover:
- Line-icon system (icon(name) → currentColor SVG). No emoji anywhere in the
create experience; template cards + tabs + chat button all use lucide-style
monochrome marks.
- Searchable template picker (Template chip → .tpick): every blueprint grouped,
filterable, keyboard-navigable; None returns to edit/compose.
- Design systems mode (new tab): reusable brand styles a generation can pull
from — preset systems (Modernist/Classical/Nocturne/Organic/Broadsheet/
Industry) with Use / Set org default, plus Create design system.
- Set up your design system wizard (#dswiz): company blurb + link a public
GitHub repo (→ e.g. github.com/hanzoai/design), a local folder, a .fig
(parsed in-browser, never uploaded), or fonts/logos.
- Agentic design-system builder (#bld): chat + todos + file tree + editor to
generate a system from those inputs.
shell.js: Chat dock button uses a monochrome SVG icon (was emoji).
De-purple the ComfyUI shell theme (hanzo-theme.css) and the Copilot sidebar
chat (hanzo-copilot.css) to the canonical monochrome design system — no color
accent. Accent tokens map to neutral (icons/links/active #ededed, primary
buttons white-on-dark with #111 text like the Home CTA, selected/checked
neutral gray, focus ring #8a8a8a). Semantic status colors are unaffected.
Copilot default model 'zen' -> 'enso' (the Hanzo flagship assistant) —
Enso default everywhere, including Studio.
Collapse the Studio Home account pill's three overlapping mechanisms (static
#acctmenu, dynamic #omenu, two competing #user handlers) into ONE identity
popover rendered through the same openMenu the composer pills use. It shows who
you are (avatar/name/email) and switches organization AND project against the
same /v1/session API the node editor uses (re-scopes tenancy + output paths on
reload). Pill now reads avatar · name · org · caret.
openMenu gains a headNode + per-item {on,href,act} so one popover serves the
composer pills AND the identity menu (backward-compatible — existing pill call
sites unchanged).
Snap the palette to the canonical neutral Hanzo design system (hanzo.app /
hanzo.ai): page #000 · surface/popover #0a0a0a · raised/hover #1a1a1a · border
#1f1f1f · text #ededed · dim #888 · brand/CTA #fff · ring #333. Every dropdown
panel (.pmenu, .ovfmenu) uses the one canonical menu spec: 10px radius + the
two-layer shadow. Fix the latent feedback-bubble contrast (white-on-white).
The Python Linting gate (ruff check .) had been RED on main across multiple
commits: 5x E702 (multiple-statements-on-one-line) in studio_home_fix_test.py.
Split each `x = ...; x.write_bytes(...)` onto two lines. Behavior identical
(19/19 pass); no other files affected.
Ships fix/studio-chat-and-dropdowns: real Design/Template/</>/Model dropdowns on
the /studio composer and Chat unified on the @hanzo/ai contract, with the three
review mustFix resolved (fail-closed pk- allowlist, chat stream→copilot fallback,
committed guard + SSE parser tests). No file overlap with the marketing-shell /
native-deploy work already on main.
Resolves the three review mustFix before a publishable key is provisioned to the
studio pod (the next deploy step), which flips on the streaming path and relies
on the key guard:
- M1 (security): _publishable is now an ALLOWLIST — emit only `pk-` (the
gateway's single client-safe prefix, iamauth.go:472-476). The old denylist
missed fw_/hz_/JWT server secrets and failed OPEN, leaking them into
window.HZ.pk in page source.
- M2 (robustness): the /studio Chat stream degrades to the same-origin copilot on
any transport failure (fetch throws / non-2xx / no body) instead of
dead-ending, so a PK provisioned before the gateway allows CORS+Authorization
for studio.hanzo.ai can't hard-break Chat. SSE parse decomplected into pure
sseLines/sseDelta; stream behavior otherwise identical.
- M3 (coverage): commit publishable_test.py (allowlist + <script> break-out incl.
U+2028/9) and sse_parser_test.py (runs the SHIPPED shell.js parser under node).
103/103 studio_home_test, 207/207 middleware_test green.
The "What should we create?" composer's four pills were broken: Design
system and Template were stub toasts / a tab-jump, </> was dead, and Model
was a hardcoded "Opus 4.8" with no handler. Replace all four with ONE
monochrome popover (open · real options · select · Esc/outside-close ·
keyboard), wired to real sources:
- Design system → the org's real designs (the SAME FILTER the gallery chips
drive; pill and chips stay in sync via paintChips — one state, two controls)
- Template → the KIND catalog, grouped, reusing pickTemplate/clearLane
- </> → a read-only request preview from composerPlan(), the ONE planner
create() now also executes (no drift; billable dispatch behavior unchanged)
- Model → the real catalog from api.hanzo.ai/v1/models, curated to the
Studio set (enso · zen · image/video), shown only where the id truly
exists upstream; default enso, persisted (hz_model), drives the Chat model
Chat sidebar (shell.js): stream enso from api.hanzo.ai/v1/chat/completions
(OpenAI-compatible SSE) when a PUBLISHABLE key is present, else the secure
same-origin copilot fallback (httpOnly cookie; keeps graph ops in the
editor). "Powered by Hanzo AI" footer; model follows the composer's pick.
Config injected as window.HZ from env (NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY):
_publishable() fails closed — refuses to hand the browser anything that
looks like a server secret (sk-/hk-/secret*), so a misconfig drops to the
copilot path rather than leaking. No secret ever reaches page source.
The render queue (/v1/tasks/*) is served by the single-pod cloud binary (Recreate
on an RWO SQLite volume), so every cloud deploy leaves a ~30s window where Traefik
has zero Ready endpoints and returns 503 'no available server' for the enqueue —
surfacing as 'GPU render worker is momentarily unavailable'. Retry the enqueue
across that transient gap (503 / connection-refused only — the request never
reached the backend, so re-sending the idempotent activityId can't double-enqueue;
a 4xx or timeout still falls straight through). ~32s ladder covers a same-node
Recreate. 3 tests: retries-through-503, no-retry-4xx, no-retry-timeout.
Two flywheel surfaces the gallery already had but the asset views did not.
1) Full-image (zoom) rating bar. Opening any asset full-size now shows the same
👍/👎/💬 controls as the cards, bound to the asset in view (ZPATH threaded
through every zoom entry — grid, queue list, stack view, openLibItem). 👍/👎
toggle + persist via the existing castVote; 💬 opens the existing critique
chat. Note-less; reuses the proven .vote classes. Verified on desktop + mobile
(Playwright): centred pill, correct up/down highlight toggle, critique dialog
opens, 44px tap targets, safe-area bottom inset, Comment label no-wrap.
2) Curation IS training signal. Archiving or flagging an output (the ONE
/v1/library/status route every archive path funnels through — queue Archive,
gallery delete, stack delete) now records it as a NEGATIVE example: vote -1 in
ratings.json + an event in feedback.jsonl carrying the deleted/flagged status
(negative-only downstream). Restoring clears that auto-negative. The downvote
is NOTE-LESS so it trains the reward dataset without polluting prompt lessons
(_mistakes gates on note text). A judgement the user typed is user-owned —
never overwritten or cleared by the auto-negative. Automatic dedup writes the
library directly (not via the route), so byte-identical twins are never
mislabelled bad. The queue button is now an explicit "🗑 Archive" and says it
teaches the model.
Decomplect: one atomic ratings writer (_rate_write, unique-temp — kills the
shared-name clobber the fixed ratings.json.tmp had, same hazard fixed for
library.json) shared by the rate route and the archive hook. Tests: archive →
vote -1 + feedback event + status=deleted + no injected lesson; restore clears
it; a user's downvote+note survives archive/restore and stays a lesson. 268
studio_home + middleware tests pass.
Trace of the whole Edit lifecycle (click -> /v1/library/fix -> dispatch_fix ->
_fix_graph -> gpu_dispatch -> worker LoadImage) confirmed the selected asset is
bound explicitly at every hop and the low-denoise Edit graph is guarded by
_assert_edit_graph (no EmptyLatentImage / text-to-image fallback). The one
residual state-isolation hole was the staged source FILENAME.
Every lane (fix/video/model3d/compose/amend) staged its source under
f"{kind}_src_{abs(hash((str(p), mtime))) % 10**10}.png"
— Python's process-salted hash, duplicated in 5 places. Two concurrent edits of
DIFFERENT sources whose names collided on a shared worker input dir would
overwrite each other's LoadImage file: the "Edit produced an unrelated picture /
mixed a different image" class of bug.
Decomplect to ONE helper, _stage_source(): the staged name is the sha256 of the
source bytes. The name IS a verifiable checksum of the exact selected image, so
the bytes the worker loads can never drift to another asset, and different
sources are cryptographically un-collidable (same source -> one idempotent
file). 5 call sites -> 1; the 3D downsize rides an optional max_px.
Observability the report asked for: a _trace() line correlated by job id at
build -> queue -> gpu.publish (studio.home + studio.gpu_dispatch). Logs ids,
content-address checksums, counts, lane/node ONLY — never signed URLs, tokens,
cookies or image bytes.
Tests (pure/fs, no torch/GPU/net): content-addressing (same bytes->same name,
one byte flips it, name==sha256); two concurrent edits share no staged file /
LoadImage ref / positive prompt / latent / output prefix; _collect_input_images
ships the selected org's exact bytes and is org-scoped; _assert_edit_graph
rejects a text-to-image graph. 188 middleware tests pass.
The CI 'Test (per hanzo.yml)' step failed because tests-unit/middleware_test/
studio_home_fix_test.py still asserted the OLD fix behavior (denoise 1.0, the
'same model, pose, framing' prompt, and the removed size=→EmptySD3LatentImage repose
branch). Updated to the new invariants: denoise == EDIT_DENOISE_DEFAULT (0.25), the
new preservation prompt, and — replacing the repose-Empty test — a
test_fix_graph_never_uses_empty_latent that proves Edit NEVER falls back to an empty
latent for any instruction (repose is now Regenerate, not Edit). Full CI set: 375 pass.
ROOT CAUSE: 'Fix' on a generated gallery image ran the Qwen edit at denoise 1.0 with a
RANDOM seed (full re-paint → color/composition/subject drift even for 'brighten very
slightly'), and a broad _REPOSE keyword regex silently swapped in EmptySD3LatentImage
(a literal text-to-image regenerate) on words like 'standing'/'looking'/'side view'.
THREE DISTINCT ACTIONS now, no ambiguity:
- Regenerate = Re-run (/v1/library/rerun: re-queue the recipe, new seed) — the only
path that may use EmptyLatentImage + denoise 1.0.
- Edit = low-denoise Qwen edit of the SOURCE: LoadImage → FluxKontextImageScale →
VAEEncode → KSampler(latent = the source, denoise from an edit-strength slider,
default 0.25) → VAEDecode → Save. Reuses the source's ORIGINAL seed (read from the
embedded graph via _provenance) so a locked seed is deterministic; a varied seed
still edits the source. NO _REPOSE→Empty branch. Full-res source (never the thumb).
- Edit selected area = masked inpaint: SetLatentNoiseMask restricts sampling to the
painted region + ImageCompositeMasked keeps pixels OUTSIDE the mask byte-identical.
INVARIANT (impossible-by-construction): _assert_edit_graph refuses any edit whose
KSampler.latent_image is an EmptyLatentImage or whose denoise leaves the edit band
[0.05,0.95] — an Edit can NEVER silently become a Regenerate; a bad edit surfaces an
inline error, never a fallback to random generation.
Frontend: Fix dialog → 'Edit image' with an edit-strength slider (10–95% → denoise,
default 25%) + a paint-the-area mask canvas (Edit area) + clear labels (gallery card:
Regenerate · Versions · Edit). sendFix passes {strength, seed?, mask?}. Output linked
as a child (parents=[source]); color-match post-process (v0.17.9) still runs.
Tests: 6 new edit-invariant tests (VAEEncode-not-Empty, denoise 0.25, regenerate
rejected, seed determinism, prompt-preservation-not-expansion, masked composite);
existing fix test updated 1.0→0.25. 42 pass. Model-compatible with the Qwen edit model
already on spark (kept per the user's choice); I did NOT submit any live render to
verify visuals (per the no-autonomous-image-creation rule) — the graph structure is
unit-proven and the user validates the visual output.
You can now delete any queue entry (a cancelled probe, a failed attempt, clutter) —
a 🗑 on every non-active queue row + a Delete button in the item detail dialog.
worklog.remove(org, ids) + POST /v1/worklog/delete drop ONLY the log rows; output
images live in the library and are archived/deleted there (unchanged). Optimistic +
idempotent. Pairs with the earlier Queue archive (output→Archive) and the retry-cap +
dispatch-dedup that stopped the workflow from re-running/duplicating a render in the
first place. Verified: remove by id + bulk + idempotent-missing.
The Assets gallery (and every design filter) now shows only status=published — a
curated view. Nothing is deleted: draft + approved items stay in the library,
reachable via the new ✎ Drafts chip (count shown), where each keeps its
Approve/Publish/Fix tools so you can publish it into the gallery. Empty published
view shows a 'open Drafts to publish' hint instead of looking like work vanished.
Filter predicate verified: all→published-only, design→published-of-design,
drafts→all unpublished (excl. deleted/flagged).
The WebP-thumbnail change served the .webp via FileResponse WITHOUT an explicit
Content-Type. aiohttp guesses from the extension via Python's mimetypes, which
does not know .webp, so every grid thumbnail came back application/octet-stream —
which the browser refuses to render as an <img>, so the whole gallery went blank.
The full-image path was unaffected (it sets Content-Type explicitly). Set
image/webp on the thumb response (and image/jpeg on the video poster for
consistency). Regression test pins it.
Root-caused 'can't see any assets': it's org resolution, not lost data. The active
org lives in the studio_active_org cookie; a fresh re-login clears it, dropping the
session to the token's home org — and if that resolves to 'default' (doesn't exist)
or an empty org (maxpower=0), the gallery is blank. The user's work is intact in
karma (416 visible) + hanzo (130). Verified the render path is sound at full scale.
Fix the confusion at the source: the empty gallery now NAMES the current workspace
and points at the org switcher (account pill, top-right) — so landing on the wrong
org reads as 'switch workspace', never 'my assets disappeared'. Also distinguishes a
no-search-match empty from a truly-empty workspace.
Verified: empty maxpower org shows 'No assets in maxpower yet — switch from the
account menu'.
Investigating 'can't see assets': verified the gallery renders all 416 karma cards at
full scale (1300 assets) with thumbnails painting on scroll, so the render path is
sound. Two real fragilities hardened + one cache trap removed:
- Thumb cache was 'immutable, max-age=1yr' (v0.17.17). Since the ?v=sha URL already
busts on change, immutable bought nothing but risked locking a single bad thumb
response in the browser for a YEAR (un-clearable without hard-refresh). → long but
REVALIDATABLE (max-age=1wk, stale-while-revalidate), so a bad response self-heals.
- runs() did (await fetch(x)).json().catch() — catches the json reject but NOT a
fetch reject, so a failed ratings/favorites fetch threw before rgrid rendered →
blank queue. Now each non-critical fetch is fully guarded.
- load() now shows 'Couldn't load your library — retry' on a transient /v1/library
failure instead of a silent blank gallery.
Happy path verified unchanged (416 cards, queue renders).
/v1/gpu-status returns online reliably (verified live), but gpuOnline() cached
BOTH true and false for 15s. When a worker's heartbeat blipped for a few seconds
(spark flapped tonight on a local-network issue), the banner cached 'no GPU' and
held it — showing 'connect a GPU to generate' even with GPUs 3/3 in the chip.
Now only a POSITIVE is cached; a negative re-checks every call, so the banner
self-heals on the next 5s livebar tick.
Four wins across gallery + queue + stacks:
1. LAZY: cards no longer eager-load every thumbnail (a 1000-image gallery fired 1000
requests up front). Each .im carries its URL as data-bg; an IntersectionObserver
(rootMargin 600px) paints it only as the card nears the viewport → a screenful of
requests, not N. Falls back to eager where IO is unavailable.
2. WEBP: thumbnails are now WebP q80 instead of JPEG q82 — measured 45% smaller.
3. QUEUE was loading FULL-RES outputs (vURL) as row backgrounds — now uses the same
downsized thumbnail as the gallery (huge cut on the History tab).
4. IMMUTABLE CACHE: thumbURL is versioned by content sha (?v=, from the indexer) or
mtime, so a thumb is served Cache-Control: immutable, max-age=1yr — instant on
repeat loads; a re-render gets a new sha → new URL → never stale.
Verified: lazy observer paints in-view cards + defers off-screen; WebP 23KB vs JPEG
41KB on a sample; ?v= present on thumb URLs.
On Queue & History, per the ask:
- Click a photo → fullscreen (already opened zoom, now with navigation). Generalized
the lightbox: zoomList(list,i) carries the queue's outputs so the on-screen ‹ › AND
the keyboard ← / → step through them (wraps both ways); Esc closes. Stack view still
uses svStep — zStep() routes to whichever context is open.
- 👍/👎 + 💬 on each output — the SAME rate endpoint + feedback.jsonl training log as
the gallery, keyed by the output's library path (a queue vote == a gallery vote).
- 🗑 Reject → Archive — soft-deletes the output (⌘Z-undoable), dims the row + marks it
✓ Rejected. Reuses the one status/undo path; no new lifecycle.
Each queue output resolves to its library asset (path = subfolder/filename), so it
reuses the gallery's vote/archive machinery — one way to do everything, no duplication.
Verified headless: lightbox a→b→c + wrap via ←/→ and the arrows; 👍 logs training
feedback (stats up=1); reject archives + ⌘Z toast.
The build-manifest sidecar scanned only image/video/marketing extensions, so a
.glb was dropped on every rebuild — the ingested mesh vanished from the library
within a sweep cycle (same class as the earlier video/ingest drops). Add
MODEL3D_EXTS to the scan and the kind default.
A 4k catalog master (~20MB) inlined to the gpu-jobs enqueue overran the 15s
urlopen timeout, so dispatch_if_worker fell back and the model-less pod returned
'GPU worker momentarily unavailable' on a perfectly good render — for EVERY lane,
not just 3D. Two fixes: the 3D lane downscales its staged reference to 1536px
(Hunyuan3D works at ~518px internally, so the mesh is identical and the body is
small), and the enqueue upload timeout goes 15s→90s so large fix/compose/video
references actually finish uploading.
The queue/history detail showed the render as a 60px thumbnail — you open a job
to SEE the result. Now the result is a large hero: an image (zoom on click), a
video (inline player), or an interactive rotatable <model-viewer> for a .glb.
References move below as a labelled small row. On phone every dialog becomes a
full-width bottom sheet (safe-area padding, scrollable, stretched action
buttons), the hero caps at 48vh so it fits, and queue-row thumbs grew 60→74px.
The retry-cap + dispatch-dedup patches stop the two known triggers; this makes the
whole class impossible at every layer, independent of any upstream bug:
COMPUTE — don't render a duplicate:
* retry cap (maximumAttempts=1): a failed render never re-runs itself.
* dispatch dedup: identical (org, prefix, instruction, refs) submits within 60s
collapse to the in-flight render.
QUEUE — nothing lingers to replay:
* scheduleToStartTimeout=1800s: a job no worker picks up in 30 min EXPIRES, so a
worker outage can't leave a backlog that silently re-bills when a worker returns.
STORAGE — the gallery is CONTENT-ADDRESSED (the last line of defense):
* library_manifest (the one indexer that builds every library.json, sweeping each
org continuously) now enforces AT MOST ONE active asset per content hash. Any
byte-identical duplicate is auto-soft-deleted, keeper = a curated asset if one
exists else the earliest. Idempotent, recoverable, and O(new-files) per sweep
(sha reused when mtime is stable). So even 873 identical re-renders can NEVER
accumulate in the gallery — the invariant heals it within one sweep.
Verified: 3 identical + 1 distinct -> 2 active (one-per-hash), distinct untouched,
stable across sweeps.
Third suite content type. Proven node-for-node on spark from ComfyUI's own
3d_hunyuan3d-v2.1 template: ImageOnlyCheckpointLoader (the ONE bundled
model+CLIP-vision+VAE — separate loaders produced noise) → CLIPVisionEncode →
Hunyuan3Dv2Conditioning → ModelSamplingAuraFlow(shift 1, the flow-matching
wrapper WITHOUT which the sampler yields fragments) → KSampler → VAEDecodeHunyuan3D
→ VoxelToMesh(surface net) → SaveGLB. Verified: a clean 3D garment mesh from a
ghost-mannequin shot (341k faces, coherent).
Lane contract, same as video: _model3d_graph + POST /v1/library/model3d (ONE
reference image required, no text-to-3D, unique job tag); .glb ingest (glTF
magic bytes, model/gltf-binary CTYPE, kind=model3d); an interactive <model-viewer>
zoom card (grid shows a 🧊 3D badge, click to rotate); the 'Image → 3D' template.
No new infra — one library, one queue, one wallet, one GPU fleet.
Tests: proven graph, dispatch route (org-from-token, unique tag, one required
ref), .glb ingest. All studio suites green.
Adversarial review (contract lens) blocked on a real bug: the composer KIND flag
stayed 'video' after a video dispatch, so the next single-photo generate silently
POSTed /v1/library/video (image-to-video) instead of /v1/library/fix — a wrong,
billable GPU dispatch. clearLane() now resets after every video dispatch, and the
Template chip is clickable to return to the neutral edit/compose lane.
DRY (identity lens): the SaveImage/SaveVideo/SaveWEBM triple was hardcoded three
times. Consolidated the two Python copies into gpu_dispatch.SAVE_NODES (the base
module, no cycle) imported by studio_home; the HTML keeps its own JS-runtime copy.
244 pass; XSS esc() + job-identity _retag + library.json lock all intact post-rebase.
LIBRARY.JSON CORRUPTION (Red V3, the curation-wipe mechanism): the build-manifest
sidecar wrote a FIXED '.library.json.tmp', shared by every pod's sidecar — two
overlapping during a roll interleaved into one inode → torn JSON → load_prior()/
reader hit JSONDecodeError → {} → every asset reset to status=draft, which mirror
then pushed to S3 and restore rehydrated. Fixed both writers: mkstemp gives each a
private tmp (kills the tear), and a shared flock (.library.lock, taken by the
sidecar across build+write and by the app's _save_library across its write)
serializes the two rebuild-then-replace paths so neither clobbers the other.
/READY LIVENESS (Red V1): the readiness probe checked ,
set once at boot and never cleared — constant-true, saying nothing about the render
path. A dead worker thread kept the pod in-rotation accepting jobs that never ran,
and a roll would promote a wedged pod. /ready now gates on the prompt_worker daemon
thread actually being alive.
Tests: unique-tmp, no fixed-tmp residue, concurrent-writers-never-tear, lock file.
226 pass.
Two root causes of repeated/duplicate GPU renders (money + gallery clutter):
1. NO retry cap: the render activity had heartbeat/startToClose timeouts but no
retryPolicy, so the tasks framework re-ran a failed render (e.g. spark OOM mid-
sample) — the same reject re-rendered. Add retryPolicy maximumAttempts=1: one
attempt, a failure surfaces to the user, no auto re-burn.
2. NO dispatch idempotency: a double-fired/unguarded submit queued a SECOND render
of the same thing (logs showed one output dispatched 4x in a second). _dispatch
is now idempotent — an identical (org, output-prefix, instruction, refs) dispatch
within a 60s window returns the in-flight prompt_id instead of billing a new job.
A genuinely different instruction on the same source still dispatches.
Server-side, so it covers every submit path (composer, fix dialog, compose, chat).
Verified: 4 identical submits → 1 render; different instruction → allowed; retry
cap present.
Red-team, same phantom-image class the user hit: re-run replayed the source
graph's own SaveImage prefix, so its file landed in the SOURCE namespace and N
re-runs shared one prefix — the library then showed the newest sibling for every
row (a job displaying a photo it never made). _retag() now points every SaveImage
at a unique per-dispatch prefix for re-run and template-render, the guarantee
fix/compose/amend already had.
Composer create() (↑ button / Enter) had no double-submit guard — a fast double
fired two billable dispatches. Now wrapped in the same enqueue()/QBUSY latch as
the dialog buttons. 223 pass.
CRITIQUE CHAT (the ask: chat about a generated photo, to make it better and
train it): the 💬 on any render now opens a conversation ABOUT that image.
/v1/library/critique loads the actual photo so the vision model's reply is
grounded in what was rendered, and EVERY user turn is recorded as the note the
flywheel reads (_mistakes → _lessons), so critiquing here conditions the next
generation of that design. The old sidebar copilot stays graph-only; this is
the image-scoped path. Thread persists per image (reopen to continue).
ARCHIVE FROM QUEUE: done rows in Queue & History get a 🗄 button — archive the
render straight from history (soft, ⌘Z to undo), no need to find it in the grid.
STORED XSS (red-team, launch-blocking): a hostile upload filename like
'<img onerror=...>.png' rendered raw into gallery card HTML and executed for any
org member. Fixed at the boundary (upload name collapsed to [A-Za-z0-9._-]) and
in depth (esc() on the filename text in render(); template Render onclick now
carries only the safe slug, name looked up client-side). svCard already esc'd —
render() and the template card were the gaps.
Tests: critique thread persistence + lesson recording + tenancy + guards;
upload-name sanitization. 222 pass.
zen-vl became a reasoning model: max_tokens 8 was spent entirely on
reasoning_content, content came back null, the strict parse read nothing and
the gate failed open on EVERY render — flagged reached zero while corrupted
outputs sat in the grid, and the failure logged at an invisible level.
_reply_text now falls back to reasoning_content when content is empty;
max_tokens 768; the verdict is the LAST whole PASS/FAIL token (deliberation
mentions both — position is the conclusion); judge failures log at warning.
The Advanced-mode node editor is served from the upstream comfyui-frontend package,
whose index still shipped <title>ComfyUI</title> + 'Loading ComfyUI...' — the only
user-facing surface still showing the upstream name (the home + web/dist SPA are
already Hanzo Studio, and the running image is our fork ghcr.io/hanzoai/studio).
editor_index() already injects our shell into that index; add two idempotent swaps
there so the Editor tab title + loading status read 'Hanzo Studio'. The ComfyUI-format
compatibility stays internal, per rule — the name is out of the UI. (Deeper editor
menu/i18n strings live in the minified frontend bundle; a full frontend rebrand is a
separate build-side change.)
Usage tracking: Umami pageviews (analytics.hanzo.ai, pre-existing site id) and
six product events (generate, fix, compose, stack_create, rate, template_use)
via a zero-dependency sendBeacon to insights.hanzo.ai/v1/e — the /v1 prefix is
load-bearing (root /e/ is IAM-gated) and the key is the project's public write
token. Telemetry fails silent.
UX: body clears the floating shell dock (content was hidden under it at page
bottom on phone); stack name input flexes instead of truncating.
Tests: gpu_dispatch tests updated to the has_models_for(prompt) seam
(per-graph model check renamed the old has_local_models).
Reported again on compose (base + ref). Root cause the v0.17.5 guard missed: the
/prompt intercept fell back to LOCAL validate_prompt whenever dispatch didn't happen
AND has_local_models() was True — but has_local_models() is 'ANY model present', and
the coordinator pod carries a stray SD1.5 checkpoint. So a Qwen fix/compose graph the
pod CANNOT load (no qwen-image-edit unet) still validated locally → 'unet not in []'
→ 'Prompt outputs failed validation'. The stray checkpoint defeated the model-less
invariant.
Fix: replace has_local_models() with has_models_for(prompt) — a per-GRAPH check of
the UNET/checkpoint/CLIP/VAE loader inputs against folder_paths. A box renders locally
only a graph it can actually load; every other graph dispatches to the fleet (or a
retryable 503), regardless of unrelated models on disk. Used in BOTH the dispatch
decision (gpu_dispatch.dispatch_if_worker) and the intercept fallback (server.post_prompt).
Verified: stray-SD1.5 pod → False (enqueue, not local); real Qwen box → True (renders
locally, dev/worker unaffected); model-less pod → False.
Reported: adding a photo to the top 'What should we create?' box opened a SECOND
box at the bottom; only that bottom box worked; typing in the top box + clicking ↑
did nothing.
Root cause: the top ↑ (create()) was a STUB that only toast'd a HIP-0506 placeholder
and never generated, while the '+' funnelled photos into a separate fixed bottom
'Next generation' bar (#reftray/genFromTray) that was the only real generate path.
Two composers, only the second wired.
Fix — collapse to ONE composer (the top box):
- create() now really generates from the top prompt + the photos attached to it:
1 photo → edit (/v1/library/fix), 2-5 → compose — the same proven graphs the
gallery uses. Text-only (no photo) gives a clear 'attach a photo with +' hint.
- attached photos render INSIDE the top composer (a thumbnail strip with × to
remove), fed by the same TRAY (+ / drag / paste / a gallery card's +).
- the bottom #reftray bar + genFromTray are removed (dead once create() is real).
- Enter submits (Shift+Enter = newline).
Verified headless: attach → thumb in the top box, NO bottom bar; type + ↑ → POST
/v1/library/fix {upload, instruction} → Queued.
Scaling the reference against a differently-sized empty latent re-created the
size-mismatch artifact class (oversaturated drift + reinvented garment,
observed live). Raw ref + AuraFlow-only matches the graphs that delivered the
velvet-front and mesh-back renders. Known limit, verified across two families
and three prompts: a true 90-degree profile from a front-facing photo
reference is beyond the current model — back views and three-quarter turns
render correctly.
Rating an output (👍/👎/💬 on every card) already logs to the per-org training
dataset. Now the retrieval half: at fix/compose/amend dispatch, the mistakes the
user noted on the base and its lineage (👎 or ≤2★ notes, and bare notes) are
injected into BOTH prompt encoders — an avoid clause in the positive, the texts
in the negative — so the very next render on that design cannot repeat what was
pointed out. Praise notes are never injected. The same judgements keep
accumulating in feedback.jsonl (with curation status) for weight training.
The queue detail showed a prior (rejected) render as a queued job's result:
re-fixing the same source reused the same SaveImage prefix, and every view
prefix-globbed disk regardless of row status. Each dispatch now appends a
6-hex job tag to its prefix (fix/compose/amend), rows expose an output only
once THEY are done, and the workflow route runs the same finalize mechanism.
Also routes view/pose-change instructions (from the side, back view, turn
around…) to the free-latent edit variant: the anchored fix latent encodes
the base — deliberately structure-preserving — and its prompt tail pinned
'same pose, framing', which contradicted exactly these asks. Repose asks now
get EmptySD3LatentImage at the reference's aspect and a tail that frees the
pose while keeping model/outfit/hair/lighting.
Feedback records now carry the judged output's curation status, so a
flagged/archived output is negative-only signal downstream — never a
positive training exemplar.
There was no way to turn user judgement into a training signal — ratings.json fed
only the runtime copilot, on the runs view, as stars. This adds a clear 👍/👎 vote
+ a written note on EVERY render (gallery card + a feedback dialog), and logs each
judgement, immutable, to the org's append-only training dataset.
- 👍/👎 on each image card (one click; click again to clear) + 💬 opens a dialog
for the written critique ('what should the model do differently?').
- /v1/library/rate extended with a normalized vote (1|-1|0); every rate also
appends {ts,user,org,key,output,vote,stars,text,prov} to orgs/<org>/output/
feedback.jsonl — the flywheel the model trainer consumes. The output PNG each row
anchors to carries its own prompt+refs in its embedded graph, so the row stays
small and the dataset is training-ready (preference / reward / instruction-tuning).
- GET /v1/feedback/stats (👍/👎/with-text/total) + GET /v1/feedback/export (the JSONL,
as a download). Toolbar shows '⬇ Feedback (N)'.
ratings.json stays the current-state store the UI reads; feedback.jsonl is the
append-only event log — orthogonal concerns, one rate call writes both. Verified
end-to-end headless: vote registers + stats increment, dialog captures vote+text,
export emits training-ready JSONL; backend unit + full app reboot clean.
A Qwen-Image-Edit fix runs at denoise 1.0 → it re-paints EVERY pixel, so the
render's global white-balance/exposure/saturation drift off the source, and the
drift COMPOUNDS across fix-of-a-fix (each fix re-encodes the prior, already-shifted
output). That is why repeatedly-fixed images end up visibly mis-colored.
Fix: restore the render's global color statistics to its source (Reinhard mean/std
transfer, per RGB channel) — the local edit stays, the global cast goes; a render
that didn't drift matches to itself = untouched. Verified: 4 successive fixes hold
Δ→source ~1 with the match vs 6.5→16.5 (compounding) without.
Applied POD-SIDE at worker-render ingest (server.upload_output → colormatch_fix_ingest):
- needs NO node on the GPU worker and touches no render graph → can never make a
render fail (avoids the 'failed validation' class entirely)
- scoped to fixes/… outputs only (never compose/normal renders), keyed to the
staged source via the render-jobs record
- fail-safe throughout: any error / missing source / size mismatch leaves the raw
bytes exactly as uploaded (proven: a save-format bug left files untouched, never
corrupted)
- strength 0.85 default (STUDIO_COLORMATCH_STRENGTH), leaving headroom for an
intentional global recolor in the instruction
New middleware/color_fidelity.py (pure algorithm + atomic in-place match);
studio_home.colormatch_fix_ingest + _fix_source_ref resolve org+source.
The soft-delete holding area is now an Archive (recover any item, or the
whole history stays visible). Inside it, permanent removal:
- 🗑 Delete forever per card + bulk on a selection, confirmed via the
existing <dialog> ask() (irreversible — no ⌘Z can undo an unlinked file)
- 🗑 Empty Archive clears the lot in one confirmed step
- POST /v1/library/purge {paths[]|all}: unlink file + drop record + drop
cached thumb. Archive-only by design — refuses any asset whose status is
not 'deleted', so a gallery mis-click can only ever archive (recoverable),
never destroy. Verified end-to-end headless: per-card/bulk/empty purge,
the archive-only guard, and rename coverage (tab, count, chips, toasts).
Rename is display-only; the underlying status stays 'deleted', so the
1,259 already-archived items need no migration.
The catalog seeder emits byte-identical copies (found 94 dups in one org's 268
visible assets). POST /v1/library/dedup hashes visible assets and soft-deletes
exact-content duplicates — keeps one per cluster, distinct re-renders (different
seeds) untouched — and returns the moved paths. Frontend "Dedupe" button beside
Select; the whole sweep pushes ONE undo entry so ⌘Z restores it all. no-store on
the response. Verified in-browser: dedup hides dups, Deleted count updates, ⌘Z
restores. v0.17.7
Deletes already persist server-side (verified live: soft-delete → status 'deleted'
holds, zero reverts); the "moved to deleted then restored" was the frontend serving
a cached /v1/library on refresh. Fixes:
- /v1/library → Cache-Control: no-store, so a delete is never masked by a stale copy.
- ⌘Z / Ctrl+Z undoes the last delete (and any status change) — soft-deletes are
reversible, so undo is just a status write-back. Dropped the bulk confirm dialog.
- 🗑 Deleted is now a top-level tab with a live count — trash is easy to find;
per-item Recover + bulk Recover (context-aware bulk bar).
- Bulk: Select → pick many → one-click "🗑 Delete selected". Discoverable.
Verified in-browser (Playwright): delete hides the card, ⌘Z restores it, Deleted tab
filters + Recover works. v0.17.6
The /prompt intercept sends every render to the org's GPU worker, but a
transient fleet-check fault (/v1/machines 502 / TLS reset / timeout) made
dispatch_if_worker return False, so the render fell through to LOCAL
validation on the model-less cloud pod → the cryptic "Prompt outputs failed
validation" (unet_name '…' not in []). That's why Fix failed only *sometimes*.
- _fleet_machines: retry 3x with backoff so a transient hiccup isn't read as
"no GPU" and bounced onto the model-less pod.
- has_local_models() + intercept: on a model-less coordinator pod a failed
dispatch now returns a clear retryable 503 "GPU render worker is momentarily
unavailable — please retry", never the doomed local validation. A box WITH
models still renders locally (dev). v0.17.5
The web/dist chat SPA shadowed studio_home.html at /studio, dropping the
per-image affordances: delx (remove → POST /v1/library/status deleted),
the Fix dialog (type-and-edit → /v1/library/fix), and the + reference tray
(→ /upload/image → /v1/library/compose, up to 5 refs). Serve the rich home
explicitly; drop the dead /studio/assets route + the node web-build stage.
web/login.html still ships (server.py serves /login). v0.17.4
On a phone (<=820px) the library rail was display:none, so a user could chat and
dispatch renders but never see them. Now the body stacks (chat fills, library
drops under it) and the rail shows as a compact scrollable bottom strip — but only
when there is something to show (jobs or assets set the `active` class), so the
empty chat state stays clean. Verified with Playwright at iPhone 14 (390x844):
empty = clean chat; with renders = 4-col strip under the composer, no overflow.
The studio-chat frontend (web/, Vite on @hanzo/ai talking to POST /v1/agent) was
repointed + committed but never served. Build it in a node stage of the Dockerfile
(-> /app/web/dist) and serve it at GET /studio, with GET /studio/assets/{name} for
the hashed bundle (immutable) and a traversal guard. Falls back to the legacy
studio_home.html when the build is absent so /studio is never a dark page. This is
the decided cutover: studio.hanzo.ai/studio becomes the /v1/agent chat app.
The orchestrator lives at POST /v1/agent (github.com/hanzoai/agent in the cloud
binary), not /v1/chat (which ai owns as its completions route). Use the canonical
`preset` selector (not the legacy `capability` alias) and thread conversationId so
the per-org history the orchestrator persists actually continues across turns.
VITE_CHAT_API → VITE_AGENT_API.
Chat hits the cloud clients/chat orchestrator directly (credentials:'include'
carries the .hanzo.ai cookie cross-origin; handler replays it into the billed
completion). Engine calls stay same-origin. Needs gateway CORS to allow the
studio.hanzo.ai origin with credentials — infra config, no backend code.
Vite+React studio-chat: say what to create, the cloud /v1/chat orchestrator drives
studio's render tools, outputs land in the library. Increment 1 (own minimal UI
wired to /v1/chat create + library/queue/gpu). Served at /studio; the ComfyUI node
editor stays separate in studio-ui (was studio-frontend). Builds clean (npm-published
@hanzo/*).
MCP-over-HTTP (JSON-RPC 2.0) exposing the studio render pipeline as tools the
cloud /v1/chat 'create' capability drives via the tool registry. Reuses the ONE
dispatch path (studio_home.dispatch_fix/dispatch_compose, extracted from the
/v1/library/{fix,compose} routes) so an LLM-issued fix behaves identically to a
UI one. Registered per-org via cloud POST /v1/tools/servers. v0.17.3.
A gallery/console/chat link lands you on the graph ready to render: sets the
session org (so a re-run dispatches to that org's BYO-GPU and outputs to its
gallery), loads the saved workflow, and optionally auto-queues. Injected as a
frontend shim (patch_deeplink.py, step 13 of apply-branding), guarded end-to-end.
/app/models is ephemeral overlay — every model vanished on pod restart
(CheckpointLoaderSimple: 'not in []'). Bake extra_model_paths.yaml into
the image registering /app/orgs/.models (PVC) as the default model store
for all model classes; main.py auto-loads it. Downloads land durable.
A GPU-less cloud pod validates ckpt_name & friends against ITS OWN (empty)
model list, rejecting every checkpoint-referencing graph with 'not in []'
before dispatch could ship it to the org's GPU box. Validation belongs where
execution happens: the dispatch attempt now runs first, and the worker
validates against its real node classes + models. Orgs with no online GPU
fall through to local validation unchanged.
BertModelWarper now delegates to the real BertModel.forward instead of
reimplementing internals whose private signatures drift between releases;
checkpoint weights remap bert.* -> bert.bert_model.* at load. The installer
force-checkouts each pin (discarding prior patches) then applies any
scripts/patches/<pack>-*.patch, so re-runs stay idempotent and the image
build gets the same fix the GB10 venv had by hand.
karma_manifest.py regenerates the ONE canonical library.json index of the
karma org's rendered assets (storefront + socials queue both read it).
studio_watchdog.sh keeps the local render backend on :8188 alive unattended;
paths derive from the script location (STUDIO/STUDIO_PY overridable).