Compare commits

...
Author SHA1 Message Date
Hanzo AI ffea1234a8 upload: 100MB cap — 4k masters run 30-60MB and belong in the library 2026-07-17 12:21:51 -07:00
9993da806e chat: surface the server's error message — a 402 says where to add credits (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:44 -07:00
b5bbbfd724 library: dotfiles are never assets (#18)
The indexer skips them; _safe_asset refuses them (one gate for file serving,
fix sources, and every other path lookup). AppleDouble forks carry image
extensions, serve resource-fork bytes, and rendered 0x0 across the library.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:02 -07:00
73abeb1109 chat: same-origin /v1/agent bridge — the cookie never leaves the host (#17)
The SPA called the gateway cross-origin; the session cookie is host-only, so
every signed-in user got 403 on their first message (401 once the cookie was
widened — the gateway takes bearers, not cookies). The bridge forwards the
caller's bearer server-side, the wallet pattern. VITE_AGENT_API still selects
direct mode.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 11:52:55 -07:00
Hanzo AI f75cb4b0cc studio-chat: keep renders reachable on mobile (library becomes a bottom strip)
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.
2026-07-17 09:38:42 -07:00
c2f16445f2 upload: reject dotfiles and sniff magic bytes (#16)
* upload: reject dotfiles and sniff magic bytes — the name lies, the bytes never do

AppleDouble forks (._foo.png) passed the extension check; 700+ poisoned a
library within an hour of the mirror going live. PNG/JPEG/WebP signatures
required; hidden names rejected.

* upload: size cap precedes the sniff; test payloads carry the full PNG signature

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 01:18:17 -07:00
Hanzo AI 2bf5421313 studio: serve the /v1/agent chat SPA (web/dist) at /studio
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.
2026-07-17 01:13:51 -07:00
93688cac99 worklog: widen ?q= search to paths + refs, not just prompt (#15)
An ingested render row has an empty prompt, so filtering Queue & History by
filename found nothing. Widen the /v1/worklog ?q= filter to also match kind,
output_prefix, output (the landed path), and refs — so a render is findable by
its filename or source image, not only by a typed prompt. Test covers filename,
ref, and prompt matches.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 00:49:53 -07:00
be313e8068 Library ingest: POST /v1/library/upload — every render lands in the library (#13)
* Library ingest: POST /v1/library/upload — every render lands in the library

Tenant-scoped ingest so EVERY render is stored in studio.hanzo.ai, including
renders produced on a GPU node outside the job path (the node's mirror loop
POSTs here). Org via _org_of like every route.

Body: multipart (field image/file) OR raw bytes + ?name=, optional ?subpath=
(default renders). Writes orgs/<org>/output/<subpath>/<name> atomically
(tmp+replace, same pattern as the work log). Rejects traversal (basename-only
name + allowlisted subpath chars), caps size at 30MB, and skips a byte-identical
existing file (name+size match → 200 {ok, existed:true}); else 200 {ok, path}.
The manifest sidecar already indexes output/ — no other wiring.

Tests (studio_home_test): round-trip via GET /v1/library/file, dedup, tenant
isolation (org A upload never visible to org B, no cross-tenant overwrite),
subpath-traversal rejected, traversal-y name neutralized to basename, non-image
rejected, oversize rejected. 259 passed.

* Ship scripts/ whole (unbreak manifest sidecar) + index renders at ingest

(A) Regression: .dockerignore `scripts/*` + `!install_custom_nodes.sh` dropped
scripts/library_manifest.py from the image, so the build-manifest sidecar's
`[ -f ]` guard silently no-op'd and NOTHING got indexed into any org library.
Ship scripts/ whole again.

(B) Make indexing event-driven at the ingest point: POST /v1/library/upload, after
writing the asset, atomically appends the org library.json entry ({path, design?,
kind, role?, status:"draft", tags:[], updatedAt}) AND a worklog row ({kind:"render",
node, prompt:"", refs:[], parents:[], output_prefix:<path>, status:"done"}) — so
every mirrored render is instantly findable in Assets AND Queue & History with its
source node, filterable by run/flow, the moment it exists. Optional ?design= ?kind=
?role= ?node= with safe defaults (node→"upload"). Upserts by path (no dup on
re-render); the existed/dedup path skips indexing. The manifest sidecar stays the
sweeper for files that appear outside the route. _row_output resolves an ingested
render's output (its stored path) so it shows a thumbnail + opens its graph.

Tests: upload → library.json entry + worklog row appear (node, done, resolved
output), tenancy holds, dedup adds no second entry. 260 passed.

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 22:19:20 -07:00
6b5597d8cb home: account menu — the chip advertised one and did nothing (#14)
cursor:pointer + title=Account with no handler. Tap now opens a menu:
identity, Manage account (hanzo.id), Sign out (/logout — existing route).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 21:52:03 -07:00
Hanzo AI dd1f9e7138 studio-chat: call /v1/agent (was /v1/chat), canonical preset field + conversation threading
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.
2026-07-16 21:10:06 -07:00
86bb0a3cad Shell: Studio/Editor toggle, sidebar chat, templates, wallet, GPUs (#11)
One shared shell asset (middleware/shell.{js,css}, served at /studio/shell.*)
is loaded by BOTH views — the Studio home <script>-includes it, and the Editor
gets a single tag injected into the engine index.html by server.get_root. It
renders one chrome in both: a Studio/Editor toggle, wallet chip, GPUs badge +
panel, and a dockable right chat sidebar backed by the ONE chat path
(/v1/copilot/chat). Same-origin cookies carry the IAM session, so login/org are
inherited — no auth is rebuilt.

New tenant-scoped routes (all via _org_of):
  GET  /studio/shell.js|css
  GET  /v1/workflow?id=<worklog id>   graph from the item's output PNG (404 if
                                      not owned / not landed) — Open in Editor
  POST /v1/templates                  save {name, graph} → orgs/<org>/templates
  GET  /v1/templates                  list
  GET  /v1/templates/{name}           fetch
  POST /v1/templates/render           queue through the ONE dispatch funnel
  GET  /v1/wallet                     display-only proxy of the user token to the
                                      cloud finance balance → {org, balance, currency}

Studio home: "Advanced mode" link replaced by the shell toggle; dead chatfab CSS
removed; Templates tab lists org templates (Open in Editor + Render); work-item
context gets Open in Editor; favorite→template now writes a REAL template.

Mobile (verified 390x844 + 768x1024): chat collapses to a full-width bottom
sheet; the pill bar stays one compact line (icon-only Chat/GPUs) so all controls
stay reachable and clear the content; zero horizontal overflow.

Wallet is display + Top up link (pay.hanzo.ai) only — no card fields, no minting.
Tests extend studio_home_test for the new routes (tenant isolation + shape).

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 20:44:50 -07:00
96a633d3ce render: cap startToClose at 4h — the tasks default (~1h) reaped live renders (#12)
Liveness is the 1200s heartbeat; the cap only bounds a live-but-stuck render.
Observed: activities reaped at ~68m mid-render, queue re-ran the same job six
times, results stranded on the node.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-16 19:31:45 -07:00
13 changed files with 1020 additions and 56 deletions
+4 -3
View File
@@ -23,9 +23,10 @@ venv/
.ruff_cache/
tests/
tests-unit/
scripts/*
# ...except the pack installer, which the image build invokes.
!scripts/install_custom_nodes.sh
# scripts/ ships WHOLE: the image build runs scripts/install_custom_nodes.sh and
# the studio pod runs scripts/library_manifest.py as the build-manifest sidecar
# (indexes every org's output/ into library.json). Excluding it silently no-ops
# the sidecar's `[ -f ]` guard, so nothing gets indexed — keep the tree intact.
notebooks/
*.swp
.env
+12
View File
@@ -1,3 +1,12 @@
# ── studio-chat SPA (web/) ── the /v1/agent chat UI served at /studio. Built here
# so the image is self-contained; the small Vite bundle lands at /app/web/dist.
FROM node:20-slim AS web
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY web/ ./
RUN npm run build
FROM python:3.11-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -25,6 +34,9 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
# Copy application code
COPY . .
# The studio-chat SPA built above → served at /studio by middleware/studio_home.py.
COPY --from=web /web/dist ./web/dist
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
# upscaling, video, utils) at their pinned commits and install their deps with the
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
+4
View File
@@ -129,6 +129,10 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
# sample) can exceed 600s; the worker heartbeats each step, so this is
# the ceiling for a genuinely long single render, not idle slack.
"heartbeatTimeout": "1200s",
# Liveness is the heartbeat above; this cap only bounds a live-but-stuck
# render. Unset, the tasks default (~1h) reaped real renders mid-run and
# the queue re-ran them for hours.
"startToCloseTimeout": "14400s",
"input": {
"prompt": prompt,
"org": org_id,
+63
View File
@@ -0,0 +1,63 @@
/* Hanzo Studio shell — the ONE shared chrome, loaded by BOTH the Studio home
* (middleware/studio_home.html) and the Editor (injected into the engine's
* index.html by server.get_root). Self-contained dark palette + `hz` id/class
* prefix so it never collides with the editor's own theme. */
#hzbar{position:fixed;bottom:18px;right:18px;z-index:2147483000;display:flex;align-items:center;gap:8px;
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;
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
.hz-pill:hover{border-color:#3a3a48}
.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-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;
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;
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}
#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}
/* 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.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: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}
/* 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
used); the pill bar wraps upward from the bottom-right so the toggle, wallet
and GPUs badge all stay reachable; the GPUs panel spans the width. */
@media (max-width:600px){
#hzbar{bottom:14px;right:14px;gap:7px}
.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;
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
#hzchat.on{transform:translateY(0)}
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
}
+201
View File
@@ -0,0 +1,201 @@
/* Hanzo Studio shell — the ONE shared chrome for BOTH views.
*
* Studio home (/studio) includes this via <script src="/studio/shell.js">
* Editor (/ ?advanced=1) gets the same tag injected into the engine's
* index.html by server.get_root
*
* It renders (identically in both views): a Studio/Editor toggle, a wallet chip,
* a GPUs badge + panel, and a dockable right chat SIDEBAR backed by the ONE chat
* path (POST /v1/copilot/chat). Same-origin cookies carry the IAM session, so org
* + login are inherited — no auth is rebuilt here. In the editor it also honours
* ?load=<worklog id> / ?template=<name> to open a graph, and offers Save as
* template. Guarded end to end: any failure logs and no-ops, never throws. */
(function () {
"use strict";
if (window.__hzShell) return;
window.__hzShell = 1;
var STUDIO = location.pathname === "/studio" || location.pathname === "/studio/";
var $ = function (id) { return document.getElementById(id); };
var el = function (tag, attrs, html) {
var e = document.createElement(tag);
if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
if (html != null) e.innerHTML = html;
return e;
};
var esc = function (s) { return String(s == null ? "" : s).replace(/[<>&]/g, function (c) { return { "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]; }); };
async function getJSON(url, opt) {
var r = await fetch(url, Object.assign({ credentials: "same-origin" }, opt || {}));
var j = {}; try { j = await r.json(); } catch (_) {}
return { ok: r.ok, status: r.status, body: j };
}
// ── chrome: bottom-right pill cluster + docked chat sidebar + GPUs panel ──────
var bar = el("div", { id: "hzbar" });
var toggle = el("a", { class: "hz-pill", href: STUDIO ? "/?advanced=1" : "/studio",
title: STUDIO ? "Open the node editor" : "Back to Studio" }, STUDIO ? "⇄ Editor" : "⇄ Studio");
var tmplBtn = STUDIO ? null : el("button", { class: "hz-pill", title: "Save the current graph as a reusable template" }, "★ Template");
var wallet = el("a", { class: "hz-pill", id: "hzwallet", href: "https://pay.hanzo.ai", target: "_blank", rel: "noopener",
title: "Wallet — top up at pay.hanzo.ai" }, "wallet …");
var gpus = el("button", { class: "hz-pill", id: "hzgpubtn", title: "GPUs" }, '<span class="hz-dot"></span><span class="hz-lbl">GPUs</span>');
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '💬<span class="hz-lbl"> Chat</span>');
bar.appendChild(toggle);
if (tmplBtn) bar.appendChild(tmplBtn);
bar.appendChild(wallet); bar.appendChild(gpus); bar.appendChild(chatBtn);
var gpuPanel = el("div", { id: "hzgpus" }, '<h4>GPUs</h4><div id="hznodes"></div>');
var chat = el("aside", { id: "hzchat" });
chat.innerHTML =
'<div class="hz-h"><span class="hz-av" id="hzav">·</span>' +
'<span class="hz-who"><b id="hzuser">…</b><span id="hzorg"></span></span>' +
'<button class="hz-x" id="hzclose" title="Close">×</button></div>' +
'<div id="hzmsgs"></div>' +
'<form id="hzform"><input id="hzin" placeholder="Ask the copilot…" autocomplete="off"><button type="submit">Send</button></form>';
function mount() {
document.body.appendChild(bar);
document.body.appendChild(gpuPanel);
document.body.appendChild(chat);
wire();
loadSession(); loadWallet(); loadGpus();
if (!STUDIO) editorBoot();
}
// ── 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 m = el("div", { class: "hz-m " + role }, esc(text));
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
return m;
}
async function graphContext() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") return {};
try {
var p = await app.graphToPrompt();
var ctx = { graph_json: p.output || {} };
try { ctx.catalog = { types: Object.keys((window.LiteGraph && window.LiteGraph.registered_node_types) || {}) }; } catch (_) {}
return ctx;
} catch (_) { return {}; }
}
async function send(text) {
HISTORY.push({ role: "user", content: text });
addMsg("u", text);
var pending = addMsg("sys", "…");
var payload = Object.assign({ 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 + ")");
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
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); }
} else if (ops.length && STUDIO) {
addMsg("sys", "Open the Editor to apply " + ops.length + " graph change" + (ops.length > 1 ? "s" : "") + ".");
}
}
function wire() {
chatBtn.onclick = function () { chat.classList.toggle("on"); if (chat.classList.contains("on")) $("hzin").focus(); };
$("hzclose").onclick = function () { chat.classList.remove("on"); };
$("hzform").onsubmit = function (e) {
e.preventDefault();
var v = $("hzin").value.trim(); if (!v) return;
$("hzin").value = ""; send(v).catch(function (err) { addMsg("sys", "error: " + err.message); });
};
gpus.onclick = function () { gpuPanel.classList.toggle("on"); if (gpuPanel.classList.contains("on")) loadGpus(); };
if (tmplBtn) tmplBtn.onclick = saveTemplate;
}
// ── session · wallet · GPUs ───────────────────────────────────────────────────
async function loadSession() {
var r = await getJSON("/v1/session"); var s = r.body || {};
var nm = (s.user || s.email || "You").trim();
$("hzav").textContent = (nm[0] || "·").toUpperCase();
$("hzuser").textContent = nm;
$("hzorg").textContent = s.org ? ("org · " + s.org) : "";
}
function money(n, cur) {
if (n == null || isNaN(n)) return "—";
var sym = (cur || "").toLowerCase() === "usd" ? "$" : "";
return sym + Number(n).toFixed(2) + (sym ? "" : " " + (cur || "").toUpperCase());
}
async function loadWallet() {
var r = await getJSON("/v1/wallet"); var w = r.body || {};
wallet.innerHTML = esc(money(w.balance, w.currency)) + '<span class="hz-topup">Top up ↗</span>';
wallet.title = "Wallet" + (w.org ? " · " + w.org : "") + " — top up at pay.hanzo.ai";
}
async function loadGpus() {
var nres = await getJSON("/v1/nodes"); var qres = await getJSON("/v1/queue/status");
var nodes = (nres.body && nres.body.nodes) || [];
var pod = (nres.body && nres.body.pod) || null;
if (pod) nodes = nodes.concat([pod]);
var items = (qres.body && qres.body.items) || {};
var byNode = {};
Object.keys(items).forEach(function (id) {
var it = items[id]; var k = it.node || "gpu";
(byNode[k] = byNode[k] || { running: null, depth: 0 });
byNode[k].depth++;
if (it.status === "running") byNode[k].running = it.prompt || it.kind || "a render";
});
var online = nodes.filter(function (n) { return n.online; }).length;
gpus.innerHTML = '<span class="hz-dot"></span><span class="hz-lbl">GPUs </span>' + online + "/" + nodes.length;
gpus.classList.toggle("on", online > 0);
$("hznodes").innerHTML = nodes.length ? nodes.map(function (n) {
var s = byNode[n.name] || { running: null, depth: 0 };
var sub = (n.online ? "online" : "offline") +
(s.running ? " · running: " + esc(String(s.running).slice(0, 40)) : (n.online ? " · idle" : "")) +
" · queue " + s.depth;
return '<div class="hz-node ' + (n.online ? "on" : "") + '"><span class="hz-dot"></span>' +
'<div class="hz-nn"><b>' + esc(n.name) + '</b><div class="hz-nsub">' + sub + "</div></div></div>";
}).join("") : '<div class="hz-nsub">No GPU nodes connected. Run <b>hanzo gpu connect</b> on a box to add one.</div>';
}
// ── editor-only: open a graph (?load / ?template), Save as template ────────────
function waitForApp(ms) {
return new Promise(function (resolve, reject) {
var t0 = Date.now();
(function poll() {
if (window.app && typeof window.app.loadGraphData === "function") return resolve(window.app);
if (Date.now() - t0 > ms) return reject(new Error("editor not ready"));
setTimeout(poll, 150);
})();
});
}
function editorLoad(g) {
var app = window.app; if (!app || !g) return false;
if (g.workflow && typeof app.loadGraphData === "function") { app.loadGraphData(g.workflow); return true; }
if (g.output && typeof app.loadApiJson === "function") { app.loadApiJson(g.output, "hanzo"); return true; }
if ((g.nodes || g.last_node_id != null) && typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
if (typeof app.loadApiJson === "function") { app.loadApiJson(g, "hanzo"); return true; }
if (typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
return false;
}
async function editorBoot() {
var q = new URLSearchParams(location.search);
var load = q.get("load"), template = q.get("template");
if (!load && !template) return;
try {
await waitForApp(30000);
var url = load ? "/v1/workflow?id=" + encodeURIComponent(load) : "/v1/templates/" + encodeURIComponent(template);
var r = await getJSON(url);
if (!r.ok) { addMsg("sys", "couldn't open: " + ((r.body && r.body.error) || r.status)); return; }
editorLoad(r.body.graph || r.body);
} catch (e) { console.warn("[hz shell]", e); }
}
async function saveTemplate() {
var app = window.app;
if (!app || typeof app.graphToPrompt !== "function") { alert("Open a workflow in the editor first."); return; }
var name = prompt("Save this graph as a template named:");
if (!name || !name.trim()) return;
var g;
try { g = await app.graphToPrompt(); } catch (e) { alert("Couldn't read the graph: " + e.message); return; }
var r = await getJSON("/v1/templates", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), graph: { workflow: g.workflow, output: g.output } }) });
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
}
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount);
})();
+50 -23
View File
@@ -2,6 +2,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hanzo Studio</title>
<link rel="stylesheet" href="/studio/shell.css">
<script src="/studio/shell.js" defer></script>
<style>
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
@@ -22,6 +24,11 @@ header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);bord
header .sp{flex:1}
header .lnk{color:var(--dim);font-size:.85rem;text-decoration:none}
header .user{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:99px;padding:3px 11px 3px 4px;cursor:pointer}
#acctmenu{position:fixed;top:52px;right:12px;background:#0e0e12;border:1px solid var(--line);border-radius:12px;padding:6px;display:none;z-index:60;min-width:210px;box-shadow:0 16px 44px #000a}
#acctmenu.on{display:block}
#acctmenu .hd{padding:8px 12px;color:var(--dim);font-size:.78rem;border-bottom:1px solid var(--line);margin-bottom:4px}
#acctmenu .mi{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;cursor:pointer;font-size:.88rem;color:var(--txt);text-decoration:none}
#acctmenu .mi:hover{background:#ffffff10}
header .user .av{width:24px;height:24px;border-radius:50%;background:#26262e;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:600}
header .user .nm{font-size:.82rem;color:var(--dim)}
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
@@ -143,18 +150,8 @@ body.immersive #stage{display:block}
#stage .dots span{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.35);cursor:pointer} #stage .dots span.on{background:#fff;width:20px;border-radius:4px}
#stage .exit{position:absolute;top:18px;right:22px;z-index:7;color:#fff;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;opacity:0;transition:.2s} #stage:hover .exit{opacity:1}
.viewbtn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
/* ── Hanzo AI chat widget ──────────────────────────────────────────────────── */
#chatfab{position:fixed;bottom:22px;right:22px;width:52px;height:52px;border-radius:50%;background:var(--brand);color:#111;border:none;font-size:1.4rem;cursor:pointer;z-index:48;box-shadow:0 8px 24px #0008;display:flex;align-items:center;justify-content:center}
#chatpanel{position:fixed;bottom:22px;right:22px;width:380px;max-width:92vw;height:560px;max-height:82vh;background:#0e0e12;border:1px solid var(--line);border-radius:16px;z-index:49;display:none;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px #000b}
#chatpanel.on{display:flex} #chatpanel .ch{display:flex;align-items:center;gap:9px;padding:14px 16px;border-bottom:1px solid var(--line)}
#chatpanel .ch .av{width:26px;height:26px;border-radius:7px;background:#111;display:flex;align-items:center;justify-content:center}
#chatpanel .ch b{font-size:.9rem} #chatpanel .ch .x{margin-left:auto;cursor:pointer;color:var(--dim);font-size:1.2rem}
#chatmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.cmsg{max-width:86%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap}
.cmsg.u{align-self:flex-end;background:var(--brand);color:#111;border-bottom-right-radius:3px}
.cmsg.a{align-self:flex-start;background:#1a1a20;border:1px solid var(--line);border-bottom-left-radius:3px}
#chatbar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
#chatbar input{flex:1;background:#0a0a0d;border:1px solid var(--line);border-radius:9px;color:var(--txt);padding:9px 11px;font:inherit;font-size:.85rem}
/* The Studio/Editor toggle, wallet, GPUs and chat sidebar are the shared shell
(/studio/shell.css + shell.js), loaded in both views. */
/* ── Responsive: tablet ≤900px, phone ≤600px ─────────────────────────────── */
@media (max-width:900px){
main{padding:12px 16px 90px}
@@ -162,7 +159,6 @@ body.immersive #stage{display:block}
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px}
.tgrid{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
#livebar{margin-left:16px;margin-right:16px}
#chatpanel{width:calc(100vw - 24px);height:70vh}
}
@media (max-width:600px){
header{padding:11px 14px;gap:9px}
@@ -180,8 +176,6 @@ body.immersive #stage{display:block}
.search{width:120px} .tabs .sp{display:none}
#livebar{flex-wrap:wrap;font-size:.78rem}
#bulkbar,#reftray{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
#chatfab{bottom:16px;right:16px}
#chatpanel{bottom:0;right:0;left:0;width:100vw;height:82vh;max-height:82vh;border-radius:16px 16px 0 0}
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
}
/* touch: no hover-reveal — always show card tools on touch devices */
@@ -192,10 +186,10 @@ body.immersive #stage{display:block}
<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>
<h1>Hanzo Studio</h1><span class="beta">Beta</span><span class="sp"></span>
<a class="lnk" href="/?advanced=1">Advanced mode</a>
<button class="btn" onclick="load()">Refresh</button>
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org"></span></div>
</header>
<div id="acctmenu"><div class="hd" id="acctwho">Account</div><a class="mi" href="https://hanzo.id" target="_blank" rel="noopener">Manage account ↗</a><a class="mi" href="/logout">Sign out</a></div>
<div id="tophover"></div>
<div id="livebar" class="idle">
<span id="lbstatus" class="lb-q">Checking queue…</span>
@@ -238,7 +232,12 @@ body.immersive #stage{display:block}
<button class="mini" onclick="bulk('deleted')">Delete</button>
<button class="mini" onclick="clearSel()">Cancel</button>
</div>
<section id="templates" hidden><div class="grid" id="tgall"></div></section>
<section id="templates" hidden>
<div class="sec-l">Your templates</div>
<div class="tgrid" id="orgtmpl"></div>
<div class="sec-l">Start from a kind</div>
<div class="grid" id="tgall"></div>
</section>
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
<section id="runs" hidden>
<div id="qhead" class="qhead"></div>
@@ -290,6 +289,7 @@ body.immersive #stage{display:block}
<div class="row" style="justify-content:flex-end;margin-top:14px;gap:6px">
<button class="mini" id="ctxcancel" onclick="cancelCtx()" style="display:none">✕ Cancel job</button>
<button class="mini" id="ctxedit" onclick="editCtx()" style="display:none">✎ Edit</button>
<button class="mini" id="ctxeditor" onclick="editorCtx()" title="Load this graph into the node editor" style="display:none">⇄ Open in Editor</button>
<button class="mini" id="ctxreuse" onclick="reuseCtx()" title="Open Fix primed with this prompt + references">↻ Reuse context</button>
<button class="mini hot" id="ctxfix" onclick="fixCtx()">Fix this version</button>
</div>
@@ -335,7 +335,26 @@ const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
for(const s of ['assets','templates','pipelines','runs'])$(s).hidden=s!==name;
if(name==='runs')runs(); if(name==='pipelines')pipes();}
if(name==='runs')runs(); if(name==='pipelines')pipes(); if(name==='templates')loadOrgTemplates();}
// ── Org templates: saved graphs, Open in Editor + Render (the ONE dispatch path) ──
async function loadOrgTemplates(){
let d={templates:[]}; try{d=await (await fetch('/v1/templates')).json();}catch(_){}
const ts=d.templates||[];
$('orgtmpl').innerHTML=ts.length?ts.map(t=>{const nm=(t.name||t.slug).replace(/</g,'&lt;'),ne=(t.name||t.slug).replace(/'/g,"\\'");
return `<div class="tcard"><div class="ic">★</div><div class="tl">${nm}</div>
<div class="td">${t.ts?new Date(t.ts*1000).toLocaleDateString():''}</div>
<div class="row" style="padding:0 11px 11px;gap:6px">
<a class="mini" href="/?advanced=1&template=${encodeURIComponent(t.slug)}">Open in Editor</a>
${t.renderable?`<button class="mini hot" onclick="renderTemplate('${t.slug}','${ne}')">Render</button>`:''}
</div></div>`;}).join(''):'<div class="empty" style="grid-column:1/-1;padding:20px">No templates yet. In the Editor, build a graph and click ★ Template to save one.</div>';
}
async function renderTemplate(slug,name){
toast('Queueing '+name+'…');
const r=await fetch('/v1/templates/render',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:slug})});
const j=await r.json().catch(()=>({}));
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); }
function pickTemplate(k,t){$('tmpl').textContent=t; tab('assets'); $('pin').focus(); toast('Template: '+t);}
function pickPreset(){toast('Design systems / presets — coming in the preset library');}
function create(){const p=$('pin').value.trim(); const t=$('tmpl').textContent;
@@ -589,6 +608,7 @@ async function openContext(id){
$('ctxfix').style.display=hasBase?'':'none';
$('ctxcancel').style.display=live?'':'none';
$('ctxedit').style.display=live?'':'none';
$('ctxeditor').style.display=(it.output&&it.id&&!String(it.id).startsWith('failed-'))?'':'none';
$('ctxdlg').showModal();
}
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
@@ -596,7 +616,7 @@ function editCtx(){ if(!CTX)return; ctxdlg.close(); openAmend(CTX.id); }
async function openLineage(path){
CTX={output:path,parents:[],refs:[],uploads:[],prompt:'',kind:'asset',status:''};
$('ctxtitle').textContent='Versions'; $('ctxbody').innerHTML=ctxHTML(CTX, await lineageFor(path));
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxdlg').showModal();
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxeditor').style.display='none'; $('ctxdlg').showModal();
}
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
async function primeFix(base,prompt,libRefs,upRefs){
@@ -672,10 +692,15 @@ async function toggleFav(key,title){const cur=RATINGS[key]||{};const nv=!cur.fav
else runs();
}
async function saveTemplate(){const nm=$('tmplname').value.trim();if(!nm){toast('Name the template');return;}
// Real save lands with the studio-app template store (HIP-0506 phase 3); for now
// persist intent on the rating entry so it's not lost and surfaces the guided flow.
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:FAVKEY,notes:'TEMPLATE: '+nm+' — '+$('tmpldesc').value})});
$('tmpldlg').close();toast('★ Saved "'+nm+'" — it will appear under Templates');runs();
// Real save: the favorited run's graph is embedded in its output (fetched via
// /v1/workflow by the run's id) → POST /v1/templates, the ONE template store the
// Editor's ★ Template also writes to.
let g=null; try{const wr=await fetch('/v1/workflow?id='+encodeURIComponent(FAVKEY)); if(wr.ok)g=(await wr.json()).graph;}catch(_){}
if(!g){$('tmpldlg').close();toast('No graph on this run — open it in the Editor and use ★ Template');return;}
const r=await fetch('/v1/templates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nm,graph:g})});
const j=await r.json().catch(()=>({}));
$('tmpldlg').close();toast(r.ok?('★ Saved "'+nm+'" — see Templates'):('Failed: '+(errMsg(j)||('error '+r.status))));
if(r.ok&&!$('templates').hidden)loadOrgTemplates();
}
async function setSt(i,status){const a=DATA.assets[i];const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path,status})});if(r.ok){a.status=status;render();toast(`${a.path.split('/').pop()}${status}`);}else toast('Failed');}
// Optimistic trash: mark deleted locally + re-render immediately (feels instant),
@@ -788,6 +813,8 @@ async function cancelJob(id){
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
const af=$('attachfile');
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
const uc=$('user'); if(uc){uc.addEventListener('click',e=>{e.stopPropagation();$('acctwho').textContent=($('org').textContent||'').trim()||'Account';$('acctmenu').classList.toggle('on');});
document.addEventListener('click',e=>{const m=$('acctmenu');if(m&&m.classList.contains('on')&&!m.contains(e.target))m.classList.remove('on');});}
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
})();
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
+386 -6
View File
@@ -38,6 +38,7 @@ import os
import random
import re
import time
import uuid
from pathlib import Path
import aiohttp
@@ -49,6 +50,12 @@ from middleware import worklog
log = logging.getLogger("studio.home")
_HTML = Path(__file__).with_name("studio_home.html")
_ASSET_DIR = Path(__file__).parent
# The studio-chat SPA (web/, a Vite bundle on @hanzo/ai talking to /v1/agent) built
# into the image at /app/web/dist and served AT /studio. When the build is absent
# (dev checkout, an image that skipped the web stage) /studio falls back to the
# legacy studio_home.html so the route is never a dark page.
_WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
@@ -142,6 +149,10 @@ def _save_library(root: Path, lib: dict) -> None:
def _safe_asset(root: Path, rel: str) -> Path | None:
p = (root / rel).resolve()
if p.name.startswith("."):
# Dotfiles are never assets (AppleDouble forks carry image extensions
# but serve resource-fork bytes) — refuse them everywhere at once.
return None
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
@@ -589,18 +600,322 @@ async def home_redirect(request: web.Request, handler):
return await handler(request)
# ── Shared shell: the ONE chrome (Studio/Editor toggle, wallet, GPUs, chat
# sidebar) loaded by BOTH views. The Studio home <script>-includes it; the Editor
# gets it injected into the engine's index.html by server.get_root. ──────────────
_SHELL_HEAD = '<link rel="stylesheet" href="/studio/shell.css">'
_SHELL_BODY = '<script src="/studio/shell.js" defer></script>'
def _asset(name: str, ctype: str) -> web.Response:
"""Serve a shell asset. no-store like the SPA HTML — it changes every release."""
return web.Response(text=(_ASSET_DIR / name).read_text(), content_type=ctype,
headers={"Cache-Control": "no-store, must-revalidate"})
def editor_index(web_root: str) -> web.Response:
"""The engine's index.html with the shared shell injected — the ONE mechanism
the Editor picks up the Studio/Editor toggle, wallet, GPUs and chat sidebar, the
same chrome the Studio home includes. Idempotent; no-cache like the upstream
root handler (the frontend changes every release)."""
html = Path(web_root, "index.html").read_text()
if "/studio/shell.js" not in html:
html = html.replace("</head>", _SHELL_HEAD + "</head>", 1) if "</head>" in html else _SHELL_HEAD + html
html = html.replace("</body>", _SHELL_BODY + "</body>", 1) if "</body>" in html else html + _SHELL_BODY
return web.Response(text=html, content_type="text/html",
headers={"Cache-Control": "no-cache", "Pragma": "no-cache", "Expires": "0"})
def _templates_root(org: str) -> Path:
"""orgs/<org>/templates — a per-org sibling of the output tree that holds
library.json/worklog.json, so templates are tenant-scoped exactly like every
other per-org file. Created on demand."""
out = Path(folder_paths.get_org_output_directory(org))
base = out.parent if out.parent.name == org else out
d = base / "templates"
d.mkdir(parents=True, exist_ok=True)
return d
def _template_slug(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")[:60]
def _template_api_prompt(graph) -> dict | None:
"""The runnable API prompt inside a stored template graph. A graph saved from the
Editor is ``{workflow, output}`` (output = API prompt); one captured from an
output PNG is a bare API prompt (numeric ids -> {class_type, inputs}). A UI-only
graph has neither and can't be queued headless — returns None so the caller says
so instead of dispatching nothing."""
if not isinstance(graph, dict) or not graph:
return None
out = graph.get("output")
if isinstance(out, dict) and out:
return out
if all(isinstance(v, dict) and "class_type" in v for v in graph.values()):
return graph
return None
async def _finance_balance(tok: str) -> dict | None:
"""Raw cloud finance balance for the caller's IAM token, or None on any failure.
The token scopes the org server-side (cloud pins org from the verified owner
claim), so this read is inherently tenant-isolated."""
from middleware.gpu_dispatch import CLOUD
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as s:
async with s.get(f"{CLOUD}/v1/finance/balance", headers={"Authorization": tok}) as r:
return await r.json() if r.status == 200 else None
except Exception:
return None
async def _wallet(request: web.Request, org: str) -> dict:
"""{org, balance, currency} for the wallet chip — a display-only proxy. Balance
is the spendable dollar amount (availableCents/100); None when signed-out or the
cloud is unreachable (the chip shows '', never a fabricated number)."""
from middleware.gpu_dispatch import _bearer
out = {"org": org, "balance": None, "currency": None}
tok = _bearer(request)
if not tok:
return out
b = await _finance_balance(tok)
if isinstance(b, dict):
cents = b.get("availableCents")
if isinstance(cents, (int, float)):
out["balance"] = round(cents / 100, 2)
out["currency"] = b.get("currency") or "usd"
return out
_MAX_UPLOAD = 100 * 1024 * 1024 # 100 MB — 4k masters run 30-60MB; the cap must hold the largest real render
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp")
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
def _safe_subpath(raw: str) -> str | None:
"""The library subfolder an upload lands in — allowlist chars only (no traversal,
no absolute), default ``renders``. Returns None for anything outside the allowlist
so the route can reject it."""
sub = (raw or "").strip("/") or "renders"
return sub if _SUBPATH_RE.fullmatch(sub) else None
def _row_output(root: Path | None, output_prefix: str | None) -> str | None:
"""The library-relative output for a work-log row: the file a SaveImage prefix
produced, or — for an INGESTED render whose output_prefix is already the stored
file's path — that path itself. Lets a mirrored render show its thumbnail and open
its graph, without a SaveImage-style counter suffix."""
landed = _landed_output(root, output_prefix)
if landed is None and root is not None and output_prefix and _safe_asset(root, output_prefix) is not None:
landed = output_prefix
return landed
def _index_upload(org: str, root: Path, rel: str, *, design, kind, role, node) -> None:
"""Event-driven index at the ingest point so a just-stored render is INSTANTLY
findable — in Assets (a library.json entry) and in Queue & History (a done
work-log row carrying its source node) — without waiting for the manifest sweeper.
Atomic per-org writes like every other metadata file; upserts by path so a
re-render of the same name never duplicates the asset."""
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
lib = _load_library(root)
assets = lib.setdefault("assets", [])
ex = next((a for a in assets if a.get("path") == rel), None)
if ex is None:
assets.append({"path": rel, "design": design, "kind": kind, "role": role,
"status": "draft", "tags": [], "updatedAt": now_iso})
else:
ex.update(design=design, kind=kind, role=role, updatedAt=now_iso)
_save_library(root, lib)
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
output_prefix=rel, node=node, status="done",
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
pid="up-" + uuid.uuid4().hex[:12])
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
@routes.get("/studio")
async def studio_home(request: web.Request):
# no-store: the SPA HTML changes every release; without this, browsers
# (and any proxy) heuristically cache it and serve a STALE page after a
# deploy — which is why UI fixes (e.g. delete) appeared "not working" to
# a user still running yesterday's cached studio_home.html. The page is
# tiny; always serve fresh. Static assets keep their own long cache.
return web.Response(text=_HTML.read_text(), content_type="text/html",
# deploy — which is why UI fixes appeared "not working" to a user still
# running yesterday's cached page. The page is tiny; always serve fresh.
# Hashed assets keep their own immutable cache. Serve the studio-chat SPA
# when built into the image; fall back to the legacy home otherwise.
index = _WEB_DIST / "index.html"
html = index.read_text() if index.is_file() else _HTML.read_text()
return web.Response(text=html, content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
@routes.get("/studio/assets/{name}")
async def studio_asset(request: web.Request):
# The studio-chat SPA's hashed bundle (the hash IS the version → immutable).
name = request.match_info["name"]
assets = (_WEB_DIST / "assets").resolve()
f = (assets / name).resolve()
if f.parent != assets or not f.is_file():
return web.Response(status=404)
ctype = ("text/javascript" if name.endswith(".js")
else "text/css" if name.endswith(".css")
else "application/octet-stream")
return web.Response(body=f.read_bytes(), content_type=ctype,
headers={"Cache-Control": "public, max-age=31536000, immutable"})
@routes.get("/studio/shell.js")
async def shell_js(request: web.Request):
return _asset("shell.js", "text/javascript")
@routes.get("/studio/shell.css")
async def shell_css(request: web.Request):
return _asset("shell.css", "text/css")
@routes.get("/v1/workflow")
async def get_workflow(request: web.Request):
"""The graph that produced a work-log item, for 'Open in Editor'. Tenant-
scoped: the id must be in THIS org's work log or it 404s (a cross-tenant id
is never confirmed). The graph is read from the item's output PNG, which
embeds the litegraph 'workflow' (UI) and/or the API 'prompt'."""
org = _org_of(request)
pid = request.query.get("id", "")
row = next((r for r in worklog.load(org) if r.get("id") == pid), None)
if row is None:
return web.json_response({"error": "not found"}, status=404)
root = _library_root(org)
landed = _row_output(root, row.get("output_prefix"))
p = _safe_asset(root, landed) if (root and landed) else None
if p is None or p.suffix.lower() != ".png":
return web.json_response({"error": "workflow not available yet"}, status=404)
try:
from PIL import Image
info = Image.open(p).info
except Exception:
info = {}
for key, fmt in (("workflow", "ui"), ("prompt", "api")):
try:
g = json.loads(info.get(key, "null"))
except Exception:
g = None
if isinstance(g, dict) and g:
return web.json_response({"id": pid, "format": fmt, "graph": g})
return web.json_response({"error": "no embedded workflow in this output"}, status=422)
@routes.post("/v1/templates")
async def save_template(request: web.Request):
"""Save a workflow graph as an org template (orgs/<org>/templates/<slug>.json,
atomic tmp+replace like the work log). Server-resolved org — a template is
only ever written to, and read from, the caller's own tenant tree."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
name = (body.get("name") or "").strip()
graph = body.get("graph")
slug = _template_slug(name)
if not slug:
return web.json_response({"error": "name required"}, status=400)
if not isinstance(graph, dict) or not graph:
return web.json_response({"error": "graph required"}, status=400)
rec = {"name": name, "slug": slug, "graph": graph, "ts": int(time.time())}
d = _templates_root(_org_of(request))
tmp = d / (slug + ".json.tmp")
tmp.write_text(json.dumps(rec))
tmp.replace(d / (slug + ".json"))
return web.json_response({"ok": True, "name": name, "slug": slug})
@routes.get("/v1/templates")
async def list_templates(request: web.Request):
org = _org_of(request)
out = []
for f in sorted(_templates_root(org).glob("*.json")):
try:
rec = json.loads(f.read_text())
except Exception:
continue
out.append({"name": rec.get("name") or f.stem, "slug": rec.get("slug") or f.stem,
"ts": rec.get("ts", 0),
"renderable": _template_api_prompt(rec.get("graph")) is not None})
return web.json_response({"org": org, "templates": out})
@routes.post("/v1/templates/render")
async def render_template(request: web.Request):
"""Queue a saved template through the ONE dispatch funnel (reseeded); its
output lands in the library via the manifest sidecar — no new path. A
UI-only graph can't run headless, so it's rejected with a clear reason."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
org = _org_of(request)
slug = _template_slug(body.get("name", ""))
f = _templates_root(org) / (slug + ".json")
if not slug or not f.is_file():
return web.json_response({"error": "not found"}, status=404)
try:
rec = json.loads(f.read_text())
except Exception:
return web.json_response({"error": "corrupt template"}, status=422)
prompt = _template_api_prompt(rec.get("graph"))
if prompt is None:
return web.json_response(
{"error": "this template has no runnable graph — open it in the Editor to run"}, status=422)
out_prefix = ""
for n in prompt.values():
if isinstance(n, dict) and n.get("class_type") == "SaveImage":
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
out_prefix = out_prefix or f"templates/{slug}"
try:
pid = await _dispatch(request, server, org, _reseed(json.loads(json.dumps(prompt))),
kind="template", prompt=rec.get("name", slug), refs=[], uploads=[],
parents=[], output_prefix=out_prefix)
except DispatchError as e:
return web.json_response({"error": str(e)}, status=400)
return web.json_response({"ok": True, "prompt_id": pid, "slug": slug})
@routes.get("/v1/templates/{name}")
async def get_template(request: web.Request):
org = _org_of(request)
slug = _template_slug(request.match_info.get("name", ""))
f = _templates_root(org) / (slug + ".json")
if not slug or not f.is_file():
return web.json_response({"error": "not found"}, status=404)
try:
return web.json_response(json.loads(f.read_text()))
except Exception:
return web.json_response({"error": "corrupt template"}, status=422)
@routes.get("/v1/wallet")
async def get_wallet(request: web.Request):
"""The org's wallet balance for the shell chip — a display-only proxy of the
user's IAM token to the cloud finance balance. Returns {org, balance,
currency}; top-up is a link to pay.hanzo.ai in the UI. No funds move here."""
org = _org_of(request)
return web.json_response(await _wallet(request, org))
@routes.post("/v1/agent")
async def agent_bridge(request: web.Request):
"""Same-origin bridge to the cloud agent orchestrator. The session cookie
stays host-only (never widened to *.hanzo.ai); this forwards the caller's
bearer server-side — the wallet pattern. Direct-to-gateway remains
available to the SPA via VITE_AGENT_API."""
from middleware.gpu_dispatch import CLOUD, _bearer
tok = _bearer(request)
if not tok:
return web.json_response({"error": "auth required"}, status=401)
body = await request.read()
if len(body) > 1 << 20:
return web.json_response({"error": "body too large"}, status=413)
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=180)) as s:
async with s.post(f"{CLOUD}/v1/agent", data=body,
headers={"Authorization": tok,
"Content-Type": "application/json"}) as r:
return web.Response(status=r.status, body=await r.read(),
content_type="application/json")
except Exception:
return web.json_response({"error": "agent unreachable"}, status=502)
@routes.get("/v1/library")
async def get_library(request: web.Request):
org = _org_of(request)
@@ -660,6 +975,67 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
pass # fall through to full-res on any thumbnail failure
return web.FileResponse(p)
@routes.post("/v1/library/upload")
async def library_upload(request: web.Request):
"""Tenant-scoped render ingest so EVERY render lands in the library — including
ones produced on a GPU node outside the job path (the node's mirror loop POSTs
here). Body is multipart (field ``image``/``file``) or raw bytes + ``?name=``,
with an optional ``?subpath=`` (default ``renders``). Writes
orgs/<org>/output/<subpath>/<name> atomically (tmp+replace, same as the work
log); rejects traversal (basename-only name + allowlisted subpath); caps size;
and skips a byte-identical existing file (name+size match → existed). The
manifest sidecar indexes output/ — no other wiring."""
org = _org_of(request)
sub = _safe_subpath(request.query.get("subpath", ""))
if sub is None:
return web.json_response({"error": "invalid subpath"}, status=400)
clen = request.content_length
if clen is not None and clen > _MAX_UPLOAD:
return web.json_response({"error": "file too large"}, status=413)
if request.headers.get("Content-Type", "").startswith("multipart/"):
post = await request.post()
f = post.get("image") or post.get("file")
if f is None or not hasattr(f, "file"):
return web.json_response({"error": "no image field"}, status=400)
name = os.path.basename(f.filename or "")
data = f.file.read()
else:
data = await request.read()
name = os.path.basename(request.query.get("name", ""))
if not name or os.path.splitext(name)[1].lower() not in _UPLOAD_EXT:
return web.json_response({"error": "image name required (.png/.jpg/.jpeg/.webp)"}, status=400)
if name.startswith("."):
# `._foo.png` (AppleDouble fork) and other dotfiles pass the extension
# check but are not renders.
return web.json_response({"error": "hidden file"}, status=400)
if not data:
return web.json_response({"error": "empty body"}, status=400)
if len(data) > _MAX_UPLOAD:
return web.json_response({"error": "file too large"}, status=413)
if not (data[:8] == b"\x89PNG\r\n\x1a\n"
or data[:3] == b"\xff\xd8\xff"
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")):
# The name lies sometimes; the bytes never do.
return web.json_response({"error": "not an image"}, status=400)
out_root = Path(folder_paths.get_org_output_directory(org))
dest = (out_root / sub / name).resolve()
if not str(dest).startswith(str(out_root.resolve()) + os.sep):
return web.json_response({"error": "invalid path"}, status=400)
rel = f"{sub}/{name}"
if dest.is_file() and dest.stat().st_size == len(data):
return web.json_response({"ok": True, "existed": True, "path": rel})
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(dest)
# index at ingest → instantly findable in Assets + Queue & History (with node)
_index_upload(org, out_root, rel,
design=request.query.get("design") or None,
kind=request.query.get("kind") or "render",
role=request.query.get("role") or None,
node=request.query.get("node") or "upload")
return web.json_response({"ok": True, "path": rel})
@routes.get("/v1/gpu-status")
async def gpu_status(request: web.Request):
"""Does the caller's org have an online GPU to render on? The UI showed
@@ -874,14 +1250,18 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
out = []
for r in reversed(rows):
row = dict(r)
landed = _landed_output(root, r.get("output_prefix"))
landed = _row_output(root, r.get("output_prefix"))
row["output"] = landed
if landed and r.get("status") not in ("failed", "cancelled"):
row["status"] = "done"
out.append(row)
q = (request.query.get("q") or "").lower().strip()
if q:
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
# search prompt + kind + the render's paths (output_prefix/output) + refs, so
# an ingested render with an empty prompt is still findable by its filename.
out = [r for r in out if q in " ".join(str(x) for x in
[r.get("prompt", ""), r.get("kind", ""), r.get("output_prefix", ""),
r.get("output", ""), *(r.get("refs") or [])]).lower()]
return web.json_response({"org": org, "items": out[:200]})
@routes.get("/v1/worklog/lineage")
+5
View File
@@ -93,6 +93,11 @@ def scan(root: str) -> list[str]:
found: list[str] = []
for base, _dirs, files in os.walk(root):
for f in files:
if f.startswith("."):
# Dotfiles are never assets: `._foo.png` AppleDouble forks ride
# along with mac transfers, carry an image extension, and are
# resource-fork bytes that render 0x0 in the library.
continue
ext = os.path.splitext(f)[1].lower()
full = os.path.join(base, f)
rel = os.path.relpath(full, root)
+5 -5
View File
@@ -435,11 +435,11 @@ class PromptServer():
},
)
response = web.FileResponse(os.path.join(self.web_root, "index.html"))
response.headers['Cache-Control'] = 'no-cache'
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Serve the editor SPA with the shared Studio shell injected (the ONE
# chrome — Studio/Editor toggle, wallet, GPUs, chat sidebar — that the
# Studio home also includes). No-cache, same as before.
from middleware.studio_home import editor_index
return editor_index(self.web_root)
@routes.get("/embeddings")
def get_embeddings(request):
@@ -340,3 +340,251 @@ async def test_home_redirect_only_hits_root_html(monkeypatch):
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
resp = await sh.home_redirect(req, handler)
assert resp.text == "spa"
# ── Shared shell: editor index injection + templates + workflow + wallet ─────────
async def _client(server, org_middleware=True):
"""A real aiohttp app with the studio-home routes and a fake-auth middleware that
stamps request['iam_user'] from an X-Org header so route tests exercise the
SAME server-side org scoping (_org_of) production uses."""
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
@web.middleware
async def fake_auth(request, handler):
o = request.headers.get("X-Org")
if o:
request["iam_user"] = {"org_id": o, "orgs": [o]}
return await handler(request)
app = web.Application(middlewares=[fake_auth] if org_middleware else [])
routes = web.RouteTableDef()
sh.add_studio_home_routes(routes, server)
app.add_routes(routes)
ts = TestServer(app)
await ts.start_server()
client = TestClient(ts)
await client.start_server()
return client
def test_editor_index_injects_shell_once(tmp_path):
root = tmp_path / "web"
root.mkdir()
(root / "index.html").write_text("<html><head></head><body><div id=app></div></body></html>")
resp = sh.editor_index(str(root))
assert resp.headers["Cache-Control"] == "no-cache"
assert '<link rel="stylesheet" href="/studio/shell.css"></head>' in resp.text
assert '<script src="/studio/shell.js" defer></script></body>' in resp.text
# idempotent: an index that already carries the shell is served unchanged
(root / "index.html").write_text(resp.text)
again = sh.editor_index(str(root))
assert again.text.count("/studio/shell.js") == 1
def test_template_api_prompt_shapes():
api = {"9": {"class_type": "KSampler", "inputs": {}}, "15": {"class_type": "SaveImage", "inputs": {}}}
assert sh._template_api_prompt({"output": api, "workflow": {"nodes": []}}) == api # editor save
assert sh._template_api_prompt(api) == api # bare API prompt
assert sh._template_api_prompt({"nodes": [{"id": 1}], "last_node_id": 1}) is None # UI-only, not runnable
assert sh._template_api_prompt({}) is None and sh._template_api_prompt("x") is None
assert sh._template_slug("Karma — Ghost Mannequin!") == "karma-ghost-mannequin"
@pytest.mark.asyncio
async def test_templates_crud_and_tenant_isolation(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
graph = {"output": {"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/x"}}}}
r = await client.post("/v1/templates", json={"name": "Ghost Mannequin", "graph": graph}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["slug"] == "ghost-mannequin"
# acme sees it (renderable — the graph carries an API prompt), fetches it by slug
r = await client.get("/v1/templates", headers={"X-Org": "acme"})
body = await r.json()
assert body["org"] == "acme" and len(body["templates"]) == 1
assert body["templates"][0]["name"] == "Ghost Mannequin" and body["templates"][0]["renderable"] is True
r = await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "acme"})
assert (await r.json())["graph"] == graph
# tenant isolation: globex's list is empty and it can't read acme's template
assert (await (await client.get("/v1/templates", headers={"X-Org": "globex"})).json())["templates"] == []
assert (await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "globex"})).status == 404
# validation
assert (await client.post("/v1/templates", json={"name": "", "graph": graph}, headers={"X-Org": "acme"})).status == 400
assert (await client.post("/v1/templates", json={"name": "x", "graph": {}}, headers={"X-Org": "acme"})).status == 400
await client.close()
@pytest.mark.asyncio
async def test_template_render_dispatches_and_rejects_ui_only(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_queue(request, server, graph):
return "pid-tmpl"
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
client = await _client(_FakeServer())
runnable = {"output": {"9": {"class_type": "KSampler", "inputs": {"seed": 1}},
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/run"}}}}
await client.post("/v1/templates", json={"name": "Run Me", "graph": runnable}, headers={"X-Org": "acme"})
r = await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "acme"})
assert r.status == 200 and (await r.json())["prompt_id"] == "pid-tmpl"
assert sh.worklog.load("acme")[-1]["kind"] == "template" # went through the ONE funnel
# UI-only graph can't run headless -> 422 with a reason, not a silent dispatch
await client.post("/v1/templates", json={"name": "UI Only", "graph": {"nodes": [], "last_node_id": 0}}, headers={"X-Org": "acme"})
assert (await client.post("/v1/templates/render", json={"name": "ui-only"}, headers={"X-Org": "acme"})).status == 422
# cross-tenant render: globex has no such template -> 404
assert (await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_workflow_reads_embedded_graph_tenant_scoped(tmp_path, monkeypatch):
from PIL import Image
from PIL.PngImagePlugin import PngInfo
_orgaware(tmp_path, monkeypatch)
root = Path(sh.folder_paths.get_org_output_directory("acme"))
(root / "library.json").write_text('{"assets":[]}')
(root / "fixes").mkdir(parents=True, exist_ok=True)
info = PngInfo()
ui = {"nodes": [{"id": 1, "type": "SaveImage"}], "last_node_id": 1}
info.add_text("workflow", json.dumps(ui))
info.add_text("prompt", json.dumps({"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/x"}}}))
Image.new("RGB", (4, 4)).save(root / "fixes" / "x_00001_.png", "PNG", pnginfo=info)
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
output_prefix="fixes/x", pid="p1")
sh.worklog.record("acme", kind="fix", prompt="pending", refs=[], uploads=[], parents=[],
output_prefix="fixes/nope", pid="p2")
client = await _client(_FakeServer())
r = await client.get("/v1/workflow?id=p1", headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["format"] == "ui" and body["graph"] == ui # UI graph preferred
# not landed yet -> 404 (no graph to open)
assert (await client.get("/v1/workflow?id=p2", headers={"X-Org": "acme"})).status == 404
# cross-tenant id is never confirmed -> 404
assert (await client.get("/v1/workflow?id=p1", headers={"X-Org": "globex"})).status == 404
await client.close()
@pytest.mark.asyncio
async def test_wallet_proxies_and_scopes_org(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
async def fake_balance(tok):
assert tok == "Bearer tkn"
return {"availableCents": 2500, "currency": "usd", "pendingCents": 0}
monkeypatch.setattr(sh, "_finance_balance", fake_balance)
client = await _client(_FakeServer())
r = await client.get("/v1/wallet", headers={"X-Org": "acme", "Authorization": "Bearer tkn"})
body = await r.json()
assert body == {"org": "acme", "balance": 25.0, "currency": "usd"}
# signed-out (no bearer): display-only null balance, never a fabricated number
r = await client.get("/v1/wallet", headers={"X-Org": "acme"})
assert await r.json() == {"org": "acme", "balance": None, "currency": None}
await client.close()
@pytest.mark.asyncio
async def test_library_upload_roundtrip_dedup_and_tenant_isolation(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
# library.json so _library_root resolves for the GET round-trip
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"acme-bytes"
# multipart upload as acme -> renders/r1.png
form = aiohttp.FormData()
form.add_field("image", png, filename="r1.png", content_type="image/png")
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
body = await r.json()
assert r.status == 200 and body["ok"] and body["path"] == "renders/r1.png" and not body.get("existed")
# round-trip through the org-scoped file server
g = await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})
assert g.status == 200 and (await g.read()) == png
# dedup: identical name+size -> existed, no rewrite
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="r1.png", content_type="image/png")
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()) == {"ok": True, "existed": True, "path": "renders/r1.png"}
# tenant isolation: org B never sees org A's file; its own upload is a separate tree
assert (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "globex"})).status == 404
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNG\r\n\x1a\nglobex",
headers={"X-Org": "globex", "Content-Type": "application/octet-stream"})
assert (await rg.json())["path"] == "renders/r1.png"
# org A's bytes are untouched by org B's same-named upload
assert (await (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})).read()) == png
await client.close()
@pytest.mark.asyncio
async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
hdr = {"X-Org": "acme", "Content-Type": "application/octet-stream"}
# subpath traversal -> 400
assert (await client.post("/v1/library/upload?name=x.png&subpath=../secret", data=b"\x89PNGx", headers=hdr)).status == 400
# non-image name -> 400
assert (await client.post("/v1/library/upload?name=notes.txt", data=b"hi", headers=hdr)).status == 400
# a traversal-y NAME is neutralized to its basename, lands safely under renders/
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNG\r\n\x1a\nevil", headers=hdr)
assert (await r.json())["path"] == "renders/evil.png"
assert (Path(sh.folder_paths.get_org_output_directory("acme")) / "renders" / "evil.png").is_file()
# size cap -> 413
monkeypatch.setattr(sh, "_MAX_UPLOAD", 8)
assert (await client.post("/v1/library/upload?name=big.png", data=b"0123456789", headers=hdr)).status == 413
await client.close()
@pytest.mark.asyncio
async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypatch):
import aiohttp
_orgaware(tmp_path, monkeypatch)
client = await _client(_FakeServer())
png = b"\x89PNG\r\n\x1a\n" + b"render-bytes"
form = aiohttp.FormData()
form.add_field("image", png, filename="shot.png", content_type="image/png")
r = await client.post("/v1/library/upload?node=spark&design=karma&kind=render",
data=form, headers={"X-Org": "acme"})
assert (await r.json())["path"] == "renders/shot.png"
# instantly findable in Assets (library.json entry, draft)
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
a = next(x for x in lib["assets"] if x["path"] == "renders/shot.png")
assert a["status"] == "draft" and a["kind"] == "render" and a["design"] == "karma" and a["tags"] == []
# instantly findable in Queue & History with its source node + resolved output
wl = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
it = next(x for x in wl["items"] if x["output_prefix"] == "renders/shot.png")
assert it["kind"] == "render" and it["node"] == "spark" and it["status"] == "done"
assert it["output"] == "renders/shot.png" and it["lane"] == "gpu"
# tenant isolation: org B sees neither the asset nor the row
lib2 = await (await client.get("/v1/library", headers={"X-Org": "globex"})).json()
assert all(x["path"] != "renders/shot.png" for x in lib2.get("assets", []))
assert (await (await client.get("/v1/worklog", headers={"X-Org": "globex"})).json())["items"] == []
# dedup: re-upload identical bytes adds no second asset or row
form2 = aiohttp.FormData()
form2.add_field("image", png, filename="shot.png", content_type="image/png")
r2 = await client.post("/v1/library/upload?node=spark", data=form2, headers={"X-Org": "acme"})
assert (await r2.json()).get("existed") is True
lib3 = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
assert sum(1 for x in lib3["assets"] if x["path"] == "renders/shot.png") == 1
wl3 = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
assert sum(1 for x in wl3["items"] if x["output_prefix"] == "renders/shot.png") == 1
await client.close()
@pytest.mark.asyncio
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
# an ingested render: empty prompt, findable only by its filename / ref
sh.worklog.record("acme", kind="render", prompt="", refs=["srcphoto.png"], uploads=[],
parents=[], output_prefix="renders/model_shot.png", pid="r1", status="done")
sh.worklog.record("acme", kind="fix", prompt="brighten the sky", refs=[], uploads=[],
parents=[], output_prefix="fixes/x", pid="r2")
client = await _client(_FakeServer())
async def ids(q):
r = await client.get("/v1/worklog?q=" + q, headers={"X-Org": "acme"})
return [it["id"] for it in (await r.json())["items"]]
assert await ids("model_shot") == ["r1"] # by output filename, empty prompt
assert await ids("srcphoto") == ["r1"] # by reference
assert await ids("brighten") == ["r2"] # prompt search still works
assert await ids("nomatch") == []
await client.close()
+5 -3
View File
@@ -5,13 +5,14 @@ import {
} from './api'
// Studio-chat: the specialized chat surface. You say what to create; the cloud
// /v1/chat "create" capability drives studio's render tools (fix/compose) and the
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
export function App() {
const [session, setSession] = useState<Session>({})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [conversationId, setConversationId] = useState<string>()
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [assets, setAssets] = useState<Asset[]>([])
@@ -44,7 +45,8 @@ export function App() {
const next = [...messages, { role: 'user' as const, content: text }]
setMessages(next); setInput(''); setBusy(true)
try {
const r = await chat(next)
const r = await chat(next, conversationId)
setConversationId(r.conversationId)
const note = r.actions.length
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `${a.name} dispatched`).join('\n')}`
: ''
@@ -90,7 +92,7 @@ export function App() {
</div>
</section>
<aside className="rail">
<aside className={`rail${assets.length || jobs.length ? ' active' : ''}`}>
{jobs.length > 0 && (
<div className="queue">
<div className="rail-h">Rendering ({jobs.length})</div>
+29 -14
View File
@@ -1,20 +1,34 @@
// The studio-chat data layer. Two backends, no proxy layer between them:
// - the cloud chat orchestrator: POST api.hanzo.ai/v1/chat (clients/chat) —
// called DIRECTLY, no studio pass-through. The hanzo_token cookie is scoped to
// .hanzo.ai so credentials:'include' carries it cross-origin, and the handler
// replays it into the per-org-billed completion. (Gateway CORS must allow the
// studio.hanzo.ai origin with credentials.)
// - the agent orchestrator: POST api.hanzo.ai/v1/agent (github.com/hanzoai/agent,
// mounted in the cloud binary) — called DIRECTLY, no studio pass-through. The
// hanzo_token cookie is scoped to .hanzo.ai so credentials:'include' carries it
// cross-origin, and the round replays it into the per-org-billed completion.
// (Gateway CORS must allow the studio.hanzo.ai origin with credentials.)
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
// same-origin on studio.hanzo.ai (same cookie).
// No token handling in the browser: the cookie is the credential for both.
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
// Default: same-origin through the studio's /v1/agent bridge — the host-only
// session cookie authorizes it and the middleware forwards the bearer, so no
// parent-domain cookie and no gateway CORS dependency. VITE_AGENT_API points
// straight at the gateway for direct mode.
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? '').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
if (!r.ok) {
// Surface the server's own message — a 402 says "add credits at
// pay.hanzo.ai", which beats a bare status code.
let msg = `${url} -> ${r.status}`
try {
const b = await r.json()
const m = b?.error?.message ?? b?.error ?? b?.message
if (typeof m === 'string' && m) msg = m
} catch { /* keep the status line */ }
throw new Error(msg)
}
return r.json() as Promise<T>
}
@@ -22,15 +36,16 @@ export type Role = 'user' | 'assistant'
export interface ChatMessage { role: Role; content: string }
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
export interface Op { name: string; args?: Record<string, unknown> }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
export interface ChatReply { reply: string; actions: Action[]; ops: Op[]; conversationId: string }
// One turn of the create capability: the model edits/combines library assets;
// `actions` are the renders it dispatched (each result carries a prompt_id).
export function chat(messages: ChatMessage[], capability = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${CHAT_API}/v1/chat`, {
// One turn of a preset: the model edits/combines library assets; `actions` are the
// renders it dispatched (each result carries a prompt_id). Pass the prior reply's
// conversationId to continue a thread — the orchestrator persists per-org history.
export function chat(messages: ChatMessage[], conversationId?: string, preset = 'create'): Promise<ChatReply> {
return j<ChatReply>(`${AGENT_API}/v1/agent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capability, messages }),
body: JSON.stringify({ preset, messages, conversationId }),
})
}
+8 -2
View File
@@ -42,7 +42,13 @@ body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system,
.empty { color: var(--dim); font-size: 13px; }
@media (max-width: 820px) {
.body { grid-template-columns: 1fr; }
.rail { display: none; }
/* Chat fills; the library drops UNDER it as a compact strip so renders stay
reachable on a phone. Empty (no jobs, no assets) it hides entirely the
`active` class is set only when there is something to show so the empty
chat state stays clean. */
.body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.chat { border-right: 0; }
.rail { display: none; }
.rail.active { display: block; border-top: 1px solid var(--line); max-height: 42vh; overflow-y: auto; }
.rail.active .grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
}