diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 017d3af..99793a9 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -297,6 +297,108 @@ function draftCopy({ name, category, draft, prompt }) { }); } +// ---- Web Designer: build a full structured project from a brief -------------- +// Reads the uploaded images and authors a COMPLETE cja_projects record (copy + +// skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns +// structured data the server validates; it never mutates the repo. +const MEDIA_RE = /^\/media\/[\w.-]+$/; +const MAX_BUILD_IMAGES = 8; + +function buildProject({ name, category, draft, prompt, images, instagram }) { + // keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO) + const srcs = (Array.isArray(images) ? images : []) + .map((im) => String(im?.src || "")) + .filter((s) => MEDIA_RE.test(s)); + const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES); + if (srcs.length > MAX_BUILD_IMAGES) { + console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`); + } + const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x + const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : ""; + + const ask = [ + "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", + "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.", + "", + "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.", + "", + "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:", + "- header: title + one-line lede", + "- facts rail: kind (project type), period (timeline label), role", + "- metrics: a few outcome stats, each {value, label, note?}", + "- gallery: your caption for each image below", + "- body: the case study", + "- skills: grouped disciplines, each {group, items[]}", + "- stack: technologies used, as plain strings", + "- categories: 1-3 short tags", + "", + `Project name: ${name}`, + category ? `Category hint: ${category}` : "", + instagram ? "There is an Instagram reel for this project." : "", + draft ? `The author's rough draft / notes:\n${draft}` : "", + prompt ? `The author's special instructions (follow these):\n${prompt}` : "", + "", + paths.length + ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}` + : "No images were uploaded.", + "", + "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.", + "", + "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:", + '- "summary": one sentence (<=160 chars) for the projects list.', + '- "lede": one punchy opening line.', + '- "body": the Markdown case study.', + '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").', + '- "period": a timeline label, e.g. "2024 — present" (or "").', + '- "role": Carlos\'s role on the project (or "").', + '- "categories": array of 1-3 short strings.', + '- "stack": array of technology strings.', + '- "skills": array of {"group": string, "items": [string, ...]}.', + '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.', + `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`, + `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`, + ].filter(Boolean).join("\n"); + + return new Promise((resolve) => { + const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"]; + const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.on("close", () => { + try { + const envelope = JSON.parse(out); + const text = typeof envelope.result === "string" ? envelope.result : ""; + const obj = JSON.parse(stripFence(text)); + // Re-zip the gallery server-side: trust OUR srcs, take the model's + // caption/alt by index. The model never dictates a file path. + const caps = Array.isArray(obj.captions) ? obj.captions : []; + const alts = Array.isArray(obj.alts) ? obj.alts : []; + const gallery = useSrcs.map((src, i) => ({ + src, + alt: String(alts[i] || "").slice(0, 200), + caption: String(caps[i] || "").slice(0, 120), + })); + const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []); + resolve({ + ok: true, + summary: obj.summary || "", + lede: obj.lede || "", + body: obj.body || "", + kind: obj.kind || "", + period: obj.period || "", + role: obj.role || "", + categories: strArr(obj.categories), + stack: strArr(obj.stack), + skills: Array.isArray(obj.skills) ? obj.skills : [], + metrics: Array.isArray(obj.metrics) ? obj.metrics : [], + gallery, + }); + } catch { resolve({ ok: false }); } + }); + child.on("error", () => resolve({ ok: false })); + }); +} + // ---- request helpers --------------------------------------------------------- function json(res, status, obj) { const body = JSON.stringify(obj); @@ -375,6 +477,22 @@ const server = createServer(async (req, res) => { return json(res, 200, d); } + // Web Designer: build a full structured project from a brief + images. + if (path === "/build-project" && method === "POST") { + if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); + const body = await readBody(req); + const name = String(body.name || "").trim(); + if (!name) return json(res, 400, { error: "Add a project name first." }); + const env = loadEnv(); + if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); + + busy = true; + const d = await buildProject(body); + busy = false; + if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." }); + return json(res, 200, d); + } + if (path === "/stream") { const id = url.searchParams.get("conversationId"); const job = id && jobs.get(id); diff --git a/api/public/controllers/adminprojects.php b/api/public/controllers/adminprojects.php index a863796..7189f8d 100644 --- a/api/public/controllers/adminprojects.php +++ b/api/public/controllers/adminprojects.php @@ -58,6 +58,8 @@ class AdminProjects extends PublicController 'cover' => $r['cover_image'] ?? '', 'categories' => json_decode($r['categories'] ?? '[]', true) ?: [], 'stack' => json_decode($r['stack'] ?? '[]', true) ?: [], + 'skills' => json_decode($r['skills'] ?? '[]', true) ?: [], + 'metrics' => json_decode($r['metrics'] ?? '[]', true) ?: [], 'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [], 'links' => json_decode($r['links'] ?? '{}', true) ?: [], ]); @@ -179,6 +181,37 @@ class AdminProjects extends PublicController $fields['stack'] = json_encode($stack, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } + // ---- skills: array of { group, items[] } ---------------------------- + if (isset($in['skills']) && is_array($in['skills'])) { + $skills = []; + foreach ($in['skills'] as $g) { + $group = mb_substr(trim((string) ($g['group'] ?? '')), 0, 60); + $items = array_values(array_filter(array_map( + fn($it) => mb_substr(trim((string) $it), 0, 60), + (array) ($g['items'] ?? []) + ))); + if ($group !== '' && $items) { + $skills[] = ['group' => $group, 'items' => $items]; + } + } + $fields['skills'] = json_encode($skills, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + // ---- metrics: array of { value, label, note? } ---------------------- + if (isset($in['metrics']) && is_array($in['metrics'])) { + $metrics = []; + foreach ($in['metrics'] as $m) { + $value = mb_substr(trim((string) ($m['value'] ?? '')), 0, 40); + $label = mb_substr(trim((string) ($m['label'] ?? '')), 0, 80); + if ($value === '' && $label === '') continue; + $metric = ['value' => $value, 'label' => $label]; + $note = mb_substr(trim((string) ($m['note'] ?? '')), 0, 120); + if ($note !== '') $metric['note'] = $note; + $metrics[] = $metric; + } + $fields['metrics'] = json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + \Db::update('cja_projects', $fields, 'project_id = ?', [$id]); $this->json(['ok' => true, 'slug' => $slug]); diff --git a/app/src/pages/admin/projects/edit.astro b/app/src/pages/admin/projects/edit.astro index 3a591e5..0432930 100644 --- a/app/src/pages/admin/projects/edit.astro +++ b/app/src/pages/admin/projects/edit.astro @@ -60,6 +60,16 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; +
+
+
+
+ +
+
+
+
+
@@ -96,6 +106,13 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; padding:.5rem 0; font-size:.95rem; color:var(--ink); font-family:var(--sans); } .sel select:focus { outline:none; border-bottom-color:var(--seal); } .btn.sm { padding:.35rem .7rem; font-size:.7rem; } + .rows { display:flex; flex-direction:column; gap:.6rem; } + .row { display:flex; gap:.6rem; align-items:center; } + .row input { flex:1; } + .row .rm { flex:0 0 auto; width:1.9rem; height:1.9rem; border:1px solid var(--rule); background:transparent; + color:var(--ink3); border-radius:2px; cursor:pointer; font-size:.9rem; line-height:1; } + .row .rm:hover { color:var(--seal); border-color:var(--seal); } + .row .grp { flex:0 0 9rem; } .row .val { flex:0 0 6rem; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; } .slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; } .thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center; @@ -128,7 +145,11 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; const msg = document.getElementById("msg"); const $ = (id) => document.getElementById(id); const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; }; - let items = []; + let items = []; // gallery + let skills = []; // [{group, items:[...]}] + let metrics = []; // [{value, label, note}] + const skillsEl = document.getElementById("skills"); + const metricsEl = document.getElementById("metrics"); function render() { galleryEl.innerHTML = ""; @@ -169,6 +190,44 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean); + // ---- skills groups ----------------------------------------------------- + function renderSkills() { + skillsEl.innerHTML = ""; + skills.forEach((g, i) => { + const row = document.createElement("div"); + row.className = "row"; + row.innerHTML = ` + + + `; + row.querySelector("[data-grp]").addEventListener("input", (e) => { skills[i].group = e.target.value; }); + row.querySelector("[data-items]").addEventListener("input", (e) => { skills[i].items = splitList(e.target.value); }); + row.querySelector("[data-rm]").addEventListener("click", () => { skills.splice(i, 1); renderSkills(); }); + skillsEl.appendChild(row); + }); + } + $("addSkill").addEventListener("click", () => { skills.push({ group: "", items: [] }); renderSkills(); }); + + // ---- metrics ----------------------------------------------------------- + function renderMetrics() { + metricsEl.innerHTML = ""; + metrics.forEach((m, i) => { + const row = document.createElement("div"); + row.className = "row"; + row.innerHTML = ` + + + + `; + row.querySelector("[data-val]").addEventListener("input", (e) => { metrics[i].value = e.target.value; }); + row.querySelector("[data-label]").addEventListener("input", (e) => { metrics[i].label = e.target.value; }); + row.querySelector("[data-note]").addEventListener("input", (e) => { metrics[i].note = e.target.value; }); + row.querySelector("[data-rm]").addEventListener("click", () => { metrics.splice(i, 1); renderMetrics(); }); + metricsEl.appendChild(row); + }); + } + $("addMetric").addEventListener("click", () => { metrics.push({ value: "", label: "", note: "" }); renderMetrics(); }); + $("save").addEventListener("click", async () => { const btn = $("save"); btn.disabled = true; setMsg("Saving…"); try { @@ -187,6 +246,8 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; body: $("f-body").value, categories: splitList($("f-cats").value), stack: splitList($("f-stack").value), + skills, + metrics, gallery: items, links, }; @@ -224,7 +285,9 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; $("f-stack").value = (p.stack || []).join(", "); $("ig").value = p.links?.instagram || ""; items = Array.isArray(p.gallery) ? p.gallery : []; - render(); + skills = Array.isArray(p.skills) ? p.skills : []; + metrics = Array.isArray(p.metrics) ? p.metrics : []; + render(); renderSkills(); renderMetrics(); } catch { setMsg("Couldn't load the project.", "error"); } })(); diff --git a/app/src/pages/admin/projects/new.astro b/app/src/pages/admin/projects/new.astro index 4c10e4e..9a3d409 100644 --- a/app/src/pages/admin/projects/new.astro +++ b/app/src/pages/admin/projects/new.astro @@ -5,7 +5,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; ← All projects

New project

-

Give it a name and a rough draft. The agent can polish the copy — then upload images and add a reel.

+

Give the Web Designer a brief — a rough draft, any special instructions, and your images. It reads the images and builds the whole page. You review everything before it goes live.

@@ -13,47 +13,78 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
- +
- + +
+ +
+ +
- - -
- -

-
- - - -
-
- +
+

Upload screenshots or photos. The Web Designer reads each one and captions it.

-

Paste a link to an existing reel, or add one later from the editor. To create one, use Instagram / your video tool, then paste the link here.

+

Paste a link to an existing reel, or add one later from the editor.

+
+ +
+ +

+
+ + +