Compare commits

..
Author SHA1 Message Date
Hanzo AI 1fe7df76a5 worklog: widen ?q= search to paths + refs, not just prompt
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.
2026-07-16 23:48:32 -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
7 changed files with 250 additions and 21 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
+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,
+8
View File
@@ -24,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}
@@ -184,6 +189,7 @@ 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>
@@ -807,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';}
+106 -3
View File
@@ -38,6 +38,7 @@ import os
import random
import re
import time
import uuid
from pathlib import Path
import aiohttp
@@ -678,6 +679,52 @@ 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")
@@ -710,7 +757,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 = _landed_output(root, row.get("output_prefix"))
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)
@@ -879,6 +926,58 @@ 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 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)
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
@@ -1093,14 +1192,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")
@@ -478,3 +478,113 @@ 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"\x89PNGglobex",
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"\x89PNGevil", 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()
+4 -2
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')}`
: ''
+14 -13
View File
@@ -1,16 +1,16 @@
// 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(/\/$/, '')
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
@@ -22,15 +22,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 }),
})
}