Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c2aa7818 |
+3
-4
@@ -23,10 +23,9 @@ venv/
|
||||
.ruff_cache/
|
||||
tests/
|
||||
tests-unit/
|
||||
# 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.
|
||||
scripts/*
|
||||
# ...except the pack installer, which the image build invokes.
|
||||
!scripts/install_custom_nodes.sh
|
||||
notebooks/
|
||||
*.swp
|
||||
.env
|
||||
|
||||
-12
@@ -1,12 +1,3 @@
|
||||
# ── 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 \
|
||||
@@ -34,9 +25,6 @@ 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.
|
||||
|
||||
@@ -129,10 +129,6 @@ 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,
|
||||
|
||||
@@ -24,11 +24,6 @@ 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}
|
||||
@@ -189,7 +184,6 @@ body.immersive #stage{display:block}
|
||||
<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>
|
||||
@@ -813,8 +807,6 @@ 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';}
|
||||
|
||||
+7
-168
@@ -38,7 +38,6 @@ import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
@@ -51,11 +50,6 @@ 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)
|
||||
|
||||
@@ -149,10 +143,6 @@ 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
|
||||
|
||||
|
||||
@@ -688,81 +678,18 @@ async def _wallet(request: web.Request, org: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
_MAX_UPLOAD = 30 * 1024 * 1024 # 30 MB — a sane ceiling for one 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 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",
|
||||
# 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",
|
||||
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")
|
||||
@@ -783,7 +710,7 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> 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"))
|
||||
landed = _landed_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)
|
||||
@@ -893,29 +820,6 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
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)
|
||||
@@ -975,67 +879,6 @@ 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
|
||||
@@ -1250,18 +1093,14 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
out = []
|
||||
for r in reversed(rows):
|
||||
row = dict(r)
|
||||
landed = _row_output(root, r.get("output_prefix"))
|
||||
landed = _landed_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:
|
||||
# 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()]
|
||||
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
|
||||
return web.json_response({"org": org, "items": out[:200]})
|
||||
|
||||
@routes.get("/v1/worklog/lineage")
|
||||
|
||||
@@ -93,11 +93,6 @@ 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)
|
||||
|
||||
@@ -478,113 +478,3 @@ async def test_wallet_proxies_and_scopes_org(tmp_path, monkeypatch):
|
||||
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()
|
||||
|
||||
Vendored
+3
-5
@@ -5,14 +5,13 @@ import {
|
||||
} from './api'
|
||||
|
||||
// Studio-chat: the specialized chat surface. You say what to create; the cloud
|
||||
// /v1/agent "create" preset drives studio's render tools (fix/compose) and the
|
||||
// /v1/chat "create" capability 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[]>([])
|
||||
@@ -45,8 +44,7 @@ export function App() {
|
||||
const next = [...messages, { role: 'user' as const, content: text }]
|
||||
setMessages(next); setInput(''); setBusy(true)
|
||||
try {
|
||||
const r = await chat(next, conversationId)
|
||||
setConversationId(r.conversationId)
|
||||
const r = await chat(next)
|
||||
const note = r.actions.length
|
||||
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `▸ ${a.name} dispatched`).join('\n')}`
|
||||
: ''
|
||||
@@ -92,7 +90,7 @@ export function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`rail${assets.length || jobs.length ? ' active' : ''}`}>
|
||||
<aside className="rail">
|
||||
{jobs.length > 0 && (
|
||||
<div className="queue">
|
||||
<div className="rail-h">Rendering ({jobs.length})</div>
|
||||
|
||||
Vendored
+14
-29
@@ -1,34 +1,20 @@
|
||||
// The studio-chat data layer. Two backends, no proxy layer between them:
|
||||
// - 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 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 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_AGENT_API override the bases for local dev.
|
||||
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
|
||||
|
||||
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').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(/\/$/, '')
|
||||
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
||||
|
||||
async function j<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(url, { credentials: 'include', ...init })
|
||||
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)
|
||||
}
|
||||
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -36,16 +22,15 @@ 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[]; conversationId: string }
|
||||
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
|
||||
|
||||
// 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`, {
|
||||
// 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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preset, messages, conversationId }),
|
||||
body: JSON.stringify({ capability, messages }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-8
@@ -42,13 +42,7 @@ body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system,
|
||||
.empty { color: var(--dim); font-size: 13px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* 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; }
|
||||
.body { grid-template-columns: 1fr; }
|
||||
.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)); }
|
||||
.chat { border-right: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user