@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.
- package/.env.example +140 -0
- package/LICENSE +21 -0
- package/README.md +655 -0
- package/brand/apple-touch-icon.png +0 -0
- package/brand/favicon.svg +4 -0
- package/brand/icon-512.png +0 -0
- package/brand/mark-mono.svg +3 -0
- package/brand/mark.svg +4 -0
- package/brand/og-es.png +0 -0
- package/brand/og-pt.png +0 -0
- package/brand/og.png +0 -0
- package/brand/ots.json +11 -0
- package/brand/tokens.css +59 -0
- package/brand/wordmark.svg +5 -0
- package/cli/7ots.mjs +400 -0
- package/cli/lib/brain.mjs +143 -0
- package/cli/lib/config.mjs +70 -0
- package/cli/lib/hooks.mjs +151 -0
- package/cli/lib/i18n.mjs +480 -0
- package/cli/lib/identity.mjs +51 -0
- package/cli/lib/install.mjs +79 -0
- package/cli/lib/lines.mjs +73 -0
- package/cli/lib/meet.mjs +166 -0
- package/cli/lib/open.mjs +19 -0
- package/cli/lib/orquesta.mjs +84 -0
- package/cli/lib/paths.mjs +49 -0
- package/cli/lib/pet-core.mjs +184 -0
- package/cli/lib/pet-server.mjs +296 -0
- package/cli/lib/prompts.mjs +75 -0
- package/cli/lib/terminal.mjs +145 -0
- package/cli/lib/ui.mjs +234 -0
- package/cli/lib/wizard.mjs +279 -0
- package/cli/pet/electron/main.cjs +86 -0
- package/cli/pet/pet.html +404 -0
- package/dist/7ots.esm.js +627 -0
- package/dist/7ots.esm.js.map +7 -0
- package/dist/7ots.iife.js +627 -0
- package/dist/7ots.iife.js.map +7 -0
- package/llms.txt +57 -0
- package/package.json +73 -0
- package/server/admin.mjs +213 -0
- package/server/apuchat-relay.mjs +170 -0
- package/server/channels/agent.mjs +136 -0
- package/server/channels/apuchat.mjs +364 -0
- package/server/channels/apumail.mjs +211 -0
- package/server/channels/index.mjs +69 -0
- package/server/contact.mjs +151 -0
- package/server/demo.mjs +154 -0
- package/server/i18n.mjs +54 -0
- package/server/identity.mjs +108 -0
- package/server/llm.mjs +413 -0
- package/server/platform/auth.mjs +290 -0
- package/server/platform/crypto.mjs +80 -0
- package/server/platform/db.mjs +130 -0
- package/server/platform/routes.mjs +388 -0
- package/server/platform/runtime.mjs +180 -0
- package/server/platform/store.mjs +331 -0
- package/server/sdk.mjs +125 -0
- package/server/server.mjs +456 -0
- package/server/settings.mjs +177 -0
- package/server/tts.mjs +186 -0
- package/skills/7ots/SKILL.md +59 -0
- package/src/actions/ActionRegistry.js +254 -0
- package/src/actions/PageTools.js +270 -0
- package/src/actions/builtins.js +565 -0
- package/src/auth/AuthManager.js +128 -0
- package/src/avatar/AvatarStage.js +473 -0
- package/src/character/AvatarEditor.js +829 -0
- package/src/character/Character.js +1191 -0
- package/src/character/motion.js +794 -0
- package/src/character/parts.js +559 -0
- package/src/context/ContextManager.js +472 -0
- package/src/core/AgentBrain.js +341 -0
- package/src/core/AgentWidget.js +1059 -0
- package/src/core/EventBus.js +61 -0
- package/src/core/Proactivity.js +363 -0
- package/src/core/storage.js +35 -0
- package/src/i18n/index.js +193 -0
- package/src/i18n/messages/character.js +543 -0
- package/src/i18n/messages/identity.js +141 -0
- package/src/i18n/messages/platform.js +114 -0
- package/src/i18n/messages/server.js +630 -0
- package/src/i18n/messages/widget.js +362 -0
- package/src/identity/ContactCard.js +351 -0
- package/src/identity/Face.js +148 -0
- package/src/identity/random.js +112 -0
- package/src/identity/schema.js +262 -0
- package/src/index.js +117 -0
- package/src/integrations/apuchat.js +212 -0
- package/src/integrations/apumail.js +88 -0
- package/src/llm/ProxyLLM.js +87 -0
- package/src/mcp/McpClient.js +153 -0
- package/src/ui/Companion.js +498 -0
- package/src/ui/UIManager.js +425 -0
- package/src/ui/VirtualPointer.js +746 -0
- package/src/ui/markdown.js +94 -0
- 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
|
+
|