Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d2068264 | ||
|
|
722ea7fd39 | ||
|
|
845ddd9dae | ||
|
|
f569319988 | ||
|
|
c249ac41a4 | ||
|
|
268857c16e | ||
|
|
a9223e59e8 | ||
|
|
10eec303fd | ||
|
|
b07530398d | ||
|
|
0d307dceb2 | ||
|
|
1b713f904c | ||
|
|
c969641d7d | ||
|
|
b7dff20861 | ||
|
|
e696fb7480 | ||
|
|
b3f20495f8 | ||
|
|
179b577add | ||
|
|
c2336e0c1e | ||
|
|
0538abf199 | ||
|
|
7eefdcd206 | ||
|
|
b238b8c568 | ||
|
|
e97194613c | ||
|
|
b31a1ea3e2 | ||
|
|
dd21fd9f85 | ||
|
|
109cd04a54 | ||
|
|
552f47b844 | ||
|
|
b0d1b06a6f | ||
|
|
023367bfec | ||
|
|
b2121abc71 | ||
|
|
0a3146e00f | ||
|
|
8e55ecac9a |
@@ -110,6 +110,12 @@
|
||||
}
|
||||
|
||||
rows.push(el("div", { class: "hzid-sep" }));
|
||||
// Exit the advanced node editor back to the content-forward Studio Home.
|
||||
// Without this there is NO way out of Advanced mode (the node editor is the
|
||||
// full-screen ComfyUI SPA) — the user gets stuck.
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: "/studio" }, [
|
||||
el("span", { class: "hzid-tick", text: "←" }), el("span", { text: "Exit to Studio Home" }),
|
||||
]));
|
||||
rows.push(el("a", { class: "hzid-item hzid-link", href: s.console_url || "https://console.hanzo.ai", target: "_blank", rel: "noopener" }, [
|
||||
el("span", { class: "hzid-tick", text: "↗" }), el("span", { text: "Console" }),
|
||||
]));
|
||||
@@ -151,6 +157,17 @@
|
||||
// ------------------------------------------------------------- boot
|
||||
function mount() {
|
||||
if (document.getElementById("hanzo-identity")) return;
|
||||
// Always-visible "Exit to Studio" button so Advanced mode is never a trap.
|
||||
// Fixed top-left, above the node editor; independent of the user menu.
|
||||
if (!document.getElementById("hanzo-exit-advanced")) {
|
||||
var exit = el("a", {
|
||||
id: "hanzo-exit-advanced", href: "/studio", title: "Exit Advanced — back to Studio Home",
|
||||
style: "position:fixed;top:10px;left:12px;z-index:100000;display:flex;align-items:center;gap:6px;" +
|
||||
"background:rgba(20,20,24,.92);color:#ececf1;border:1px solid #33333c;border-radius:8px;" +
|
||||
"padding:6px 11px;font:600 12px/1 Inter,system-ui,sans-serif;text-decoration:none;cursor:pointer;backdrop-filter:blur(8px)"
|
||||
}, [el("span", { text: "←" }), el("span", { text: "Studio" })]);
|
||||
document.body.appendChild(exit);
|
||||
}
|
||||
var root = el("div", { id: "hanzo-identity", class: "hanzo-identity" });
|
||||
document.body.appendChild(root);
|
||||
getSession().then(function (s) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import sys
|
||||
STATIC_DIR = sys.argv[1]
|
||||
BRANDING_DIR = sys.argv[2]
|
||||
|
||||
BUNDLE_VERSION = "3"
|
||||
BUNDLE_VERSION = "4"
|
||||
JS = "hanzo-identity.js"
|
||||
CSS = "hanzo-identity.css"
|
||||
LINK_TAG = f'<link rel="stylesheet" href="{CSS}?v={BUNDLE_VERSION}"/>'
|
||||
|
||||
+15
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import mimetypes
|
||||
import logging
|
||||
@@ -94,6 +95,19 @@ def get_org_id() -> str | None:
|
||||
return _org_id
|
||||
|
||||
|
||||
_ORG_ID_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
def _safe_org_id(oid: str) -> str:
|
||||
"""The single choke point where an org value becomes a filesystem path — so it
|
||||
is also the one place to reject traversal. An org id must be a plain slug; `.`,
|
||||
`..`, `/`, `\\`, or any other character is refused so a malformed/hostile claim
|
||||
can never escape the orgs/ tree (defense-in-depth; org id is IAM-derived today)."""
|
||||
if oid in (".", "..") or not _ORG_ID_RE.match(oid):
|
||||
raise ValueError(f"invalid org_id for path scoping: {oid!r}")
|
||||
return oid
|
||||
|
||||
|
||||
def get_org_base_path(org_id: str | None = None) -> str:
|
||||
"""
|
||||
Get the org-scoped base path. If multi-tenant is enabled and org_id is set,
|
||||
@@ -101,7 +115,7 @@ def get_org_base_path(org_id: str | None = None) -> str:
|
||||
"""
|
||||
oid = org_id or _org_id
|
||||
if _multi_tenant and oid:
|
||||
org_path = os.path.join(base_path, "orgs", oid)
|
||||
org_path = os.path.join(base_path, "orgs", _safe_org_id(oid))
|
||||
os.makedirs(org_path, exist_ok=True)
|
||||
return org_path
|
||||
return base_path
|
||||
|
||||
@@ -76,11 +76,36 @@ def _bearer(request) -> str:
|
||||
return f"Bearer {tok}" if tok else ""
|
||||
|
||||
|
||||
def _has_online_gpu(tok: str) -> bool:
|
||||
"""True if the caller's org has an online GPU machine in the cloud fleet."""
|
||||
# Identity keys a fleet machine might carry, most human-friendly first. We READ
|
||||
# whatever the gpu-jobs lane publishes (home-lab labels like spark/dbc/evo land in
|
||||
# `name`/`hostname`); we never invent a parallel registry.
|
||||
_NODE_KEYS = ("name", "hostname", "label", "node", "worker_id", "machine_id", "id", "gpu_model")
|
||||
|
||||
|
||||
def _node_label(m: dict) -> str:
|
||||
for k in _NODE_KEYS:
|
||||
v = m.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return "gpu"
|
||||
|
||||
|
||||
def _fleet_machines(tok: str) -> list:
|
||||
"""The caller-org's machines from the cloud fleet. The token scopes the fleet to
|
||||
the caller's org, so this is inherently tenant-isolated."""
|
||||
req = urllib.request.Request(f"{CLOUD}/v1/machines", headers={"Authorization": tok})
|
||||
d = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
||||
return any(m.get("status") == "online" and m.get("gpu") for m in d.get("machines", []))
|
||||
ms = d.get("machines", [])
|
||||
return ms if isinstance(ms, list) else []
|
||||
|
||||
|
||||
def _online_gpu_nodes(tok: str) -> list:
|
||||
return [m for m in _fleet_machines(tok) if m.get("status") == "online" and m.get("gpu")]
|
||||
|
||||
|
||||
def _has_online_gpu(tok: str) -> bool:
|
||||
"""True if the caller's org has an online GPU machine in the cloud fleet."""
|
||||
return bool(_online_gpu_nodes(tok))
|
||||
|
||||
|
||||
def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bool:
|
||||
@@ -89,14 +114,25 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
Never raises — any failure falls back to local."""
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
if not tok or not _has_online_gpu(tok):
|
||||
online = _online_gpu_nodes(tok) if tok else []
|
||||
if not online:
|
||||
return False
|
||||
# Which node runs it: the single online GPU is definitive; with several the
|
||||
# claimer is decided at pick-up time, so record the ambiguous "gpu".
|
||||
node = _node_label(online[0]) if len(online) == 1 else "gpu"
|
||||
body = json.dumps({
|
||||
"activityId": prompt_id,
|
||||
"runId": prompt_id,
|
||||
"activityType": {"name": "studio.render"},
|
||||
"taskQueue": JOBS_NS,
|
||||
"heartbeatTimeout": "600s",
|
||||
# 1200s: a heavy Qwen edit on a cold GB10 (model reload + full 30-step
|
||||
# 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,
|
||||
@@ -113,6 +149,49 @@ def dispatch_if_worker(request, org_id: str, prompt_id: str, prompt: dict) -> bo
|
||||
headers={"Authorization": tok, "Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=15).read()
|
||||
_track_dispatched(org_id, prompt_id, prompt, node=node)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _track_dispatched(org_id: str, prompt_id: str, prompt: dict, node: str = "gpu") -> None:
|
||||
"""Record a BYO-GPU render dispatched to gpu-jobs so the studio UI can SHOW it
|
||||
in the queue. BYO-GPU jobs render on the worker (gpu-jobs), not the pod's local
|
||||
/queue — so without this the user's fix/compose 'never appears in the queue'.
|
||||
A tiny per-org JSON the /v1/render-queue endpoint reads; entries drop off when
|
||||
their output lands in the library or they age out. Never raises."""
|
||||
try:
|
||||
import time
|
||||
import folder_paths
|
||||
# human title from the SaveImage prefix; refs from LoadImage for a thumbnail
|
||||
full_prefix, refs = "render", []
|
||||
for n in prompt.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct = n.get("class_type", "")
|
||||
if ct == "SaveImage":
|
||||
full_prefix = n.get("inputs", {}).get("filename_prefix") or "render"
|
||||
elif "LoadImage" in ct:
|
||||
im = n.get("inputs", {}).get("image")
|
||||
if im:
|
||||
refs.append(im)
|
||||
out_dir = folder_paths.get_org_output_directory(org_id)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
jf = os.path.join(out_dir, "render_jobs.json")
|
||||
try:
|
||||
jobs = json.loads(open(jf).read())
|
||||
except Exception:
|
||||
jobs = []
|
||||
jobs = [j for j in jobs if j.get("id") != prompt_id][-40:] # dedupe + cap
|
||||
# store the FULL output prefix (e.g. "fixes/model01_..._00001_") so the queue
|
||||
# endpoint can check for the ACTUAL output file, not a fuzzy base match that
|
||||
# collides with the source image and makes the job vanish after 30s.
|
||||
jobs.append({"id": prompt_id, "prefix": full_prefix.split("/")[-1],
|
||||
"outPrefix": full_prefix, "refs": refs[:1], "node": node,
|
||||
"ts": int(time.time()), "status": "rendering"})
|
||||
tmp = jf + ".tmp"
|
||||
open(tmp, "w").write(json.dumps(jobs))
|
||||
os.replace(tmp, jf)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""MCP-over-HTTP for Hanzo Studio — the studio render pipeline as a Model Context
|
||||
Protocol server so the cloud chat `create` capability can drive it.
|
||||
|
||||
POST /v1/mcp speaks JSON-RPC 2.0 (the MCP wire format) and exposes the SAME render
|
||||
dispatch the /v1/library/{fix,compose} routes use — ``studio_home.dispatch_fix`` /
|
||||
``studio_home.dispatch_compose`` — as MCP tools. So a fix/compose an LLM issues
|
||||
behaves identically to one the Studio UI issues: one dispatch path, no second
|
||||
pipeline. Auth and org ride the existing IAM middleware exactly like every other
|
||||
/v1/* route (org = ``studio_home._org_of(request)``); nothing is registered here
|
||||
that the global auth layer does not already gate.
|
||||
|
||||
Methods (JSON-RPC 2.0 envelope: {jsonrpc,id,result|error}):
|
||||
initialize -> {protocolVersion, serverInfo, capabilities:{tools:{}}}
|
||||
tools/list -> the fix / compose / ask tools + JSON schemas
|
||||
tools/call -> dispatch fix|compose|ask and return MCP tool-result content
|
||||
(the render prompt_id / output_prefix). A DispatchError
|
||||
becomes an isError tool-result, never a 500. An unknown
|
||||
METHOD is a JSON-RPC method-not-found error.
|
||||
|
||||
── Registration: the ONE recipe ──────────────────────────────────────────────
|
||||
An operator/admin registers THIS studio MCP server per-org, once, through the
|
||||
cloud tool registry — the studio public URL with the /v1/mcp path:
|
||||
|
||||
POST https://api.hanzo.ai/v1/tools/servers
|
||||
{ "name": "studio",
|
||||
"url": "https://studio.hanzo.ai/v1/mcp",
|
||||
"org": "<org>" }
|
||||
|
||||
Cloud then calls tools/list here and exposes the returned tools to /v1/chat's
|
||||
`create` capability as SourceMCP tools. There is no per-tool wiring in cloud: the
|
||||
tool set is whatever tools/list returns, so adding a studio tool is a one-line
|
||||
change to _TOOLS here — it flows to chat with no cloud deploy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from middleware import studio_home
|
||||
from studio_version import __version__
|
||||
|
||||
# The MCP revision this server implements. Reported in `initialize`.
|
||||
_PROTOCOL_VERSION = "2025-06-18"
|
||||
|
||||
# JSON-RPC 2.0 error codes (only the ones we emit).
|
||||
_PARSE_ERROR = -32700
|
||||
_INVALID_REQUEST = -32600
|
||||
_METHOD_NOT_FOUND = -32601
|
||||
|
||||
# The studio render tools. `inputSchema` is the JSON Schema cloud advertises to
|
||||
# the model; the dispatch functions validate again (instruction length, asset
|
||||
# existence), so the schema is a hint, not the trust boundary.
|
||||
_TOOLS = [
|
||||
{
|
||||
"name": "fix",
|
||||
"description": (
|
||||
"Edit one library asset with an instruction (Qwen image-edit). "
|
||||
"Returns the render prompt_id and output_prefix."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "library-relative path of the asset to edit"},
|
||||
"instruction": {"type": "string", "description": "the edit to apply (3-500 chars)"},
|
||||
},
|
||||
"required": ["path", "instruction"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "compose",
|
||||
"description": (
|
||||
"Combine 2-5 library assets into a new output per an instruction. "
|
||||
"Returns the render prompt_id and output_prefix."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 2,
|
||||
"maxItems": 5,
|
||||
"description": "2-5 library-relative asset paths to combine",
|
||||
},
|
||||
"instruction": {"type": "string", "description": "how to combine them (3-500 chars)"},
|
||||
},
|
||||
"required": ["paths", "instruction"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ask",
|
||||
"description": (
|
||||
"Clarifier no-op: echo a question back to the user when the request is "
|
||||
"ambiguous. Produces no render."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {"type": "string", "description": "the clarifying question to ask"},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _result(id_, result: dict) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
||||
|
||||
|
||||
def _error(id_, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def _content(payload: dict, is_error: bool = False) -> dict:
|
||||
"""An MCP tool result: text content parts + optional isError flag. The text is
|
||||
the JSON dispatch payload (prompt_id / output_prefix) or the error message."""
|
||||
r = {"content": [{"type": "text", "text": json.dumps(payload)}]}
|
||||
if is_error:
|
||||
r["isError"] = True
|
||||
return r
|
||||
|
||||
|
||||
async def _call_tool(request: web.Request, server, name: str, arguments: dict):
|
||||
"""Dispatch one tool call to the shared render path. Returns the MCP
|
||||
tool-result, or None for an unknown tool name (caller maps that to isError)."""
|
||||
org = studio_home._org_of(request)
|
||||
if name == "fix":
|
||||
out = await studio_home.dispatch_fix(
|
||||
request, server, org, arguments.get("path", ""), arguments.get("instruction", ""))
|
||||
return _content(out)
|
||||
if name == "compose":
|
||||
out = await studio_home.dispatch_compose(
|
||||
request, server, org, arguments.get("paths") or [], arguments.get("instruction", ""))
|
||||
return _content(out)
|
||||
if name == "ask":
|
||||
return _content({"ok": True, "question": str(arguments.get("question", "")).strip()})
|
||||
return None
|
||||
|
||||
|
||||
async def _handle(request: web.Request, server, msg) -> dict | None:
|
||||
"""Process one JSON-RPC 2.0 message. Returns the response envelope, or None
|
||||
for a notification (a message with no ``id`` — no response is sent)."""
|
||||
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
||||
return _error(None, _INVALID_REQUEST, "invalid JSON-RPC 2.0 request")
|
||||
is_notification = "id" not in msg
|
||||
id_ = msg.get("id")
|
||||
method = msg.get("method")
|
||||
params = msg.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
result = {
|
||||
"protocolVersion": _PROTOCOL_VERSION,
|
||||
"serverInfo": {"name": "hanzo-studio", "version": __version__},
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
result = {"tools": _TOOLS}
|
||||
elif method == "tools/call":
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
result = await _call_tool(request, server, name, arguments)
|
||||
except studio_home.DispatchError as e:
|
||||
result = _content({"error": str(e)}, is_error=True)
|
||||
if result is None:
|
||||
result = _content({"error": f"unknown tool: {name}"}, is_error=True)
|
||||
else:
|
||||
return None if is_notification else _error(id_, _METHOD_NOT_FOUND, f"unknown method: {method}")
|
||||
|
||||
return None if is_notification else _result(id_, result)
|
||||
|
||||
|
||||
def add_mcp_routes(routes: web.RouteTableDef, server) -> None:
|
||||
"""Register POST /v1/mcp — the studio MCP-over-HTTP endpoint."""
|
||||
|
||||
@routes.post("/v1/mcp")
|
||||
async def mcp_endpoint(request: web.Request):
|
||||
try:
|
||||
msg = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(_error(None, _PARSE_ERROR, "invalid JSON"), status=400)
|
||||
resp = await _handle(request, server, msg)
|
||||
if resp is None: # notification: acknowledge with no body
|
||||
return web.Response(status=202)
|
||||
return web.json_response(resp)
|
||||
+806
-117
@@ -3,161 +3,850 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo Studio</title>
|
||||
<style>
|
||||
:root{--bg:#0b0b0d;--panel:#131317;--line:#232329;--txt:#ececf1;--dim:#9a9aa5;
|
||||
--brand:#e8ff47;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;--bad:#ff5d5d;font-size:15px}
|
||||
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
|
||||
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
body{background:var(--bg);color:var(--txt);font:400 1rem/1.45 Inter,system-ui,-apple-system,sans-serif}
|
||||
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);
|
||||
position:sticky;top:0;background:rgba(11,11,13,.92);backdrop-filter:blur(8px);z-index:5}
|
||||
header h1{font-size:1.05rem;font-weight:600;letter-spacing:.2px}
|
||||
header .org{color:var(--dim);font-size:.85rem;border:1px solid var(--line);border-radius:99px;padding:3px 10px}
|
||||
body{background:var(--bg);color:var(--txt);font:400 1rem/1.5 Inter,system-ui,-apple-system,sans-serif}
|
||||
.serif{font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif}
|
||||
a{color:inherit}
|
||||
header{display:flex;align-items:center;gap:12px;padding:14px 26px;border-bottom:1px solid var(--line);
|
||||
position:fixed;top:0;left:0;right:0;background:rgba(10,10,11,.82);backdrop-filter:blur(14px);z-index:30;
|
||||
transition:transform .28s ease,opacity .28s ease}
|
||||
/* immersive: header hides; a top hover-zone or mouse-near-top reveals it */
|
||||
body.immersive header{transform:translateY(-100%);opacity:0;border-bottom-color:transparent}
|
||||
body.immersive header.reveal{transform:translateY(0);opacity:1}
|
||||
#tophover{position:fixed;top:0;left:0;right:0;height:24px;z-index:29}
|
||||
header .logo{width:26px;height:26px;border-radius:7px;display:block;flex:0 0 auto}
|
||||
header h1{font-size:1.02rem;font-weight:600;letter-spacing:.2px}
|
||||
header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);border-radius:5px;padding:1px 5px;text-transform:uppercase;letter-spacing:.08em}
|
||||
header .sp{flex:1}
|
||||
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;
|
||||
padding:7px 13px;font-size:.85rem;cursor:pointer;transition:.15s}
|
||||
.btn:hover{border-color:var(--dim)}
|
||||
.btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
|
||||
nav{display:flex;gap:4px;padding:12px 22px 0}
|
||||
nav button{background:none;border:none;color:var(--dim);font-size:.92rem;padding:8px 14px;cursor:pointer;
|
||||
border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
|
||||
nav button.on{color:var(--txt);border-bottom-color:var(--brand)}
|
||||
main{padding:18px 22px 60px;max-width:1500px;margin:0 auto}
|
||||
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0 18px}
|
||||
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.82rem;color:var(--dim);cursor:pointer}
|
||||
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}
|
||||
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}
|
||||
.btn:hover{border-color:var(--dim)} .btn.brand{background:var(--brand);border-color:var(--brand);color:#111;font-weight:600}
|
||||
main{max-width:1120px;margin:0 auto;padding:12px 24px 80px}
|
||||
body{padding-top:56px} /* clear the fixed header */
|
||||
body.immersive{padding-top:0}
|
||||
/* persistent live-queue bar — always visible so you can see what's generating */
|
||||
#livebar{position:sticky;top:56px;z-index:19;max-width:1120px;margin:0 auto 8px;display:flex;align-items:center;gap:14px;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 15px;font-size:.84rem}
|
||||
#livebar .lb-run{color:var(--ok);display:flex;align-items:center;gap:7px}
|
||||
#livebar .lb-run .spin{width:9px;height:9px;border-radius:50%;background:var(--ok);animation:pulse 1.1s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:.35}50%{opacity:1}}
|
||||
#livebar .lb-q{color:var(--dim)} #livebar .lb-eta{color:var(--warn)} #livebar .sp{flex:1}
|
||||
#livebar a.lb-more{color:var(--dim);text-decoration:none;font-size:.8rem;border:1px solid var(--line);border-radius:7px;padding:3px 10px;cursor:pointer}
|
||||
#livebar.idle{opacity:.72}
|
||||
.hero{text-align:center;font-size:3rem;font-weight:500;margin:56px 0 26px;letter-spacing:-.01em}
|
||||
/* prompt box */
|
||||
.prompt{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px 16px 12px;max-width:820px;margin:0 auto}
|
||||
.prompt textarea{width:100%;background:none;border:none;color:var(--txt);font:inherit;font-size:1.02rem;resize:none;outline:none;min-height:26px;max-height:180px}
|
||||
.prompt textarea::placeholder{color:var(--dim)}
|
||||
.pctl{display:flex;align-items:center;gap:8px;margin-top:12px}
|
||||
.pchip{display:flex;flex-direction:column;line-height:1.1;border:1px solid var(--line);border-radius:9px;padding:5px 11px;background:var(--panel2);cursor:pointer;font-size:.8rem}
|
||||
.pchip .k{color:var(--dim);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em}
|
||||
.pchip .v{color:var(--txt);font-weight:500}
|
||||
.pico{border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2);cursor:pointer;color:var(--dim);font-size:.9rem}
|
||||
.psend{margin-left:auto;width:38px;height:38px;border-radius:10px;border:none;background:var(--brand);color:#111;font-size:1.1rem;cursor:pointer}
|
||||
/* templates */
|
||||
.sec-l{color:var(--dim);font-size:.82rem;margin:40px 0 12px}
|
||||
.tgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
|
||||
.tcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;cursor:pointer;transition:.12s}
|
||||
.tcard:hover{border-color:#3a3a48;transform:translateY(-2px)}
|
||||
.tcard .ic{aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:1.7rem;background:var(--panel2)}
|
||||
.tcard .tl{padding:9px 11px;font-size:.83rem;font-weight:500}
|
||||
.tcard .td{padding:0 11px 10px;font-size:.7rem;color:var(--dim)}
|
||||
/* tabs */
|
||||
.tabs{display:flex;gap:6px;align-items:center;margin:44px 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
|
||||
.tabs button{background:none;border:none;color:var(--dim);font-size:.9rem;padding:8px 12px;cursor:pointer;border-radius:8px 8px 0 0;border-bottom:2px solid transparent}
|
||||
.tabs button.on{color:var(--txt);border-bottom-color:var(--brand)}
|
||||
.tabs .sp{flex:1}
|
||||
.search{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:6px 12px;color:var(--txt);font:inherit;font-size:.85rem;width:220px}
|
||||
.chips{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px}
|
||||
.chip{border:1px solid var(--line);border-radius:99px;padding:4px 12px;font-size:.8rem;color:var(--dim);cursor:pointer}
|
||||
.chip.on{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:15px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:13px;overflow:hidden;display:flex;flex-direction:column}
|
||||
.card .im{aspect-ratio:2/3;background:#0f0f12 center/cover no-repeat;cursor:zoom-in}
|
||||
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem}
|
||||
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:8px}
|
||||
.card .t{font-size:.83rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--txt)}
|
||||
.card .sub{font-size:.72rem;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.card .doc{aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:.8rem;text-align:center;padding:12px}
|
||||
.card .meta{padding:10px 12px;display:flex;flex-direction:column;gap:7px}
|
||||
.card .t{font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .sub{font-size:.7rem;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.st{font-size:.7rem;font-weight:600;border-radius:99px;padding:2px 9px;text-transform:uppercase;letter-spacing:.4px}
|
||||
.st{font-size:.66rem;font-weight:600;border-radius:99px;padding:2px 8px;text-transform:uppercase;letter-spacing:.4px}
|
||||
.st.draft{background:#2a2a31;color:var(--dim)}.st.approved{background:rgba(61,220,132,.15);color:var(--ok)}
|
||||
.st.queued{background:rgba(255,182,72,.15);color:var(--warn)}.st.published{background:rgba(106,165,255,.18);color:var(--pub)}
|
||||
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.74rem;cursor:pointer}
|
||||
.mini:hover{color:var(--txt);border-color:var(--dim)}
|
||||
.mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.wf{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}
|
||||
.wf .name{font-size:.9rem;flex:1}
|
||||
.empty{color:var(--dim);padding:40px;text-align:center}
|
||||
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);
|
||||
border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;max-width:80vw}
|
||||
.mini{border:1px solid var(--line);background:none;color:var(--dim);border-radius:7px;padding:4px 9px;font-size:.72rem;cursor:pointer}
|
||||
.mini:hover{color:var(--txt);border-color:var(--dim)} .mini.hot{color:#111;background:var(--brand);border-color:var(--brand);font-weight:600}
|
||||
.empty{color:var(--dim);padding:44px;text-align:center}
|
||||
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#1c1c22;border:1px solid var(--line);border-radius:10px;padding:10px 18px;font-size:.85rem;opacity:0;transition:.25s;pointer-events:none;z-index:50}
|
||||
#toast.show{opacity:1}
|
||||
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.88);display:none;align-items:center;justify-content:center;z-index:20;cursor:zoom-out}
|
||||
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;align-items:center;justify-content:center;z-index:40;cursor:zoom-out}
|
||||
#zoom img{max-width:94vw;max-height:94vh;border-radius:8px}
|
||||
dialog{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:14px;padding:22px;max-width:460px;width:92vw}
|
||||
dialog::backdrop{background:rgba(0,0,0,.6)}
|
||||
dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;
|
||||
padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
|
||||
dialog::backdrop{background:rgba(0,0,0,.6)} dialog textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px;font:inherit;font-size:.9rem;min-height:90px;margin:12px 0}
|
||||
#bulkbar{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:9px 14px;display:none;gap:9px;align-items:center;z-index:45;box-shadow:0 10px 32px #000a}
|
||||
#bulkbar.on{display:flex} #bulkn{font-size:.82rem;color:#cfcfe0;margin-right:4px}
|
||||
.card.sel-mode{cursor:pointer} .card.picked{outline:2px solid var(--brand);outline-offset:-2px}
|
||||
/* cleaner grid: tools hidden until hover */
|
||||
.card .tools{max-height:0;overflow:hidden;opacity:0;transition:.15s}
|
||||
.card:hover .tools{max-height:60px;opacity:1;margin-top:2px}
|
||||
.card .meta{gap:5px}
|
||||
.card .pick{position:absolute;top:8px;left:9px;width:20px;height:20px;border-radius:5px;border:1.5px solid #fff;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;font-size:.8rem;color:#111;z-index:3}
|
||||
.card{position:relative} .sel-mode .pick{display:flex} .picked .pick{background:var(--brand)}
|
||||
/* YouTube-style queue + history */
|
||||
.qcol{margin-bottom:26px} .qh{font-size:.9rem;font-weight:600;margin-bottom:10px;color:var(--txt);display:flex;align-items:center;gap:8px}
|
||||
.qlist{display:flex;flex-direction:column;gap:8px}
|
||||
.qrow{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:9px 13px}
|
||||
.qrow:hover{border-color:#333340}
|
||||
.qrow .qthumb{width:60px;height:80px;border-radius:7px;background:#0f0f12 center/cover;flex:0 0 auto;font-size:1.1rem}
|
||||
.qrow .qtitle{flex:1;min-width:0;font-size:.88rem;font-weight:500}
|
||||
.qrow .qdesc{font-size:.76rem;color:#b3b3bd;margin-top:3px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.qrow .qsub{font-size:.72rem;color:var(--dim);margin-top:4px}
|
||||
.stars{display:inline-flex;gap:1px;cursor:pointer} .stars span{font-size:1rem;color:#3a3a44;transition:.1s} .stars span.on{color:#ffcf4a}
|
||||
.notebox{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font:inherit;font-size:.76rem;margin-top:6px;min-height:34px;resize:vertical}
|
||||
.fav{cursor:pointer;font-size:1rem;color:#3a3a44} .fav.on{color:#ffcf4a}
|
||||
dialog .drop{border:1.5px dashed var(--line);border-radius:9px;padding:14px;text-align:center;color:var(--dim);font-size:.82rem;margin:8px 0;cursor:pointer}
|
||||
.drop.drag{border-color:var(--brand);color:var(--txt)}
|
||||
.prompt.drag{outline:2px dashed var(--brand);outline-offset:2px}
|
||||
.dlgerr{display:none;color:#ff8f8f;background:rgba(192,57,43,.12);border:1px solid rgba(192,57,43,.4);border-radius:8px;padding:8px 11px;font-size:.8rem;margin:8px 0 0;white-space:pre-wrap}
|
||||
.ctxlabel{font-size:.66rem;text-transform:uppercase;letter-spacing:.05em;color:var(--dim);margin:14px 0 6px}
|
||||
.wst{font-size:.66rem;font-weight:600;border-radius:99px;padding:1px 7px}
|
||||
.wst.queued{color:var(--warn)}.wst.running{color:var(--ok)}.wst.done{color:var(--pub)}.wst.failed{color:#ff8f8f}.wst.cancelled{color:var(--dim)}
|
||||
.wmeta{font-size:.72rem;color:var(--dim);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.wnode{border:1px solid var(--line);border-radius:6px;padding:0 6px;color:var(--txt)}
|
||||
.qhead{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 14px;font-size:.8rem;color:var(--dim)}
|
||||
.nodebadge{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:99px;padding:3px 11px;background:var(--panel)}
|
||||
.nodebadge .dot{width:7px;height:7px;border-radius:50%;background:#555}
|
||||
.nodebadge.on .dot{background:var(--ok)} .nodebadge.off{opacity:.6}
|
||||
.qhead .mstat{margin-left:auto;color:var(--dim)}
|
||||
#ctxdlg{max-width:560px}
|
||||
.card .addref{position:absolute;top:8px;right:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:1rem;cursor:pointer;z-index:4;line-height:1}
|
||||
.card .addref:hover{background:var(--brand);color:#111} .card .addref.on{background:var(--brand);color:#111}
|
||||
.card .delx{position:absolute;top:8px;left:9px;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.6);color:#fff;font-size:.85rem;cursor:pointer;z-index:4;line-height:1;opacity:.8}
|
||||
.card .delx:hover{background:#c0392b;color:#fff;opacity:1}
|
||||
#reftray{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1a1a24;border:1px solid #34344a;border-radius:12px;padding:10px 14px;display:none;gap:11px;align-items:center;z-index:44;box-shadow:0 10px 32px #000a}
|
||||
#reftray.on{display:flex} #reftray .rt{width:38px;height:56px;object-fit:cover;border-radius:5px;border:1px solid var(--line)}
|
||||
/* ── Immersive full-viewport stage (editorial hero) ────────────────────────── */
|
||||
#stage{position:relative;width:100vw;height:100vh;overflow:hidden;background:#000;display:none;user-select:none}
|
||||
body.immersive #stage{display:block}
|
||||
#stage .slide{position:absolute;inset:0;background:#000 center/cover no-repeat;opacity:0;transition:opacity .6s ease}
|
||||
#stage .slide.on{opacity:1}
|
||||
#stage .cap{position:absolute;left:48px;bottom:56px;z-index:6;max-width:60vw;text-shadow:0 2px 20px rgba(0,0,0,.6)}
|
||||
#stage .cap .k{font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:rgba(255,255,255,.75)}
|
||||
#stage .cap .t{font-size:2.2rem;font-weight:500;color:#fff;margin-top:6px;font-family:"Iowan Old Style",Georgia,serif}
|
||||
#stage .cap .tools{display:flex;gap:8px;margin-top:14px;opacity:0;transition:.2s} #stage:hover .cap .tools{opacity:1}
|
||||
#stage .nav{position:absolute;top:0;bottom:0;width:16vw;z-index:5;cursor:pointer;display:flex;align-items:center;color:rgba(255,255,255,.0);font-size:2rem;transition:.2s}
|
||||
#stage .nav:hover{color:rgba(255,255,255,.9)} #stage .nav.prev{left:0;justify-content:flex-start;padding-left:28px} #stage .nav.next{right:0;justify-content:flex-end;padding-right:28px}
|
||||
#stage .dots{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);display:flex;gap:7px;z-index:6}
|
||||
#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}
|
||||
/* ── Responsive: tablet ≤900px, phone ≤600px ─────────────────────────────── */
|
||||
@media (max-width:900px){
|
||||
main{padding:12px 16px 90px}
|
||||
.hero{font-size:2.1rem;margin:34px 0 20px}
|
||||
.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}
|
||||
header h1{font-size:.95rem} header .beta{display:none} header .lnk{display:none}
|
||||
header .user .nm{display:none} /* avatar only on phone */
|
||||
.hero{font-size:1.6rem;margin:22px 0 16px}
|
||||
.prompt{padding:12px 12px 10px;border-radius:13px}
|
||||
.prompt textarea{font-size:.95rem}
|
||||
.pctl{flex-wrap:wrap;gap:6px} .psend{margin-left:0}
|
||||
.grid{grid-template-columns:repeat(auto-fill,minmax(46vw,1fr));gap:9px}
|
||||
.tgrid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
|
||||
.sec-l{margin:26px 0 10px}
|
||||
.tabs{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
|
||||
.tabs button{padding:8px 10px;font-size:.84rem}
|
||||
.search{width:120px} .tabs .sp{display:none}
|
||||
#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 */
|
||||
@media (hover:none){
|
||||
.card .tools{max-height:none;opacity:1;margin-top:2px}
|
||||
}
|
||||
</style>
|
||||
<header>
|
||||
<h1>Hanzo Studio</h1><span class="org" id="org">…</span><span class="sp"></span>
|
||||
<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>
|
||||
<a class="btn brand" href="/?advanced=1" style="text-decoration:none">Advanced mode</a>
|
||||
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org">…</span></div>
|
||||
</header>
|
||||
<nav>
|
||||
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
|
||||
<button data-tab="pipelines" onclick="tab('pipelines')">Pipelines</button>
|
||||
<button data-tab="runs" onclick="tab('runs')">Runs</button>
|
||||
</nav>
|
||||
<div id="tophover"></div>
|
||||
<div id="livebar" class="idle">
|
||||
<span id="lbstatus" class="lb-q">Checking queue…</span>
|
||||
<span class="sp"></span>
|
||||
<a class="lb-more" onclick="tab('runs');document.getElementById('runs').scrollIntoView({behavior:'smooth'})">Queue & History →</a>
|
||||
</div>
|
||||
<main>
|
||||
<h2 class="hero serif">What should we create?</h2>
|
||||
<div class="prompt">
|
||||
<textarea id="pin" rows="1" placeholder="Describe what you want to create…"></textarea>
|
||||
<div class="pctl">
|
||||
<span class="pico" title="Attach a photo" onclick="attachPick()">+</span>
|
||||
<input id="attachfile" type="file" accept="image/*" multiple hidden>
|
||||
<span class="pchip" onclick="pickPreset()"><span class="k">Design system</span><span class="v" id="ds">None</span></span>
|
||||
<span class="pchip" onclick="tab('templates')"><span class="k">Template</span><span class="v" id="tmpl">None</span></span>
|
||||
<span class="pico" title="Code"></></span>
|
||||
<span class="pchip" style="margin-left:auto"><span class="k">Model</span><span class="v">Opus 4.8</span></span>
|
||||
<button class="psend" onclick="create()" title="Create">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-l">Use a template</div>
|
||||
<div class="tgrid" id="tcards"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
|
||||
<button data-tab="templates" onclick="tab('templates')">Templates</button>
|
||||
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
|
||||
<button data-tab="runs" onclick="tab('runs')">Queue & History</button>
|
||||
<span class="sp"></span>
|
||||
<button class="btn" id="selbtn" onclick="toggleSel()" style="padding:6px 12px">Select</button>
|
||||
<input class="search" id="q" placeholder="Search…" oninput="render()">
|
||||
</div>
|
||||
<section id="assets"><div class="chips" id="filters"></div><div class="grid" id="agrid"></div></section>
|
||||
<section id="pipelines" hidden><div class="list" id="wflist"></div></section>
|
||||
<section id="runs" hidden><div class="grid" id="rgrid"></div></section>
|
||||
<div id="bulkbar"><span id="bulkn">0 selected</span>
|
||||
<button class="mini hot" onclick="openCompose()" title="Combine selected assets into a new one">✦ Compose new</button>
|
||||
<button class="mini" onclick="bulk('approved')">Approve</button>
|
||||
<button class="mini" onclick="bulk('published')">Publish</button>
|
||||
<button class="mini" onclick="bulkFork()">Fork / redo</button>
|
||||
<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="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
|
||||
<section id="runs" hidden>
|
||||
<div id="qhead" class="qhead"></div>
|
||||
<div class="qcol"><h3 class="qh">🗂 Your work
|
||||
<input class="search" id="wlq" placeholder="Search prompts…" oninput="worklog()" style="margin-left:10px"></h3>
|
||||
<div class="qlist" id="wllist"></div></div>
|
||||
<div class="qcol"><h3 class="qh">▶ Now running <span id="qrunN" class="cnt">0</span></h3><div class="qlist" id="qrun"></div></div>
|
||||
<div class="qcol"><h3 class="qh">⏳ Up next <span id="qpendN" class="cnt">0</span> <button class="mini" onclick="clearQueue()" style="margin-left:8px">Clear all</button></h3><div class="qlist" id="qpend"></div></div>
|
||||
<div class="qcol"><h3 class="qh">✓ History — rate & note for better future generations</h3><div class="grid" id="rgrid"></div></div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="reftray">
|
||||
<div style="font-size:.72rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em">Next generation</div>
|
||||
<div id="refthumbs" style="display:flex;gap:6px;align-items:center"></div>
|
||||
<input id="refprompt" placeholder="Describe what to make from these…" style="background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font:inherit;font-size:.82rem;width:280px">
|
||||
<button class="mini hot" onclick="genFromTray()">✦ Generate</button>
|
||||
<button class="mini" onclick="clearTray()">Clear</button>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<div id="zoom" onclick="this.style.display='none'"><img id="zoomimg"></div>
|
||||
<dialog id="fixdlg">
|
||||
<h3 style="font-size:1rem">Fix with chat</h3>
|
||||
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render of this exact shot is queued with only that change applied.</p>
|
||||
<textarea id="fixtext" placeholder="e.g. make the straps thicker and remove the blotchy skin texture"></textarea>
|
||||
<div class="row" style="justify-content:flex-end">
|
||||
<button class="mini" onclick="fixdlg.close()">Cancel</button>
|
||||
<button class="mini hot" onclick="sendFix()">Queue fix</button>
|
||||
<h3 id="fixtitle" style="font-size:1rem">Fix with chat</h3>
|
||||
<p id="fixdesc" style="color:var(--dim);font-size:.83rem;margin-top:6px">Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.</p>
|
||||
<div class="row" id="fixrefs" style="margin:12px 0 0;flex-wrap:wrap"></div>
|
||||
<div class="drop" id="fixdrop">Drag images here, or <b>click to upload</b> references</div>
|
||||
<input id="fixfile" type="file" accept="image/*" multiple hidden>
|
||||
<textarea id="fixtext" placeholder="e.g. make the straps thicker, smooth the skin"></textarea>
|
||||
<div id="gpuwarn" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
|
||||
<div id="fixerr" class="dlgerr"></div>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<button class="mini" id="fixhistbtn" onclick="toggleFixHistory()">+ From history</button>
|
||||
<div class="row"><button class="mini" onclick="fixdlg.close()">Cancel</button><button class="mini hot" onclick="sendFix()">Queue fix</button></div>
|
||||
</div>
|
||||
<div id="fixhist" style="display:none;max-height:210px;overflow:auto;margin-top:10px;border-top:1px solid var(--line);padding-top:10px"></div>
|
||||
</dialog>
|
||||
<dialog id="tmpldlg">
|
||||
<h3 style="font-size:1rem">★ Save as template</h3>
|
||||
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Turn this favorited generation into a reusable template — its workflow becomes a one-click KIND you can run again with new inputs.</p>
|
||||
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">Template name</label>
|
||||
<input id="tmplname" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Karma ghost mannequin — white studio">
|
||||
<label style="display:block;font-size:.72rem;color:var(--dim);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.04em">What it makes</label>
|
||||
<input id="tmpldesc" style="width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit" placeholder="e.g. Product on invisible form, real fabric">
|
||||
<div id="tmplsteps" style="color:var(--dim);font-size:.78rem;margin-top:12px;line-height:1.6"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:8px"><button class="mini" onclick="tmpldlg.close()">Cancel</button><button class="mini hot" onclick="saveTemplate()">Save template</button></div>
|
||||
</dialog>
|
||||
<dialog id="ctxdlg">
|
||||
<div class="row"><h3 id="ctxtitle" style="font-size:1rem;flex:1">Work item</h3><button class="mini" onclick="ctxdlg.close()">Close</button></div>
|
||||
<div id="ctxbody" style="margin-top:6px"></div>
|
||||
<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="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>
|
||||
</dialog>
|
||||
<dialog id="compdlg">
|
||||
<h3 style="font-size:1rem">✦ Compose new from <span id="compn">0</span> assets</h3>
|
||||
<p style="color:var(--dim);font-size:.83rem;margin-top:6px">Explain HOW to use the selected assets — e.g. "put the garment from the first onto the model in the second, keep the second's lighting."</p>
|
||||
<div class="row" id="compthumbs" style="margin:10px 0 0;flex-wrap:wrap"></div>
|
||||
<textarea id="comptext" placeholder="e.g. combine these — garment from #1 on the model + pose from #2, studio white background"></textarea>
|
||||
<div id="gpuwarn2" style="display:none;color:var(--warn);font-size:.78rem;margin:-4px 0 8px"></div>
|
||||
<div class="row" style="justify-content:flex-end"><button class="mini" onclick="compdlg.close()">Cancel</button><button class="mini hot" onclick="sendCompose()">Generate</button></div>
|
||||
</dialog>
|
||||
<script>
|
||||
let DATA={assets:[],workflows:[]},FILTER='all',FIXPATH=null;
|
||||
// ── Template / KIND catalog — everything Studio supports ─────────────────────
|
||||
const TEMPLATES=[
|
||||
// ── Fashion / commerce (the proven karma workflows, systematized) ──
|
||||
{k:'cad_ghost', t:'CAD → Ghost mannequin', d:'Real fabric, invisible form', ic:'👕', wf:'cad_ghost', grp:'Fashion'},
|
||||
{k:'outfit', t:'Outfit switcher', d:'Swap clothes, keep model+light', ic:'🔁', wf:'outfit', grp:'Fashion'},
|
||||
{k:'product', t:'Product photography', d:'Ecom front·side·back', ic:'🛍', wf:'product', grp:'Fashion'},
|
||||
{k:'lifestyle', t:'Lifestyle photography', d:'Same model/3D, in scene', ic:'🌅', wf:'lifestyle', grp:'Fashion'},
|
||||
{k:'cad_mockup',t:'CAD → mockup', d:'Fashion / packaging', ic:'✳️', wf:'cad_mockup', grp:'Fashion'},
|
||||
{k:'cad_3d', t:'CAD → 3D + drape', d:'Multi-view → 3D, fabric drape',ic:'🧊', wf:'cad_3d', grp:'Fashion'},
|
||||
// ── Media ──
|
||||
{k:'video', t:'Video', d:'Wan · LTX · Hunyuan', ic:'🎬', wf:'video', grp:'Media'},
|
||||
{k:'music', t:'Music', d:'ACE-Step · song', ic:'🎵', wf:'music', grp:'Media'},
|
||||
{k:'voice', t:'Voice', d:'Narration · TTS', ic:'🎙', wf:'voice', grp:'Media'},
|
||||
// ── Marketing ──
|
||||
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'📣', wf:'social', grp:'Marketing'},
|
||||
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'✉️', wf:'email', grp:'Marketing'},
|
||||
{k:'deck', t:'Deck', d:'Sales · investor', ic:'📊', wf:'deck', grp:'Marketing'},
|
||||
// ── Documents ──
|
||||
{k:'doc', t:'Document', d:'Word · guide', ic:'📄', wf:'doc', grp:'Documents'},
|
||||
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'🧮', wf:'sheet', grp:'Documents'},
|
||||
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'📐', wf:'paper', grp:'Documents'},
|
||||
// ── Web ──
|
||||
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'🖥', wf:'site', grp:'Web'},
|
||||
];
|
||||
let DATA={assets:[],workflows:[]},FILTER='all';
|
||||
const $=id=>document.getElementById(id);
|
||||
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3200)};
|
||||
const viewURL=v=>`/view?filename=${encodeURIComponent(v.filename)}&subfolder=${encodeURIComponent(v.subfolder)}&type=${v.type}`;
|
||||
function tab(name){document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
|
||||
for(const s of ['assets','pipelines','runs'])$(s).hidden=s!==name; if(name==='runs')runs();}
|
||||
async function load(){
|
||||
const r=await fetch('/v1/library');DATA=await r.json();
|
||||
$('org').textContent=DATA.org||'';
|
||||
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
|
||||
$('filters').innerHTML=['all','marketing',...groups].map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('');
|
||||
render();pipes();
|
||||
const toast=m=>{const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3000)};
|
||||
const imgURL=a=>`/v1/library/file?path=${encodeURIComponent(a.path)}`;
|
||||
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();}
|
||||
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;
|
||||
if(!p&&t==='None'){toast('Describe what to create, or pick a template');return;}
|
||||
toast('Creation flow wires copilot → exec/tasks (HIP-0506 phase 1)');
|
||||
// real path once phase 1 lands: POST /v1/studio/create {prompt,template,design_system}
|
||||
}
|
||||
async function load(){
|
||||
const r=await fetch('/v1/library'); DATA=await r.json(); $('org').textContent=DATA.org||'';
|
||||
$('tcards').innerHTML=TEMPLATES.slice(0,7).map(tcard).join('');
|
||||
// Templates tab: grouped by category
|
||||
const grps=[...new Set(TEMPLATES.map(t=>t.grp))];
|
||||
$('tgall').innerHTML=grps.map(g=>`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${g}</div>`+
|
||||
TEMPLATES.filter(t=>t.grp===g).map(tcard).join('')).join('');
|
||||
const groups=[...new Set(DATA.assets.map(a=>a.design).filter(Boolean))].sort();
|
||||
const delN=DATA.assets.filter(a=>a.status==='deleted').length;
|
||||
const chips=['all','marketing',...groups];
|
||||
$('filters').innerHTML=chips.map(g=>`<span class="chip ${g===FILTER?'on':''}" onclick="FILTER='${g}';render()">${g}</span>`).join('')
|
||||
+`<span class="chip ${FILTER==='deleted'?'on':''}" onclick="FILTER='deleted';render()" style="margin-left:auto">🗑 Deleted${delN?' ('+delN+')':''}</span>`;
|
||||
render();
|
||||
}
|
||||
let SELMODE=false; const SEL=new Set();
|
||||
function toggleSel(){SELMODE=!SELMODE;$('selbtn').classList.toggle('brand',SELMODE);if(!SELMODE)SEL.clear();updateBulk();render();}
|
||||
function clearSel(){SEL.clear();SELMODE=false;$('selbtn').classList.remove('brand');updateBulk();render();}
|
||||
function updateBulk(){$('bulkbar').classList.toggle('on',SELMODE&&SEL.size>0);$('bulkn').textContent=SEL.size+' selected';}
|
||||
function pick(ev,path){ev.stopPropagation();if(SEL.has(path))SEL.delete(path);else SEL.add(path);updateBulk();render();}
|
||||
function render(){
|
||||
const rows=DATA.assets.filter(a=>FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER);
|
||||
$('agrid').innerHTML=rows.map((a,i)=>{
|
||||
const img=a.view&&/\.(png|jpe?g|webp)$/i.test(a.view.filename);
|
||||
const prov=a.prov&&a.prov.workflow?`· ${a.prov.workflow}${a.prov.cfg?` · cfg ${a.prov.cfg}`:''}`:'';
|
||||
return `<div class="card">
|
||||
${img?`<div class="im" style="background-image:url('${viewURL(a.view)}')" onclick="zoom('${viewURL(a.view)}')"></div>`
|
||||
const q=($('q').value||'').toLowerCase();
|
||||
const showDel=FILTER==='deleted';
|
||||
let rows=DATA.assets.filter(a=>{
|
||||
if(showDel)return a.status==='deleted';
|
||||
if(a.status==='deleted')return false; // hide trashed from normal views
|
||||
return FILTER==='all'?true:FILTER==='marketing'?(a.path||'').startsWith('marketing/'):a.design===FILTER;
|
||||
});
|
||||
if(q)rows=rows.filter(a=>(a.path||'').toLowerCase().includes(q));
|
||||
$('agrid').innerHTML=rows.map(a=>{
|
||||
const i=DATA.assets.indexOf(a);
|
||||
const img=/\.(png|jpe?g|webp)$/i.test(a.path||'');
|
||||
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
|
||||
const picked=SEL.has(a.path);
|
||||
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
|
||||
const inTray=trayHasLib(a.path);
|
||||
const pe=a.path.replace(/'/g,"\\'");
|
||||
return `<div class="card ${SELMODE?'sel-mode':''} ${picked?'picked':''}" ${cardClick}>
|
||||
<div class="pick">${picked?'✓':''}</div>
|
||||
${img&&!SELMODE?`<button class="addref ${inTray?'on':''}" title="Add to next generation" onclick="event.stopPropagation();addRef('${pe}')">${inTray?'✓':'+'}</button>`:''}
|
||||
${img&&!SELMODE&&!showDel?`<button class="delx" title="Move to Deleted (recoverable)" onclick="event.stopPropagation();quickDel('${pe}')">🗑</button>`:''}
|
||||
${img?`<div class="im" style="background-image:url('${thumbURL(a)}')" ${SELMODE?'':`onclick="zoom('${imgURL(a)}')"`}></div>`
|
||||
:`<div class="doc">${(a.path||'').split('/').pop()}</div>`}
|
||||
<div class="meta">
|
||||
<span class="t">${(a.path||'').split('/').pop()}</span>
|
||||
<span class="sub">${a.design||a.kind||''} ${prov}</span>
|
||||
<span class="sub">${a.design||a.kind||''}${prov}</span>
|
||||
<div class="row"><span class="st ${a.status}">${a.status}</span></div>
|
||||
<div class="row">
|
||||
${SELMODE?'':`<div class="row tools">
|
||||
${showDel?`<button class="mini" onclick="setSt(${i},'draft')">Recover</button>`:`
|
||||
${a.status!=='approved'?`<button class="mini" onclick="setSt(${i},'approved')">Approve</button>`:''}
|
||||
${a.status!=='queued'?`<button class="mini" onclick="setSt(${i},'queued')">Queue</button>`:''}
|
||||
${a.status!=='published'?`<button class="mini" onclick="setSt(${i},'published')">Publish</button>`:''}
|
||||
${a.status!=='draft'?`<button class="mini" onclick="setSt(${i},'draft')">Draft</button>`:''}
|
||||
</div>
|
||||
${img?`<div class="row">
|
||||
<button class="mini" onclick="rerun(${i})">Re-run</button>
|
||||
<button class="mini hot" onclick="openFix(${i})">Fix with chat</button>
|
||||
</div>`:''}
|
||||
</div></div>`;}).join('')||'<div class="empty">No assets yet — run a pipeline.</div>';
|
||||
${img?`<button class="mini" onclick="rerun(${i})">Re-run</button><button class="mini" onclick="openLineage('${pe}')" title="Show this image's versions (what it came from + later fixes)">Versions</button><button class="mini hot" onclick="openFix(${i})">Fix</button>`:''}
|
||||
<button class="mini" onclick="setSt(${i},'deleted')" title="Move to Deleted (recoverable)">🗑</button>`}
|
||||
</div>`}
|
||||
</div></div>`;}).join('')||`<div class="empty">${showDel?'Deleted is empty.':'No assets in this view yet.'}</div>`;
|
||||
}
|
||||
function pipes(){
|
||||
$('wflist').innerHTML=(DATA.workflows||[]).map(w=>`<div class="wf"><span class="name">${w}</span>
|
||||
<a class="mini" style="text-decoration:none" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a>
|
||||
<a class="mini hot" style="text-decoration:none" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div>`).join('')
|
||||
||'<div class="empty">No saved workflows for this org.</div>';
|
||||
async function bulk(status){
|
||||
if(!SEL.size)return;
|
||||
if(status==='deleted'&&!confirm('Move '+SEL.size+' asset(s) to Deleted? (recoverable)'))return;
|
||||
const paths=[...SEL];
|
||||
for(const p of paths){await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,status})});
|
||||
const a=DATA.assets.find(x=>x.path===p);if(a)a.status=status;}
|
||||
toast(paths.length+' → '+status); clearSel();
|
||||
}
|
||||
async function bulkFork(){ if(!SEL.size)return; toast('Fork/redo '+SEL.size+' — re-running each with a fresh seed');
|
||||
for(const p of [...SEL]){await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})});}
|
||||
clearSel(); }
|
||||
// ── Shared image-upload lane (ONE implementation) ────────────────────────────
|
||||
// Every "add a photo" surface (composer "+", Fix dialog, drag/drop, paste) funnels
|
||||
// through here → POST /upload/image, which lands the file in the caller's org input
|
||||
// dir (multi-tenant) and returns its name. Errors are RETURNED, never swallowed, so
|
||||
// every caller can SHOW them (no-silent-swallow rule).
|
||||
function errMsg(j){ if(!j)return ''; const e=j.error; return (e&&(e.message||e))||j.message||''; }
|
||||
async function uploadImage(file){
|
||||
if(!file||!file.type||!file.type.startsWith('image/'))return {error:'not an image file'};
|
||||
const fd=new FormData(); fd.append('image',file,file.name||'upload.png');
|
||||
try{
|
||||
const r=await fetch('/upload/image',{method:'POST',body:fd});
|
||||
let j={}; try{j=await r.json();}catch(_){}
|
||||
if(!r.ok)return {error:errMsg(j)||('upload failed ('+r.status+')')};
|
||||
if(!j.name)return {error:'upload failed (no name returned)'};
|
||||
return {name:j.name};
|
||||
}catch(e){ return {error:'upload failed ('+((e&&e.message)||e)+')'}; }
|
||||
}
|
||||
async function uploadMany(files,onName,onErr){ for(const f of [...files]){ const r=await uploadImage(f); if(r.name)onName(r.name); else onErr(r.error); } }
|
||||
function wireDrop(el,onFiles){
|
||||
['dragenter','dragover'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.add('drag');}));
|
||||
['dragleave','dragend'].forEach(ev=>el.addEventListener(ev,e=>{e.preventDefault();el.classList.remove('drag');}));
|
||||
el.addEventListener('drop',e=>{e.preventDefault();el.classList.remove('drag');if(e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files.length)onFiles(e.dataTransfer.files);});
|
||||
}
|
||||
function wirePaste(el,onFiles){ el.addEventListener('paste',e=>{const items=(e.clipboardData&&e.clipboardData.items)||[]; const fs=[...items].filter(i=>i.kind==='file').map(i=>i.getAsFile()).filter(Boolean); if(fs.length){e.preventDefault();onFiles(fs);}}); }
|
||||
const inputThumb=name=>`/v1/library/file?input=1&path=${encodeURIComponent(name)}`;
|
||||
// ── Reference tray: "+" on any photo / composer attach → next generation ──────
|
||||
// Holds mixed refs: {kind:'lib', ref:<library path>} and {kind:'up', ref:<input name>}.
|
||||
let TRAY=[];
|
||||
const trayHasLib=path=>TRAY.some(r=>r.kind==='lib'&&r.ref===path);
|
||||
function addRef(path){ const i=TRAY.findIndex(r=>r.kind==='lib'&&r.ref===path); if(i>=0)TRAY.splice(i,1); else{ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'lib',ref:path,thumb:thumbURL({path})}); } drawTray(); render(); }
|
||||
function addTrayUpload(name){ if(TRAY.length>=5){toast('Up to 5 references');return;} TRAY.push({kind:'up',ref:name,thumb:inputThumb(name)}); drawTray(); }
|
||||
function removeTrayRef(i){ TRAY.splice(i,1); drawTray(); render(); }
|
||||
function clearTray(){ TRAY=[]; drawTray(); render(); }
|
||||
function drawTray(){
|
||||
$('reftray').classList.toggle('on',TRAY.length>0);
|
||||
$('refthumbs').innerHTML=TRAY.map((r,i)=>`<span style="position:relative;display:inline-block">
|
||||
<img class="rt" src="${r.thumb}" title="${r.ref}">
|
||||
<button onclick="removeTrayRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.62rem;line-height:1;cursor:pointer">×</button></span>`).join('');
|
||||
}
|
||||
function attachPick(){ $('attachfile').click(); }
|
||||
async function attachFiles(files){ await uploadMany(files, addTrayUpload, err=>toast('Upload failed: '+err)); }
|
||||
async function genFromTray(){
|
||||
const t=$('refprompt').value.trim();
|
||||
if(!TRAY.length){toast('Add photos with + first');return;}
|
||||
if(t.length<3){toast('Describe what to make');return;}
|
||||
if(!(await gpuOnline()))toast('⚠ No GPU online — queues until a BYO-GPU connects');
|
||||
const libs=TRAY.filter(r=>r.kind==='lib').map(r=>r.ref), ups=TRAY.filter(r=>r.kind==='up').map(r=>r.ref);
|
||||
let r;
|
||||
if(libs.length+ups.length===1){ // single ref → edit (library or uploaded base)
|
||||
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(libs.length?{path:libs[0],instruction:t}:{upload:ups[0],instruction:t})});
|
||||
}else{ // 2-5 refs → compose
|
||||
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:libs,uploads:ups,instruction:t})});
|
||||
}
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ toast('Queued → see Queue & History'); $('refprompt').value=''; clearTray(); }
|
||||
else toast('Failed: '+(errMsg(j)||('error '+r.status)));
|
||||
}
|
||||
// ── GPU status: fix/compose need an ONLINE GPU worker (Qwen models live there) ──
|
||||
let GPU_ONLINE=null, GPU_TS=0;
|
||||
async function gpuOnline(){
|
||||
// Check the FLEET (same source dispatch uses), not /v1/engines — a `hanzo gpu
|
||||
// connect` box registers with the fleet, so /v1/engines missed it and the UI
|
||||
// wrongly said "connect a GPU". Cache 15s so we don't hammer it.
|
||||
if(GPU_ONLINE!==null && Date.now()-GPU_TS<15000)return GPU_ONLINE;
|
||||
try{const s=await (await fetch('/v1/gpu-status')).json(); GPU_ONLINE=!!s.online;}
|
||||
catch(_){GPU_ONLINE=false;}
|
||||
GPU_TS=Date.now(); return GPU_ONLINE;
|
||||
}
|
||||
async function openCompose(){
|
||||
if(SEL.size<2){toast('Select 2+ assets to compose');return;}
|
||||
const paths=[...SEL]; $('compn').textContent=paths.length;
|
||||
$('compthumbs').innerHTML=paths.map((p,i)=>`<div style="text-align:center"><img src="/v1/library/file?thumb=1&path=${encodeURIComponent(p)}" style="width:64px;height:96px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"><div style="font-size:.62rem;color:var(--dim)">#${i+1}</div></div>`).join('');
|
||||
$('comptext').value='';
|
||||
$('gpuwarn2').style.display=(await gpuOnline())?'none':'block';
|
||||
$('gpuwarn2').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
|
||||
$('compdlg').showModal();
|
||||
}
|
||||
async function sendCompose(){
|
||||
const t=$('comptext').value.trim(); if(t.length<3){toast('Describe how to combine them');return;}
|
||||
const paths=[...SEL]; toast('Composing from '+paths.length+' assets…');
|
||||
const r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths,instruction:t})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ $('compdlg').close(); toast(`Compose queued (${j.refs} refs) → see Queue & History`); clearSel(); }
|
||||
else{ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldn’t queue: '+(errMsg(j)||('error '+r.status)); }
|
||||
}
|
||||
function pipes(){ $('wfgrid').innerHTML=(DATA.workflows||[]).map(w=>`<div class="card"><div class="doc">⚙︎ ${w}</div><div class="meta"><div class="row"><a class="mini" href="/?advanced=1&workflow=${encodeURIComponent(w)}">Edit</a><a class="mini hot" href="/?advanced=1&workflow=${encodeURIComponent(w)}&run=1">Run</a></div></div></div>`).join('')||'<div class="empty">No saved workflows for this org yet.</div>'; }
|
||||
// ── YouTube-style Queue & History ───────────────────────────────────────────
|
||||
let RATINGS={};
|
||||
const vURL=im=>`/view?filename=${encodeURIComponent(im.filename)}&subfolder=${encodeURIComponent(im.subfolder||'')}&type=${im.type||'output'}`;
|
||||
const promptTitle=v=>{ // pull the SaveImage prefix / positive text from the run's graph
|
||||
const g=(v.prompt&&v.prompt[2])||{};
|
||||
for(const n of Object.values(g)){if(n&&n.class_type==='SaveImage')return (n.inputs.filename_prefix||'render').split('/').pop();}
|
||||
return 'render';
|
||||
};
|
||||
// ── Work log: the org's dispatched renders with real status + full context ────
|
||||
let WL=[], CTX=null;
|
||||
// live queue status (positions/ETAs/node) computed SERVER-SIDE; QLOCAL seeds the
|
||||
// client-side elapsed tick between polls. NODES = the org's GPU nodes for badges.
|
||||
let QST={items:{},medians:{},lanes:{},now:0}, QLOCAL=Date.now(), NODES=[];
|
||||
const WSTATUS={queued:['⏳','queued'],running:['●','running'],done:['✓','done'],failed:['⚠','failed'],cancelled:['✕','cancelled']};
|
||||
const fmtDur=s=>{ s=Math.max(0,Math.round(s)); if(s<60)return s+'s'; const m=Math.floor(s/60),r=s%60; if(s<3600)return m+'m'+(r?(' '+r+'s'):''); return Math.floor(s/3600)+'h '+Math.floor((s%3600)/60)+'m'; };
|
||||
const fmtEta=s=>{ s=Math.max(0,Math.round(s)); return s<60?('~'+s+'s est'):('~'+Math.round(s/60)+' min est'); };
|
||||
function wlThumb(it){ if(it.output)return thumbURL({path:it.output});
|
||||
if(it.refs&&it.refs[0])return inputThumb(it.refs[0]);
|
||||
if(it.parents&&it.parents[0])return thumbURL({path:it.parents[0]}); return ''; }
|
||||
function qmetaHTML(it,e){
|
||||
if(!e)return '';
|
||||
const node=`<span class="wnode">⚙ ${(e.node||it.node||'gpu')}</span>`;
|
||||
if(e.status==='running'){
|
||||
const el=(e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000;
|
||||
return `<div class="wmeta">${node} · #${e.position}/${e.of} · running <span id="el-${it.id}">${fmtDur(el)}</span>${e.eta_seconds!=null?(' · ~'+fmtDur(e.eta_seconds)+' left'):''}</div>`;
|
||||
}
|
||||
return `<div class="wmeta">${node} · #${e.position}/${e.of} in line · ${fmtEta(e.eta_seconds||0)}</div>`;
|
||||
}
|
||||
function wlRow(it){
|
||||
const e=QST.items[it.id]; // live status wins over the stored one
|
||||
const status=(e&&e.status)||it.status;
|
||||
const [ic,lbl]=WSTATUS[status]||['·',status||''];
|
||||
const th=wlThumb(it), when=it.ts?new Date(it.ts*1000).toLocaleString():'';
|
||||
const active=status==='queued'||status==='running';
|
||||
return `<div class="qrow" style="cursor:pointer" onclick="openContext('${it.id}')">
|
||||
<div class="qthumb" style="${th?`background-image:url('${th}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${th?'':'🗂'}</div>
|
||||
<div class="qtitle">${it.kind} · <span class="wst ${status}">${ic} ${lbl}</span>
|
||||
<div class="qdesc">${(it.prompt||'(no prompt)').replace(/</g,'<')}</div>
|
||||
${active&&e?qmetaHTML(it,e):`<div class="qsub">${when}${it.node?(' · ⚙ '+it.node):''}${it.error?` · <span style="color:#ff8f8f">${it.error.replace(/</g,'<')}</span>`:''}</div>`}</div>
|
||||
${active?`<button class="mini" onclick="event.stopPropagation();openAmend('${it.id}')" title="Amend prompt/refs (re-queues)">Edit</button><button class="mini" onclick="event.stopPropagation();cancelJob('${it.id}')">Cancel</button>`:''}
|
||||
<button class="mini" onclick="event.stopPropagation();openContext('${it.id}')">Open</button></div>`;
|
||||
}
|
||||
// server does the position/ETA math over the org's OWN jobs; the client only renders.
|
||||
async function loadQueueStatus(){ let d; try{d=await (await fetch('/v1/queue/status')).json();}catch(_){d=null;} if(d){QST=d;QLOCAL=Date.now();} }
|
||||
async function loadNodes(){ try{const d=await (await fetch('/v1/nodes')).json(); NODES=d.nodes||[];}catch(_){NODES=[];} renderQhead(); }
|
||||
function renderQhead(){
|
||||
if(!$('qhead'))return;
|
||||
const badges=(NODES||[]).map(n=>`<span class="nodebadge ${n.online?'on':'off'}"><span class="dot"></span>${n.name}${n.online?'':' · offline'}</span>`).join('')
|
||||
+`<span class="nodebadge on"><span class="dot"></span>studio pod</span>`;
|
||||
const m=QST.medians||{}, ks=Object.keys(m);
|
||||
const ms=ks.length?('median '+ks.map(k=>k+' ~'+fmtDur(m[k])).join(' · ')):'';
|
||||
$('qhead').innerHTML=badges+(ms?`<span class="mstat">${ms}</span>`:'');
|
||||
}
|
||||
async function refreshQueue(){
|
||||
const before=Object.keys(QST.items||{});
|
||||
await loadQueueStatus(); await worklog(); renderQhead();
|
||||
let fin=0; before.forEach(id=>{ if(!QST.items[id]){ const w=WL.find(x=>x.id===id); if(w&&w.status==='done')fin++; } });
|
||||
if(fin>0)toast('✓ '+fin+' render'+(fin>1?'s':'')+' finished — in your library');
|
||||
}
|
||||
async function worklog(){
|
||||
const q=($('wlq')&&$('wlq').value||'').trim();
|
||||
let d={items:[]}; try{d=await (await fetch('/v1/worklog'+(q?('?q='+encodeURIComponent(q)):''))).json();}catch(_){}
|
||||
WL=d.items||[];
|
||||
$('wllist').innerHTML=WL.map(wlRow).join('')||'<div class="qsub" style="padding:6px 2px">No work yet — your renders will appear here with full context.</div>';
|
||||
}
|
||||
function pv(url,label,click){ return `<div style="text-align:center">
|
||||
<img src="${url}" ${click?`onclick="${click}" style="cursor:pointer;`:'style="'}width:60px;height:88px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
|
||||
<div style="font-size:.58rem;color:var(--dim);margin-top:2px">${label}</div></div>`; }
|
||||
function ctxHTML(it,lin){
|
||||
const parents=(it.parents||[]).map(p=>pv(thumbURL({path:p}),'from',`openLibItem('${p.replace(/'/g,"\\'")}')`)).join('');
|
||||
const refs=(it.refs||[]).map(n=>pv(inputThumb(n),(it.uploads||[]).includes(n)?'upload':'ref')).join('');
|
||||
const result=it.output?pv(thumbURL({path:it.output}),'result',`openLibItem('${it.output.replace(/'/g,"\\'")}')`):'';
|
||||
const params=it.params&&Object.keys(it.params).length?Object.entries(it.params).map(([k,v])=>k+':'+v).join(' · '):'';
|
||||
const trail=(it.events||[]).map(e=>`${e.s}${e.ts?(' '+new Date(e.ts*1000).toLocaleTimeString()):''}${e.note?(' — '+e.note.replace(/</g,'<')):''}`).join(' → ');
|
||||
const chain=[...lin.ancestors.map(a=>[a,a.kind||'ancestor']),...(it.output?[[{path:it.output},'● this']]:[]),...lin.descendants.map(d=>[d,d.kind||'later'])];
|
||||
const chainHTML=chain.length>1?`<div class="ctxlabel">Versions</div><div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:4px">${chain.map(([n,role])=>pv(thumbURL({path:n.path}),role,role==='● this'?'':`openLibItem('${(n.path||'').replace(/'/g,"\\'")}')`)).join('')}</div>`:'';
|
||||
return `${it.prompt?`<div class="ctxlabel">Prompt</div><div style="font-size:.86rem;background:#0f0f12;border:1px solid var(--line);border-radius:8px;padding:9px 11px">${it.prompt.replace(/</g,'<')}</div>`:''}
|
||||
${(parents||refs||result)?`<div class="ctxlabel">References & result</div><div class="row" style="flex-wrap:wrap">${parents}${refs}${result}</div>`:''}
|
||||
<div class="ctxlabel">Workflow</div><div style="font-size:.82rem;color:var(--dim)">${it.kind} · ${it.status||''}${it.lane&&it.lane!=='local'?(' · '+it.lane):''}${params?(' · '+params):''}</div>
|
||||
${trail?`<div class="ctxlabel">Process</div><div style="font-size:.78rem;color:var(--dim)">${trail}</div>`:''}
|
||||
${it.error?`<div class="dlgerr" style="display:block">${it.error.replace(/</g,'<')}</div>`:''}${chainHTML}`;
|
||||
}
|
||||
async function lineageFor(path){ if(!path)return {ancestors:[],descendants:[],node:''}; try{return await (await fetch('/v1/worklog/lineage?path='+encodeURIComponent(path))).json();}catch(_){return {ancestors:[],descendants:[],node:path};} }
|
||||
async function openContext(id){
|
||||
const it=WL.find(x=>x.id===id); if(!it)return; CTX=it;
|
||||
$('ctxtitle').textContent=it.kind+' — '+(it.status||'');
|
||||
$('ctxbody').innerHTML=ctxHTML(it, await lineageFor(it.output));
|
||||
const hasBase=!!(it.output||(it.parents&&it.parents[0]));
|
||||
const live=it.status==='queued'||it.status==='running';
|
||||
$('ctxreuse').style.display=hasBase?'':'none';
|
||||
$('ctxfix').style.display=hasBase?'':'none';
|
||||
$('ctxcancel').style.display=live?'':'none';
|
||||
$('ctxedit').style.display=live?'':'none';
|
||||
$('ctxdlg').showModal();
|
||||
}
|
||||
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
|
||||
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();
|
||||
}
|
||||
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
|
||||
async function primeFix(base,prompt,libRefs,upRefs){
|
||||
await openFixPath(base,prompt);
|
||||
(libRefs||[]).filter(p=>p&&p!==base).forEach(p=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'lib',ref:p,thumb:thumbURL({path:p})}); });
|
||||
(upRefs||[]).forEach(n=>{ if(FIXREFS.length<MAXFIXREFS)FIXREFS.push({kind:'up',ref:n,thumb:inputThumb(n)}); });
|
||||
drawFixRefs();
|
||||
}
|
||||
function reuseCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No base image to reuse');return;} ctxdlg.close(); primeFix(base,CTX.prompt||'',CTX.parents||[],CTX.uploads||[]); }
|
||||
function fixCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.parents[0]); if(!base){toast('No result to fix yet');return;} ctxdlg.close(); openFixPath(base,''); }
|
||||
async function runs(){
|
||||
const h=await (await fetch('/history?max_items=24')).json();
|
||||
const items=Object.entries(h).reverse().map(([id,v])=>{
|
||||
const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))ims.push(im);
|
||||
const ok=(v.status||{}).status_str||'?';
|
||||
return {id,ok,im:ims[0]};
|
||||
});
|
||||
$('rgrid').innerHTML=items.map(r=>`<div class="card">
|
||||
${r.im?`<div class="im" style="background-image:url('${viewURL(r.im)}')" onclick="zoom('${viewURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
|
||||
<div class="meta"><span class="t">${r.im?r.im.filename:r.id.slice(0,8)}</span>
|
||||
<div class="row"><span class="st ${r.ok==='success'?'approved':'draft'}">${r.ok}</span></div></div></div>`).join('')
|
||||
||'<div class="empty">No runs yet.</div>';
|
||||
refreshQueue(); loadNodes();
|
||||
RATINGS=await (await fetch('/v1/library/ratings')).json().catch(()=>({}));
|
||||
// live queue
|
||||
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
|
||||
const qr=q.queue_running||[], qp=q.queue_pending||[];
|
||||
$('qrunN').textContent=qr.length; $('qpendN').textContent=qp.length;
|
||||
// YouTube-style up-next row: reference thumbnail + title + brief description.
|
||||
const qitem=(it,running,pos)=>{
|
||||
const id=it[1], g=it[2]||{};
|
||||
const nodes=Object.values(g);
|
||||
const title=(nodes.find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||id+'').split('/').pop();
|
||||
// reference image(s) attached to this job (fix/compose sources live in input dir)
|
||||
const refs=nodes.filter(n=>n.class_type==='LoadImage').map(n=>n.inputs?.image).filter(Boolean);
|
||||
const thumb=refs.length?`/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(refs[0])}`:'';
|
||||
// brief description = the positive prompt / instruction
|
||||
let desc='';
|
||||
for(const n of nodes){ if(n.class_type&&n.class_type.indexOf('TextEncode')>=0){ const t=n.inputs?.prompt||n.inputs?.text||''; if(t&&!/blurry|deformed|watermark|ghosting/i.test(t.slice(0,40))){desc=t;break;} } }
|
||||
desc=(desc||'').replace(/\.\s*(Photorealistic|Combine|keep the same).*$/i,'').trim().slice(0,140);
|
||||
const eta=running?'rendering now':(pos!=null?`~${(pos+1)*ETA_MIN} min · #${pos+1} in line`:'queued');
|
||||
return `<div class="qrow">
|
||||
<div class="qthumb" style="${thumb?`background-image:url('${thumb}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--dim)'}">${thumb?'':(running?'▶':'⏳')}</div>
|
||||
<div class="qtitle">${title}<div class="qdesc">${desc||'(no description)'}</div><div class="qsub">${running?'<span style=color:var(--ok)>● </span>':''}${eta}${refs.length>1?` · ${refs.length} refs`:''}</div></div>
|
||||
${running?'':`<button class="mini" onclick="cancelJob('${id}')">Remove</button>`}</div>`;};
|
||||
// BYO-GPU renders (fix/compose on a connected GPU) — rendered on the worker,
|
||||
// shown here so a dispatched fix is visible (was invisible before).
|
||||
let gjobs=[]; try{gjobs=(await (await fetch('/v1/render-queue')).json()).jobs||[];}catch(_){}
|
||||
const gjRow=j=>`<div class="qrow">
|
||||
<div class="qthumb" style="${j.refs&&j.refs[0]?`background-image:url('/v1/library/file?thumb=1&input=1&path=${encodeURIComponent(j.refs[0])}');background-size:cover;background-position:center`:'display:flex;align-items:center;justify-content:center;color:var(--ok)'}">${j.refs&&j.refs[0]?'':'▶'}</div>
|
||||
<div class="qtitle">${j.prefix||'render'}<div class="qsub"><span style=color:var(--ok)>● </span>rendering on ${j.node||'your GPU'} · ${Math.max(0,Math.round((j.age||0)/60))}m elapsed${QST.items[j.id]&&QST.items[j.id].eta_seconds!=null?(' · ~'+fmtDur(QST.items[j.id].eta_seconds)+' left'):''}</div></div>
|
||||
${j.id?`<button class="mini" onclick="cancelJob('${j.id}')">Cancel</button>`:''}</div>`;
|
||||
const gjHtml=gjobs.map(gjRow).join('');
|
||||
$('qrun').innerHTML=gjHtml + qr.map(it=>qitem(it,true,null)).join('') || '<div class="qsub" style="padding:6px 2px">Idle — nothing rendering.</div>';
|
||||
$('qpend').innerHTML=qp.map((it,i)=>qitem(it,false,i)).join('')||'<div class="qsub" style="padding:6px 2px">Nothing queued.</div>';
|
||||
// history
|
||||
const h=await (await fetch('/history?max_items=40')).json();
|
||||
const items=Object.entries(h).reverse().map(([id,v])=>{const ims=[];for(const o of Object.values(v.outputs||{}))for(const im of (o.images||[]))if(im.type==='output')ims.push(im);
|
||||
return {id,ok:(v.status||{}).status_str||'?',im:ims[0],title:promptTitle(v)};});
|
||||
$('rgrid').innerHTML=items.map(r=>{
|
||||
const rt=RATINGS[r.id]||{}; const st=rt.stars||0;
|
||||
const stars=[1,2,3,4,5].map(n=>`<span class="${n<=st?'on':''}" onclick="rate('${r.id}',${n})">★</span>`).join('');
|
||||
return `<div class="card">
|
||||
${r.im?`<div class="im" style="background-image:url('${vURL(r.im)}')" onclick="zoom('${vURL(r.im)}')"></div>`:`<div class="doc">no output</div>`}
|
||||
<div class="meta">
|
||||
<span class="t">${r.title}</span>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<span class="stars">${stars}</span>
|
||||
<span class="fav ${rt.favorite?'on':''}" onclick="toggleFav('${r.id}','${r.title}')" title="Favorite → save as template">★fav</span>
|
||||
</div>
|
||||
<textarea class="notebox" placeholder="Notes for the AI to improve next time…" onblur="rateNote('${r.id}',this.value)">${(rt.notes||'').replace(/</g,'<')}</textarea>
|
||||
</div></div>`;}).join('')||'<div class="empty">No runs yet.</div>';
|
||||
}
|
||||
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: '+(await r.json()).error);
|
||||
async function clearQueue(){if(!confirm('Clear all queued jobs?'))return;await fetch('/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clear:true})});toast('Queue cleared');runs();}
|
||||
async function rate(key,stars){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,stars})});RATINGS[key]={...(RATINGS[key]||{}),stars};runs();}
|
||||
async function rateNote(key,notes){await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,notes})});toast('Note saved — the AI will use it next time');}
|
||||
let FAVKEY=null,FAVTITLE=null;
|
||||
async function toggleFav(key,title){const cur=RATINGS[key]||{};const nv=!cur.favorite;
|
||||
await fetch('/v1/library/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,favorite:nv})});
|
||||
RATINGS[key]={...cur,favorite:nv};
|
||||
if(nv){FAVKEY=key;FAVTITLE=title;$('tmplname').value=title;$('tmpldesc').value='';
|
||||
$('tmplsteps').innerHTML='This favorited run has an embedded workflow. Saving turns it into a reusable KIND:<br>1 · name it & describe what it makes<br>2 · it appears under Templates for your org<br>3 · run it anytime with new inputs';
|
||||
$('tmpldlg').showModal();}
|
||||
else runs();
|
||||
}
|
||||
async function rerun(i){
|
||||
const a=DATA.assets[i];toast('Queueing re-run…');
|
||||
const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({path:a.path})});
|
||||
const j=await r.json();
|
||||
toast(r.ok?`Re-run queued (${(j.prompt_ids||[]).length}) — watch Runs tab`:'Failed: '+j.error);
|
||||
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();
|
||||
}
|
||||
function openFix(i){FIXPATH=DATA.assets[i].path;$('fixtext').value='';$('fixdlg').showModal();}
|
||||
async function sendFix(){
|
||||
const txt=$('fixtext').value.trim();if(!txt)return;
|
||||
$('fixdlg').close();toast('Queueing fix…');
|
||||
const r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({path:FIXPATH,instruction:txt})});
|
||||
const j=await r.json();
|
||||
toast(r.ok?`Fix queued → ${j.output_prefix} — watch Runs tab`:'Failed: '+j.error);
|
||||
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),
|
||||
// then persist. On failure, revert. This is the always-visible 🗑 on each card.
|
||||
async function quickDel(path){
|
||||
const a=DATA.assets.find(x=>x.path===path); if(!a)return;
|
||||
const prev=a.status; a.status='deleted'; render(); toast('Moved to Deleted');
|
||||
try{const r=await fetch('/v1/library/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path,status:'deleted'})});
|
||||
if(!r.ok)throw 0;}catch(_){a.status=prev;render();toast('Delete failed — restored');}
|
||||
}
|
||||
async function rerun(i){const a=DATA.assets[i];toast('Queueing re-run…');const r=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:a.path})});const j=await r.json();toast(r.ok?`Re-run queued — see Runs`:'Failed');}
|
||||
// ── Fix surface: base image + up to 4 references (history picks + drag/drop
|
||||
// uploads). No references → the single-image edit (/v1/library/fix); any
|
||||
// reference → the multi-input compose (/v1/library/compose) — same proven Qwen
|
||||
// graph, base image first. 5-ref cap = base + 4. ─────────────────────────────
|
||||
let FIXPATH=null; // the base image (library asset path)
|
||||
let FIXREFS=[]; // [{kind:'lib'|'up', ref, thumb}] added references
|
||||
let FIXMODE='fix'; // 'fix' (new render) | 'amend' (swap a queued job)
|
||||
let AMENDID=null; // the queued prompt_id when FIXMODE==='amend'
|
||||
let ORIGREFS=[]; // amend: the job's current refs (input-dir names), locked base
|
||||
let MAXFIXREFS=4; // added-ref cap = 5 − base count (set per open)
|
||||
function drawFixRefs(){
|
||||
let base;
|
||||
if(FIXMODE==='amend'){
|
||||
base=(ORIGREFS||[]).map(n=>`<div style="text-align:center"><img src="${inputThumb(n)}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">current</div></div>`).join('')
|
||||
||`<div style="font-size:.72rem;color:var(--dim)">Amending queued job</div>`;
|
||||
}else{
|
||||
base=`<div style="text-align:center"><img src="${thumbURL({path:FIXPATH})}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:2px solid var(--brand)"><div style="font-size:.6rem;color:var(--dim);margin-top:3px">base</div></div>`;
|
||||
}
|
||||
const refs=FIXREFS.map((r,i)=>`<div style="position:relative;text-align:center">
|
||||
<img src="${r.thumb}" style="width:56px;height:84px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">
|
||||
<button onclick="removeFixRef(${i})" title="Remove" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:#c0392b;color:#fff;font-size:.72rem;line-height:1;cursor:pointer">×</button>
|
||||
<div style="font-size:.6rem;color:var(--dim);margin-top:3px">${r.kind==='up'?'upload':'ref'}</div></div>`).join('');
|
||||
$('fixrefs').innerHTML=base+refs;
|
||||
}
|
||||
function removeFixRef(i){FIXREFS.splice(i,1);drawFixRefs();if($('fixhist').style.display!=='none')renderFixHist();}
|
||||
function toggleFixHistory(){const h=$('fixhist');const show=h.style.display==='none';h.style.display=show?'block':'none';if(show)renderFixHist();}
|
||||
function renderFixHist(){
|
||||
const imgs=DATA.assets.filter(a=>/\.(png|jpe?g|webp)$/i.test(a.path||'')&&a.status!=='deleted'&&a.path!==FIXPATH);
|
||||
$('fixhist').innerHTML=`<div style="font-size:.72rem;color:var(--dim);margin-bottom:8px">Tap a past generation to add it as a reference (max ${MAXFIXREFS}).</div>`
|
||||
+`<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:8px">`
|
||||
+imgs.map(a=>{const on=FIXREFS.some(r=>r.kind==='lib'&&r.ref===a.path);
|
||||
return `<img src="${thumbURL(a)}" onclick="addFixHistory('${a.path.replace(/'/g,"\\'")}')" title="${a.path}" style="width:100%;aspect-ratio:2/3;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid ${on?'var(--brand)':'transparent'}">`;
|
||||
}).join('')+`</div>`;
|
||||
}
|
||||
function addFixHistory(path){
|
||||
const at=FIXREFS.findIndex(r=>r.kind==='lib'&&r.ref===path);
|
||||
if(at>=0){FIXREFS.splice(at,1);}
|
||||
else{ if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');return;} FIXREFS.push({kind:'lib',ref:path,thumb:thumbURL({path})}); }
|
||||
drawFixRefs(); renderFixHist();
|
||||
}
|
||||
function showFixErr(m){ const e=$('fixerr'); e.textContent=m||''; e.style.display=m?'block':'none'; }
|
||||
async function uploadFixFiles(files){
|
||||
for(const f of [...files]){
|
||||
if(FIXREFS.length>=MAXFIXREFS){toast('Up to '+MAXFIXREFS+' references');break;}
|
||||
const r=await uploadImage(f);
|
||||
if(r.name){FIXREFS.push({kind:'up',ref:r.name,thumb:inputThumb(r.name)}); drawFixRefs(); showFixErr('');}
|
||||
else showFixErr('Upload failed: '+r.error);
|
||||
}
|
||||
}
|
||||
async function openFixPath(path,prompt){FIXMODE='fix';AMENDID=null;ORIGREFS=[];MAXFIXREFS=4;
|
||||
FIXPATH=path;FIXREFS=[];$('fixtext').value=prompt||'';showFixErr('');
|
||||
$('fixtitle').textContent='Fix with chat';
|
||||
$('fixdesc').textContent='Describe the change — a new render is queued from this image. Add reference photos (drag in, or pick from your history) to guide it.';
|
||||
$('fixhist').style.display='none'; drawFixRefs();
|
||||
$('gpuwarn').style.display=(await gpuOnline())?'none':'block';
|
||||
$('gpuwarn').textContent='⚠ No GPU worker online — this queues but renders only when a BYO-GPU (e.g. spark) is connected.';
|
||||
$('fixdlg').showModal();}
|
||||
async function openFix(i){ return openFixPath(DATA.assets[i].path,''); }
|
||||
async function openAmend(id){
|
||||
const it=WL.find(x=>x.id===id); if(!it){toast('Job not found');return;}
|
||||
FIXMODE='amend';AMENDID=id;FIXPATH=null;ORIGREFS=it.refs||[];FIXREFS=[];MAXFIXREFS=Math.max(0,5-ORIGREFS.length);
|
||||
$('fixtext').value=it.prompt||'';showFixErr('');
|
||||
$('fixtitle').textContent='Edit queued job';
|
||||
$('fixdesc').textContent='Change the prompt and/or add references. Saving cancels the queued job and re-queues it with the amendment — it re-enters the queue, so its position resets.';
|
||||
$('fixhist').style.display='none'; drawFixRefs();
|
||||
$('gpuwarn').style.display='none';
|
||||
$('fixdlg').showModal();
|
||||
}
|
||||
async function sendFix(){const t=$('fixtext').value.trim();if(t.length<3){showFixErr('Describe the change (3+ characters).');return;}
|
||||
showFixErr(''); toast(FIXMODE==='amend'?'Amending…':'Queueing fix…');
|
||||
const paths=FIXREFS.filter(x=>x.kind==='lib').map(x=>x.ref);
|
||||
const uploads=FIXREFS.filter(x=>x.kind==='up').map(x=>x.ref);
|
||||
let r;
|
||||
if(FIXMODE==='amend'){ // atomic swap: cancel the queued job + re-dispatch amended
|
||||
r=await fetch('/v1/queue/amend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:AMENDID,instruction:t,paths,uploads})});
|
||||
}else if(!FIXREFS.length){ // single base image → the proven edit path
|
||||
r=await fetch('/v1/library/fix',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:FIXPATH,instruction:t})});
|
||||
}else{ // base + references → multi-reference compose
|
||||
r=await fetch('/v1/library/compose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[FIXPATH,...paths],instruction:t,uploads})});
|
||||
}
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ $('fixdlg').close(); toast(FIXMODE==='amend'?'Amended — re-queued (position reset)':'Fix queued → see Queue & History'); if(typeof runs==='function')runs(); }
|
||||
else showFixErr('Couldn’t '+(FIXMODE==='amend'?'amend':'queue')+': '+(errMsg(j)||('error '+r.status))); // stay open so the reason is visible
|
||||
}
|
||||
async function cancelJob(id){
|
||||
if(!confirm('Cancel this job?'))return;
|
||||
const r=await fetch('/v1/queue/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt_id:id})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok)toast('Cancelled'+(j.canceled==='gpu'?' — if it was already rendering on your GPU it may still finish':''));
|
||||
else toast('Cancel failed: '+(errMsg(j)||('error '+r.status)));
|
||||
if(typeof runs==='function')runs();
|
||||
}
|
||||
// One shared drag/drop/paste + click-to-upload wiring, applied to every add-photo
|
||||
// surface: the Fix dialog dropzone AND the composer prompt box + "+" button.
|
||||
(function(){
|
||||
const fdrop=$('fixdrop'),ffile=$('fixfile');
|
||||
fdrop.addEventListener('click',()=>ffile.click());
|
||||
ffile.addEventListener('change',e=>{uploadFixFiles(e.target.files);e.target.value='';});
|
||||
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
|
||||
const af=$('attachfile');
|
||||
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
|
||||
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
|
||||
})();
|
||||
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
|
||||
load();
|
||||
const ta=$('pin'); ta.addEventListener('input',()=>{ta.style.height='auto';ta.style.height=Math.min(ta.scrollHeight,180)+'px';});
|
||||
async function loadSession(){
|
||||
let s={}; try{ s=await (await fetch('/v1/session')).json(); }catch(e){ return; }
|
||||
const nm=(s.user||s.email||'').trim(); $('av').textContent=(nm[0]||'·').toUpperCase(); if(s.org)$('org').textContent=s.org;
|
||||
$('user').onclick=()=>{ const ex=$('omenu'); if(ex){ex.remove();return;}
|
||||
const orgs=(s.orgs&&s.orgs.length?s.orgs:[s.org]).filter(Boolean);
|
||||
const m=document.createElement('div'); m.id='omenu';
|
||||
m.style.cssText='position:fixed;top:54px;right:24px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:6px;min-width:190px;z-index:30;box-shadow:0 12px 34px #000b';
|
||||
m.innerHTML=orgs.map(o=>`<div class="omi" data-o="${o}" style="padding:8px 11px;border-radius:8px;cursor:pointer;font-size:.85rem;color:${o===s.org?'var(--txt)':'var(--dim)'}">${o===s.org?'● ':'○ '}${o}</div>`).join('')
|
||||
+`<div style="height:1px;background:var(--line);margin:5px 4px"></div>`+(s.console_url?`<a href="${s.console_url}" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Console ↗</a>`:'')+`<a href="/logout" style="display:block;padding:8px 11px;color:var(--dim);text-decoration:none;font-size:.85rem">Sign out</a>`;
|
||||
document.body.appendChild(m);
|
||||
m.querySelectorAll('.omi').forEach(el=>el.onclick=async()=>{try{await fetch('/v1/session/org',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({org:el.dataset.o})});}catch(e){} location.reload();});
|
||||
setTimeout(()=>document.addEventListener('click',function h(e){if(!m.contains(e.target)&&!$('user').contains(e.target)){m.remove();document.removeEventListener('click',h);}}),0);
|
||||
};
|
||||
}
|
||||
// ── persistent live-queue bar: always shows what's generating + ETA ──────────
|
||||
const ETA_MIN=9; // ~min per render on a warm BYO-GPU
|
||||
async function updateLivebar(){
|
||||
// Two queues: the pod's local /queue (in-pod renders) AND the org's BYO-GPU
|
||||
// renders on gpu-jobs (fix/compose/rerun dispatched to a connected GPU). The
|
||||
// latter is why a queued fix "never showed" — it renders on the worker, not
|
||||
// the pod. Merge both.
|
||||
let q={queue_running:[],queue_pending:[]}; try{q=await (await fetch('/queue')).json();}catch(_){}
|
||||
let rq={jobs:[]}; try{rq=await (await fetch('/v1/render-queue')).json();}catch(_){}
|
||||
const run=q.queue_running||[], pend=q.queue_pending||[];
|
||||
const gpuJobs=rq.jobs||[];
|
||||
const lb=$('livebar'), st=$('lbstatus');
|
||||
const name=it=>{try{return (Object.values(it[2]).find(n=>n.class_type==='SaveImage')?.inputs?.filename_prefix||'render').split('/').pop();}catch(_){return 'render';}};
|
||||
if(gpuJobs.length){
|
||||
lb.classList.remove('idle');
|
||||
const first=gpuJobs[0], more=gpuJobs.length-1;
|
||||
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating on your GPU: <b>${first.prefix||'render'}</b></span>`
|
||||
+ (more>0?` <span class="lb-q">· ${more} more</span>`:'')
|
||||
+ ` <span class="lb-eta">· ~${ETA_MIN} min each</span>`;
|
||||
} else if(run.length){
|
||||
lb.classList.remove('idle');
|
||||
const q1=pend.length;
|
||||
st.innerHTML=`<span class="lb-run"><span class="spin"></span>Generating: <b>${name(run[0])}</b></span>`
|
||||
+ (q1?` <span class="lb-q">· ${q1} queued</span>`:'')
|
||||
+ ` <span class="lb-eta">· ~${ETA_MIN}${q1?`–${(q1+1)*ETA_MIN}`:''} min</span>`;
|
||||
} else if(pend.length){
|
||||
lb.classList.remove('idle');
|
||||
st.innerHTML=`<span class="lb-q">⏳ ${pend.length} queued · ~${pend.length*ETA_MIN} min · waiting for a GPU</span>`;
|
||||
} else {
|
||||
lb.classList.add('idle');
|
||||
const gpu=await gpuOnline();
|
||||
st.innerHTML=`<span class="lb-q">Idle — nothing generating.</span>${gpu?'':' <span class="lb-eta">· connect a GPU to generate</span>'}`;
|
||||
}
|
||||
}
|
||||
// header auto-hide (immersive) — reveal on top-hover / mouse near top
|
||||
document.getElementById('tophover').addEventListener('mouseenter',()=>document.querySelector('header').classList.add('reveal'));
|
||||
document.addEventListener('mousemove',e=>{if(e.clientY<8)document.querySelector('header').classList.add('reveal');else if(e.clientY>90&&document.body.classList.contains('immersive'))document.querySelector('header').classList.remove('reveal');});
|
||||
load(); loadSession(); updateLivebar(); setInterval(updateLivebar, 5000);
|
||||
// Queue transparency: refresh positions/ETAs while the queue view is open (sane
|
||||
// cadence, no hammering); node badges less often; tick the running item's elapsed.
|
||||
setInterval(()=>{ if(!$('runs').hidden)refreshQueue(); }, 6000);
|
||||
setInterval(()=>{ if(!$('runs').hidden)loadNodes(); }, 20000);
|
||||
setInterval(()=>{ for(const id in (QST.items||{})){ const e=QST.items[id]; if(e.status==='running'){ const el=$('el-'+id); if(el)el.textContent=fmtDur((e.elapsed_seconds||0)+(Date.now()-QLOCAL)/1000); } } }, 1000);
|
||||
</script>
|
||||
|
||||
+688
-28
@@ -12,9 +12,11 @@ storage or lifecycle:
|
||||
POST /v1/library/status {path, status} draft|approved|queued|published
|
||||
POST /v1/library/rerun {path, count?} re-queue the EXACT embedded graph, new seed
|
||||
POST /v1/library/fix {path, instruction} chat-fix: Qwen edit pass on that output
|
||||
POST /v1/library/compose {paths[], uploads[]?, instruction} multi-reference render
|
||||
(library assets and/or images uploaded via /upload/image)
|
||||
|
||||
One source of truth per concern, all pre-existing:
|
||||
* assets + curation state -> orgs/<org>/output/library.json (karma_manifest.py)
|
||||
* assets + curation state -> orgs/<org>/output/library.json (library_manifest.py)
|
||||
* what made an image -> the API graph ComfyUI embeds in the PNG itself
|
||||
* running work -> the ONE /prompt path (self-POST, so gpu_dispatch,
|
||||
billing and content_publish all apply unchanged)
|
||||
@@ -42,11 +44,12 @@ import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
from middleware import worklog
|
||||
|
||||
log = logging.getLogger("studio.home")
|
||||
|
||||
_HTML = Path(__file__).with_name("studio_home.html")
|
||||
_STATUSES = ("draft", "approved", "queued", "published")
|
||||
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
|
||||
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
|
||||
|
||||
_NEG = ("ghosting, double exposure, duplicated body, transparent fabric, sheer "
|
||||
@@ -142,6 +145,31 @@ def _safe_asset(root: Path, rel: str) -> Path | None:
|
||||
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
|
||||
|
||||
|
||||
_REF_EXT = (".png", ".jpg", ".jpeg", ".webp")
|
||||
|
||||
|
||||
def _resolve_uploads(org: str, uploads: list | None) -> list[str]:
|
||||
"""Validate reference images already uploaded via ``POST /upload/image`` — the
|
||||
core engine route lands them in this org's input dir (the SAME place fix/compose
|
||||
stage their library-sourced references), so the compose graph's LoadImage nodes
|
||||
can name them directly and the BYO-GPU collector ships them with the job. Return
|
||||
the safe basenames, in order, de-duped. A traversal/absent/non-image entry is
|
||||
dropped, never fatal — the caller enforces the reference count."""
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
root = str(in_dir.resolve())
|
||||
names: list[str] = []
|
||||
for u in uploads or []:
|
||||
if not isinstance(u, str):
|
||||
continue
|
||||
name = os.path.basename(u.strip())
|
||||
if not name or name in names or os.path.splitext(name)[1].lower() not in _REF_EXT:
|
||||
continue
|
||||
p = (in_dir / name).resolve()
|
||||
if p.is_file() and str(p).startswith(root):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _reseed(graph: dict) -> dict:
|
||||
for node in graph.values():
|
||||
ins = node.get("inputs", {})
|
||||
@@ -177,20 +205,362 @@ def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _compose_graph(ref_names: list[str], instruction: str, prefix: str) -> dict:
|
||||
"""Multi-reference compose: several chosen assets become the references and the
|
||||
user's words say HOW to combine them into a new output. Same proven Qwen
|
||||
reference-conditioned wiring as _fix_graph (TextEncodeQwenImageEditPlus takes
|
||||
image1..imageN), so no new pipeline — the multi-input case of the good graph."""
|
||||
prompt = (f"{instruction.strip()}. Combine the provided reference images as "
|
||||
f"described. Photorealistic, coherent single result.")
|
||||
g: dict = {
|
||||
"3": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen2.5-vl-7b.safetensors", "type": "qwen_image", "device": "default"}},
|
||||
"4": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen-image-edit-2511.safetensors", "weight_dtype": "default"}},
|
||||
"5": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen-image-vae.safetensors"}},
|
||||
}
|
||||
pos_inputs: dict = {"clip": ["3", 0], "vae": ["5", 0], "prompt": prompt}
|
||||
nid = 20
|
||||
for i, name in enumerate(ref_names[:5], start=1): # up to 5 references
|
||||
li, si = str(nid), str(nid + 1)
|
||||
g[li] = {"class_type": "LoadImage", "inputs": {"image": name}}
|
||||
g[si] = {"class_type": "FluxKontextImageScale", "inputs": {"image": [li, 0]}}
|
||||
pos_inputs[f"image{i}"] = [si, 0]
|
||||
nid += 2
|
||||
g["6"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": pos_inputs}
|
||||
g["7"] = {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "prompt": _NEG}}
|
||||
g["8"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["6", 0], "reference_latents_method": "index_timestep_zero"}}
|
||||
g["9"] = {"class_type": "FluxKontextMultiReferenceLatentMethod", "inputs": {"conditioning": ["7", 0], "reference_latents_method": "index_timestep_zero"}}
|
||||
g["10"] = {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1536, "batch_size": 1}}
|
||||
g["11"] = {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}}
|
||||
g["12"] = {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}}
|
||||
g["13"] = {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["10", 0],
|
||||
"seed": random.randrange(2**31), "steps": 30, "cfg": 4.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}}
|
||||
g["14"] = {"class_type": "VAEDecode", "inputs": {"samples": ["13", 0], "vae": ["5", 0]}}
|
||||
g["15"] = {"class_type": "SaveImage", "inputs": {"images": ["14", 0], "filename_prefix": prefix}}
|
||||
return g
|
||||
|
||||
|
||||
class DispatchError(ValueError):
|
||||
"""A create/fix/compose request that can't be honored — bad input, unknown
|
||||
asset, or a downstream /prompt failure. Carries a human message; the routes
|
||||
return it as ``{"error": <message>}`` so a dispatch NEVER fails silently."""
|
||||
|
||||
|
||||
def _prompt_error_message(body) -> str:
|
||||
"""A short, human reason from a /prompt non-200 body. On a model-less cloud
|
||||
pod the graph fails validation ("unet_name '…' not in []"); the router returns
|
||||
'no GPU worker' / 'provisioning'. This is what the user must SEE instead of a
|
||||
silent nothing."""
|
||||
err = body.get("error") if isinstance(body, dict) else None
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message") or err.get("type") or "render could not be queued"
|
||||
det = err.get("details")
|
||||
return f"{msg} — {det}" if det and det != msg else msg
|
||||
if isinstance(err, str) and err.strip():
|
||||
return err.strip()
|
||||
if isinstance(body, dict) and body.get("message"):
|
||||
return str(body["message"])
|
||||
return "render could not be queued — no GPU worker online for this org"
|
||||
|
||||
|
||||
async def _queue_prompt(request: web.Request, server, graph: dict) -> str:
|
||||
"""Self-POST to the ONE /prompt path with the caller's own credentials, so
|
||||
dispatch/billing/tenancy/content-publish behave exactly as a UI run."""
|
||||
dispatch/billing/tenancy/content-publish behave exactly as a UI run. A non-200
|
||||
from /prompt (model-less pod fails validation; router has no GPU worker) is
|
||||
raised as a ``DispatchError`` carrying the real reason — NOT a raw 502 whose
|
||||
truncated body the dialog can't parse (that was the deployed 'nothing happened /
|
||||
nothing queued' — the error was swallowed)."""
|
||||
port = getattr(server, "port", None) or int(os.environ.get("PORT", "8188"))
|
||||
headers = {"Content-Type": "application/json"}
|
||||
for h in ("Authorization", "Cookie"):
|
||||
if request.headers.get(h):
|
||||
headers[h] = request.headers[h]
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
|
||||
async with s.post(f"http://127.0.0.1:{port}/prompt", json={"prompt": graph}, headers=headers) as r:
|
||||
body = await r.json(content_type=None)
|
||||
if r.status != 200:
|
||||
raise web.HTTPBadGateway(text=json.dumps(body)[:500])
|
||||
return body.get("prompt_id", "")
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as s:
|
||||
async with s.post(f"http://127.0.0.1:{port}/prompt", json={"prompt": graph}, headers=headers) as r:
|
||||
text = await r.text()
|
||||
try:
|
||||
body = json.loads(text) if text else {}
|
||||
except ValueError:
|
||||
body = {} # non-JSON (e.g. an HTML error page) — don't 500, report it
|
||||
if r.status != 200:
|
||||
raise DispatchError(_prompt_error_message(body) if body
|
||||
else f"render engine returned HTTP {r.status}")
|
||||
pid = body.get("prompt_id", "") if isinstance(body, dict) else ""
|
||||
if not pid:
|
||||
raise DispatchError("render was not queued (no prompt_id returned)")
|
||||
return pid
|
||||
except aiohttp.ClientError as e:
|
||||
raise DispatchError(f"could not reach the render engine ({e})")
|
||||
|
||||
|
||||
def _graph_params(graph: dict) -> dict:
|
||||
"""The render params worth showing in the work-log context panel."""
|
||||
p: dict = {}
|
||||
for n in graph.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
ct, ins = n.get("class_type", ""), n.get("inputs", {})
|
||||
if "KSampler" in ct:
|
||||
p.update(steps=ins.get("steps"), cfg=ins.get("cfg"),
|
||||
sampler=ins.get("sampler_name"), scheduler=ins.get("scheduler"),
|
||||
seed=ins.get("seed", ins.get("noise_seed")))
|
||||
elif ct == "EmptySD3LatentImage":
|
||||
p.update(width=ins.get("width"), height=ins.get("height"))
|
||||
return {k: v for k, v in p.items() if v is not None}
|
||||
|
||||
|
||||
def _in_render_jobs(org: str, pid: str) -> bool:
|
||||
"""True if a prompt_id was dispatched to this org's BYO-GPU lane (gpu-jobs) —
|
||||
gpu_dispatch._track_dispatched writes it to render_jobs.json during the self-POST."""
|
||||
return _render_job(org, pid) is not None
|
||||
|
||||
|
||||
def _render_job(org: str, pid: str) -> dict | None:
|
||||
try:
|
||||
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
|
||||
for j in json.loads(jf.read_text()):
|
||||
if j.get("id") == pid:
|
||||
return j
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _render_job_node(org: str, pid: str) -> str:
|
||||
"""The node label the gpu-jobs lane recorded for this dispatch (spark/dbc/…)."""
|
||||
j = _render_job(org, pid)
|
||||
return (j.get("node") if j else None) or "gpu"
|
||||
|
||||
|
||||
def _output_mtime(root: Path | None, rel: str) -> int | None:
|
||||
try:
|
||||
return int((root / rel).stat().st_mtime) if root and rel else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _remove_render_job(org: str, pid: str) -> bool:
|
||||
"""Drop a BYO-GPU tracking row from this org's render_jobs.json. The file is
|
||||
per-org so a match is inherently owned. Returns True if one was removed."""
|
||||
try:
|
||||
jf = Path(folder_paths.get_org_output_directory(org)) / "render_jobs.json"
|
||||
jobs = json.loads(jf.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
keep = [j for j in jobs if j.get("id") != pid]
|
||||
if len(keep) == len(jobs):
|
||||
return False
|
||||
tmp = jf.with_name(jf.name + ".tmp")
|
||||
tmp.write_text(json.dumps(keep))
|
||||
tmp.replace(jf)
|
||||
return True
|
||||
|
||||
|
||||
def _owns_job(server, org: str, pid: str) -> bool:
|
||||
"""Does this prompt_id belong to the caller's org? True if it's in the org's
|
||||
work log, or a local-queue item stamped with this org, or the org's BYO-GPU
|
||||
tracking file. A cross-tenant id matches none of these → the route 404s and
|
||||
never reveals that it exists for someone else."""
|
||||
if any(r.get("id") == pid for r in worklog.load(org)):
|
||||
return True
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
if any(it[1] == pid and (it[3] or {}).get("org_id") == org
|
||||
for it in list(running) + list(pending)):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return _in_render_jobs(org, pid)
|
||||
|
||||
|
||||
def _cancel_job(server, org: str, pid: str) -> str:
|
||||
"""Cancel an OWNED job across both realms (caller must have checked ownership).
|
||||
Local pod queue: delete a pending item, or interrupt a running one — both
|
||||
org-scoped so one tenant can't touch another's render. BYO-GPU: drop the
|
||||
tracking row (a queued gpu-job stops showing; one already rendering on the
|
||||
worker may still finish — the pod can't stop a remote process). Marks the work
|
||||
log cancelled. Returns the realm acted on."""
|
||||
realm = "gone"
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
except Exception:
|
||||
running, pending = [], []
|
||||
if any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in pending):
|
||||
server.prompt_queue.delete_queue_item(
|
||||
lambda it: it[1] == pid and (it[3] or {}).get("org_id") == org)
|
||||
realm = "local"
|
||||
elif any(it[1] == pid and (it[3] or {}).get("org_id") == org for it in running):
|
||||
try:
|
||||
import nodes
|
||||
nodes.interrupt_processing()
|
||||
realm = "local"
|
||||
except Exception:
|
||||
pass
|
||||
if _remove_render_job(org, pid) and realm == "gone":
|
||||
realm = "gpu"
|
||||
worklog.set_status(org, pid, "cancelled")
|
||||
return realm
|
||||
|
||||
|
||||
def _landed_output(root: Path | None, prefix: str) -> str | None:
|
||||
"""Library-relative path of the newest file a SaveImage prefix produced, or None
|
||||
if it hasn't landed yet. Lets the work-log report done + wire lineage edges."""
|
||||
if root is None or not prefix:
|
||||
return None
|
||||
try:
|
||||
parent = root / os.path.dirname(prefix)
|
||||
stem = os.path.basename(prefix)
|
||||
# Match SaveImage's exact "<prefix>_<counter>_.png" — NOT a loose "<prefix>*"
|
||||
# which also matches a CHILD prefix ("a" would swallow "a_00001__00001_"),
|
||||
# collapsing distinct lineage steps onto one output.
|
||||
pat = re.compile(r"^" + re.escape(stem) + r"_\d+_\.png$")
|
||||
hits = [p for p in parent.glob(stem + "_*_.png") if pat.match(p.name)]
|
||||
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if hits:
|
||||
return str(hits[0].relative_to(root))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt: str,
|
||||
refs: list, uploads: list, parents: list, output_prefix: str) -> str:
|
||||
"""The ONE place a render is queued AND recorded. On success writes a `queued`
|
||||
work-log row (lane detected); on failure writes a `failed` row with the reason —
|
||||
so a dispatch is NEVER invisible, whatever lane it took or however it failed."""
|
||||
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
|
||||
parents=parents, output_prefix=output_prefix, params=_graph_params(graph))
|
||||
try:
|
||||
pid = await _queue_prompt(request, server, graph)
|
||||
except DispatchError as e:
|
||||
worklog.record(org, status="failed", error=str(e), **common)
|
||||
raise
|
||||
lane = "gpu" if _in_render_jobs(org, pid) else "local"
|
||||
node = _render_job_node(org, pid) if lane == "gpu" else "studio pod"
|
||||
worklog.record(org, pid=pid, status="queued", lane=lane, node=node, **common)
|
||||
return pid
|
||||
|
||||
|
||||
async def dispatch_fix(request: web.Request, server, org: str, rel: str,
|
||||
instruction: str, upload: str | None = None) -> dict:
|
||||
"""Run the proven Qwen single-image edit. The base image is EITHER a library
|
||||
asset (``rel``, staged into the org input dir) OR an image the caller just
|
||||
uploaded via ``/upload/image`` (``upload``, already resident there) — so a fresh
|
||||
photo can be edited without first landing it in the library. The ONE fix path:
|
||||
the /v1/library/fix route and the chat `create` capability both call this."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
if upload:
|
||||
names = _resolve_uploads(org, [upload])
|
||||
if not names:
|
||||
raise DispatchError(f"unknown upload: {upload}")
|
||||
ref_name = names[0]
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(ref_name).stem)[:48]
|
||||
else:
|
||||
root = _library_root(org)
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / ref_name).write_bytes(p.read_bytes())
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
|
||||
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}"),
|
||||
kind="fix", prompt=instruction, refs=[ref_name],
|
||||
uploads=[upload] if upload else [], parents=[] if upload else [rel],
|
||||
output_prefix=f"fixes/{stem}")
|
||||
return {"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"}
|
||||
|
||||
|
||||
async def dispatch_compose(request: web.Request, server, org: str, paths: list,
|
||||
instruction: str, uploads: list | None = None) -> dict:
|
||||
"""Stage 2-5 references and run the multi-input Qwen graph. References are
|
||||
library assets (``paths``, staged into the org input dir) and/or images the
|
||||
caller just uploaded via ``/upload/image`` (``uploads``, already resident in
|
||||
that dir). Shared by the /v1/library/compose route, the Fix surface's
|
||||
add-reference flow, and the chat `create` capability."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
paths = paths or []
|
||||
uploaded = _resolve_uploads(org, uploads)
|
||||
if not 2 <= len(paths) + len(uploaded) <= 5:
|
||||
raise DispatchError("select 2-5 references to compose")
|
||||
root = _library_root(org)
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref_names = []
|
||||
for rel in paths:
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
nm = f"compose_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / nm).write_bytes(p.read_bytes())
|
||||
ref_names.append(nm)
|
||||
ref_names.extend(uploaded)
|
||||
labels = [Path(x).stem for x in paths] + [Path(u).stem for u in uploaded]
|
||||
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2])
|
||||
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}"),
|
||||
kind="compose", prompt=instruction, refs=ref_names,
|
||||
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
|
||||
return {"ok": True, "prompt_id": pid, "output_prefix": f"composes/{stem}", "refs": len(ref_names)}
|
||||
|
||||
|
||||
class NotOwned(DispatchError):
|
||||
"""The prompt_id isn't the caller's — routes map this to 404 (never reveals it)."""
|
||||
|
||||
|
||||
async def dispatch_amend(request: web.Request, server, org: str, prompt_id: str,
|
||||
instruction: str, paths: list | None = None,
|
||||
uploads: list | None = None) -> dict:
|
||||
"""Amend a still-queued job as an honest ATOMIC SWAP: queued graphs are
|
||||
immutable, so cancel the original and re-dispatch with the amended prompt and an
|
||||
augmented reference set. The original's references (already staged in the org
|
||||
input dir, read from its work-log row — the authoritative ownership record) are
|
||||
kept and combined with any new library picks (``paths``) and uploads
|
||||
(``uploads``). The re-dispatch goes through the ONE funnel, so it lands in the
|
||||
right lane and records a fresh work-log row. Position resets (it re-enters the
|
||||
queue) — the caller SHOWS that honestly."""
|
||||
instruction = (instruction or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
raise DispatchError("instruction required (3-500 chars)")
|
||||
orig = next((r for r in worklog.load(org) if r.get("id") == prompt_id), None)
|
||||
if orig is None:
|
||||
raise NotOwned(f"no such job: {prompt_id}")
|
||||
if orig.get("status") in ("done", "failed", "cancelled"):
|
||||
raise DispatchError("this job already finished — start a new fix instead")
|
||||
root = _library_root(org)
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
# keep the original references that are still present (already input-dir names)
|
||||
ref_names = _resolve_uploads(org, orig.get("refs") or [])
|
||||
new_uploads = _resolve_uploads(org, uploads)
|
||||
for rel in (paths or []):
|
||||
p = _safe_asset(root, rel) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
raise DispatchError(f"unknown asset: {rel}")
|
||||
nm = f"amend_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / nm).write_bytes(p.read_bytes())
|
||||
ref_names.append(nm)
|
||||
# de-dupe, keep order, cap at the graph's 5
|
||||
seen: set = set()
|
||||
refs = [n for n in ref_names + new_uploads if not (n in seen or seen.add(n))][:5]
|
||||
if not refs:
|
||||
raise DispatchError("no reference images available to amend")
|
||||
_cancel_job(server, org, prompt_id)
|
||||
parents = list(orig.get("parents") or []) + list(paths or [])
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + "_amended"
|
||||
if len(refs) == 1:
|
||||
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}"), "fix", f"fixes/{stem}"
|
||||
else:
|
||||
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}"), "compose", f"composes/{stem}"
|
||||
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
|
||||
pid = await _dispatch(request, server, org, graph, kind=kind, prompt=instruction,
|
||||
refs=refs, uploads=all_uploads, parents=parents, output_prefix=out)
|
||||
return {"ok": True, "prompt_id": pid, "amended_from": prompt_id,
|
||||
"output_prefix": out, "refs": len(refs)}
|
||||
|
||||
|
||||
def _workflows(org: str) -> list[str]:
|
||||
@@ -223,7 +593,13 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
|
||||
@routes.get("/studio")
|
||||
async def studio_home(request: web.Request):
|
||||
return web.Response(text=_HTML.read_text(), content_type="text/html")
|
||||
# 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",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
|
||||
@routes.get("/v1/library")
|
||||
async def get_library(request: web.Request):
|
||||
@@ -246,6 +622,103 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
assets.append(row)
|
||||
return web.json_response({"org": org, "assets": assets, "workflows": _workflows(org)})
|
||||
|
||||
@routes.get("/v1/library/file")
|
||||
async def get_asset_file(request: web.Request):
|
||||
# Org-scoped asset serving. The Studio engine's core /view serves the
|
||||
# GLOBAL output dir, so org assets (orgs/<org>/output/…) 404 there → blank
|
||||
# thumbnails. This resolves the caller's org and serves from its library root.
|
||||
# ?thumb=1 → a cached, downsized JPEG (grid loads full-res 1-2MB PNGs one by
|
||||
# one otherwise, which makes the whole page crawl). Full-res on click/zoom.
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
# ?input=1 → a staged reference in orgs/<org>/input (fix/compose sources),
|
||||
# so YouTube-style queue rows can show the referenced work as a thumbnail.
|
||||
if request.query.get("input"):
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
name = os.path.basename(request.query.get("path", ""))
|
||||
ip = in_dir / name
|
||||
if not name or not ip.is_file() or not str(ip.resolve()).startswith(str(in_dir.resolve())):
|
||||
return web.Response(status=404)
|
||||
p = ip
|
||||
else:
|
||||
p = _safe_asset(root, request.query.get("path", "")) if root else None
|
||||
if p is None:
|
||||
return web.Response(status=404)
|
||||
if request.query.get("thumb") and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
||||
try:
|
||||
tdir = root / ".thumbs"
|
||||
tdir.mkdir(exist_ok=True)
|
||||
key = re.sub(r"[^a-zA-Z0-9]", "_", str(p))[-120:]
|
||||
tp = tdir / f"{key}.jpg"
|
||||
if not tp.is_file() or tp.stat().st_mtime < p.stat().st_mtime:
|
||||
from PIL import Image
|
||||
im = Image.open(p)
|
||||
im.thumbnail((480, 720)) # grid-sized; keeps aspect
|
||||
im.convert("RGB").save(tp, "JPEG", quality=82)
|
||||
return web.FileResponse(tp, headers={"Cache-Control": "public, max-age=86400"})
|
||||
except Exception:
|
||||
pass # fall through to full-res on any thumbnail failure
|
||||
return web.FileResponse(p)
|
||||
|
||||
@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
|
||||
'connect a GPU' even with a GPU connected because it checked /v1/engines
|
||||
(the worker-federation) — but `hanzo gpu connect` registers with the FLEET
|
||||
(gpu-jobs), which is exactly what dispatch uses. Report the SAME source of
|
||||
truth as dispatch (_has_online_gpu) so the UI matches what actually works."""
|
||||
try:
|
||||
from middleware.gpu_dispatch import _bearer, _has_online_gpu
|
||||
except ImportError:
|
||||
from .middleware.gpu_dispatch import _bearer, _has_online_gpu
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
online = bool(tok and _has_online_gpu(tok))
|
||||
except Exception:
|
||||
online = False
|
||||
return web.json_response({"online": online})
|
||||
|
||||
@routes.get("/v1/render-queue")
|
||||
async def render_queue(request: web.Request):
|
||||
"""The org's IN-FLIGHT BYO-GPU renders (fix/compose/rerun dispatched to a
|
||||
connected GPU). These run on the worker's gpu-jobs queue, NOT the pod's
|
||||
local /queue, so the UI could not see them — this is what makes a queued
|
||||
fix visible. Drops entries whose output has landed in the library or that
|
||||
have aged out (~25 min). Cheap: reads one small per-org JSON."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
if root is None:
|
||||
return web.json_response({"jobs": []})
|
||||
jf = root / "render_jobs.json"
|
||||
try:
|
||||
jobs = json.loads(jf.read_text())
|
||||
except Exception:
|
||||
jobs = []
|
||||
now = int(time.time())
|
||||
live = []
|
||||
for j in jobs:
|
||||
age = now - int(j.get("ts", 0))
|
||||
if age > 1500: # aged out (worker deadline is 1200s)
|
||||
continue
|
||||
# Completed only when the ACTUAL output for THIS job exists: the render
|
||||
# saves to "<outPrefix>_NNNNN_.png". Check precisely (glob the exact
|
||||
# output prefix) — not a fuzzy base match, which collided with the source
|
||||
# image and made the job disappear from the queue after 30s.
|
||||
out_prefix = j.get("outPrefix") or j.get("prefix") or ""
|
||||
landed = False
|
||||
if out_prefix:
|
||||
try:
|
||||
parent = root / os.path.dirname(out_prefix)
|
||||
stem = os.path.basename(out_prefix)
|
||||
landed = any(parent.glob(stem + "*.png"))
|
||||
except Exception:
|
||||
landed = False
|
||||
if landed and age > 20:
|
||||
continue
|
||||
j["age"] = age
|
||||
live.append(j)
|
||||
return web.json_response({"jobs": live})
|
||||
|
||||
@routes.post("/v1/library/status")
|
||||
async def set_status(request: web.Request):
|
||||
body = await request.json()
|
||||
@@ -280,27 +753,214 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
graph = None
|
||||
if not isinstance(graph, dict):
|
||||
return web.json_response({"error": "no embedded workflow in this image"}, status=422)
|
||||
rel = body.get("path", "")
|
||||
out_prefix = ""
|
||||
instr = ""
|
||||
for n in graph.values():
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
if n.get("class_type") == "SaveImage":
|
||||
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
|
||||
elif "TextEncode" in n.get("class_type", "") and not instr:
|
||||
t = n.get("inputs", {}).get("prompt") or n.get("inputs", {}).get("text") or ""
|
||||
if t and t != _NEG:
|
||||
instr = t
|
||||
ids = []
|
||||
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
|
||||
ids.append(await _queue_prompt(request, server, _reseed(json.loads(json.dumps(graph)))))
|
||||
try:
|
||||
for _ in range(max(1, min(int(body.get("count", 1)), 4))):
|
||||
ids.append(await _dispatch(
|
||||
request, server, org, _reseed(json.loads(json.dumps(graph))),
|
||||
kind="rerun", prompt=instr, refs=[], uploads=[], parents=[rel],
|
||||
output_prefix=out_prefix))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"ok": True, "prompt_ids": ids})
|
||||
|
||||
@routes.post("/v1/queue/cancel")
|
||||
async def cancel_job(request: web.Request):
|
||||
"""Cancel one of the caller's OWN queued/running jobs (local pod queue or
|
||||
BYO-GPU lane). Ownership is resolved server-side; a prompt_id that isn't the
|
||||
caller's 404s — never confirming another tenant's job exists."""
|
||||
org = _org_of(request)
|
||||
pid = (await request.json()).get("prompt_id", "")
|
||||
if not pid:
|
||||
return web.json_response({"error": "prompt_id required"}, status=400)
|
||||
if not _owns_job(server, org, pid):
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response({"ok": True, "canceled": _cancel_job(server, org, pid)})
|
||||
|
||||
@routes.post("/v1/queue/amend")
|
||||
async def amend_job(request: web.Request):
|
||||
"""Amend a still-queued job (atomic cancel + re-dispatch with more context)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(await dispatch_amend(
|
||||
request, server, _org_of(request), body.get("prompt_id", ""),
|
||||
body.get("instruction", ""), body.get("paths"), body.get("uploads")))
|
||||
except NotOwned as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
@routes.get("/v1/queue/status")
|
||||
async def queue_status(request: web.Request):
|
||||
"""This org's LIVE queue with where + when: per-lane position N of M, the
|
||||
node each job runs on, elapsed for the running item, and an ETA computed
|
||||
server-side from the org's OWN measured render medians. Tenant-scoped — only
|
||||
the caller's active jobs and their positions. Cheap: reads the org work log,
|
||||
stamps started/finished as it observes them, no fleet call."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
now = int(time.time())
|
||||
rows = worklog.load(org)
|
||||
# finalize any job whose output has landed (real finish time = file mtime)
|
||||
for r in rows:
|
||||
if r.get("status") in ("done", "failed", "cancelled"):
|
||||
continue
|
||||
landed = _landed_output(root, r.get("output_prefix"))
|
||||
if landed:
|
||||
mt = _output_mtime(root, landed) or now
|
||||
worklog.mark_finished(org, r["id"], mt)
|
||||
r["status"], r["finished_at"] = "done", mt # mirror in-memory
|
||||
active = [r for r in rows if r.get("status") not in ("done", "failed", "cancelled")
|
||||
and now - int(r.get("ts", now)) <= 1800]
|
||||
# the oldest job in each lane is the one rendering — stamp its start once
|
||||
by_lane: dict[str, list[dict]] = {}
|
||||
for r in active:
|
||||
by_lane.setdefault(r.get("lane") or "local", []).append(r)
|
||||
for jobs in by_lane.values():
|
||||
head = min(jobs, key=lambda j: j.get("ts", 0))
|
||||
if not head.get("started_at"):
|
||||
worklog.mark_started(org, head["id"], now)
|
||||
head["started_at"] = now
|
||||
head["status"] = "running"
|
||||
medians = worklog.median_durations(rows)
|
||||
est, counts = worklog.estimate_lanes(active, medians, now)
|
||||
items = {}
|
||||
for r in active:
|
||||
items[r["id"]] = {**est.get(r["id"], {}), "id": r["id"], "kind": r.get("kind", ""),
|
||||
"prompt": r.get("prompt", ""), "status": r.get("status"),
|
||||
"node": r.get("node")}
|
||||
return web.json_response({"items": items, "medians": medians, "lanes": counts, "now": now})
|
||||
|
||||
@routes.get("/v1/nodes")
|
||||
async def get_nodes(request: web.Request):
|
||||
"""The caller-org's GPU nodes (online/offline) for the queue header badges,
|
||||
plus this studio pod. The fleet is token-scoped, so only the caller's org's
|
||||
nodes are ever returned."""
|
||||
try:
|
||||
from middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
|
||||
except ImportError:
|
||||
from .middleware.gpu_dispatch import _bearer, _fleet_machines, _node_label
|
||||
nodes = []
|
||||
try:
|
||||
tok = _bearer(request)
|
||||
for m in (_fleet_machines(tok) if tok else []):
|
||||
if not m.get("gpu"):
|
||||
continue
|
||||
nodes.append({"name": _node_label(m), "online": m.get("status") == "online"})
|
||||
except Exception:
|
||||
nodes = []
|
||||
return web.json_response({"nodes": nodes, "pod": {"name": "studio pod", "online": True}})
|
||||
|
||||
@routes.get("/v1/worklog")
|
||||
async def get_worklog(request: web.Request):
|
||||
"""This org's dispatched renders (fix/compose/rerun) with REAL status — the
|
||||
queue/history backbone. Reverse-chronological; ?q= filters over prompt+kind.
|
||||
Server-resolved org: a caller only ever sees its OWN log file."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
rows = worklog.load(org)
|
||||
out = []
|
||||
for r in reversed(rows):
|
||||
row = dict(r)
|
||||
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:
|
||||
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")
|
||||
async def get_lineage(request: web.Request):
|
||||
"""The version chain for one library asset — ancestors (what it was derived
|
||||
from, with the prompt at each step) and descendants (later fixes of it).
|
||||
Pure over THIS org's rows; cross-tenant lineage is impossible by construction."""
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
target = request.query.get("path", "")
|
||||
rows = worklog.load(org)
|
||||
for r in rows:
|
||||
r["output"] = _landed_output(root, r.get("output_prefix"))
|
||||
return web.json_response(worklog.build_lineage(rows, target))
|
||||
|
||||
@routes.post("/v1/library/fix")
|
||||
async def fix(request: web.Request):
|
||||
body = await request.json()
|
||||
instruction = (body.get("instruction") or "").strip()
|
||||
if not 3 <= len(instruction) <= 500:
|
||||
return web.json_response({"error": "instruction required (3-500 chars)"}, status=400)
|
||||
org = _org_of(request)
|
||||
root = _library_root(org)
|
||||
p = _safe_asset(root, body.get("path", "")) if root else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
return web.json_response({"error": "unknown asset"}, status=404)
|
||||
# LoadImage reads from the input dir: stage the chosen output there.
|
||||
in_dir = Path(folder_paths.get_org_input_directory(org))
|
||||
in_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
|
||||
(in_dir / ref_name).write_bytes(p.read_bytes())
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
|
||||
pid = await _queue_prompt(request, server, _fix_graph(ref_name, instruction, f"fixes/{stem}"))
|
||||
return web.json_response({"ok": True, "prompt_id": pid, "output_prefix": f"fixes/{stem}"})
|
||||
try:
|
||||
return web.json_response(await dispatch_fix(
|
||||
request, server, _org_of(request), body.get("path", ""),
|
||||
body.get("instruction", ""), body.get("upload")))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
@routes.post("/v1/library/compose")
|
||||
async def compose(request: web.Request):
|
||||
"""Select N references (library assets and/or freshly uploaded images) + say
|
||||
HOW to combine them → a new generated output. The multi-input case of the
|
||||
proven Qwen reference-conditioned graph."""
|
||||
body = await request.json()
|
||||
try:
|
||||
return web.json_response(await dispatch_compose(
|
||||
request, server, _org_of(request), body.get("paths") or [],
|
||||
body.get("instruction", ""), body.get("uploads")))
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
# ── Ratings + notes: the curation feedback loop (HIP-0506 §5) ──────────────
|
||||
# Stored per-org in orgs/<org>/output/ratings.json keyed by asset path OR run
|
||||
# prompt_id. 0-5 stars + free notes; the copilot reads these before assembling
|
||||
# a workflow so generations get closer to intent each pass.
|
||||
@routes.get("/v1/library/ratings")
|
||||
async def get_ratings(request: web.Request):
|
||||
root = _library_root(_org_of(request))
|
||||
if root is None:
|
||||
return web.json_response({})
|
||||
try:
|
||||
return web.json_response(json.loads((root / "ratings.json").read_text()))
|
||||
except Exception:
|
||||
return web.json_response({})
|
||||
|
||||
@routes.post("/v1/library/rate")
|
||||
async def rate(request: web.Request):
|
||||
body = await request.json()
|
||||
key = (body.get("key") or "").strip() # asset path or run prompt_id
|
||||
if not key:
|
||||
return web.json_response({"error": "key required"}, status=400)
|
||||
stars = body.get("stars")
|
||||
if stars is not None and not (isinstance(stars, int) and 0 <= stars <= 5):
|
||||
return web.json_response({"error": "stars must be 0-5"}, status=400)
|
||||
root = _library_root(_org_of(request))
|
||||
if root is None:
|
||||
return web.json_response({"error": "no library for org"}, status=404)
|
||||
rf = root / "ratings.json"
|
||||
try:
|
||||
data = json.loads(rf.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
entry = data.get(key, {})
|
||||
if stars is not None:
|
||||
entry["stars"] = stars
|
||||
if "notes" in body:
|
||||
entry["notes"] = str(body.get("notes") or "")[:2000]
|
||||
if body.get("favorite") is not None:
|
||||
entry["favorite"] = bool(body.get("favorite"))
|
||||
entry["updatedAt"] = int(time.time())
|
||||
data[key] = entry
|
||||
tmp = root / "ratings.json.tmp"
|
||||
tmp.write_text(json.dumps(data, indent=1))
|
||||
tmp.replace(rf)
|
||||
return web.json_response({"ok": True, "key": key, "entry": entry})
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Per-org work log — the ONE record of every render this org dispatched.
|
||||
|
||||
The single dispatch funnel (``studio_home.dispatch_fix`` / ``dispatch_compose`` /
|
||||
``rerun``) writes one row here at dispatch time, so the studio surface can show:
|
||||
|
||||
* the queue/history with REAL status — queued · running · done · failed ·
|
||||
cancelled — including gpu-lane jobs the local /queue can't see (that was the
|
||||
'nothing showed as queued' report) and dispatches that failed before queuing;
|
||||
* the full context that produced each item — prompt, reference images/uploads,
|
||||
the workflow family (fix / compose / …), params, and the process trail;
|
||||
* parent → child lineage: each derived asset records the library asset(s) it was
|
||||
derived from, so any item's versions (ancestors + later fixes) form a chain.
|
||||
|
||||
Storage is one JSON array per org at ``orgs/<org>/output/worklog.json`` — the same
|
||||
per-org metadata pattern as ``library.json`` / ``render_jobs.json`` / ``ratings.json``
|
||||
(atomic tmp+replace). No new database. No backfill: history starts when this ships;
|
||||
items generated earlier have no recorded row (and derived items no recorded parent).
|
||||
|
||||
Tenant isolation: every function takes an already-resolved ``org`` (the caller's
|
||||
IAM org, resolved server-side by ``studio_home._org_of``). A row lives only in its
|
||||
org's file, so one org can never read or mutate another's log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
|
||||
# Cap the file so a busy org can't grow it without bound; oldest rows drop off.
|
||||
_CAP = 1000
|
||||
_TERMINAL = ("done", "failed", "cancelled")
|
||||
|
||||
|
||||
def _dir(org: str) -> Path:
|
||||
return Path(folder_paths.get_org_output_directory(org))
|
||||
|
||||
|
||||
def _path(org: str) -> Path:
|
||||
return _dir(org) / "worklog.json"
|
||||
|
||||
|
||||
def load(org: str) -> list[dict]:
|
||||
try:
|
||||
rows = json.loads(_path(org).read_text())
|
||||
return rows if isinstance(rows, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save(org: str, rows: list[dict]) -> None:
|
||||
d = _dir(org)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
p = _path(org)
|
||||
tmp = p.with_name(p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(rows[-_CAP:]))
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def record(org: str, *, kind: str, prompt: str, refs: list, uploads: list,
|
||||
parents: list, output_prefix: str, params: dict | None = None,
|
||||
pid: str | None = None, lane: str | None = None, node: str | None = None,
|
||||
status: str = "queued", error: str | None = None) -> dict:
|
||||
"""Append one dispatch row. ``pid`` is the /prompt id on success (used to match
|
||||
live queue/history); a failed dispatch has no id, so a synthetic one is minted
|
||||
and the row is marked failed with the reason so it STILL shows (never silent)."""
|
||||
now = int(time.time())
|
||||
row: dict = {
|
||||
"id": pid or f"failed-{uuid.uuid4().hex[:12]}",
|
||||
"ts": now,
|
||||
"kind": kind,
|
||||
"prompt": (prompt or "")[:800],
|
||||
"refs": [str(x) for x in (refs or [])][:8],
|
||||
"uploads": [str(x) for x in (uploads or [])][:8],
|
||||
"parents": [str(x) for x in (parents or [])][:8],
|
||||
"output_prefix": output_prefix or "",
|
||||
"params": params or {},
|
||||
"lane": lane or ("failed" if status == "failed" else "local"),
|
||||
"node": node or ("studio pod" if (lane in (None, "local")) else "gpu"),
|
||||
"status": status,
|
||||
"updatedAt": now,
|
||||
"events": [{"s": "queued", "ts": now}],
|
||||
}
|
||||
if error:
|
||||
row["error"] = str(error)[:400]
|
||||
row["events"].append({"s": "failed", "ts": now, "note": row["error"]})
|
||||
rows = load(org)
|
||||
rows.append(row)
|
||||
_save(org, rows)
|
||||
return row
|
||||
|
||||
|
||||
def set_status(org: str, pid: str, status: str, note: str | None = None) -> bool:
|
||||
"""Advance a row's status by prompt_id (running/done/failed/cancelled). Returns
|
||||
True if a row was updated. Idempotent-safe; terminal rows are left untouched."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
now = int(time.time())
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["status"] = status
|
||||
r["updatedAt"] = now
|
||||
r.setdefault("events", []).append(
|
||||
{"s": status, "ts": now, **({"note": note} if note else {})})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_started(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp when a job first begins rendering (first seen at the head of its lane).
|
||||
Idempotent: only the first observation sets started_at. Feeds duration = finished
|
||||
− started, the basis of the ETA median."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and not r.get("started_at") and r.get("status") not in _TERMINAL:
|
||||
r["started_at"] = int(at)
|
||||
r["status"] = "running"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "running", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def mark_finished(org: str, pid: str, at: int) -> bool:
|
||||
"""Stamp real completion (the output file's mtime) and close the row as done."""
|
||||
if not pid:
|
||||
return False
|
||||
rows = load(org)
|
||||
hit = False
|
||||
for r in rows:
|
||||
if r.get("id") == pid and r.get("status") not in _TERMINAL:
|
||||
r["finished_at"] = int(at)
|
||||
r["status"] = "done"
|
||||
r["updatedAt"] = int(time.time())
|
||||
r.setdefault("events", []).append({"s": "done", "ts": int(at)})
|
||||
hit = True
|
||||
if hit:
|
||||
_save(org, rows)
|
||||
return hit
|
||||
|
||||
|
||||
def _duration(r: dict) -> float | None:
|
||||
"""Measured render seconds for a completed row: finished − started when both
|
||||
exist (true render time), else finished − queued (turnaround, an upper bound)."""
|
||||
fin = r.get("finished_at")
|
||||
if fin is None:
|
||||
return None
|
||||
start = r.get("started_at")
|
||||
if start is None:
|
||||
start = r.get("ts")
|
||||
if start is None:
|
||||
return None
|
||||
return float(fin - start) if fin > start else None
|
||||
|
||||
|
||||
def median_durations(rows: list[dict]) -> dict:
|
||||
"""Rolling median render seconds per kind (fix/compose/rerun) over completed
|
||||
rows — the ETA basis. Pure. Only kinds with a measured sample appear."""
|
||||
buckets: dict[str, list[float]] = {}
|
||||
for r in rows:
|
||||
d = _duration(r)
|
||||
if d and d > 0:
|
||||
buckets.setdefault(r.get("kind", ""), []).append(d)
|
||||
out: dict[str, int] = {}
|
||||
for k, v in buckets.items():
|
||||
v.sort()
|
||||
n = len(v)
|
||||
out[k] = int(v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2)
|
||||
return out
|
||||
|
||||
|
||||
def estimate_lanes(active: list[dict], medians: dict, now: int,
|
||||
default_median: int = 180) -> tuple[dict, dict]:
|
||||
"""Pure position + ETA math, per lane. ``active`` items are
|
||||
``{id, kind, lane, ts, status, started_at?}``. Within a lane the running item
|
||||
(head) is position 1; each queued item's wait ≈ jobs-ahead × their medians +
|
||||
the running item's remaining time (median − elapsed). ETA to done = wait +
|
||||
its own median. Returns (per-id info, per-lane count). No client math over
|
||||
another tenant's data — the caller passes only THIS org's active jobs."""
|
||||
lanes: dict[str, list[dict]] = {}
|
||||
for j in active:
|
||||
lanes.setdefault(j.get("lane") or "local", []).append(j)
|
||||
info: dict[str, dict] = {}
|
||||
counts: dict[str, int] = {}
|
||||
for lane, jobs in lanes.items():
|
||||
jobs.sort(key=lambda j: (0 if j.get("status") == "running" else 1, j.get("ts", 0)))
|
||||
counts[lane] = len(jobs)
|
||||
cum = 0.0 # seconds until the START of the current item
|
||||
for idx, j in enumerate(jobs):
|
||||
m = medians.get(j.get("kind", ""), default_median)
|
||||
row = {"lane": lane, "position": idx + 1, "of": len(jobs),
|
||||
"node": j.get("node") or ("studio pod" if lane == "local" else "gpu")}
|
||||
if idx == 0 and j.get("status") == "running":
|
||||
started = j.get("started_at") or j.get("ts") or now
|
||||
elapsed = max(0, now - started)
|
||||
remaining = max(0.0, m - elapsed)
|
||||
row.update(elapsed_seconds=int(elapsed), wait_seconds=0,
|
||||
remaining_seconds=int(remaining), eta_seconds=int(remaining))
|
||||
cum = remaining
|
||||
else:
|
||||
row.update(elapsed_seconds=0, wait_seconds=int(cum),
|
||||
remaining_seconds=None, eta_seconds=int(cum + m))
|
||||
cum += m
|
||||
info[j["id"]] = row
|
||||
return info, counts
|
||||
|
||||
|
||||
def build_lineage(rows: list[dict], target: str) -> dict:
|
||||
"""Pure: given work-log rows (each with ``parents`` = source library paths and
|
||||
``output`` = the landed library path, resolved by the caller) and a target
|
||||
library asset path, return its version chain:
|
||||
|
||||
{"ancestors": [older … immediate parent], "node": <target>,
|
||||
"descendants": [later derivations, chronological]}
|
||||
|
||||
Each entry is ``{path, prompt, kind, ts}``. Ancestors walk the first-parent edge
|
||||
up from the row that PRODUCED the target; descendants are rows that name the
|
||||
target among their parents (and, transitively, their outputs). Cycles and
|
||||
missing rows terminate the walk — never loops."""
|
||||
by_output: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
out = r.get("output")
|
||||
if out and out not in by_output:
|
||||
by_output[out] = r
|
||||
|
||||
def entry(path: str, r: dict | None) -> dict:
|
||||
return {
|
||||
"path": path,
|
||||
"prompt": (r or {}).get("prompt", ""),
|
||||
"kind": (r or {}).get("kind", ""),
|
||||
"ts": (r or {}).get("ts", 0),
|
||||
}
|
||||
|
||||
# Ancestors: from the row that produced `target`, follow its first parent up.
|
||||
ancestors: list[dict] = []
|
||||
seen = {target}
|
||||
cur = target
|
||||
while True:
|
||||
producer = by_output.get(cur)
|
||||
if not producer:
|
||||
break
|
||||
parents = producer.get("parents") or []
|
||||
if not parents:
|
||||
break
|
||||
parent = parents[0]
|
||||
if parent in seen:
|
||||
break
|
||||
seen.add(parent)
|
||||
ancestors.append(entry(parent, by_output.get(parent)))
|
||||
cur = parent
|
||||
ancestors.reverse() # oldest first
|
||||
|
||||
# Descendants: rows that derive FROM target, then rows deriving from those, …
|
||||
descendants: list[dict] = []
|
||||
frontier = [target]
|
||||
seen_out = {target}
|
||||
while frontier:
|
||||
nxt: list[str] = []
|
||||
kids = [r for r in rows if any(p in frontier for p in (r.get("parents") or []))]
|
||||
kids.sort(key=lambda r: r.get("ts", 0))
|
||||
for r in kids:
|
||||
out = r.get("output")
|
||||
key = out or r.get("id")
|
||||
if key in seen_out:
|
||||
continue
|
||||
seen_out.add(key)
|
||||
descendants.append(entry(out or "", r))
|
||||
if out:
|
||||
nxt.append(out)
|
||||
frontier = nxt
|
||||
|
||||
return {"ancestors": ancestors, "node": target, "descendants": descendants}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-studio"
|
||||
version = "0.15.2"
|
||||
version = "0.17.3"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""karma_manifest.py — regenerate the ONE canonical asset-library index.
|
||||
"""library_manifest.py — regenerate the ONE canonical asset-library index.
|
||||
|
||||
The Karma content pipeline has a single source of truth: a `library.json`
|
||||
manifest that sits at the root of the karma org's studio-output subtree and
|
||||
manifest that sits at the root of an org's studio-output subtree (any tenant; pass --root) and
|
||||
indexes every asset Antje has rendered. The storefront (karma.style) and the
|
||||
socials/blog queue both read THIS file — there is no second index.
|
||||
|
||||
@@ -159,7 +159,7 @@ def build(root: str) -> dict:
|
||||
return {
|
||||
"_meta": {
|
||||
"note": "Canonical Karma asset library. ONE index the storefront + "
|
||||
"socials queue read. Regenerated by karma_manifest.py; edit "
|
||||
"socials queue read. Regenerated by library_manifest.py; edit "
|
||||
"status/caption/tags with karma-queue.py (they are preserved).",
|
||||
"prefix": "orgs/karma/output",
|
||||
"generatedAt": iso(time.time()),
|
||||
@@ -183,7 +183,7 @@ def write_atomic(root: str, doc: dict) -> str:
|
||||
|
||||
def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None:
|
||||
if not os.path.isdir(root):
|
||||
msg = f"karma_manifest: root does not exist yet: {root}"
|
||||
msg = f"library_manifest: root does not exist yet: {root}"
|
||||
if strict:
|
||||
sys.exit(msg)
|
||||
if not quiet:
|
||||
@@ -193,7 +193,7 @@ def run_once(root: str, quiet: bool = False, strict: bool = True) -> dict | None
|
||||
path = write_atomic(root, doc)
|
||||
if not quiet:
|
||||
m = doc["_meta"]
|
||||
print(f"karma_manifest: {m['count']} assets -> {path} {m['byStatus']}")
|
||||
print(f"library_manifest: {m['count']} assets -> {path} {m['byStatus']}")
|
||||
return doc
|
||||
|
||||
|
||||
@@ -210,12 +210,12 @@ def main() -> None:
|
||||
run_once(args.root)
|
||||
return
|
||||
|
||||
print(f"karma_manifest: watching {args.root} every {args.interval}s", flush=True)
|
||||
print(f"library_manifest: watching {args.root} every {args.interval}s", flush=True)
|
||||
while True:
|
||||
try:
|
||||
run_once(args.root, quiet=True, strict=False)
|
||||
except Exception as e: # a bad cycle must never kill the sidecar
|
||||
print(f"karma_manifest: cycle error: {e}", file=sys.stderr, flush=True)
|
||||
print(f"library_manifest: cycle error: {e}", file=sys.stderr, flush=True)
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
@@ -64,6 +64,41 @@ def _remove_sensitive_from_queue(queue: list) -> list:
|
||||
return [item[:5] for item in queue]
|
||||
|
||||
|
||||
# ── Multi-tenant scoping for the inherited core endpoints (HIP-0506 security) ──
|
||||
# /history and /queue are process-global in upstream ComfyUI. In a multi-tenant
|
||||
# deployment that is cross-tenant disclosure (one org reads another's prompts,
|
||||
# graphs, seeds) and a cross-tenant DoS (one org's "clear queue" wipes another's).
|
||||
# Every item carries its owner in extra_data["org_id"] (bound at enqueue). These
|
||||
# filter each list/dict to the caller's org. `org is None` means auth is OFF
|
||||
# (single-tenant/local dev) → return everything unchanged (no behavior change).
|
||||
def _item_org(item) -> str | None:
|
||||
"""org_id from a queue-item tuple (index 3 = extra_data)."""
|
||||
try:
|
||||
return item[3].get("org_id") if len(item) > 3 and isinstance(item[3], dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _scope_queue_to_org(items: list, org: str | None) -> list:
|
||||
if not org:
|
||||
return items
|
||||
return [it for it in items if _item_org(it) == org]
|
||||
|
||||
|
||||
def _scope_history_to_org(hist: dict, org: str | None) -> dict:
|
||||
if not org:
|
||||
return hist
|
||||
out = {}
|
||||
for k, entry in (hist or {}).items():
|
||||
try:
|
||||
ed = entry.get("prompt", [None, None, None, {}])[3]
|
||||
if isinstance(ed, dict) and ed.get("org_id") == org:
|
||||
out[k] = entry
|
||||
except Exception:
|
||||
continue # malformed/legacy entry with no org → excluded (fail-closed)
|
||||
return out
|
||||
|
||||
|
||||
async def send_socket_catch_exception(function, message):
|
||||
try:
|
||||
await function(message)
|
||||
@@ -969,19 +1004,24 @@ class PromptServer():
|
||||
else:
|
||||
offset = -1
|
||||
|
||||
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
|
||||
hist = self.prompt_queue.get_history(max_items=max_items, offset=offset)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/history/{prompt_id}")
|
||||
async def get_history_prompt_id(request):
|
||||
prompt_id = request.match_info.get("prompt_id", None)
|
||||
return web.json_response(self.prompt_queue.get_history(prompt_id=prompt_id))
|
||||
hist = self.prompt_queue.get_history(prompt_id=prompt_id)
|
||||
hist = _scope_history_to_org(hist, self._caller_org(request))
|
||||
return web.json_response(hist)
|
||||
|
||||
@routes.get("/queue")
|
||||
async def get_queue(request):
|
||||
org = self._caller_org(request)
|
||||
queue_info = {}
|
||||
current_queue = self.prompt_queue.get_current_queue_volatile()
|
||||
queue_info['queue_running'] = _remove_sensitive_from_queue(current_queue[0])
|
||||
queue_info['queue_pending'] = _remove_sensitive_from_queue(current_queue[1])
|
||||
queue_info['queue_running'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[0]), org)
|
||||
queue_info['queue_pending'] = _scope_queue_to_org(_remove_sensitive_from_queue(current_queue[1]), org)
|
||||
return web.json_response(queue_info)
|
||||
|
||||
@routes.post("/prompt")
|
||||
@@ -1106,13 +1146,20 @@ class PromptServer():
|
||||
@routes.post("/queue")
|
||||
async def post_queue(request):
|
||||
json_data = await request.json()
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
if "clear" in json_data:
|
||||
if json_data["clear"]:
|
||||
self.prompt_queue.wipe_queue()
|
||||
if org is None:
|
||||
self.prompt_queue.wipe_queue()
|
||||
else:
|
||||
# SCOPED clear: only THIS org's pending items, never others'.
|
||||
self.prompt_queue.delete_queue_item(lambda a: _item_org(a) == org)
|
||||
if "delete" in json_data:
|
||||
to_delete = json_data['delete']
|
||||
for id_to_delete in to_delete:
|
||||
delete_func = lambda a: a[1] == id_to_delete
|
||||
# Only delete an item the caller owns (org must match when auth on).
|
||||
delete_func = (lambda a, i=id_to_delete: a[1] == i) if org is None \
|
||||
else (lambda a, i=id_to_delete: a[1] == i and _item_org(a) == org)
|
||||
self.prompt_queue.delete_queue_item(delete_func)
|
||||
|
||||
return web.Response(status=200)
|
||||
@@ -1124,16 +1171,19 @@ class PromptServer():
|
||||
except json.JSONDecodeError:
|
||||
json_data = {}
|
||||
|
||||
org = self._caller_org(request) # None = auth off → upstream behavior
|
||||
# Check if a specific prompt_id was provided for targeted interruption
|
||||
prompt_id = json_data.get('prompt_id')
|
||||
if prompt_id:
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
|
||||
# Check if the prompt_id matches any currently running prompt
|
||||
# Check if the prompt_id matches any currently running prompt — and
|
||||
# (multi-tenant) that it belongs to the caller's org, so one tenant
|
||||
# cannot interrupt another tenant's render.
|
||||
should_interrupt = False
|
||||
for item in currently_running:
|
||||
# item structure: (number, prompt_id, prompt, extra_data, outputs_to_execute)
|
||||
if item[1] == prompt_id:
|
||||
if item[1] == prompt_id and (org is None or _item_org(item) == org):
|
||||
logging.info(f"Interrupting prompt {prompt_id}")
|
||||
should_interrupt = True
|
||||
break
|
||||
@@ -1141,11 +1191,18 @@ class PromptServer():
|
||||
if should_interrupt:
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
logging.info(f"Prompt {prompt_id} is not currently running, skipping interrupt")
|
||||
else:
|
||||
# No prompt_id provided, do a global interrupt
|
||||
logging.info(f"Prompt {prompt_id} is not currently running / not owned by caller, skipping interrupt")
|
||||
elif org is None:
|
||||
# No prompt_id + auth off → upstream global interrupt.
|
||||
logging.info("Global interrupt (no prompt_id specified)")
|
||||
nodes.interrupt_processing()
|
||||
else:
|
||||
# Multi-tenant: a global interrupt would kill another tenant's render.
|
||||
# Only interrupt if the running job belongs to the caller's org.
|
||||
currently_running, _ = self.prompt_queue.get_current_queue()
|
||||
if any(_item_org(item) == org for item in currently_running):
|
||||
logging.info(f"Interrupting current render for org {org}")
|
||||
nodes.interrupt_processing()
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@@ -1322,6 +1379,16 @@ class PromptServer():
|
||||
return iam_user.get("org_id", "default")
|
||||
return os.environ.get("STUDIO_ORG_ID", "default")
|
||||
|
||||
def _caller_org(self, request) -> str | None:
|
||||
"""The verified caller org to SCOPE cross-tenant reads by — or None when
|
||||
multi-tenant auth is OFF (single-tenant/local dev) so /history + /queue
|
||||
behave exactly as upstream. Only returns a value when there is a real IAM
|
||||
user, so scoping never hides another tenant's data by accident."""
|
||||
iam_user = request.get("iam_user")
|
||||
if iam_user and iam_user.get("org_id"):
|
||||
return iam_user.get("org_id")
|
||||
return None
|
||||
|
||||
async def setup(self):
|
||||
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
||||
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
||||
@@ -1351,6 +1418,8 @@ class PromptServer():
|
||||
add_session_routes(self.routes, self)
|
||||
from middleware.studio_home import add_studio_home_routes
|
||||
add_studio_home_routes(self.routes, self)
|
||||
from middleware.mcp import add_mcp_routes
|
||||
add_mcp_routes(self.routes, self)
|
||||
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
||||
|
||||
# Prefix every route with /api for easier matching for delegation.
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# This file is automatically generated by the build process when version is
|
||||
# updated in pyproject.toml.
|
||||
__version__ = "0.14.1"
|
||||
__version__ = "0.17.3"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Studio watchdog: keep :8188 alive through the overnight karma render run.
|
||||
# Emits a line ONLY on a restart event (so it's a signal, not a firehose).
|
||||
# Detects: process gone, port unresponsive, or output-log silent >12min while a
|
||||
# render is supposedly in flight (the 6h-hang failure mode from the QC log).
|
||||
set -u
|
||||
STUDIO=/home/z/work/hanzo/studio
|
||||
LOG=$STUDIO/studio-local.log
|
||||
launch() {
|
||||
cd "$STUDIO"
|
||||
# Free the port first: a stale/duplicate main.py holding :8188 makes the new
|
||||
# one crash on bind (EADDRINUSE). Kill any existing listener, wait for release.
|
||||
pkill -f 'main.py --listen' 2>/dev/null || true
|
||||
for _ in $(seq 1 15); do
|
||||
ss -ltn 2>/dev/null | grep -q ':8188 ' || break
|
||||
sleep 1
|
||||
done
|
||||
HF_HOME=/home/z/.cache/huggingface \
|
||||
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
|
||||
STUDIO_PERSIST_QUEUE=1 \
|
||||
nohup .venv/bin/python main.py --listen 0.0.0.0 --port 8188 --normalvram \
|
||||
--disable-auto-launch --output-directory "$STUDIO/output" >> "$LOG" 2>&1 &
|
||||
sleep 1
|
||||
local pid
|
||||
pid=$(pgrep -f 'main.py --listen' | head -1)
|
||||
[ -n "$pid" ] && sudo -n sh -c "echo -700 > /proc/$pid/oom_score_adj" 2>/dev/null || true
|
||||
echo "$pid"
|
||||
}
|
||||
while true; do
|
||||
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
if [ "$code" != "200" ]; then
|
||||
# give it one grace re-check (could be mid-render, momentarily busy)
|
||||
sleep 8
|
||||
code=$(curl -s -m 5 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
if [ "$code" != "200" ]; then
|
||||
pid=$(launch)
|
||||
# wait for it to answer
|
||||
for i in $(seq 1 45); do
|
||||
c=$(curl -s -m 4 http://127.0.0.1:8188/system_stats -o /dev/null -w "%{http_code}" 2>/dev/null || echo 000)
|
||||
[ "$c" = "200" ] && break
|
||||
sleep 4
|
||||
done
|
||||
echo "STUDIO_RESTARTED pid=$pid health=$c"
|
||||
fi
|
||||
fi
|
||||
sleep 45
|
||||
done
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit tests for BYO-GPU dispatch node identity (middleware/gpu_dispatch.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import gpu_dispatch as g
|
||||
|
||||
|
||||
def test_node_label_prefers_human_name():
|
||||
assert g._node_label({"name": "spark", "hostname": "h", "gpu": True}) == "spark"
|
||||
assert g._node_label({"hostname": "dbc-01", "gpu": True}) == "dbc-01"
|
||||
assert g._node_label({"worker_id": "gpu-acme-123"}) == "gpu-acme-123"
|
||||
assert g._node_label({"gpu_model": "RTX 4090"}) == "RTX 4090"
|
||||
assert g._node_label({}) == "gpu" # never blank
|
||||
|
||||
|
||||
def test_online_gpu_nodes_filters_offline_and_non_gpu(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "online", "gpu": True},
|
||||
{"name": "idle", "status": "offline", "gpu": True},
|
||||
{"name": "cpu-box", "status": "online", "gpu": False},
|
||||
])
|
||||
assert [m["name"] for m in g._online_gpu_nodes("tok")] == ["spark"]
|
||||
assert g._has_online_gpu("tok") is True
|
||||
|
||||
|
||||
def test_no_online_gpu_when_all_offline(monkeypatch):
|
||||
monkeypatch.setattr(g, "_fleet_machines", lambda tok: [
|
||||
{"name": "spark", "status": "offline", "gpu": True},
|
||||
])
|
||||
assert g._online_gpu_nodes("tok") == []
|
||||
assert g._has_online_gpu("tok") is False
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for the /v1/mcp MCP-over-HTTP endpoint: initialize, tools/list, the
|
||||
tools/call dispatch, DispatchError -> isError result, and unknown-method error.
|
||||
|
||||
Mirrors session_test.py: a real aiohttp app with a middleware that injects the
|
||||
IAM user the auth layer would have set, so org resolution and the JSON-RPC
|
||||
envelopes are the real thing. The render dispatch (studio_home.dispatch_fix /
|
||||
dispatch_compose) is monkeypatched, so no GPU / self-POST is needed.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from middleware import mcp
|
||||
from middleware import studio_home
|
||||
|
||||
ORG_USER = {"sub": "hanzo/antje", "org_id": "hanzo", "orgs": ["hanzo"]}
|
||||
|
||||
|
||||
def _app(iam_user=ORG_USER):
|
||||
app = web.Application()
|
||||
|
||||
@web.middleware
|
||||
async def inject(request, handler):
|
||||
if iam_user is not None:
|
||||
request["iam_user"] = iam_user
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(inject)
|
||||
routes = web.RouteTableDef()
|
||||
mcp.add_mcp_routes(routes, None)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
|
||||
def _rpc(client, method, params=None, id_=1):
|
||||
payload = {"jsonrpc": "2.0", "id": id_, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return client.post("/v1/mcp", json=payload)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# --- initialize ----------------------------------------------------------
|
||||
|
||||
def test_initialize_reports_server_and_tools_capability():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "initialize")
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert body["jsonrpc"] == "2.0"
|
||||
assert body["id"] == 1
|
||||
assert body["result"]["serverInfo"]["name"] == "hanzo-studio"
|
||||
assert body["result"]["protocolVersion"]
|
||||
assert "tools" in body["result"]["capabilities"]
|
||||
|
||||
|
||||
# --- tools/list ----------------------------------------------------------
|
||||
|
||||
def test_tools_list_returns_the_three_render_tools():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/list")
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
tools = body["result"]["tools"]
|
||||
assert {t["name"] for t in tools} == {"fix", "compose", "ask"}
|
||||
for t in tools:
|
||||
assert t["inputSchema"]["type"] == "object"
|
||||
compose = next(t for t in tools if t["name"] == "compose")
|
||||
assert compose["inputSchema"]["properties"]["paths"]["minItems"] == 2
|
||||
assert compose["inputSchema"]["properties"]["paths"]["maxItems"] == 5
|
||||
|
||||
|
||||
# --- tools/call ----------------------------------------------------------
|
||||
|
||||
def test_tools_call_fix_dispatches(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_fix(request, server, org, path, instruction):
|
||||
seen.update(org=org, path=path, instruction=instruction)
|
||||
return {"ok": True, "prompt_id": "p1", "output_prefix": "fixes/x"}
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_fix", fake_fix)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "fix",
|
||||
"arguments": {"path": "a/b.png", "instruction": "make it red"},
|
||||
})
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert seen == {"org": "hanzo", "path": "a/b.png", "instruction": "make it red"}
|
||||
result = body["result"]
|
||||
assert result.get("isError") is not True
|
||||
payload = json.loads(result["content"][0]["text"])
|
||||
assert payload["prompt_id"] == "p1"
|
||||
assert payload["output_prefix"] == "fixes/x"
|
||||
|
||||
|
||||
def test_tools_call_compose_dispatches(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_compose(request, server, org, paths, instruction):
|
||||
seen.update(org=org, paths=paths, instruction=instruction)
|
||||
return {"ok": True, "prompt_id": "p2", "output_prefix": "composes/y", "refs": len(paths)}
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_compose", fake_compose)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "compose",
|
||||
"arguments": {"paths": ["a.png", "b.png"], "instruction": "merge them"},
|
||||
})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert seen["paths"] == ["a.png", "b.png"]
|
||||
payload = json.loads(body["result"]["content"][0]["text"])
|
||||
assert payload["prompt_id"] == "p2"
|
||||
assert payload["refs"] == 2
|
||||
|
||||
|
||||
def test_tools_call_ask_echoes_question():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "ask", "arguments": {"question": "which asset?"},
|
||||
})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
payload = json.loads(body["result"]["content"][0]["text"])
|
||||
assert payload["question"] == "which asset?"
|
||||
assert body["result"].get("isError") is not True
|
||||
|
||||
|
||||
def test_dispatch_error_becomes_iserror_result(monkeypatch):
|
||||
async def bad_fix(request, server, org, path, instruction):
|
||||
raise studio_home.DispatchError("unknown asset: nope.png")
|
||||
|
||||
monkeypatch.setattr(studio_home, "dispatch_fix", bad_fix)
|
||||
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {
|
||||
"name": "fix", "arguments": {"path": "nope.png", "instruction": "x"},
|
||||
})
|
||||
assert r.status == 200 # never a 500
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert "error" not in body # not a JSON-RPC error
|
||||
assert body["result"]["isError"] is True
|
||||
assert "unknown asset" in body["result"]["content"][0]["text"]
|
||||
|
||||
|
||||
def test_unknown_tool_is_iserror_result():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/call", {"name": "frobnicate", "arguments": {}})
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert body["result"]["isError"] is True
|
||||
|
||||
|
||||
# --- protocol edges ------------------------------------------------------
|
||||
|
||||
def test_unknown_method_is_jsonrpc_error():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await _rpc(c, "tools/frobnicate")
|
||||
assert r.status == 200
|
||||
return await r.json()
|
||||
body = _run(go())
|
||||
assert "result" not in body
|
||||
assert body["error"]["code"] == -32601
|
||||
assert body["id"] == 1
|
||||
|
||||
|
||||
def test_notification_gets_no_body():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await c.post("/v1/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
return r.status, await r.text()
|
||||
status, text = _run(go())
|
||||
assert status == 202
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_bad_json_is_parse_error():
|
||||
async def go():
|
||||
async with TestClient(TestServer(_app())) as c:
|
||||
r = await c.post("/v1/mcp", data="not json", headers={"Content-Type": "application/json"})
|
||||
return r.status, await r.json()
|
||||
status, body = _run(go())
|
||||
assert status == 400
|
||||
assert body["error"]["code"] == -32700
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for the per-org work log (middleware/worklog.py)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from middleware import worklog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgdirs(tmp_path, monkeypatch):
|
||||
"""Map each org to its own output dir, exactly as folder_paths does per-org, so
|
||||
the tests exercise real cross-tenant isolation (one file per org)."""
|
||||
def out_dir(org=None):
|
||||
d = tmp_path / "orgs" / (org or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(worklog.folder_paths, "get_org_output_directory", out_dir)
|
||||
return out_dir
|
||||
|
||||
|
||||
def test_record_and_load_roundtrip(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="brighten", refs=["r.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a",
|
||||
pid="pid-1", lane="gpu")
|
||||
assert row["id"] == "pid-1" and row["status"] == "queued" and row["lane"] == "gpu"
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 1 and rows[0]["parents"] == ["a.png"] and rows[0]["kind"] == "fix"
|
||||
|
||||
|
||||
def test_failed_dispatch_still_recorded_with_reason(orgdirs):
|
||||
row = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a",
|
||||
status="failed", error="no GPU worker online for this org")
|
||||
assert row["status"] == "failed" and row["id"].startswith("failed-")
|
||||
assert "no GPU worker" in row["error"]
|
||||
assert any(e["s"] == "failed" for e in row["events"]) # visible, not swallowed
|
||||
|
||||
|
||||
def test_set_status_advances_but_respects_terminal(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/a", pid="p1")
|
||||
assert worklog.set_status("acme", "p1", "running")
|
||||
assert worklog.load("acme")[0]["status"] == "running"
|
||||
assert worklog.set_status("acme", "p1", "cancelled")
|
||||
# a terminal row is frozen — a late 'done' can't resurrect a cancelled job
|
||||
assert not worklog.set_status("acme", "p1", "done")
|
||||
assert worklog.load("acme")[0]["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_cross_tenant_isolation(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="secret acme prompt", refs=[], uploads=[],
|
||||
parents=["a.png"], output_prefix="fixes/a", pid="p1")
|
||||
# a different org's log is a different file — zero leakage either direction
|
||||
assert worklog.load("acme") and worklog.load("globex") == []
|
||||
worklog.record("globex", kind="fix", prompt="globex prompt", refs=[], uploads=[],
|
||||
parents=["b.png"], output_prefix="fixes/b", pid="p2")
|
||||
assert [r["prompt"] for r in worklog.load("acme")] == ["secret acme prompt"]
|
||||
assert [r["prompt"] for r in worklog.load("globex")] == ["globex prompt"]
|
||||
|
||||
|
||||
def test_cap_keeps_newest(orgdirs, monkeypatch):
|
||||
monkeypatch.setattr(worklog, "_CAP", 5)
|
||||
for i in range(12):
|
||||
worklog.record("acme", kind="fix", prompt=f"p{i}", refs=[], uploads=[],
|
||||
parents=[], output_prefix=f"fixes/{i}", pid=f"p{i}")
|
||||
rows = worklog.load("acme")
|
||||
assert len(rows) == 5 and [r["prompt"] for r in rows] == [f"p{i}" for i in range(7, 12)]
|
||||
|
||||
|
||||
def test_build_lineage_chain():
|
||||
# a.png --fix--> b.png --fix--> c.png ; and a.png --fix--> d.png (a sibling branch)
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "step1", "ts": 1, "parents": ["a.png"], "output": "b.png"},
|
||||
{"kind": "fix", "prompt": "step2", "ts": 2, "parents": ["b.png"], "output": "c.png"},
|
||||
{"kind": "fix", "prompt": "branch", "ts": 3, "parents": ["a.png"], "output": "d.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "c.png")
|
||||
assert [a["path"] for a in lin["ancestors"]] == ["a.png", "b.png"] # oldest first
|
||||
assert lin["node"] == "c.png"
|
||||
assert lin["descendants"] == []
|
||||
# from the root, descendants include both branches, chronological
|
||||
lin2 = worklog.build_lineage(rows, "a.png")
|
||||
assert [d["path"] for d in lin2["descendants"]] == ["b.png", "d.png", "c.png"]
|
||||
assert lin2["ancestors"] == []
|
||||
|
||||
|
||||
def test_record_stores_node(orgdirs):
|
||||
r = worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="f", pid="p1", lane="gpu", node="spark")
|
||||
assert r["node"] == "spark"
|
||||
r2 = worklog.record("acme", kind="fix", prompt="y", refs=[], uploads=[], parents=[],
|
||||
output_prefix="g", pid="p2", lane="local")
|
||||
assert r2["node"] == "studio pod" # local jobs run on the pod
|
||||
|
||||
|
||||
def test_mark_started_finished_and_median(orgdirs):
|
||||
worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert worklog.mark_started("acme", "p1", 1000)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["started_at"] == 1000 and r["status"] == "running"
|
||||
assert not worklog.mark_started("acme", "p1", 2000) # idempotent: keeps first
|
||||
assert worklog.load("acme")[0]["started_at"] == 1000
|
||||
assert worklog.mark_finished("acme", "p1", 1050)
|
||||
r = worklog.load("acme")[0]
|
||||
assert r["finished_at"] == 1050 and r["status"] == "done"
|
||||
assert worklog.median_durations(worklog.load("acme"))["fix"] == 50 # 1050-1000
|
||||
|
||||
|
||||
def test_median_durations_per_kind():
|
||||
rows = [
|
||||
{"kind": "fix", "ts": 100, "started_at": 100, "finished_at": 110}, # 10
|
||||
{"kind": "fix", "ts": 200, "started_at": 200, "finished_at": 220}, # 20
|
||||
{"kind": "fix", "ts": 300, "started_at": 300, "finished_at": 360}, # 60 → median 20
|
||||
{"kind": "compose", "ts": 0, "started_at": 0, "finished_at": 100}, # 100
|
||||
{"kind": "fix", "ts": 400}, # unfinished → ignored
|
||||
]
|
||||
m = worklog.median_durations(rows)
|
||||
assert m == {"fix": 20, "compose": 100}
|
||||
# no started_at → duration falls back to finished − queued(ts)
|
||||
assert worklog.median_durations([{"kind": "rerun", "ts": 0, "finished_at": 30}]) == {"rerun": 30}
|
||||
|
||||
|
||||
def test_estimate_lanes_positions_and_eta():
|
||||
now = 1000
|
||||
active = [
|
||||
{"id": "a", "kind": "fix", "lane": "gpu", "ts": 100, "status": "running", "started_at": now - 60, "node": "spark"},
|
||||
{"id": "b", "kind": "fix", "lane": "gpu", "ts": 200, "status": "queued", "node": "spark"},
|
||||
{"id": "c", "kind": "compose", "lane": "gpu", "ts": 300, "status": "queued", "node": "spark"},
|
||||
{"id": "d", "kind": "fix", "lane": "local", "ts": 150, "status": "queued", "node": "studio pod"},
|
||||
]
|
||||
medians = {"fix": 120, "compose": 300}
|
||||
est, counts = worklog.estimate_lanes(active, medians, now)
|
||||
assert counts == {"gpu": 3, "local": 1}
|
||||
# running: elapsed 60 of median 120 → 60 remaining
|
||||
assert est["a"]["position"] == 1 and est["a"]["of"] == 3
|
||||
assert est["a"]["elapsed_seconds"] == 60 and est["a"]["remaining_seconds"] == 60 and est["a"]["eta_seconds"] == 60
|
||||
assert est["a"]["node"] == "spark"
|
||||
# queued #2: wait = running remaining(60); eta = 60 + own fix median(120)
|
||||
assert est["b"]["position"] == 2 and est["b"]["wait_seconds"] == 60 and est["b"]["eta_seconds"] == 180
|
||||
# queued #3: wait = 60 + b's fix(120) = 180; eta = 180 + compose(300)
|
||||
assert est["c"]["position"] == 3 and est["c"]["wait_seconds"] == 180 and est["c"]["eta_seconds"] == 480
|
||||
# separate lane, single queued item, nothing running → starts ~now
|
||||
assert est["d"]["position"] == 1 and est["d"]["of"] == 1 and est["d"]["wait_seconds"] == 0 and est["d"]["eta_seconds"] == 120
|
||||
|
||||
|
||||
def test_estimate_lanes_default_median_for_unknown_kind():
|
||||
est, _ = worklog.estimate_lanes(
|
||||
[{"id": "x", "kind": "video", "lane": "gpu", "ts": 1, "status": "queued"}], {}, 0, default_median=200)
|
||||
assert est["x"]["eta_seconds"] == 200
|
||||
|
||||
|
||||
def test_build_lineage_terminates_on_cycle():
|
||||
rows = [
|
||||
{"kind": "fix", "prompt": "x", "ts": 1, "parents": ["y.png"], "output": "x.png"},
|
||||
{"kind": "fix", "prompt": "y", "ts": 2, "parents": ["x.png"], "output": "y.png"},
|
||||
]
|
||||
lin = worklog.build_lineage(rows, "x.png") # must not loop forever
|
||||
assert lin["node"] == "x.png"
|
||||
@@ -44,7 +44,273 @@ def test_safe_asset_blocks_traversal(tmp_path):
|
||||
|
||||
|
||||
def test_status_vocab_matches_library_lifecycle():
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published")
|
||||
assert sh._STATUSES == ("draft", "approved", "queued", "published", "deleted")
|
||||
|
||||
|
||||
def test_resolve_uploads_keeps_only_safe_existing_images(tmp_path, monkeypatch):
|
||||
in_dir = tmp_path / "orgs" / "acme" / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "ref.png").write_bytes(b"\x89PNG")
|
||||
(in_dir / "shot.jpg").write_bytes(b"jpg")
|
||||
(tmp_path / "secret.txt").write_text("no")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda org=None: str(in_dir))
|
||||
out = sh._resolve_uploads("acme", [
|
||||
"ref.png", # kept
|
||||
"sub/dir/shot.jpg", # basename kept (upload names are flat)
|
||||
"ref.png", # de-duped
|
||||
"../secret.txt", # traversal + non-image -> dropped
|
||||
"missing.png", # absent -> dropped
|
||||
"notes.txt", # non-image -> dropped
|
||||
42, # non-string -> dropped
|
||||
])
|
||||
assert out == ["ref.png", "shot.jpg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_accepts_uploaded_references(tmp_path, monkeypatch):
|
||||
"""A base library asset + one uploaded image is a valid 2-reference compose:
|
||||
the upload is named directly (already in the input dir), the library asset is
|
||||
staged, and both reach the graph — no lower bound violation."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG-base")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "up_0001_.png").write_bytes(b"\x89PNG-up")
|
||||
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-1"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
|
||||
out = await sh.dispatch_compose(None, None, org, ["a.png"], "merge them", uploads=["up_0001_.png"])
|
||||
assert out["ok"] and out["refs"] == 2
|
||||
load_names = [n["inputs"]["image"] for n in captured["graph"].values()
|
||||
if n["class_type"] == "LoadImage"]
|
||||
assert "up_0001_.png" in load_names # upload referenced by its own name
|
||||
assert any(n.startswith("compose_src_") for n in load_names) # library asset staged
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def __init__(self, running=None, pending=None):
|
||||
self.running = running or []
|
||||
self.pending = pending or []
|
||||
self.deleted = []
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return (self.running, self.pending)
|
||||
|
||||
def delete_queue_item(self, fn):
|
||||
before = len(self.pending)
|
||||
self.pending = [it for it in self.pending if not fn(it)]
|
||||
self.deleted.append(before - len(self.pending))
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
port = 1
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.prompt_queue = _FakeQueue(**kw)
|
||||
|
||||
|
||||
def _orgaware(tmp_path, monkeypatch):
|
||||
def outd(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
def ind(o=None):
|
||||
d = tmp_path / "orgs" / (o or "default") / "input"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", outd)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", ind)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
return outd, ind
|
||||
|
||||
|
||||
def test_cancel_marks_cancelled_and_removes_gpu_row_org_scoped(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1", lane="gpu")
|
||||
(Path(outd("acme")) / "render_jobs.json").write_text(json.dumps([{"id": "p1", "prefix": "x"}]))
|
||||
srv = _FakeServer()
|
||||
assert sh._owns_job(srv, "acme", "p1")
|
||||
assert not sh._owns_job(srv, "globex", "p1") # cross-tenant: not owned
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "gpu"
|
||||
assert sh.worklog.load("acme")[0]["status"] == "cancelled"
|
||||
assert json.loads((Path(outd("acme")) / "render_jobs.json").read_text()) == []
|
||||
|
||||
|
||||
def test_cancel_deletes_local_pending_only_when_org_matches(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
mine = (1, "p1", {}, {"org_id": "acme"}, [])
|
||||
theirs = (2, "p2", {}, {"org_id": "globex"}, [])
|
||||
srv = _FakeServer(pending=[mine, theirs])
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
assert sh._cancel_job(srv, "acme", "p1") == "local"
|
||||
assert [it[1] for it in srv.prompt_queue.pending] == ["p2"] # only mine deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_atomic_swap_keeps_original_refs_and_adds_new(tmp_path, monkeypatch):
|
||||
outd, ind = _orgaware(tmp_path, monkeypatch)
|
||||
(Path(outd("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(ind("acme")) / "fix_src_1.png").write_bytes(b"\x89PNG") # original ref, still present
|
||||
(Path(ind("acme")) / "drop_9.png").write_bytes(b"\x89PNG") # new upload
|
||||
sh.worklog.record("acme", kind="fix", prompt="brighten", refs=["fix_src_1.png"],
|
||||
uploads=[], parents=["a.png"], output_prefix="fixes/a", pid="p1", lane="local")
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "p2"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "brighten a lot more",
|
||||
paths=[], uploads=["drop_9.png"])
|
||||
assert out["amended_from"] == "p1" and out["prompt_id"] == "p2" and out["refs"] == 2
|
||||
rows = {r["id"]: r for r in sh.worklog.load("acme")}
|
||||
assert rows["p1"]["status"] == "cancelled" # original swapped out
|
||||
assert rows["p2"]["status"] == "queued" and rows["p2"]["prompt"] == "brighten a lot more"
|
||||
loads = [n["inputs"]["image"] for n in captured["graph"].values() if n.get("class_type") == "LoadImage"]
|
||||
assert "fix_src_1.png" in loads and "drop_9.png" in loads # original kept + new added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_unknown_job_is_not_owned(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
with pytest.raises(sh.NotOwned):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "ghost", "change it")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_terminal_job_rejected(tmp_path, monkeypatch):
|
||||
outd, _ = _orgaware(tmp_path, monkeypatch)
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=["r.png"], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
sh.worklog.set_status("acme", "p1", "done")
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_amend(None, _FakeServer(), "acme", "p1", "too late")
|
||||
|
||||
|
||||
def test_prompt_error_message_extracts_real_reason():
|
||||
# model-less pod: /prompt returns a validation error — the user must SEE it.
|
||||
body = {"error": {"type": "prompt_outputs_failed_validation",
|
||||
"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name 'qwen-image-edit-2511.safetensors' not in []"}}
|
||||
msg = sh._prompt_error_message(body)
|
||||
assert "Prompt outputs failed validation" in msg and "not in []" in msg
|
||||
assert sh._prompt_error_message({}) and "silent" not in sh._prompt_error_message({}).lower()
|
||||
assert sh._prompt_error_message({"error": "no GPU worker"}) == "no GPU worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_only_fix_dispatches_single_image(tmp_path, monkeypatch):
|
||||
"""REGRESSION LOCK: click Fix, type text, no references → the single-image
|
||||
/v1/library/fix path (base library asset staged, proven Qwen edit graph)."""
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-fix"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "a.png", "make the straps thicker")
|
||||
assert out["ok"] and out["prompt_id"] == "pid-fix"
|
||||
g = captured["graph"]
|
||||
assert g["1"]["class_type"] == "LoadImage" and g["1"]["inputs"]["image"].startswith("fix_src_")
|
||||
assert g["15"]["class_type"] == "SaveImage" and "straps thicker" in g["6"]["inputs"]["prompt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_accepts_uploaded_base_image(tmp_path, monkeypatch):
|
||||
"""A freshly uploaded photo can be the fix base (upload=) without landing it in
|
||||
the library first — the '+ add a photo' single-ref path."""
|
||||
org = "acme"
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
(in_dir / "shot_1.png").write_bytes(b"\x89PNG-up")
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
captured = {}
|
||||
async def fake_queue(request, server, graph):
|
||||
captured["graph"] = graph
|
||||
return "pid-up"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
out = await sh.dispatch_fix(None, None, org, "", "brighten it", upload="shot_1.png")
|
||||
assert out["ok"] and captured["graph"]["1"]["inputs"]["image"] == "shot_1.png"
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_fix(None, None, org, "", "brighten it", upload="../etc/passwd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_prompt_surfaces_downstream_error(tmp_path, monkeypatch):
|
||||
"""A non-200 from the self-POST /prompt (model-less pod) becomes a DispatchError
|
||||
carrying the REAL reason — never a raw 502 the dialog swallows into 'Failed:'."""
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
class Srv:
|
||||
port = None
|
||||
srv = Srv()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
@routes.post("/prompt")
|
||||
async def prompt(request):
|
||||
return web.json_response({"error": {"message": "Prompt outputs failed validation",
|
||||
"details": "UNETLoader: unet_name '…' not in []"}}, status=400)
|
||||
|
||||
async def edit(request):
|
||||
graph = (await request.json())["graph"]
|
||||
try:
|
||||
pid = await sh._queue_prompt(request, srv, graph)
|
||||
return web.json_response({"prompt_id": pid})
|
||||
except sh.DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
routes.post("/edit")(edit)
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(routes)
|
||||
ts = TestServer(app)
|
||||
await ts.start_server()
|
||||
srv.port = ts.port
|
||||
client = TestClient(ts)
|
||||
await client.start_server()
|
||||
r = await client.post("/edit", json={"graph": {"1": {"class_type": "SaveImage", "inputs": {}}}})
|
||||
body = await r.json()
|
||||
assert r.status == 400 and "error" in body
|
||||
assert "failed validation" in body["error"] and "not in []" in body["error"] # real reason, shown
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compose_rejects_single_reference(tmp_path, monkeypatch):
|
||||
org = "acme"
|
||||
root = tmp_path / "orgs" / org / "output"
|
||||
root.mkdir(parents=True)
|
||||
(root / "library.json").write_text(json.dumps({"assets": [{"path": "a.png"}]}))
|
||||
(root / "a.png").write_bytes(b"\x89PNG")
|
||||
in_dir = tmp_path / "orgs" / org / "input"
|
||||
in_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_input_directory", lambda o=None: str(in_dir))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_org_output_directory", lambda o=None: str(root))
|
||||
monkeypatch.setattr(sh.folder_paths, "get_output_directory", lambda: str(tmp_path))
|
||||
with pytest.raises(sh.DispatchError):
|
||||
await sh.dispatch_compose(None, None, org, ["a.png"], "just one", uploads=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# Studio web — the Hanzo Studio product frontend
|
||||
|
||||
Our unique studio frontend: a specialized **chat app** on the shared Hanzo stack.
|
||||
You say what to create; the cloud `/v1/chat` orchestrator drives studio's render
|
||||
tools and the outputs land in the library. This is the default surface at
|
||||
`studio.hanzo.ai/studio`.
|
||||
|
||||
- **Stack**: Vite + React + TS. `@hanzo/*` packages are npm-published, so a clean
|
||||
`npm install` — no workspace linking.
|
||||
- **Chat**: `POST api.hanzo.ai/v1/chat {capability, messages}` → `{reply, actions, ops}`
|
||||
— the cloud `clients/chat` orchestrator, called **directly** (no studio proxy). The
|
||||
`hanzo_token` cookie is `.hanzo.ai`-scoped, so `credentials:'include'` carries it
|
||||
cross-origin and the handler bills the caller org. (Gateway CORS must allow the
|
||||
`studio.hanzo.ai` origin with credentials.)
|
||||
- **Engine**: the studio pod serves `/v1/session`, `/v1/library`, `/v1/render-queue`,
|
||||
`/v1/gpu-status` same-origin (same cookie) — no token handling in the browser.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # -> dist/ (served at /studio; base = /studio/)
|
||||
npm run dev # local, set VITE_STUDIO_API=https://studio.hanzo.ai
|
||||
```
|
||||
|
||||
The ComfyUI-derived node editor ("Advanced mode") is a **separate** repo —
|
||||
`hanzoai/studio-ui` (formerly `studio-frontend`) — not this app.
|
||||
|
||||
## Status / next
|
||||
- Increment 1 (done): chat wired to `/v1/chat` create + live library/queue/gpu strip.
|
||||
- Increment 2: swap the transcript/composer for `@hanzo/chat` `<Chat>` on `@hanzo/gui`
|
||||
(GuiProvider + SessionProvider, copying console2's `Provider.tsx` spine).
|
||||
- Wiring: Dockerfile multi-stage to build `dist/` and serve it at `/studio` in
|
||||
place of `middleware/studio_home.html`. (No proxy — chat hits api.hanzo.ai direct.)
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Hanzo Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1888
File diff suppressed because it is too large
Load Diff
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hanzo/studio-chat",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Studio as a specialized chat app — @hanzo/ai + @hanzo/gui talking to the cloud /v1/chat orchestrator, served at studio.hanzo.ai/studio.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
chat, getSession, getLibrary, getRenderQueue, gpuOnline, thumbURL,
|
||||
type ChatMessage, type Session, type Asset, type RenderJob,
|
||||
} 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
|
||||
// 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 [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([])
|
||||
const [gpu, setGpu] = useState(false)
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => { getSession().then(setSession) }, [])
|
||||
|
||||
// Poll the library + in-flight queue so dispatched renders appear as they land.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const tick = async () => {
|
||||
if (!alive) return
|
||||
const [lib, q, on] = await Promise.all([getLibrary(), getRenderQueue(), gpuOnline()])
|
||||
if (!alive) return
|
||||
setAssets(lib.assets.filter(a => a.status !== 'deleted').slice(0, 24))
|
||||
setJobs(q.jobs); setGpu(on)
|
||||
}
|
||||
tick()
|
||||
const t = setInterval(tick, 5000)
|
||||
return () => { alive = false; clearInterval(t) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const next = [...messages, { role: 'user' as const, content: text }]
|
||||
setMessages(next); setInput(''); setBusy(true)
|
||||
try {
|
||||
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')}`
|
||||
: ''
|
||||
setMessages([...next, { role: 'assistant', content: (r.reply || '…') + note }])
|
||||
} catch (e) {
|
||||
setMessages([...next, { role: 'assistant', content: `Couldn't reach the studio chat (${(e as Error).message}).` }])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="top">
|
||||
<div className="brand">Hanzo <b>Studio</b></div>
|
||||
<div className="spacer" />
|
||||
<div className={`gpu ${gpu ? 'on' : 'off'}`}>{gpu ? 'GPU online' : 'no GPU'}</div>
|
||||
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
|
||||
</header>
|
||||
|
||||
<main className="body">
|
||||
<section className="chat">
|
||||
<div className="scroll" ref={scroller}>
|
||||
{messages.length === 0 && (
|
||||
<div className="hero">
|
||||
<h1>What should we create?</h1>
|
||||
<p>Describe an edit or a new shot — “make the Valentina back crotchless”,
|
||||
“put design 13 on model 01”. I’ll run it on your GPU and it lands below.</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`msg ${m.role}`}><div className="bubble">{m.content}</div></div>
|
||||
))}
|
||||
{busy && <div className="msg assistant"><div className="bubble dim">working…</div></div>}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
value={input} placeholder="Describe what to create or change…"
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||
/>
|
||||
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="rail">
|
||||
{jobs.length > 0 && (
|
||||
<div className="queue">
|
||||
<div className="rail-h">Rendering ({jobs.length})</div>
|
||||
{jobs.map(jb => <div key={jb.id} className="job">{jb.prefix || jb.id.slice(0, 8)}</div>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rail-h">Library</div>
|
||||
<div className="grid">
|
||||
{assets.map(a => (
|
||||
<a key={a.path} className="cell" href={thumbURL(a.path)} target="_blank" rel="noreferrer">
|
||||
<img loading="lazy" src={thumbURL(a.path)} alt="" />
|
||||
</a>
|
||||
))}
|
||||
{assets.length === 0 && <div className="empty">Renders appear here.</div>}
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
// 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 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.
|
||||
|
||||
const BASE = (import.meta.env.VITE_STUDIO_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) throw new Error(`${url} -> ${r.status}`)
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
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[] }
|
||||
|
||||
// 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({ capability, messages }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user?: string; email?: string; org?: string; orgs?: string[]
|
||||
authenticated?: boolean; iam_url?: string
|
||||
}
|
||||
export const getSession = () => j<Session>(`${BASE}/v1/session`).catch(() => ({ authenticated: false } as Session))
|
||||
|
||||
export interface Asset { path: string; status?: string; mtime?: number; view?: unknown }
|
||||
export const getLibrary = () => j<{ org: string; assets: Asset[] }>(`${BASE}/v1/library`).catch(() => ({ org: '', assets: [] }))
|
||||
|
||||
export interface RenderJob { id: string; prefix?: string; refs?: string[]; ts?: number; age?: number; status?: string }
|
||||
export const getRenderQueue = () => j<{ jobs: RenderJob[] }>(`${BASE}/v1/render-queue`).catch(() => ({ jobs: [] }))
|
||||
export const gpuOnline = () => j<{ online: boolean }>(`${BASE}/v1/gpu-status`).then(r => r.online).catch(() => false)
|
||||
|
||||
// Thumbnail URL for a library asset (the studio pod serves a downsized JPEG).
|
||||
export const thumbURL = (path: string) => `${BASE}/v1/library/file?thumb=1&path=${encodeURIComponent(path)}`
|
||||
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
--bg: #0d0d0f; --panel: #151517; --line: #26262b; --text: #f4f4f6;
|
||||
--dim: #9a9aa2; --accent: #f4f4f6; --accent-ink: #0d0d0f;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #fafafa; --panel: #fff; --line: #e6e6ea; --text: #16161a; --dim: #6b6b73; --accent: #16161a; --accent-ink: #fff; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
.top { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
|
||||
.brand { font-weight: 500; letter-spacing: .2px; } .brand b { font-weight: 700; }
|
||||
.spacer { flex: 1; }
|
||||
.gpu { font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
|
||||
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: #35c26b; }
|
||||
.who { font-size: 13px; color: var(--dim); }
|
||||
|
||||
.body { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
|
||||
.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); }
|
||||
.scroll { flex: 1; overflow-y: auto; padding: 22px; }
|
||||
.hero { max-width: 560px; margin: 8vh auto 0; text-align: center; }
|
||||
.hero h1 { font-size: 30px; font-weight: 600; margin: 0 0 10px; }
|
||||
.hero p { color: var(--dim); }
|
||||
.msg { display: flex; margin: 10px 0; } .msg.user { justify-content: flex-end; }
|
||||
.bubble { max-width: 74%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; border: 1px solid var(--line); background: var(--panel); }
|
||||
.msg.user .bubble { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.bubble.dim { color: var(--dim); }
|
||||
|
||||
.composer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--line); }
|
||||
.composer textarea { flex: 1; resize: none; height: 44px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
|
||||
.composer button { padding: 0 18px; border-radius: 12px; border: 0; background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
|
||||
.composer button:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.rail { overflow-y: auto; padding: 16px; }
|
||||
.rail-h { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--dim); margin: 14px 0 8px; }
|
||||
.queue .job { font-size: 12px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; color: var(--dim); }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.cell { aspect-ratio: 3 / 4; overflow: hidden; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.empty { color: var(--dim); font-size: 13px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.body { grid-template-columns: 1fr; }
|
||||
.rail { display: none; }
|
||||
.chat { border-right: 0; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Served same-origin by the studio Python pod at /studio, so the app is based at
|
||||
// /studio/ and every /v1/* fetch rides the same-origin hanzo_token cookie (no CORS).
|
||||
export default defineConfig({
|
||||
base: '/studio/',
|
||||
plugins: [react()],
|
||||
build: { outDir: 'dist', sourcemap: false },
|
||||
})
|
||||
Binary file not shown.
Reference in New Issue
Block a user