@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,1059 @@
1
+ /**
2
+ * AgentWidget — el Web Component <ots-agent> que une todas las piezas.
3
+ *
4
+ * ┌──────────────── página anfitriona ────────────────┐
5
+ * │ DOM del sitio ◄── ContextManager (lee) │
6
+ * │ ▲ │
7
+ * │ └──── ActionRegistry (actúa: click, form…) │
8
+ * │ <div data-ots-root="overlay"> ◄── UIManager │ modales / sidebars / toasts
9
+ * │ <ots-agent> #shadow-root ◄── este archivo │ burbuja + panel + avatar + chat
10
+ * └───────────────────────────────────────────────────┘
11
+ *
12
+ * usuario ─► AgentBrain ─► ProxyLLM ─► /api/agent/chat (tu servidor) ─► Claude / OpenAI
13
+ * │
14
+ * ├─► ActionRegistry ─► builtins · tus acciones · MCP del sitio · apumail · apuchat
15
+ * └─► VoiceEngine ─► AvatarStage (TalkingHead 3D / cara 2D)
16
+ *
17
+ * Todo el CSS vive en el Shadow DOM: el sitio no rompe el widget y el widget no rompe el sitio.
18
+ * El widget y el overlay llevan data-ots-root, así el agente nunca se "lee" a sí mismo.
19
+ *
20
+ * Normalmente no se usa directamente: SevenOts.init(config) (src/index.js) lo crea.
21
+ * También puede declararse en HTML: <ots-agent endpoint="/api/agent" name="Ana" locale="en"></ots-agent>
22
+ *
23
+ * Idioma: los textos del widget salen del catálogo src/i18n/messages/widget.js (claves `widget.*`).
24
+ * `locale` fija el idioma; sin él se usa <html lang> y luego el navegador. Se cambia en caliente
25
+ * con agent.setLocale('pt'). El modelo contesta en el idioma en que le escriba el visitante.
26
+ */
27
+
28
+ import { EventBus } from './EventBus.js';
29
+ import { createStore } from './storage.js';
30
+ import { AgentBrain } from './AgentBrain.js';
31
+ import { Proactivity } from './Proactivity.js';
32
+ import { ContextManager } from '../context/ContextManager.js';
33
+ import { ActionRegistry } from '../actions/ActionRegistry.js';
34
+ import { createBuiltinActions } from '../actions/builtins.js';
35
+ import { AuthManager } from '../auth/AuthManager.js';
36
+ import { UIManager } from '../ui/UIManager.js';
37
+ import { VirtualPointer } from '../ui/VirtualPointer.js';
38
+ import { PageTools } from '../actions/PageTools.js';
39
+ import { renderMarkdown, escapeHtml } from '../ui/markdown.js';
40
+ import { ProxyLLM } from '../llm/ProxyLLM.js';
41
+ import { AvatarStage, extractExpressions } from '../avatar/AvatarStage.js';
42
+ import { Companion, createCompanionActions } from '../ui/Companion.js';
43
+ import { VoiceEngine } from '../voice/VoiceEngine.js';
44
+ import { createApumailActions } from '../integrations/apumail.js';
45
+ import { ApuchatBridge, createApuchatActions } from '../integrations/apuchat.js';
46
+ import { defineIdentity, identityToWidgetConfig } from '../identity/schema.js';
47
+ import { actionTitle } from '../actions/ActionRegistry.js';
48
+ import { acceptLanguage } from '../llm/ProxyLLM.js';
49
+ import { translator, detectLocale } from '../i18n/index.js';
50
+ import '../i18n/messages/widget.js';
51
+
52
+ // Las etiquetas de los chips de acciones (antes ACTION_LABELS) viven en el catálogo: widget.actions.<nombre>.
53
+
54
+ /** Acciones que tocan un elemento: en modo compañero, el avatar se acerca a él. */
55
+ const FOLLOW_ACTIONS = new Set(['click', 'hover', 'type_text', 'press_key', 'drag_and_drop', 'fill_form', 'highlight', 'scroll_to', 'read_element']);
56
+
57
+ const DEFAULTS = {
58
+ endpoint: '/api/agent',
59
+ siteKey: null,
60
+ llm: null,
61
+ // name: null → widget.defaultName en el idioma del widget ('Asistente', 'Assistant'…)
62
+ agent: { name: null, role: 'asistente virtual', language: null, instructions: '', expressive: true },
63
+ // Idioma de la interfaz: 'es' | 'en' | 'pt' (o 'en-GB', 'pt-PT'…). null → <html lang> y luego el
64
+ // navegador; un idioma sin catálogo cae a inglés. También es el idioma por defecto de las respuestas
65
+ // y de la voz (si voice.lang no se da: es→es-ES, en→en-US, pt→pt-BR, respetando la región).
66
+ locale: null,
67
+ avatar: {},
68
+ voice: {},
69
+ auth: {},
70
+ context: {},
71
+ navigation: {},
72
+ proactive: {},
73
+ actions: [],
74
+ mcp: [],
75
+ templates: {},
76
+ contact: { apuchat: true, apumail: true },
77
+ theme: {},
78
+ open: false,
79
+ confirmClicks: 'submit',
80
+ pointer: {},
81
+ pageTools: true,
82
+ mode: 'panel', // 'panel' | 'companion' (el avatar se pasea por la página)
83
+ companion: {},
84
+ namespace: 'default',
85
+ identity: null, // objeto de identidad, URL de una tarjeta, o true = {endpoint}/identity
86
+ };
87
+
88
+ export class AgentWidget extends HTMLElement {
89
+ constructor() {
90
+ super();
91
+ this.attachShadow({ mode: 'open' });
92
+ this._started = false;
93
+ this._offs = [];
94
+ }
95
+
96
+ /** Config por atributos para el uso declarativo en HTML. */
97
+ connectedCallback() {
98
+ this.setAttribute('data-ots-root', 'widget');
99
+ if (!this._started && !this._pendingConfig) {
100
+ // Espera un tick: SevenOts.init() llama a configure() justo después de insertar.
101
+ queueMicrotask(() => {
102
+ if (this._started) return;
103
+ const a = (n) => this.getAttribute(n);
104
+ this.configure({
105
+ endpoint: a('endpoint') || undefined,
106
+ siteKey: a('site-key') || undefined,
107
+ identity: a('identity') === '' || a('identity') === 'true' ? true : a('identity') || undefined,
108
+ locale: a('locale') || undefined,
109
+ agent: { name: a('name') || undefined, siteName: a('site-name') || undefined },
110
+ avatar: a('avatar') === 'false' ? false : a('avatar-url') ? { url: a('avatar-url') } : {},
111
+ });
112
+ });
113
+ }
114
+ }
115
+
116
+ disconnectedCallback() {
117
+ // Solo si lo quitan del DOM de verdad (no en un re-parenting).
118
+ queueMicrotask(() => { if (!this.isConnected) this.destroy(); });
119
+ }
120
+
121
+ /**
122
+ * Arranca el agente con la configuración dada. Solo se ejecuta una vez.
123
+ * @param {object} userConfig ver DEFAULTS y README
124
+ */
125
+ configure(userConfig = {}) {
126
+ if (this._started) return this;
127
+ this._started = true;
128
+ // Identidad: lo que el sitio pase explícitamente en init() manda sobre ella.
129
+ const idSrc = userConfig.identity;
130
+ this._userConfig = userConfig;
131
+ this.identity = idSrc && typeof idSrc === 'object' ? defineIdentity(idSrc) : null;
132
+ const base = this.identity ? mergeConfig(DEFAULTS, identityToWidgetConfig(this.identity)) : DEFAULTS;
133
+ const cfg = (this.config = mergeConfig(base, userConfig));
134
+
135
+ // ── idioma ──
136
+ // this.t es estable (los componentes lo guardan) y delega en el traductor del idioma actual,
137
+ // así setLocale() llega a todos sin recrearlos. t.locale viaja como Accept-Language.
138
+ this._t = translator(detectLocale(cfg.locale));
139
+ const t = (this.t = (key, vars) => this._t(key, vars));
140
+ Object.defineProperty(t, 'locale', { get: () => this._t.locale });
141
+ t.has = (key) => this._t.has(key);
142
+ this._autoName = !cfg.agent.name;
143
+ if (this._autoName) cfg.agent.name = t('widget.defaultName');
144
+ // Voz: la de la config (o la identidad) manda; si no hay, se deriva del idioma del widget.
145
+ this._voiceLangAuto = cfg.voice !== false && !cfg.voice?.lang;
146
+ const lang = this._voiceLangAuto ? voiceLangFor(t.locale, [cfg.locale, cfg.agent.language]) : cfg.voice?.lang || voiceLangFor(t.locale);
147
+
148
+ // ── núcleo ──
149
+ this.bus = new EventBus();
150
+ this.store = createStore(cfg.namespace);
151
+ this.auth = new AuthManager({ bus: this.bus, config: cfg.auth });
152
+ this.ui = new UIManager({ bus: this.bus, theme: cfg.theme, t });
153
+ this.context = new ContextManager({ bus: this.bus, ...cfg.context });
154
+ // Ratón y teclado del agente. pointer:false → sin herramientas de ratón (click instantáneo).
155
+ this.pointer =
156
+ cfg.pointer === false
157
+ ? null
158
+ : new VirtualPointer({
159
+ name: cfg.agent.name,
160
+ color: cfg.theme.primary || '#4f46e5',
161
+ t,
162
+ ...cfg.pointer,
163
+ onCancel: () => {
164
+ this.brain?.abort();
165
+ this.ui.toast({ message: t('widget.pointer.stopped'), type: 'info' });
166
+ },
167
+ });
168
+ this.actions = new ActionRegistry({ bus: this.bus, auth: this.auth, ui: this.ui, context: this.context, pointer: this.pointer, agent: null, t });
169
+ this.llm = cfg.llm || new ProxyLLM({ endpoint: cfg.endpoint, siteKey: cfg.siteKey, locale: () => t.locale });
170
+
171
+ this.brain = new AgentBrain({
172
+ llm: this.llm,
173
+ actions: this.actions,
174
+ context: this.context,
175
+ auth: this.auth,
176
+ bus: this.bus,
177
+ store: this.store,
178
+ t,
179
+ persona: {
180
+ ...cfg.agent,
181
+ locale: t.locale,
182
+ siteName: cfg.agent.siteName || document.title.split(/[|–—-]/)[0].trim(),
183
+ instructions: [
184
+ cfg.agent.instructions,
185
+ cfg.mode === 'companion'
186
+ ? 'Modo compañero: tu avatar vive SOBRE la página, no solo en el chat. Cuando hables de algo que está en pantalla, ve y señálalo con point_at (mensaje de una frase); para explicar varias cosas usa tour. Sé cercano y juguetón, pero breve.'
187
+ : '',
188
+ ].filter(Boolean).join('\n'),
189
+ },
190
+ });
191
+
192
+ // ── acciones ──
193
+ const excluded = new Set(cfg.builtins?.exclude || []);
194
+ this.actions.registerMany(
195
+ createBuiltinActions({
196
+ navigation: cfg.navigation,
197
+ confirmClicks: cfg.builtins?.confirmClicks ?? cfg.confirmClicks,
198
+ beforeNavigate: (url, reason) => this._beforeNavigate(url, reason),
199
+ pointer: this.pointer,
200
+ t,
201
+ }).filter((a) => !excluded.has(a.name)),
202
+ );
203
+ // Herramientas declaradas en el HTML del sitio (data-ots-tool), se actualizan con la página.
204
+ this.pageTools = cfg.pageTools === false ? null : new PageTools({ actions: this.actions, context: this.context, bus: this.bus, pointer: this.pointer, t });
205
+ const getTranscript = () => this.brain.transcript();
206
+ this.apuchat = null;
207
+ if (cfg.contact?.apumail) {
208
+ const o = typeof cfg.contact.apumail === 'object' ? cfg.contact.apumail : {};
209
+ this.actions.registerMany(createApumailActions({ endpoint: cfg.endpoint, siteKey: cfg.siteKey, getTranscript, t, ...o }));
210
+ }
211
+ if (cfg.contact?.apuchat) {
212
+ this.apuchat = new ApuchatBridge({ endpoint: cfg.endpoint, siteKey: cfg.siteKey, bus: this.bus, store: this.store, t });
213
+ this.actions.registerMany(createApuchatActions({ endpoint: cfg.endpoint, siteKey: cfg.siteKey, bridge: this.apuchat, getTranscript, t }));
214
+ }
215
+ for (const def of cfg.actions) this.actions.register(def);
216
+ for (const [name, fn] of Object.entries(cfg.templates)) this.ui.registerTemplate(name, fn);
217
+
218
+ // ── interfaz ──
219
+ this._render();
220
+ this.avatar = cfg.avatar === false ? null : new AvatarStage({ container: this.$('.stage'), config: cfg.avatar, language: lang });
221
+ // Modo compañero: el avatar sale del panel y flota sobre la página.
222
+ this.companion = null;
223
+ if (cfg.mode === 'companion') {
224
+ this.companion = new Companion({
225
+ root: this.shadowRoot,
226
+ stage: this.avatar ? this.$('.stage') : null,
227
+ avatar: this.avatar,
228
+ ui: this.ui,
229
+ name: cfg.agent.name,
230
+ t,
231
+ side: cfg.theme.position === 'left' ? 'left' : 'right',
232
+ config: cfg.companion,
233
+ speak: (text) => (this.voice && !this.voice.muted ? this.voice.say(text) : Promise.reject()),
234
+ onActivate: () => (this.isOpen ? this.close() : this.open()),
235
+ panelOpen: () => this.isOpen,
236
+ });
237
+ this.shadowRoot.host.classList.add('companion');
238
+ this.$('.launcher').hidden = true;
239
+ this.$('.stage-wrap').hidden = true;
240
+ this.actions.registerMany(createCompanionActions(this.companion, { t }));
241
+ this.companion.mount();
242
+ if (!this.avatar) this.companion.el.querySelector('.b-stage').innerHTML = `<span class="b-initial">${escapeHtml(cfg.agent.name.slice(0, 1))}</span>`;
243
+ }
244
+ this.voice =
245
+ cfg.voice === false
246
+ ? null
247
+ : new VoiceEngine({
248
+ bus: this.bus,
249
+ avatar: this.avatar,
250
+ store: this.store,
251
+ t,
252
+ config: { endpoint: cfg.endpoint, siteKey: cfg.siteKey, lang, ...cfg.voice },
253
+ });
254
+ this.proactivity =
255
+ cfg.proactive === false
256
+ ? null
257
+ : new Proactivity({
258
+ bus: this.bus,
259
+ brain: this.brain,
260
+ context: this.context,
261
+ store: this.store,
262
+ config: { ...cfg.proactive, greet: cfg.proactive.greet !== false && !this.brain.history.length },
263
+ });
264
+
265
+ // Vida propia del compañero y ancla de las tarjetas.
266
+ this.ui.cardAnchor = () => this.companion?.el.querySelector('.b-body') || (this.isOpen ? this.$('.panel') : null);
267
+ if (this.companion) {
268
+ this.companion.canWander = () => !this.brain.busy && !this.isOpen;
269
+ const pc = this.proactivity?.cfg;
270
+ if (pc) this.companion.setBehavior({ wanderMs: cfg.companion.wanderMs ?? pc.wanderMs, watchCursor: cfg.companion.watchCursor ?? pc.watchCursor });
271
+ this._offs.push(
272
+ this.bus.on('proactive:level', ({ config: c }) => this.companion.setBehavior({ wanderMs: c.wanderMs, watchCursor: c.watchCursor })),
273
+ );
274
+ }
275
+
276
+ this.actions.deps.agent = this.api = createPublicApi(this);
277
+ // Identidad remota: el avatar espera a tenerla (así no carga dos .glb) como mucho 3 s.
278
+ if (idSrc === true || typeof idSrc === 'string') {
279
+ const url = idSrc === true ? `${cfg.endpoint.replace(/\/$/, '')}/identity` : idSrc;
280
+ this._identityReady = Promise.race([
281
+ fetch(url, { credentials: 'omit', headers: acceptLanguage(t.locale) })
282
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`identidad ${r.status}`))))
283
+ .then((id) => this.setIdentity(id))
284
+ .catch((e) => console.warn('[7ots] no se pudo cargar la identidad:', e.message)),
285
+ new Promise((r) => setTimeout(r, 3000)),
286
+ ]);
287
+ }
288
+ this._wireEvents();
289
+ this.context.start();
290
+ this.ui.mount();
291
+ this.pageTools?.start();
292
+
293
+ // ── restaurar estado tras navegar de página ──
294
+ for (const m of this.brain.transcript()) this._addMessage(m.from, m.text, { name: m.name, restore: true });
295
+ const pending = this.store.get('pendingNav', null);
296
+ this.store.remove('pendingNav');
297
+ if (this.store.get('open', cfg.open)) this.open({ focus: false });
298
+ if (this.apuchat?.active) {
299
+ this.apuchat.resume();
300
+ this._setHandoff(true);
301
+ }
302
+
303
+ // MCP del sitio: se conecta en segundo plano (y se reconecta si cambia la sesión).
304
+ this._connectMcp();
305
+
306
+ const boot = async () => {
307
+ if (this.companion) this._loadAvatar(); // el compañero se ve siempre: carga ya el avatar
308
+ this.proactivity?.start();
309
+ if (pending && Date.now() - pending.at < 60000) {
310
+ await this.brain.notify(
311
+ `La navegación a ${location.pathname} terminó (motivo: ${pending.reason || 'petición del usuario'}). ` +
312
+ 'Continúa con la tarea que el usuario pidió, usando el contexto de esta página. Si ya está completa, díselo en una frase.',
313
+ { allowNoop: false },
314
+ );
315
+ }
316
+ };
317
+ if (document.readyState === 'complete') boot();
318
+ else window.addEventListener('load', boot, { once: true });
319
+
320
+ this.bus.emit('ready', { api: this.api });
321
+ return this;
322
+ }
323
+
324
+ $(sel) {
325
+ return this.shadowRoot.querySelector(sel);
326
+ }
327
+
328
+ /**
329
+ * Aplica una identidad en caliente: nombre, papel, idioma, color, retrato/avatar y voz.
330
+ * Lo que el sitio fijó explícitamente en init() se respeta. Devuelve la identidad normalizada.
331
+ */
332
+ setIdentity(input) {
333
+ const id = (this.identity = defineIdentity(input));
334
+ const ic = identityToWidgetConfig(id);
335
+ const u = this._userConfig || {};
336
+ const own = (sec, key) => u[sec] && typeof u[sec] === 'object' && u[sec][key] !== undefined;
337
+ const take = (sec) => Object.fromEntries(Object.entries(ic[sec]).filter(([k]) => !own(sec, k)));
338
+ const agent = take('agent');
339
+ if (!agent.name) delete agent.name;
340
+ else this._autoName = false;
341
+ Object.assign(this.config.agent, agent);
342
+ Object.assign(this.brain.persona, agent);
343
+ this._relabel();
344
+ if (!own('theme', 'primary')) {
345
+ this.config.theme.primary = id.look.color;
346
+ this.style.setProperty('--p', id.look.color);
347
+ this.ui.setPrimary?.(id.look.color);
348
+ if (this.pointer) this.pointer.color = id.look.color;
349
+ }
350
+ if (this.voice && this.config.voice !== false) {
351
+ const v = take('voice');
352
+ if (v.lang) this._voiceLangAuto = false; // la voz de la identidad manda sobre la del idioma
353
+ this.voice.configure(v);
354
+ }
355
+ if (this.avatar && u.avatar !== false) {
356
+ const mine = u.avatar && typeof u.avatar === 'object' ? u.avatar : {};
357
+ this.avatar.applyLook({ ...take('avatar'), ...mine }).then((mode) => {
358
+ const w = this.$('.stage-wrap');
359
+ if (w && mode !== 'none') w.dataset.mode = mode;
360
+ });
361
+ }
362
+ this.bus.emit('identity:change', { identity: id });
363
+ return id;
364
+ }
365
+
366
+ /**
367
+ * Cambia el idioma del widget en caliente: textos de la interfaz, idioma por defecto de las
368
+ * respuestas, Accept-Language y la voz (si no se fijó voice.lang). Los mensajes se conservan.
369
+ * @param {string} lang 'es' | 'en' | 'pt' (o con región: 'en-GB')
370
+ * @returns {string} el idioma resuelto
371
+ */
372
+ setLocale(lang) {
373
+ if (!this._started) return this.t?.locale;
374
+ const prev = this.t.locale;
375
+ this._t = translator(detectLocale(lang));
376
+ const locale = this.t.locale;
377
+ if (this._autoName) {
378
+ this.config.agent.name = this.t('widget.defaultName');
379
+ this.brain.persona.name = this.config.agent.name;
380
+ }
381
+ this.brain.setLocale(locale);
382
+ if (this.voice && this._voiceLangAuto) this.voice.configure({ lang: voiceLangFor(locale, [lang]) });
383
+ this._relabel();
384
+ if (locale !== prev) this.bus.emit('locale:change', { locale, previous: prev });
385
+ return locale;
386
+ }
387
+
388
+ /** Vuelve a pintar los textos de la interfaz (idioma o nombre nuevos) sin tocar los mensajes. */
389
+ _relabel() {
390
+ const t = this.t;
391
+ const name = this.config.agent.name;
392
+ const vars = { name };
393
+ const root = this.shadowRoot;
394
+ root.querySelectorAll('[data-t]').forEach((el) => (el.textContent = t(el.dataset.t, vars)));
395
+ root.querySelectorAll('[data-t-aria]').forEach((el) => el.setAttribute('aria-label', t(el.dataset.tAria, vars)));
396
+ root.querySelectorAll('[data-t-title]').forEach((el) => (el.title = t(el.dataset.tTitle, vars)));
397
+ const who = this.$('.who strong');
398
+ if (who) who.textContent = name;
399
+ const sub = this.$('.sub');
400
+ if (sub) sub.textContent = t(this._statusKey || 'widget.status.online');
401
+ const ta = this.$('textarea');
402
+ if (ta) ta.placeholder = t(this._handoffOn ? 'widget.composer.placeholderTeam' : 'widget.composer.placeholder');
403
+ this.$('.typing')?.setAttribute('aria-label', t('widget.typing'));
404
+ const lbl = this.$('[data-more="proactive"] .lbl');
405
+ if (lbl && this.store) lbl.textContent = t(this.store.get('pro:muted', false) ? 'widget.menu.unmuteSuggestions' : 'widget.menu.muteSuggestions');
406
+ this._paintVoice?.();
407
+ if (this.pointer) this.pointer.name = name;
408
+ if (this.companion) {
409
+ this.companion.name = name;
410
+ this.companion.relabel();
411
+ const initial = this.companion.el?.querySelector('.b-initial');
412
+ if (initial) initial.textContent = name.slice(0, 1);
413
+ }
414
+ }
415
+
416
+ // ───────────────────────────── render ─────────────────────────────
417
+
418
+ _render() {
419
+ const t = this.config.theme;
420
+ const name = escapeHtml(this.config.agent.name);
421
+ const hasContact = this.config.contact && (this.config.contact.apuchat || this.config.contact.apumail);
422
+ this.shadowRoot.innerHTML = `
423
+ <style>${widgetCss(t)}</style>
424
+ <div class="teaser" hidden role="status"><button class="teaser-x" data-t-aria="widget.teaser.dismiss">×</button><div class="teaser-text"></div></div>
425
+ <button class="launcher" data-t-aria="widget.launcher.open" aria-expanded="false">
426
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-6l-4.5 3.6A.9.9 0 0 1 5 18.9V16a3 3 0 0 1-1-2.2Z"/></svg>
427
+ <span class="dot" hidden></span>
428
+ </button>
429
+ <section class="panel" role="dialog" data-t-aria="widget.panel.label" hidden>
430
+ <header>
431
+ <span class="status" aria-hidden="true"></span>
432
+ <div class="who"><strong>${name}</strong><small class="sub"></small></div>
433
+ <button class="icon b-voice" data-t-aria="widget.voice.mute" aria-pressed="false">${ICONS.voice}</button>
434
+ ${hasContact ? `<button class="icon b-contact" data-t-title="widget.menu.contact" data-t-aria="widget.menu.contact" aria-haspopup="menu">${ICONS.contact}</button>` : ''}
435
+ <button class="icon b-menu" data-t-title="widget.menu.more" data-t-aria="widget.menu.more" aria-haspopup="menu">${ICONS.more}</button>
436
+ <button class="icon b-close" data-t-title="widget.menu.minimize" data-t-aria="widget.menu.minimize">${ICONS.close}</button>
437
+ </header>
438
+ <div class="menu contact-menu" role="menu" hidden>
439
+ ${this.config.contact?.apuchat ? `<button role="menuitem" data-contact="human">${ICONS.person}<span data-t="widget.contact.human"></span></button>` : ''}
440
+ ${this.config.contact?.apumail ? `<button role="menuitem" data-contact="mail">${ICONS.mail}<span data-t="widget.contact.mail"></span></button>` : ''}
441
+ </div>
442
+ <div class="menu more-menu" role="menu" hidden>
443
+ ${this.config.proactive !== false ? `<div class="level" role="group" data-t-aria="widget.menu.initiativeLabel"><span data-t="widget.menu.initiative"></span>
444
+ <div class="seg">${['quiet', 'normal', 'bold'].map((v) => `<button role="menuitemradio" data-level="${v}" data-t="widget.menu.level.${v}" aria-checked="false"></button>`).join('')}</div></div>` : ''}
445
+ <button role="menuitem" data-more="proactive">${ICONS.bell}<span class="lbl"></span></button>
446
+ <button role="menuitem" data-more="reset">${ICONS.reset}<span data-t="widget.menu.reset"></span></button>
447
+ </div>
448
+ <div class="stage-wrap" ${this.config.avatar === false ? 'hidden' : ''}>
449
+ <div class="stage" data-ots-ignore></div>
450
+ <div class="loading" hidden><span></span></div>
451
+ <div class="subtitles" aria-hidden="true"></div>
452
+ </div>
453
+ <div class="handoff" hidden>
454
+ <span>${ICONS.person} <b class="handoff-who" data-t="widget.handoff.team"></b></span>
455
+ <button class="link b-video" data-t="widget.handoff.video"></button>
456
+ <button class="link b-endhandoff" data-t="widget.handoff.back"></button>
457
+ </div>
458
+ <ol class="messages" aria-live="polite" aria-relevant="additions"></ol>
459
+ <form class="composer">
460
+ <textarea rows="1" data-t-aria="widget.composer.label"></textarea>
461
+ <button type="button" class="icon b-mic" data-t-aria="widget.composer.mic" hidden>${ICONS.mic}</button>
462
+ <button type="submit" class="icon send" data-t-aria="widget.composer.send">${ICONS.send}</button>
463
+ </form>
464
+ <footer class="brand"><span data-t="widget.footer.ai"></span> · <a href="https://7ots.com" target="_blank" rel="noopener">7ots</a></footer>
465
+ </section>`;
466
+ this._relabel();
467
+ }
468
+
469
+ _wireEvents() {
470
+ const on = (e, fn) => this._offs.push(this.bus.on(e, fn));
471
+ const ta = this.$('textarea');
472
+
473
+ // Cualquier interacción con el widget desbloquea el audio (política de autoplay).
474
+ this.shadowRoot.addEventListener('pointerdown', () => this.avatar?.unlockAudio(), { capture: true });
475
+
476
+ this.$('.launcher').onclick = () => (this.isOpen ? this.close() : this.open());
477
+ this.$('.b-close').onclick = () => this.close();
478
+ this.$('.teaser').onclick = (e) => {
479
+ this._hideTeaser();
480
+ if (!e.target.closest('.teaser-x')) this.open();
481
+ };
482
+ this.$('.composer').onsubmit = (e) => {
483
+ e.preventDefault();
484
+ this._submit(ta.value);
485
+ ta.value = '';
486
+ autoGrow(ta);
487
+ };
488
+ ta.addEventListener('keydown', (e) => {
489
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
490
+ e.preventDefault();
491
+ this.$('.composer').requestSubmit();
492
+ }
493
+ if (e.key === 'Escape') this.close();
494
+ });
495
+ ta.addEventListener('input', () => autoGrow(ta));
496
+
497
+ // Voz
498
+ const vb = this.$('.b-voice');
499
+ if (!this.voice) vb.hidden = true;
500
+ const paintVoice = (this._paintVoice = () => {
501
+ const muted = !!this.voice?.muted;
502
+ vb.innerHTML = muted ? ICONS.voiceOff : ICONS.voice;
503
+ vb.setAttribute('aria-pressed', String(muted));
504
+ vb.title = this.t(muted ? 'widget.voice.off' : 'widget.voice.on');
505
+ });
506
+ paintVoice();
507
+ vb.onclick = () => {
508
+ this.voice?.setMuted(!this.voice.muted);
509
+ paintVoice();
510
+ };
511
+ const mic = this.$('.b-mic');
512
+ if (this.voice?.canListen) {
513
+ mic.hidden = false;
514
+ mic.onclick = async () => {
515
+ if (this.voice.listening) return this.voice.stopListening();
516
+ mic.classList.add('rec');
517
+ const text = await this.voice.listen({ onPartial: (t) => (ta.value = t) });
518
+ mic.classList.remove('rec');
519
+ ta.value = '';
520
+ if (text) this._submit(text, 'voice');
521
+ };
522
+ }
523
+
524
+ // Menús
525
+ const toggleMenu = (sel) => {
526
+ const m = this.$(sel);
527
+ const show = m.hidden;
528
+ this.shadowRoot.querySelectorAll('.menu').forEach((x) => (x.hidden = true));
529
+ m.hidden = !show;
530
+ };
531
+ this.$('.b-contact')?.addEventListener('click', () => toggleMenu('.contact-menu'));
532
+ this.$('.b-menu').onclick = () => {
533
+ this.$('[data-more="proactive"] .lbl').textContent = this.t(this.store.get('pro:muted', false) ? 'widget.menu.unmuteSuggestions' : 'widget.menu.muteSuggestions');
534
+ this.shadowRoot.querySelectorAll('[data-level]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.level === this.proactivity?.level)));
535
+ toggleMenu('.more-menu');
536
+ };
537
+ this.shadowRoot.addEventListener('click', (e) => {
538
+ if (!e.target.closest('.menu, .b-contact, .b-menu')) this.shadowRoot.querySelectorAll('.menu').forEach((x) => (x.hidden = true));
539
+ });
540
+ this.$('.contact-menu').onclick = (e) => {
541
+ const b = e.target.closest('[data-contact]');
542
+ if (!b) return;
543
+ this.$('.contact-menu').hidden = true;
544
+ this._submit(this.t(b.dataset.contact === 'human' ? 'widget.contact.askHuman' : 'widget.contact.askMail'));
545
+ };
546
+ this.$('.more-menu').onclick = (e) => {
547
+ const lv = e.target.closest('[data-level]');
548
+ if (lv) {
549
+ this.proactivity?.setLevel(lv.dataset.level);
550
+ if (this.store.get('pro:muted', false)) this.proactivity?.mute(false);
551
+ this.shadowRoot.querySelectorAll('[data-level]').forEach((b) => b.setAttribute('aria-checked', String(b === lv)));
552
+ return;
553
+ }
554
+ const b = e.target.closest('[data-more]');
555
+ if (!b) return;
556
+ this.$('.more-menu').hidden = true;
557
+ if (b.dataset.more === 'proactive') this.proactivity?.mute(!this.store.get('pro:muted', false));
558
+ if (b.dataset.more === 'reset') this.reset();
559
+ };
560
+
561
+ // Derivación a humano
562
+ this.$('.b-video').onclick = () => this.apuchat?.callUrl && window.open(this.apuchat.callUrl, '_blank', 'noopener');
563
+ this.$('.b-endhandoff').onclick = () => this.apuchat?.end(this.t('widget.handoff.userLeft'));
564
+
565
+ // ── eventos del agente ──
566
+ on('agent:thinking', ({ on: v }) => this._setThinking(v));
567
+ on('agent:message', ({ text, error }) => this._onAgentMessage(text, { error }));
568
+ on('action:start', ({ name, args }) => {
569
+ this._actionChip(name, 'start');
570
+ const t = args?.target ?? args?.source ?? args?.fields?.[0]?.target;
571
+ if (this.companion && FOLLOW_ACTIONS.has(name) && t) this.companion.follow(this.context.resolve(t));
572
+ });
573
+ on('action:end', ({ name, result }) => this._actionChip(name, result.ok ? 'ok' : 'fail'));
574
+ on('handoff:start', () => this._setHandoff(true));
575
+ on('handoff:end', ({ reason }) => {
576
+ this._setHandoff(false);
577
+ this._addMessage('system', reason || this.t('widget.handoff.ended'));
578
+ });
579
+ on('handoff:message', ({ from, text }) => {
580
+ this.brain.recordHuman(text, from);
581
+ this._addMessage('human', text, { name: from });
582
+ const who = this.$('.handoff-who');
583
+ who.removeAttribute('data-t'); // ya es el nombre de la persona: no se traduce
584
+ who.textContent = from;
585
+ if (!this.isOpen) this._showTeaser(`${from}: ${text}`);
586
+ });
587
+ on('auth:change', () => this._connectMcp());
588
+ }
589
+
590
+ // ───────────────────────────── acciones de UI ─────────────────────────────
591
+
592
+ get isOpen() {
593
+ return !this.$('.panel').hidden;
594
+ }
595
+
596
+ open({ focus = true } = {}) {
597
+ this.$('.panel').hidden = false;
598
+ this.$('.launcher').setAttribute('aria-expanded', 'true');
599
+ this.$('.launcher').classList.add('active');
600
+ this.$('.dot').hidden = true;
601
+ this._hideTeaser();
602
+ this.store.set('open', true);
603
+ this._loadAvatar();
604
+ this._scrollBottom();
605
+ if (focus) this.$('textarea').focus();
606
+ this.companion?.panelChanged(true);
607
+ this.bus.emit('widget:open', {});
608
+ }
609
+
610
+ close() {
611
+ this.$('.panel').hidden = true;
612
+ this.$('.launcher').setAttribute('aria-expanded', 'false');
613
+ this.$('.launcher').classList.remove('active');
614
+ this.store.set('open', false);
615
+ this.voice?.interrupt();
616
+ this.companion?.panelChanged(false);
617
+ this.bus.emit('widget:close', {});
618
+ }
619
+
620
+ async _loadAvatar() {
621
+ if (!this.avatar || this._avatarLoading) return;
622
+ this._avatarLoading = true;
623
+ await this._identityReady;
624
+ const loading = this.$('.loading');
625
+ loading.hidden = false;
626
+ await this.avatar.load((p) => {
627
+ if (p != null) loading.firstElementChild.style.width = `${Math.round(p * 100)}%`;
628
+ });
629
+ loading.hidden = true;
630
+ this.$('.stage-wrap').dataset.mode = this.avatar.mode;
631
+ }
632
+
633
+ _submit(text, source = 'text') {
634
+ const clean = String(text || '').trim();
635
+ if (!clean) return;
636
+ this._addMessage('user', clean);
637
+ if (this.apuchat?.active) {
638
+ this.brain.recordHuman(clean, 'usuario');
639
+ this.apuchat.send(clean).catch(() => this._addMessage('system', this.t('widget.handoff.sendFailed')));
640
+ return;
641
+ }
642
+ this.voice?.interrupt();
643
+ this.brain.send(clean, { source });
644
+ }
645
+
646
+ _onAgentMessage(text, { error } = {}) {
647
+ const { clean, moods, gestures } = extractExpressions(text);
648
+ if (!clean) return;
649
+ this._addMessage('agent', clean, { error });
650
+ if (this.avatar?.mode && this.avatar.mode !== 'none') {
651
+ if (moods.length) this.avatar.setMood(moods.at(-1));
652
+ if (gestures.length) this.avatar.gesture(gestures[0]);
653
+ }
654
+ if (this.companion) {
655
+ // El bocadillo siempre; la voz, por aquí solo si el chat está cerrado (abierto habla abajo).
656
+ this.companion.say(clean, { speak: !this.isOpen });
657
+ if (!this.isOpen) return;
658
+ } else if (!this.isOpen) {
659
+ this._showTeaser(clean);
660
+ return;
661
+ }
662
+ if (this.voice) {
663
+ this._subtitle(clean);
664
+ this.voice.say(clean).then(() => this._subtitle(''));
665
+ }
666
+ }
667
+
668
+ _addMessage(from, text, { name, error, restore } = {}) {
669
+ const li = document.createElement('li');
670
+ li.className = `msg ${from}${error ? ' error' : ''}`;
671
+ if (from === 'user' || from === 'system') li.textContent = text;
672
+ else {
673
+ li.innerHTML = (from === 'human' ? `<span class="author">${escapeHtml(name || this.t('widget.handoff.author'))}</span>` : '') + renderMarkdown(text);
674
+ }
675
+ this.$('.messages').appendChild(li);
676
+ if (!restore) this._scrollBottom();
677
+ return li;
678
+ }
679
+
680
+ _actionChip(name, state) {
681
+ const list = this.$('.messages');
682
+ const key = `widget.actions.${name}`;
683
+ const own = this.actions._actions.get(name);
684
+ const label = (this.t.has(key) ? this.t(key) : own && actionTitle(own)) || name.replace(/__/g, ' · ').replace(/_/g, ' ');
685
+ let chip = list.querySelector(`.chip[data-running="${CSS.escape(name)}"]`);
686
+ if (state === 'start') {
687
+ chip = document.createElement('li');
688
+ chip.className = 'chip';
689
+ chip.dataset.running = name;
690
+ chip.textContent = `${label}…`;
691
+ list.appendChild(chip);
692
+ } else if (chip) {
693
+ chip.removeAttribute('data-running');
694
+ chip.classList.add(state);
695
+ chip.textContent = state === 'ok' ? `✓ ${label}` : `✕ ${label}`;
696
+ }
697
+ this._scrollBottom();
698
+ }
699
+
700
+ _setThinking(on) {
701
+ this._statusKey = on ? 'widget.status.thinking' : this.apuchat?.active ? 'widget.status.withTeam' : 'widget.status.online';
702
+ this.$('.sub').textContent = this.t(this._statusKey);
703
+ this.$('.panel').classList.toggle('thinking', on);
704
+ let t = this.$('.typing');
705
+ if (on && !t) {
706
+ t = document.createElement('li');
707
+ t.className = 'typing';
708
+ t.innerHTML = '<span></span><span></span><span></span>';
709
+ t.setAttribute('aria-label', this.t('widget.typing'));
710
+ this.$('.messages').appendChild(t);
711
+ this._scrollBottom();
712
+ } else if (!on) t?.remove();
713
+ }
714
+
715
+ _setHandoff(on) {
716
+ this.$('.handoff').hidden = !on;
717
+ this.$('.b-video').hidden = !this.apuchat?.callUrl;
718
+ this.$('.panel').classList.toggle('human', on);
719
+ this._handoffOn = on;
720
+ this._statusKey = on ? 'widget.status.withTeam' : 'widget.status.online';
721
+ this.$('.sub').textContent = this.t(this._statusKey);
722
+ this.$('textarea').placeholder = this.t(on ? 'widget.composer.placeholderTeam' : 'widget.composer.placeholder');
723
+ }
724
+
725
+ _subtitle(text) {
726
+ const s = this.$('.subtitles');
727
+ s.textContent = text.length > 160 ? text.slice(0, 157) + '…' : text;
728
+ }
729
+
730
+ _showTeaser(text) {
731
+ const t = this.$('.teaser');
732
+ t.querySelector('.teaser-text').textContent = text.length > 180 ? text.slice(0, 177) + '…' : text;
733
+ t.hidden = false;
734
+ this.$('.dot').hidden = false;
735
+ }
736
+
737
+ _hideTeaser() {
738
+ this.$('.teaser').hidden = true;
739
+ }
740
+
741
+ _scrollBottom() {
742
+ const m = this.$('.messages');
743
+ requestAnimationFrame(() => (m.scrollTop = m.scrollHeight));
744
+ }
745
+
746
+ _beforeNavigate(url, reason) {
747
+ this.store.set('pendingNav', { url, reason, at: Date.now() });
748
+ this.brain._persist();
749
+ this.apuchat?.suspend();
750
+ }
751
+
752
+ async _connectMcp() {
753
+ for (const m of this.config.mcp) {
754
+ if ((m.requiresAuth ?? true) && !this.auth.isAuthenticated()) continue;
755
+ try {
756
+ const names = await this.actions.registerMcpServer(m);
757
+ if (this.config.debug) console.info(`[7ots] MCP "${m.name || 'site'}": ${names.length} herramientas`);
758
+ } catch (err) {
759
+ console.warn(`[7ots] no se pudo conectar el MCP ${m.url}:`, err?.message || err);
760
+ }
761
+ }
762
+ }
763
+
764
+ /** Borra la conversación (y cierra una derivación activa). */
765
+ reset() {
766
+ this.brain.reset();
767
+ this.voice?.interrupt();
768
+ this.apuchat?.end('');
769
+ this.$('.messages').replaceChildren();
770
+ this.ui.closeAll();
771
+ }
772
+
773
+ destroy() {
774
+ if (!this._started) return;
775
+ this._started = false;
776
+ this._offs.forEach((off) => off());
777
+ this.proactivity?.stop();
778
+ this.context.stop();
779
+ this.brain.abort();
780
+ this.voice?.interrupt();
781
+ this.avatar?.dispose();
782
+ this.apuchat?.suspend();
783
+ this.actions.closeAll();
784
+ this.pageTools?.stop();
785
+ this.pointer?.destroy();
786
+ this.companion?.destroy();
787
+ this.ui.destroy();
788
+ this.bus.clear();
789
+ this.shadowRoot.replaceChildren();
790
+ if (this.isConnected) this.remove();
791
+ }
792
+ }
793
+
794
+ // ───────────────────────────── API pública ─────────────────────────────
795
+
796
+ /**
797
+ * Objeto que recibe el desarrollador (y los handlers como ctx.agent).
798
+ * Todo lo interno sigue accesible vía agent.widget para casos avanzados.
799
+ */
800
+ function createPublicApi(w) {
801
+ return {
802
+ widget: w,
803
+ bus: w.bus,
804
+ ui: w.ui,
805
+ auth: w.auth,
806
+ context: w.context,
807
+ actions: w.actions,
808
+ /** Registra una acción (herramienta) propia. Devuelve una función para quitarla. */
809
+ registerAction: (def) => w.actions.register(def),
810
+ /** Conecta otro servidor MCP del sitio en caliente. */
811
+ registerMcp: (opt) => w.actions.registerMcpServer(opt),
812
+ /** Plantillas HTML de confianza para show_modal/open_sidebar. */
813
+ registerTemplate: (name, fn) => w.ui.registerTemplate(name, fn),
814
+ /** Actualiza el JWT del usuario (login/logout/refresh) y reconecta el MCP. */
815
+ setAuthToken: (token) => w.auth.setToken(token),
816
+ /** Envía un mensaje como si lo escribiera el usuario. */
817
+ ask: (text) => {
818
+ if (!w.isOpen && !w.companion) w.open(); // el compañero contesta en su bocadillo
819
+ w._submit(text);
820
+ },
821
+ /** Hace que el agente diga algo literal (sin pasar por el LLM). */
822
+ say: (text) => w.bus.emit('agent:message', { text, final: true }),
823
+ /** Dispara una observación proactiva propia ("el carrito lleva 5 min abandonado"). */
824
+ notify: (text, opt) => w.brain.notify(text, opt),
825
+ /** Identidad del agente (nombre, aspecto, voz, contacto); se aplica en caliente. */
826
+ setIdentity: (identity) => w.setIdentity(identity),
827
+ get identity() {
828
+ return w.identity;
829
+ },
830
+ /** Nivel de iniciativa: 'quiet' | 'normal' | 'bold'. */
831
+ setProactivity: (level) => w.proactivity?.setLevel(level),
832
+ /** Idioma del widget en caliente: 'es' | 'en' | 'pt'. Emite 'locale:change'. Devuelve el resuelto. */
833
+ setLocale: (lang) => w.setLocale(lang),
834
+ get locale() {
835
+ return w.t?.locale;
836
+ },
837
+ open: () => w.open(),
838
+ close: () => w.close(),
839
+ reset: () => w.reset(),
840
+ on: (event, fn) => w.bus.on(event, fn),
841
+ destroy: () => w.destroy(),
842
+ };
843
+ }
844
+
845
+ // ───────────────────────────── helpers ─────────────────────────────
846
+
847
+ function mergeConfig(base, over) {
848
+ const out = { ...base };
849
+ for (const [k, v] of Object.entries(over || {})) {
850
+ if (v === undefined) continue;
851
+ const b = base[k];
852
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) && b && typeof b === 'object' && !Array.isArray(b) && typeof v !== 'function'
853
+ ? mergeConfig(b, v)
854
+ : v;
855
+ }
856
+ return out;
857
+ }
858
+
859
+ const VOICE_REGION = { es: 'es-ES', en: 'en-US', pt: 'pt-BR', fr: 'fr-FR', de: 'de-DE', it: 'it-IT' };
860
+
861
+ /**
862
+ * Etiqueta BCP 47 para la voz a partir del idioma del widget. Conserva la región si alguna
863
+ * preferencia del mismo idioma la trae (locale 'en-GB', agent.language 'es-MX', <html lang="pt-PT">);
864
+ * si no, la región habitual (es→es-ES, en→en-US, pt→pt-BR). El navegador no cuenta: la voz
865
+ * sigue al sitio, como antes.
866
+ * @param {string} locale idioma resuelto del widget
867
+ * @param {Array<string|null|undefined>} [prefs] preferencias explícitas (van primero)
868
+ */
869
+ function voiceLangFor(locale, prefs = []) {
870
+ const base = String(locale || 'es').split('-')[0].toLowerCase();
871
+ const cands = [locale, ...prefs, document.documentElement.lang].filter(Boolean).map(String);
872
+ const withRegion = cands.find((c) => c.includes('-') && c.split('-')[0].toLowerCase() === base);
873
+ return withRegion || VOICE_REGION[base] || base;
874
+ }
875
+
876
+ function autoGrow(ta) {
877
+ ta.style.height = 'auto';
878
+ ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`;
879
+ }
880
+
881
+ const svg = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
882
+ const ICONS = {
883
+ voice: svg('<path d="M4 9v6h4l5 4V5L8 9H4Zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4Z"/>'),
884
+ voiceOff: svg('<path d="M4 9v6h4l5 4V5L8 9H4Zm16.3-.3-1.4-1.4L17 9.2l-1.9-1.9-1.4 1.4 1.9 1.9-1.9 1.9 1.4 1.4 1.9-1.9 1.9 1.9 1.4-1.4-1.9-1.9Z"/>'),
885
+ contact: svg('<path d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Zm0 4-8 5-8-5V6l8 5 8-5Z"/>'),
886
+ more: svg('<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>'),
887
+ close: svg('<path d="M5 11h14v2H5z"/>'),
888
+ person: svg('<path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4Zm0 2c-3.3 0-8 1.7-8 5v1h16v-1c0-3.3-4.7-5-8-5Z"/>'),
889
+ mail: svg('<path d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Zm0 4-8 5-8-5V6l8 5 8-5Z"/>'),
890
+ bell: svg('<path d="M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2Zm6-6V11a6 6 0 0 0-5-5.9V4h-2v1.1A6 6 0 0 0 6 11v5l-2 2v1h16v-1Z"/>'),
891
+ reset: svg('<path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/>'),
892
+ mic: svg('<path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1A7 7 0 0 0 19 11Z"/>'),
893
+ send: svg('<path d="M3 20.5 21 12 3 3.5 3 10l12 2-12 2Z"/>'),
894
+ };
895
+
896
+ function widgetCss(t = {}) {
897
+ const side = t.position === 'left' ? 'left' : 'right';
898
+ return `
899
+ :host { all: initial; display: block; }
900
+ :host {
901
+ --p: ${t.primary || '#4f46e5'};
902
+ --r: ${t.radius ?? 16}px;
903
+ --bg: #ffffff; --fg: #111827; --mut: #6b7280; --line: #e5e7eb; --soft: #f3f4f6;
904
+ --agent: #f3f4f6; --human: #ecfdf5; --human-b: #10b981;
905
+ position: fixed; ${side}: 20px; bottom: 20px; z-index: ${t.zIndex ?? 2147483000};
906
+ font: 14px/1.45 ${t.font || 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'};
907
+ color: var(--fg);
908
+ }
909
+ @media (prefers-color-scheme: dark) {
910
+ :host { --bg: #111827; --fg: #f9fafb; --mut: #9ca3af; --line: #374151; --soft: #1f2937; --agent: #1f2937; --human: #064e3b; }
911
+ }
912
+ *, *::before, *::after { box-sizing: border-box; }
913
+ [hidden] { display: none !important; }
914
+ button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; }
915
+ svg { width: 20px; height: 20px; fill: currentColor; }
916
+
917
+ /* ── modo compañero ── */
918
+ .buddy { position: fixed; inset: auto; left: 0; top: 0; width: var(--bs); height: var(--bs); z-index: 1;
919
+ margin: 0; padding: 0; border: 0; background: none; overflow: visible; color: var(--fg);
920
+ transition-property: transform; transition-timing-function: cubic-bezier(.45,.05,.3,1); will-change: transform; }
921
+ :host(.companion) .panel { bottom: 0; }
922
+ .buddy.behind { opacity: 0; pointer-events: none; }
923
+ .b-body { position: absolute; inset: 0; padding: 0; border-radius: 50%; cursor: grab; touch-action: none; display: block; }
924
+ .b-body:active { cursor: grabbing; }
925
+ .b-body:focus-visible { outline: 3px solid var(--p); outline-offset: 2px; }
926
+ .b-stage { position: absolute; inset: 0; display: block; border-radius: 50%; overflow: hidden;
927
+ background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--p) 22%, transparent), transparent 70%); }
928
+ .b-stage .stage { position: absolute; inset: 0; }
929
+ .buddy .b-stage .ots-face { width: 88%; height: 88%; position: absolute; left: 6%; top: 6%; transform: none; }
930
+ .b-initial { position: absolute; inset: 12%; border-radius: 50%; background: var(--p); color: #fff; display: grid; place-items: center;
931
+ font: 700 calc(var(--bs) * .32)/1 system-ui, sans-serif; }
932
+ .b-shadow { position: absolute; left: 22%; right: 22%; bottom: -6px; height: 10px; border-radius: 50%; background: rgba(0,0,0,.18); filter: blur(3px); }
933
+ .buddy:not(.walking) .b-body { animation: idle 3.2s ease-in-out infinite; }
934
+ .buddy.walking .b-stage { animation: hop .32s ease-in-out infinite alternate; }
935
+ .buddy.curious:not(.walking) .b-stage { animation: tilt 1.6s ease-in-out infinite alternate; }
936
+ @keyframes tilt { from { transform: rotate(-4deg); } to { transform: rotate(4deg) translateY(-3px); } }
937
+ .buddy.walking.to-left .b-stage { animation-name: hop-l; }
938
+ @keyframes idle { 50% { transform: translateY(-4px); } }
939
+ @keyframes hop { from { transform: translateY(0) rotate(-3deg); } to { transform: translateY(-10px) rotate(3deg); } }
940
+ @keyframes hop-l { from { transform: translateY(0) rotate(3deg); } to { transform: translateY(-10px) rotate(-3deg); } }
941
+ .b-say { position: absolute; width: max-content; max-width: min(280px, calc(100vw - 32px)); padding: 10px 14px; border-radius: 16px;
942
+ background: var(--bg); color: var(--fg); box-shadow: 0 10px 30px rgba(0,0,0,.22); cursor: pointer; animation: pop .2s ease; font-size: 14px; }
943
+ .b-say p { margin: 0; }
944
+ .b-say p + p { margin-top: 6px; }
945
+ .buddy[data-v="bottom"] .b-say { bottom: calc(100% + 10px); }
946
+ .buddy[data-v="top"] .b-say { top: calc(100% + 10px); }
947
+ .buddy[data-h="right"] .b-say { right: 10%; }
948
+ .buddy[data-h="left"] .b-say { left: 10%; }
949
+ .b-say::after { content: ''; position: absolute; width: 14px; height: 14px; background: inherit; transform: rotate(45deg); }
950
+ .buddy[data-v="bottom"] .b-say::after { bottom: -6px; }
951
+ .buddy[data-v="top"] .b-say::after { top: -6px; }
952
+ .buddy[data-h="right"] .b-say::after { right: 26px; }
953
+ .buddy[data-h="left"] .b-say::after { left: 26px; }
954
+ @keyframes pop { from { opacity: 0; transform: scale(.9); } }
955
+ .b-arrow { position: absolute; left: 0; top: 0; width: 34px; height: 14px; margin: -7px 0 0 0; transform-origin: 0 50%; pointer-events: none; }
956
+ .b-arrow::before { content: ''; position: absolute; inset: 4px 10px 4px 0; background: var(--p); border-radius: 3px; }
957
+ .b-arrow::after { content: ''; position: absolute; right: 0; top: 0; border: 7px solid transparent; border-left: 12px solid var(--p); border-right: 0; }
958
+ .b-arrow.go { animation: poke .5s ease-in-out 4 alternate; }
959
+ @keyframes poke { to { margin-left: 8px; } }
960
+ @media (prefers-reduced-motion: reduce) { .buddy *, .buddy { animation: none !important; } }
961
+
962
+ .launcher { position: relative; width: 60px; height: 60px; border-radius: 50%; background: var(--p); color: #fff;
963
+ display: grid; place-items: center; box-shadow: 0 8px 24px rgba(0,0,0,.25); transition: transform .2s; margin-${side}: 0; float: ${side}; }
964
+ .launcher:hover { transform: scale(1.06); }
965
+ .launcher svg { width: 28px; height: 28px; }
966
+ .launcher.active { transform: scale(.9); }
967
+ .launcher .dot { position: absolute; top: 4px; ${side === 'right' ? 'right' : 'left'}: 4px; width: 14px; height: 14px; border-radius: 50%; background: #ef4444; border: 2px solid #fff; }
968
+
969
+ .teaser { position: absolute; bottom: 72px; ${side}: 0; width: min(300px, calc(100vw - 40px)); background: var(--bg); color: var(--fg);
970
+ border-radius: var(--r); padding: 12px 30px 12px 14px; box-shadow: 0 10px 30px rgba(0,0,0,.18); cursor: pointer; animation: pop .25s ease-out; }
971
+ .teaser-x { position: absolute; top: 4px; ${side}: 6px; font-size: 18px; color: var(--mut); }
972
+
973
+ .panel { position: absolute; bottom: 72px; ${side}: 0; width: min(380px, calc(100vw - 24px)); height: min(640px, calc(100vh - 110px));
974
+ background: var(--bg); border-radius: var(--r); box-shadow: 0 20px 50px rgba(0,0,0,.28); display: flex; flex-direction: column;
975
+ overflow: hidden; animation: pop .22s ease-out; border: 1px solid var(--line); }
976
+ header { display: flex; align-items: center; gap: 4px; padding: 10px 8px 10px 14px; border-bottom: 1px solid var(--line); }
977
+ .status { width: 9px; height: 9px; border-radius: 50%; background: #22c55e; margin-right: 6px; }
978
+ .panel.thinking .status { background: #f59e0b; animation: pulse 1s infinite; }
979
+ .panel.human .status { background: var(--human-b); }
980
+ .who { flex: 1; display: flex; flex-direction: column; min-width: 0; }
981
+ .who small { color: var(--mut); font-size: 12px; }
982
+ .icon { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--mut); }
983
+ .icon:hover { background: var(--soft); color: var(--fg); }
984
+ .icon[aria-pressed="true"] { color: #ef4444; }
985
+
986
+ .menu { position: absolute; top: 52px; right: 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
987
+ box-shadow: 0 10px 30px rgba(0,0,0,.15); padding: 6px; z-index: 5; min-width: 230px; }
988
+ .menu button { display: flex; gap: 10px; align-items: center; width: 100%; padding: 9px 10px; border-radius: 8px; text-align: left; }
989
+ .menu button:hover { background: var(--soft); }
990
+ .level { padding: 6px 10px 8px; font-size: 12px; color: var(--muted); }
991
+ .level .seg { display: flex; gap: 2px; margin-top: 6px; background: var(--soft); border-radius: 9px; padding: 2px; }
992
+ .menu .level .seg button { justify-content: center; padding: 6px 4px; font-size: 12px; border-radius: 7px; color: var(--fg); }
993
+ .menu .level .seg button[aria-checked="true"] { background: var(--primary); color: #fff; }
994
+
995
+ .stage-wrap { position: relative; height: 230px; flex: none; background: radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--p) 18%, var(--bg)), var(--bg)); }
996
+ .stage { position: absolute; inset: 0; }
997
+ .stage-wrap[data-mode="2d"] { height: 150px; }
998
+ .stage-wrap:has(.otsc) { height: 190px; }
999
+ .stage > .otsc { padding: 10px 0 6px; box-sizing: border-box; }
1000
+ .buddy .b-stage:has(.otsc) { overflow: visible; background: none; }
1001
+ .stage .ots-face { width: 120px; height: 120px; position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); }
1002
+ .ots-face-bg { fill: var(--face-bg, var(--p)); }
1003
+ .ots-eyes, .ots-mouth { fill: var(--face-fg, #fff); }
1004
+ .ots-portrait { position: absolute; left: 50%; top: 50%; width: 120px; height: 120px; transform: translate(-50%,-50%); border-radius: 50%;
1005
+ overflow: hidden; background: var(--p); box-shadow: 0 0 0 calc(3px + var(--ots-talk, 0) * 12px) color-mix(in srgb, var(--p) 45%, transparent);
1006
+ transition: box-shadow .08s linear; }
1007
+ .ots-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }
1008
+ .buddy .b-stage .ots-portrait { width: 88%; height: 88%; }
1009
+ .ots-mouth { transition: ry .06s; }
1010
+ .ots-face[data-mood="sad"] .ots-eyes { transform: translateY(3px); transform-origin: center; }
1011
+ .loading { position: absolute; left: 20%; right: 20%; bottom: 14px; height: 4px; background: var(--line); border-radius: 4px; overflow: hidden; }
1012
+ .loading span { display: block; height: 100%; width: 10%; background: var(--p); transition: width .2s; }
1013
+ .subtitles { position: absolute; left: 10px; right: 10px; bottom: 8px; text-align: center; font-size: 13px; color: #fff;
1014
+ text-shadow: 0 1px 3px rgba(0,0,0,.8); pointer-events: none; }
1015
+
1016
+ .handoff { display: flex; gap: 8px; align-items: center; padding: 8px 12px; background: var(--human); font-size: 13px; }
1017
+ .handoff span { flex: 1; display: flex; gap: 6px; align-items: center; }
1018
+ .handoff svg { width: 16px; height: 16px; }
1019
+ .link { color: var(--p); text-decoration: underline; font-size: 12px; }
1020
+
1021
+ .messages { flex: 1; overflow-y: auto; list-style: none; margin: 0; padding: 14px; display: flex; flex-direction: column; gap: 8px; overscroll-behavior: contain; }
1022
+ .msg { max-width: 86%; padding: 9px 12px; border-radius: 14px; word-wrap: break-word; }
1023
+ .msg p { margin: 0 0 6px; } .msg p:last-child { margin: 0; }
1024
+ .msg ul, .msg ol { margin: 4px 0; padding-left: 20px; }
1025
+ .msg a { color: var(--p); }
1026
+ .msg code { background: rgba(0,0,0,.07); padding: 1px 4px; border-radius: 4px; }
1027
+ .msg.user { align-self: flex-end; background: var(--p); color: #fff; border-bottom-right-radius: 4px; white-space: pre-wrap; }
1028
+ .msg.agent { align-self: flex-start; background: var(--agent); border-bottom-left-radius: 4px; }
1029
+ .msg.human { align-self: flex-start; background: var(--human); border-left: 3px solid var(--human-b); }
1030
+ .msg.human .author { display: block; font-size: 11px; font-weight: 600; color: var(--human-b); margin-bottom: 2px; }
1031
+ .msg.system { align-self: center; color: var(--mut); font-size: 12px; background: none; }
1032
+ .msg.error { border: 1px solid #fca5a5; }
1033
+ .chip { align-self: flex-start; font-size: 12px; color: var(--mut); padding: 2px 10px; border-radius: 999px; border: 1px dashed var(--line); }
1034
+ .chip.ok { border-style: solid; } .chip.fail { color: #ef4444; }
1035
+ .typing { align-self: flex-start; display: flex; gap: 4px; padding: 12px; background: var(--agent); border-radius: 14px; }
1036
+ .typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--mut); animation: blink 1.2s infinite; }
1037
+ .typing span:nth-child(2) { animation-delay: .2s; } .typing span:nth-child(3) { animation-delay: .4s; }
1038
+
1039
+ .composer { display: flex; gap: 4px; align-items: flex-end; padding: 8px; border-top: 1px solid var(--line); }
1040
+ textarea { flex: 1; resize: none; border: 1px solid var(--line); border-radius: 12px; padding: 9px 12px; font: inherit; color: var(--fg);
1041
+ background: var(--bg); max-height: 120px; outline: none; }
1042
+ textarea:focus { border-color: var(--p); box-shadow: 0 0 0 3px color-mix(in srgb, var(--p) 20%, transparent); }
1043
+ .send { color: var(--p); }
1044
+ .b-mic.rec { color: #fff; background: #ef4444; animation: pulse 1s infinite; }
1045
+ .brand { font-size: 10.5px; text-align: center; color: var(--mut); padding: 0 0 6px; }
1046
+ .brand a { color: inherit; }
1047
+ :focus-visible { outline: 2px solid var(--p); outline-offset: 2px; }
1048
+
1049
+ @keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98); } }
1050
+ @keyframes pulse { 50% { opacity: .5; } }
1051
+ @keyframes blink { 0%, 80%, 100% { opacity: .3; } 40% { opacity: 1; } }
1052
+ @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
1053
+ @media (max-width: 480px) {
1054
+ :host { ${side}: 12px; bottom: 12px; }
1055
+ .panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; border-radius: 0; bottom: 0; }
1056
+ }`;
1057
+ }
1058
+
1059
+ if (!customElements.get('ots-agent')) customElements.define('ots-agent', AgentWidget);