@7ots/cli 0.1.0

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 (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * AuthManager — autenticación y estado del usuario del sitio anfitrión.
3
+ *
4
+ * El widget NO hace login: se engancha a la sesión que el sitio ya tiene.
5
+ * Tres formas de conectarlo (config.auth):
6
+ *
7
+ * { token: 'eyJ...' } // JWT fijo
8
+ * { getToken: async () => miStore.jwt } // se pide en cada llamada (soporta refresh)
9
+ * { credentials: 'include' } // sesión por cookie (same-origin)
10
+ *
11
+ * Opcionales:
12
+ * getUser: () => ({ name, plan }) // datos NO sensibles que el agente puede conocer
13
+ * exposeClaims: ['name', 'role'] // claims del JWT que el agente puede ver (por defecto ninguno)
14
+ * allowedOrigins: ['https://api.misitio.com'] // orígenes que reciben el token (además del propio)
15
+ * header: 'Authorization', scheme: 'Bearer'
16
+ *
17
+ * Garantías:
18
+ * - El token jamás se incluye en el prompt ni se manda al proxy del LLM.
19
+ * - Solo se adjunta a peticiones hacia el mismo origen o `allowedOrigins`.
20
+ * - El JWT se decodifica SOLO para leer exp/claims de presentación; la verificación
21
+ * real la hace siempre el backend del sitio.
22
+ */
23
+ export class AuthManager {
24
+ constructor({ bus, config = {} } = {}) {
25
+ this.bus = bus;
26
+ this.cfg = { header: 'Authorization', scheme: 'Bearer', exposeClaims: [], allowedOrigins: [], ...config };
27
+ this._token = this.cfg.token || null;
28
+ }
29
+
30
+ /** Actualiza el token (p. ej. tras login/logout/refresh en el sitio). */
31
+ setToken(token) {
32
+ this._token = token || null;
33
+ this.bus?.emit('auth:change', { authenticated: this.isAuthenticated(), user: this.publicUser() });
34
+ }
35
+
36
+ async getToken() {
37
+ if (typeof this.cfg.getToken === 'function') {
38
+ try {
39
+ return (await this.cfg.getToken()) || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ if (this._token && isExpired(this._token)) return null;
45
+ return this._token;
46
+ }
47
+
48
+ isAuthenticated() {
49
+ if (typeof this.cfg.isAuthenticated === 'function') return !!this.cfg.isAuthenticated();
50
+ // Con getToken/cookies el token no es legible de forma síncrona: si hay getUser,
51
+ // "hay usuario" = sesión iniciada; si no, se asume sesión y decide el backend (401).
52
+ if (typeof this.cfg.getToken === 'function' || this.cfg.credentials === 'include') {
53
+ if (typeof this.cfg.getUser !== 'function') return typeof this.cfg.getToken === 'function';
54
+ try {
55
+ return !!this.cfg.getUser();
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ return !!this._token && !isExpired(this._token);
61
+ }
62
+
63
+ /** Datos del usuario que SÍ puede ver el LLM (nombre, plan…). Nunca el token. */
64
+ publicUser() {
65
+ if (!this.isAuthenticated()) return null;
66
+ const user = {};
67
+ const claims = this._token ? decodeJwt(this._token) : null;
68
+ for (const k of this.cfg.exposeClaims) if (claims?.[k] !== undefined) user[k] = claims[k];
69
+ try {
70
+ Object.assign(user, this.cfg.getUser?.() || {});
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ return user;
75
+ }
76
+
77
+ canReceiveToken(url) {
78
+ try {
79
+ const origin = new URL(url, location.href).origin;
80
+ return origin === location.origin || this.cfg.allowedOrigins.includes(origin);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** Headers de auth para una URL (vacío si ese origen no está autorizado). */
87
+ async headersFor(url) {
88
+ if (!this.canReceiveToken(url)) return {};
89
+ const token = await this.getToken();
90
+ if (!token) return {};
91
+ return { [this.cfg.header]: this.cfg.scheme ? `${this.cfg.scheme} ${token}` : token };
92
+ }
93
+
94
+ /**
95
+ * fetch autenticado para los handlers de acciones:
96
+ * const r = await ctx.auth.fetch('/api/me/balance')
97
+ */
98
+ async fetch(url, init = {}) {
99
+ const headers = new Headers(init.headers || {});
100
+ for (const [k, v] of Object.entries(await this.headersFor(url))) headers.set(k, v);
101
+ const res = await fetch(url, {
102
+ ...init,
103
+ headers,
104
+ credentials: init.credentials || this.cfg.credentials || 'same-origin',
105
+ });
106
+ if (res.status === 401) {
107
+ this.bus?.emit('auth:change', { authenticated: false, user: null, reason: 'unauthorized' });
108
+ if (typeof this.cfg.onUnauthorized === 'function') this.cfg.onUnauthorized();
109
+ }
110
+ return res;
111
+ }
112
+ }
113
+
114
+ /** Decodifica el payload de un JWT sin verificar la firma (solo lectura de claims). */
115
+ export function decodeJwt(token) {
116
+ try {
117
+ const part = token.split('.')[1];
118
+ const json = atob(part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '='));
119
+ return JSON.parse(decodeURIComponent(escape(json)));
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function isExpired(token) {
126
+ const exp = decodeJwt(token)?.exp;
127
+ return typeof exp === 'number' && exp * 1000 < Date.now() - 5000;
128
+ }
@@ -0,0 +1,473 @@
1
+ /**
2
+ * AvatarStage — el "cuerpo" del agente.
3
+ *
4
+ * Modo 3D: TalkingHead (Three.js) con un modelo .glb con blendshapes ARKit + visemas
5
+ * Oculus (p. ej. los avatares de TalkingHead, Avaturn, o exportados desde Blender).
6
+ * Lip-sync real a partir del audio + tiempos de palabra, estados de ánimo y gestos.
7
+ *
8
+ * Modo 2D (fallback automático): si no hay WebGL o falla la carga del módulo o el .glb, se
9
+ * muestra el personaje 2D (`character`, ver src/character) con lip-sync, ánimos, mirada y
10
+ * gestos; o el retrato (`image`) con un halo que late al hablar; o una cara SVG mínima.
11
+ * `mode: '2d'` fuerza el modo 2D aunque haya WebGL (también si hay `character` y no `url`).
12
+ *
13
+ * Requisito del modo 3D en la página anfitriona (TalkingHead importa "three" por nombre):
14
+ *
15
+ * <script type="importmap">
16
+ * { "imports": {
17
+ * "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
18
+ * "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
19
+ * } }
20
+ * </script>
21
+ *
22
+ * API usada por VoiceEngine:
23
+ * await stage.speak({ text, audio }) // audio: ArrayBuffer (mp3/wav) → reproduce + lip-sync
24
+ * await stage.mimic({ text, durationMs }) // sin audio (speechSynthesis): solo mueve la boca
25
+ * stage.mimicSync(charIndex) // resincroniza con SpeechSynthesisUtterance.onboundary
26
+ * stage.stop(); stage.setMood('happy'); stage.gesture('thumbup');
27
+ * stage.morph('wings') / stage.effect('hearts') // solo 2D (transformaciones y efectos del ot)
28
+ *
29
+ * Gestos: en 2D valen todos los de CHARACTER_GESTURES (y sus alias: handup, index, ok…);
30
+ * en 3D se traducen al gesto de TalkingHead más parecido (GESTURE_3D).
31
+ */
32
+
33
+ export const TALKINGHEAD_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7.0/modules/talkinghead.mjs';
34
+ export const DEFAULT_AVATAR_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/TalkingHead@1.7.0/avatars/brunette.glb';
35
+
36
+ /** Enums cerrados: el LLM solo puede pedir estos valores (TalkingHead lanza con otros). */
37
+ import { createCharacter, CHARACTER_GESTURES, CHARACTER_GESTURE_ALIAS } from '../character/Character.js';
38
+
39
+ export const MOODS = ['neutral', 'happy', 'angry', 'sad', 'fear', 'disgust', 'love', 'sleep'];
40
+ export const GESTURES = ['handup', 'index', 'ok', 'thumbup', 'thumbdown', 'side', 'shrug', 'namaste'];
41
+ /** Moods que entiende el personaje 2D (los de TalkingHead + 'surprised'). */
42
+ export const MOODS_2D = [...MOODS, 'surprised'];
43
+ /** Todos los gestos aceptados: los de TalkingHead, los del personaje 2D y sus alias. */
44
+ export const ALL_GESTURES = [...new Set([...GESTURES, ...CHARACTER_GESTURES, ...Object.keys(CHARACTER_GESTURE_ALIAS)])];
45
+ /** Gesto del personaje 2D → gesto de TalkingHead (3D). Los que no están no tienen equivalente. */
46
+ export const GESTURE_3D = {
47
+ wave: 'handup', hi: 'handup', hello: 'handup', celebrate: 'thumbup', thumbsup: 'thumbup', thumbsdown: 'thumbdown',
48
+ point: 'index', nod: 'ok', yes: 'ok', shrug: 'shrug', bow: 'namaste', wiggle: 'side', think: 'index', clap: 'thumbup', cheer: 'thumbup',
49
+ };
50
+
51
+ /**
52
+ * TalkingHead solo trae lip-sync para: en, fi, de, fr, lt.
53
+ * El finés es casi fonético (una letra ≈ un sonido), así que funciona muy bien
54
+ * para español, italiano o portugués. Configurable con `avatar.lipsyncLang`.
55
+ */
56
+ const LIPSYNC_MAP = { es: 'fi', it: 'fi', pt: 'fi', ca: 'fi', en: 'en', de: 'de', fr: 'fr', fi: 'fi', lt: 'lt' };
57
+
58
+ export class AvatarStage {
59
+ /**
60
+ * @param {object} o
61
+ * @param {HTMLElement} o.container nodo dentro del Shadow DOM del widget
62
+ * @param {object|false} o.config { url, body:'F'|'M', mood, cameraView, lipsyncLang, module, name, image, face, mode }
63
+ * @param {string} [o.language] idioma del agente (para elegir lip-sync)
64
+ */
65
+ constructor({ container, config = {}, language = document.documentElement.lang || 'es' }) {
66
+ this.container = container;
67
+ this.cfg = config === false ? false : {
68
+ url: DEFAULT_AVATAR_URL,
69
+ body: 'F',
70
+ mood: 'neutral',
71
+ cameraView: 'upper',
72
+ module: TALKINGHEAD_URL,
73
+ ...config,
74
+ };
75
+ this.lang = (this.cfg && this.cfg.lipsyncLang) || LIPSYNC_MAP[language.slice(0, 2).toLowerCase()] || 'en';
76
+ this.head = null;
77
+ this.mode = 'none';
78
+ this._ctx = null; // AudioContext propio (modo 2D)
79
+ this._src = null;
80
+ this._mimicTimer = null;
81
+ this._speakTimer = null;
82
+ this._resolveSpeak = null;
83
+ }
84
+
85
+ /** Carga el avatar 3D o cae al 2D. Nunca lanza. */
86
+ async load(onProgress) {
87
+ if (this.cfg === false || this.cfg.mode === '2d' || (this.cfg.character && !this.cfg.url && this.cfg.mode !== '3d')) return this._load2D();
88
+ if (!hasWebGL()) {
89
+ console.info('[7ots] el navegador no tiene WebGL: avatar 2D');
90
+ return this._load2D();
91
+ }
92
+ try {
93
+ const { TalkingHead } = await import(/* @vite-ignore */ /* webpackIgnore: true */ this.cfg.module);
94
+ this.head = new TalkingHead(this.container, {
95
+ ttsEndpoint: '', // no usamos el TTS de Google de TalkingHead: el audio viene de VoiceEngine
96
+ lipsyncModules: [this.lang],
97
+ lipsyncLang: this.lang,
98
+ cameraView: this.cfg.cameraView,
99
+ avatarMood: this.cfg.mood,
100
+ modelFPS: 30,
101
+ });
102
+ await this.head.showAvatar(
103
+ { url: this.cfg.url, body: this.cfg.body, avatarMood: this.cfg.mood, lipsyncLang: this.lang },
104
+ (ev) => onProgress?.(ev.lengthComputable ? ev.loaded / ev.total : null),
105
+ );
106
+ this.mode = '3d';
107
+ // Ahorra batería: pausa el render cuando la pestaña no se ve.
108
+ this._vis = () => (document.hidden ? this.head?.stop() : this.head?.start());
109
+ document.addEventListener('visibilitychange', this._vis);
110
+ } catch (err) {
111
+ console.warn('[7ots] avatar 3D no disponible, uso 2D:', err?.message || err);
112
+ try { this.head?.dispose?.(); } catch {}
113
+ this.head = null;
114
+ this.container.replaceChildren();
115
+ this._load2D();
116
+ }
117
+ return this.mode;
118
+ }
119
+
120
+ _load2D() {
121
+ this.mode = '2d';
122
+ this._ch?.destroy();
123
+ this._ch = null;
124
+ const character = this.cfg && this.cfg.character;
125
+ if (character && !this.cfg.image) {
126
+ this.container.replaceChildren();
127
+ this._mouth = null;
128
+ this._face = null;
129
+ this._ch = createCharacter(this.container, character, { title: '' });
130
+ if (this.cfg.mood) this._ch.mood(this.cfg.mood);
131
+ return this.mode;
132
+ }
133
+ const image = this.cfg && this.cfg.image;
134
+ if (image) {
135
+ this.container.innerHTML = `<div class="ots-portrait"><img alt="" referrerpolicy="no-referrer"></div>`;
136
+ const img = this.container.querySelector('img');
137
+ img.onerror = () => this._drawFace(); // retrato roto → cara dibujada
138
+ img.src = image;
139
+ this._mouth = null;
140
+ this._face = this.container.querySelector('.ots-portrait');
141
+ return this.mode;
142
+ }
143
+ return this._drawFace();
144
+ }
145
+
146
+ _drawFace() {
147
+ this.mode = '2d';
148
+ const f = (this.cfg && this.cfg.face) || {};
149
+ const vars = [f.skin && `--face-bg:${f.skin}`, f.eyes && `--face-fg:${f.eyes}`].filter(Boolean).join(';');
150
+ this.container.innerHTML = `
151
+ <svg class="ots-face" viewBox="0 0 120 120" aria-hidden="true"${vars ? ` style="${vars}"` : ''}>
152
+ <circle cx="60" cy="60" r="54" class="ots-face-bg"/>
153
+ <g class="ots-eyes"><ellipse cx="42" cy="50" rx="6" ry="8"/><ellipse cx="78" cy="50" rx="6" ry="8"/></g>
154
+ <ellipse class="ots-mouth" cx="60" cy="82" rx="16" ry="2"/>
155
+ </svg>`;
156
+ this._mouth = this.container.querySelector('.ots-mouth');
157
+ this._face = this.container.querySelector('.ots-face');
158
+ return this.mode;
159
+ }
160
+
161
+ _mouthOpen(v) {
162
+ const x = Math.max(0, Math.min(1, v));
163
+ this._mouth?.setAttribute('ry', String(2 + x * 12));
164
+ this._ch?.mouth(x);
165
+ this.container.style.setProperty('--ots-talk', x.toFixed(2)); // halo del retrato
166
+ }
167
+
168
+ /**
169
+ * Cambia retrato / colores de la cara en caliente (identidad nueva). En 3D solo se guardan
170
+ * para un futuro fallback: cambiar el .glb requiere recargar el avatar (reload()).
171
+ */
172
+ setLook({ image, face, character } = {}) {
173
+ if (!this.cfg) return;
174
+ if (image !== undefined) this.cfg.image = image;
175
+ if (face !== undefined) this.cfg.face = face;
176
+ if (character !== undefined) this.cfg.character = character;
177
+ if (this._ch && character && image === undefined) return void this._ch.update(character);
178
+ if (this.mode === '2d') this._load2D();
179
+ }
180
+
181
+ /**
182
+ * Aplica el aspecto de una identidad nueva: recarga si cambia entre 2D y 3D o el .glb/cuerpo,
183
+ * y si no, actualiza en caliente. Devuelve el modo resultante.
184
+ */
185
+ async applyLook(look = {}) {
186
+ if (!this.cfg) return this.mode;
187
+ const want3d = look.mode === '3d' || (look.mode !== '2d' && !!look.url);
188
+ if (this.mode === 'none') {
189
+ Object.assign(this.cfg, { image: '', character: null }, look);
190
+ return this.mode;
191
+ }
192
+ const next = { image: '', character: null, ...look };
193
+ if (want3d !== (this.mode === '3d') || (this.mode === '3d' && ((look.url || '') !== (this.cfg.url || '') || (look.body && look.body !== this.cfg.body)))) {
194
+ return this.reload(next);
195
+ }
196
+ Object.assign(this.cfg, next);
197
+ if (this.mode === '2d') {
198
+ if (this._ch && next.character && !next.image) this._ch.update(next.character);
199
+ else this._load2D();
200
+ if (next.mood) this.setMood(next.mood);
201
+ }
202
+ return this.mode;
203
+ }
204
+
205
+ /** Vuelve a cargar el avatar con otra configuración (p. ej. otro .glb). */
206
+ async reload(config = {}, onProgress) {
207
+ const next = { ...(this.cfg || {}), ...config };
208
+ this.dispose();
209
+ this._ctx = null; // dispose() lo cerró
210
+ this._vis = null;
211
+ this.cfg = next;
212
+ this.mode = 'none';
213
+ return this.load(onProgress);
214
+ }
215
+
216
+ /** AudioContext compartido; debe reanudarse tras un gesto del usuario (autoplay). */
217
+ audioContext() {
218
+ if (this.head?.audioCtx) return this.head.audioCtx;
219
+ this._ctx ??= new (window.AudioContext || window.webkitAudioContext)();
220
+ return this._ctx;
221
+ }
222
+
223
+ async unlockAudio() {
224
+ try { await this.audioContext().resume(); } catch {}
225
+ }
226
+
227
+ /**
228
+ * Reproduce audio TTS con lip-sync. Resuelve cuando termina (o al llamar stop()).
229
+ * @param {{text:string, audio:ArrayBuffer}} o
230
+ */
231
+ async speak({ text, audio }) {
232
+ this.stop();
233
+ const ctx = this.audioContext();
234
+ await ctx.resume().catch(() => {});
235
+ const buffer = await ctx.decodeAudioData(audio.slice(0));
236
+ const ms = buffer.duration * 1000;
237
+
238
+ if (this.mode === '3d') {
239
+ const timing = wordTimings(text, ms);
240
+ this.head.speakAudio({ audio: buffer, ...timing }, { lipsyncLang: this.lang });
241
+ return this._waitFor(ms + 300);
242
+ }
243
+
244
+ // 2D: reproducimos nosotros y animamos la boca con un analizador.
245
+ const src = ctx.createBufferSource();
246
+ const analyser = ctx.createAnalyser();
247
+ analyser.fftSize = 1024;
248
+ src.buffer = buffer;
249
+ src.connect(analyser).connect(ctx.destination);
250
+ this._src = src;
251
+ if (this._ch) {
252
+ // Personaje 2D: lip-sync por energía + timbre (con auto-ganancia), mezclado con los visemas del texto.
253
+ this._lipStop = this._ch.lipsync(analyser, { text, durationMs: ms });
254
+ } else {
255
+ const data = new Uint8Array(analyser.fftSize);
256
+ const tick = () => {
257
+ if (this._src !== src) return this._mouthOpen(0);
258
+ analyser.getByteTimeDomainData(data);
259
+ let sum = 0;
260
+ for (const v of data) sum += ((v - 128) / 128) ** 2;
261
+ this._mouthOpen(Math.sqrt(sum / data.length) * 4);
262
+ requestAnimationFrame(tick);
263
+ };
264
+ requestAnimationFrame(tick);
265
+ }
266
+ return new Promise((resolve) => {
267
+ this._resolveSpeak = resolve;
268
+ src.onended = () => {
269
+ if (this._src === src) this._src = null;
270
+ this._lipStop?.();
271
+ this._lipStop = null;
272
+ this._mouthOpen(0);
273
+ resolve();
274
+ };
275
+ src.start();
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Mueve la boca sin audio propio (cuando la voz es speechSynthesis del navegador).
281
+ * En 3D se pasa a TalkingHead un buffer silencioso con los tiempos estimados.
282
+ */
283
+ mimic({ text, durationMs }) {
284
+ this.stop();
285
+ if (this.mode === '3d') {
286
+ const ctx = this.audioContext();
287
+ const silent = ctx.createBuffer(1, Math.max(1, Math.ceil((durationMs / 1000) * ctx.sampleRate)), ctx.sampleRate);
288
+ this.head.speakAudio({ audio: silent, ...wordTimings(text, durationMs) }, { lipsyncLang: this.lang });
289
+ return;
290
+ }
291
+ if (this._ch) {
292
+ // Personaje 2D: visemas a partir del texto, repartidos en la duración estimada.
293
+ this._speech = this._ch.speak(text || durationMs, { durationMs });
294
+ return;
295
+ }
296
+ const t0 = performance.now();
297
+ const loop = () => {
298
+ const t = performance.now() - t0;
299
+ if (t > durationMs) return this._mouthOpen(0);
300
+ this._mouthOpen(0.35 + 0.35 * Math.sin(t / 70) * Math.sin(t / 190));
301
+ this._mimicTimer = requestAnimationFrame(loop);
302
+ };
303
+ loop();
304
+ }
305
+
306
+ /**
307
+ * Resincroniza el lip-sync 2D con la posición real de la voz del navegador
308
+ * (SpeechSynthesisUtterance.onboundary → e.charIndex).
309
+ */
310
+ mimicSync(charIndex) {
311
+ this._speech?.sync?.(charIndex);
312
+ }
313
+
314
+ stop() {
315
+ try { this.head?.stopSpeaking(); } catch {}
316
+ this._speech?.stop?.();
317
+ this._speech = null;
318
+ this._lipStop?.();
319
+ this._lipStop = null;
320
+ if (this._src) {
321
+ try { this._src.stop(); } catch {}
322
+ this._src = null;
323
+ }
324
+ cancelAnimationFrame(this._mimicTimer);
325
+ clearTimeout(this._speakTimer);
326
+ this._resolveSpeak?.();
327
+ this._resolveSpeak = null;
328
+ this._mouthOpen(0);
329
+ }
330
+
331
+ setMood(mood) {
332
+ if (!MOODS_2D.includes(mood)) return false;
333
+ if (this.mode === '3d') {
334
+ if (!MOODS.includes(mood)) mood = 'happy'; // TalkingHead no tiene 'surprised'
335
+ try { this.head.setMood(mood); } catch { return false; }
336
+ } else if (this._ch) {
337
+ this._ch.mood(mood);
338
+ } else if (this._face) {
339
+ this._face.dataset.mood = mood;
340
+ }
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * @param {string} name gesto de ALL_GESTURES
346
+ * @param {number} [seconds] duración (en 2D, si no se indica, cada gesto usa la suya)
347
+ * @param {boolean} [mirror] con la otra mano (p. ej. para señalar a la izquierda)
348
+ */
349
+ gesture(name, seconds, mirror = false) {
350
+ if (!ALL_GESTURES.includes(name)) return false;
351
+ if (this.mode === '3d') {
352
+ const g = GESTURES.includes(name) ? name : GESTURE_3D[name];
353
+ if (!g) return false;
354
+ try { this.head.playGesture(g, seconds ?? 2.5, mirror); } catch { return false; }
355
+ return true;
356
+ }
357
+ if (!this._ch) return false;
358
+ return this._ch.gesture(name, { mirror, ...(seconds ? { ms: seconds * 1000 } : {}) });
359
+ }
360
+
361
+ /** Transformación temporal del personaje 2D ('wings', 'spikes', 'hands', 'shape:heart'…). No hace nada en 3D. */
362
+ morph(name, opts) {
363
+ return this._ch ? this._ch.morph(name, opts) : false;
364
+ }
365
+
366
+ /** Efecto 2D ('hearts', 'sparkles', 'zzz'…). No hace nada en 3D. */
367
+ effect(name, opts) {
368
+ return this._ch ? this._ch.effect(name, opts) : false;
369
+ }
370
+
371
+ /** Mira hacia un punto de la pantalla (coordenadas de viewport) durante `ms`. */
372
+ lookAt(x, y, ms = 1500) {
373
+ if (this.mode === '3d') {
374
+ try { this.head.lookAt(x, y, ms); } catch {}
375
+ return;
376
+ }
377
+ if (this._ch) {
378
+ this._ch.lookAtPoint(x, y);
379
+ clearTimeout(this._lookTimer);
380
+ this._lookTimer = setTimeout(() => this._ch?.lookAt(0, 0), ms);
381
+ return;
382
+ }
383
+ const eyes = this._face?.querySelector('.ots-eyes');
384
+ if (!eyes) return;
385
+ const r = this._face.getBoundingClientRect();
386
+ const k = (v, c, size) => Math.max(-5, Math.min(5, ((v - c) / Math.max(size, 1)) * 6));
387
+ eyes.style.transition = 'transform .25s';
388
+ eyes.style.transform = `translate(${k(x, r.left + r.width / 2, r.width)}px, ${k(y, r.top + r.height / 2, r.height)}px)`;
389
+ clearTimeout(this._lookTimer);
390
+ this._lookTimer = setTimeout(() => (eyes.style.transform = ''), ms);
391
+ }
392
+
393
+ /** Aplica las etiquetas [[happy]] / [[thumbup]] de una respuesta y devuelve el texto limpio. */
394
+ applyExpressions(text) {
395
+ const { clean, moods, gestures } = extractExpressions(text);
396
+ if (moods.length) this.setMood(moods[moods.length - 1]);
397
+ if (gestures.length) this.gesture(gestures[0]);
398
+ return clean;
399
+ }
400
+
401
+ _waitFor(ms) {
402
+ return new Promise((resolve) => {
403
+ this._resolveSpeak = resolve;
404
+ this._speakTimer = setTimeout(() => {
405
+ this._resolveSpeak = null;
406
+ resolve();
407
+ }, ms);
408
+ });
409
+ }
410
+
411
+ dispose() {
412
+ this.stop();
413
+ if (this._vis) document.removeEventListener('visibilitychange', this._vis);
414
+ try { this.head?.dispose?.(); } catch {}
415
+ this.head = null;
416
+ this._ch?.destroy();
417
+ this._ch = null;
418
+ this._ctx?.close().catch(() => {});
419
+ this.container.replaceChildren();
420
+ }
421
+ }
422
+
423
+ // ───────────────────────────── helpers ─────────────────────────────
424
+
425
+ /** Separa las etiquetas de expresión [[...]] del texto. */
426
+ export function extractExpressions(text) {
427
+ const moods = [];
428
+ const gestures = [];
429
+ const clean = String(text || '')
430
+ .replace(/\[\[\s*([a-z]+)\s*\]\]/gi, (m, tag) => {
431
+ const t = tag.toLowerCase();
432
+ if (MOODS_2D.includes(t)) moods.push(t);
433
+ else if (ALL_GESTURES.includes(t)) gestures.push(t);
434
+ return '';
435
+ })
436
+ .replace(/\s{2,}/g, ' ')
437
+ .trim();
438
+ return { clean, moods, gestures };
439
+ }
440
+
441
+ /**
442
+ * Estima cuándo empieza y cuánto dura cada palabra repartiendo la duración total
443
+ * proporcionalmente a la longitud de las palabras (+ pausas en signos de puntuación).
444
+ * Es suficiente para un lip-sync creíble sin necesitar timestamps del proveedor TTS.
445
+ */
446
+ export function wordTimings(text, totalMs) {
447
+ const words = String(text).split(/\s+/).filter(Boolean);
448
+ if (!words.length) return { words: [], wtimes: [], wdurations: [] };
449
+ const weight = (w) => w.replace(/[^\p{L}\p{N}]/gu, '').length + 1 + (/[.,;:!?…]$/.test(w) ? 3 : 0);
450
+ const total = words.reduce((s, w) => s + weight(w), 0);
451
+ const lead = Math.min(150, totalMs * 0.03); // silencio inicial típico de los TTS
452
+ const span = Math.max(1, totalMs - lead * 2);
453
+ const wtimes = [];
454
+ const wdurations = [];
455
+ let t = lead;
456
+ for (const w of words) {
457
+ const d = (weight(w) / total) * span;
458
+ const pause = /[.,;:!?…]$/.test(w) ? d * 0.3 : 0;
459
+ wtimes.push(Math.round(t));
460
+ wdurations.push(Math.round(d - pause));
461
+ t += d;
462
+ }
463
+ return { words, wtimes, wdurations };
464
+ }
465
+
466
+ function hasWebGL() {
467
+ try {
468
+ const c = document.createElement('canvas');
469
+ return !!(c.getContext('webgl2') || c.getContext('webgl'));
470
+ } catch {
471
+ return false;
472
+ }
473
+ }