privateboard 0.1.38 → 0.1.41

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 (78) hide show
  1. package/dist/boot.js +327 -50
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +327 -50
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +201 -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-overlay.css +14 -6
  13. package/public/agent-overlay.js +6 -6
  14. package/public/agent-profile.css +9 -12
  15. package/public/agent-profile.js +91 -38
  16. package/public/app.js +471 -528
  17. package/public/avatar-3d-snap.js +205 -0
  18. package/public/avatar-3d.js +836 -0
  19. package/public/avatar-customizer.html +274 -0
  20. package/public/avatar3d-editor.css +240 -0
  21. package/public/avatar3d-editor.js +484 -0
  22. package/public/avatars/3d/chair.png +0 -0
  23. package/public/avatars/3d/first-principles.png +0 -0
  24. package/public/avatars/3d/historian.png +0 -0
  25. package/public/avatars/3d/long-horizon.png +0 -0
  26. package/public/avatars/3d/phenomenologist.png +0 -0
  27. package/public/avatars/3d/socrates.png +0 -0
  28. package/public/avatars/3d/user-empathy.png +0 -0
  29. package/public/avatars/3d/value-investor.png +0 -0
  30. package/public/core-avatars.js +86 -0
  31. package/public/home-3d-loader.js +15 -4
  32. package/public/home-3d-mock.js +25 -14
  33. package/public/home.html +78 -16
  34. package/public/i18n.js +8 -4
  35. package/public/icons/avatar_1779855104027.glb +0 -0
  36. package/public/icons/logo.png +0 -0
  37. package/public/icons/new-style.glb +0 -0
  38. package/public/icons/new-style2.glb +0 -0
  39. package/public/icons/new-style3.glb +0 -0
  40. package/public/icons/new-style4.glb +0 -0
  41. package/public/icons/new-style5.glb +0 -0
  42. package/public/icons/new-style6.glb +0 -0
  43. package/public/icons/office.glb +0 -0
  44. package/public/icons/stuff.glb +0 -0
  45. package/public/index.html +169 -141
  46. package/public/magazine.html +1 -1
  47. package/public/new-agent.js +46 -20
  48. package/public/newspaper.html +1 -1
  49. package/public/office-viewer.html +340 -0
  50. package/public/ppt.html +1 -1
  51. package/public/stuff-viewer.html +330 -0
  52. package/public/thread.css +16 -15
  53. package/public/user-settings.css +7 -31
  54. package/public/user-settings.js +75 -89
  55. package/public/vendor/BufferGeometryUtils.js +1434 -0
  56. package/public/vendor/DRACOLoader.js +739 -0
  57. package/public/vendor/GLTFLoader.js +4860 -0
  58. package/public/vendor/RoomEnvironment.js +185 -0
  59. package/public/vendor/SkeletonUtils.js +496 -0
  60. package/public/vendor/draco/draco_decoder.js +34 -0
  61. package/public/vendor/draco/draco_decoder.wasm +0 -0
  62. package/public/vendor/draco/draco_encoder.js +33 -0
  63. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  64. package/public/vendor/meshopt_decoder.module.js +196 -0
  65. package/public/voice-3d-banner.js +19 -7
  66. package/public/voice-3d.js +1407 -432
  67. package/public/voice-replay.js +21 -0
  68. package/public/avatar-skill.js +0 -629
  69. package/public/avatars/chair-blink.svg +0 -1
  70. package/public/avatars/chair.svg +0 -1
  71. package/public/avatars/first-principles.svg +0 -1
  72. package/public/avatars/historian.svg +0 -1
  73. package/public/avatars/long-horizon.svg +0 -1
  74. package/public/avatars/phenomenologist.svg +0 -1
  75. package/public/avatars/socrates.svg +0 -1
  76. package/public/avatars/user-empathy.svg +0 -1
  77. package/public/avatars/value-investor.svg +0 -1
  78. package/public/icons/folded-sidebar.png +0 -0
@@ -0,0 +1,484 @@
1
+ /* ═══════════════════════════════════════════
2
+ 3D AVATAR EDITOR (捏 avatar) · profile overlay
3
+ ───────────────────────────────────────────
4
+ Opened from the director profile's ⋯ menu (data-ap-menu-action=
5
+ "edit-avatar3d") via window.openAvatar3DEditor(slug). Left pane is a
6
+ three.js stage; right pane is the customizer panel. On Save it renders a
7
+ PNG portrait and PATCHes { avatarPath: png, avatar3d: config } so the 2D
8
+ avatar everywhere updates AND the voice room can rebuild the 3D figure.
9
+
10
+ three.js is imported lazily on first open so it doesn't cost every page
11
+ load. Mirrors the room-settings overlay open/close/dismiss conventions.
12
+ ═══════════════════════════════════════════ */
13
+ (function () {
14
+ // ── DOM (built once) ──────────────────────────────────────────────
15
+ let overlay = null, modal = null, stageEl = null, panelBody = null;
16
+ let statusEl = null, saveBtn = null;
17
+
18
+ // ── three.js + scene (loaded lazily, kept across opens) ───────────
19
+ let THREE = null, av = null, OrbitControls = null, RoomEnvironment = null;
20
+ let renderer = null, scene = null, camera = null, controls = null;
21
+ let pmrem = null, ground = null, group = null, ro = null;
22
+ let booted = false; // scene built
23
+ let raf = null;
24
+
25
+ // ── per-open state ────────────────────────────────────────────────
26
+ // target · who we're editing. { type:"agent", slug } saves to the agent
27
+ // record; { type:"user" } saves to the user's prefs.
28
+ let target = null;
29
+ let buildSeed = "editor"; // seed passed to buildAvatar3D (deterministic look)
30
+ let sel = null; // { model, hairStyle, outfitStyle, accessory, skin, hair, brow, outfit }
31
+ let saving = false;
32
+
33
+ const FULL_CAM = { pos: [0.7, 1.45, 2.7], target: [0, 0.9, 0] };
34
+ const AVATAR_HEIGHT = 1.6;
35
+
36
+ function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
37
+
38
+ /* ── DOM scaffold ────────────────────────────────────────────────── */
39
+ function buildDom() {
40
+ if (overlay) return;
41
+ overlay = document.createElement("div");
42
+ overlay.className = "avatar3d-overlay";
43
+ overlay.setAttribute("aria-hidden", "true");
44
+ overlay.innerHTML = `
45
+ <div class="avatar3d-modal" role="dialog" aria-modal="true" aria-label="3D avatar editor">
46
+ <div class="av3d-stage">
47
+ <div class="av3d-hint">拖动旋转 · 滚轮缩放</div>
48
+ <div class="av3d-status"></div>
49
+ </div>
50
+ <div class="av3d-panel">
51
+ <div class="av3d-head">
52
+ <h2 class="av3d-kicker">捏 avatar</h2>
53
+ <button type="button" class="av3d-close" data-av3d-close aria-label="Close">✕</button>
54
+ </div>
55
+ <div class="av3d-body">
56
+ <div class="av3d-grp"><div class="av3d-lab">发型 Hair</div><div class="av3d-sw is-pill" data-row="hairStyle"></div></div>
57
+ <div class="av3d-grp"><div class="av3d-lab">眼眉 Brow</div><div class="av3d-sw is-pill" data-row="browStyle"></div></div>
58
+ <div class="av3d-grp"><div class="av3d-lab">眼睛 Eyes</div><div class="av3d-sw is-pill" data-row="eyeStyle"></div></div>
59
+ <div class="av3d-grp"><div class="av3d-lab">衣服款式 Outfit</div><div class="av3d-sw is-pill" data-row="outfitStyle"></div></div>
60
+ <div class="av3d-grp"><div class="av3d-lab">领带 Tie</div><div class="av3d-sw is-pill" data-row="tieStyle"></div></div>
61
+ <div class="av3d-grp"><div class="av3d-lab">装饰 Accessory</div><div class="av3d-sw is-pill" data-row="accessory"></div></div>
62
+ <div class="av3d-grp"><div class="av3d-lab">肤色 Skin</div><div class="av3d-sw is-color" data-row="skin"></div></div>
63
+ <div class="av3d-grp"><div class="av3d-lab">发色 Hair</div><div class="av3d-sw is-color" data-row="hair"></div></div>
64
+ <div class="av3d-grp"><div class="av3d-lab">眉色 Brow</div><div class="av3d-sw is-color" data-row="brow"></div></div>
65
+ <div class="av3d-grp"><div class="av3d-lab">瞳色 Eye</div><div class="av3d-sw is-color" data-row="eye"></div></div>
66
+ <div class="av3d-grp"><div class="av3d-lab">衣色 Outfit color</div><div class="av3d-sw is-color" data-row="outfit"></div></div>
67
+ <div class="av3d-grp"><div class="av3d-lab">颈饰色 Tie/Bow</div><div class="av3d-sw is-color" data-row="tie"></div></div>
68
+ </div>
69
+ <div class="av3d-foot">
70
+ <button type="button" class="av3d-btn-rand" data-av3d-rand title="随机">🎲</button>
71
+ <button type="button" class="av3d-btn-cancel" data-av3d-close>取消</button>
72
+ <button type="button" class="av3d-btn-save" data-av3d-save>保存</button>
73
+ </div>
74
+ </div>
75
+ </div>`;
76
+ document.body.appendChild(overlay);
77
+ modal = overlay.querySelector(".avatar3d-modal");
78
+ stageEl = overlay.querySelector(".av3d-stage");
79
+ panelBody = overlay.querySelector(".av3d-body");
80
+ statusEl = overlay.querySelector(".av3d-status");
81
+ saveBtn = overlay.querySelector("[data-av3d-save]");
82
+
83
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
84
+ overlay.querySelectorAll("[data-av3d-close]").forEach((b) =>
85
+ b.addEventListener("click", (e) => { e.preventDefault(); close(); }));
86
+ overlay.querySelector("[data-av3d-rand]").addEventListener("click", (e) => { e.preventDefault(); randomize(); });
87
+ saveBtn.addEventListener("click", (e) => { e.preventDefault(); void save(); });
88
+ // Escape closes the editor only (stop it from also closing the profile,
89
+ // which listens in capture phase).
90
+ document.addEventListener("keydown", (e) => {
91
+ if (e.key === "Escape" && overlay.classList.contains("open")) {
92
+ e.stopImmediatePropagation();
93
+ close();
94
+ }
95
+ }, true);
96
+ }
97
+
98
+ /* ── three.js scene · built once on first open ───────────────────── */
99
+ async function ensureScene() {
100
+ if (booted) return;
101
+ THREE = await import("/vendor/three.module.min.js");
102
+ ({ OrbitControls } = await import("/vendor/OrbitControls.js"));
103
+ ({ RoomEnvironment } = await import("/vendor/RoomEnvironment.js"));
104
+ av = await import("/avatar-3d.js");
105
+
106
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
107
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
108
+ renderer.shadowMap.enabled = true;
109
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
110
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
111
+ renderer.toneMappingExposure = 0.7;
112
+ renderer.setClearColor(0x000000, 0); // transparent · CSS gradient shows through
113
+ stageEl.appendChild(renderer.domElement);
114
+
115
+ scene = new THREE.Scene();
116
+ camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
117
+ controls = new OrbitControls(camera, renderer.domElement);
118
+ controls.enableDamping = true; controls.dampingFactor = 0.1;
119
+ controls.minDistance = 1.4; controls.maxDistance = 5;
120
+ controls.enablePan = false;
121
+
122
+ pmrem = new THREE.PMREMGenerator(renderer);
123
+ scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
124
+ scene.environmentIntensity = 0.35;
125
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x33384a, 0.18));
126
+ const key = new THREE.DirectionalLight(0xffffff, 0.7);
127
+ key.position.set(3, 6, 4); key.castShadow = true; scene.add(key);
128
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.3);
129
+ rim.position.set(-4, 3, -3); scene.add(rim);
130
+ ground = new THREE.Mesh(
131
+ new THREE.CircleGeometry(2.0, 48),
132
+ new THREE.MeshStandardMaterial({ color: 0x20232a, roughness: 1 }),
133
+ );
134
+ ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
135
+
136
+ applyCamera(FULL_CAM);
137
+ ro = new ResizeObserver(() => resize());
138
+ ro.observe(stageEl);
139
+ raf = renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
140
+ booted = true;
141
+ }
142
+
143
+ function applyCamera({ pos, target }) {
144
+ camera.position.set(pos[0], pos[1], pos[2]);
145
+ controls.target.set(target[0], target[1], target[2]);
146
+ controls.update();
147
+ }
148
+
149
+ function resize() {
150
+ if (!renderer || !stageEl) return;
151
+ const w = stageEl.clientWidth || 1, h = stageEl.clientHeight || 1;
152
+ renderer.setSize(w, h, false);
153
+ renderer.domElement.style.width = w + "px";
154
+ renderer.domElement.style.height = h + "px";
155
+ camera.aspect = w / h;
156
+ camera.updateProjectionMatrix();
157
+ }
158
+
159
+ /* ── build / recolour the avatar from `sel` ──────────────────────── */
160
+ function rebuild() {
161
+ if (group) { scene.remove(group); group = null; }
162
+ group = av.buildAvatar3D(buildSeed || "editor", {
163
+ model: sel.model, hairStyle: sel.hairStyle, outfitStyle: sel.outfitStyle,
164
+ browStyle: sel.browStyle, tieStyle: sel.tieStyle, eyeStyle: sel.eyeStyle,
165
+ accessory: sel.accessory, height: AVATAR_HEIGHT,
166
+ skin: sel.skin, hair: sel.hair, brow: sel.brow, outfit: sel.outfit, tie: sel.tie, eye: sel.eye,
167
+ });
168
+ if (group) scene.add(group);
169
+ }
170
+
171
+ /* ── panel population ────────────────────────────────────────────── */
172
+ // Body style ("model") is intentionally NOT user-selectable — it's fixed by
173
+ // the saved/derived config; the user shapes the look via hair / outfit /
174
+ // accessory / colours instead.
175
+ const PILL_LISTS = () => ({
176
+ hairStyle: av.HAIR_STYLES,
177
+ browStyle: av.BROW_STYLES,
178
+ eyeStyle: av.EYE_STYLES,
179
+ outfitStyle: av.OUTFIT_STYLES,
180
+ tieStyle: av.TIE_STYLES,
181
+ accessory: av.ACCESSORY_STYLES,
182
+ });
183
+ const COLOR_LISTS = () => ({
184
+ skin: av.AVATAR_PALETTES.skin,
185
+ hair: av.AVATAR_PALETTES.hair,
186
+ brow: av.AVATAR_PALETTES.brow,
187
+ eye: av.AVATAR_PALETTES.eye,
188
+ outfit: av.AVATAR_PALETTES.outfit,
189
+ tie: av.AVATAR_PALETTES.tie,
190
+ });
191
+
192
+ function buildPanel() {
193
+ const pills = PILL_LISTS();
194
+ for (const role of Object.keys(pills)) {
195
+ const c = panelBody.querySelector(`[data-row="${role}"]`);
196
+ c.innerHTML = "";
197
+ pills[role].forEach((opt) => {
198
+ const b = document.createElement("button");
199
+ b.type = "button";
200
+ b.textContent = opt.label;
201
+ if (opt.id === sel[role]) b.classList.add("on");
202
+ b.addEventListener("click", () => {
203
+ if (sel[role] === opt.id) return;
204
+ sel[role] = opt.id;
205
+ markOn(c, b);
206
+ rebuild(); // hair / outfit / accessory all overlay from preloaded models
207
+ });
208
+ c.appendChild(b);
209
+ });
210
+ }
211
+ const colors = COLOR_LISTS();
212
+ for (const role of Object.keys(colors)) {
213
+ const c = panelBody.querySelector(`[data-row="${role}"]`);
214
+ c.innerHTML = "";
215
+ colors[role].forEach((hex) => {
216
+ const b = document.createElement("button");
217
+ b.type = "button";
218
+ b.style.background = hex;
219
+ b.title = hex;
220
+ if (String(sel[role]).toLowerCase() === hex.toLowerCase()) b.classList.add("on");
221
+ b.addEventListener("click", () => {
222
+ sel[role] = hex;
223
+ markOn(c, b);
224
+ av.recolorAvatar(group, { [role]: hex }); // instant, no rebuild
225
+ });
226
+ c.appendChild(b);
227
+ });
228
+ }
229
+ }
230
+
231
+ function markOn(container, btn) {
232
+ container.querySelectorAll("button").forEach((x) => x.classList.remove("on"));
233
+ btn.classList.add("on");
234
+ }
235
+
236
+ function randomize() {
237
+ const P = av.AVATAR_PALETTES;
238
+ const r = (a) => a[Math.floor(Math.random() * a.length)];
239
+ sel.skin = r(P.skin); sel.hair = r(P.hair); sel.brow = r(P.brow); sel.outfit = r(P.outfit); sel.tie = r(P.tie); sel.eye = r(P.eye);
240
+ av.recolorAvatar(group, sel);
241
+ // re-mark the colour swatches
242
+ for (const role of ["skin", "hair", "brow", "outfit", "tie", "eye"]) {
243
+ const c = panelBody.querySelector(`[data-row="${role}"]`);
244
+ c.querySelectorAll("button").forEach((x) =>
245
+ x.classList.toggle("on", x.title.toLowerCase() === String(sel[role]).toLowerCase()));
246
+ }
247
+ }
248
+
249
+ /* ── config normalization · saved blob → editor sel ──────────────── */
250
+ function normalizeConfig(cfg) {
251
+ if (!cfg || typeof cfg !== "object") return null;
252
+ const need = ["model", "hairStyle", "outfitStyle", "accessory", "skin", "hair", "brow", "outfit"];
253
+ for (const k of need) if (typeof cfg[k] !== "string" || !cfg[k]) return null;
254
+ const out = { ...cfg };
255
+ // Newer dimensions · default when absent (configs saved before they existed).
256
+ if (typeof out.browStyle !== "string" || !out.browStyle) out.browStyle = "default";
257
+ if (typeof out.tieStyle !== "string" || !out.tieStyle) out.tieStyle = "none";
258
+ if (typeof out.eyeStyle !== "string" || !out.eyeStyle) out.eyeStyle = "default";
259
+ if (typeof out.tie !== "string" || !out.tie) out.tie = (av.AVATAR_PALETTES.tie || ["#3b5b78"])[0];
260
+ if (typeof out.eye !== "string" || !out.eye) out.eye = (av.AVATAR_PALETTES.eye || ["#0d0d0d"])[0];
261
+ return out;
262
+ }
263
+
264
+ // World-space bounding box of the avatar's FACE features (eyes / brow /
265
+ // mouth / teeth). Stable across hairstyles + accessories, so it's a reliable
266
+ // anchor for consistent portrait framing. Null if no face meshes are found.
267
+ // NOTE: "eyewhite" is intentionally excluded — some models name their white
268
+ // SHOES "White", which resolveRole tags as eyewhite, dragging the box to the
269
+ // feet. Eye/brow/mouth/teeth are reliably head-only.
270
+ const FACE_ROLES = ["eye", "brow", "mouth", "teeth"];
271
+ function getFaceBox(g) {
272
+ if (!g) return null;
273
+ g.updateMatrixWorld(true);
274
+ const box = new THREE.Box3();
275
+ let any = false;
276
+ g.traverse((o) => {
277
+ if (o.isMesh && o.visible && o.userData && FACE_ROLES.includes(o.userData.avatarRole)) {
278
+ box.expandByObject(o);
279
+ any = true;
280
+ }
281
+ });
282
+ return any ? box : null;
283
+ }
284
+
285
+ // Position `cam` for a consistent head-and-shoulders portrait of `g`, anchored
286
+ // on its face box so head size is uniform across hairstyles / accessories
287
+ // (the avatar's total height — incl. hats/crowns — varies, but the face
288
+ // doesn't). Multipliers in units of face height; verified across all looks.
289
+ function applyFaceFraming(cam, g) {
290
+ const face = getFaceBox(g);
291
+ if (face) {
292
+ const fc = face.getCenter(new THREE.Vector3());
293
+ const faceH = Math.max(face.max.y - face.min.y, 1e-3);
294
+ const TOP = 2.0, BOTTOM = 1.5;
295
+ const topY = fc.y + TOP * faceH, botY = fc.y - BOTTOM * faceH;
296
+ const lookY = (topY + botY) / 2, spanY = topY - botY;
297
+ const dist = (spanY / 2) / Math.tan((cam.fov * Math.PI / 180) / 2);
298
+ cam.position.set(fc.x, lookY, fc.z + dist);
299
+ cam.lookAt(fc.x, lookY, fc.z);
300
+ } else {
301
+ cam.position.set(0, 1.4, 1.6);
302
+ cam.lookAt(0, 1.22, 0);
303
+ }
304
+ cam.updateProjectionMatrix();
305
+ }
306
+
307
+ /* ── render a square head/shoulders PNG portrait ─────────────────── */
308
+ function capturePng(size = 384) {
309
+ const prevPos = camera.position.clone();
310
+ const prevTarget = controls.target.clone();
311
+ const prevAspect = camera.aspect;
312
+ const prevW = stageEl.clientWidth || size, prevH = stageEl.clientHeight || size;
313
+ const prevPR = renderer.getPixelRatio();
314
+ const groundVisible = ground.visible;
315
+
316
+ ground.visible = false;
317
+ renderer.setPixelRatio(1);
318
+ renderer.setSize(size, size, false);
319
+ camera.aspect = 1;
320
+ camera.up.set(0, 1, 0);
321
+
322
+ // Consistent head-and-shoulders framing · anchored on the face (shared).
323
+ applyFaceFraming(camera, group);
324
+ renderer.render(scene, camera);
325
+ const url = renderer.domElement.toDataURL("image/png");
326
+
327
+ // restore live view
328
+ ground.visible = groundVisible;
329
+ renderer.setPixelRatio(prevPR);
330
+ renderer.setSize(prevW, prevH, false);
331
+ camera.aspect = prevAspect;
332
+ camera.position.copy(prevPos);
333
+ camera.updateProjectionMatrix();
334
+ controls.target.copy(prevTarget);
335
+ controls.update();
336
+ return url;
337
+ }
338
+
339
+ /* ── save · capture PNG + config, persist to the target ──────────── */
340
+ async function save() {
341
+ if (saving || !group || !target) return;
342
+ saving = true; saveBtn.disabled = true; setStatus("保存中…");
343
+ try {
344
+ const png = capturePng();
345
+ const cfg = { ...sel };
346
+ if (target.type === "user") await saveUser(png, cfg);
347
+ else await saveAgent(target.slug, png, cfg);
348
+ close();
349
+ } catch (e) {
350
+ console.error("[avatar3d-editor] save failed", e);
351
+ setStatus("保存失败:" + (e && e.message ? e.message : e));
352
+ } finally {
353
+ saving = false; saveBtn.disabled = false;
354
+ }
355
+ }
356
+
357
+ // Director · PATCH /api/agents/:id, then refresh roster + repaint profile.
358
+ async function saveAgent(slug, png, cfg) {
359
+ const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
360
+ const res = await fetch("/api/agents/" + encodeURIComponent(slug), {
361
+ method: "PATCH",
362
+ headers: { "content-type": "application/json" },
363
+ body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
364
+ });
365
+ if (!res.ok) throw new Error("save failed (" + res.status + ")");
366
+ const updated = await res.json();
367
+ if (live) { live.avatarPath = updated.avatarPath || png; live.avatar3d = updated.avatar3d || cfg; }
368
+ if (window.app && typeof window.app.refreshAgents === "function") await window.app.refreshAgents();
369
+ else if (window.app && typeof window.app.renderSidebarAgents === "function") window.app.renderSidebarAgents();
370
+ if (typeof window.openAgentProfile === "function") window.openAgentProfile(slug);
371
+ }
372
+
373
+ // User (host) · PUT /api/prefs, then repaint sidebar foot + settings frame.
374
+ async function saveUser(png, cfg) {
375
+ const res = await fetch("/api/prefs", {
376
+ method: "PUT",
377
+ headers: { "content-type": "application/json" },
378
+ body: JSON.stringify({ avatar3d: cfg, avatarUrl: png }),
379
+ });
380
+ if (!res.ok) throw new Error("save failed (" + res.status + ")");
381
+ const prefs = await res.json();
382
+ if (window.app) {
383
+ window.app.prefs = { ...(window.app.prefs || {}), avatar3d: prefs.avatar3d, avatarUrl: prefs.avatarUrl };
384
+ if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
385
+ }
386
+ // Let the user-settings pane repaint its avatar frame if it's open.
387
+ window.dispatchEvent(new CustomEvent("pb:user-avatar-updated", { detail: { avatarUrl: prefs.avatarUrl } }));
388
+ }
389
+
390
+ /* ── open / close ────────────────────────────────────────────────── */
391
+ // `arg` is an agent slug (string) or { kind: "user" }.
392
+ async function open(arg) {
393
+ if (arg && arg.kind === "user") {
394
+ target = { type: "user" };
395
+ } else if (typeof arg === "string" && arg) {
396
+ target = { type: "agent", slug: arg };
397
+ } else {
398
+ return;
399
+ }
400
+ buildDom();
401
+ overlay.classList.add("open");
402
+ overlay.setAttribute("aria-hidden", "false");
403
+ document.body.style.overflow = "hidden";
404
+ setStatus("加载中…");
405
+ try {
406
+ await ensureScene();
407
+ resize();
408
+ await Promise.all(av.AVATAR_MODELS.map((m) => av.loadAvatar3D(m.id)));
409
+ if (target.type === "user") {
410
+ const u = (window.app && window.app.prefs) || {};
411
+ buildSeed = u.avatarSeed || "user";
412
+ sel = normalizeConfig(u.avatar3d) || av.deriveDefaultAvatarConfig(buildSeed);
413
+ } else {
414
+ const live = window.app && window.app.agentsById ? window.app.agentsById[target.slug] : null;
415
+ buildSeed = target.slug;
416
+ sel = normalizeConfig(live && live.avatar3d) || av.deriveDefaultAvatarConfig(target.slug);
417
+ }
418
+ buildPanel();
419
+ rebuild();
420
+ applyCamera(FULL_CAM);
421
+ setStatus("");
422
+ } catch (e) {
423
+ console.error("[avatar3d-editor] open failed", e);
424
+ setStatus("加载失败:" + (e && e.message ? e.message : e));
425
+ }
426
+ }
427
+
428
+ function close() {
429
+ if (!overlay) return;
430
+ overlay.classList.remove("open");
431
+ overlay.setAttribute("aria-hidden", "true");
432
+ document.body.style.overflow = "";
433
+ }
434
+
435
+ /* ── Standalone portrait render ───────────────────────────────────
436
+ Render an avatar3d `config` to a head-and-shoulders PNG using a throwaway
437
+ offscreen renderer — same framing + lighting as the editor's capture, but
438
+ without opening the overlay. Used to give newly-created directors a 3D
439
+ screenshot avatar. Returns a PNG data URL (or null on failure). */
440
+ async function renderAvatar3DPortrait(config, size = 384) {
441
+ if (!THREE) THREE = await import("/vendor/three.module.min.js");
442
+ if (!RoomEnvironment) ({ RoomEnvironment } = await import("/vendor/RoomEnvironment.js"));
443
+ if (!av) av = await import("/avatar-3d.js");
444
+ await Promise.all(av.AVATAR_MODELS.map((m) => av.loadAvatar3D(m.id)));
445
+ const cfg = config || av.deriveDefaultAvatarConfig("portrait");
446
+ const cv = document.createElement("canvas"); cv.width = size; cv.height = size;
447
+ const r = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true, preserveDrawingBuffer: true });
448
+ r.setPixelRatio(1); r.setSize(size, size, false); r.setClearColor(0x000000, 0);
449
+ r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 0.7;
450
+ const sc = new THREE.Scene();
451
+ const pmrem = new THREE.PMREMGenerator(r);
452
+ sc.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
453
+ sc.environmentIntensity = 0.35;
454
+ sc.add(new THREE.HemisphereLight(0xffffff, 0x33384a, 0.18));
455
+ const key = new THREE.DirectionalLight(0xffffff, 0.7); key.position.set(3, 6, 4); sc.add(key);
456
+ const rim = new THREE.DirectionalLight(0xbfd4ff, 0.3); rim.position.set(-4, 3, -3); sc.add(rim);
457
+ let url = null;
458
+ try {
459
+ const g = av.buildAvatar3D("portrait", {
460
+ model: cfg.model, hairStyle: cfg.hairStyle, outfitStyle: cfg.outfitStyle,
461
+ browStyle: cfg.browStyle, tieStyle: cfg.tieStyle, eyeStyle: cfg.eyeStyle, accessory: cfg.accessory, height: AVATAR_HEIGHT,
462
+ skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit, tie: cfg.tie, eye: cfg.eye,
463
+ });
464
+ if (g) {
465
+ sc.add(g);
466
+ const cam = new THREE.PerspectiveCamera(32, 1, 0.1, 100); cam.up.set(0, 1, 0);
467
+ applyFaceFraming(cam, g);
468
+ r.render(sc, cam);
469
+ url = cv.toDataURL("image/png");
470
+ }
471
+ } finally {
472
+ // Dispose the renderer (frees this context). Do NOT dispose geometry —
473
+ // it's shared with the cached templates.
474
+ try { pmrem.dispose(); } catch (_) {}
475
+ try { r.forceContextLoss(); } catch (_) {}
476
+ try { r.dispose(); } catch (_) {}
477
+ }
478
+ return url;
479
+ }
480
+
481
+ window.openAvatar3DEditor = open;
482
+ window.closeAvatar3DEditor = close;
483
+ window.renderAvatar3DPortrait = renderAvatar3DPortrait;
484
+ })();
Binary file
Binary file
Binary file
@@ -0,0 +1,86 @@
1
+ /* ═══════════════════════════════════════════════════════════════════
2
+ core-avatars.js · canonical 3D-avatar configs for the chair +
3
+ seven core seeded directors.
4
+
5
+ ── Why this exists ─────────────────────────────────────────────────
6
+ The voice-3d.js scene (`buildDirectorFigure`) builds a director
7
+ figure from their `member.avatar3d` config when one is present,
8
+ and falls back to a deterministic RNG-driven default otherwise.
9
+ The marketing homepage's CAST (`home-3d-mock.js`), the onboarding
10
+ storyline's banner (`voice-3d-banner.js`), and the in-app voice
11
+ room itself all populate this `member.avatar3d` field — but the
12
+ marketing + onboarding paths previously sent CAST entries with NO
13
+ avatar3d, so they rendered randomised faces that didn't match the
14
+ actual chair / director look the user sees in-app or on the
15
+ product backend (`src/seed/chair.ts` + `src/seed/directors.ts`).
16
+
17
+ This file is the single shared frontend source of truth · classic-
18
+ script so it can be loaded ahead of the classic-script consumers
19
+ without a module dance. Backend (TypeScript) carries the same
20
+ constants in `src/seed/*.ts` — keep them in sync by hand.
21
+
22
+ ── How consumers use it ────────────────────────────────────────────
23
+ // After CAST is defined:
24
+ if (window.PB_CORE_AVATARS) {
25
+ for (const m of CAST) {
26
+ const cfg = window.PB_CORE_AVATARS[m.id];
27
+ if (cfg) m.avatar3d = cfg;
28
+ }
29
+ }
30
+ That's it · `voice-3d.js` does the rest of the rigging.
31
+ ═══════════════════════════════════════════════════════════════════ */
32
+ (function (root) {
33
+ if (root.PB_CORE_AVATARS) return; // idempotent · safe to load twice
34
+ root.PB_CORE_AVATARS = {
35
+ // Chair · the canonical 杨天真 portrait.
36
+ chair: {
37
+ model: "classic", hairStyle: "glasses", outfitStyle: "casual",
38
+ accessory: "glasses",
39
+ skin: "#f7d7b8", hair: "#6f4e37", brow: "#7a3b28", outfit: "#d8392b",
40
+ browStyle: "default", tieStyle: "none",
41
+ },
42
+ socrates: {
43
+ model: "classic", hairStyle: "street", outfitStyle: "street",
44
+ accessory: "glasses",
45
+ skin: "#e0ac69", hair: "#4a3526", brow: "#241c16", outfit: "#7a5a3b",
46
+ browStyle: "default", tieStyle: "none",
47
+ },
48
+ "first-principles": {
49
+ model: "glasses", hairStyle: "royal", outfitStyle: "classic",
50
+ accessory: "none",
51
+ skin: "#e0ac69", hair: "#6e6e6e", brow: "#3a2a1e", outfit: "#1a1a1a",
52
+ browStyle: "default", tieStyle: "xmas",
53
+ tie: "#d8392b", eye: "#0d0d0d",
54
+ },
55
+ "value-investor": {
56
+ model: "casual", hairStyle: "classic", outfitStyle: "casual",
57
+ accessory: "none",
58
+ skin: "#f7d7b8", hair: "#8d6a45", brow: "#7a3b28", outfit: "#6b3f4a",
59
+ browStyle: "default", tieStyle: "none",
60
+ },
61
+ historian: {
62
+ model: "glasses", hairStyle: "classic", outfitStyle: "classic",
63
+ accessory: "shades",
64
+ skin: "#ffe0bd", hair: "#6f4e37", brow: "#6f4e37", outfit: "#e0b400",
65
+ browStyle: "default", tieStyle: "none",
66
+ },
67
+ "user-empathy": {
68
+ model: "classic", hairStyle: "glasses", outfitStyle: "street",
69
+ accessory: "glasses",
70
+ skin: "#f7d7b8", hair: "#6f4e37", brow: "#6f4e37", outfit: "#0fb5b5",
71
+ browStyle: "default", tieStyle: "none",
72
+ },
73
+ "long-horizon": {
74
+ model: "classic", hairStyle: "none", outfitStyle: "casual",
75
+ accessory: "none",
76
+ skin: "#f7d7b8", hair: "#3a3a3a", brow: "#3a3a3a", outfit: "#3f4a6b",
77
+ browStyle: "royal", tieStyle: "none",
78
+ },
79
+ phenomenologist: {
80
+ model: "glasses", hairStyle: "classic", outfitStyle: "classic",
81
+ accessory: "glasses",
82
+ skin: "#8d5524", hair: "#b08d57", brow: "#e8cf9a", outfit: "#7a4a52",
83
+ browStyle: "default", tieStyle: "royal",
84
+ },
85
+ };
86
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -61,7 +61,11 @@
61
61
  setTimeout(() => hintEl.classList.remove("is-visible"), 3500);
62
62
  }
63
63
 
64
- async function mountHero3d(heroEl, stageEl, hintEl) {
64
+ async function mountHero3d(heroEl, stageEl, hintEl, loadingEl) {
65
+ // Drop the loading veil on any non-mounted exit (capability bail,
66
+ // mount failure, import throw) so it never spins forever. On the
67
+ // success path the `[data-mounted]` CSS rule fades it instead.
68
+ const hideLoading = () => { if (loadingEl) loadingEl.setAttribute("hidden", ""); };
65
69
  try {
66
70
  // Import voice-3d.js · its top-level `import` of three.module +
67
71
  // OrbitControls pulls those in too. The IIFE inside sets
@@ -70,7 +74,7 @@
70
74
  // after the import completes.
71
75
  await import("/voice-3d.js");
72
76
  const VS3D = window.VoiceStage3D;
73
- if (!VS3D || !VS3D.isSupported || !VS3D.isSupported()) return;
77
+ if (!VS3D || !VS3D.isSupported || !VS3D.isSupported()) { hideLoading(); return; }
74
78
 
75
79
  // The stage host must be visible (display: block, not [hidden])
76
80
  // before mount, otherwise WebGLRenderer reads a 0×0 canvas size
@@ -96,7 +100,7 @@
96
100
  // top of the poster would just darken it before the canvas
97
101
  // takes over.
98
102
  loading: false,
99
- })) return;
103
+ })) { hideLoading(); return; }
100
104
 
101
105
  // Pull the cast + start the rotating-speaker driver. The mock
102
106
  // module is small (~3KB) so importing it after VS3D.mount keeps
@@ -116,6 +120,7 @@
116
120
  } catch (e) {
117
121
  // Silently fall back to the poster. The visitor still sees a
118
122
  // polished hero, just without the live scene.
123
+ hideLoading();
119
124
  try { console.warn("[home-3d] mount failed, poster fallback:", e); } catch (_) {}
120
125
  }
121
126
  }
@@ -125,6 +130,7 @@
125
130
  if (!heroEl) return;
126
131
  const stageEl = heroEl.querySelector("[data-hero-3d-stage]");
127
132
  const hintEl = heroEl.querySelector("[data-hero-3d-hint]");
133
+ const loadingEl = heroEl.querySelector("[data-hero-3d-loading]");
128
134
  if (!stageEl) return;
129
135
 
130
136
  if (shouldSkip3d()) return; // keep poster, do nothing
@@ -139,12 +145,17 @@
139
145
  for (const entry of entries) {
140
146
  if (!entry.isIntersecting) continue;
141
147
  observer.disconnect();
148
+ // Commit to loading · reveal the loading veil NOW (before the
149
+ // idle yield + ~500KB download) so the section shows a spinner
150
+ // for the whole gap, not blank. Torn down on mount / failure
151
+ // inside mountHero3d.
152
+ if (loadingEl) loadingEl.removeAttribute("hidden");
142
153
  // Yield to the main thread so initial paint / hero font load
143
154
  // / hero CSS animations all finish before we kick off ~500KB
144
155
  // of JS download. requestIdleCallback is the right primitive
145
156
  // here; setTimeout(..., 0) fallback for Safari which still
146
157
  // lacks it as of 2026.
147
- const kick = () => mountHero3d(heroEl, stageEl, hintEl);
158
+ const kick = () => mountHero3d(heroEl, stageEl, hintEl, loadingEl);
148
159
  if (typeof window.requestIdleCallback === "function") {
149
160
  window.requestIdleCallback(kick, { timeout: 1500 });
150
161
  } else {