Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d2068264 |
+3
-4
@@ -23,10 +23,9 @@ venv/
|
||||
.ruff_cache/
|
||||
tests/
|
||||
tests-unit/
|
||||
# scripts/ ships WHOLE: the image build runs scripts/install_custom_nodes.sh and
|
||||
# the studio pod runs scripts/library_manifest.py as the build-manifest sidecar
|
||||
# (indexes every org's output/ into library.json). Excluding it silently no-ops
|
||||
# the sidecar's `[ -f ]` guard, so nothing gets indexed — keep the tree intact.
|
||||
scripts/*
|
||||
# ...except the pack installer, which the image build invokes.
|
||||
!scripts/install_custom_nodes.sh
|
||||
notebooks/
|
||||
*.swp
|
||||
.env
|
||||
|
||||
-12
@@ -1,12 +1,3 @@
|
||||
# ── studio-chat SPA (web/) ── the /v1/agent chat UI served at /studio. Built here
|
||||
# so the image is self-contained; the small Vite bundle lands at /app/web/dist.
|
||||
FROM node:20-slim AS web
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci 2>/dev/null || npm install
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -34,9 +25,6 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# The studio-chat SPA built above → served at /studio by middleware/studio_home.py.
|
||||
COPY --from=web /web/dist ./web/dist
|
||||
|
||||
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
|
||||
# upscaling, video, utils) at their pinned commits and install their deps with the
|
||||
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
|
||||
|
||||
@@ -23,8 +23,7 @@ test:
|
||||
tests-unit/folder_paths_test \
|
||||
tests-unit/middleware_test \
|
||||
tests-unit/app_test \
|
||||
tests-unit/prompt_server_test \
|
||||
tests-unit/studio_home_test
|
||||
tests-unit/prompt_server_test
|
||||
|
||||
# Deploy: roll the freshly-built image onto the studio Service CR.
|
||||
deploy:
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/* Hanzo Studio shell — the ONE shared chrome, loaded by BOTH the Studio home
|
||||
* (middleware/studio_home.html) and the Editor (injected into the engine's
|
||||
* index.html by server.get_root). Self-contained dark palette + `hz` id/class
|
||||
* prefix so it never collides with the editor's own theme. */
|
||||
#hzbar{position:fixed;bottom:18px;right:18px;z-index:2147483000;display:flex;align-items:center;gap:8px;
|
||||
flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);
|
||||
font:400 14px/1.4 Inter,system-ui,-apple-system,sans-serif}
|
||||
#hzbar button,#hzbar a{font:inherit}
|
||||
.hz-pill{display:inline-flex;align-items:center;gap:7px;background:#131317;color:#ececf1;border:1px solid #232329;
|
||||
border-radius:999px;padding:7px 13px;cursor:pointer;text-decoration:none;white-space:nowrap}
|
||||
.hz-pill:hover{border-color:#3a3a48}
|
||||
.hz-pill .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;flex:0 0 auto}
|
||||
.hz-pill.on .hz-dot{background:#3ddc84}
|
||||
.hz-pill.hz-chat{background:#f4f4f6;color:#111;border-color:#f4f4f6;font-weight:600}
|
||||
.hz-topup{color:#6aa5ff;text-decoration:none;font-size:.82em;border-left:1px solid #232329;padding-left:7px}
|
||||
.hz-topup:hover{text-decoration:underline}
|
||||
/* right-docked chat sidebar (both views) */
|
||||
#hzchat{position:fixed;top:0;right:0;height:100vh;width:380px;max-width:94vw;background:#0e0e12;color:#ececf1;
|
||||
border-left:1px solid #232329;z-index:2147483001;display:flex;flex-direction:column;
|
||||
transform:translateX(100%);transition:transform .22s ease;box-shadow:-14px 0 44px #0007}
|
||||
#hzchat.on{transform:translateX(0)}
|
||||
#hzchat .hz-h{display:flex;align-items:center;gap:9px;padding:13px 15px;border-bottom:1px solid #232329;flex-wrap:wrap}
|
||||
#hzchat .hz-h .hz-av{width:26px;height:26px;border-radius:50%;background:#26262e;display:flex;align-items:center;
|
||||
justify-content:center;font-size:.72rem;font-weight:600;flex:0 0 auto}
|
||||
#hzchat .hz-h .hz-who{font-size:.82rem;min-width:0}
|
||||
#hzchat .hz-h .hz-who b{display:block;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
#hzchat .hz-h .hz-who span{color:#8a8a95;font-size:.72rem}
|
||||
#hzchat .hz-h .hz-x{margin-left:auto;cursor:pointer;color:#8a8a95;font-size:1.3rem;line-height:1;background:none;border:none}
|
||||
#hzmsgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.hz-m{max-width:88%;padding:9px 12px;border-radius:12px;font-size:.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
||||
.hz-m.u{align-self:flex-end;background:#f4f4f6;color:#111;border-bottom-right-radius:3px}
|
||||
.hz-m.a{align-self:flex-start;background:#1a1a20;border:1px solid #232329;border-bottom-left-radius:3px}
|
||||
.hz-m.sys{align-self:center;color:#8a8a95;font-size:.78rem;background:none}
|
||||
#hzform{display:flex;gap:8px;padding:12px;border-top:1px solid #232329}
|
||||
#hzform input{flex:1;background:#0a0a0d;border:1px solid #232329;border-radius:9px;color:#ececf1;padding:9px 11px;font:inherit;font-size:.85rem;outline:none}
|
||||
#hzform button{background:#f4f4f6;color:#111;border:none;border-radius:9px;padding:0 14px;font-weight:600;cursor:pointer}
|
||||
/* GPUs panel (both views) */
|
||||
#hzgpus{position:fixed;bottom:64px;right:18px;z-index:2147483001;width:320px;max-width:92vw;background:#131317;
|
||||
color:#ececf1;border:1px solid #232329;border-radius:12px;padding:12px 14px;display:none;box-shadow:0 18px 50px #0009}
|
||||
#hzgpus.on{display:block}
|
||||
#hzgpus h4{font-size:.82rem;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:7px}
|
||||
.hz-node{display:flex;align-items:flex-start;gap:9px;padding:7px 0;border-top:1px solid #1e1e24;font-size:.8rem}
|
||||
.hz-node:first-of-type{border-top:none}
|
||||
.hz-node .hz-dot{width:8px;height:8px;border-radius:50%;background:#555;margin-top:5px;flex:0 0 auto}
|
||||
.hz-node.on .hz-dot{background:#3ddc84}
|
||||
.hz-node .hz-nn{flex:1;min-width:0}
|
||||
.hz-node .hz-nn b{font-weight:500}
|
||||
.hz-node .hz-nsub{color:#8a8a95;font-size:.72rem;margin-top:2px}
|
||||
/* Tablet: a touch narrower sidebar; the pill bar + panels already fit. */
|
||||
@media (max-width:900px){#hzchat{width:340px}}
|
||||
/* Phone: chat becomes a full-width bottom sheet (same pattern the old chatpanel
|
||||
used); the pill bar wraps upward from the bottom-right so the toggle, wallet
|
||||
and GPUs badge all stay reachable; the GPUs panel spans the width. */
|
||||
@media (max-width:600px){
|
||||
#hzbar{bottom:14px;right:14px;gap:7px}
|
||||
.hz-pill{padding:7px 11px}
|
||||
.hz-pill .hz-lbl{display:none} /* icon-only Chat/GPUs → one line, clears content */
|
||||
#hzchat{top:auto;bottom:0;width:100vw;max-width:100vw;height:85vh;
|
||||
border-left:none;border-top:1px solid #232329;border-radius:16px 16px 0 0;
|
||||
transform:translateY(100%);box-shadow:0 -14px 44px #0007}
|
||||
#hzchat.on{transform:translateY(0)}
|
||||
#hzgpus{left:12px;right:12px;width:auto;bottom:66px}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/* Hanzo Studio shell — the ONE shared chrome for BOTH views.
|
||||
*
|
||||
* Studio home (/studio) includes this via <script src="/studio/shell.js">
|
||||
* Editor (/ ?advanced=1) gets the same tag injected into the engine's
|
||||
* index.html by server.get_root
|
||||
*
|
||||
* It renders (identically in both views): a Studio/Editor toggle, a wallet chip,
|
||||
* a GPUs badge + panel, and a dockable right chat SIDEBAR backed by the ONE chat
|
||||
* path (POST /v1/copilot/chat). Same-origin cookies carry the IAM session, so org
|
||||
* + login are inherited — no auth is rebuilt here. In the editor it also honours
|
||||
* ?load=<worklog id> / ?template=<name> to open a graph, and offers Save as
|
||||
* template. Guarded end to end: any failure logs and no-ops, never throws. */
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__hzShell) return;
|
||||
window.__hzShell = 1;
|
||||
|
||||
var STUDIO = location.pathname === "/studio" || location.pathname === "/studio/";
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var el = function (tag, attrs, html) {
|
||||
var e = document.createElement(tag);
|
||||
if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
|
||||
if (html != null) e.innerHTML = html;
|
||||
return e;
|
||||
};
|
||||
var esc = function (s) { return String(s == null ? "" : s).replace(/[<>&]/g, function (c) { return { "<": "<", ">": ">", "&": "&" }[c]; }); };
|
||||
async function getJSON(url, opt) {
|
||||
var r = await fetch(url, Object.assign({ credentials: "same-origin" }, opt || {}));
|
||||
var j = {}; try { j = await r.json(); } catch (_) {}
|
||||
return { ok: r.ok, status: r.status, body: j };
|
||||
}
|
||||
|
||||
// ── chrome: bottom-right pill cluster + docked chat sidebar + GPUs panel ──────
|
||||
var bar = el("div", { id: "hzbar" });
|
||||
var toggle = el("a", { class: "hz-pill", href: STUDIO ? "/?advanced=1" : "/studio",
|
||||
title: STUDIO ? "Open the node editor" : "Back to Studio" }, STUDIO ? "⇄ Editor" : "⇄ Studio");
|
||||
var tmplBtn = STUDIO ? null : el("button", { class: "hz-pill", title: "Save the current graph as a reusable template" }, "★ Template");
|
||||
var wallet = el("a", { class: "hz-pill", id: "hzwallet", href: "https://pay.hanzo.ai", target: "_blank", rel: "noopener",
|
||||
title: "Wallet — top up at pay.hanzo.ai" }, "wallet …");
|
||||
var gpus = el("button", { class: "hz-pill", id: "hzgpubtn", title: "GPUs" }, '<span class="hz-dot"></span><span class="hz-lbl">GPUs</span>');
|
||||
var chatBtn = el("button", { class: "hz-pill hz-chat", title: "Chat" }, '💬<span class="hz-lbl"> Chat</span>');
|
||||
bar.appendChild(toggle);
|
||||
if (tmplBtn) bar.appendChild(tmplBtn);
|
||||
bar.appendChild(wallet); bar.appendChild(gpus); bar.appendChild(chatBtn);
|
||||
|
||||
var gpuPanel = el("div", { id: "hzgpus" }, '<h4>GPUs</h4><div id="hznodes"></div>');
|
||||
var chat = el("aside", { id: "hzchat" });
|
||||
chat.innerHTML =
|
||||
'<div class="hz-h"><span class="hz-av" id="hzav">·</span>' +
|
||||
'<span class="hz-who"><b id="hzuser">…</b><span id="hzorg"></span></span>' +
|
||||
'<button class="hz-x" id="hzclose" title="Close">×</button></div>' +
|
||||
'<div id="hzmsgs"></div>' +
|
||||
'<form id="hzform"><input id="hzin" placeholder="Ask the copilot…" autocomplete="off"><button type="submit">Send</button></form>';
|
||||
|
||||
function mount() {
|
||||
document.body.appendChild(bar);
|
||||
document.body.appendChild(gpuPanel);
|
||||
document.body.appendChild(chat);
|
||||
wire();
|
||||
loadSession(); loadWallet(); loadGpus();
|
||||
if (!STUDIO) editorBoot();
|
||||
}
|
||||
|
||||
// ── chat: the ONE path — POST /v1/copilot/chat; ops applied via the editor's
|
||||
// existing applier when present (no second chat mechanism) ────────────────────
|
||||
var HISTORY = [];
|
||||
function addMsg(role, text) {
|
||||
var m = el("div", { class: "hz-m " + role }, esc(text));
|
||||
$("hzmsgs").appendChild(m); $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight;
|
||||
return m;
|
||||
}
|
||||
async function graphContext() {
|
||||
var app = window.app;
|
||||
if (!app || typeof app.graphToPrompt !== "function") return {};
|
||||
try {
|
||||
var p = await app.graphToPrompt();
|
||||
var ctx = { graph_json: p.output || {} };
|
||||
try { ctx.catalog = { types: Object.keys((window.LiteGraph && window.LiteGraph.registered_node_types) || {}) }; } catch (_) {}
|
||||
return ctx;
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
async function send(text) {
|
||||
HISTORY.push({ role: "user", content: text });
|
||||
addMsg("u", text);
|
||||
var pending = addMsg("sys", "…");
|
||||
var payload = Object.assign({ messages: HISTORY.slice(-20) }, await graphContext());
|
||||
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
pending.remove();
|
||||
var reply = (r.body && r.body.reply) || (r.body && r.body.error) || (r.ok ? "" : "chat failed (" + r.status + ")");
|
||||
if (reply) { HISTORY.push({ role: "assistant", content: reply }); addMsg("a", reply); }
|
||||
var ops = (r.body && r.body.ops) || [];
|
||||
if (ops.length && window.HanzoCopilot && typeof window.HanzoCopilot.applyOps === "function") {
|
||||
try { window.HanzoCopilot.applyOps(ops); } catch (e) { addMsg("sys", "couldn't apply changes: " + e.message); }
|
||||
} else if (ops.length && STUDIO) {
|
||||
addMsg("sys", "Open the Editor to apply " + ops.length + " graph change" + (ops.length > 1 ? "s" : "") + ".");
|
||||
}
|
||||
}
|
||||
|
||||
function wire() {
|
||||
chatBtn.onclick = function () { chat.classList.toggle("on"); if (chat.classList.contains("on")) $("hzin").focus(); };
|
||||
$("hzclose").onclick = function () { chat.classList.remove("on"); };
|
||||
$("hzform").onsubmit = function (e) {
|
||||
e.preventDefault();
|
||||
var v = $("hzin").value.trim(); if (!v) return;
|
||||
$("hzin").value = ""; send(v).catch(function (err) { addMsg("sys", "error: " + err.message); });
|
||||
};
|
||||
gpus.onclick = function () { gpuPanel.classList.toggle("on"); if (gpuPanel.classList.contains("on")) loadGpus(); };
|
||||
if (tmplBtn) tmplBtn.onclick = saveTemplate;
|
||||
}
|
||||
|
||||
// ── session · wallet · GPUs ───────────────────────────────────────────────────
|
||||
async function loadSession() {
|
||||
var r = await getJSON("/v1/session"); var s = r.body || {};
|
||||
var nm = (s.user || s.email || "You").trim();
|
||||
$("hzav").textContent = (nm[0] || "·").toUpperCase();
|
||||
$("hzuser").textContent = nm;
|
||||
$("hzorg").textContent = s.org ? ("org · " + s.org) : "";
|
||||
}
|
||||
function money(n, cur) {
|
||||
if (n == null || isNaN(n)) return "—";
|
||||
var sym = (cur || "").toLowerCase() === "usd" ? "$" : "";
|
||||
return sym + Number(n).toFixed(2) + (sym ? "" : " " + (cur || "").toUpperCase());
|
||||
}
|
||||
async function loadWallet() {
|
||||
var r = await getJSON("/v1/wallet"); var w = r.body || {};
|
||||
wallet.innerHTML = esc(money(w.balance, w.currency)) + '<span class="hz-topup">Top up ↗</span>';
|
||||
wallet.title = "Wallet" + (w.org ? " · " + w.org : "") + " — top up at pay.hanzo.ai";
|
||||
}
|
||||
async function loadGpus() {
|
||||
var nres = await getJSON("/v1/nodes"); var qres = await getJSON("/v1/queue/status");
|
||||
var nodes = (nres.body && nres.body.nodes) || [];
|
||||
var pod = (nres.body && nres.body.pod) || null;
|
||||
if (pod) nodes = nodes.concat([pod]);
|
||||
var items = (qres.body && qres.body.items) || {};
|
||||
var byNode = {};
|
||||
Object.keys(items).forEach(function (id) {
|
||||
var it = items[id]; var k = it.node || "gpu";
|
||||
(byNode[k] = byNode[k] || { running: null, depth: 0 });
|
||||
byNode[k].depth++;
|
||||
if (it.status === "running") byNode[k].running = it.prompt || it.kind || "a render";
|
||||
});
|
||||
var online = nodes.filter(function (n) { return n.online; }).length;
|
||||
gpus.innerHTML = '<span class="hz-dot"></span><span class="hz-lbl">GPUs </span>' + online + "/" + nodes.length;
|
||||
gpus.classList.toggle("on", online > 0);
|
||||
$("hznodes").innerHTML = nodes.length ? nodes.map(function (n) {
|
||||
var s = byNode[n.name] || { running: null, depth: 0 };
|
||||
var sub = (n.online ? "online" : "offline") +
|
||||
(s.running ? " · running: " + esc(String(s.running).slice(0, 40)) : (n.online ? " · idle" : "")) +
|
||||
" · queue " + s.depth;
|
||||
return '<div class="hz-node ' + (n.online ? "on" : "") + '"><span class="hz-dot"></span>' +
|
||||
'<div class="hz-nn"><b>' + esc(n.name) + '</b><div class="hz-nsub">' + sub + "</div></div></div>";
|
||||
}).join("") : '<div class="hz-nsub">No GPU nodes connected. Run <b>hanzo gpu connect</b> on a box to add one.</div>';
|
||||
}
|
||||
|
||||
// ── editor-only: open a graph (?load / ?template), Save as template ────────────
|
||||
function waitForApp(ms) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t0 = Date.now();
|
||||
(function poll() {
|
||||
if (window.app && typeof window.app.loadGraphData === "function") return resolve(window.app);
|
||||
if (Date.now() - t0 > ms) return reject(new Error("editor not ready"));
|
||||
setTimeout(poll, 150);
|
||||
})();
|
||||
});
|
||||
}
|
||||
function editorLoad(g) {
|
||||
var app = window.app; if (!app || !g) return false;
|
||||
if (g.workflow && typeof app.loadGraphData === "function") { app.loadGraphData(g.workflow); return true; }
|
||||
if (g.output && typeof app.loadApiJson === "function") { app.loadApiJson(g.output, "hanzo"); return true; }
|
||||
if ((g.nodes || g.last_node_id != null) && typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
|
||||
if (typeof app.loadApiJson === "function") { app.loadApiJson(g, "hanzo"); return true; }
|
||||
if (typeof app.loadGraphData === "function") { app.loadGraphData(g); return true; }
|
||||
return false;
|
||||
}
|
||||
async function editorBoot() {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var load = q.get("load"), template = q.get("template");
|
||||
if (!load && !template) return;
|
||||
try {
|
||||
await waitForApp(30000);
|
||||
var url = load ? "/v1/workflow?id=" + encodeURIComponent(load) : "/v1/templates/" + encodeURIComponent(template);
|
||||
var r = await getJSON(url);
|
||||
if (!r.ok) { addMsg("sys", "couldn't open: " + ((r.body && r.body.error) || r.status)); return; }
|
||||
editorLoad(r.body.graph || r.body);
|
||||
} catch (e) { console.warn("[hz shell]", e); }
|
||||
}
|
||||
async function saveTemplate() {
|
||||
var app = window.app;
|
||||
if (!app || typeof app.graphToPrompt !== "function") { alert("Open a workflow in the editor first."); return; }
|
||||
var name = prompt("Save this graph as a template named:");
|
||||
if (!name || !name.trim()) return;
|
||||
var g;
|
||||
try { g = await app.graphToPrompt(); } catch (e) { alert("Couldn't read the graph: " + e.message); return; }
|
||||
var r = await getJSON("/v1/templates", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), graph: { workflow: g.workflow, output: g.output } }) });
|
||||
alert(r.ok ? '★ Saved template "' + (r.body.name || name.trim()) + '"' : "Save failed: " + ((r.body && r.body.error) || r.status));
|
||||
}
|
||||
|
||||
if (document.body) mount();
|
||||
else document.addEventListener("DOMContentLoaded", mount);
|
||||
})();
|
||||
+31
-72
@@ -2,8 +2,6 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo Studio</title>
|
||||
<link rel="stylesheet" href="/studio/shell.css">
|
||||
<script src="/studio/shell.js" defer></script>
|
||||
<style>
|
||||
:root{--bg:#0a0a0b;--panel:#131317;--panel2:#17171c;--line:#232329;--txt:#ececf1;--dim:#8a8a95;
|
||||
--brand:#f4f4f6;--ok:#3ddc84;--warn:#ffb648;--pub:#6aa5ff;font-size:15px}
|
||||
@@ -24,11 +22,6 @@ header .beta{font-size:.62rem;color:var(--dim);border:1px solid var(--line);bord
|
||||
header .sp{flex:1}
|
||||
header .lnk{color:var(--dim);font-size:.85rem;text-decoration:none}
|
||||
header .user{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:99px;padding:3px 11px 3px 4px;cursor:pointer}
|
||||
#acctmenu{position:fixed;top:52px;right:12px;background:#0e0e12;border:1px solid var(--line);border-radius:12px;padding:6px;display:none;z-index:60;min-width:210px;box-shadow:0 16px 44px #000a}
|
||||
#acctmenu.on{display:block}
|
||||
#acctmenu .hd{padding:8px 12px;color:var(--dim);font-size:.78rem;border-bottom:1px solid var(--line);margin-bottom:4px}
|
||||
#acctmenu .mi{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px;cursor:pointer;font-size:.88rem;color:var(--txt);text-decoration:none}
|
||||
#acctmenu .mi:hover{background:#ffffff10}
|
||||
header .user .av{width:24px;height:24px;border-radius:50%;background:#26262e;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:600}
|
||||
header .user .nm{font-size:.82rem;color:var(--dim)}
|
||||
.btn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
|
||||
@@ -150,8 +143,18 @@ body.immersive #stage{display:block}
|
||||
#stage .dots span{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.35);cursor:pointer} #stage .dots span.on{background:#fff;width:20px;border-radius:4px}
|
||||
#stage .exit{position:absolute;top:18px;right:22px;z-index:7;color:#fff;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.25);border-radius:8px;padding:6px 12px;font-size:.8rem;cursor:pointer;opacity:0;transition:.2s} #stage:hover .exit{opacity:1}
|
||||
.viewbtn{border:1px solid var(--line);background:var(--panel);color:var(--txt);border-radius:8px;padding:7px 13px;font-size:.85rem;cursor:pointer}
|
||||
/* The Studio/Editor toggle, wallet, GPUs and chat sidebar are the shared shell
|
||||
(/studio/shell.css + shell.js), loaded in both views. */
|
||||
/* ── 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}
|
||||
@@ -159,6 +162,7 @@ body.immersive #stage{display:block}
|
||||
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:11px}
|
||||
.tgrid{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
|
||||
#livebar{margin-left:16px;margin-right:16px}
|
||||
#chatpanel{width:calc(100vw - 24px);height:70vh}
|
||||
}
|
||||
@media (max-width:600px){
|
||||
header{padding:11px 14px;gap:9px}
|
||||
@@ -176,6 +180,8 @@ body.immersive #stage{display:block}
|
||||
.search{width:120px} .tabs .sp{display:none}
|
||||
#livebar{flex-wrap:wrap;font-size:.78rem}
|
||||
#bulkbar,#reftray{left:8px;right:8px;transform:none;flex-wrap:wrap;justify-content:center}
|
||||
#chatfab{bottom:16px;right:16px}
|
||||
#chatpanel{bottom:0;right:0;left:0;width:100vw;height:82vh;max-height:82vh;border-radius:16px 16px 0 0}
|
||||
.card .delx,.card .addref{width:30px;height:30px} /* bigger tap targets */
|
||||
}
|
||||
/* touch: no hover-reveal — always show card tools on touch devices */
|
||||
@@ -186,10 +192,10 @@ body.immersive #stage{display:block}
|
||||
<header>
|
||||
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
|
||||
<h1>Hanzo Studio</h1><span class="beta">Beta</span><span class="sp"></span>
|
||||
<a class="lnk" href="/?advanced=1">Advanced mode</a>
|
||||
<button class="btn" onclick="load()">Refresh</button>
|
||||
<div class="user" id="user" title="Account"><span class="av" id="av">·</span><span class="nm" id="org">…</span></div>
|
||||
</header>
|
||||
<div id="acctmenu"><div class="hd" id="acctwho">Account</div><a class="mi" href="https://hanzo.id" target="_blank" rel="noopener">Manage account ↗</a><a class="mi" href="/logout">Sign out</a></div>
|
||||
<div id="tophover"></div>
|
||||
<div id="livebar" class="idle">
|
||||
<span id="lbstatus" class="lb-q">Checking queue…</span>
|
||||
@@ -232,12 +238,7 @@ body.immersive #stage{display:block}
|
||||
<button class="mini" onclick="bulk('deleted')">Delete</button>
|
||||
<button class="mini" onclick="clearSel()">Cancel</button>
|
||||
</div>
|
||||
<section id="templates" hidden>
|
||||
<div class="sec-l">Your templates</div>
|
||||
<div class="tgrid" id="orgtmpl"></div>
|
||||
<div class="sec-l">Start from a kind</div>
|
||||
<div class="grid" id="tgall"></div>
|
||||
</section>
|
||||
<section id="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>
|
||||
@@ -289,7 +290,6 @@ body.immersive #stage{display:block}
|
||||
<div class="row" style="justify-content:flex-end;margin-top:14px;gap:6px">
|
||||
<button class="mini" id="ctxcancel" onclick="cancelCtx()" style="display:none">✕ Cancel job</button>
|
||||
<button class="mini" id="ctxedit" onclick="editCtx()" style="display:none">✎ Edit</button>
|
||||
<button class="mini" id="ctxeditor" onclick="editorCtx()" title="Load this graph into the node editor" style="display:none">⇄ Open in Editor</button>
|
||||
<button class="mini" id="ctxreuse" onclick="reuseCtx()" title="Open Fix primed with this prompt + references">↻ Reuse context</button>
|
||||
<button class="mini hot" id="ctxfix" onclick="fixCtx()">Fix this version</button>
|
||||
</div>
|
||||
@@ -335,26 +335,7 @@ const thumbURL=a=>`/v1/library/file?thumb=1&path=${encodeURIComponent(a.path)}`;
|
||||
function tcard(x){return `<div class="tcard" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><div class="ic">${x.ic}</div><div class="tl">${x.t}</div><div class="td">${x.d}</div></div>`;}
|
||||
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
|
||||
for(const s of ['assets','templates','pipelines','runs'])$(s).hidden=s!==name;
|
||||
if(name==='runs')runs(); if(name==='pipelines')pipes(); if(name==='templates')loadOrgTemplates();}
|
||||
// ── Org templates: saved graphs, Open in Editor + Render (the ONE dispatch path) ──
|
||||
async function loadOrgTemplates(){
|
||||
let d={templates:[]}; try{d=await (await fetch('/v1/templates')).json();}catch(_){}
|
||||
const ts=d.templates||[];
|
||||
$('orgtmpl').innerHTML=ts.length?ts.map(t=>{const nm=(t.name||t.slug).replace(/</g,'<'),ne=(t.name||t.slug).replace(/'/g,"\\'");
|
||||
return `<div class="tcard"><div class="ic">★</div><div class="tl">${nm}</div>
|
||||
<div class="td">${t.ts?new Date(t.ts*1000).toLocaleDateString():''}</div>
|
||||
<div class="row" style="padding:0 11px 11px;gap:6px">
|
||||
<a class="mini" href="/?advanced=1&template=${encodeURIComponent(t.slug)}">Open in Editor</a>
|
||||
${t.renderable?`<button class="mini hot" onclick="renderTemplate('${t.slug}','${ne}')">Render</button>`:''}
|
||||
</div></div>`;}).join(''):'<div class="empty" style="grid-column:1/-1;padding:20px">No templates yet. In the Editor, build a graph and click ★ Template to save one.</div>';
|
||||
}
|
||||
async function renderTemplate(slug,name){
|
||||
toast('Queueing '+name+'…');
|
||||
const r=await fetch('/v1/templates/render',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:slug})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
toast(r.ok?'Render queued → see Queue & History':'Failed: '+(errMsg(j)||('error '+r.status)));
|
||||
}
|
||||
function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeURIComponent(CTX.id); }
|
||||
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;
|
||||
@@ -426,8 +407,7 @@ async function bulk(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]){const rr=await fetch('/v1/library/rerun',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})});
|
||||
if(!rr.ok&&await healStale(await rr.json().catch(()=>({}))))return;}
|
||||
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
|
||||
@@ -435,18 +415,6 @@ async function bulkFork(){ if(!SEL.size)return; toast('Fork/redo '+SEL.size+'
|
||||
// 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||''; }
|
||||
// One self-heal for a stale-asset dispatch (fix / compose / rerun): the item the user
|
||||
// clicked was deduped/renamed/cleaned since the page loaded, so /prompt 400s "unknown
|
||||
// asset: <path>". Re-fetch the library, drop the stale selection, and tell them to pick
|
||||
// again — never strand them on a raw error. Returns true when it handled the error.
|
||||
async function healStale(j){
|
||||
if(!/unknown asset/i.test(errMsg(j)))return false;
|
||||
document.querySelectorAll('dialog[open]').forEach(d=>{try{d.close();}catch(_){}});
|
||||
clearSel(); clearTray();
|
||||
await load();
|
||||
toast('That item changed — library refreshed, pick it again.');
|
||||
return true;
|
||||
}
|
||||
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');
|
||||
@@ -497,7 +465,7 @@ async function genFromTray(){
|
||||
}
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.ok){ toast('Queued → see Queue & History'); $('refprompt').value=''; clearTray(); }
|
||||
else if(!(await healStale(j))) toast('Failed: '+(errMsg(j)||('error '+r.status)));
|
||||
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;
|
||||
@@ -525,11 +493,11 @@ async function sendCompose(){
|
||||
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 if(!(await healStale(j))){ $('gpuwarn2').style.display='block'; $('gpuwarn2').textContent='Couldn’t queue: '+(errMsg(j)||('error '+r.status)); }
|
||||
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={};let FAVS={};
|
||||
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])||{};
|
||||
@@ -621,7 +589,6 @@ async function openContext(id){
|
||||
$('ctxfix').style.display=hasBase?'':'none';
|
||||
$('ctxcancel').style.display=live?'':'none';
|
||||
$('ctxedit').style.display=live?'':'none';
|
||||
$('ctxeditor').style.display=(it.output&&it.id&&!String(it.id).startsWith('failed-'))?'':'none';
|
||||
$('ctxdlg').showModal();
|
||||
}
|
||||
function cancelCtx(){ if(!CTX)return; ctxdlg.close(); cancelJob(CTX.id); }
|
||||
@@ -629,7 +596,7 @@ function editCtx(){ if(!CTX)return; ctxdlg.close(); openAmend(CTX.id); }
|
||||
async function openLineage(path){
|
||||
CTX={output:path,parents:[],refs:[],uploads:[],prompt:'',kind:'asset',status:''};
|
||||
$('ctxtitle').textContent='Versions'; $('ctxbody').innerHTML=ctxHTML(CTX, await lineageFor(path));
|
||||
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxeditor').style.display='none'; $('ctxdlg').showModal();
|
||||
$('ctxreuse').style.display='none'; $('ctxfix').style.display=''; $('ctxdlg').showModal();
|
||||
}
|
||||
function openLibItem(path){ ctxdlg.close(); zoom(imgURL({path})); }
|
||||
async function primeFix(base,prompt,libRefs,upRefs){
|
||||
@@ -643,7 +610,6 @@ function fixCtx(){ if(!CTX)return; const base=CTX.output||(CTX.parents&&CTX.pare
|
||||
async function runs(){
|
||||
refreshQueue(); loadNodes();
|
||||
RATINGS=await (await fetch('/v1/library/ratings')).json().catch(()=>({}));
|
||||
FAVS=((await (await fetch('/v1/favorites')).json().catch(()=>({}))).favorites)||{};
|
||||
// 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||[];
|
||||
@@ -688,7 +654,7 @@ async function runs(){
|
||||
<span class="t">${r.title}</span>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<span class="stars">${stars}</span>
|
||||
<span class="fav ${FAVS[r.id]?'on':''}" onclick="toggleFav('${r.id}','${r.title}')" title="Favorite → save as template">★fav</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>';
|
||||
@@ -697,24 +663,19 @@ async function clearQueue(){if(!confirm('Clear all queued jobs?'))return;await f
|
||||
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 nv=!FAVS[key];
|
||||
await fetch('/v1/favorites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,favorite:nv})});
|
||||
if(nv)FAVS[key]={ts:Math.floor(Date.now()/1000)};else delete FAVS[key];
|
||||
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 saveTemplate(){const nm=$('tmplname').value.trim();if(!nm){toast('Name the template');return;}
|
||||
// Real save: the favorited run's graph is embedded in its output (fetched via
|
||||
// /v1/workflow by the run's id) → POST /v1/templates, the ONE template store the
|
||||
// Editor's ★ Template also writes to.
|
||||
let g=null; try{const wr=await fetch('/v1/workflow?id='+encodeURIComponent(FAVKEY)); if(wr.ok)g=(await wr.json()).graph;}catch(_){}
|
||||
if(!g){$('tmpldlg').close();toast('No graph on this run — open it in the Editor and use ★ Template');return;}
|
||||
const r=await fetch('/v1/templates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nm,graph:g})});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
$('tmpldlg').close();toast(r.ok?('★ Saved "'+nm+'" — see Templates'):('Failed: '+(errMsg(j)||('error '+r.status))));
|
||||
if(r.ok&&!$('templates').hidden)loadOrgTemplates();
|
||||
// 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();
|
||||
}
|
||||
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),
|
||||
@@ -827,8 +788,6 @@ async function cancelJob(id){
|
||||
wireDrop($('fixdlg'),uploadFixFiles); wirePaste($('fixdlg'),uploadFixFiles);
|
||||
const af=$('attachfile');
|
||||
af.addEventListener('change',e=>{attachFiles(e.target.files);e.target.value='';});
|
||||
const uc=$('user'); if(uc){uc.addEventListener('click',e=>{e.stopPropagation();$('acctwho').textContent=($('org').textContent||'').trim()||'Account';$('acctmenu').classList.toggle('on');});
|
||||
document.addEventListener('click',e=>{const m=$('acctmenu');if(m&&m.classList.contains('on')&&!m.contains(e.target))m.classList.remove('on');});}
|
||||
const box=document.querySelector('.prompt'); if(box){ wireDrop(box,attachFiles); wirePaste(box,attachFiles); }
|
||||
})();
|
||||
function zoom(u){$('zoomimg').src=u;$('zoom').style.display='flex';}
|
||||
|
||||
+8
-510
@@ -38,7 +38,6 @@ import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
@@ -50,12 +49,6 @@ from middleware import worklog
|
||||
log = logging.getLogger("studio.home")
|
||||
|
||||
_HTML = Path(__file__).with_name("studio_home.html")
|
||||
_ASSET_DIR = Path(__file__).parent
|
||||
# The studio-chat SPA (web/, a Vite bundle on @hanzo/ai talking to /v1/agent) built
|
||||
# into the image at /app/web/dist and served AT /studio. When the build is absent
|
||||
# (dev checkout, an image that skipped the web stage) /studio falls back to the
|
||||
# legacy studio_home.html so the route is never a dark page.
|
||||
_WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
|
||||
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
|
||||
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
|
||||
|
||||
@@ -149,10 +142,6 @@ def _save_library(root: Path, lib: dict) -> None:
|
||||
|
||||
def _safe_asset(root: Path, rel: str) -> Path | None:
|
||||
p = (root / rel).resolve()
|
||||
if p.name.startswith("."):
|
||||
# Dotfiles are never assets (AppleDouble forks carry image extensions
|
||||
# but serve resource-fork bytes) — refuse them everywhere at once.
|
||||
return None
|
||||
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
|
||||
|
||||
|
||||
@@ -600,381 +589,18 @@ async def home_redirect(request: web.Request, handler):
|
||||
return await handler(request)
|
||||
|
||||
|
||||
# ── Shared shell: the ONE chrome (Studio/Editor toggle, wallet, GPUs, chat
|
||||
# sidebar) loaded by BOTH views. The Studio home <script>-includes it; the Editor
|
||||
# gets it injected into the engine's index.html by server.get_root. ──────────────
|
||||
_SHELL_HEAD = '<link rel="stylesheet" href="/studio/shell.css">'
|
||||
_SHELL_BODY = '<script src="/studio/shell.js" defer></script>'
|
||||
|
||||
|
||||
def _asset(name: str, ctype: str) -> web.Response:
|
||||
"""Serve a shell asset. no-store like the SPA HTML — it changes every release."""
|
||||
return web.Response(text=(_ASSET_DIR / name).read_text(), content_type=ctype,
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
|
||||
|
||||
def editor_index(web_root: str) -> web.Response:
|
||||
"""The engine's index.html with the shared shell injected — the ONE mechanism
|
||||
the Editor picks up the Studio/Editor toggle, wallet, GPUs and chat sidebar, the
|
||||
same chrome the Studio home includes. Idempotent; no-cache like the upstream
|
||||
root handler (the frontend changes every release)."""
|
||||
html = Path(web_root, "index.html").read_text()
|
||||
if "/studio/shell.js" not in html:
|
||||
html = html.replace("</head>", _SHELL_HEAD + "</head>", 1) if "</head>" in html else _SHELL_HEAD + html
|
||||
html = html.replace("</body>", _SHELL_BODY + "</body>", 1) if "</body>" in html else html + _SHELL_BODY
|
||||
return web.Response(text=html, content_type="text/html",
|
||||
headers={"Cache-Control": "no-cache", "Pragma": "no-cache", "Expires": "0"})
|
||||
|
||||
|
||||
def _templates_root(org: str) -> Path:
|
||||
"""orgs/<org>/templates — a per-org sibling of the output tree that holds
|
||||
library.json/worklog.json, so templates are tenant-scoped exactly like every
|
||||
other per-org file. Created on demand."""
|
||||
out = Path(folder_paths.get_org_output_directory(org))
|
||||
base = out.parent if out.parent.name == org else out
|
||||
d = base / "templates"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _template_slug(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")[:60]
|
||||
|
||||
|
||||
def _template_api_prompt(graph) -> dict | None:
|
||||
"""The runnable API prompt inside a stored template graph. A graph saved from the
|
||||
Editor is ``{workflow, output}`` (output = API prompt); one captured from an
|
||||
output PNG is a bare API prompt (numeric ids -> {class_type, inputs}). A UI-only
|
||||
graph has neither and can't be queued headless — returns None so the caller says
|
||||
so instead of dispatching nothing."""
|
||||
if not isinstance(graph, dict) or not graph:
|
||||
return None
|
||||
out = graph.get("output")
|
||||
if isinstance(out, dict) and out:
|
||||
return out
|
||||
if all(isinstance(v, dict) and "class_type" in v for v in graph.values()):
|
||||
return graph
|
||||
return None
|
||||
|
||||
|
||||
async def _finance_balance(tok: str) -> dict | None:
|
||||
"""Raw cloud finance balance for the caller's IAM token, or None on any failure.
|
||||
The token scopes the org server-side (cloud pins org from the verified owner
|
||||
claim), so this read is inherently tenant-isolated."""
|
||||
from middleware.gpu_dispatch import CLOUD
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as s:
|
||||
async with s.get(f"{CLOUD}/v1/finance/balance", headers={"Authorization": tok}) as r:
|
||||
return await r.json() if r.status == 200 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _wallet(request: web.Request, org: str) -> dict:
|
||||
"""{org, balance, currency} for the wallet chip — a display-only proxy. Balance
|
||||
is the spendable dollar amount (availableCents/100); None when signed-out or the
|
||||
cloud is unreachable (the chip shows '—', never a fabricated number)."""
|
||||
from middleware.gpu_dispatch import _bearer
|
||||
out = {"org": org, "balance": None, "currency": None}
|
||||
tok = _bearer(request)
|
||||
if not tok:
|
||||
return out
|
||||
b = await _finance_balance(tok)
|
||||
if isinstance(b, dict):
|
||||
cents = b.get("availableCents")
|
||||
if isinstance(cents, (int, float)):
|
||||
out["balance"] = round(cents / 100, 2)
|
||||
out["currency"] = b.get("currency") or "usd"
|
||||
return out
|
||||
|
||||
|
||||
_MAX_UPLOAD = 100 * 1024 * 1024 # 100 MB — 4k masters run 30-60MB; the cap must hold the largest real render
|
||||
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp")
|
||||
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
|
||||
|
||||
|
||||
def _safe_subpath(raw: str) -> str | None:
|
||||
"""The library subfolder an upload lands in — allowlist chars only (no traversal,
|
||||
no absolute), default ``renders``. Returns None for anything outside the allowlist
|
||||
so the route can reject it."""
|
||||
sub = (raw or "").strip("/") or "renders"
|
||||
return sub if _SUBPATH_RE.fullmatch(sub) else None
|
||||
|
||||
|
||||
def _row_output(root: Path | None, output_prefix: str | None) -> str | None:
|
||||
"""The library-relative output for a work-log row: the file a SaveImage prefix
|
||||
produced, or — for an INGESTED render whose output_prefix is already the stored
|
||||
file's path — that path itself. Lets a mirrored render show its thumbnail and open
|
||||
its graph, without a SaveImage-style counter suffix."""
|
||||
landed = _landed_output(root, output_prefix)
|
||||
if landed is None and root is not None and output_prefix and _safe_asset(root, output_prefix) is not None:
|
||||
landed = output_prefix
|
||||
return landed
|
||||
|
||||
|
||||
def _index_upload(org: str, root: Path, rel: str, *, design, kind, role, node) -> None:
|
||||
"""Event-driven index at the ingest point so a just-stored render is INSTANTLY
|
||||
findable — in Assets (a library.json entry) and in Queue & History (a done
|
||||
work-log row carrying its source node) — without waiting for the manifest sweeper.
|
||||
Atomic per-org writes like every other metadata file; upserts by path so a
|
||||
re-render of the same name never duplicates the asset."""
|
||||
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
lib = _load_library(root)
|
||||
assets = lib.setdefault("assets", [])
|
||||
ex = next((a for a in assets if a.get("path") == rel), None)
|
||||
if ex is None:
|
||||
assets.append({"path": rel, "design": design, "kind": kind, "role": role,
|
||||
"status": "draft", "tags": [], "updatedAt": now_iso})
|
||||
else:
|
||||
ex.update(design=design, kind=kind, role=role, updatedAt=now_iso)
|
||||
_save_library(root, lib)
|
||||
# The worklog upserts by output too: re-ingesting identical bytes (an
|
||||
# interrupted mirror, a restored store) must repair a missing row, never
|
||||
# stack a second one.
|
||||
if not worklog.has_output(org, rel):
|
||||
worklog.record(org, kind="render", prompt="", refs=[], uploads=[], parents=[],
|
||||
output_prefix=rel, node=node, status="done",
|
||||
lane=("gpu" if node not in ("upload", "studio pod") else "local"),
|
||||
pid="up-" + uuid.uuid4().hex[:12])
|
||||
|
||||
|
||||
def _org_store(org: str, name: str) -> Path:
|
||||
"""A per-org metadata file beside worklog.json/library.json (favorites, stacks).
|
||||
The same tenant-scoped output tree every per-org file uses; created on demand so
|
||||
the store works before the org has a library.json."""
|
||||
d = Path(folder_paths.get_org_output_directory(org))
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / name
|
||||
|
||||
|
||||
def _load_store(p: Path, default):
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _save_store(p: Path, data) -> None:
|
||||
"""Atomic tmp+replace, the ONE write pattern for per-org metadata."""
|
||||
tmp = p.with_name(p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(data))
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def _norm_stacks(raw) -> list:
|
||||
"""Validate a client stack set: each stack is ``{id, name, items[], ts}`` where
|
||||
items are run ids / library paths (strings), de-duped and capped. A stack needs
|
||||
at least TWO members — a folder of one isn't a folder (it dissolves back to a
|
||||
card). The whole set is bounded so one org can't write an unbounded file."""
|
||||
out = []
|
||||
for s in (raw if isinstance(raw, list) else [])[:200]:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
sid = str(s.get("id") or "").strip()[:64]
|
||||
if not sid:
|
||||
continue
|
||||
seen: set = set()
|
||||
items = [i for i in (str(x)[:512] for x in (s.get("items") or []) if isinstance(x, str))
|
||||
if i and not (i in seen or seen.add(i))][:200]
|
||||
if len(items) < 2:
|
||||
continue
|
||||
out.append({"id": sid, "name": (str(s.get("name") or "").strip() or "Stack")[:80],
|
||||
"items": items, "ts": int(s.get("ts") or time.time())})
|
||||
return out
|
||||
|
||||
|
||||
def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
|
||||
@routes.get("/studio")
|
||||
async def studio_home(request: web.Request):
|
||||
# no-store: the SPA HTML changes every release; without this, browsers
|
||||
# (and any proxy) heuristically cache it and serve a STALE page after a
|
||||
# deploy — which is why UI fixes appeared "not working" to a user still
|
||||
# running yesterday's cached page. The page is tiny; always serve fresh.
|
||||
# Hashed assets keep their own immutable cache. Serve the studio-chat SPA
|
||||
# when built into the image; fall back to the legacy home otherwise.
|
||||
index = _WEB_DIST / "index.html"
|
||||
html = index.read_text() if index.is_file() else _HTML.read_text()
|
||||
return web.Response(text=html, content_type="text/html",
|
||||
# deploy — which is why UI fixes (e.g. delete) appeared "not working" to
|
||||
# a user still running yesterday's cached studio_home.html. The page is
|
||||
# tiny; always serve fresh. Static assets keep their own long cache.
|
||||
return web.Response(text=_HTML.read_text(), content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
|
||||
@routes.get("/studio/assets/{name}")
|
||||
async def studio_asset(request: web.Request):
|
||||
# The studio-chat SPA's hashed bundle (the hash IS the version → immutable).
|
||||
name = request.match_info["name"]
|
||||
assets = (_WEB_DIST / "assets").resolve()
|
||||
f = (assets / name).resolve()
|
||||
if f.parent != assets or not f.is_file():
|
||||
return web.Response(status=404)
|
||||
ctype = ("text/javascript" if name.endswith(".js")
|
||||
else "text/css" if name.endswith(".css")
|
||||
else "application/octet-stream")
|
||||
return web.Response(body=f.read_bytes(), content_type=ctype,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||
|
||||
@routes.get("/studio/shell.js")
|
||||
async def shell_js(request: web.Request):
|
||||
return _asset("shell.js", "text/javascript")
|
||||
|
||||
@routes.get("/studio/shell.css")
|
||||
async def shell_css(request: web.Request):
|
||||
return _asset("shell.css", "text/css")
|
||||
|
||||
@routes.get("/v1/workflow")
|
||||
async def get_workflow(request: web.Request):
|
||||
"""The graph that produced a work-log item, for 'Open in Editor'. Tenant-
|
||||
scoped: the id must be in THIS org's work log or it 404s (a cross-tenant id
|
||||
is never confirmed). The graph is read from the item's output PNG, which
|
||||
embeds the litegraph 'workflow' (UI) and/or the API 'prompt'."""
|
||||
org = _org_of(request)
|
||||
pid = request.query.get("id", "")
|
||||
row = next((r for r in worklog.load(org) if r.get("id") == pid), None)
|
||||
if row is None:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
root = _library_root(org)
|
||||
landed = _row_output(root, row.get("output_prefix"))
|
||||
p = _safe_asset(root, landed) if (root and landed) else None
|
||||
if p is None or p.suffix.lower() != ".png":
|
||||
return web.json_response({"error": "workflow not available yet"}, status=404)
|
||||
try:
|
||||
from PIL import Image
|
||||
info = Image.open(p).info
|
||||
except Exception:
|
||||
info = {}
|
||||
for key, fmt in (("workflow", "ui"), ("prompt", "api")):
|
||||
try:
|
||||
g = json.loads(info.get(key, "null"))
|
||||
except Exception:
|
||||
g = None
|
||||
if isinstance(g, dict) and g:
|
||||
return web.json_response({"id": pid, "format": fmt, "graph": g})
|
||||
return web.json_response({"error": "no embedded workflow in this output"}, status=422)
|
||||
|
||||
@routes.post("/v1/templates")
|
||||
async def save_template(request: web.Request):
|
||||
"""Save a workflow graph as an org template (orgs/<org>/templates/<slug>.json,
|
||||
atomic tmp+replace like the work log). Server-resolved org — a template is
|
||||
only ever written to, and read from, the caller's own tenant tree."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
name = (body.get("name") or "").strip()
|
||||
graph = body.get("graph")
|
||||
# A flow template captures a reusable generation FLOW (kind + instruction
|
||||
# pattern + references) rather than a node graph — what "favorite → turn into
|
||||
# a template" saves for a chat/run. Either a graph or a flow makes a template.
|
||||
flow = body.get("flow")
|
||||
slug = _template_slug(name)
|
||||
if not slug:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
has_graph = isinstance(graph, dict) and bool(graph)
|
||||
has_flow = isinstance(flow, dict) and bool(flow)
|
||||
if not has_graph and not has_flow:
|
||||
return web.json_response({"error": "graph or flow required"}, status=400)
|
||||
rec = {"name": name, "slug": slug, "ts": int(time.time())}
|
||||
if has_graph:
|
||||
rec["graph"] = graph
|
||||
if has_flow:
|
||||
rec["flow"] = flow
|
||||
d = _templates_root(_org_of(request))
|
||||
tmp = d / (slug + ".json.tmp")
|
||||
tmp.write_text(json.dumps(rec))
|
||||
tmp.replace(d / (slug + ".json"))
|
||||
return web.json_response({"ok": True, "name": name, "slug": slug})
|
||||
|
||||
@routes.get("/v1/templates")
|
||||
async def list_templates(request: web.Request):
|
||||
org = _org_of(request)
|
||||
out = []
|
||||
for f in sorted(_templates_root(org).glob("*.json")):
|
||||
try:
|
||||
rec = json.loads(f.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
out.append({"name": rec.get("name") or f.stem, "slug": rec.get("slug") or f.stem,
|
||||
"ts": rec.get("ts", 0),
|
||||
"renderable": _template_api_prompt(rec.get("graph")) is not None})
|
||||
return web.json_response({"org": org, "templates": out})
|
||||
|
||||
@routes.post("/v1/templates/render")
|
||||
async def render_template(request: web.Request):
|
||||
"""Queue a saved template through the ONE dispatch funnel (reseeded); its
|
||||
output lands in the library via the manifest sidecar — no new path. A
|
||||
UI-only graph can't run headless, so it's rejected with a clear reason."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
org = _org_of(request)
|
||||
slug = _template_slug(body.get("name", ""))
|
||||
f = _templates_root(org) / (slug + ".json")
|
||||
if not slug or not f.is_file():
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
try:
|
||||
rec = json.loads(f.read_text())
|
||||
except Exception:
|
||||
return web.json_response({"error": "corrupt template"}, status=422)
|
||||
prompt = _template_api_prompt(rec.get("graph"))
|
||||
if prompt is None:
|
||||
return web.json_response(
|
||||
{"error": "this template has no runnable graph — open it in the Editor to run"}, status=422)
|
||||
out_prefix = ""
|
||||
for n in prompt.values():
|
||||
if isinstance(n, dict) and n.get("class_type") == "SaveImage":
|
||||
out_prefix = n.get("inputs", {}).get("filename_prefix", "") or out_prefix
|
||||
out_prefix = out_prefix or f"templates/{slug}"
|
||||
try:
|
||||
pid = await _dispatch(request, server, org, _reseed(json.loads(json.dumps(prompt))),
|
||||
kind="template", prompt=rec.get("name", slug), refs=[], uploads=[],
|
||||
parents=[], output_prefix=out_prefix)
|
||||
except DispatchError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"ok": True, "prompt_id": pid, "slug": slug})
|
||||
|
||||
@routes.get("/v1/templates/{name}")
|
||||
async def get_template(request: web.Request):
|
||||
org = _org_of(request)
|
||||
slug = _template_slug(request.match_info.get("name", ""))
|
||||
f = _templates_root(org) / (slug + ".json")
|
||||
if not slug or not f.is_file():
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
try:
|
||||
return web.json_response(json.loads(f.read_text()))
|
||||
except Exception:
|
||||
return web.json_response({"error": "corrupt template"}, status=422)
|
||||
|
||||
@routes.get("/v1/wallet")
|
||||
async def get_wallet(request: web.Request):
|
||||
"""The org's wallet balance for the shell chip — a display-only proxy of the
|
||||
user's IAM token to the cloud finance balance. Returns {org, balance,
|
||||
currency}; top-up is a link to pay.hanzo.ai in the UI. No funds move here."""
|
||||
org = _org_of(request)
|
||||
return web.json_response(await _wallet(request, org))
|
||||
|
||||
@routes.post("/v1/agent")
|
||||
async def agent_bridge(request: web.Request):
|
||||
"""Same-origin bridge to the cloud agent orchestrator. The session cookie
|
||||
stays host-only (never widened to *.hanzo.ai); this forwards the caller's
|
||||
bearer server-side — the wallet pattern. Direct-to-gateway remains
|
||||
available to the SPA via VITE_AGENT_API."""
|
||||
from middleware.gpu_dispatch import CLOUD, _bearer
|
||||
tok = _bearer(request)
|
||||
if not tok:
|
||||
return web.json_response({"error": "auth required"}, status=401)
|
||||
body = await request.read()
|
||||
if len(body) > 1 << 20:
|
||||
return web.json_response({"error": "body too large"}, status=413)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=180)) as s:
|
||||
async with s.post(f"{CLOUD}/v1/agent", data=body,
|
||||
headers={"Authorization": tok,
|
||||
"Content-Type": "application/json"}) as r:
|
||||
return web.Response(status=r.status, body=await r.read(),
|
||||
content_type="application/json")
|
||||
except Exception:
|
||||
return web.json_response({"error": "agent unreachable"}, status=502)
|
||||
|
||||
@routes.get("/v1/library")
|
||||
async def get_library(request: web.Request):
|
||||
org = _org_of(request)
|
||||
@@ -1034,75 +660,6 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
pass # fall through to full-res on any thumbnail failure
|
||||
return web.FileResponse(p)
|
||||
|
||||
@routes.post("/v1/library/upload")
|
||||
async def library_upload(request: web.Request):
|
||||
"""Tenant-scoped render ingest so EVERY render lands in the library — including
|
||||
ones produced on a GPU node outside the job path (the node's mirror loop POSTs
|
||||
here). Body is multipart (field ``image``/``file``) or raw bytes + ``?name=``,
|
||||
with an optional ``?subpath=`` (default ``renders``). Writes
|
||||
orgs/<org>/output/<subpath>/<name> atomically (tmp+replace, same as the work
|
||||
log); rejects traversal (basename-only name + allowlisted subpath); caps size;
|
||||
and skips a byte-identical existing file (name+size match → existed). The
|
||||
manifest sidecar indexes output/ — no other wiring."""
|
||||
org = _org_of(request)
|
||||
sub = _safe_subpath(request.query.get("subpath", ""))
|
||||
if sub is None:
|
||||
return web.json_response({"error": "invalid subpath"}, status=400)
|
||||
clen = request.content_length
|
||||
if clen is not None and clen > _MAX_UPLOAD:
|
||||
return web.json_response({"error": "file too large"}, status=413)
|
||||
if request.headers.get("Content-Type", "").startswith("multipart/"):
|
||||
post = await request.post()
|
||||
f = post.get("image") or post.get("file")
|
||||
if f is None or not hasattr(f, "file"):
|
||||
return web.json_response({"error": "no image field"}, status=400)
|
||||
name = os.path.basename(f.filename or "")
|
||||
data = f.file.read()
|
||||
else:
|
||||
data = await request.read()
|
||||
name = os.path.basename(request.query.get("name", ""))
|
||||
if not name or os.path.splitext(name)[1].lower() not in _UPLOAD_EXT:
|
||||
return web.json_response({"error": "image name required (.png/.jpg/.jpeg/.webp)"}, status=400)
|
||||
if name.startswith("."):
|
||||
# `._foo.png` (AppleDouble fork) and other dotfiles pass the extension
|
||||
# check but are not renders.
|
||||
return web.json_response({"error": "hidden file"}, status=400)
|
||||
if not data:
|
||||
return web.json_response({"error": "empty body"}, status=400)
|
||||
if len(data) > _MAX_UPLOAD:
|
||||
return web.json_response({"error": "file too large"}, status=413)
|
||||
if not (data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
or data[:3] == b"\xff\xd8\xff"
|
||||
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")):
|
||||
# The name lies sometimes; the bytes never do.
|
||||
return web.json_response({"error": "not an image"}, status=400)
|
||||
out_root = Path(folder_paths.get_org_output_directory(org))
|
||||
dest = (out_root / sub / name).resolve()
|
||||
if not str(dest).startswith(str(out_root.resolve()) + os.sep):
|
||||
return web.json_response({"error": "invalid path"}, status=400)
|
||||
rel = f"{sub}/{name}"
|
||||
if dest.is_file() and dest.stat().st_size == len(data):
|
||||
# Identical bytes: skip the write but STILL index — files can exist
|
||||
# without rows (an interrupted mirror, a restored store), and the
|
||||
# index upsert is idempotent by path. Recovery depends on this.
|
||||
_index_upload(org, out_root, rel,
|
||||
design=request.query.get("design") or None,
|
||||
kind=request.query.get("kind") or "render",
|
||||
role=request.query.get("role") or None,
|
||||
node=request.query.get("node") or "upload")
|
||||
return web.json_response({"ok": True, "existed": True, "path": rel})
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.name + ".tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(dest)
|
||||
# index at ingest → instantly findable in Assets + Queue & History (with node)
|
||||
_index_upload(org, out_root, rel,
|
||||
design=request.query.get("design") or None,
|
||||
kind=request.query.get("kind") or "render",
|
||||
role=request.query.get("role") or None,
|
||||
node=request.query.get("node") or "upload")
|
||||
return web.json_response({"ok": True, "path": rel})
|
||||
|
||||
@routes.get("/v1/gpu-status")
|
||||
async def gpu_status(request: web.Request):
|
||||
"""Does the caller's org have an online GPU to render on? The UI showed
|
||||
@@ -1317,18 +874,14 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
out = []
|
||||
for r in reversed(rows):
|
||||
row = dict(r)
|
||||
landed = _row_output(root, r.get("output_prefix"))
|
||||
landed = _landed_output(root, r.get("output_prefix"))
|
||||
row["output"] = landed
|
||||
if landed and r.get("status") not in ("failed", "cancelled"):
|
||||
row["status"] = "done"
|
||||
out.append(row)
|
||||
q = (request.query.get("q") or "").lower().strip()
|
||||
if q:
|
||||
# search prompt + kind + the render's paths (output_prefix/output) + refs, so
|
||||
# an ingested render with an empty prompt is still findable by its filename.
|
||||
out = [r for r in out if q in " ".join(str(x) for x in
|
||||
[r.get("prompt", ""), r.get("kind", ""), r.get("output_prefix", ""),
|
||||
r.get("output", ""), *(r.get("refs") or [])]).lower()]
|
||||
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
|
||||
return web.json_response({"org": org, "items": out[:200]})
|
||||
|
||||
@routes.get("/v1/worklog/lineage")
|
||||
@@ -1403,66 +956,11 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
entry["stars"] = stars
|
||||
if "notes" in body:
|
||||
entry["notes"] = str(body.get("notes") or "")[:2000]
|
||||
if "path" in body:
|
||||
# The run's landed output, stored beside the rating so the flywheel row
|
||||
# carries {path, stars, note, ts} keyed by run id — the AI can load the
|
||||
# image it's being told to do differently next time.
|
||||
entry["path"] = str(body.get("path") or "")[:512]
|
||||
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})
|
||||
|
||||
# ── Favorites: the heart toggle. A per-org set of saved runs/flows, keyed by run
|
||||
# id (or output path) exactly like ratings — but a SEPARATE concern: a favorite is
|
||||
# the user's bookmark (→ "turn this into a template"), a rating is quality feedback
|
||||
# for the AI. One store each; a run can be either, both, or neither. ─────────────
|
||||
@routes.get("/v1/favorites")
|
||||
async def get_favorites(request: web.Request):
|
||||
org = _org_of(request)
|
||||
favs = _load_store(_org_store(org, "favorites.json"), {})
|
||||
return web.json_response({"org": org, "favorites": favs if isinstance(favs, dict) else {}})
|
||||
|
||||
@routes.post("/v1/favorites")
|
||||
async def set_favorite(request: web.Request):
|
||||
org = _org_of(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
key = (body.get("key") or "").strip()[:512]
|
||||
if not key:
|
||||
return web.json_response({"error": "key required"}, status=400)
|
||||
fav = bool(body.get("favorite", True))
|
||||
p = _org_store(org, "favorites.json")
|
||||
data = _load_store(p, {})
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
if fav:
|
||||
data[key] = {"ts": int(time.time())}
|
||||
else:
|
||||
data.pop(key, None)
|
||||
_save_store(p, data)
|
||||
return web.json_response({"ok": True, "key": key, "favorite": fav})
|
||||
|
||||
# ── Stacks: iOS-folder groupings of runs (drag-hold). The client owns the
|
||||
# arrangement and POSTs the whole normalized set; the server bounds + validates
|
||||
# it and drops empty stacks. Per-org file, so a stack is inherently tenant-owned.
|
||||
@routes.get("/v1/stacks")
|
||||
async def get_stacks(request: web.Request):
|
||||
org = _org_of(request)
|
||||
stacks = _load_store(_org_store(org, "stacks.json"), [])
|
||||
return web.json_response({"org": org, "stacks": stacks if isinstance(stacks, list) else []})
|
||||
|
||||
@routes.post("/v1/stacks")
|
||||
async def save_stacks(request: web.Request):
|
||||
org = _org_of(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||||
stacks = _norm_stacks(body.get("stacks"))
|
||||
_save_store(_org_store(org, "stacks.json"), stacks)
|
||||
return web.json_response({"org": org, "stacks": stacks})
|
||||
|
||||
@@ -59,13 +59,6 @@ def _save(org: str, rows: list[dict]) -> None:
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def has_output(org: str, output_prefix: str) -> bool:
|
||||
"""Whether any row already carries this landed output — the ingest upsert
|
||||
key (re-ingesting identical bytes must repair, never duplicate)."""
|
||||
return any(r.get("output_prefix") == output_prefix or r.get("output") == output_prefix
|
||||
for r in load(org))
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -93,11 +93,6 @@ def scan(root: str) -> list[str]:
|
||||
found: list[str] = []
|
||||
for base, _dirs, files in os.walk(root):
|
||||
for f in files:
|
||||
if f.startswith("."):
|
||||
# Dotfiles are never assets: `._foo.png` AppleDouble forks ride
|
||||
# along with mac transfers, carry an image extension, and are
|
||||
# resource-fork bytes that render 0x0 in the library.
|
||||
continue
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
full = os.path.join(base, f)
|
||||
rel = os.path.relpath(full, root)
|
||||
|
||||
@@ -435,11 +435,11 @@ class PromptServer():
|
||||
},
|
||||
)
|
||||
|
||||
# Serve the editor SPA with the shared Studio shell injected (the ONE
|
||||
# chrome — Studio/Editor toggle, wallet, GPUs, chat sidebar — that the
|
||||
# Studio home also includes). No-cache, same as before.
|
||||
from middleware.studio_home import editor_index
|
||||
return editor_index(self.web_root)
|
||||
response = web.FileResponse(os.path.join(self.web_root, "index.html"))
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
@routes.get("/embeddings")
|
||||
def get_embeddings(request):
|
||||
|
||||
@@ -340,345 +340,3 @@ async def test_home_redirect_only_hits_root_html(monkeypatch):
|
||||
req = make_mocked_request("GET", "/", headers={"Accept": "text/html"})
|
||||
resp = await sh.home_redirect(req, handler)
|
||||
assert resp.text == "spa"
|
||||
|
||||
|
||||
# ── Shared shell: editor index injection + templates + workflow + wallet ─────────
|
||||
async def _client(server, org_middleware=True):
|
||||
"""A real aiohttp app with the studio-home routes and a fake-auth middleware that
|
||||
stamps request['iam_user'] from an X-Org header — so route tests exercise the
|
||||
SAME server-side org scoping (_org_of) production uses."""
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
@web.middleware
|
||||
async def fake_auth(request, handler):
|
||||
o = request.headers.get("X-Org")
|
||||
if o:
|
||||
request["iam_user"] = {"org_id": o, "orgs": [o]}
|
||||
return await handler(request)
|
||||
|
||||
app = web.Application(middlewares=[fake_auth] if org_middleware else [])
|
||||
routes = web.RouteTableDef()
|
||||
sh.add_studio_home_routes(routes, server)
|
||||
app.add_routes(routes)
|
||||
ts = TestServer(app)
|
||||
await ts.start_server()
|
||||
client = TestClient(ts)
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
|
||||
def test_editor_index_injects_shell_once(tmp_path):
|
||||
root = tmp_path / "web"
|
||||
root.mkdir()
|
||||
(root / "index.html").write_text("<html><head></head><body><div id=app></div></body></html>")
|
||||
resp = sh.editor_index(str(root))
|
||||
assert resp.headers["Cache-Control"] == "no-cache"
|
||||
assert '<link rel="stylesheet" href="/studio/shell.css"></head>' in resp.text
|
||||
assert '<script src="/studio/shell.js" defer></script></body>' in resp.text
|
||||
# idempotent: an index that already carries the shell is served unchanged
|
||||
(root / "index.html").write_text(resp.text)
|
||||
again = sh.editor_index(str(root))
|
||||
assert again.text.count("/studio/shell.js") == 1
|
||||
|
||||
|
||||
def test_template_api_prompt_shapes():
|
||||
api = {"9": {"class_type": "KSampler", "inputs": {}}, "15": {"class_type": "SaveImage", "inputs": {}}}
|
||||
assert sh._template_api_prompt({"output": api, "workflow": {"nodes": []}}) == api # editor save
|
||||
assert sh._template_api_prompt(api) == api # bare API prompt
|
||||
assert sh._template_api_prompt({"nodes": [{"id": 1}], "last_node_id": 1}) is None # UI-only, not runnable
|
||||
assert sh._template_api_prompt({}) is None and sh._template_api_prompt("x") is None
|
||||
assert sh._template_slug("Karma — Ghost Mannequin!") == "karma-ghost-mannequin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_templates_crud_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
graph = {"output": {"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/x"}}}}
|
||||
r = await client.post("/v1/templates", json={"name": "Ghost Mannequin", "graph": graph}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json())["slug"] == "ghost-mannequin"
|
||||
# acme sees it (renderable — the graph carries an API prompt), fetches it by slug
|
||||
r = await client.get("/v1/templates", headers={"X-Org": "acme"})
|
||||
body = await r.json()
|
||||
assert body["org"] == "acme" and len(body["templates"]) == 1
|
||||
assert body["templates"][0]["name"] == "Ghost Mannequin" and body["templates"][0]["renderable"] is True
|
||||
r = await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "acme"})
|
||||
assert (await r.json())["graph"] == graph
|
||||
# tenant isolation: globex's list is empty and it can't read acme's template
|
||||
assert (await (await client.get("/v1/templates", headers={"X-Org": "globex"})).json())["templates"] == []
|
||||
assert (await client.get("/v1/templates/ghost-mannequin", headers={"X-Org": "globex"})).status == 404
|
||||
# validation
|
||||
assert (await client.post("/v1/templates", json={"name": "", "graph": graph}, headers={"X-Org": "acme"})).status == 400
|
||||
assert (await client.post("/v1/templates", json={"name": "x", "graph": {}}, headers={"X-Org": "acme"})).status == 400
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_render_dispatches_and_rejects_ui_only(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
async def fake_queue(request, server, graph):
|
||||
return "pid-tmpl"
|
||||
monkeypatch.setattr(sh, "_queue_prompt", fake_queue)
|
||||
client = await _client(_FakeServer())
|
||||
runnable = {"output": {"9": {"class_type": "KSampler", "inputs": {"seed": 1}},
|
||||
"15": {"class_type": "SaveImage", "inputs": {"filename_prefix": "t/run"}}}}
|
||||
await client.post("/v1/templates", json={"name": "Run Me", "graph": runnable}, headers={"X-Org": "acme"})
|
||||
r = await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json())["prompt_id"] == "pid-tmpl"
|
||||
assert sh.worklog.load("acme")[-1]["kind"] == "template" # went through the ONE funnel
|
||||
# UI-only graph can't run headless -> 422 with a reason, not a silent dispatch
|
||||
await client.post("/v1/templates", json={"name": "UI Only", "graph": {"nodes": [], "last_node_id": 0}}, headers={"X-Org": "acme"})
|
||||
assert (await client.post("/v1/templates/render", json={"name": "ui-only"}, headers={"X-Org": "acme"})).status == 422
|
||||
# cross-tenant render: globex has no such template -> 404
|
||||
assert (await client.post("/v1/templates/render", json={"name": "run-me"}, headers={"X-Org": "globex"})).status == 404
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_reads_embedded_graph_tenant_scoped(tmp_path, monkeypatch):
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
root = Path(sh.folder_paths.get_org_output_directory("acme"))
|
||||
(root / "library.json").write_text('{"assets":[]}')
|
||||
(root / "fixes").mkdir(parents=True, exist_ok=True)
|
||||
info = PngInfo()
|
||||
ui = {"nodes": [{"id": 1, "type": "SaveImage"}], "last_node_id": 1}
|
||||
info.add_text("workflow", json.dumps(ui))
|
||||
info.add_text("prompt", json.dumps({"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "fixes/x"}}}))
|
||||
Image.new("RGB", (4, 4)).save(root / "fixes" / "x_00001_.png", "PNG", pnginfo=info)
|
||||
sh.worklog.record("acme", kind="fix", prompt="x", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/x", pid="p1")
|
||||
sh.worklog.record("acme", kind="fix", prompt="pending", refs=[], uploads=[], parents=[],
|
||||
output_prefix="fixes/nope", pid="p2")
|
||||
client = await _client(_FakeServer())
|
||||
r = await client.get("/v1/workflow?id=p1", headers={"X-Org": "acme"})
|
||||
body = await r.json()
|
||||
assert r.status == 200 and body["format"] == "ui" and body["graph"] == ui # UI graph preferred
|
||||
# not landed yet -> 404 (no graph to open)
|
||||
assert (await client.get("/v1/workflow?id=p2", headers={"X-Org": "acme"})).status == 404
|
||||
# cross-tenant id is never confirmed -> 404
|
||||
assert (await client.get("/v1/workflow?id=p1", headers={"X-Org": "globex"})).status == 404
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_proxies_and_scopes_org(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
async def fake_balance(tok):
|
||||
assert tok == "Bearer tkn"
|
||||
return {"availableCents": 2500, "currency": "usd", "pendingCents": 0}
|
||||
monkeypatch.setattr(sh, "_finance_balance", fake_balance)
|
||||
client = await _client(_FakeServer())
|
||||
r = await client.get("/v1/wallet", headers={"X-Org": "acme", "Authorization": "Bearer tkn"})
|
||||
body = await r.json()
|
||||
assert body == {"org": "acme", "balance": 25.0, "currency": "usd"}
|
||||
# signed-out (no bearer): display-only null balance, never a fabricated number
|
||||
r = await client.get("/v1/wallet", headers={"X-Org": "acme"})
|
||||
assert await r.json() == {"org": "acme", "balance": None, "currency": None}
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_upload_roundtrip_dedup_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
import aiohttp
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
# library.json so _library_root resolves for the GET round-trip
|
||||
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
|
||||
client = await _client(_FakeServer())
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"acme-bytes"
|
||||
# multipart upload as acme -> renders/r1.png
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("image", png, filename="r1.png", content_type="image/png")
|
||||
r = await client.post("/v1/library/upload", data=form, headers={"X-Org": "acme"})
|
||||
body = await r.json()
|
||||
assert r.status == 200 and body["ok"] and body["path"] == "renders/r1.png" and not body.get("existed")
|
||||
# round-trip through the org-scoped file server
|
||||
g = await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})
|
||||
assert g.status == 200 and (await g.read()) == png
|
||||
# dedup: identical name+size -> existed, no rewrite
|
||||
form2 = aiohttp.FormData()
|
||||
form2.add_field("image", png, filename="r1.png", content_type="image/png")
|
||||
r2 = await client.post("/v1/library/upload", data=form2, headers={"X-Org": "acme"})
|
||||
assert (await r2.json()) == {"ok": True, "existed": True, "path": "renders/r1.png"}
|
||||
# tenant isolation: org B never sees org A's file; its own upload is a separate tree
|
||||
assert (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "globex"})).status == 404
|
||||
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNG\r\n\x1a\nglobex",
|
||||
headers={"X-Org": "globex", "Content-Type": "application/octet-stream"})
|
||||
assert (await rg.json())["path"] == "renders/r1.png"
|
||||
# org A's bytes are untouched by org B's same-named upload
|
||||
assert (await (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "acme"})).read()) == png
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
hdr = {"X-Org": "acme", "Content-Type": "application/octet-stream"}
|
||||
# subpath traversal -> 400
|
||||
assert (await client.post("/v1/library/upload?name=x.png&subpath=../secret", data=b"\x89PNGx", headers=hdr)).status == 400
|
||||
# non-image name -> 400
|
||||
assert (await client.post("/v1/library/upload?name=notes.txt", data=b"hi", headers=hdr)).status == 400
|
||||
# a traversal-y NAME is neutralized to its basename, lands safely under renders/
|
||||
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNG\r\n\x1a\nevil", headers=hdr)
|
||||
assert (await r.json())["path"] == "renders/evil.png"
|
||||
assert (Path(sh.folder_paths.get_org_output_directory("acme")) / "renders" / "evil.png").is_file()
|
||||
# size cap -> 413
|
||||
monkeypatch.setattr(sh, "_MAX_UPLOAD", 8)
|
||||
assert (await client.post("/v1/library/upload?name=big.png", data=b"0123456789", headers=hdr)).status == 413
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypatch):
|
||||
import aiohttp
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"render-bytes"
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("image", png, filename="shot.png", content_type="image/png")
|
||||
r = await client.post("/v1/library/upload?node=spark&design=karma&kind=render",
|
||||
data=form, headers={"X-Org": "acme"})
|
||||
assert (await r.json())["path"] == "renders/shot.png"
|
||||
# instantly findable in Assets (library.json entry, draft)
|
||||
lib = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
|
||||
a = next(x for x in lib["assets"] if x["path"] == "renders/shot.png")
|
||||
assert a["status"] == "draft" and a["kind"] == "render" and a["design"] == "karma" and a["tags"] == []
|
||||
# instantly findable in Queue & History with its source node + resolved output
|
||||
wl = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
|
||||
it = next(x for x in wl["items"] if x["output_prefix"] == "renders/shot.png")
|
||||
assert it["kind"] == "render" and it["node"] == "spark" and it["status"] == "done"
|
||||
assert it["output"] == "renders/shot.png" and it["lane"] == "gpu"
|
||||
# tenant isolation: org B sees neither the asset nor the row
|
||||
lib2 = await (await client.get("/v1/library", headers={"X-Org": "globex"})).json()
|
||||
assert all(x["path"] != "renders/shot.png" for x in lib2.get("assets", []))
|
||||
assert (await (await client.get("/v1/worklog", headers={"X-Org": "globex"})).json())["items"] == []
|
||||
# dedup: re-upload identical bytes adds no second asset or row
|
||||
form2 = aiohttp.FormData()
|
||||
form2.add_field("image", png, filename="shot.png", content_type="image/png")
|
||||
r2 = await client.post("/v1/library/upload?node=spark", data=form2, headers={"X-Org": "acme"})
|
||||
assert (await r2.json()).get("existed") is True
|
||||
lib3 = await (await client.get("/v1/library", headers={"X-Org": "acme"})).json()
|
||||
assert sum(1 for x in lib3["assets"] if x["path"] == "renders/shot.png") == 1
|
||||
wl3 = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
|
||||
assert sum(1 for x in wl3["items"] if x["output_prefix"] == "renders/shot.png") == 1
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
# an ingested render: empty prompt, findable only by its filename / ref
|
||||
sh.worklog.record("acme", kind="render", prompt="", refs=["srcphoto.png"], uploads=[],
|
||||
parents=[], output_prefix="renders/model_shot.png", pid="r1", status="done")
|
||||
sh.worklog.record("acme", kind="fix", prompt="brighten the sky", refs=[], uploads=[],
|
||||
parents=[], output_prefix="fixes/x", pid="r2")
|
||||
client = await _client(_FakeServer())
|
||||
|
||||
async def ids(q):
|
||||
r = await client.get("/v1/worklog?q=" + q, headers={"X-Org": "acme"})
|
||||
return [it["id"] for it in (await r.json())["items"]]
|
||||
|
||||
assert await ids("model_shot") == ["r1"] # by output filename, empty prompt
|
||||
assert await ids("srcphoto") == ["r1"] # by reference
|
||||
assert await ids("brighten") == ["r2"] # prompt search still works
|
||||
assert await ids("nomatch") == []
|
||||
await client.close()
|
||||
|
||||
|
||||
# ── Queue/History surface: favorites, stacks, ratings-with-notes, flow templates ──
|
||||
@pytest.mark.asyncio
|
||||
async def test_favorites_toggle_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
# empty to start
|
||||
assert (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json()) == {"org": "acme", "favorites": {}}
|
||||
# favorite a run (key = run id) — echoes back, stamps a ts
|
||||
r = await client.post("/v1/favorites", json={"key": "run-1", "favorite": True}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json()) == {"ok": True, "key": "run-1", "favorite": True}
|
||||
favs = (await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]
|
||||
assert "run-1" in favs and isinstance(favs["run-1"]["ts"], int)
|
||||
# favorite defaults to True when omitted
|
||||
await client.post("/v1/favorites", json={"key": "run-2"}, headers={"X-Org": "acme"})
|
||||
assert set((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == {"run-1", "run-2"}
|
||||
# un-favorite removes it (the heart toggles both ways)
|
||||
await client.post("/v1/favorites", json={"key": "run-1", "favorite": False}, headers={"X-Org": "acme"})
|
||||
assert list((await (await client.get("/v1/favorites", headers={"X-Org": "acme"})).json())["favorites"]) == ["run-2"]
|
||||
# validation: empty key -> 400
|
||||
assert (await client.post("/v1/favorites", json={"key": ""}, headers={"X-Org": "acme"})).status == 400
|
||||
# tenant isolation: globex sees none of acme's favorites
|
||||
assert (await (await client.get("/v1/favorites", headers={"X-Org": "globex"})).json())["favorites"] == {}
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stacks_save_normalizes_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
assert (await (await client.get("/v1/stacks", headers={"X-Org": "acme"})).json()) == {"org": "acme", "stacks": []}
|
||||
payload = {"stacks": [
|
||||
{"id": "s1", "name": "Karma", "items": ["run-a", "run-b", "run-b", "run-a"], "ts": 100}, # items de-duped
|
||||
{"id": "s2", "items": ["only-one"]}, # < 2 members -> dropped (not a folder)
|
||||
{"id": "", "items": ["x", "y"]}, # no id -> dropped
|
||||
{"name": "bad"}, # no id -> dropped
|
||||
]}
|
||||
stacks = (await (await client.post("/v1/stacks", json=payload, headers={"X-Org": "acme"})).json())["stacks"]
|
||||
assert stacks == [{"id": "s1", "name": "Karma", "items": ["run-a", "run-b"], "ts": 100}]
|
||||
# persisted for the org (whole-set replace is the ONE write)
|
||||
assert (await (await client.get("/v1/stacks", headers={"X-Org": "acme"})).json())["stacks"] == stacks
|
||||
# name defaults when omitted
|
||||
r2 = await client.post("/v1/stacks", json={"stacks": [{"id": "s3", "items": ["run-c", "run-d"]}]}, headers={"X-Org": "acme"})
|
||||
assert (await r2.json())["stacks"][0]["name"] == "Stack"
|
||||
# tenant isolation: globex has its own (empty) set, untouched by acme's writes
|
||||
assert (await (await client.get("/v1/stacks", headers={"X-Org": "globex"})).json())["stacks"] == []
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_stars_notes_path_and_tenant_isolation(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
# rate resolves via _library_root -> the org needs a library.json
|
||||
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
|
||||
(Path(sh.folder_paths.get_org_output_directory("globex")) / "library.json").write_text('{"assets":[]}')
|
||||
client = await _client(_FakeServer())
|
||||
# 0-5 stars + notes + the run's output path, keyed by run id (the flywheel row)
|
||||
r = await client.post("/v1/library/rate", json={
|
||||
"key": "run-1", "stars": 4, "notes": "hands look off", "path": "fixes/x_00001_.png"},
|
||||
headers={"X-Org": "acme"})
|
||||
assert r.status == 200
|
||||
entry = (await r.json())["entry"]
|
||||
assert entry["stars"] == 4 and entry["notes"] == "hands look off" and entry["path"] == "fixes/x_00001_.png"
|
||||
# visible on reopen; a note-only update keeps the stars (partial update)
|
||||
await client.post("/v1/library/rate", json={"key": "run-1", "notes": "better now"}, headers={"X-Org": "acme"})
|
||||
ratings = await (await client.get("/v1/library/ratings", headers={"X-Org": "acme"})).json()
|
||||
assert ratings["run-1"]["stars"] == 4 and ratings["run-1"]["notes"] == "better now"
|
||||
# a 0-star rating is valid (0-5 inclusive)
|
||||
assert (await client.post("/v1/library/rate", json={"key": "run-1", "stars": 0}, headers={"X-Org": "acme"})).status == 200
|
||||
# validation: out of range -> 400; missing key -> 400
|
||||
assert (await client.post("/v1/library/rate", json={"key": "r", "stars": 6}, headers={"X-Org": "acme"})).status == 400
|
||||
assert (await client.post("/v1/library/rate", json={"key": "", "stars": 3}, headers={"X-Org": "acme"})).status == 400
|
||||
# tenant isolation: globex never sees acme's rating
|
||||
assert (await (await client.get("/v1/library/ratings", headers={"X-Org": "globex"})).json()) == {}
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_accepts_flow_payload_not_just_graph(tmp_path, monkeypatch):
|
||||
_orgaware(tmp_path, monkeypatch)
|
||||
client = await _client(_FakeServer())
|
||||
# a flow template (no node graph) — what "favorite -> turn into a template" saves
|
||||
flow = {"kind": "fix", "prompt": "make the straps thicker", "refs": ["a.png"], "parents": ["a.png"]}
|
||||
r = await client.post("/v1/templates", json={"name": "Thicken Straps", "flow": flow}, headers={"X-Org": "acme"})
|
||||
assert r.status == 200 and (await r.json())["slug"] == "thicken-straps"
|
||||
# lists (not renderable — no runnable graph) and round-trips the captured flow
|
||||
lst = (await (await client.get("/v1/templates", headers={"X-Org": "acme"})).json())["templates"]
|
||||
assert lst[0]["name"] == "Thicken Straps" and lst[0]["renderable"] is False
|
||||
got = await (await client.get("/v1/templates/thicken-straps", headers={"X-Org": "acme"})).json()
|
||||
assert got["flow"] == flow and "graph" not in got
|
||||
# neither graph nor flow -> 400 (a template must capture something)
|
||||
assert (await client.post("/v1/templates", json={"name": "empty"}, headers={"X-Org": "acme"})).status == 400
|
||||
await client.close()
|
||||
|
||||
Vendored
+44
-76
@@ -1,46 +1,42 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { chat, getSession, gpuOnline, type ChatMessage, type Session } from './api'
|
||||
import { Rail } from './rail'
|
||||
import { useLayout, startDrag, clamp, DEFAULT, RAIL_MIN, RAIL_MAX } from './layout'
|
||||
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/agent "create" preset drives studio's render tools (fix/compose) and the runs
|
||||
// land in the queue/history rail on the right — Up next while they render, History
|
||||
// once done. The main area is the conversation / current generation; the rail is the
|
||||
// whole YouTube-watch-page-style queue + history + stacks + ratings surface.
|
||||
|
||||
const EXAMPLES = [
|
||||
'make the Valentina back crotchless',
|
||||
'put design 13 on model 01',
|
||||
'brighten the studio lighting',
|
||||
'compose design 4 and design 9 into one look',
|
||||
]
|
||||
// /v1/chat "create" capability drives studio's render tools (fix/compose) and the
|
||||
// outputs land in the library strip. Increment 1 uses a minimal own-UI; the next
|
||||
// increment swaps the transcript/composer for @hanzo/chat's <Chat> on @hanzo/gui.
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<Session>({})
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [conversationId, setConversationId] = useState<string>()
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([])
|
||||
const [gpu, setGpu] = useState(false)
|
||||
const [acct, setAcct] = useState(false)
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
const composer = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Layout is keyed by org+user so each person's dividers/collapse/filter persist.
|
||||
const who = session.authenticated !== undefined ? `${session.org || 'default'}:${session.user || 'anon'}` : ''
|
||||
const { layout, patch, toggle, reset, beginDrag, endDrag } = useLayout(who)
|
||||
|
||||
useEffect(() => { getSession().then(setSession) }, [])
|
||||
|
||||
// Poll the library + in-flight queue so dispatched renders appear as they land.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const tick = () => gpuOnline().then(on => { if (alive) setGpu(on) })
|
||||
tick(); const t = setInterval(tick, 15000)
|
||||
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])
|
||||
|
||||
const ready = !!input.trim() && !busy
|
||||
useEffect(() => { scroller.current?.scrollTo(0, scroller.current.scrollHeight) }, [messages, busy])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
@@ -48,10 +44,9 @@ export function App() {
|
||||
const next = [...messages, { role: 'user' as const, content: text }]
|
||||
setMessages(next); setInput(''); setBusy(true)
|
||||
try {
|
||||
const r = await chat(next, conversationId)
|
||||
setConversationId(r.conversationId)
|
||||
const r = await chat(next)
|
||||
const note = r.actions.length
|
||||
? `\n\n${r.actions.map(a => a.error ? `⚠︎ ${a.name}: ${a.error}` : `▸ ${a.name} dispatched — see Up next →`).join('\n')}`
|
||||
? `\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) {
|
||||
@@ -61,47 +56,13 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function insertRef(text: string) {
|
||||
setInput(v => (v.trim() ? `${v.trim()} ${text}` : text) + ' ')
|
||||
composer.current?.focus()
|
||||
}
|
||||
function useExample(ex: string) {
|
||||
setInput(ex); composer.current?.focus()
|
||||
}
|
||||
|
||||
function onVSplit(e: React.PointerEvent) {
|
||||
beginDrag()
|
||||
const startW = layout.railW
|
||||
startDrag(e, 'x', d => patch({ railW: clamp(startW - d, RAIL_MIN, RAIL_MAX) }), endDrag)
|
||||
}
|
||||
|
||||
const initials = (session.user || session.org || session.email || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'
|
||||
|
||||
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="acct-wrap">
|
||||
<button className="avatar" aria-label="account" aria-haspopup="menu"
|
||||
title={session.email || session.user || 'account'} onClick={() => setAcct(a => !a)}>
|
||||
{initials}
|
||||
</button>
|
||||
{acct && (
|
||||
<div className="rc-menu acct-menu" role="menu" onMouseLeave={() => setAcct(false)}>
|
||||
<div className="acct-id">
|
||||
<div className="acct-name">{session.user || (session.authenticated ? 'signed in' : 'guest')}</div>
|
||||
{session.email && <div className="acct-sub">{session.email}</div>}
|
||||
{session.org && <div className="acct-sub">org · {session.org}</div>}
|
||||
</div>
|
||||
<a role="menuitem" href="https://console.hanzo.ai" target="_blank" rel="noreferrer">Console</a>
|
||||
{session.authenticated
|
||||
? <a role="menuitem" href="/logout">Sign out</a>
|
||||
: <a role="menuitem" href={session.iam_url || '/login'}>Sign in with Hanzo</a>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="who">{session.authenticated ? (session.org || session.user || 'signed in') : 'sign in'}</div>
|
||||
</header>
|
||||
|
||||
<main className="body">
|
||||
@@ -110,11 +71,8 @@ export function App() {
|
||||
{messages.length === 0 && (
|
||||
<div className="hero">
|
||||
<h1>What should we create?</h1>
|
||||
<p>Describe an edit or a new shot. Runs appear in <b>Up next</b> on the right as they render,
|
||||
and finished results land in <b>History</b> — rate them to teach the next pass.</p>
|
||||
<div className="examples">
|
||||
{EXAMPLES.map(ex => <button key={ex} className="ex-chip" onClick={() => useExample(ex)}>{ex}</button>)}
|
||||
</div>
|
||||
<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) => (
|
||||
@@ -124,20 +82,30 @@ export function App() {
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
ref={composer} value={input} placeholder="Describe what to create or change…"
|
||||
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 className={`send${ready ? ' ready' : ''}`} onClick={send} disabled={busy}>Send</button>
|
||||
<button onClick={send} disabled={busy || !input.trim()}>Send</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="vsplit" onPointerDown={onVSplit} onDoubleClick={() => patch({ railW: DEFAULT.railW })}
|
||||
title="drag to resize · double-click to reset" role="separator" aria-orientation="vertical" />
|
||||
|
||||
<aside className="rail" style={{ width: layout.railW }}>
|
||||
<Rail layout={layout} patch={patch} toggle={toggle} reset={reset}
|
||||
beginDrag={beginDrag} endDrag={endDrag} onUseRef={insertRef} />
|
||||
<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
+23
-145
@@ -1,34 +1,20 @@
|
||||
// The studio-chat data layer. Two backends, no proxy layer between them:
|
||||
// - the agent orchestrator: POST api.hanzo.ai/v1/agent (github.com/hanzoai/agent,
|
||||
// mounted in the cloud binary) — called DIRECTLY, no studio pass-through. The
|
||||
// hanzo_token cookie is scoped to .hanzo.ai so credentials:'include' carries it
|
||||
// cross-origin, and the round replays it into the per-org-billed completion.
|
||||
// (Gateway CORS must allow the studio.hanzo.ai origin with credentials.)
|
||||
// - the studio engine API: /v1/session, /v1/worklog, /v1/queue/*, /v1/favorites,
|
||||
// /v1/stacks, … — same-origin on studio.hanzo.ai (same cookie).
|
||||
// - the cloud chat orchestrator: POST api.hanzo.ai/v1/chat (clients/chat) —
|
||||
// called DIRECTLY, no studio pass-through. The hanzo_token cookie is scoped to
|
||||
// .hanzo.ai so credentials:'include' carries it cross-origin, and the handler
|
||||
// replays it into the per-org-billed completion. (Gateway CORS must allow the
|
||||
// studio.hanzo.ai origin with credentials.)
|
||||
// - the studio engine API: /v1/session, /v1/library, /v1/render-queue, … —
|
||||
// same-origin on studio.hanzo.ai (same cookie).
|
||||
// No token handling in the browser: the cookie is the credential for both.
|
||||
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
|
||||
// VITE_STUDIO_API / VITE_CHAT_API override the bases for local dev.
|
||||
|
||||
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
|
||||
// Default: same-origin through the studio's /v1/agent bridge — the host-only
|
||||
// session cookie authorizes it and the middleware forwards the bearer, so no
|
||||
// parent-domain cookie and no gateway CORS dependency. VITE_AGENT_API points
|
||||
// straight at the gateway for direct mode.
|
||||
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? '').replace(/\/$/, '')
|
||||
const CHAT_API = (import.meta.env.VITE_CHAT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
||||
|
||||
async function j<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(url, { credentials: 'include', ...init })
|
||||
if (!r.ok) {
|
||||
// Surface the server's own message — a 402 says "add credits at
|
||||
// pay.hanzo.ai", which beats a bare status code.
|
||||
let msg = `${url} -> ${r.status}`
|
||||
try {
|
||||
const b = await r.json()
|
||||
const m = b?.error?.message ?? b?.error ?? b?.message
|
||||
if (typeof m === 'string' && m) msg = m
|
||||
} catch { /* keep the status line */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -36,16 +22,15 @@ export type Role = 'user' | 'assistant'
|
||||
export interface ChatMessage { role: Role; content: string }
|
||||
export interface Action { name: string; args?: Record<string, unknown>; result?: unknown; error?: string }
|
||||
export interface Op { name: string; args?: Record<string, unknown> }
|
||||
export interface ChatReply { reply: string; actions: Action[]; ops: Op[]; conversationId: string }
|
||||
export interface ChatReply { reply: string; actions: Action[]; ops: Op[] }
|
||||
|
||||
// One turn of a preset: the model edits/combines library assets; `actions` are the
|
||||
// renders it dispatched (each result carries a prompt_id). Pass the prior reply's
|
||||
// conversationId to continue a thread — the orchestrator persists per-org history.
|
||||
export function chat(messages: ChatMessage[], conversationId?: string, preset = 'create'): Promise<ChatReply> {
|
||||
return j<ChatReply>(`${AGENT_API}/v1/agent`, {
|
||||
// One turn of the create capability: the model edits/combines library assets;
|
||||
// `actions` are the renders it dispatched (each result carries a prompt_id).
|
||||
export function chat(messages: ChatMessage[], capability = 'create'): Promise<ChatReply> {
|
||||
return j<ChatReply>(`${CHAT_API}/v1/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preset, messages, conversationId }),
|
||||
body: JSON.stringify({ capability, messages }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,121 +39,14 @@ export interface Session {
|
||||
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)
|
||||
|
||||
// A run in a flow: one dispatched generation (fix/compose/rerun/render/template) with
|
||||
// its instruction, references, parents (the assets it derived from), landed output and
|
||||
// real status. The queue/history backbone — GET /v1/worklog, newest first.
|
||||
export type RunStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'
|
||||
export interface Run {
|
||||
id: string; ts: number; kind: string; prompt: string
|
||||
refs: string[]; uploads: string[]; parents: string[]
|
||||
output_prefix: string; output: string | null
|
||||
lane: string; node: string; status: RunStatus
|
||||
started_at?: number; finished_at?: number; error?: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
export const getWorklog = (q?: string) =>
|
||||
j<{ org: string; items: Run[] }>(`${BASE}/v1/worklog${q ? `?q=${encodeURIComponent(q)}` : ''}`)
|
||||
.catch(() => ({ org: '', items: [] as Run[] }))
|
||||
|
||||
// Live position + ETA overlay for the ACTIVE runs, per lane, from the org's own
|
||||
// measured medians. Joined onto the worklog rows by id.
|
||||
export interface QueueItem {
|
||||
id: string; kind: string; prompt: string; status: string; node: string
|
||||
lane?: string; position?: number; of?: number
|
||||
eta_seconds?: number; wait_seconds?: number; elapsed_seconds?: number; remaining_seconds?: number | null
|
||||
}
|
||||
export interface QueueStatus { items: Record<string, QueueItem>; medians: Record<string, number>; lanes: Record<string, number>; now: number }
|
||||
export const getQueueStatus = () =>
|
||||
j<QueueStatus>(`${BASE}/v1/queue/status`).catch(() => ({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) }))
|
||||
|
||||
// The version chain of one output — ancestors (what it derived from) + descendants
|
||||
// (later iterations). The iteration history of a design flow.
|
||||
export interface LineageStep { path: string; prompt: string; kind: string; ts: number }
|
||||
export interface Lineage { ancestors: LineageStep[]; node: string; descendants: LineageStep[] }
|
||||
export const getLineage = (path: string) =>
|
||||
j<Lineage>(`${BASE}/v1/worklog/lineage?path=${encodeURIComponent(path)}`)
|
||||
.catch(() => ({ ancestors: [], node: path, descendants: [] }))
|
||||
|
||||
// Delete a queued run (tenant-scoped cancel; a foreign id 404s).
|
||||
export const cancelRun = (id: string) =>
|
||||
j<{ ok: boolean; canceled: string }>(`${BASE}/v1/queue/cancel`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt_id: id }),
|
||||
})
|
||||
|
||||
// Amend a still-queued run — the ONE way to change it (atomic cancel + re-dispatch
|
||||
// with the same instruction plus added references). Adding references IS an amend.
|
||||
export const amendRun = (id: string, instruction: string, uploads: string[], paths: string[] = []) =>
|
||||
j<{ ok: boolean; prompt_id: string; amended_from: string; refs: number }>(`${BASE}/v1/queue/amend`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt_id: id, instruction, uploads, paths }),
|
||||
})
|
||||
|
||||
// Stage a reference image in the org input dir (the engine's upload route); returns
|
||||
// the flat name amend/compose reference by.
|
||||
export const uploadRef = (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file, file.name)
|
||||
return j<{ name: string; subfolder: string; type: string }>(`${BASE}/upload/image`, { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
// Ratings feed the AI flywheel: 0-5 stars + a note, keyed by run id, carrying the
|
||||
// output path. Persisted per-org; visible on reopen.
|
||||
export interface Rating { stars?: number; notes?: string; path?: string; updatedAt?: number }
|
||||
export const getRatings = () => j<Record<string, Rating>>(`${BASE}/v1/library/ratings`).catch(() => ({}))
|
||||
export const rate = (key: string, patch: { stars?: number; notes?: string; path?: string }) =>
|
||||
j<{ ok: boolean; key: string; entry: Rating }>(`${BASE}/v1/library/rate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, ...patch }),
|
||||
})
|
||||
|
||||
// Soft-delete a finished result — the library status lifecycle ('deleted'), the same
|
||||
// mechanism the assets view uses. Keyed by the run's output path.
|
||||
export const softDelete = (path: string) =>
|
||||
j<{ ok: boolean; path: string; status: string }>(`${BASE}/v1/library/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, status: 'deleted' }),
|
||||
})
|
||||
// The org's soft-deleted asset paths — so History hides results deleted here or in the
|
||||
// assets view, and the delete persists across reloads.
|
||||
export const getDeletedPaths = () =>
|
||||
j<{ assets: { path: string; status?: string }[] }>(`${BASE}/v1/library`)
|
||||
.then(r => (r.assets || []).filter(a => a.status === 'deleted').map(a => a.path))
|
||||
.catch(() => [] as string[])
|
||||
|
||||
// Favorites: the heart toggle, a per-org set keyed by run id. Separate concern from
|
||||
// ratings — a bookmark, not quality feedback.
|
||||
export const getFavorites = () =>
|
||||
j<{ org: string; favorites: Record<string, { ts: number }> }>(`${BASE}/v1/favorites`)
|
||||
.then(r => r.favorites).catch(() => ({} as Record<string, { ts: number }>))
|
||||
export const setFavorite = (key: string, favorite: boolean) =>
|
||||
j<{ ok: boolean; key: string; favorite: boolean }>(`${BASE}/v1/favorites`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, favorite }),
|
||||
})
|
||||
|
||||
// Stacks: iOS-folder groupings of runs. The client owns the arrangement; the whole
|
||||
// normalized set is POSTed back and echoed.
|
||||
export interface Stack { id: string; name: string; items: string[]; ts: number }
|
||||
export const getStacks = () =>
|
||||
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`).then(r => r.stacks).catch(() => [] as Stack[])
|
||||
export const saveStacks = (stacks: Stack[]) =>
|
||||
j<{ org: string; stacks: Stack[] }>(`${BASE}/v1/stacks`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stacks }),
|
||||
}).then(r => r.stacks)
|
||||
|
||||
// Favorite → template: capture a reusable FLOW (kind + instruction + refs + parents).
|
||||
export interface Flow { kind: string; prompt: string; refs: string[]; parents: string[] }
|
||||
export const saveTemplate = (name: string, flow: Flow) =>
|
||||
j<{ ok: boolean; name: string; slug: string }>(`${BASE}/v1/templates`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, flow }),
|
||||
})
|
||||
|
||||
// Thumbnails: a landed library output, a staged input reference, and the full-res file.
|
||||
// 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 inputThumbURL = (name: string) => `${BASE}/v1/library/file?input=1&thumb=1&path=${encodeURIComponent(name)}`
|
||||
export const fileURL = (path: string) => `${BASE}/v1/library/file?path=${encodeURIComponent(path)}`
|
||||
|
||||
Vendored
-219
@@ -1,219 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
type Run, type QueueItem, type Rating,
|
||||
thumbURL, inputThumbURL, fileURL,
|
||||
} from './api'
|
||||
|
||||
// ── Small shared bits ────────────────────────────────────────────────────────────
|
||||
export function relTime(ts: number, now: number): string {
|
||||
const s = Math.max(0, now - ts)
|
||||
if (s < 45) return 'just now'
|
||||
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h ago`
|
||||
return `${Math.round(s / 86400)}d ago`
|
||||
}
|
||||
export function fmtDur(sec: number): string {
|
||||
if (sec < 60) return `${Math.round(sec)}s`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}m`
|
||||
return `${(sec / 3600).toFixed(1)}h`
|
||||
}
|
||||
// A friendly identifier for a run, from its output/prefix (the finding: cards must
|
||||
// carry names like the "design 13" the prompts teach). Strips the _NNNNN_ counter.
|
||||
export function idLabel(run: Run): string {
|
||||
const raw = (run.output || run.output_prefix || run.kind || '').split('/').pop() || run.kind
|
||||
return raw.replace(/_\d+_?\.\w+$/, '').replace(/_\d+_$/, '').replace(/\.\w+$/, '') || run.kind
|
||||
}
|
||||
export function runThumb(run: Run): string | null {
|
||||
if (run.output) return thumbURL(run.output)
|
||||
if (run.parents[0]) return thumbURL(run.parents[0])
|
||||
if (run.refs[0]) return inputThumbURL(run.refs[0])
|
||||
return null
|
||||
}
|
||||
const isActive = (run: Run) => run.status === 'queued' || run.status === 'running'
|
||||
|
||||
export function Stars({ value, onChange }: { value: number; onChange?: (n: number) => void }) {
|
||||
const stars = [1, 2, 3, 4, 5]
|
||||
return (
|
||||
<span className={`stars${onChange ? ' interactive' : ''}`} role={onChange ? 'radiogroup' : undefined} aria-label="rating">
|
||||
{stars.map(n => (
|
||||
<button
|
||||
key={n} type="button" className={`star${n <= value ? ' on' : ''}`}
|
||||
disabled={!onChange} aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
onClick={onChange ? () => onChange(n === value ? 0 : n) : undefined}
|
||||
>★</button>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One run card ─────────────────────────────────────────────────────────────────
|
||||
export interface CardProps {
|
||||
run: Run
|
||||
qitem?: QueueItem
|
||||
rating?: Rating
|
||||
favorite: boolean
|
||||
now: number
|
||||
version?: { pos: number; len: number }
|
||||
onRemove: (run: Run) => void
|
||||
onAmend: (run: Run, files: File[]) => Promise<void>
|
||||
onRate: (run: Run, patch: { stars?: number; notes?: string; path?: string }) => void
|
||||
onFavorite: (run: Run) => void
|
||||
onAddToStack: (run: Run) => void
|
||||
onOpenVersions: (run: Run) => void
|
||||
onUseRef: (run: Run) => void
|
||||
dnd: {
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragEnter: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
intent: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function RunCard(p: CardProps) {
|
||||
const { run, qitem, rating, favorite, now, version } = p
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [panel, setPanel] = useState<'none' | 'rate' | 'refs'>('none')
|
||||
const [stars, setStars] = useState(rating?.stars ?? 0)
|
||||
const [notes, setNotes] = useState(rating?.notes ?? '')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [fileOver, setFileOver] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
|
||||
const active = isActive(run)
|
||||
const src = runThumb(run)
|
||||
const fresh = run.status === 'done' && now - (run.finished_at || run.ts) < 120
|
||||
|
||||
// Corner badge: ETA while queued, elapsed while running, stars-or-node when done.
|
||||
let badge = ''
|
||||
if (run.status === 'running') badge = qitem?.elapsed_seconds != null ? fmtDur(qitem.elapsed_seconds) : 'running'
|
||||
else if (run.status === 'queued') badge = qitem?.eta_seconds != null ? `~${fmtDur(qitem.eta_seconds)}` : 'queued'
|
||||
else if (run.status === 'failed') badge = 'failed'
|
||||
else if (run.status === 'cancelled') badge = 'cancelled'
|
||||
else badge = rating?.stars ? `★${rating.stars}` : run.node
|
||||
|
||||
const pos = qitem?.position && qitem?.of ? `${qitem.position}/${qitem.of}` : ''
|
||||
const meta = [run.node, relTime(run.ts, now), run.status + (pos ? ` · ${pos}` : '')].filter(Boolean).join(' · ')
|
||||
|
||||
const collectFiles = (list: FileList | null) =>
|
||||
setFiles(f => [...f, ...[...(list || [])].filter(x => x.type.startsWith('image/'))])
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
const dropped = [...(e.dataTransfer?.files || [])].filter(x => x.type.startsWith('image/'))
|
||||
if (dropped.length && active) { // files onto a queued card → add references
|
||||
e.preventDefault(); e.stopPropagation(); setFileOver(false)
|
||||
setPanel('refs'); setFiles(f => [...f, ...dropped]); return
|
||||
}
|
||||
setFileOver(false); p.dnd.onDrop(e) // otherwise an internal run drag → stacking
|
||||
}
|
||||
function onDragOver(e: React.DragEvent) {
|
||||
if (active && e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); setFileOver(true); return }
|
||||
p.dnd.onDragOver(e) // internal run drag → let the stack drop fire
|
||||
}
|
||||
|
||||
async function applyRefs() {
|
||||
if (!files.length || busy) return
|
||||
setBusy(true)
|
||||
try { await p.onAmend(run, files); setFiles([]); setPanel('none') } finally { setBusy(false) }
|
||||
}
|
||||
function saveRate() {
|
||||
p.onRate(run, { stars, notes, path: run.output || undefined })
|
||||
setPanel('none')
|
||||
}
|
||||
// The × (and the menu's Delete) share ONE handler: a queued run cancels straight
|
||||
// away; a finished result is a destructive soft-delete, so it asks first.
|
||||
function requestDelete() {
|
||||
if (active) p.onRemove(run)
|
||||
else setConfirmDel(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rc rc-${run.status}${p.dnd.intent ? ' stack-intent' : ''}${fileOver ? ' file-over' : ''}`}
|
||||
draggable onDragStart={p.dnd.onDragStart}
|
||||
onDragEnter={p.dnd.onDragEnter} onDragLeave={e => { setFileOver(false); p.dnd.onDragLeave(e) }}
|
||||
onDragOver={onDragOver} onDrop={onDrop}
|
||||
>
|
||||
<a className="rc-thumb" draggable={false} href={run.output ? fileURL(run.output) : undefined}
|
||||
target="_blank" rel="noreferrer" onClick={e => { if (!run.output) e.preventDefault() }}>
|
||||
{src ? <img loading="lazy" draggable={false} src={src} alt="" /> : <span className="rc-ph">{run.kind || '—'}</span>}
|
||||
<span className={`rc-badge s-${run.status}`}>{badge}</span>
|
||||
{fresh && <span className="rc-new">New</span>}
|
||||
</a>
|
||||
|
||||
<button className="rc-x" aria-label={active ? 'cancel' : 'delete'}
|
||||
title={active ? 'Cancel' : 'Delete'} onClick={requestDelete}>×</button>
|
||||
{confirmDel && (
|
||||
<div className="rc-confirm" role="alertdialog" aria-label="confirm delete">
|
||||
<span>Delete result?</span>
|
||||
<button className="danger" onClick={() => { setConfirmDel(false); p.onRemove(run) }}>Delete</button>
|
||||
<button onClick={() => setConfirmDel(false)}>Keep</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rc-body">
|
||||
<div className="rc-title" title={run.prompt || run.kind}>{run.prompt || `(${run.kind})`}</div>
|
||||
<div className="rc-meta">{meta}</div>
|
||||
<div className="rc-tags">
|
||||
<span className="rc-id">{idLabel(run)}</span>
|
||||
{version && version.len > 1 && (
|
||||
<button className="rc-vers" title="versions of this design" onClick={() => p.onOpenVersions(run)}>
|
||||
{Array.from({ length: Math.min(version.len, 6) }).map((_, i) =>
|
||||
<span key={i} className={`pip${i === version.pos - 1 ? ' on' : ''}`} />)}
|
||||
<span className="rc-vn">v{version.pos}</span>
|
||||
</button>
|
||||
)}
|
||||
{favorite && <span className="rc-fav" title="favorite">♥</span>}
|
||||
{rating?.stars ? <span className="rc-rated"><Stars value={rating.stars} /></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="rc-more" aria-label="more actions" aria-haspopup="menu"
|
||||
onClick={() => setMenu(m => !m)}>⋯</button>
|
||||
|
||||
{menu && (
|
||||
<div className="rc-menu" role="menu" onMouseLeave={() => setMenu(false)}>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); requestDelete() }}>Delete</button>
|
||||
{active && <button role="menuitem" onClick={() => { setMenu(false); setPanel('refs') }}>Add references</button>}
|
||||
<button role="menuitem" onClick={() => { setMenu(false); setStars(rating?.stars ?? 0); setNotes(rating?.notes ?? ''); setPanel('rate') }}>Rate & note</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onFavorite(run) }}>{favorite ? 'Unfavorite' : 'Favorite'}</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onAddToStack(run) }}>Add to stack</button>
|
||||
<button role="menuitem" onClick={() => { setMenu(false); p.onUseRef(run) }}>Use as reference</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'rate' && (
|
||||
<div className="rc-panel">
|
||||
<Stars value={stars} onChange={setStars} />
|
||||
<textarea className="rc-notes" value={notes} onChange={e => setNotes(e.target.value)}
|
||||
placeholder="What should the AI do differently next time?" />
|
||||
<div className="rc-panel-row">
|
||||
<button className="pri" onClick={saveRate}>Save</button>
|
||||
<button onClick={() => setPanel('none')}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel === 'refs' && (
|
||||
<div className={`rc-panel rc-drop${fileOver ? ' over' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setFileOver(true) }}
|
||||
onDragLeave={() => setFileOver(false)}
|
||||
onDrop={e => { e.preventDefault(); e.stopPropagation(); setFileOver(false); collectFiles(e.dataTransfer.files) }}>
|
||||
<div className="rc-drop-hint">Drop images here, or</div>
|
||||
<label className="rc-pick">Choose files
|
||||
<input type="file" multiple accept="image/*" onChange={e => collectFiles(e.target.files)} />
|
||||
</label>
|
||||
{files.length > 0 && <div className="rc-files">{files.map((f, i) => <span key={i} className="chip">{f.name}</span>)}</div>}
|
||||
<div className="rc-panel-row">
|
||||
<button className="pri" disabled={!files.length || busy} onClick={applyRefs}>
|
||||
{busy ? 'Requeuing…' : `Add ${files.length || ''} & requeue`}
|
||||
</button>
|
||||
<button onClick={() => { setFiles([]); setPanel('none') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Vendored
-82
@@ -1,82 +0,0 @@
|
||||
// Layout state for the queue/history rail: divider positions, per-section collapse,
|
||||
// and the active filter chip — ONE mechanism, persisted in localStorage keyed by
|
||||
// org+user (from /v1/session) and restored on load. Dividers are native pointer
|
||||
// drags; nothing here depends on a DnD library.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
export type Filter = 'all' | 'queued' | 'done' | 'favorites' | 'stacks'
|
||||
|
||||
export interface Layout {
|
||||
railW: number // px — the main | rail divider (horizontal resize)
|
||||
upFrac: number // 0..1 — the Up next | History divider (vertical resize)
|
||||
collapsed: { next: boolean; up: boolean; hist: boolean }
|
||||
filter: Filter
|
||||
}
|
||||
|
||||
export const DEFAULT: Layout = {
|
||||
railW: 360, upFrac: 0.5,
|
||||
collapsed: { next: false, up: false, hist: false }, filter: 'all',
|
||||
}
|
||||
export const RAIL_MIN = 300
|
||||
export const RAIL_MAX = 640
|
||||
|
||||
const KEY = (who: string) => `studio.layout:${who}`
|
||||
export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
||||
|
||||
export function useLayout(who: string) {
|
||||
const [layout, setLayout] = useState<Layout>(DEFAULT)
|
||||
const loaded = useRef(false)
|
||||
const dragging = useRef(false)
|
||||
|
||||
// Restore once we know who the user is (org:user); merge over defaults so a stored
|
||||
// layout from an older shape never loses a newly-added field.
|
||||
useEffect(() => {
|
||||
if (!who || loaded.current) return
|
||||
loaded.current = true
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY(who))
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw)
|
||||
setLayout({ ...DEFAULT, ...s, collapsed: { ...DEFAULT.collapsed, ...(s.collapsed || {}) } })
|
||||
}
|
||||
} catch { /* corrupt entry — fall back to defaults */ }
|
||||
}, [who])
|
||||
|
||||
// Persist on change — but not on every drag frame; the drag commits once on release.
|
||||
useEffect(() => {
|
||||
if (!who || !loaded.current || dragging.current) return
|
||||
try { localStorage.setItem(KEY(who), JSON.stringify(layout)) } catch { /* quota — ignore */ }
|
||||
}, [who, layout])
|
||||
|
||||
const patch = useCallback((p: Partial<Layout>) => setLayout(l => ({ ...l, ...p })), [])
|
||||
const toggle = useCallback((k: keyof Layout['collapsed']) =>
|
||||
setLayout(l => ({ ...l, collapsed: { ...l.collapsed, [k]: !l.collapsed[k] } })), [])
|
||||
const reset = useCallback(() => { setLayout(DEFAULT); dragging.current = false }, [])
|
||||
const beginDrag = useCallback(() => { dragging.current = true }, [])
|
||||
const endDrag = useCallback(() => { dragging.current = false; setLayout(l => ({ ...l })) }, [])
|
||||
|
||||
return { layout, patch, toggle, reset, beginDrag, endDrag }
|
||||
}
|
||||
|
||||
// Native divider drag. Tracks pointer delta from the grab point on the given axis and
|
||||
// hands it to `onMove` (the caller clamps + applies); `onEnd` commits/persists.
|
||||
export function startDrag(
|
||||
e: React.PointerEvent, axis: 'x' | 'y',
|
||||
onMove: (delta: number) => void, onEnd: () => void,
|
||||
) {
|
||||
e.preventDefault()
|
||||
const origin = axis === 'x' ? e.clientX : e.clientY
|
||||
const move = (ev: PointerEvent) => onMove((axis === 'x' ? ev.clientX : ev.clientY) - origin)
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
onEnd()
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'
|
||||
}
|
||||
Vendored
-442
@@ -1,442 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getWorklog, getQueueStatus, getRatings, getFavorites, getStacks, saveStacks,
|
||||
cancelRun, amendRun, uploadRef, rate, setFavorite, saveTemplate, getLineage,
|
||||
softDelete, getDeletedPaths, thumbURL, fileURL,
|
||||
type Run, type QueueStatus, type QueueItem, type Rating, type Stack, type Lineage,
|
||||
} from './api'
|
||||
import { RunCard, runThumb, idLabel, relTime, fmtDur } from './card'
|
||||
import { type Filter, type Layout, startDrag, clamp } from './layout'
|
||||
|
||||
// The queue/history rail — the whole YouTube-watch-page surface adapted to studio's
|
||||
// generative flows. App owns the shell + composer + layout persistence; this owns all
|
||||
// queue/history/favorites/stacks data, polling and mutations, and calls back to insert
|
||||
// a reference into the composer.
|
||||
|
||||
// Version chain (pos/len) for each run's output, derived from the loaded rows — the
|
||||
// same parent→child edges the server's lineage walk uses, counted for the "vN" pips.
|
||||
function chains(rows: Run[]): Map<string, { pos: number; len: number }> {
|
||||
const producer = new Map<string, Run>()
|
||||
for (const r of rows) if (r.output && !producer.has(r.output)) producer.set(r.output, r)
|
||||
const childrenOf = new Map<string, Run[]>()
|
||||
for (const r of rows) for (const par of r.parents || []) {
|
||||
const arr = childrenOf.get(par)
|
||||
if (arr) arr.push(r); else childrenOf.set(par, [r])
|
||||
}
|
||||
const out = new Map<string, { pos: number; len: number }>()
|
||||
for (const r of rows) {
|
||||
if (!r.output) continue
|
||||
let anc = 0, cur: string | undefined = r.output
|
||||
const seen = new Set([r.output])
|
||||
for (;;) {
|
||||
const par: string | undefined = producer.get(cur!)?.parents?.[0]
|
||||
if (!par || seen.has(par)) break
|
||||
seen.add(par); anc++; cur = par
|
||||
}
|
||||
let desc = 0
|
||||
const q = [r.output], seenD = new Set([r.output])
|
||||
while (q.length) {
|
||||
for (const kid of childrenOf.get(q.shift()!) || []) {
|
||||
const key = kid.output || kid.id
|
||||
if (seenD.has(key)) continue
|
||||
seenD.add(key); desc++
|
||||
if (kid.output) q.push(kid.output)
|
||||
}
|
||||
}
|
||||
out.set(r.id, { pos: anc + 1, len: anc + 1 + desc })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RailProps {
|
||||
layout: Layout
|
||||
patch: (p: Partial<Layout>) => void
|
||||
toggle: (k: 'next' | 'up' | 'hist') => void
|
||||
reset: () => void
|
||||
beginDrag: () => void
|
||||
endDrag: () => void
|
||||
onUseRef: (text: string) => void
|
||||
}
|
||||
|
||||
export function Rail(props: RailProps) {
|
||||
const { layout, patch, toggle, reset, beginDrag, endDrag } = props
|
||||
const filter = layout.filter
|
||||
|
||||
const [rows, setRows] = useState<Run[]>([])
|
||||
const [q, setQ] = useState<QueueStatus>({ items: {}, medians: {}, lanes: {}, now: Math.floor(Date.now() / 1000) })
|
||||
const [now, setNow] = useState(Math.floor(Date.now() / 1000))
|
||||
const [ratings, setRatings] = useState<Record<string, Rating>>({})
|
||||
const [favorites, setFavorites] = useState<Record<string, { ts: number }>>({})
|
||||
const [stacks, setStacks] = useState<Stack[]>([])
|
||||
const [deletedPaths, setDeletedPaths] = useState<Set<string>>(new Set())
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const [openStack, setOpenStack] = useState<string | null>(null)
|
||||
const [stackPick, setStackPick] = useState<Run | null>(null)
|
||||
const [versionsFor, setVersionsFor] = useState<Run | null>(null)
|
||||
const [lineage, setLineage] = useState<Lineage | null>(null)
|
||||
const [offer, setOffer] = useState<Run | null>(null)
|
||||
const [railMenu, setRailMenu] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const pollLive = useCallback(async () => {
|
||||
const [w, qs] = await Promise.all([getWorklog(query), getQueueStatus()])
|
||||
setRows(w.items); setQ(qs); setNow(qs.now)
|
||||
}, [query])
|
||||
const loadRatings = useCallback(() => getRatings().then(setRatings), [])
|
||||
const loadFavorites = useCallback(() => getFavorites().then(setFavorites), [])
|
||||
const loadStacks = useCallback(() => getStacks().then(setStacks), [])
|
||||
const loadDeleted = useCallback(() => getDeletedPaths().then(ps => setDeletedPaths(new Set(ps))), [])
|
||||
|
||||
useEffect(() => { pollLive(); const t = setInterval(pollLive, 4000); return () => clearInterval(t) }, [pollLive])
|
||||
useEffect(() => { loadRatings(); loadFavorites(); loadStacks(); loadDeleted() }, [loadRatings, loadFavorites, loadStacks, loadDeleted])
|
||||
useEffect(() => { if (!err) return; const t = setTimeout(() => setErr(''), 4000); return () => clearTimeout(t) }, [err])
|
||||
|
||||
// Active = the runs the server still counts as in-flight (present in queue/status),
|
||||
// plus just-dispatched rows not yet in the next poll — so a stalled row doesn't
|
||||
// linger in Up next forever (matches the queue's own 30-min active window).
|
||||
const active = rows.filter(r => (r.status === 'queued' || r.status === 'running') && (q.items[r.id] || now - r.ts <= 1800))
|
||||
.sort((a, b) => ((q.items[a.id]?.position ?? 999) - (q.items[b.id]?.position ?? 999)) || (a.ts - b.ts))
|
||||
const history = rows.filter(r => (r.status === 'done' || r.status === 'failed' || r.status === 'cancelled')
|
||||
&& !hidden.has(r.id) && !(r.output && deletedPaths.has(r.output)))
|
||||
const favRuns = rows.filter(r => favorites[r.id])
|
||||
const nextRun = active[0]
|
||||
const chainMap = chains(rows)
|
||||
|
||||
// ── Mutations (each refreshes the affected store) ────────────────────────────────
|
||||
// A dispatch/mutation can fail because the run or asset changed since this list was
|
||||
// fetched (deduped, finished, cancelled). The trailing refresh already re-fetches;
|
||||
// swap the raw error for a plain "pick it again" — one self-heal for every mutation.
|
||||
const STALE = /unknown asset|not found|no such job|already finished/i
|
||||
const heal = (e: Error): boolean => {
|
||||
if (!STALE.test(e.message)) return false
|
||||
setErr('That run changed — refreshed, pick it again.')
|
||||
return true
|
||||
}
|
||||
// The ONE remove handler behind both the card's × and its menu Delete: a queued run
|
||||
// cancels; a finished result soft-deletes its output (the library lifecycle) and is
|
||||
// hidden from History optimistically (reconciled from the server's deleted set).
|
||||
async function onRemove(run: Run) {
|
||||
if (run.status === 'queued' || run.status === 'running') {
|
||||
try { await cancelRun(run.id) } catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
pollLive(); return
|
||||
}
|
||||
setHidden(h => new Set(h).add(run.id))
|
||||
const out = run.output
|
||||
if (out) {
|
||||
try { await softDelete(out); setDeletedPaths(d => new Set(d).add(out)) }
|
||||
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
}
|
||||
pollLive()
|
||||
}
|
||||
async function onAmend(run: Run, files: File[]) {
|
||||
const names: string[] = []
|
||||
for (const f of files) { try { const u = await uploadRef(f); if (u?.name) names.push(u.name) } catch (e) { setErr((e as Error).message) } }
|
||||
if (!names.length) return
|
||||
const instr = (run.prompt || '').trim()
|
||||
try { await amendRun(run.id, instr.length >= 3 ? instr : `${run.kind || 'edit'} update`, names) }
|
||||
catch (e) { if (!heal(e as Error)) setErr((e as Error).message) }
|
||||
pollLive()
|
||||
}
|
||||
async function onRate(run: Run, p: { stars?: number; notes?: string; path?: string }) {
|
||||
try { await rate(run.id, p) } catch (e) { setErr((e as Error).message) }
|
||||
loadRatings()
|
||||
}
|
||||
async function onFavorite(run: Run) {
|
||||
const nowFav = !favorites[run.id]
|
||||
try { await setFavorite(run.id, nowFav) } catch (e) { setErr((e as Error).message) }
|
||||
await loadFavorites()
|
||||
if (nowFav) setOffer(run) // offer to turn the flow into a template
|
||||
}
|
||||
async function onOpenVersions(run: Run) {
|
||||
setVersionsFor(run); setLineage(null)
|
||||
setLineage(await getLineage(run.output || ''))
|
||||
}
|
||||
async function persistStacks(next: Stack[]) {
|
||||
const clean = next.filter(s => s.items.length >= 2) // a folder of one dissolves
|
||||
setStacks(clean)
|
||||
try { setStacks(await saveStacks(clean)) } catch (e) { setErr((e as Error).message) }
|
||||
}
|
||||
function stackRuns(from: string, target: string) {
|
||||
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== from) }))
|
||||
const host = next.find(s => s.items.includes(target))
|
||||
if (host) host.items = [...host.items, from]
|
||||
else next.push({ id: `st_${Date.now().toString(36)}`, name: 'Stack', items: [target, from], ts: Math.floor(Date.now() / 1000) })
|
||||
persistStacks(next)
|
||||
}
|
||||
function addToStack(run: Run, stackId: string) {
|
||||
const next = stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) }))
|
||||
const host = next.find(s => s.id === stackId)
|
||||
if (host) host.items = [...host.items, run.id]
|
||||
persistStacks(next); setStackPick(null)
|
||||
}
|
||||
function unstack(run: Run) {
|
||||
persistStacks(stacks.map(s => ({ ...s, items: s.items.filter(i => i !== run.id) })))
|
||||
}
|
||||
function renameStack(id: string, name: string) {
|
||||
setStacks(s => s.map(x => x.id === id ? { ...x, name } : x))
|
||||
}
|
||||
function commitRename() { persistStacks(stacks) }
|
||||
|
||||
// ── Drag-hold stacking (native HTML5 drag + a 600ms hover timer) ─────────────────
|
||||
const dragId = useRef<string | null>(null)
|
||||
const holdTimer = useRef<number | undefined>(undefined)
|
||||
const [intentId, setIntentId] = useState<string | null>(null)
|
||||
function dndFor(run: Run) {
|
||||
return {
|
||||
onDragStart: (e: React.DragEvent) => { dragId.current = run.id; e.dataTransfer.setData('text/plain', run.id); e.dataTransfer.effectAllowed = 'move' },
|
||||
onDragOver: (e: React.DragEvent) => { if (dragId.current && dragId.current !== run.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } },
|
||||
onDragEnter: (e: React.DragEvent) => {
|
||||
if (!dragId.current || dragId.current === run.id) return
|
||||
e.preventDefault()
|
||||
window.clearTimeout(holdTimer.current)
|
||||
holdTimer.current = window.setTimeout(() => setIntentId(run.id), 600) // hold to form a stack
|
||||
},
|
||||
onDragLeave: () => { window.clearTimeout(holdTimer.current); setIntentId(cur => cur === run.id ? null : cur) },
|
||||
onDrop: () => {
|
||||
window.clearTimeout(holdTimer.current)
|
||||
const from = dragId.current, held = intentId === run.id
|
||||
setIntentId(null); dragId.current = null
|
||||
if (from && from !== run.id && held) stackRuns(from, run.id)
|
||||
},
|
||||
intent: intentId === run.id,
|
||||
}
|
||||
}
|
||||
|
||||
const card = (run: Run) => (
|
||||
<RunCard
|
||||
key={run.id} run={run} qitem={q.items[run.id]} rating={ratings[run.id]}
|
||||
favorite={!!favorites[run.id]} now={now} version={chainMap.get(run.id)}
|
||||
onRemove={onRemove} onAmend={onAmend} onRate={onRate} onFavorite={onFavorite}
|
||||
onAddToStack={r => setStackPick(r)} onOpenVersions={onOpenVersions}
|
||||
onUseRef={r => props.onUseRef(idLabel(r))} dnd={dndFor(run)}
|
||||
/>
|
||||
)
|
||||
|
||||
// ── Section layout (content-driven flex + the vertical resize divider) ───────────
|
||||
const sectionsRef = useRef<HTMLDivElement>(null)
|
||||
const bothOpen = filter === 'all' && !layout.collapsed.up && !layout.collapsed.hist
|
||||
const upStyle: React.CSSProperties = filter === 'queued' ? { flex: '1 1 auto' }
|
||||
: bothOpen ? { flex: `0 0 ${Math.round(layout.upFrac * 100)}%` }
|
||||
: layout.collapsed.up ? { flex: '0 0 auto' } : { flex: '1 1 auto' }
|
||||
const histStyle: React.CSSProperties = layout.collapsed.hist ? { flex: '0 0 auto' } : { flex: '1 1 0' }
|
||||
function onHSplit(e: React.PointerEvent) {
|
||||
beginDrag()
|
||||
const h = sectionsRef.current?.getBoundingClientRect().height || 1
|
||||
const start = layout.upFrac
|
||||
startDrag(e, 'y', d => patch({ upFrac: clamp(start + d / h, 0.15, 0.85) }), endDrag)
|
||||
}
|
||||
|
||||
const chips: { k: Filter; label: string; n?: number }[] = [
|
||||
{ k: 'all', label: 'All' },
|
||||
{ k: 'queued', label: 'Queued', n: active.length },
|
||||
{ k: 'done', label: 'Done' },
|
||||
{ k: 'favorites', label: 'Favorites', n: favRuns.length },
|
||||
{ k: 'stacks', label: 'Stacks', n: stacks.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{(filter === 'all' || filter === 'queued') && nextRun && (
|
||||
<div className={`nextpin${layout.collapsed.next ? ' col' : ''}`}>
|
||||
<button className="pin-h" onClick={() => toggle('next')} aria-expanded={!layout.collapsed.next}>
|
||||
<span className="pin-lab">Next</span>
|
||||
<span className="pin-count">{active.length} in queue</span>
|
||||
<span className="chev">{layout.collapsed.next ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
{!layout.collapsed.next && <NextBody run={nextRun} q={q.items[nextRun.id]} now={now} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chips">
|
||||
<div className="chips-row">
|
||||
{chips.map(c => (
|
||||
<button key={c.k} className={`chip-f${filter === c.k ? ' on' : ''}`} onClick={() => patch({ filter: c.k })}>
|
||||
{c.label}{c.n ? <span className="chip-n">{c.n}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rail-more-wrap">
|
||||
<button className="rail-more" aria-label="rail options" onClick={() => setRailMenu(m => !m)}>⋯</button>
|
||||
{railMenu && (
|
||||
<div className="rc-menu" role="menu" onMouseLeave={() => setRailMenu(false)}>
|
||||
<button role="menuitem" onClick={() => { setRailMenu(false); reset() }}>Reset layout</button>
|
||||
<button role="menuitem" onClick={() => { setRailMenu(false); pollLive(); loadRatings(); loadFavorites(); loadStacks() }}>Refresh</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rail-main">
|
||||
{filter === 'stacks' ? (
|
||||
<div className="stacks-view">
|
||||
{stacks.length === 0 && <div className="empty">No stacks yet. Drag one card onto another and hold (~½s) to make a stack.</div>}
|
||||
{stacks.map(s => {
|
||||
const members = s.items.map(id => rows.find(r => r.id === id)).filter((r): r is Run => !!r)
|
||||
const open = openStack === s.id
|
||||
return (
|
||||
<div key={s.id} className={`stack${open ? ' open' : ''}`}>
|
||||
<button className="stack-tile" onClick={() => setOpenStack(open ? null : s.id)}>
|
||||
<span className="stack-cards">
|
||||
{members.slice(0, 3).map((m, i) => {
|
||||
const src = runThumb(m)
|
||||
return <span key={i} className="sc" style={src ? { backgroundImage: `url(${src})` } : undefined} />
|
||||
})}
|
||||
</span>
|
||||
<span className="stack-meta">
|
||||
<span className="stack-name">{s.name}</span>
|
||||
<span className="stack-count">{members.length}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="stack-open">
|
||||
<input className="stack-rename" value={s.name} aria-label="stack name"
|
||||
onChange={e => renameStack(s.id, e.target.value)} onBlur={commitRename} />
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="stack-member">
|
||||
{card(m)}
|
||||
<button className="unstack" title="remove from stack" onClick={() => unstack(m)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : filter === 'favorites' ? (
|
||||
<section className="sec">
|
||||
<div className="sec-body">
|
||||
{favRuns.length ? favRuns.map(card) : <div className="empty">No favorites yet. Tap ♥ on any card.</div>}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="sections" ref={sectionsRef}>
|
||||
{(filter === 'all' || filter === 'queued') && (
|
||||
<section className={`sec${layout.collapsed.up ? ' col' : ''}`} style={upStyle}>
|
||||
<SecHeader label="Up next" n={active.length} collapsed={layout.collapsed.up} onToggle={() => toggle('up')} />
|
||||
{!layout.collapsed.up && (
|
||||
<div className="sec-body">{active.length ? active.map(card) : <div className="empty">Nothing queued. Describe a change to start a run.</div>}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{bothOpen && <div className="hsplit" onPointerDown={onHSplit} onDoubleClick={() => patch({ upFrac: 0.5 })} title="drag to resize · double-click to reset" />}
|
||||
{(filter === 'all' || filter === 'done') && (
|
||||
<section className={`sec${layout.collapsed.hist ? ' col' : ''}`} style={histStyle}>
|
||||
<SecHeader label="History" n={history.length} collapsed={layout.collapsed.hist} onToggle={() => toggle('hist')}
|
||||
search={<input className="sec-search" placeholder="Search history…" value={query} onChange={e => setQuery(e.target.value)} />} />
|
||||
{!layout.collapsed.hist && (
|
||||
<div className="sec-body">{history.length ? history.map(card) : <div className="empty">No past generations{query ? ' match your search' : ' yet'}.</div>}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stackPick && (
|
||||
<div className="ovl" onClick={() => setStackPick(null)}>
|
||||
<div className="sheet" onClick={e => e.stopPropagation()}>
|
||||
<div className="sheet-h">Add “{idLabel(stackPick)}” to a stack</div>
|
||||
{stacks.length === 0
|
||||
? <p className="muted">No stacks yet — drag one card onto another and hold to make the first one.</p>
|
||||
: stacks.map(s => <button key={s.id} className="sheet-item" onClick={() => addToStack(stackPick, s.id)}>{s.name} · {s.items.length}</button>)}
|
||||
<div className="sheet-row"><button onClick={() => setStackPick(null)}>Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versionsFor && (
|
||||
<div className="ovl" onClick={() => setVersionsFor(null)}>
|
||||
<div className="sheet vers" onClick={e => e.stopPropagation()}>
|
||||
<div className="sheet-h">Versions of {idLabel(versionsFor)}</div>
|
||||
{!lineage ? <div className="empty">Loading…</div> : (
|
||||
<div className="vers-strip">
|
||||
{[...lineage.ancestors,
|
||||
{ path: lineage.node, prompt: versionsFor.prompt, kind: versionsFor.kind, ts: versionsFor.ts },
|
||||
...lineage.descendants].map((step, i) => (
|
||||
<a key={i} className={`vstep${step.path === lineage.node ? ' cur' : ''}`}
|
||||
href={step.path ? fileURL(step.path) : undefined} target="_blank" rel="noreferrer"
|
||||
onClick={e => { if (!step.path) e.preventDefault() }} title={step.prompt || step.kind}>
|
||||
{step.path ? <img src={thumbURL(step.path)} alt="" /> : <span className="rc-ph">?</span>}
|
||||
<span className="vlabel">v{i + 1}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="sheet-row"><button onClick={() => setVersionsFor(null)}>Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offer && <TemplateOffer run={offer} onClose={() => setOffer(null)} onError={setErr} />}
|
||||
{err && <div className="rail-toast" role="alert">{err}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NextBody({ run, q, now }: { run: Run; q?: QueueItem; now: number }) {
|
||||
const src = runThumb(run)
|
||||
const badge = run.status === 'running'
|
||||
? (q?.elapsed_seconds != null ? `running · ${fmtDur(q.elapsed_seconds)}` : 'running')
|
||||
: (q?.eta_seconds != null ? `starts in ~${fmtDur(q.eta_seconds)}` : 'queued')
|
||||
return (
|
||||
<div className="pin-body">
|
||||
<div className="pin-thumb">{src ? <img src={src} alt="" /> : <span className="rc-ph">{run.kind}</span>}</div>
|
||||
<div className="pin-txt">
|
||||
<div className="pin-prompt" title={run.prompt}>{run.prompt || `(${run.kind})`}</div>
|
||||
<div className="pin-meta">{badge} · {run.node} · {relTime(run.ts, now)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecHeader({ label, n, collapsed, onToggle, search }:
|
||||
{ label: string; n: number; collapsed: boolean; onToggle: () => void; search?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sec-h">
|
||||
<button className="sec-toggle" onClick={onToggle} aria-expanded={!collapsed}>
|
||||
<span className="chev">{collapsed ? '▸' : '▾'}</span>
|
||||
<span className="sec-lab">{label}</span>
|
||||
<span className="sec-n">{n}</span>
|
||||
</button>
|
||||
{search}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateOffer({ run, onClose, onError }: { run: Run; onClose: () => void; onError: (m: string) => void }) {
|
||||
const [stage, setStage] = useState<'offer' | 'name'>('offer')
|
||||
const [name, setName] = useState(idLabel(run))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await saveTemplate(name.trim() || idLabel(run), { kind: run.kind, prompt: run.prompt, refs: run.refs, parents: run.parents })
|
||||
setDone(true); setTimeout(onClose, 900)
|
||||
} catch (e) { onError((e as Error).message); onClose() } finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="ovl" onClick={onClose}>
|
||||
<div className="sheet" onClick={e => e.stopPropagation()}>
|
||||
{done ? <div className="sheet-h">Saved to templates ✓</div>
|
||||
: stage === 'offer' ? (
|
||||
<>
|
||||
<div className="sheet-h">Turn this into a template?</div>
|
||||
<p className="muted">Reuse this flow — {run.kind}, its instruction and {run.refs.length} reference{run.refs.length === 1 ? '' : 's'} — as a starting point next time.</p>
|
||||
<div className="sheet-row"><button className="pri" onClick={() => setStage('name')}>Name it…</button><button onClick={onClose}>Not now</button></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sheet-h">Name this template</div>
|
||||
<input className="sheet-in" value={name} autoFocus onChange={e => setName(e.target.value)} placeholder="Template name" />
|
||||
<p className="muted">Captures: {run.kind} · “{run.prompt || '(no instruction)'}” · {run.refs.length} ref{run.refs.length === 1 ? '' : 's'}</p>
|
||||
<div className="sheet-row"><button className="pri" disabled={busy || !name.trim()} onClick={save}>{busy ? 'Saving…' : 'Save template'}</button><button onClick={onClose}>Cancel</button></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+28
-216
@@ -1,236 +1,48 @@
|
||||
:root {
|
||||
--bg: #0d0d0f; --panel: #151517; --panel2: #1c1c20; --line: #26262b; --text: #f4f4f6;
|
||||
--dim: #a6a6ae; --accent: #f4f4f6; --accent-ink: #0d0d0f; --brand: #4f8cff;
|
||||
--good: #35c26b; --bad: #ff6b6b; --halo: #4f8cff;
|
||||
--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; --panel2: #f3f3f5; --line: #e6e6ea; --text: #16161a;
|
||||
--dim: #55565e; --accent: #16161a; --accent-ink: #fff; --brand: #2f6be0;
|
||||
--good: #1f9d57; --bad: #d33; --halo: #2f6be0;
|
||||
}
|
||||
: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;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
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: 10px 16px; border-bottom: 1px solid var(--line); }
|
||||
.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 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--dim); }
|
||||
.gpu.on { color: var(--text); } .gpu.on::before { content: "● "; color: var(--good); }
|
||||
.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); }
|
||||
|
||||
/* Account avatar + menu — a 44px target, not bare text */
|
||||
.acct-wrap { position: relative; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: 999px; border: 1px solid var(--line);
|
||||
background: var(--panel2); color: var(--text); font-weight: 700; font-size: 13px;
|
||||
cursor: pointer; letter-spacing: .5px;
|
||||
}
|
||||
.avatar:hover { border-color: var(--dim); }
|
||||
.acct-menu { right: 0; top: 50px; min-width: 210px; }
|
||||
.acct-id { padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.acct-name { font-weight: 600; }
|
||||
.acct-sub { font-size: 12px; color: var(--dim); }
|
||||
|
||||
.body { flex: 1; display: flex; min-height: 0; }
|
||||
.chat { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.scroll { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.hero { max-width: 580px; margin: 4vh auto 0; text-align: center; }
|
||||
.hero h1 { font-size: 27px; font-weight: 600; margin: 0 0 8px; }
|
||||
.hero p { color: var(--dim); margin: 0 0 16px; }
|
||||
.examples { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.ex-chip {
|
||||
border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
border-radius: 999px; padding: 7px 13px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.ex-chip:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.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: 12px 14px; border-top: 1px solid var(--line); background: var(--bg); }
|
||||
.composer textarea { flex: 1; resize: none; height: 46px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; }
|
||||
.composer textarea:focus { outline: none; border-color: var(--brand); }
|
||||
.send {
|
||||
padding: 0 20px; border-radius: 12px; cursor: pointer; font-weight: 600;
|
||||
border: 1px solid var(--line); background: transparent; color: var(--dim); /* empty: muted outline */
|
||||
}
|
||||
.send.ready { border-color: transparent; background: var(--brand); color: #fff; } /* text present: brand fill */
|
||||
.send:disabled { cursor: default; opacity: .6; }
|
||||
.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; }
|
||||
|
||||
/* ── The main | rail divider ─────────────────────────────────────────────── */
|
||||
.vsplit { flex: 0 0 6px; cursor: col-resize; background: var(--line); opacity: .5; }
|
||||
.vsplit:hover { opacity: 1; background: var(--brand); }
|
||||
.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; }
|
||||
|
||||
/* ── The rail ────────────────────────────────────────────────────────────── */
|
||||
.rail { border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: var(--bg); }
|
||||
.chev { color: var(--dim); font-size: 11px; }
|
||||
|
||||
.nextpin { border-bottom: 1px solid var(--line); }
|
||||
.pin-h { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 14px; background: var(--panel2); border: 0; cursor: pointer; text-align: left; }
|
||||
.pin-lab { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .6px; }
|
||||
.pin-count { flex: 1; color: var(--dim); font-size: 12px; }
|
||||
.pin-body { display: flex; gap: 10px; padding: 10px 14px; }
|
||||
.pin-thumb { flex: 0 0 64px; height: 64px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--panel); display: grid; place-items: center; }
|
||||
.pin-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.pin-txt { min-width: 0; }
|
||||
.pin-prompt { font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.pin-meta { font-size: 12px; color: var(--dim); margin-top: 3px; }
|
||||
|
||||
.chips { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.chips-row { flex: 1; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
||||
.chips-row::-webkit-scrollbar { display: none; }
|
||||
.chip-f { flex: 0 0 auto; border: 1px solid var(--line); background: var(--panel); color: var(--dim); border-radius: 999px; padding: 5px 11px; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.chip-f.on { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
||||
.chip-n { font-size: 11px; background: color-mix(in srgb, var(--dim) 22%, transparent); border-radius: 999px; padding: 0 6px; }
|
||||
.chip-f.on .chip-n { background: color-mix(in srgb, var(--accent-ink) 25%, transparent); }
|
||||
.rail-more-wrap { position: relative; }
|
||||
.rail-more, .rc-more { border: 0; background: transparent; cursor: pointer; color: var(--dim); font-size: 18px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
|
||||
.rail-more:hover { background: var(--panel2); color: var(--text); }
|
||||
|
||||
.rail-main { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sections { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sec { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.sec.col { flex: 0 0 auto !important; }
|
||||
.sec-h { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||
.sec-toggle { flex: 0 0 auto; display: flex; align-items: center; gap: 7px; background: transparent; border: 0; cursor: pointer; padding: 2px 4px; }
|
||||
.sec-lab { font-size: 12px; text-transform: uppercase; letter-spacing: .6px; font-weight: 700; }
|
||||
.sec-n { font-size: 11px; color: var(--dim); background: var(--panel2); border-radius: 999px; padding: 0 7px; }
|
||||
.sec-search { flex: 1; min-width: 0; border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 8px; padding: 5px 9px; font-size: 13px; }
|
||||
.sec-search:focus { outline: none; border-color: var(--brand); }
|
||||
.sec-body { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hsplit { flex: 0 0 6px; cursor: row-resize; background: var(--line); opacity: .5; }
|
||||
.hsplit:hover { opacity: 1; background: var(--brand); }
|
||||
.empty { color: var(--dim); font-size: 13px; padding: 14px; text-align: center; }
|
||||
|
||||
/* ── Run card ────────────────────────────────────────────────────────────── */
|
||||
.rc { position: relative; display: flex; flex-wrap: wrap; gap: 10px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
|
||||
.rc:hover { border-color: var(--dim); }
|
||||
.rc.stack-intent { border-color: var(--halo); box-shadow: 0 0 0 3px color-mix(in srgb, var(--halo) 35%, transparent); }
|
||||
.rc.file-over { border-color: var(--brand); border-style: dashed; }
|
||||
.rc-thumb { position: relative; flex: 0 0 48%; max-width: 48%; border-radius: 8px; overflow: hidden; background: var(--panel2); border: 1px solid var(--line); display: block; aspect-ratio: 4 / 5; text-decoration: none; }
|
||||
.rc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rc-ph { display: grid; place-items: center; width: 100%; height: 100%; color: var(--dim); font-size: 12px; }
|
||||
.rc-badge { position: absolute; left: 5px; bottom: 5px; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: color-mix(in srgb, #000 62%, transparent); color: #fff; }
|
||||
.rc-badge.s-running { background: var(--good); }
|
||||
.rc-badge.s-failed { background: var(--bad); }
|
||||
.rc-new { position: absolute; right: 5px; top: 5px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; padding: 1px 6px; border-radius: 6px; background: var(--brand); color: #fff; }
|
||||
.rc-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.rc-title { font-weight: 500; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
|
||||
.rc-meta { font-size: 12px; color: var(--dim); }
|
||||
.rc-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.rc-id { font-size: 11px; color: var(--dim); background: var(--panel2); border-radius: 6px; padding: 1px 6px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rc-vers { display: inline-flex; align-items: center; gap: 3px; border: 1px solid var(--line); background: transparent; border-radius: 999px; padding: 1px 7px 1px 5px; cursor: pointer; }
|
||||
.rc-vers .pip { width: 5px; height: 5px; border-radius: 999px; background: var(--dim); opacity: .5; }
|
||||
.rc-vers .pip.on { background: var(--brand); opacity: 1; }
|
||||
.rc-vn { font-size: 11px; color: var(--dim); }
|
||||
.rc-fav { color: var(--bad); }
|
||||
.rc-more { position: absolute; right: 4px; top: 4px; }
|
||||
.rc-more:hover { background: var(--panel2); color: var(--text); }
|
||||
/* Always-visible fast-path delete/cancel (× top-left of the thumb) */
|
||||
.rc-x { position: absolute; left: 6px; top: 6px; z-index: 4; width: 22px; height: 22px; border-radius: 999px; border: 0; background: color-mix(in srgb, #000 55%, transparent); color: #fff; font-size: 16px; line-height: 1; cursor: pointer; display: grid; place-items: center; opacity: .9; }
|
||||
.rc-x:hover { background: var(--bad); opacity: 1; }
|
||||
.rc-confirm { position: absolute; left: 6px; top: 6px; z-index: 7; display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 5px 7px; box-shadow: 0 6px 20px rgba(0,0,0,.28); font-size: 12px; }
|
||||
.rc-confirm button { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 6px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
|
||||
.rc-confirm .danger { background: var(--bad); color: #fff; border-color: transparent; }
|
||||
|
||||
.rc-menu { position: absolute; z-index: 20; right: 6px; top: 30px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.22); padding: 5px; display: flex; flex-direction: column; min-width: 168px; }
|
||||
.rc-menu > button, .rc-menu > a { text-align: left; background: transparent; border: 0; border-radius: 7px; padding: 8px 10px; cursor: pointer; color: var(--text); text-decoration: none; font-size: 14px; }
|
||||
.rc-menu > button:hover, .rc-menu > a:hover { background: var(--panel2); }
|
||||
|
||||
.rc-panel { flex-basis: 100%; order: 9; border-top: 1px solid var(--line); margin-top: 6px; padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rc-notes { width: 100%; min-height: 56px; resize: vertical; border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 8px; padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||
.rc-notes:focus { outline: none; border-color: var(--brand); }
|
||||
.rc-panel-row { display: flex; gap: 8px; }
|
||||
.rc-panel-row .pri, .sheet .pri, .send.ready { }
|
||||
.rc-panel-row button, .sheet-row button, .sheet-item { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 8px; padding: 7px 13px; cursor: pointer; font-size: 13px; }
|
||||
.rc-panel-row .pri, .sheet-row .pri { background: var(--brand); color: #fff; border-color: transparent; }
|
||||
.rc-panel-row button:disabled, .sheet-row button:disabled { opacity: .5; cursor: default; }
|
||||
.rc-drop { align-items: stretch; text-align: center; }
|
||||
.rc-drop.over { outline: 2px dashed var(--brand); outline-offset: 2px; border-radius: 8px; }
|
||||
.rc-drop-hint { font-size: 12px; color: var(--dim); }
|
||||
.rc-pick { display: inline-block; border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px; align-self: center; }
|
||||
.rc-pick input { display: none; }
|
||||
.rc-files { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.chip { font-size: 11px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* stars */
|
||||
.stars { display: inline-flex; gap: 1px; }
|
||||
.stars .star { border: 0; background: transparent; padding: 0 1px; cursor: default; color: var(--dim); font-size: 15px; line-height: 1; }
|
||||
.stars.interactive .star { cursor: pointer; font-size: 20px; }
|
||||
.stars .star.on { color: #f5b301; }
|
||||
.rc-rated .stars .star { font-size: 12px; }
|
||||
|
||||
/* ── Stacks ──────────────────────────────────────────────────────────────── */
|
||||
.stacks-view { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; align-content: start; }
|
||||
.stack { grid-column: 1 / -1; }
|
||||
.stack:not(.open) { grid-column: auto; }
|
||||
.stack-tile { width: 100%; border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 10px; cursor: pointer; display: flex; flex-direction: column; gap: 8px; }
|
||||
.stack-tile:hover { border-color: var(--dim); }
|
||||
.stack-cards { position: relative; height: 92px; }
|
||||
.stack-cards .sc { position: absolute; width: 66%; height: 84px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel2) center/cover no-repeat; box-shadow: 0 2px 8px rgba(0,0,0,.15); }
|
||||
.stack-cards .sc:nth-child(1) { left: 0; top: 8px; transform: rotate(-5deg); }
|
||||
.stack-cards .sc:nth-child(2) { left: 17%; top: 4px; transform: rotate(1deg); z-index: 1; }
|
||||
.stack-cards .sc:nth-child(3) { left: 34%; top: 0; transform: rotate(6deg); z-index: 2; }
|
||||
.stack-meta { display: flex; align-items: center; justify-content: space-between; }
|
||||
.stack-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stack-count { font-size: 12px; color: var(--dim); background: var(--panel2); border-radius: 999px; padding: 1px 8px; }
|
||||
.stack.open .stack-open { animation: folder-open .22s ease; overflow: hidden; margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
@keyframes folder-open { from { opacity: 0; transform: translateY(-6px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
.stack-rename { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 8px; padding: 6px 9px; font: inherit; font-weight: 600; }
|
||||
.stack-rename:focus { outline: none; border-color: var(--brand); }
|
||||
.stack-member { position: relative; }
|
||||
.unstack { position: absolute; right: -6px; top: -6px; z-index: 5; width: 22px; height: 22px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; line-height: 1; }
|
||||
.unstack:hover { background: var(--bad); color: #fff; border-color: transparent; }
|
||||
|
||||
/* ── Overlays / sheets ───────────────────────────────────────────────────── */
|
||||
.ovl { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.4); display: grid; place-items: center; padding: 16px; }
|
||||
.sheet { width: min(460px, 100%); max-height: 80vh; overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
|
||||
.sheet-h { font-weight: 600; font-size: 16px; }
|
||||
.muted { color: var(--dim); font-size: 13px; margin: 0; }
|
||||
.sheet-in { border: 1px solid var(--line); background: var(--panel2); color: var(--text); border-radius: 10px; padding: 10px 12px; font: inherit; }
|
||||
.sheet-in:focus { outline: none; border-color: var(--brand); }
|
||||
.sheet-item { text-align: left; }
|
||||
.sheet-item:hover { border-color: var(--dim); }
|
||||
.sheet-row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||
.vers-strip { display: flex; gap: 10px; overflow-x: auto; padding: 4px 0 8px; }
|
||||
.vstep { flex: 0 0 auto; width: 92px; text-align: center; text-decoration: none; color: var(--dim); }
|
||||
.vstep img, .vstep .rc-ph { width: 92px; height: 92px; object-fit: cover; border-radius: 10px; border: 1px solid var(--line); }
|
||||
.vstep.cur img, .vstep.cur .rc-ph { border-color: var(--brand); box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 40%, transparent); }
|
||||
.vlabel { display: block; font-size: 12px; margin-top: 4px; }
|
||||
.vstep.cur .vlabel { color: var(--text); font-weight: 600; }
|
||||
|
||||
.rail-toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 80; background: var(--bad); color: #fff; padding: 9px 16px; border-radius: 10px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.3); max-width: 90vw; }
|
||||
|
||||
/* ── Mobile / tablet: ONE vertical scroll — chat transcript, then the rail as the
|
||||
bottom section — with the composer FIXED to the viewport bottom + safe-area inset
|
||||
(the #1 thumb-reach fix). Dividers are a desktop affordance (hidden); collapse
|
||||
chevrons + filter chips + persistence all still apply. */
|
||||
@media (max-width: 820px) {
|
||||
.body { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(84px + env(safe-area-inset-bottom)); }
|
||||
.chat { flex: 0 0 auto; min-height: 52vh; }
|
||||
.scroll { flex: 0 0 auto; overflow: visible; }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
||||
border-top: 1px solid var(--line); box-shadow: 0 -6px 18px rgba(0,0,0,.12);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
|
||||
.vsplit, .hsplit { display: none; }
|
||||
.rail { width: auto !important; flex: 0 0 auto; border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
|
||||
.rail-main, .sections, .sec, .sec-body, .stacks-view { overflow: visible; min-height: 0; }
|
||||
.sec, .sec.col { flex: 0 0 auto !important; }
|
||||
.chips { position: sticky; top: 0; z-index: 5; background: var(--bg); }
|
||||
.rc-thumb { flex-basis: 44%; max-width: 44%; }
|
||||
.stacks-view { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.stacks-view { grid-template-columns: 1fr 1fr; }
|
||||
.sheet { border-radius: 14px; }
|
||||
.body { grid-template-columns: 1fr; }
|
||||
.rail { display: none; }
|
||||
.chat { border-right: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user