@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,262 @@
1
+ /**
2
+ * Esquema de la identidad de un agente 7ots: quién es, cómo se ve, cómo suena y cómo se le contacta.
3
+ * Código puro (sin DOM ni Node): lo usan el widget, la tarjeta <ots-identity>, la cara
4
+ * (createFace), el proxy y el SDK de servidor.
5
+ *
6
+ * {
7
+ * id, name, role, tagline, seed, bio, language, languages[],
8
+ * personality: { tone, traits[], instructions }, // instructions: privadas, solo servidor
9
+ * look: { kind: character|realistic|image, character: {…personaje 2D, ver src/character},
10
+ * color, accent, emoji, image, avatar: { url, body, mood, cameraView },
11
+ * face: { skin, eyes }, meetAvatar, meetScene },
12
+ * voice: { provider: auto|apuchat|elevenlabs|grok|fish|openai|browser, voiceId, model, style, lang, rate, pitch, browserVoice },
13
+ * contact: { site, email, apuchat, meet, call, hours },
14
+ * }
15
+ *
16
+ * La identidad NUNCA contiene secretos (las claves de apumail/apuchat/TTS viven en el servidor).
17
+ */
18
+
19
+ import { normalizeCharacter } from '../character/Character.js';
20
+ import { translator } from '../i18n/index.js';
21
+ import '../i18n/messages/identity.js';
22
+
23
+ export const LOOK_KINDS = ['character', 'realistic', 'image'];
24
+ export const MEET_AVATARS = ['shibu', 'shino', 'fumiriya', 'victoria', 'vita', 'vivi', 'kuro', 'maya', 'ingrid', 'kenji', 'viktor', 'marcus', 'leo', 'mei', 'claire', 'doctor', 'amira'];
25
+ export const MEET_SCENES = ['studio', 'beach', 'castle', 'space', 'sunset', 'forest', 'night', 'office', 'neon'];
26
+ export const VOICE_PROVIDERS = ['auto', 'apuchat', 'elevenlabs', 'grok', 'fish', 'openai', 'browser'];
27
+ /** Voces integradas de xAI (Grok) TTS; también admite ids de voces clonadas propias. */
28
+ export const GROK_VOICES = ['eve', 'ara', 'leo', 'rex', 'sal'];
29
+ export const OPENAI_VOICES = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse'];
30
+ export const MOODS = ['neutral', 'happy', 'angry', 'sad', 'fear', 'disgust', 'love', 'sleep'];
31
+
32
+ export const IDENTITY_DEFAULTS = Object.freeze({
33
+ id: 'asistente',
34
+ name: 'Asistente',
35
+ role: 'asistente virtual',
36
+ tagline: '',
37
+ seed: '',
38
+ bio: '',
39
+ language: 'es',
40
+ languages: [],
41
+ personality: { tone: 'cercano y claro', traits: [], instructions: '' },
42
+ look: {
43
+ kind: '', // character (2D editable) | realistic (3D .glb) | image (retrato). Vacío = se deduce
44
+ character: null, // personaje 2D (null = el de serie)
45
+ color: '#6d5dfc',
46
+ accent: '#22d3ee',
47
+ emoji: '',
48
+ image: '', // retrato (URL) para tarjetas, correo y vCard
49
+ avatar: { url: '', body: 'F', mood: 'neutral', cameraView: 'upper' }, // .glb 3D (vacío = el de serie)
50
+ face: { skin: '', eyes: '' }, // colores de la cara 2D
51
+ meetAvatar: 'vivi',
52
+ meetScene: '',
53
+ },
54
+ voice: { provider: 'auto', voiceId: '', model: '', style: '', lang: 'es-ES', rate: 1.05, pitch: 1, browserVoice: '' },
55
+ contact: { site: '', email: '', apuchat: '', meet: true, call: true, hours: '' },
56
+ });
57
+
58
+ /**
59
+ * Normaliza y valida una identidad (acepta objetos parciales). Nunca lanza: descarta lo inválido.
60
+ * @param {object} input
61
+ * @param {object} [base] identidad sobre la que se aplica (por defecto, los valores de serie)
62
+ */
63
+ export function defineIdentity(input = {}, base = IDENTITY_DEFAULTS) {
64
+ const i = input && typeof input === 'object' ? input : {};
65
+ const b = base;
66
+ const look = { ...b.look, ...pick(i.look) };
67
+ const voice = { ...b.voice, ...pick(i.voice) };
68
+ const contact = { ...b.contact, ...pick(i.contact) };
69
+ const personality = { ...b.personality, ...pick(i.personality) };
70
+ const out = {
71
+ // El id es estable: solo cambia si se da uno explícito o si aún es el de serie.
72
+ id: slug(i.id) || (b.id !== IDENTITY_DEFAULTS.id ? b.id : slug(i.name)) || IDENTITY_DEFAULTS.id,
73
+ name: str(i.name ?? b.name, 60) || IDENTITY_DEFAULTS.name,
74
+ role: str(i.role ?? b.role, 120),
75
+ tagline: str(i.tagline ?? b.tagline, 160),
76
+ seed: String(i.seed ?? b.seed ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32), // semilla de randomIdentity
77
+ bio: str(i.bio ?? b.bio, 1200),
78
+ language: lang(i.language ?? b.language) || 'es',
79
+ languages: list(i.languages ?? b.languages, 12).map(lang).filter(Boolean),
80
+ personality: {
81
+ tone: str(personality.tone, 120),
82
+ traits: list(personality.traits, 12).map((t) => str(t, 40)).filter(Boolean),
83
+ instructions: str(personality.instructions, 4000),
84
+ },
85
+ look: {
86
+ kind: LOOK_KINDS.includes(look.kind) ? look.kind : look.avatar?.url ? 'realistic' : look.image ? 'image' : 'character',
87
+ character: normalizeCharacter(look.character || {}),
88
+ color: color(look.color) || IDENTITY_DEFAULTS.look.color,
89
+ accent: color(look.accent) || IDENTITY_DEFAULTS.look.accent,
90
+ emoji: str(look.emoji, 8),
91
+ image: url(look.image),
92
+ avatar: {
93
+ url: url(look.avatar?.url),
94
+ body: look.avatar?.body === 'M' ? 'M' : 'F',
95
+ mood: MOODS.includes(look.avatar?.mood) ? look.avatar.mood : 'neutral',
96
+ cameraView: ['full', 'mid', 'upper', 'head'].includes(look.avatar?.cameraView) ? look.avatar.cameraView : 'upper',
97
+ },
98
+ face: { skin: color(look.face?.skin), eyes: color(look.face?.eyes) },
99
+ meetAvatar: MEET_AVATARS.includes(String(look.meetAvatar).toLowerCase()) ? String(look.meetAvatar).toLowerCase() : 'vivi',
100
+ meetScene: MEET_SCENES.includes(String(look.meetScene).toLowerCase()) ? String(look.meetScene).toLowerCase() : '',
101
+ },
102
+ voice: {
103
+ provider: VOICE_PROVIDERS.includes(voice.provider) ? voice.provider : 'auto',
104
+ voiceId: str(voice.voiceId, 80),
105
+ model: str(voice.model, 80),
106
+ style: str(voice.style, 400),
107
+ lang: /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(String(voice.lang)) ? voice.lang : 'es-ES',
108
+ rate: num(voice.rate, 0.5, 2, 1.05),
109
+ pitch: num(voice.pitch, 0, 2, 1),
110
+ browserVoice: str(voice.browserVoice, 120),
111
+ },
112
+ contact: {
113
+ site: url(contact.site),
114
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(contact.email || '')) ? String(contact.email).toLowerCase() : '',
115
+ apuchat: String(contact.apuchat || '').trim().replace(/^https?:\/\/[^/]+\//i, '').replace(/^@/, '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32),
116
+ meet: contact.meet !== false,
117
+ call: contact.call !== false,
118
+ hours: str(contact.hours, 120),
119
+ },
120
+ };
121
+ return out;
122
+ }
123
+
124
+ // ───────────────────────────── salidas ─────────────────────────────
125
+
126
+ /**
127
+ * Tarjeta pública: lo que cualquiera puede saber del agente y cómo contactarlo.
128
+ * @param {object} id identidad normalizada
129
+ * @param {{ email?: boolean, apuchat?: string|null, meet?: boolean }} [live] canales que atiende el propio agente
130
+ */
131
+ export function publicIdentity(id, live = {}) {
132
+ const apuchat = live.apuchat || id.contact.apuchat || '';
133
+ return {
134
+ '@type': '7ots/agent',
135
+ version: 1,
136
+ id: id.id,
137
+ name: id.name,
138
+ role: id.role,
139
+ tagline: id.tagline,
140
+ seed: id.seed || undefined,
141
+ bio: id.bio,
142
+ language: id.language,
143
+ languages: id.languages,
144
+ personality: { tone: id.personality.tone, traits: id.personality.traits }, // las instrucciones no son públicas
145
+ look: id.look,
146
+ voice: { provider: id.voice.provider, lang: id.voice.lang, rate: id.voice.rate, pitch: id.voice.pitch, browserVoice: id.voice.browserVoice },
147
+ contact: {
148
+ site: id.contact.site,
149
+ hours: id.contact.hours,
150
+ email: id.contact.email,
151
+ apuchat,
152
+ // meet: el agente entra en llamadas de meet.apuchat.com; call: se puede abrir una desde la web
153
+ meet: !!(id.contact.meet && apuchat && live.meet !== false),
154
+ call: !!(id.contact.call && live.meet),
155
+ // qué canales contesta el agente él solo (el resto los lee una persona o tu sistema)
156
+ answers: { email: !!live.email, apuchat: !!live.apuchat, meet: !!live.meet },
157
+ },
158
+ };
159
+ }
160
+
161
+ /** vCard 4.0 de la tarjeta pública. */
162
+ export function toVCard(card, { baseUrl = '' } = {}) {
163
+ const esc = (s) => String(s || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/([,;])/g, '\\$1');
164
+ const lines = ['BEGIN:VCARD', 'VERSION:4.0', 'KIND:individual', `FN:${esc(card.name)}`, `UID:urn:7ots:${card.id}`];
165
+ if (card.role) lines.push(`TITLE:${esc(card.role)}`);
166
+ if (card.bio || card.tagline) lines.push(`NOTE:${esc(card.tagline || card.bio)}`);
167
+ if (card.contact.email) lines.push(`EMAIL;TYPE=work:${card.contact.email}`);
168
+ if (card.contact.apuchat) lines.push(`IMPP;PREF=1:apuchat:${card.contact.apuchat}`);
169
+ if (card.contact.site) lines.push(`URL:${card.contact.site}`);
170
+ if (card.look.image) lines.push(`PHOTO:${card.look.image}`);
171
+ if (card.language) lines.push(`LANG:${card.language}`);
172
+ if (baseUrl) lines.push(`SOURCE:${baseUrl}`);
173
+ lines.push('END:VCARD');
174
+ return lines.join('\r\n') + '\r\n';
175
+ }
176
+
177
+ /**
178
+ * Líneas del system prompt que dan personalidad al agente (web y canales).
179
+ * Se redactan en el idioma de la identidad (`id.language`; uno sin catálogo usa inglés).
180
+ * @param {object} id identidad normalizada
181
+ * @param {string} [locale] fuerza otro idioma
182
+ */
183
+ export function identityPrompt(id, locale = id.language) {
184
+ const p = id.personality;
185
+ const t = translator(locale || 'es');
186
+ return [
187
+ id.role ? t('identity.prompt.youAreRole', { name: id.name, role: id.role }) : t('identity.prompt.youAre', { name: id.name }),
188
+ id.bio ? t('identity.prompt.about', { bio: id.bio }) : '',
189
+ p.tone ? t('identity.prompt.tone', { tone: p.tone }) : '',
190
+ p.traits.length ? t('identity.prompt.traits', { traits: p.traits.join(', ') }) : '',
191
+ p.instructions,
192
+ ]
193
+ .filter(Boolean)
194
+ .join('\n');
195
+ }
196
+
197
+ /**
198
+ * Traduce la identidad a la configuración del widget (agent, avatar, voice, theme).
199
+ * Lo que el sitio pase explícitamente en init() manda sobre esto.
200
+ */
201
+ export function identityToWidgetConfig(id) {
202
+ const a = id.look.avatar;
203
+ const kind = id.look.kind || 'character';
204
+ // El personaje 2D va siempre: es el avatar en modo 'character' y el respaldo si falla el 3D.
205
+ const avatar =
206
+ kind === 'realistic'
207
+ ? { mode: '3d', ...(a.url ? { url: a.url } : {}), body: a.body, mood: a.mood, cameraView: a.cameraView, character: id.look.character }
208
+ : kind === 'image' && id.look.image
209
+ ? { mode: '2d', image: id.look.image, face: id.look.face, mood: a.mood }
210
+ : { mode: '2d', character: id.look.character, mood: a.mood };
211
+ return {
212
+ agent: { name: id.name, role: id.role, language: id.language },
213
+ avatar,
214
+ voice: {
215
+ lang: id.voice.lang,
216
+ rate: id.voice.rate,
217
+ pitch: id.voice.pitch,
218
+ ...(id.voice.browserVoice ? { voice: id.voice.browserVoice } : {}),
219
+ ...(id.voice.provider === 'browser' ? { tts: 'browser' } : {}),
220
+ },
221
+ theme: { primary: id.look.color, accent: id.look.accent },
222
+ };
223
+ }
224
+
225
+ // ───────────────────────────── helpers ─────────────────────────────
226
+
227
+ function pick(o) {
228
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
229
+ }
230
+ function str(v, max) {
231
+ return v == null ? '' : String(v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, max);
232
+ }
233
+ function list(v, max) {
234
+ if (typeof v === 'string') v = v.split(',');
235
+ return Array.isArray(v) ? v.slice(0, max) : [];
236
+ }
237
+ function slug(v) {
238
+ return String(v || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
239
+ }
240
+ function lang(v) {
241
+ const m = String(v || '').trim().toLowerCase().match(/^[a-z]{2,3}/);
242
+ return m ? m[0] : '';
243
+ }
244
+ function color(v) {
245
+ const s = String(v || '').trim();
246
+ return /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s) ? s : '';
247
+ }
248
+ function url(v) {
249
+ const s = String(v || '').trim();
250
+ if (!s) return '';
251
+ if (s.startsWith('/') && !s.startsWith('//')) return s.slice(0, 500); // ruta del propio sitio
252
+ try {
253
+ const u = new URL(s);
254
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href.slice(0, 500) : '';
255
+ } catch {
256
+ return '';
257
+ }
258
+ }
259
+ function num(v, min, max, def) {
260
+ const n = Number(v);
261
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
262
+ }
package/src/index.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 7ots — asistente web proactivo, embebible en cualquier sitio.
3
+ *
4
+ * import { init } from '@7ots/cli'; // ESM
5
+ * const agent = SevenOts.init({...}); // <script> (build IIFE: dist/7ots.iife.js)
6
+ *
7
+ * Configuración (todo opcional salvo `endpoint` si usas el proxy incluido):
8
+ *
9
+ * SevenOts.init({
10
+ * endpoint: '/api/agent', // tu proxy (server/): el LLM y las claves viven allí
11
+ * identity: true | '/url/identidad' | { name, role, look, voice, contact }, // quién es (ver src/identity/schema.js)
12
+ * siteKey: 'pk_demo', // identificador público del sitio (no es secreto)
13
+ * agent: { name: 'Ana', role: 'asesora de ventas', siteName: 'Acme',
14
+ * language: 'español', instructions: 'Ofrece siempre el plan anual…' },
15
+ * avatar: { url: '/avatars/ana.glb', body: 'F', mood: 'happy', cameraView: 'upper' } | false,
16
+ * voice: { tts: 'proxy' | 'browser' | false, stt: true, lang: 'es-ES' } | false,
17
+ * auth: { getToken: () => localStorage.getItem('jwt'), getUser: () => ({ name, email, plan }) },
18
+ * mcp: [{ url: '/mcp', name: 'acme', requiresAuth: true }],
19
+ * actions: [{ name, description, parameters, handler, confirm, requiresAuth }],
20
+ * templates: { producto: (data, { escape }) => `<h3>${escape(data.nombre)}</h3>` },
21
+ * navigation: { allowedOrigins: ['https://docs.acme.com'], router: (url) => nextRouter.push(url) },
22
+ * proactive: { level: 'quiet' | 'normal' | 'bold', // preset de iniciativa; el visitante lo cambia en el menú
23
+ * dwellMs, idleMs, hesitationMs, exitIntent, selection, cooldownMs, maxPerSession,
24
+ * rules: [{ id, when: (snap) => bool, message }] } | false,
25
+ * context: { privateSelectors: ['.saldo'], ignoreSelectors: ['#cookie-banner'], extra: () => ({ carrito }) },
26
+ * contact: { apuchat: true, apumail: true | { categories } } | false, // hub y claves: en el proxy
27
+ * builtins: { exclude: ['click'], confirmClicks: 'submit' | 'all' | 'none' },
28
+ * pointer: { speed: 1, visible: true } | false, // ratón y teclado visibles del agente (Esc lo detiene)
29
+ * pageTools: true | false, // [data-ots-tool] del HTML → herramientas
30
+ * mode: 'panel' | 'companion', // companion: el avatar se pasea por la página y señala
31
+ * companion: { size: 150, idleHomeMs: 30000, follow: true, wanderMs, watchCursor },
32
+ * theme: { primary: '#e11d48', radius: 18, position: 'right', font: 'Inter, sans-serif' },
33
+ * open: false,
34
+ * });
35
+ *
36
+ * API devuelta: registerAction, registerMcp, registerTemplate, setAuthToken, ask, say,
37
+ * notify, setProactivity(level), setIdentity(identity), identity, open, close, reset,
38
+ * on(event, fn), destroy, y ui/auth/context/actions/bus.
39
+ *
40
+ * Módulo de identidad (sin el chat): quién es el agente, cómo se ve, cómo suena y cómo se le contacta.
41
+ *
42
+ * const face = await SevenOts.createFace(el, { identity: '/api/agent/identity', endpoint: '/api/agent' });
43
+ * face.say('Hola'); // cara + voz de la identidad, lip-sync
44
+ * <ots-identity src="/api/agent/identity" face></ots-identity> // tarjeta de contacto
45
+ *
46
+ * Personaje 2D y editor de avatar (estilo Pou: forma deformable, ojos, bocas, accesorios, estilos):
47
+ *
48
+ * const ch = SevenOts.createCharacter(el, { preset: 'pirate' }); ch.mouth(0.6); ch.mood('happy');
49
+ * SevenOts.createAvatarEditor(el, { value: identity.look, onChange: (look) => … });
50
+ * <ots-avatar-editor></ots-avatar-editor>
51
+ */
52
+
53
+ import { AgentWidget } from './core/AgentWidget.js';
54
+
55
+ export { AgentWidget };
56
+ export { EventBus } from './core/EventBus.js';
57
+ export { AgentBrain } from './core/AgentBrain.js';
58
+ export { ContextManager } from './context/ContextManager.js';
59
+ export { ActionRegistry, validate } from './actions/ActionRegistry.js';
60
+ export { createBuiltinActions, setFieldValue } from './actions/builtins.js';
61
+ export { VirtualPointer } from './ui/VirtualPointer.js';
62
+ export { PageTools } from './actions/PageTools.js';
63
+ export { Companion, createCompanionActions } from './ui/Companion.js';
64
+ export { AuthManager, decodeJwt } from './auth/AuthManager.js';
65
+ export { McpClient } from './mcp/McpClient.js';
66
+ export { UIManager } from './ui/UIManager.js';
67
+ export { AvatarStage, MOODS, GESTURES, MOODS_2D, ALL_GESTURES, GESTURE_3D } from './avatar/AvatarStage.js';
68
+ export { VoiceEngine } from './voice/VoiceEngine.js';
69
+ export { ProxyLLM } from './llm/ProxyLLM.js';
70
+ export { ApuchatBridge, createApuchatActions } from './integrations/apuchat.js';
71
+ export { createApumailActions } from './integrations/apumail.js';
72
+ export { defineIdentity, identityToWidgetConfig, publicIdentity, toVCard, IDENTITY_DEFAULTS, MEET_AVATARS, MEET_SCENES, OPENAI_VOICES, GROK_VOICES } from './identity/schema.js';
73
+ export { randomIdentity, randomName, newSeed, cleanSeed } from './identity/random.js';
74
+ import { createFace } from './identity/Face.js';
75
+ import { IdentityCard } from './identity/ContactCard.js';
76
+ import { defineIdentity } from './identity/schema.js';
77
+ export { createFace, IdentityCard };
78
+ export {
79
+ createCharacter, renderCharacter, normalizeCharacter, randomCharacter, applyPreset, DEFAULT_CHARACTER,
80
+ CHARACTER_MOODS, CHARACTER_GESTURES, CHARACTER_MORPHS, CHARACTER_EFFECTS, CHARACTER_GESTURE_ALIAS, VISEMES, textToVisemes, SHAPES, EYES, BROWS, MOUTHS, CHEEKS, PATTERNS, FINISHES, ACCESSORIES, ACCESSORY_GROUPS, PRESETS, partLabel,
81
+ } from './character/Character.js';
82
+ import { createAvatarEditor, AvatarEditor } from './character/AvatarEditor.js';
83
+ import { renderCharacter as _renderCharacter, createCharacter as _createCharacter } from './character/Character.js';
84
+ export { createAvatarEditor, AvatarEditor };
85
+
86
+ export { t, translator, addMessages, addCatalog, resolveLocale, detectLocale, getLocale, setLocale, onLocaleChange, availableLocales, formatNumber, formatList, LOCALES, LOCALE_NAMES, DEFAULT_LOCALE } from './i18n/index.js';
87
+ import { t as _t, translator as _translator, addMessages as _addMessages, setLocale as _setLocale, getLocale as _getLocale, LOCALES as _LOCALES } from './i18n/index.js';
88
+ export const version = '0.1.0';
89
+
90
+ let singleton = null;
91
+
92
+ /**
93
+ * Crea (o devuelve) el agente de la página.
94
+ * @returns {ReturnType<AgentWidget['configure']>['api']}
95
+ */
96
+ export function init(config = {}) {
97
+ if (singleton?.widget.isConnected) {
98
+ console.warn('[7ots] ya inicializado; se devuelve la instancia existente');
99
+ return singleton;
100
+ }
101
+ const el = document.createElement('ots-agent');
102
+ // Se inserta antes de configurar para que el Shadow DOM exista y el CSS se aplique.
103
+ (document.body || document.documentElement).appendChild(el);
104
+ el.configure(config);
105
+ singleton = el.api;
106
+ return singleton;
107
+ }
108
+
109
+ /** La instancia actual (o null). */
110
+ export function get() {
111
+ return singleton;
112
+ }
113
+
114
+ // Global para el uso con <script> (y para depurar desde la consola).
115
+ if (typeof window !== 'undefined') {
116
+ window.SevenOts = Object.assign(window.SevenOts || {}, { init, get, version, createFace, defineIdentity, createAvatarEditor, createCharacter: _createCharacter, renderCharacter: _renderCharacter, i18n: { t: _t, translator: _translator, addMessages: _addMessages, setLocale: _setLocale, getLocale: _getLocale, LOCALES: _LOCALES } });
117
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Integración Apuchat — derivar la conversación a una persona real.
3
+ *
4
+ * Flujo (sin claves en el navegador):
5
+ *
6
+ * LLM → escalate_to_human({reason, summary})
7
+ * → confirmación del usuario
8
+ * → POST {endpoint}/contact/apuchat (proxy del sitio)
9
+ * proxy → POST {hub}/api/video-call crea un canal privado
10
+ * proxy → entra al canal con la identidad del agente (relé)
11
+ * proxy → POST {hub}/api/dm avisa al operador con su enlace
12
+ * proxy ← {handoff_id, handoff_token, call_url_public}
13
+ * → ApuchatBridge.join() → el widget pasa a "modo humano":
14
+ * lo que escribe el usuario → POST {endpoint}/contact/apuchat/send
15
+ * respuestas del operador ← POST {endpoint}/contact/apuchat/wait (long-poll)
16
+ * → Botón "Videollamada" abre call_url_public (voz/vídeo de apuchat).
17
+ *
18
+ * Todo pasa por el proxy: el hub no admite CORS desde sitios de terceros y las credenciales
19
+ * del canal no deben salir del servidor. El navegador solo guarda un id + token opacos del relé
20
+ * (en sessionStorage, para sobrevivir a navegaciones de página).
21
+ */
22
+
23
+ import { acceptLanguage } from '../llm/ProxyLLM.js';
24
+ import { translator } from '../i18n/index.js';
25
+ import '../i18n/messages/widget.js';
26
+
27
+ const MAX_MSG = 8192;
28
+
29
+ export class ApuchatBridge {
30
+ /**
31
+ * @param {object} o
32
+ * @param {string} o.endpoint proxy de 7ots (p. ej. '/api/agent')
33
+ * @param {string} [o.siteKey]
34
+ * @param {import('../core/EventBus.js').EventBus} o.bus
35
+ * @param {ReturnType<import('../core/storage.js').createStore>} o.store
36
+ * @param {(key: string, vars?: object) => string} [o.t] traductor (avisos del chat; su `locale` va en Accept-Language)
37
+ */
38
+ constructor({ endpoint, siteKey, bus, store, t = translator() }) {
39
+ this.t = t;
40
+ this.base = `${endpoint.replace(/\/$/, '')}/contact/apuchat`;
41
+ this.siteKey = siteKey;
42
+ this.bus = bus;
43
+ this.store = store;
44
+ this.session = store.get('apuchat', null); // {id, token, since, call_url_public}
45
+ if (this.session && !this.session.token) this.session = null; // formato antiguo
46
+ this._stop = null;
47
+ }
48
+
49
+ get active() {
50
+ return !!this.session;
51
+ }
52
+
53
+ get callUrl() {
54
+ return this.session?.call_url_public || null;
55
+ }
56
+
57
+ /** Reanuda una sesión guardada (tras navegar de página). */
58
+ resume() {
59
+ if (this.session && !this._stop) this._loop();
60
+ }
61
+
62
+ /** @param {{handoff_id, handoff_token, call_url_public}} started respuesta de /contact/apuchat */
63
+ join({ handoff_id, handoff_token, call_url_public }) {
64
+ this.session = { id: handoff_id, token: handoff_token, since: null, call_url_public: call_url_public || null };
65
+ this._save();
66
+ this._loop();
67
+ this.bus.emit('handoff:start', { callUrl: this.callUrl });
68
+ }
69
+
70
+ async send(text) {
71
+ if (!this.session) throw new Error('No hay conversación con una persona activa');
72
+ const res = await this._post('send', { text: String(text).slice(0, MAX_MSG) });
73
+ if (res.status === 404 || res.status === 410) {
74
+ this.end(this.t('widget.handoff.closed'));
75
+ throw new Error('canal cerrado');
76
+ }
77
+ if (!res.ok) throw new Error(`apuchat send ${res.status}`);
78
+ }
79
+
80
+ /** Long-poll (vía proxy) de mensajes del operador. */
81
+ _loop() {
82
+ let stopped = false;
83
+ const ctrl = new AbortController();
84
+ this._stop = () => {
85
+ stopped = true;
86
+ ctrl.abort();
87
+ };
88
+ const run = async () => {
89
+ let backoff = 1000;
90
+ while (!stopped && this.session) {
91
+ const s = this.session;
92
+ try {
93
+ const res = await this._post('wait', { since: s.since }, ctrl.signal);
94
+ if (res.status === 404 || res.status === 410) return this.end(this.t('widget.handoff.closed'));
95
+ if (!res.ok) throw new Error(`wait ${res.status}`);
96
+ const { messages = [], since, ended } = await res.json();
97
+ if (ended) return this.end(this.t('widget.handoff.closed'));
98
+ if (since != null) s.since = since;
99
+ for (const m of messages) this.bus.emit('handoff:message', { from: m.from, text: m.text });
100
+ this._save();
101
+ backoff = 1000;
102
+ } catch (err) {
103
+ if (stopped) return;
104
+ await new Promise((r) => setTimeout(r, backoff));
105
+ backoff = Math.min(backoff * 2, 15000);
106
+ }
107
+ }
108
+ };
109
+ run();
110
+ }
111
+
112
+ /** Termina el modo humano y vuelve al asistente. */
113
+ end(reason = '') {
114
+ this._stop?.();
115
+ this._stop = null;
116
+ if (!this.session) return;
117
+ const { id, token } = this.session;
118
+ this.session = null;
119
+ this.store.remove('apuchat');
120
+ // Best-effort: avisa al equipo y libera el relé (keepalive por si se cierra la página).
121
+ this._post('end', { id, token }, undefined, true).catch(() => {});
122
+ this.bus.emit('handoff:end', { reason });
123
+ }
124
+
125
+ /** Pausa el polling sin cerrar la sesión (antes de navegar). */
126
+ suspend() {
127
+ this._stop?.();
128
+ this._stop = null;
129
+ }
130
+
131
+ _post(path, data, signal, keepalive = false) {
132
+ const { id, token } = this.session || {};
133
+ return fetch(`${this.base}/${path}`, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json', ...(this.siteKey ? { 'X-Site-Key': this.siteKey } : {}), ...acceptLanguage(this.t.locale) },
136
+ body: JSON.stringify({ id, token, ...data }),
137
+ signal,
138
+ keepalive,
139
+ });
140
+ }
141
+
142
+ _save() {
143
+ this.store.set('apuchat', this.session);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Herramienta para el LLM + conexión del puente.
149
+ * @param {object} o
150
+ * @param {string} o.endpoint
151
+ * @param {string} [o.siteKey]
152
+ * @param {ApuchatBridge} o.bridge
153
+ * @param {() => Array<{from,text}>} o.getTranscript
154
+ * @param {(key: string, vars?: object) => string} [o.t] traductor (por defecto, el del puente)
155
+ */
156
+ export function createApuchatActions({ endpoint, siteKey, bridge, getTranscript, t = bridge?.t || translator() }) {
157
+ return [
158
+ {
159
+ name: 'escalate_to_human',
160
+ category: 'contact',
161
+ description:
162
+ 'Conecta al usuario en vivo con una persona del equipo (chat en este mismo widget, con opción de videollamada) vía apuchat. ' +
163
+ 'Úsalo si el usuario lo pide, si está frustrado, o si el caso requiere una persona (pagos, reclamaciones, excepciones). ' +
164
+ 'Incluye un resumen para que la persona no tenga que volver a preguntar.',
165
+ parameters: {
166
+ type: 'object',
167
+ properties: {
168
+ reason: { type: 'string', description: 'Motivo en pocas palabras' },
169
+ summary: { type: 'string', description: 'Resumen del caso y lo que ya se intentó' },
170
+ urgency: { type: 'string', enum: ['baja', 'normal', 'alta'] },
171
+ },
172
+ required: ['reason', 'summary'],
173
+ },
174
+ confirm: () => t('widget.contact.humanConfirm'),
175
+ enabled: () => !bridge.active,
176
+ timeoutMs: 30000,
177
+ handler: async (args, { auth, ui }) => {
178
+ const user = auth?.publicUser() || null;
179
+ const res = await fetch(`${endpoint.replace(/\/$/, '')}/contact/apuchat`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json', ...(siteKey ? { 'X-Site-Key': siteKey } : {}), ...acceptLanguage(t.locale) },
182
+ body: JSON.stringify({
183
+ reason: args.reason,
184
+ summary: args.summary,
185
+ urgency: args.urgency || 'normal',
186
+ user,
187
+ page: { url: location.href, title: document.title },
188
+ transcript: getTranscript().slice(-30),
189
+ }),
190
+ });
191
+ const body = await res.json().catch(() => ({}));
192
+ if (!res.ok) return { ok: false, error: body.error || `No se pudo contactar con soporte (HTTP ${res.status}). Ofrece dejar un correo.` };
193
+
194
+ bridge.join(body);
195
+ // Primer mensaje al operador, en el idioma del visitante (así sabe en cuál contestar).
196
+ await bridge
197
+ .send(t('widget.contact.handoffIntro', { name: user?.name || t('widget.contact.visitor'), reason: args.reason, summary: args.summary, url: location.href }))
198
+ .catch(() => {});
199
+ ui?.toast({ message: t('widget.contact.connected'), type: 'success' });
200
+ return {
201
+ ok: true,
202
+ data: {
203
+ connected: true,
204
+ operatorNotified: body.operatorNotified !== false,
205
+ note: 'El usuario ya está en el chat con una persona; tus próximos mensajes no serán necesarios hasta que vuelva. Despídete en una frase.',
206
+ },
207
+ };
208
+ },
209
+ },
210
+ ];
211
+ }
212
+