@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,351 @@
1
+ /**
2
+ * <ots-identity> — tarjeta de contacto de un agente: quién es y cómo hablar con él.
3
+ *
4
+ * <ots-identity src="/api/agent/identity"></ots-identity>
5
+ * <ots-identity src="https://otro-sitio.com/.well-known/7ots-agent.json" face></ots-identity>
6
+ *
7
+ * Atributos:
8
+ * src URL de la tarjeta (defecto /api/agent/identity). También: `el.identity = {...}`.
9
+ * endpoint proxy de 7ots para videollamadas y voz (defecto: el de `src` si acaba en /identity)
10
+ * face muestra la cara viva (createFace) con un botón «Escuchar»
11
+ * greeting lo que dice al pulsar «Escuchar» (defecto: se presenta)
12
+ * compact versión en una línea
13
+ * lang idioma de la interfaz ('es', 'en', 'pt'…). También `el.lang = 'en'`; se aplica en caliente.
14
+ * Sin él: el del ancestro con [lang] / <html lang> / navegador (detectLocale), y sigue a
15
+ * setLocale() si la página cambia el idioma global. No se usa `identity.language` para
16
+ * los botones (la tarjeta la lee el visitante), pero sí para el saludo de «Escuchar»,
17
+ * que dice la voz del agente en su idioma.
18
+ *
19
+ * Botones según la tarjeta: chatear aquí (si hay un <ots-agent> en la página), correo,
20
+ * @handle de apuchat (copiar), videollamada en meet.apuchat.com y guardar contacto (vCard).
21
+ * Eventos: `ots-identity:load` { identity }, `ots-identity:call` { url }, `ots-identity:error`.
22
+ */
23
+
24
+ import { defineIdentity } from './schema.js';
25
+ import { createFace } from './Face.js';
26
+ import { translator, detectLocale, getLocale, onLocaleChange } from '../i18n/index.js';
27
+ import '../i18n/messages/identity.js';
28
+
29
+ export class IdentityCard extends HTMLElement {
30
+ static get observedAttributes() {
31
+ return ['src', 'endpoint', 'face', 'compact', 'greeting', 'lang'];
32
+ }
33
+
34
+ constructor() {
35
+ super();
36
+ this.attachShadow({ mode: 'open' });
37
+ this._card = null;
38
+ this._face = null;
39
+ this._t = translator(detectLocale());
40
+ }
41
+
42
+ /** Idioma en uso (resuelto). Para cambiarlo, el atributo/propiedad `lang`. */
43
+ get locale() {
44
+ return this._t.locale;
45
+ }
46
+
47
+ /** lang propio → idioma global si la página lo cambió → ancestro [lang] / <html lang> / navegador. */
48
+ _resolveLocale() {
49
+ const own = this.getAttribute('lang');
50
+ if (own) return detectLocale(own);
51
+ if (this._followGlobal) return getLocale();
52
+ return detectLocale(this.parentElement?.closest?.('[lang]')?.getAttribute('lang'));
53
+ }
54
+
55
+ connectedCallback() {
56
+ this.setAttribute('data-ots-ignore', ''); // la proactividad del widget no reacciona a la tarjeta
57
+ this._t = translator(this._resolveLocale());
58
+ this._offLocale?.();
59
+ this._g0 = getLocale();
60
+ this._offLocale = onLocaleChange((g) => {
61
+ if (g !== this._g0) {
62
+ this._g0 = g;
63
+ this._followGlobal = true;
64
+ }
65
+ if (this._localeQueued) return; // addMessages emite una vez por idioma: agrupar
66
+ this._localeQueued = true;
67
+ queueMicrotask(() => {
68
+ this._localeQueued = false;
69
+ this._relocalize();
70
+ });
71
+ });
72
+ if (!this._card && !this._loading) this._load();
73
+ }
74
+
75
+ disconnectedCallback() {
76
+ this._face?.destroy();
77
+ this._face = null;
78
+ this._offLocale?.();
79
+ this._offLocale = null;
80
+ }
81
+
82
+ attributeChangedCallback(name, prev, next) {
83
+ if (prev === next || !this.isConnected) return;
84
+ if (name === 'lang') this._relocalize();
85
+ else if (name === 'src') this._load();
86
+ else if (this._card) this._render();
87
+ }
88
+
89
+ /** Vuelve a pintar en el idioma actual (sin recargar la tarjeta). */
90
+ _relocalize() {
91
+ const next = translator(this._resolveLocale());
92
+ const changed = next.locale !== this._t.locale;
93
+ this._t = next;
94
+ if (!this.isConnected) return;
95
+ if (this._card) {
96
+ // La cara viva no tiene textos visibles: solo se rehace si cambia el idioma de verdad.
97
+ if (changed || !this.hasAttribute('face')) this._render();
98
+ } else if (this._failed) this._renderError();
99
+ }
100
+
101
+ _renderError() {
102
+ this.shadowRoot.innerHTML = `<style>${css()}</style><div class="card error">${esc(this._t('identity.card.loadError'))}</div>`;
103
+ }
104
+
105
+ /** Tarjeta actual (normalizada). Asignar un objeto la pinta sin pedir nada a la red. */
106
+ get identity() {
107
+ return this._card;
108
+ }
109
+ set identity(v) {
110
+ this._gen = (this._gen || 0) + 1; // una carga en curso ya no pisa lo asignado
111
+ this._raw = v;
112
+ this._card = normalizeCard(v);
113
+ this._render();
114
+ }
115
+
116
+ get endpoint() {
117
+ const e = this.getAttribute('endpoint');
118
+ if (e) return e.replace(/\/$/, '');
119
+ const src = this.getAttribute('src') || '/api/agent/identity';
120
+ return /\/identity$/.test(src) ? src.replace(/\/identity$/, '') : '';
121
+ }
122
+
123
+ async _load() {
124
+ if (this._raw && !this.hasAttribute('src')) return this._render();
125
+ const src = this.getAttribute('src') || '/api/agent/identity';
126
+ const gen = (this._gen = (this._gen || 0) + 1);
127
+ this._loading = true;
128
+ this._failed = false;
129
+ this.shadowRoot.innerHTML = `<style>${css()}</style><div class="card loading" aria-busy="true" aria-label="${esc(this._t('identity.card.loading'))}"><div class="sk"></div></div>`;
130
+ try {
131
+ const r = await fetch(src, { credentials: 'omit' });
132
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
133
+ const data = await r.json();
134
+ if (gen !== this._gen) return;
135
+ this._card = normalizeCard(data);
136
+ this._render();
137
+ this.dispatchEvent(new CustomEvent('ots-identity:load', { detail: { identity: this._card }, bubbles: true }));
138
+ } catch (e) {
139
+ if (gen !== this._gen) return;
140
+ this._failed = true;
141
+ this._renderError();
142
+ this.dispatchEvent(new CustomEvent('ots-identity:error', { detail: { error: e }, bubbles: true }));
143
+ } finally {
144
+ this._loading = false;
145
+ }
146
+ }
147
+
148
+ _render() {
149
+ const c = this._card;
150
+ if (!c) return;
151
+ this._face?.destroy();
152
+ this._face = null;
153
+ const compact = this.hasAttribute('compact');
154
+ const live = this.hasAttribute('face');
155
+ const ep = this.endpoint;
156
+ const hasWidget = !!document.querySelector('ots-agent');
157
+ const initial = esc((c.look.emoji || c.name.slice(0, 1)).toUpperCase());
158
+ const portrait = c.look.image ? `<img src="${esc(c.look.image)}" alt="" referrerpolicy="no-referrer">` : `<span>${initial}</span>`;
159
+ const ans = c.contact.answers || {};
160
+ const t = this._t;
161
+ const btn = (act, icon, label, sub = '') =>
162
+ `<button class="act" data-act="${act}">${ICON[icon]}<span><b>${esc(label)}</b>${sub ? `<small>${esc(sub)}</small>` : ''}</span></button>`;
163
+ const actions = [
164
+ hasWidget ? btn('chat', 'chat', t('identity.card.chat'), t('identity.card.chatSub')) : '',
165
+ c.contact.email ? btn('mail', 'mail', t('identity.card.mail'), ans.email ? t('identity.card.mailInstant', { email: c.contact.email }) : c.contact.email) : '',
166
+ c.contact.apuchat ? btn('apuchat', 'at', `@${c.contact.apuchat}`, ans.apuchat ? t('identity.card.apuchatInstant') : t('identity.card.apuchatSub')) : '',
167
+ c.contact.call && ep ? btn('call', 'video', t('identity.card.call'), t('identity.card.callSub')) : '',
168
+ live ? btn('listen', 'voice', t('identity.card.listen')) : '',
169
+ ep ? `<a class="act" href="${esc(`${ep}/identity.vcf`)}" download="${esc(c.id)}.vcf">${ICON.card}<span><b>${esc(t('identity.card.save'))}</b><small>${esc(t('identity.card.saveSub'))}</small></span></a>` : '',
170
+ ].filter(Boolean);
171
+ const langs = [...new Set([c.language, ...c.languages].filter(Boolean))];
172
+
173
+ this.shadowRoot.innerHTML = `<style>${css()}</style>
174
+ <article class="card ${compact ? 'compact' : ''}" style="--p:${esc(c.look.color)};--a:${esc(c.look.accent)}" aria-label="${esc(t('identity.card.label', { name: c.name }))}">
175
+ <header>
176
+ <div class="pic ${live ? 'live' : ''}">${live ? '' : portrait}</div>
177
+ <div class="who">
178
+ <h2>${esc(c.name)}</h2>
179
+ ${c.role ? `<p class="role">${esc(c.role)}</p>` : ''}
180
+ ${c.tagline && !compact ? `<p class="tag">${esc(c.tagline)}</p>` : ''}
181
+ </div>
182
+ </header>
183
+ ${!compact && c.bio ? `<p class="bio">${esc(c.bio)}</p>` : ''}
184
+ ${!compact && (c.personality?.traits?.length || langs.length) ? `<ul class="chips">${langs.map((l) => `<li class="lang">${esc(l.toUpperCase())}</li>`).join('')}${(c.personality?.traits || []).map((t) => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}
185
+ ${!compact && c.contact.hours ? `<p class="hours">${esc(c.contact.hours)}</p>` : ''}
186
+ ${actions.length ? `<nav class="acts">${actions.join('')}</nav>` : ''}
187
+ <p class="note" role="status" aria-live="polite"></p>
188
+ <footer>${esc(t('identity.card.footer'))} · <a href="https://7ots.com" target="_blank" rel="noopener">7ots</a></footer>
189
+ </article>`;
190
+
191
+ this.shadowRoot.querySelectorAll('button.act').forEach((b) => b.addEventListener('click', () => this._act(b.dataset.act, b)));
192
+ if (live) this._mountFace();
193
+ }
194
+
195
+ async _mountFace() {
196
+ const pic = this.shadowRoot.querySelector('.pic');
197
+ try {
198
+ this._face = await createFace(pic, { identity: this._raw && !this.hasAttribute('src') ? this._raw : this._card, endpoint: this.endpoint, subtitles: false, locale: this._t.locale });
199
+ } catch (e) {
200
+ pic.innerHTML = this._card.look.image ? `<img src="${esc(this._card.look.image)}" alt="">` : `<span>${esc(this._card.name.slice(0, 1))}</span>`;
201
+ }
202
+ }
203
+
204
+ async _act(act, b) {
205
+ const c = this._card;
206
+ const t = this._t;
207
+ const note = (s) => (this.shadowRoot.querySelector('.note').textContent = s);
208
+ if (act === 'chat') {
209
+ const w = document.querySelector('ots-agent');
210
+ w?.api?.open();
211
+ return;
212
+ }
213
+ if (act === 'mail') {
214
+ location.href = `mailto:${c.contact.email}?subject=${encodeURIComponent(t('identity.card.mailSubject', { name: c.name }))}`;
215
+ return;
216
+ }
217
+ if (act === 'apuchat') {
218
+ try {
219
+ await navigator.clipboard.writeText(`@${c.contact.apuchat}`);
220
+ note(t('identity.card.apuchatCopied', { handle: c.contact.apuchat }));
221
+ } catch {
222
+ note(t('identity.card.apuchatSearch', { handle: c.contact.apuchat }));
223
+ }
224
+ return;
225
+ }
226
+ if (act === 'listen') {
227
+ b.disabled = true;
228
+ try {
229
+ await this._face?.say(this.getAttribute('greeting') || greetingFor(c));
230
+ } finally {
231
+ b.disabled = false;
232
+ }
233
+ return;
234
+ }
235
+ if (act === 'call') {
236
+ // La ventana se abre ya (dentro del clic) para que el navegador no la bloquee.
237
+ const win = window.open('', '_blank');
238
+ b.disabled = true;
239
+ note(t('identity.card.callPreparing', { name: c.name }));
240
+ try {
241
+ const r = await fetch(`${this.endpoint}/identity/call`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
242
+ const out = await r.json().catch(() => ({}));
243
+ if (!r.ok || !out.call_url) throw new Error(out.error || `HTTP ${r.status}`);
244
+ if (win) {
245
+ win.opener = null;
246
+ win.location.href = out.call_url;
247
+ } else location.href = out.call_url;
248
+ note(t('identity.card.callReady', { name: c.name }));
249
+ this.dispatchEvent(new CustomEvent('ots-identity:call', { detail: { url: out.call_url }, bubbles: true }));
250
+ } catch (e) {
251
+ win?.close();
252
+ note(t('identity.card.callError', { error: e.message }));
253
+ } finally {
254
+ b.disabled = false;
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ /** Presentación por defecto de «Escuchar»: la dice la voz del agente, así que va en su idioma. */
261
+ function greetingFor(c) {
262
+ const t = translator(c.language || 'es');
263
+ const intro = c.role ? t('identity.greeting.introRole', { name: c.name, role: c.role }) : t('identity.greeting.intro', { name: c.name });
264
+ return `${intro} ${c.tagline || t('identity.greeting.offer')}`;
265
+ }
266
+
267
+ /** Acepta una tarjeta pública o una identidad completa y devuelve la forma de la tarjeta. */
268
+ function normalizeCard(raw) {
269
+ const id = defineIdentity(raw || {});
270
+ const contact = raw?.contact || {};
271
+ return {
272
+ ...id,
273
+ contact: {
274
+ ...id.contact,
275
+ call: !!contact.call,
276
+ meet: !!contact.meet,
277
+ answers: contact.answers || {},
278
+ },
279
+ };
280
+ }
281
+
282
+ function esc(s) {
283
+ return String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
284
+ }
285
+
286
+ const svg = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
287
+ const ICON = {
288
+ chat: svg('<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"/>'),
289
+ 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"/>'),
290
+ at: svg('<path d="M12 2a10 10 0 1 0 5 18.7l-1-1.7A8 8 0 1 1 20 12v1a2 2 0 0 1-4 0V8h-2v.8A5 5 0 1 0 15 16a4 4 0 0 0 7-3v-1A10 10 0 0 0 12 2Zm0 13a3 3 0 1 1 3-3 3 3 0 0 1-3 3Z"/>'),
291
+ video: svg('<path d="M15 8a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-2.5l5 3.5V7l-5 3.5Z"/>'),
292
+ 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"/>'),
293
+ card: 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-2ZM9 8a2.5 2.5 0 1 1-2.5 2.5A2.5 2.5 0 0 1 9 8Zm5 9H4v-.8C4 14.3 7.3 13.5 9 13.5s5 .8 5 2.7Zm6-3h-4v-2h4Zm0-4h-4V8h4Z"/>'),
294
+ };
295
+
296
+ function css() {
297
+ return `
298
+ :host { all: initial; display: block; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--ots-fg, #14141f); }
299
+ * { box-sizing: border-box; }
300
+ .card { --p: #6d5dfc; --a: #22d3ee; max-width: 380px; background: var(--ots-bg, #fff); border: 1px solid var(--ots-line, #e7e7ef); border-radius: 18px;
301
+ padding: 18px; box-shadow: 0 8px 30px rgba(20,20,40,.08); position: relative; overflow: hidden; }
302
+ .card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 64px; background: linear-gradient(120deg, var(--p), var(--a)); opacity: .14; }
303
+ .card.loading .sk { height: 120px; border-radius: 12px; background: linear-gradient(90deg, #f1f1f6, #e7e7ef, #f1f1f6); background-size: 200% 100%; animation: sk 1.2s infinite linear; }
304
+ @keyframes sk { to { background-position: -200% 0; } }
305
+ .card.error { color: #9b1c1c; font-size: 14px; }
306
+ header { display: flex; gap: 14px; align-items: center; position: relative; }
307
+ .pic { width: 72px; height: 72px; flex: none; border-radius: 50%; background: var(--p); display: grid; place-items: center; overflow: hidden;
308
+ box-shadow: 0 0 0 4px var(--ots-bg, #fff), 0 0 0 6px color-mix(in srgb, var(--p) 45%, transparent); position: relative; }
309
+ .pic.live { width: 96px; height: 96px; background: color-mix(in srgb, var(--p) 18%, #fff); }
310
+ .pic img { width: 100%; height: 100%; object-fit: cover; }
311
+ .pic span { color: #fff; font-size: 30px; font-weight: 700; }
312
+ h2 { margin: 0; font-size: 20px; line-height: 1.2; }
313
+ .role { margin: 2px 0 0; color: var(--p); font-weight: 600; font-size: 14px; }
314
+ .tag { margin: 4px 0 0; color: #5b5b6b; font-size: 13px; }
315
+ .bio { margin: 14px 0 0; font-size: 14px; line-height: 1.5; color: #33334a; }
316
+ .chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 0; padding: 0; }
317
+ .chips li { font-size: 12px; padding: 3px 9px; border-radius: 99px; background: color-mix(in srgb, var(--p) 10%, #fff); color: #33334a; }
318
+ .chips li.lang { background: var(--p); color: #fff; font-weight: 600; }
319
+ .hours { margin: 10px 0 0; font-size: 12px; color: #6b6b7b; }
320
+ .acts { display: grid; gap: 8px; margin-top: 16px; }
321
+ .act { all: unset; box-sizing: border-box; display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--ots-line, #e7e7ef);
322
+ border-radius: 12px; cursor: pointer; transition: border-color .15s, background .15s; color: inherit; text-decoration: none; }
323
+ .act:hover { border-color: var(--p); background: color-mix(in srgb, var(--p) 6%, #fff); }
324
+ .act:focus-visible { outline: 2px solid var(--p); outline-offset: 2px; }
325
+ .act[disabled] { opacity: .6; cursor: progress; }
326
+ .act svg { width: 20px; height: 20px; fill: var(--p); flex: none; }
327
+ .act span { display: flex; flex-direction: column; min-width: 0; }
328
+ .act b { font-size: 14px; font-weight: 600; }
329
+ .act small { font-size: 12px; color: #6b6b7b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
330
+ .note { min-height: 0; margin: 10px 0 0; font-size: 13px; color: #33334a; }
331
+ .note:empty { display: none; }
332
+ footer { margin-top: 14px; font-size: 11px; color: #8b8b9b; text-align: right; }
333
+ footer a { color: inherit; }
334
+ .compact { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 12px; }
335
+ .compact::before { display: none; }
336
+ .compact .pic { width: 48px; height: 48px; }
337
+ .compact h2 { font-size: 16px; }
338
+ .compact .acts { margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
339
+ .compact .act small, .compact footer { display: none; }
340
+ .compact .act { padding: 6px 10px; }
341
+ @media (prefers-color-scheme: dark) {
342
+ :host { --ots-fg: #ececf4; --ots-bg: #17171f; --ots-line: #2c2c3a; }
343
+ .bio, .chips li, .note { color: #d0d0dc; }
344
+ .chips li { background: color-mix(in srgb, var(--p) 22%, #17171f); }
345
+ .act:hover { background: color-mix(in srgb, var(--p) 14%, #17171f); }
346
+ .tag, .act small, .hours { color: #9d9dad; }
347
+ }
348
+ `;
349
+ }
350
+
351
+ if (typeof customElements !== 'undefined' && !customElements.get('ots-identity')) customElements.define('ots-identity', IdentityCard);
@@ -0,0 +1,148 @@
1
+ /**
2
+ * createFace — la cara y la voz de una identidad, sin chat ni cerebro.
3
+ *
4
+ * Para quien ya tiene su agente (propio backend, otro framework…) y solo quiere darle un
5
+ * rostro que habla con lip-sync y la voz de su identidad:
6
+ *
7
+ * const face = await SevenOts.createFace(document.querySelector('#cara'), {
8
+ * identity: '/api/agent/identity', // o el objeto de identidad
9
+ * endpoint: '/api/agent', // proxy de 7ots para el TTS (opcional: sin él, voz del navegador)
10
+ * });
11
+ * await face.say('Hola, soy Nube. ¿En qué te ayudo?');
12
+ * face.mood('happy'); face.gesture('thumbup'); face.morph('wings'); face.effect('hearts');
13
+ * const texto = await face.listen(); // micrófono → texto (Web Speech API)
14
+ *
15
+ * El avatar 3D necesita el importmap de three en la página (ver AvatarStage); sin WebGL
16
+ * se muestra el retrato de la identidad o la cara 2D.
17
+ */
18
+
19
+ import { AvatarStage } from '../avatar/AvatarStage.js';
20
+ import { VoiceEngine } from '../voice/VoiceEngine.js';
21
+ import { EventBus } from '../core/EventBus.js';
22
+ import { createStore } from '../core/storage.js';
23
+ import { defineIdentity, identityToWidgetConfig } from './schema.js';
24
+ import { translator, detectLocale } from '../i18n/index.js';
25
+ import '../i18n/messages/identity.js';
26
+
27
+ /**
28
+ * @param {HTMLElement} container
29
+ * @param {object} [o]
30
+ * @param {object|string} [o.identity] identidad o URL de su tarjeta (defecto: {endpoint}/identity si hay endpoint)
31
+ * @param {string} [o.endpoint] proxy de 7ots (TTS con la voz de la identidad)
32
+ * @param {string} [o.siteKey]
33
+ * @param {object|false} [o.avatar] sobrescribe el avatar de la identidad ({ mode: '2d' } fuerza retrato/cara)
34
+ * @param {object|false} [o.voice] sobrescribe la voz ({ tts: 'browser' }, false = muda)
35
+ * @param {number} [o.size] alto en px (defecto: el del contenedor, mínimo 160)
36
+ * @param {boolean} [o.subtitles=true] muestra lo que dice
37
+ * @param {string} [o.locale] idioma de los textos de interfaz (etiquetas, errores); defecto: detectLocale()
38
+ * (la voz y el lip-sync siguen el `language` de la identidad)
39
+ */
40
+ export async function createFace(container, o = {}) {
41
+ if (!container) throw new Error('createFace necesita un elemento contenedor');
42
+ const endpoint = o.endpoint ? o.endpoint.replace(/\/$/, '') : '';
43
+ const t = translator(detectLocale(o.locale));
44
+ const src = o.identity ?? (endpoint ? `${endpoint}/identity` : {});
45
+ let identity = defineIdentity(typeof src === 'string' ? await fetchJson(src, t) : src);
46
+ let ic = identityToWidgetConfig(identity);
47
+
48
+ const host = document.createElement('div');
49
+ host.setAttribute('data-ots-root', 'face');
50
+ host.style.cssText = `display:block;position:relative;width:100%;height:${o.size ? `${o.size}px` : '100%'};min-height:160px;`;
51
+ container.appendChild(host);
52
+ const root = host.attachShadow({ mode: 'open' });
53
+ root.innerHTML = `<style>${faceCss()}</style><div class="wrap"><div class="stage"></div><div class="subs" aria-live="polite" aria-label="${escAttr(t('identity.face.subtitles'))}"></div></div>`;
54
+ const wrap = root.querySelector('.wrap');
55
+ const subs = root.querySelector('.subs');
56
+ const applyColor = () => wrap.style.setProperty('--p', identity.look.color);
57
+ applyColor();
58
+
59
+ const bus = new EventBus();
60
+ const avatarCfg = o.avatar === false ? { mode: '2d' } : { ...ic.avatar, ...(o.avatar || {}) };
61
+ const avatar = new AvatarStage({ container: root.querySelector('.stage'), config: avatarCfg, language: identity.language });
62
+ const voice =
63
+ o.voice === false
64
+ ? null
65
+ : new VoiceEngine({
66
+ bus,
67
+ avatar,
68
+ store: createStore(`face:${identity.id}`),
69
+ config: { endpoint, siteKey: o.siteKey, tts: endpoint ? 'proxy' : 'browser', stt: true, ...ic.voice, ...(o.voice || {}) },
70
+ });
71
+ if (voice?.muted) voice.setMuted(false); // la cara existe para hablar: no hereda silencios viejos
72
+ const mode = await avatar.load();
73
+ wrap.dataset.mode = mode;
74
+
75
+ let subsTimer = 0;
76
+ const face = {
77
+ get identity() {
78
+ return identity;
79
+ },
80
+ mode,
81
+ /** Dice un texto con la voz de la identidad; resuelve al terminar. */
82
+ async say(text) {
83
+ await avatar.unlockAudio();
84
+ if (o.subtitles !== false) {
85
+ clearTimeout(subsTimer);
86
+ subs.textContent = String(text || '');
87
+ }
88
+ await (voice ? voice.say(text) : Promise.resolve());
89
+ if (o.subtitles !== false) subsTimer = setTimeout(() => (subs.textContent = ''), 1200);
90
+ },
91
+ /** Escucha por el micrófono y devuelve el texto reconocido ('' si nada). */
92
+ listen: (opt) => voice?.listen(opt) ?? Promise.resolve(''),
93
+ interrupt: () => voice?.interrupt(),
94
+ mute: (on = true) => voice?.setMuted(on),
95
+ // AvatarStage valida los nombres (moods 2D/3D, gestos del personaje + alias de TalkingHead).
96
+ mood: (m) => avatar.setMood(m),
97
+ gesture: (g, seconds) => avatar.gesture(g, seconds),
98
+ morph: (name, opts) => avatar.morph(name, opts),
99
+ effect: (name, opts) => avatar.effect(name, opts),
100
+ lookAt: (x, y, ms) => avatar.lookAt(x, y, ms),
101
+ /** Cambia de identidad en caliente (voz, color, retrato; .glb distinto → recarga). */
102
+ async setIdentity(next) {
103
+ identity = defineIdentity(typeof next === 'string' ? await fetchJson(next, t) : next);
104
+ ic = identityToWidgetConfig(identity);
105
+ applyColor();
106
+ voice?.configure({ ...ic.voice, ...(o.voice || {}) });
107
+ wrap.dataset.mode = await avatar.applyLook({ ...ic.avatar, ...(o.avatar || {}) });
108
+ return identity;
109
+ },
110
+ on: (event, fn) => bus.on(event, fn), // voice:start, voice:end, voice:listen
111
+ destroy() {
112
+ voice?.interrupt();
113
+ avatar.dispose();
114
+ host.remove();
115
+ },
116
+ };
117
+ return face;
118
+ }
119
+
120
+ async function fetchJson(url, t) {
121
+ const r = await fetch(url, { credentials: 'omit' });
122
+ if (!r.ok) throw new Error(t('identity.face.loadError', { status: r.status }));
123
+ return r.json();
124
+ }
125
+
126
+ function escAttr(s) {
127
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
128
+ }
129
+
130
+ function faceCss() {
131
+ return `
132
+ :host { all: initial; display: block; }
133
+ .wrap { --p: #6d5dfc; position: absolute; inset: 0; overflow: hidden; border-radius: inherit;
134
+ background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--p) 22%, transparent), transparent 70%); font-family: system-ui, sans-serif; }
135
+ .stage { position: absolute; inset: 0; }
136
+ .ots-face, .ots-portrait { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: min(70%, 70vmin, 220px); aspect-ratio: 1; height: auto; }
137
+ .ots-face-bg { fill: var(--face-bg, var(--p)); }
138
+ .ots-eyes, .ots-mouth { fill: var(--face-fg, #fff); }
139
+ .ots-mouth { transition: ry .06s; }
140
+ .ots-portrait { border-radius: 50%; overflow: hidden; background: var(--p);
141
+ box-shadow: 0 0 0 calc(4px + var(--ots-talk, 0) * 16px) color-mix(in srgb, var(--p) 40%, transparent); transition: box-shadow .08s linear; }
142
+ .ots-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }
143
+ .subs { position: absolute; left: 8px; right: 8px; bottom: 8px; text-align: center; font-size: 14px; line-height: 1.35; color: #fff;
144
+ text-shadow: 0 1px 3px rgba(0,0,0,.85); pointer-events: none; }
145
+ .subs:empty { display: none; }
146
+ @media (prefers-reduced-motion: reduce) { .ots-portrait { transition: none; } }
147
+ `;
148
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Identidades aleatorias reproducibles: la misma semilla da siempre el mismo ot (cara, nombre,
3
+ * carácter, voz y avatar de meet). Así la web, el CLI, la mascota y apuchat meet comparten una
4
+ * identidad sin guardarla en ningún servidor: basta con la semilla («k7x2qa»).
5
+ * Código puro (sin DOM ni Node).
6
+ */
7
+
8
+ import { randomCharacter } from '../character/Character.js';
9
+ import { defineIdentity, MEET_AVATARS, MEET_SCENES } from './schema.js';
10
+
11
+ const START = ['b', 'd', 'f', 'g', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'z', 'ch', 'br', 'tr', 'gl', 'pl', 'qu'];
12
+ const VOWEL = ['a', 'e', 'i', 'o', 'u', 'a', 'o', 'i'];
13
+ const END = ['', '', '', 'n', 'l', 's', 'r', 'x', 'k', 'm'];
14
+
15
+ const TEXT = {
16
+ es: {
17
+ roles: ['asistente de la casa', 'guía curioso', 'compañero de código', 'anfitrión digital', 'ayudante incansable', 'explorador de páginas', 'mascota con opiniones', 'vigía de la terminal'],
18
+ tones: ['cercano y juguetón', 'tranquilo y claro', 'entusiasta y directo', 'irónico pero amable', 'tierno y curioso', 'serio con chispa'],
19
+ traits: ['curioso', 'paciente', 'bromista', 'ordenado', 'dramático', 'optimista', 'despistado', 'leal', 'mandón', 'dormilón', 'glotón', 'valiente'],
20
+ tagline: (n) => `Hola, soy ${n}.`,
21
+ },
22
+ en: {
23
+ roles: ['house assistant', 'curious guide', 'coding buddy', 'digital host', 'tireless helper', 'page explorer', 'opinionated pet', 'terminal lookout'],
24
+ tones: ['warm and playful', 'calm and clear', 'eager and direct', 'wry but kind', 'sweet and curious', 'serious with a spark'],
25
+ traits: ['curious', 'patient', 'cheeky', 'tidy', 'dramatic', 'optimistic', 'scatterbrained', 'loyal', 'bossy', 'sleepy', 'hungry', 'brave'],
26
+ tagline: (n) => `Hi, I'm ${n}.`,
27
+ },
28
+ pt: {
29
+ roles: ['assistente da casa', 'guia curioso', 'parceiro de código', 'anfitrião digital', 'ajudante incansável', 'explorador de páginas', 'mascote com opiniões', 'vigia do terminal'],
30
+ tones: ['próximo e brincalhão', 'calmo e claro', 'entusiasmado e direto', 'irônico mas gentil', 'fofo e curioso', 'sério com faísca'],
31
+ traits: ['curioso', 'paciente', 'brincalhão', 'organizado', 'dramático', 'otimista', 'distraído', 'leal', 'mandão', 'dorminhoco', 'guloso', 'corajoso'],
32
+ tagline: (n) => `Oi, eu sou ${n}.`,
33
+ },
34
+ };
35
+
36
+ /** Semilla corta y legible (6 caracteres base36). */
37
+ export function newSeed() {
38
+ let s = '';
39
+ for (let i = 0; i < 6; i++) s += Math.floor(Math.random() * 36).toString(36);
40
+ return s;
41
+ }
42
+
43
+ /** Normaliza una semilla escrita por una persona: minúsculas, [a-z0-9-], máx. 32. */
44
+ export function cleanSeed(seed) {
45
+ return String(seed ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);
46
+ }
47
+
48
+ /** Texto → entero de 32 bits (FNV-1a). */
49
+ export function seedNumber(seed) {
50
+ let h = 2166136261;
51
+ for (const c of cleanSeed(seed) || '7ots') h = Math.imul(h ^ c.charCodeAt(0), 16777619) >>> 0;
52
+ return h || 1;
53
+ }
54
+
55
+ function rng(n) {
56
+ let s = n >>> 0 || 1;
57
+ const rnd = () => ((s = (Math.imul(s ^ (s >>> 15), 2246822507) + 0x9e3779b9) >>> 0) / 2 ** 32);
58
+ return { rnd, pick: (l) => l[Math.floor(rnd() * l.length)] };
59
+ }
60
+
61
+ /** Nombre pronunciable a partir de la semilla («Tobix», «Mirel»…). */
62
+ export function randomName(seed) {
63
+ const { rnd, pick } = rng(seedNumber(seed) ^ 0x51ed);
64
+ const syl = rnd() < 0.55 ? 2 : rnd() < 0.8 ? 1 : 3;
65
+ let n = '';
66
+ for (let i = 0; i < syl; i++) n += pick(START) + pick(VOWEL);
67
+ n += pick(END);
68
+ return n.charAt(0).toUpperCase() + n.slice(1);
69
+ }
70
+
71
+ /**
72
+ * Identidad completa y válida (pasa por defineIdentity).
73
+ * @param {string} seed
74
+ * @param {{ lang?: 'en'|'es'|'pt', name?: string }} [opts] el nombre puede fijarse; el resto sale de la semilla
75
+ */
76
+ export function randomIdentity(seed, { lang = 'en', name } = {}) {
77
+ const sd = cleanSeed(seed) || newSeed();
78
+ const L = TEXT[lang] ? lang : 'en';
79
+ const tx = TEXT[L];
80
+ const { rnd, pick } = rng(seedNumber(sd));
81
+ const character = randomCharacter(seedNumber(sd));
82
+ const nm = String(name || '').trim().slice(0, 40) || randomName(sd);
83
+ const traits = [];
84
+ while (traits.length < 3) {
85
+ const t = pick(tx.traits);
86
+ if (!traits.includes(t)) traits.push(t);
87
+ }
88
+ const female = rnd() < 0.5;
89
+ return defineIdentity({
90
+ id: `${nm}-${sd}`,
91
+ name: nm,
92
+ role: pick(tx.roles),
93
+ tagline: tx.tagline(nm),
94
+ language: L,
95
+ personality: { tone: pick(tx.tones), traits },
96
+ look: {
97
+ kind: 'character',
98
+ character,
99
+ color: character.body.color,
100
+ accent: character.eyes.iris,
101
+ meetAvatar: pick(MEET_AVATARS),
102
+ meetScene: pick(MEET_SCENES),
103
+ },
104
+ voice: {
105
+ provider: 'auto',
106
+ lang: { es: 'es-ES', en: 'en-US', pt: 'pt-BR' }[L],
107
+ rate: Math.round((0.95 + rnd() * 0.2) * 100) / 100,
108
+ pitch: Math.round((female ? 1.05 + rnd() * 0.3 : 0.8 + rnd() * 0.25) * 100) / 100,
109
+ },
110
+ seed: sd,
111
+ });
112
+ }