privateboard 0.1.38 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/boot.js +323 -50
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +323 -50
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +200 -40
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/agent-profile.css +3 -9
  13. package/public/agent-profile.js +85 -32
  14. package/public/app.js +469 -526
  15. package/public/avatar-3d-snap.js +205 -0
  16. package/public/avatar-3d.js +792 -0
  17. package/public/avatar-customizer.html +274 -0
  18. package/public/avatar3d-editor.css +240 -0
  19. package/public/avatar3d-editor.js +481 -0
  20. package/public/avatars/3d/chair.png +0 -0
  21. package/public/avatars/3d/first-principles.png +0 -0
  22. package/public/avatars/3d/historian.png +0 -0
  23. package/public/avatars/3d/long-horizon.png +0 -0
  24. package/public/avatars/3d/phenomenologist.png +0 -0
  25. package/public/avatars/3d/socrates.png +0 -0
  26. package/public/avatars/3d/user-empathy.png +0 -0
  27. package/public/avatars/3d/value-investor.png +0 -0
  28. package/public/core-avatars.js +86 -0
  29. package/public/home-3d-loader.js +15 -4
  30. package/public/home-3d-mock.js +18 -7
  31. package/public/home.html +78 -16
  32. package/public/i18n.js +12 -4
  33. package/public/icons/avatar_1779855104027.glb +0 -0
  34. package/public/icons/logo.png +0 -0
  35. package/public/icons/new-style.glb +0 -0
  36. package/public/icons/new-style2.glb +0 -0
  37. package/public/icons/new-style3.glb +0 -0
  38. package/public/icons/new-style4.glb +0 -0
  39. package/public/icons/new-style5.glb +0 -0
  40. package/public/icons/office.glb +0 -0
  41. package/public/icons/stuff.glb +0 -0
  42. package/public/index.html +148 -121
  43. package/public/new-agent.js +46 -20
  44. package/public/office-viewer.html +340 -0
  45. package/public/stuff-viewer.html +330 -0
  46. package/public/thread.css +8 -8
  47. package/public/user-settings.css +10 -13
  48. package/public/user-settings.js +86 -78
  49. package/public/vendor/BufferGeometryUtils.js +1434 -0
  50. package/public/vendor/DRACOLoader.js +739 -0
  51. package/public/vendor/GLTFLoader.js +4860 -0
  52. package/public/vendor/RoomEnvironment.js +185 -0
  53. package/public/vendor/SkeletonUtils.js +496 -0
  54. package/public/vendor/draco/draco_decoder.js +34 -0
  55. package/public/vendor/draco/draco_decoder.wasm +0 -0
  56. package/public/vendor/draco/draco_encoder.js +33 -0
  57. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  58. package/public/vendor/meshopt_decoder.module.js +196 -0
  59. package/public/voice-3d-banner.js +12 -0
  60. package/public/voice-3d.js +1407 -432
  61. package/public/voice-replay.js +21 -0
  62. package/public/avatar-skill.js +0 -629
  63. package/public/icons/folded-sidebar.png +0 -0
package/dist/version.d.ts CHANGED
@@ -12,6 +12,6 @@
12
12
  * number ends up surfaced in the user-facing footer or banner. Keep
13
13
  * this file as the canonical source — every callsite reads from here.
14
14
  */
15
- declare const VERSION = "0.1.38";
15
+ declare const VERSION = "0.1.40";
16
16
 
17
17
  export { VERSION };
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/version.ts
4
- var VERSION = "0.1.38";
4
+ var VERSION = "0.1.40";
5
5
  export {
6
6
  VERSION
7
7
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.38\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.40\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateboard",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
5
5
  "type": "module",
6
6
  "main": "electron-entry.cjs",
@@ -0,0 +1,156 @@
1
+ <!DOCTYPE html>
2
+ <html><head><meta charset="utf-8"><title>avatar3d test</title>
3
+ <style>
4
+ html,body{margin:0;height:100%;background:#1a1a1a;overflow:hidden}
5
+ #status{position:fixed;top:8px;left:10px;color:#9fe;font:12px/1.5 monospace;z-index:9;white-space:pre-wrap;max-width:92vw}
6
+ #panel{position:fixed;top:8px;right:10px;z-index:9;background:rgba(0,0,0,.55);border:1px solid #444;border-radius:8px;padding:10px 12px;color:#cde;font:11px/1.6 monospace;width:230px}
7
+ #panel label{display:block;margin:6px 0 2px}
8
+ #panel input[type=range]{width:100%}
9
+ #panel b{color:#9fe}
10
+ #copy{margin-top:8px;width:100%;padding:5px;background:#2a3340;color:#cde;border:1px solid #556;border-radius:5px;cursor:pointer;font:11px monospace}
11
+ </style>
12
+ </head><body>
13
+ <div id="status">booting…</div>
14
+ <!-- Live brightness controls · drag to taste, then read the values off
15
+ the readout (or click "复制参数") and tell me — I'll bake them into
16
+ the room. -->
17
+ <div id="panel">
18
+ <label>曝光 exposure · <b id="vExp">0.70</b></label>
19
+ <input type="range" id="exp" min="0.2" max="1.4" step="0.01" value="0.70">
20
+ <label>环境强度 envIntensity · <b id="vEnv">0.35</b></label>
21
+ <input type="range" id="env" min="0" max="1.5" step="0.01" value="0.35">
22
+ <label>主光 key · <b id="vKey">0.70</b></label>
23
+ <input type="range" id="key" min="0" max="3" step="0.01" value="0.70">
24
+ <button id="copy">复制参数</button>
25
+ </div>
26
+
27
+ <!-- Classic script FIRST · catches module/import/protocol failures that a
28
+ top-level `import` would otherwise swallow into the console only. -->
29
+ <script>
30
+ window.__setStatus = function (msg, isErr) {
31
+ var el = document.getElementById("status");
32
+ if (!el) return;
33
+ el.textContent = msg;
34
+ el.style.color = isErr ? "#f88" : "#9fe";
35
+ };
36
+ if (location.protocol === "file:") {
37
+ window.__setStatus("⚠ 你是用 file:// 打开的 — 模块和 GLB 都加载不了。\n请在【运行中的 app】里访问 http://localhost:<端口>/__avatar3d_test.html", true);
38
+ }
39
+ window.addEventListener("error", function (e) {
40
+ // Resource (script/module) load failures arrive here as ErrorEvents.
41
+ var what = e.message || (e.target && (e.target.src || e.target.href)) || "unknown";
42
+ window.__setStatus("JS/资源错误: " + what, true);
43
+ }, true);
44
+ window.addEventListener("unhandledrejection", function (e) {
45
+ var r = e.reason; window.__setStatus("未处理的 Promise 拒绝: " + ((r && r.message) || r), true);
46
+ });
47
+ setTimeout(function () {
48
+ var el = document.getElementById("status");
49
+ if (el && /booting|importing|loading|building/i.test(el.textContent)) {
50
+ window.__setStatus(el.textContent + "\n(>6s 仍未完成 — 打开 DevTools Console 看具体报错)", true);
51
+ }
52
+ }, 6000);
53
+ </script>
54
+
55
+ <script type="module">
56
+ const S = window.__setStatus;
57
+ (async () => {
58
+ try {
59
+ // Step 0 · is the GLB even reachable on this server? (isolates
60
+ // "asset not served" from "module failed to load".)
61
+ S("检查 GLB 可达性…");
62
+ const GLB = "/icons/avatar_1779855104027.glb";
63
+ const head = await fetch(GLB, { method: "GET", headers: { Range: "bytes=0-0" } }).catch((e) => ({ ok: false, status: "fetch失败 " + e.message }));
64
+ if (!head.ok && head.status !== 206 && head.status !== 200) {
65
+ S("GLB 拿不到: " + GLB + " → " + head.status + "(文件没被服务?)", true);
66
+ return;
67
+ }
68
+
69
+ S("加载 three…");
70
+ const THREE = await import("/vendor/three.module.min.js");
71
+ S("加载 OrbitControls…");
72
+ const { OrbitControls } = await import("/vendor/OrbitControls.js");
73
+ const { RoomEnvironment } = await import("/vendor/RoomEnvironment.js");
74
+ S("加载 avatar-3d skill…");
75
+ const av = await import("/avatar-3d.js");
76
+
77
+ const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
78
+ renderer.setSize(innerWidth, innerHeight);
79
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
80
+ renderer.shadowMap.enabled = true;
81
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
82
+ // ACES tone mapping + sRGB output · the standard "make PBR look
83
+ // good" pair. Without tone mapping the env reflections clip / look
84
+ // flat; with it skin + hair get that soft filmic sheen.
85
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
86
+ renderer.toneMappingExposure = 0.7;
87
+ document.body.appendChild(renderer.domElement);
88
+
89
+ const scene = new THREE.Scene();
90
+ scene.background = new THREE.Color(0x222428);
91
+ const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
92
+ camera.position.set(1.6, 1.7, 3.0);
93
+ const controls = new OrbitControls(camera, renderer.domElement);
94
+ controls.target.set(0, 0.9, 0);
95
+ controls.update();
96
+
97
+ // Image-based lighting · a PMREM-filtered procedural RoomEnvironment
98
+ // is what gives the materials real reflections / gloss (no HDRI
99
+ // file needed). This is the single biggest lever for "光泽 / 质感".
100
+ const pmrem = new THREE.PMREMGenerator(renderer);
101
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
102
+ // RoomEnvironment is studio-bright · dim its contribution so it
103
+ // lights + reflects without washing the skin out. Keeps the gloss
104
+ // shape (reflections) while pulling overall exposure down.
105
+ scene.environmentIntensity = 0.35;
106
+
107
+ // The env map already provides ambient fill, so direct lights stay
108
+ // modest · a strong key + rim on top of IBL blew the faces out to
109
+ // pure white. One soft key for shape + a faint rim for separation.
110
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x33384a, 0.18));
111
+ const dir = new THREE.DirectionalLight(0xffffff, 0.7);
112
+ dir.position.set(3, 6, 4); dir.castShadow = true; scene.add(dir);
113
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.3);
114
+ rim.position.set(-4, 3, -3); scene.add(rim);
115
+ const ground = new THREE.Mesh(
116
+ new THREE.CircleGeometry(2.4, 48),
117
+ new THREE.MeshStandardMaterial({ color: 0x33363c, roughness: 1 }),
118
+ );
119
+ ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
120
+
121
+ renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
122
+
123
+ // Live brightness sliders → renderer / scene / key-light.
124
+ const $ = (id) => document.getElementById(id);
125
+ const wire = (id, vid, fn, fmt) => {
126
+ const el = $(id); const lab = $(vid);
127
+ const upd = () => { const v = parseFloat(el.value); fn(v); lab.textContent = fmt(v); };
128
+ el.addEventListener("input", upd); upd();
129
+ };
130
+ wire("exp", "vExp", (v) => { renderer.toneMappingExposure = v; }, (v) => v.toFixed(2));
131
+ wire("env", "vEnv", (v) => { scene.environmentIntensity = v; }, (v) => v.toFixed(2));
132
+ wire("key", "vKey", (v) => { dir.intensity = v; }, (v) => v.toFixed(2));
133
+ $("copy").addEventListener("click", () => {
134
+ const t = `exposure=${$("exp").value} envIntensity=${$("env").value} key=${$("key").value}`;
135
+ navigator.clipboard && navigator.clipboard.writeText(t);
136
+ $("copy").textContent = "已复制: " + t;
137
+ });
138
+
139
+ S("下载 + 解析 GLB…");
140
+ await av.loadAvatar3D(GLB);
141
+
142
+ S("生成 avatar 实例…");
143
+ const seeds = ["yang-tianzhen", "socrates", "first-principles"];
144
+ seeds.forEach((seed, i) => {
145
+ const a = av.buildAvatar3D(seed, { height: 1.7 });
146
+ a.position.x = (i - 1) * 1.1;
147
+ a.rotation.y = 0;
148
+ scene.add(a);
149
+ });
150
+ S("✓ 加载完成 · " + seeds.length + " 个 avatar(拖动可旋转)");
151
+ } catch (e) {
152
+ S("ERROR: " + (e && e.stack ? e.stack : (e && e.message) || e), true);
153
+ }
154
+ })();
155
+ </script>
156
+ </body></html>
@@ -1639,13 +1639,6 @@
1639
1639
  height: 76px;
1640
1640
  margin-top: -46px;
1641
1641
  margin-bottom: 0;
1642
- border-radius: 50%;
1643
- background: var(--panel-2);
1644
- /* Ring color follows the page bg so the avatar reads as separated
1645
- from the cover gradient on every theme · in light themes the ring
1646
- is white against the gray cover; in dark themes the ring is the
1647
- near-black bg against the panel-toned cover. */
1648
- border: 4px solid var(--bg);
1649
1642
  overflow: hidden;
1650
1643
  display: flex;
1651
1644
  align-items: center;
@@ -1657,8 +1650,9 @@
1657
1650
  width: 100%;
1658
1651
  height: 100%;
1659
1652
  object-fit: cover;
1660
- image-rendering: pixelated;
1661
- image-rendering: crisp-edges;
1653
+ /* The 3D-avatar portrait is a smooth downscaled PNG · keep it smooth
1654
+ (pixelated would render it blocky at this size). */
1655
+ image-rendering: auto;
1662
1656
  }
1663
1657
  /* Text column · sits to the right of the avatar in the same row,
1664
1658
  so name + meta read as one tight identifier block. */
@@ -760,39 +760,81 @@
760
760
  return tag.toUpperCase().slice(0, 8);
761
761
  }
762
762
 
763
- /* Per-agent rules (visual only · localStorage). */
763
+ /* Per-agent rules · PERSISTED SERVER-SIDE (agent.userRules) so the
764
+ orchestrator can inject them into the director's turn prompt. (They
765
+ used to be localStorage-only / "visual" — which is why a rule like
766
+ "不要谈及范冰冰" had zero effect: it never reached the model.)
767
+ A per-slug working copy backs the inputs for snappy editing; changes
768
+ debounce-flush to PATCH /api/agents/:id. */
764
769
  const RULES_MAX = 5;
765
- function rulesKey(slug) { return "boardroom.agent.rules." + slug; }
766
- function rulesForAgent(slug) {
767
- try {
768
- const raw = localStorage.getItem(rulesKey(slug));
769
- if (raw) {
770
- const arr = JSON.parse(raw);
771
- if (Array.isArray(arr)) return arr;
770
+ const _rules = Object.create(null); // slug -> string[] working copy
771
+ const _rulesTimer = Object.create(null); // slug -> debounce timer
772
+ function _legacyRulesKey(slug) { return "boardroom.agent.rules." + slug; }
773
+ function _liveAgentFor(slug) {
774
+ return (window.app && window.app.agentsById) ? window.app.agentsById[slug] : null;
775
+ }
776
+ // Seed the working copy once per slug: prefer the server value
777
+ // (agent.userRules); else migrate any legacy localStorage rules up to
778
+ // the server so a user who set rules in the old "visual-only" era
779
+ // doesn't lose them (and they start actually working).
780
+ function seedRules(slug) {
781
+ if (_rules[slug]) return _rules[slug];
782
+ const live = _liveAgentFor(slug);
783
+ let arr = (live && Array.isArray(live.userRules)) ? live.userRules.slice() : [];
784
+ if (arr.length === 0) {
785
+ try {
786
+ const raw = localStorage.getItem(_legacyRulesKey(slug));
787
+ if (raw) {
788
+ const a = JSON.parse(raw);
789
+ if (Array.isArray(a)) {
790
+ const legacy = a.map((x) => String(x).trim()).filter((x) => x.length > 0);
791
+ if (legacy.length > 0) { arr = legacy; _rules[slug] = arr; persistRules(slug); }
792
+ }
793
+ }
794
+ } catch (e) { /* */ }
795
+ }
796
+ _rules[slug] = arr;
797
+ return _rules[slug];
798
+ }
799
+ function rulesForAgent(slug) { return seedRules(slug); }
800
+ function _cleanRules(slug) {
801
+ return (_rules[slug] || []).map((x) => String(x).trim()).filter((x) => x.length > 0).slice(0, RULES_MAX);
802
+ }
803
+ function persistRules(slug) {
804
+ const arr = _cleanRules(slug);
805
+ const live = _liveAgentFor(slug);
806
+ if (live) live.userRules = arr.slice(); // optimistic
807
+ fetch("/api/agents/" + encodeURIComponent(slug), {
808
+ method: "PATCH",
809
+ headers: { "content-type": "application/json" },
810
+ body: JSON.stringify({ userRules: arr }),
811
+ }).then((r) => (r.ok ? r.json() : null)).then((updated) => {
812
+ if (updated && Array.isArray(updated.userRules) && live) {
813
+ live.userRules = updated.userRules.slice();
772
814
  }
773
- } catch (e) { /* */ }
774
- return [];
815
+ try { localStorage.removeItem(_legacyRulesKey(slug)); } catch (e) { /* */ }
816
+ }).catch(() => { /* offline · working copy keeps the edit */ });
775
817
  }
776
- function setRulesFor(slug, arr) {
777
- try { localStorage.setItem(rulesKey(slug), JSON.stringify(arr)); } catch (e) { /* */ }
818
+ function persistRulesSoon(slug) {
819
+ if (_rulesTimer[slug]) clearTimeout(_rulesTimer[slug]);
820
+ _rulesTimer[slug] = setTimeout(() => { _rulesTimer[slug] = null; persistRules(slug); }, 600);
778
821
  }
779
822
  function addRuleFor(slug) {
780
- const rules = rulesForAgent(slug);
823
+ const rules = seedRules(slug);
781
824
  if (rules.length >= RULES_MAX) return;
782
- rules.push("");
783
- setRulesFor(slug, rules);
825
+ rules.push(""); // empty rows aren't persisted until typed into
784
826
  }
785
827
  function setRuleAt(slug, idx, body) {
786
- const rules = rulesForAgent(slug);
828
+ const rules = seedRules(slug);
787
829
  if (idx < 0 || idx >= rules.length) return;
788
830
  rules[idx] = body;
789
- setRulesFor(slug, rules);
831
+ persistRulesSoon(slug);
790
832
  }
791
833
  function removeRuleFor(slug, idx) {
792
- const rules = rulesForAgent(slug);
834
+ const rules = seedRules(slug);
793
835
  if (idx < 0 || idx >= rules.length) return;
794
836
  rules.splice(idx, 1);
795
- setRulesFor(slug, rules);
837
+ persistRules(slug); // immediate on remove
796
838
  }
797
839
  function repaintProfileRules(slug) {
798
840
  const card = document.querySelector(`.ap-card[data-ap-card-slug="${slug}"]`);
@@ -1123,8 +1165,9 @@
1123
1165
  /** Render the RULES block · editable list of numbered constraints.
1124
1166
  * Mirrors the new-agent overlay UX: each row is a numbered input
1125
1167
  * with a trailing remove button; an "add rule" button below the
1126
- * list (hidden when the cap of 5 is reached). All mutations
1127
- * persist immediately via setRulesFor. */
1168
+ * list (hidden when the cap of 5 is reached). Mutations persist
1169
+ * server-side via PATCH /api/agents/:id (see setRuleAt / removeRuleFor)
1170
+ * so the orchestrator injects them into the director's prompt. */
1128
1171
  function renderRulesBlock(slug) {
1129
1172
  return `<div class="ap-rules-block" data-ap-rules-block data-slug="${escape(slug)}">${renderRulesInner(slug)}</div>`;
1130
1173
  }
@@ -2402,6 +2445,11 @@
2402
2445
  <span class="ap-id-menu-mark">◆</span>
2403
2446
  <span>Regenerate 8-bit avatar</span>
2404
2447
  </button>`);
2448
+ parts.push(`
2449
+ <button type="button" class="ap-id-menu-item" data-ap-menu-action="edit-avatar3d">
2450
+ <span class="ap-id-menu-mark">◈</span>
2451
+ <span>Customize 3D avatar</span>
2452
+ </button>`);
2405
2453
  }
2406
2454
  // Persona MD download · only present for Full-mode agents (those
2407
2455
  // built via the deep persona-builder pipeline). Their `personaSpec`
@@ -2440,17 +2488,19 @@
2440
2488
  if (el) el.remove();
2441
2489
  }
2442
2490
 
2443
- /** Generate a fresh 8-bit SVG and persist it as the agent's
2491
+ /** Render a fresh 3D voxel portrait and persist it as the agent's
2444
2492
  * avatar. Updates the live store so subsequent renders use the
2445
- * new image, then repaints the profile in place. Seeded directors
2446
- * fall back to a localStorage override (the server only stores
2447
- * user-created agents). */
2493
+ * new image, then repaints the profile in place. Uses the shared
2494
+ * Avatar3DSnap helper (same pipeline the agent-profile capture
2495
+ * and home / new-agent flows go through) — no more 8-bit SVG.
2496
+ * Seeded directors fall back to a localStorage override (the
2497
+ * server only stores user-created agents). */
2448
2498
  async function regenerateProfileAvatar(slug) {
2449
- const skill = window.AvatarSkill;
2450
- if (!skill) return;
2451
- const seed = skill.randomSeed();
2452
- const svg = skill.generate(seed);
2453
- const dataUrl = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
2499
+ const snap = window.Avatar3DSnap;
2500
+ if (!snap || typeof snap.generate !== "function") return;
2501
+ const seed = snap.randomSeed();
2502
+ const dataUrl = await snap.generate(seed);
2503
+ if (!dataUrl) return;
2454
2504
  const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
2455
2505
  if (live) {
2456
2506
  try {
@@ -4358,6 +4408,9 @@
4358
4408
  e.preventDefault();
4359
4409
  closeProfileIdMenu();
4360
4410
  if (action === "regen-avatar" && slug) regenerateProfileAvatar(slug);
4411
+ if (action === "edit-avatar3d" && slug && typeof window.openAvatar3DEditor === "function") {
4412
+ window.openAvatar3DEditor(slug);
4413
+ }
4361
4414
  if (action === "delete" && slug && window.app && typeof window.app.deleteAgent === "function") {
4362
4415
  // deleteAgent handles confirm + DELETE call + closes the
4363
4416
  // profile + refreshes the sidebar. No-op for seed/chair
@@ -5050,8 +5103,8 @@
5050
5103
  }
5051
5104
  });
5052
5105
 
5053
- // Rules · persist edits as the user types (debounce-free; the
5054
- // payload is small and writes go to localStorage).
5106
+ // Rules · persist edits as the user types · setRuleAt debounce-
5107
+ // flushes to PATCH /api/agents/:id so the orchestrator picks them up.
5055
5108
  document.addEventListener("input", (e) => {
5056
5109
  const ri = e.target.closest("[data-ap-rule-input]");
5057
5110
  if (!ri) return;