From e764ca01b7b7b9946bf9e9f117cf5f2afa87adf6 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Tue, 7 Jul 2026 13:35:32 +0800 Subject: [PATCH 1/2] share: store remote rounds as manifests --- share-server/README.md | 15 +- share-server/package.json | 6 + share-server/test/worker.test.js | 155 +++++++ share-server/worker.js | 413 +++++++++++++++++-- skills/brand-studio/SKILL.md | 10 +- skills/brand-studio/assets/share-review.html | 32 +- 6 files changed, 566 insertions(+), 65 deletions(-) create mode 100644 share-server/package.json create mode 100644 share-server/test/worker.test.js diff --git a/share-server/README.md b/share-server/README.md index 74efeb3..7fde866 100644 --- a/share-server/README.md +++ b/share-server/README.md @@ -7,7 +7,9 @@ verdicts back directly — no artifact private-wall, no copy/paste shuttle. Cloudflare Worker + KV (`worker.js`, deployed via `bunx wrangler deploy` from this directory; uses the wrangler OAuth login, not `CLOUDFLARE_API_TOKEN`). Modeled on the mc-launcher share store: content-derived FNV-1a ids, idempotent -republish. No auth by design — org-internal links, short TTL. +republish. Remote rounds store the round manifest as the source of truth and the +Worker renders the review board from it. No auth by design — org-internal links, +short TTL. ## Lifetime @@ -19,7 +21,7 @@ an idle share dies after one day. Republish (same content → same id) to revive | Method + path | Purpose | | --- | --- | -| `POST /share[?series=&round=&title=&by=]` (body = self-contained HTML, ≤4MB) | Publish a board → `{id, url}`. Content-derived id, idempotent. `series/round/title` register it in a series (round switcher + `/s/` picker); `by` is the publisher's everyday name, shown in the injected topbar ("Board by jackson") and the series page. | +| `POST /share[?by=]` (body = round manifest JSON, ≤4MB) | Publish a board → `{id, url}`. Content-derived id, idempotent. `series/round/title` come from the manifest and register it in a series (round switcher + `/s/` picker); `by` is the publisher's everyday name, shown in the injected topbar ("Board by jackson") and the series page. Legacy self-contained HTML bodies still work for old callers. | | `GET /share/` | Serve the board (re-arms TTL). `410` when expired. | | `POST /share//verdict` (`{name, decisions[], next?}`) | One reviewer's submission; keyed by name, resubmit overwrites. | | `GET /share//verdicts` | Merged submissions, agent-readable: `{id, count, submissions[]}`. | @@ -28,6 +30,9 @@ an idle share dies after one day. Republish (same content → same id) to revive `skills/brand-studio/assets/share-review.html` — the round-review board wired to this transport (submit → `POST /verdict`, aggregate → `GET -/verdicts`, drafts in `localStorage`). Fill `__ITEMS_JSON__` (items with -inline `svg` markup or data-URI `jpg`) and `__GOAL__`, then `POST /share` the -result. The board must stay self-contained: inline SVGs / data-URI images only. +/verdicts`, drafts in `localStorage`). The Worker injects the posted +manifest into `__ROUND_JSON__`; the template reads `R.series`, `R.round`, +`R.title`, `R.goal`, and `R.items`. + +Remote manifests should use inline `svg` markup or data-URI `jpg`/`img` values +for visual items because the public board cannot read local files. diff --git a/share-server/package.json b/share-server/package.json new file mode 100644 index 0000000..40a1214 --- /dev/null +++ b/share-server/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "scripts": { + "test": "node --test test/worker.test.js" + } +} diff --git a/share-server/test/worker.test.js b/share-server/test/worker.test.js new file mode 100644 index 0000000..c96fe72 --- /dev/null +++ b/share-server/test/worker.test.js @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import worker from "../worker.js"; + +class FakeKV { + constructor() { + this.entries = new Map(); + } + + async put(key, value, options = {}) { + this.entries.set(key, { value: String(value), options }); + } + + async get(key) { + return this.entries.has(key) ? this.entries.get(key).value : null; + } + + async list(options = {}) { + const prefix = options.prefix || ""; + return { + keys: [...this.entries.keys()] + .filter((name) => name.startsWith(prefix)) + .sort() + .map((name) => ({ name })), + }; + } +} + +function makeEnv() { + return { SHARES: new FakeKV() }; +} + +function roundManifest() { + return { + series: "org-logo", + round: 2, + title: "tail refinement", + goal: "Refine the accepted tail direction without changing the mark family.", + items: [ + { + id: "r2-tail-01", + concept: "cream-tipped tail curl", + svg: '', + }, + ], + }; +} + +test("POST /share with a round manifest stores manifest as the remote source of truth", async () => { + const env = makeEnv(); + const res = await worker.fetch( + new Request("https://brand-studio.example/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(roundManifest()), + }), + env, + ); + + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.series, "org-logo"); + assert.match(body.url, /^https:\/\/brand-studio\.example\/share\/[0-9a-f]{16}\?series=org-logo$/); + + const manifest = JSON.parse(await env.SHARES.get(`r:${body.id}`)); + assert.equal(manifest.series, "org-logo"); + assert.equal(manifest.round, 2); + assert.equal(manifest.title, "tail refinement"); + assert.equal(manifest.items[0].id, "r2-tail-01"); + assert.equal(await env.SHARES.get(`s:${body.id}`), null); + + const index = JSON.parse(await env.SHARES.get("x:org-logo")); + assert.deepEqual(index.map(({ round, title, id }) => ({ round, title, id })), [ + { round: 2, title: "tail refinement", id: body.id }, + ]); +}); + +test("GET /share renders the remote board from the stored round manifest", async () => { + const env = makeEnv(); + const create = await worker.fetch( + new Request("https://brand-studio.example/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(roundManifest()), + }), + env, + ); + const { id } = await create.json(); + + const res = await worker.fetch( + new Request(`https://brand-studio.example/share/${id}?series=org-logo`), + env, + ); + + assert.equal(res.status, 200); + const html = await res.text(); + assert.match(html, /org-logo · round 2/); + assert.match(html, /tail refinement/); + assert.match(html, /Refine the accepted tail direction/); + assert.match(html, /cream-tipped tail curl/); + assert.match(html, /const R = /); + assert.doesNotMatch(html, /const ROUND = 1/); +}); + +test("manifest-backed boards accept reviewer verdicts", async () => { + const env = makeEnv(); + const create = await worker.fetch( + new Request("https://brand-studio.example/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(roundManifest()), + }), + env, + ); + const { id } = await create.json(); + + const submit = await worker.fetch( + new Request(`https://brand-studio.example/share/${id}/verdict`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + series: "org-logo", + round: 2, + title: "tail refinement", + name: "jc", + decisions: [{ id: "r2-tail-01", verdict: "keep", note: "strongest tail" }], + }), + }), + env, + ); + + assert.equal(submit.status, 200); + const verdicts = await worker.fetch( + new Request(`https://brand-studio.example/share/${id}/verdicts`), + env, + ); + + const body = await verdicts.json(); + assert.match(body.submissions[0].ts, /\d{4}-\d{2}-\d{2}T/); + delete body.submissions[0].ts; + assert.deepEqual(body, { + id, + count: 1, + submissions: [ + { + series: "org-logo", + round: 2, + title: "tail refinement", + name: "jc", + decisions: [{ id: "r2-tail-01", verdict: "keep", note: "strongest tail" }], + }, + ], + }); +}); diff --git a/share-server/worker.js b/share-server/worker.js index 1803eca..9fad315 100644 --- a/share-server/worker.js +++ b/share-server/worker.js @@ -1,7 +1,7 @@ // brand-studio share server — Cloudflare Worker + KV. -// Shares a self-contained review-board HTML at /share/ and collects -// per-reviewer verdicts the agent can read back directly (GET .../verdicts). -// ponytail: no auth by design — org-internal links, content-hash ids, short TTL. +// Shares round-review boards at /share/ and collects per-reviewer verdicts +// the agent can read back directly (GET .../verdicts). +// No auth by design: org-internal links, content-hash ids, short TTL. const TTL = 86400; // seconds idle before a share dies; every hit re-arms it. @@ -20,7 +20,45 @@ function fnv1a(str) { return h.toString(16).padStart(16, "0"); } -const slug = (s) => s.replace(/[^\w一-鿿-]+/g, "_").slice(0, 60); +const slug = (s) => String(s || "").replace(/[^\w一-鿿-]+/g, "_").slice(0, 60); +const seriesSlug = (s) => String(s || "").match(/^[\w-]{1,64}$/)?.[0] || ""; +const text = (s, max = 1000) => String(s ?? "").slice(0, max); + +const HTML_ESCAPES = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]); +} + +function scriptJson(value) { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + const next = canonical(value[key]); + if (next !== undefined) out[key] = next; + } + return out; + } + return value; +} + +const stableJson = (value) => JSON.stringify(canonical(value)); const json = (obj, status = 200) => new Response(JSON.stringify(obj), { @@ -32,43 +70,118 @@ async function touch(env, key, value) { await env.SHARES.put(key, value, { expirationTtl: TTL }); } +function normalizeRoundItem(item, index) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`items[${index}] must be an object`); + } + const id = text(item.id, 80); + if (!id) throw new Error(`items[${index}].id is required`); + + const out = {}; + for (const key of Object.keys(item).sort()) { + if (item[key] !== undefined && item[key] !== null) out[key] = item[key]; + } + out.id = id; + out.concept = text(item.concept, 500); + return canonical(out); +} + +function normalizeRoundManifest(raw, url) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("manifest must be an object"); + } + if (!Array.isArray(raw.items) || raw.items.length === 0) { + throw new Error("manifest needs a non-empty items[]"); + } + + const round = Number.parseInt(String(raw.round ?? url.searchParams.get("round") ?? "1"), 10); + if (!Number.isInteger(round) || round < 1) { + throw new Error("round must be a positive integer"); + } + + const manifest = { + series: seriesSlug(raw.series || url.searchParams.get("series") || ""), + round, + title: text(raw.title ?? url.searchParams.get("title") ?? "", 160), + goal: text(raw.goal, 2000), + prev: Array.isArray(raw.prev) ? canonical(raw.prev) : [], + items: raw.items.map(normalizeRoundItem), + }; + if (raw.meta && typeof raw.meta === "object" && !Array.isArray(raw.meta)) { + manifest.meta = canonical(raw.meta); + } + return manifest; +} + +async function readSharePayload(req, url) { + const body = await req.text(); + if (!body || body.length > 4_000_000) { + return { error: "empty or >4MB", status: 400 }; + } + + const contentType = req.headers.get("Content-Type") || ""; + const trimmed = body.trim(); + if (contentType.includes("application/json") || trimmed.startsWith("{")) { + try { + return { + kind: "manifest", + manifest: normalizeRoundManifest(JSON.parse(body), url), + }; + } catch (error) { + return { error: error.message || "invalid round manifest", status: 400 }; + } + } + + return { kind: "html", html: body }; +} + +async function storedBoard(env, id) { + const manifest = await env.SHARES.get(`r:${id}`); + if (manifest !== null) return { kind: "manifest", key: `r:${id}`, value: manifest }; + + const html = await env.SHARES.get(`s:${id}`); + if (html !== null) return { kind: "html", key: `s:${id}`, value: html }; + + return null; +} + // Series index: `x:` holds [{round, title, id, ts}] so a whole review // series lives behind one stable URL with switchable rounds (artifact // version-history style). -async function seriesUpsert(env, slug, entry) { - const raw = await env.SHARES.get(`x:${slug}`); +async function seriesUpsert(env, series, entry) { + const raw = await env.SHARES.get(`x:${series}`); const list = raw ? JSON.parse(raw) : []; const i = list.findIndex((e) => String(e.round) === String(entry.round)); if (i >= 0) list[i] = entry; else list.push(entry); list.sort((a, b) => Number(a.round) - Number(b.round)); - await touch(env, `x:${slug}`, JSON.stringify(list)); + await touch(env, `x:${series}`, JSON.stringify(list)); return list; } -function seriesPage(origin, slug, list) { +function seriesPage(origin, series, list) { const rows = [...list].reverse().map((e, i) => - `round ${e.round} · ${e.title || ""}${e.by ? ` by ${e.by}` : ""}${i === 0 ? ' latest' : ""}${(e.ts || "").slice(0, 10)}`, + `round ${escapeHtml(e.round)} · ${escapeHtml(e.title || "")}${e.by ? ` by ${escapeHtml(e.by)}` : ""}${i === 0 ? ' latest' : ""}${escapeHtml((e.ts || "").slice(0, 10))}`, ).join(""); return ` -${slug} · rounds +${escapeHtml(series)} · rounds -

brand-studio · series${slug}

${rows || "

还没有任何 round。

"}`; +

brand-studio · series${escapeHtml(series)}

${rows || "

还没有任何 round。

"}`; } // Outer chrome injected above a board (claude.ai-artifact style): org glyph, // round-history dropdown, share button. Pure prepend — board HTML untouched. -function topbar(origin, slug, list, curId) { +function topbar(origin, series, list, curId) { const cur = list.find((e) => e.id === curId) || {}; const items = [...list].reverse().map((e) => - `` + - `round ${e.round}${e.title || ""}` + - `${e.id === curId ? "Current" : ""}`, + `` + + `round ${escapeHtml(e.round)}${escapeHtml(e.title || "")}` + + `${e.id === curId ? "Current" : ""}`, ).join(""); const glyph = ``; return `
@@ -92,11 +205,11 @@ function topbar(origin, slug, list, curId) { #bs-pop .bs-all:hover{background:#F5EFE4} ${glyph} - -Board${cur.by ? " by " + cur.by : " · brand-studio"} +Board${cur.by ? " by " + escapeHtml(cur.by) : " · brand-studio"} -

Round history

${items}全部 rounds →
+

Round history

${items}全部 rounds →
`; +} + export default { async fetch(req, env) { const url = new URL(req.url); @@ -125,12 +418,12 @@ export default { // Series routes: GET /s/ (round picker page), GET /s//index.json const sm = url.pathname.match(/^\/s\/([\w-]{1,64})(\/index\.json)?$/); if (sm && req.method === "GET") { - const [, slug, wantJson] = sm; - const raw = await env.SHARES.get(`x:${slug}`); + const [, series, wantJson] = sm; + const raw = await env.SHARES.get(`x:${series}`); const list = raw ? JSON.parse(raw) : []; - if (raw) await touch(env, `x:${slug}`, raw); - if (wantJson) return json({ series: slug, rounds: list }); - return new Response(seriesPage(url.origin, slug, list), { + if (raw) await touch(env, `x:${series}`, raw); + if (wantJson) return json({ series, rounds: list }); + return new Response(seriesPage(url.origin, series, list), { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, }); } @@ -143,19 +436,46 @@ export default { } const [, id, tail] = m; - // POST /share[?series=&round=&title=] — publish a board. + // POST /share with JSON manifest stores the manifest as the remote SOT. + // Legacy HTML bodies are still accepted and stored as `s:`. if (!id && req.method === "POST") { - const html = await req.text(); - if (!html || html.length > 4_000_000) return json({ error: "empty or >4MB" }, 400); - const newId = fnv1a(html); // content-derived => idempotent republish - await touch(env, `s:${newId}`, html); - const series = (url.searchParams.get("series") || "").match(/^[\w-]{1,64}$/)?.[0]; + const payload = await readSharePayload(req, url); + if (payload.error) return json({ error: payload.error }, payload.status || 400); + + if (payload.kind === "manifest") { + const raw = stableJson(payload.manifest); + const newId = fnv1a(raw); + await touch(env, `r:${newId}`, raw); + const series = payload.manifest.series; + let shareUrl = `${url.origin}/share/${newId}`; + if (series) { + await seriesUpsert(env, series, { + round: payload.manifest.round, + title: payload.manifest.title, + by: text(url.searchParams.get("by") || "", 40), + id: newId, + ts: new Date().toISOString(), + }); + shareUrl += `?series=${series}`; + } + return json({ + id: newId, + url: shareUrl, + series: series || null, + round: payload.manifest.round, + title: payload.manifest.title, + }); + } + + const newId = fnv1a(payload.html); // content-derived => idempotent republish + await touch(env, `s:${newId}`, payload.html); + const series = seriesSlug(url.searchParams.get("series") || ""); let shareUrl = `${url.origin}/share/${newId}`; if (series) { await seriesUpsert(env, series, { round: url.searchParams.get("round") || "1", title: url.searchParams.get("title") || "", - by: (url.searchParams.get("by") || "").slice(0, 40), + by: text(url.searchParams.get("by") || "", 40), id: newId, ts: new Date().toISOString(), }); @@ -177,11 +497,10 @@ export default { return json({ error: "need {name, decisions[]}" }, 400); } body.ts = new Date().toISOString(); - const boardKey = `s:${id}`; - const board = await env.SHARES.get(boardKey); + const board = await storedBoard(env, id); if (board === null) return json({ error: "share expired" }, 410); await touch(env, `v:${id}:${slug(body.name)}`, JSON.stringify(body)); - await touch(env, boardKey, board); // activity re-arms the board too + await touch(env, board.key, board.value); // activity re-arms the board too return json({ ok: true, name: body.name, count: body.decisions.length }); } @@ -197,24 +516,30 @@ export default { return json({ id, count: submissions.length, submissions }); } - // GET /share/:id — serve the board, re-arm its TTL. With ?series= the - // server injects an outer chrome topbar (round history dropdown, share), - // so every board — old or new — gets it without touching stored HTML. + // GET /share/:id — serve the board, re-arm its TTL. With a series in the + // manifest or URL, inject an outer round-history topbar. if (!tail && req.method === "GET") { - const html = await env.SHARES.get(`s:${id}`); - if (html === null) { + const board = await storedBoard(env, id); + if (board === null) { return new Response("这个 share 已过期(闲置超过 1 天)或不存在。", { status: 410, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } - await touch(env, `s:${id}`, html); - let body = html; - const series = (url.searchParams.get("series") || "").match(/^[\w-]{1,64}$/)?.[0]; - if (series) { - const raw = await env.SHARES.get(`x:${series}`); + await touch(env, board.key, board.value); + + let body = board.value; + let boardSeries = seriesSlug(url.searchParams.get("series") || ""); + if (board.kind === "manifest") { + const manifest = JSON.parse(board.value); + body = renderShareReview(manifest); + boardSeries = boardSeries || manifest.series; + } + + if (boardSeries) { + const raw = await env.SHARES.get(`x:${boardSeries}`); const list = raw ? JSON.parse(raw) : []; - if (list.length) body = topbar(url.origin, series, list, id) + html; + if (list.length) body = topbar(url.origin, boardSeries, list, id) + body; } return new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, diff --git a/skills/brand-studio/SKILL.md b/skills/brand-studio/SKILL.md index 602106d..89e1f0c 100644 --- a/skills/brand-studio/SKILL.md +++ b/skills/brand-studio/SKILL.md @@ -548,10 +548,10 @@ bakes them into a single hardcoded prompt. comparable across rounds, with round goal, prev-round nav, and next-round direction built in. The board ships over three channels — pick by audience: **local** (`serve-review.py`, default for a solo reviewer — decisions save - straight to disk); **share server** (default for multi-person review: fill - `assets/share-review.html` with the round's items — inline SVGs / data-URI - images, fully self-contained — and `POST` it to the org share server, e.g. - `https://brand-studio.sma1lboy.me/share` → one public link, no login, every + straight to disk); **share server** (default for multi-person review: `POST` + the same round manifest JSON to the org share server, e.g. + `https://brand-studio.sma1lboy.me/share`; use inline SVGs / data-URI images + for remote visual items so the public board is self-contained → one public link, no login, every reviewer submits by name, and the agent reads the merged verdicts back directly; see `share-server/README.md`); **Artifact** (fallback when no share server is deployed — note artifacts publish private-by-default and @@ -581,7 +581,7 @@ bakes them into a single hardcoded prompt. 5. **Next round** — offer to iterate: refine a pick, try a new direction/style, or pull in a new reference element. Loop until the user is done. -`round.json` schema: `{ round, title, goal, prev: [{round, title, data}], +`round.json` schema: `{ series, round, title, goal, prev: [{round, title, data}], items: [{id, concept, img}] }`. Each round is one `round.json`; the same `assets/round-review.html` renders it. Keep a `rounds.json` index next to it (`[{round, title, data}]`, one entry per round) so every round page cross-links diff --git a/skills/brand-studio/assets/share-review.html b/skills/brand-studio/assets/share-review.html index 907d46b..e809114 100644 --- a/skills/brand-studio/assets/share-review.html +++ b/skills/brand-studio/assets/share-review.html @@ -1,4 +1,4 @@ -org mark · round 1 评审 +brand-studio · round review