@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,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Companion — modo lúdico: el avatar sale del chat y se pasea por la página.
|
|
3
|
+
*
|
|
4
|
+
* init({ mode: 'companion', companion: { size: 150, idleHomeMs: 30000, follow: true } })
|
|
5
|
+
*
|
|
6
|
+
* El avatar (3D o 2D) flota sobre la página dentro del Shadow DOM del widget. Camina hasta
|
|
7
|
+
* los elementos, los señala (gesto + flecha + resaltado), habla en un bocadillo y vuelve a su
|
|
8
|
+
* rincón cuando se queda quieto. Pulsarlo abre el chat; arrastrarlo lo cambia de sitio.
|
|
9
|
+
*
|
|
10
|
+
* El modelo lo usa con las acciones `point_at`, `tour` y `go_home`. Además, con `follow`, se
|
|
11
|
+
* acerca a lo que toque con el ratón virtual (click, type_text…) para que se vea quién actúa.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { renderMarkdown } from './markdown.js';
|
|
15
|
+
import { revealElement } from './UIManager.js';
|
|
16
|
+
import { escapeHtml } from './markdown.js';
|
|
17
|
+
import { translator } from '../i18n/index.js';
|
|
18
|
+
import '../i18n/messages/widget.js';
|
|
19
|
+
|
|
20
|
+
const MARGIN = 10;
|
|
21
|
+
const GAP = 14;
|
|
22
|
+
|
|
23
|
+
export class Companion {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} o
|
|
26
|
+
* @param {ShadowRoot} o.root shadow root del widget (comparte su CSS)
|
|
27
|
+
* @param {HTMLElement|null} o.stage nodo del avatar que se muda al compañero
|
|
28
|
+
* @param {import('../avatar/AvatarStage.js').AvatarStage|null} o.avatar
|
|
29
|
+
* @param {object} o.ui UIManager (highlight)
|
|
30
|
+
* @param {string} o.name
|
|
31
|
+
* @param {'right'|'left'} [o.side]
|
|
32
|
+
* @param {(text:string)=>Promise<void>} [o.speak] voz (resuelve al terminar)
|
|
33
|
+
* @param {()=>void} [o.onActivate] click en el compañero
|
|
34
|
+
* @param {()=>boolean} [o.panelOpen]
|
|
35
|
+
* @param {(key: string, vars?: object) => string} [o.t] traductor (etiquetas accesibles)
|
|
36
|
+
*/
|
|
37
|
+
constructor({ root, stage, avatar, ui, name, side = 'right', speak = null, onActivate = null, panelOpen = () => false, config = {}, t = translator() }) {
|
|
38
|
+
this.t = t;
|
|
39
|
+
this.root = root;
|
|
40
|
+
this.stage = stage;
|
|
41
|
+
this.avatar = avatar;
|
|
42
|
+
this.ui = ui;
|
|
43
|
+
this.name = name;
|
|
44
|
+
this.side = side;
|
|
45
|
+
this.speak = speak;
|
|
46
|
+
this.onActivate = onActivate;
|
|
47
|
+
this.panelOpen = panelOpen;
|
|
48
|
+
this.cfg = { size: 150, idleHomeMs: 30000, follow: true, wanderMs: 0, watchCursor: false, ...config };
|
|
49
|
+
/** Lo pone el widget: false mientras el agente trabaja o el chat está abierto. */
|
|
50
|
+
this.canWander = () => true;
|
|
51
|
+
this.x = 0;
|
|
52
|
+
this.y = 0;
|
|
53
|
+
this._anchor = null; // elemento junto al que está (lo sigue al hacer scroll)
|
|
54
|
+
this._home = null; // posición elegida por el usuario al arrastrarlo
|
|
55
|
+
this._timers = {};
|
|
56
|
+
this._offs = [];
|
|
57
|
+
this._sayToken = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get size() {
|
|
61
|
+
return innerWidth < 640 ? Math.round(this.cfg.size * 0.66) : this.cfg.size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
mount() {
|
|
65
|
+
const el = document.createElement('div');
|
|
66
|
+
el.className = 'buddy';
|
|
67
|
+
el.setAttribute('data-ots-ignore', '');
|
|
68
|
+
el.innerHTML = `
|
|
69
|
+
<div class="b-say" role="status" aria-live="polite" hidden><div class="b-say-text"></div></div>
|
|
70
|
+
<button class="b-body" aria-label="${escapeHtml(this.t('widget.companion.talkTo', { name: this.name }))}" title="${escapeHtml(this.name)}">
|
|
71
|
+
<span class="b-stage"></span><span class="b-shadow"></span>
|
|
72
|
+
</button>
|
|
73
|
+
<span class="b-arrow" aria-hidden="true" hidden></span>`;
|
|
74
|
+
// Capa superior (popover) para no quedar bajo el fondo de un <dialog> modal del sitio.
|
|
75
|
+
if ('popover' in el) el.popover = 'manual';
|
|
76
|
+
this.root.appendChild(el);
|
|
77
|
+
this.el = el;
|
|
78
|
+
this._raise();
|
|
79
|
+
if (this.stage) el.querySelector('.b-stage').appendChild(this.stage);
|
|
80
|
+
el.style.setProperty('--bs', `${this.size}px`);
|
|
81
|
+
|
|
82
|
+
// Click = abrir el chat; arrastrar = moverlo (y recordar el sitio).
|
|
83
|
+
const body = el.querySelector('.b-body');
|
|
84
|
+
let drag = null;
|
|
85
|
+
body.addEventListener('pointerdown', (e) => {
|
|
86
|
+
if (e.button !== 0) return;
|
|
87
|
+
drag = { sx: e.clientX, sy: e.clientY, x: this.x, y: this.y, moved: false };
|
|
88
|
+
body.setPointerCapture(e.pointerId);
|
|
89
|
+
});
|
|
90
|
+
body.addEventListener('pointermove', (e) => {
|
|
91
|
+
if (!drag) return;
|
|
92
|
+
const dx = e.clientX - drag.sx;
|
|
93
|
+
const dy = e.clientY - drag.sy;
|
|
94
|
+
if (!drag.moved && Math.hypot(dx, dy) < 6) return;
|
|
95
|
+
drag.moved = true;
|
|
96
|
+
this._anchor = null;
|
|
97
|
+
this._place(drag.x + dx, drag.y + dy, 0);
|
|
98
|
+
});
|
|
99
|
+
body.addEventListener('pointerup', () => {
|
|
100
|
+
if (drag?.moved) this._home = { x: this.x, y: this.y };
|
|
101
|
+
else if (drag) this.onActivate?.();
|
|
102
|
+
drag = null;
|
|
103
|
+
});
|
|
104
|
+
body.addEventListener('pointercancel', () => (drag = null));
|
|
105
|
+
el.querySelector('.b-say').addEventListener('click', () => this.onActivate?.());
|
|
106
|
+
|
|
107
|
+
// Si está junto a un elemento, lo acompaña al hacer scroll o cambiar el tamaño.
|
|
108
|
+
let raf = 0;
|
|
109
|
+
const follow = () => {
|
|
110
|
+
cancelAnimationFrame(raf);
|
|
111
|
+
raf = requestAnimationFrame(() => {
|
|
112
|
+
el.style.setProperty('--bs', `${this.size}px`);
|
|
113
|
+
if (this._anchor?.isConnected) this._placeNear(this._anchor, 0);
|
|
114
|
+
else this.home({ instant: true });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
addEventListener('scroll', follow, { passive: true, capture: true });
|
|
118
|
+
addEventListener('resize', follow);
|
|
119
|
+
this._offs.push(() => removeEventListener('scroll', follow, { capture: true }), () => removeEventListener('resize', follow));
|
|
120
|
+
|
|
121
|
+
this.home({ instant: true });
|
|
122
|
+
this._idleLook();
|
|
123
|
+
this.setBehavior(this.cfg);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Vuelve a poner nombre e idioma en las etiquetas (identidad o idioma nuevos). */
|
|
127
|
+
relabel() {
|
|
128
|
+
const body = this.el?.querySelector('.b-body');
|
|
129
|
+
if (!body) return;
|
|
130
|
+
body.setAttribute('aria-label', this.t('widget.companion.talkTo', { name: this.name }));
|
|
131
|
+
body.title = this.name;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Cuánta vida propia tiene (lo ajusta el nivel de iniciativa).
|
|
136
|
+
* @param {{ wanderMs?: number, watchCursor?: boolean }} o wanderMs 0 = no curiosea
|
|
137
|
+
*/
|
|
138
|
+
setBehavior({ wanderMs = this.cfg.wanderMs, watchCursor = this.cfg.watchCursor } = {}) {
|
|
139
|
+
this.cfg.wanderMs = wanderMs;
|
|
140
|
+
this.cfg.watchCursor = watchCursor;
|
|
141
|
+
this._scheduleWander();
|
|
142
|
+
this._offCursor?.();
|
|
143
|
+
this._offCursor = null;
|
|
144
|
+
if (watchCursor) {
|
|
145
|
+
let last = 0;
|
|
146
|
+
const look = (e) => {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - last < 300 || this._busyLooking()) return;
|
|
149
|
+
last = now;
|
|
150
|
+
this.avatar?.lookAt?.(e.clientX, e.clientY, 700);
|
|
151
|
+
};
|
|
152
|
+
addEventListener('pointermove', look, { passive: true });
|
|
153
|
+
this._offCursor = () => removeEventListener('pointermove', look);
|
|
154
|
+
this._offs.push(() => this._offCursor?.());
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_busyLooking() {
|
|
159
|
+
return document.hidden || this.el.classList.contains('walking') || !this.el.querySelector('.b-arrow').hidden;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Curiosidad: de vez en cuando se acerca a algo de la pantalla y lo mira, sin hablar. */
|
|
163
|
+
_scheduleWander() {
|
|
164
|
+
clearTimeout(this._timers.wander);
|
|
165
|
+
const ms = this.cfg.wanderMs;
|
|
166
|
+
if (!ms) return;
|
|
167
|
+
this._timers.wander = setTimeout(() => {
|
|
168
|
+
this._wander().finally(() => this._scheduleWander());
|
|
169
|
+
}, ms * (0.7 + Math.random() * 0.6));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async _wander() {
|
|
173
|
+
if (document.hidden || this._anchor || !this.canWander() || !this.el.querySelector('.b-say').hidden) return;
|
|
174
|
+
const s = this.size;
|
|
175
|
+
const candidates = [...document.querySelectorAll('h1, h2, h3, img, button, a[class*=btn], a[class*=button], [class*=card], figure, table')].filter((el) => {
|
|
176
|
+
if (el.closest('[data-ots-root], [data-ots-ignore]')) return false;
|
|
177
|
+
const r = el.getBoundingClientRect();
|
|
178
|
+
return r.width > 30 && r.height > 16 && r.top > 40 && r.bottom < innerHeight - 40 && r.width < innerWidth * 0.9 && el !== this._lastWander;
|
|
179
|
+
});
|
|
180
|
+
if (!candidates.length) return;
|
|
181
|
+
const el = candidates[Math.floor(Math.random() * candidates.length)];
|
|
182
|
+
this._lastWander = el;
|
|
183
|
+
this.el.classList.add('curious');
|
|
184
|
+
await this._placeNear(el);
|
|
185
|
+
if (this._anchor) return this.el.classList.remove('curious'); // el agente lo necesitó mientras tanto
|
|
186
|
+
const r = el.getBoundingClientRect();
|
|
187
|
+
this.avatar?.lookAt?.(r.left + r.width / 2, r.top + r.height / 2, 2000);
|
|
188
|
+
if (Math.random() < 0.35) this.avatar?.gesture?.(['index', 'shrug', 'handup'][Math.floor(Math.random() * 3)], 1.6, r.left + r.width / 2 < this.x + s / 2);
|
|
189
|
+
await sleep(2600 + Math.random() * 2000);
|
|
190
|
+
this.el.classList.remove('curious');
|
|
191
|
+
if (!this._anchor && this.canWander()) this.home();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
destroy() {
|
|
195
|
+
Object.values(this._timers).forEach(clearTimeout);
|
|
196
|
+
this._offs.forEach((f) => f());
|
|
197
|
+
this.el?.remove();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Acompaña al panel: si se abre, se aparta a un lado; en móvil se esconde. */
|
|
201
|
+
panelChanged(open) {
|
|
202
|
+
this.el.classList.toggle('behind', open && innerWidth < 640);
|
|
203
|
+
// Al abrir se aparta junto al panel; al cerrar se queda donde estaba si señalaba algo.
|
|
204
|
+
if (open || !this._anchor) this.home();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ───────────────────────────── movimiento ─────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** Vuelve a su rincón (o al sitio donde lo dejó el usuario). */
|
|
210
|
+
home({ instant = false } = {}) {
|
|
211
|
+
this._anchor = null;
|
|
212
|
+
const s = this.size;
|
|
213
|
+
let x;
|
|
214
|
+
let y;
|
|
215
|
+
if (this._home) ({ x, y } = this._home);
|
|
216
|
+
else {
|
|
217
|
+
const open = this.panelOpen() && innerWidth >= 640;
|
|
218
|
+
const offset = open ? Math.min(380, innerWidth - 24) + 32 : 20;
|
|
219
|
+
x = this.side === 'left' ? offset : innerWidth - s - offset;
|
|
220
|
+
y = innerHeight - s - 20;
|
|
221
|
+
}
|
|
222
|
+
return this._place(x, y, instant ? 0 : undefined);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Camina hasta quedarse al lado del elemento, sin taparlo. */
|
|
226
|
+
async goTo(el) {
|
|
227
|
+
if (!el?.isConnected) throw new Error('El elemento ya no está en la página');
|
|
228
|
+
const r = el.getBoundingClientRect();
|
|
229
|
+
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) {
|
|
230
|
+
revealElement(el);
|
|
231
|
+
await settle(el);
|
|
232
|
+
}
|
|
233
|
+
this._anchor = el;
|
|
234
|
+
await this._placeNear(el);
|
|
235
|
+
this._touch();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Va al elemento, lo señala y (opcional) dice algo. Resuelve al terminar de hablar. */
|
|
239
|
+
async pointAt(el, message = '', { signal } = {}) {
|
|
240
|
+
await this.goTo(el);
|
|
241
|
+
if (signal?.aborted) return;
|
|
242
|
+
const r = el.getBoundingClientRect();
|
|
243
|
+
const tx = r.left + r.width / 2;
|
|
244
|
+
const ty = r.top + r.height / 2;
|
|
245
|
+
const cx = this.x + this.size / 2;
|
|
246
|
+
this.avatar?.lookAt?.(tx, ty, 2500);
|
|
247
|
+
this.avatar?.gesture?.('index', 2.5, tx < cx);
|
|
248
|
+
this._arrow(tx, ty);
|
|
249
|
+
this.ui?.highlight?.(el, { duration: 2600, scroll: false });
|
|
250
|
+
if (message) await this.say(message, { signal });
|
|
251
|
+
else await sleep(1200, signal);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Recorrido: señala varias cosas seguidas, hablando en cada una. */
|
|
255
|
+
async tour(steps, { signal, resolve } = {}) {
|
|
256
|
+
let done = 0;
|
|
257
|
+
for (const s of steps) {
|
|
258
|
+
if (signal?.aborted) break;
|
|
259
|
+
const el = resolve(s.target);
|
|
260
|
+
if (!el) continue;
|
|
261
|
+
await this.pointAt(el, s.message || '', { signal });
|
|
262
|
+
done++;
|
|
263
|
+
await sleep(350, signal);
|
|
264
|
+
}
|
|
265
|
+
return done;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Se acerca a lo que toca el ratón virtual (sin hablar ni esperar a que llegue). */
|
|
269
|
+
follow(el) {
|
|
270
|
+
if (!this.cfg.follow || !el?.isConnected) return;
|
|
271
|
+
this._anchor = el;
|
|
272
|
+
this._placeNear(el);
|
|
273
|
+
this._touch();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_placeNear(el, duration) {
|
|
277
|
+
const s = this.size;
|
|
278
|
+
const r = el.getBoundingClientRect();
|
|
279
|
+
const vw = innerWidth;
|
|
280
|
+
const vh = innerHeight;
|
|
281
|
+
const midY = r.top + r.height / 2 - s / 2;
|
|
282
|
+
const midX = r.left + r.width / 2 - s / 2;
|
|
283
|
+
const cy = clamp(midY, MARGIN, vh - s - MARGIN);
|
|
284
|
+
const cx = clamp(midX, MARGIN, vw - s - MARGIN);
|
|
285
|
+
const right = [r.right + GAP, cy];
|
|
286
|
+
const left = [r.left - s - GAP, cy];
|
|
287
|
+
// Prefiere el lado natural del widget para no cruzar la pantalla sin necesidad.
|
|
288
|
+
const options = [...(this.side === 'left' ? [left, right] : [right, left]), [cx, r.bottom + GAP], [cx, r.top - s - GAP]];
|
|
289
|
+
const fits = ([x, y]) => x >= MARGIN && y >= MARGIN && x + s <= vw - MARGIN && y + s <= vh - MARGIN;
|
|
290
|
+
// Si no cabe fuera (elemento enorme), se pone dentro de su esquina superior.
|
|
291
|
+
const [x, y] = options.find(fits) || [
|
|
292
|
+
this.side === 'left' ? Math.max(r.left, 0) + MARGIN : Math.min(r.right, vw) - s - MARGIN,
|
|
293
|
+
Math.max(r.top, 0) + MARGIN,
|
|
294
|
+
];
|
|
295
|
+
return this._place(x, y, duration);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Mueve el cuerpo con un paseo animado. Resuelve al llegar. */
|
|
299
|
+
_place(x, y, duration) {
|
|
300
|
+
const s = this.size;
|
|
301
|
+
x = clamp(x, MARGIN, innerWidth - s - MARGIN);
|
|
302
|
+
y = clamp(y, MARGIN, innerHeight - s - MARGIN);
|
|
303
|
+
const dist = Math.hypot(x - this.x, y - this.y);
|
|
304
|
+
const instant = document.hidden || matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
305
|
+
const ms = duration ?? (instant ? 0 : clamp(dist * 1.1, 0, 1300));
|
|
306
|
+
const el = this.el;
|
|
307
|
+
if (ms > 0 && dist > 4) {
|
|
308
|
+
el.classList.add('walking');
|
|
309
|
+
el.classList.toggle('to-left', x < this.x);
|
|
310
|
+
clearTimeout(this._timers.walk);
|
|
311
|
+
this._timers.walk = setTimeout(() => el.classList.remove('walking'), ms);
|
|
312
|
+
}
|
|
313
|
+
this._raise();
|
|
314
|
+
el.style.transitionDuration = `${ms}ms`;
|
|
315
|
+
el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
316
|
+
el.dataset.v = y < innerHeight / 2 ? 'top' : 'bottom';
|
|
317
|
+
el.dataset.h = x + s / 2 < innerWidth / 2 ? 'left' : 'right';
|
|
318
|
+
this.x = x;
|
|
319
|
+
this.y = y;
|
|
320
|
+
return sleep(ms);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Se vuelve a poner encima si el sitio abrió un modal después. */
|
|
324
|
+
_raise() {
|
|
325
|
+
const el = this.el;
|
|
326
|
+
if (!el?.showPopover) return;
|
|
327
|
+
try {
|
|
328
|
+
if (el.matches(':popover-open')) {
|
|
329
|
+
if (!document.querySelector('dialog:modal')) return;
|
|
330
|
+
el.hidePopover();
|
|
331
|
+
}
|
|
332
|
+
el.showPopover();
|
|
333
|
+
} catch {}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_arrow(tx, ty) {
|
|
337
|
+
const a = this.el.querySelector('.b-arrow');
|
|
338
|
+
const cx = this.size / 2;
|
|
339
|
+
const ang = Math.atan2(ty - (this.y + cx), tx - (this.x + cx));
|
|
340
|
+
const d = cx * 0.95;
|
|
341
|
+
a.style.transform = `translate(${cx + Math.cos(ang) * d}px, ${cx + Math.sin(ang) * d}px) rotate(${ang}rad)`;
|
|
342
|
+
a.hidden = false;
|
|
343
|
+
a.classList.remove('go');
|
|
344
|
+
void a.offsetWidth;
|
|
345
|
+
a.classList.add('go');
|
|
346
|
+
clearTimeout(this._timers.arrow);
|
|
347
|
+
this._timers.arrow = setTimeout(() => (a.hidden = true), 2600);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ───────────────────────────── habla ─────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Bocadillo junto al avatar. Con `speak`, además lo dice en voz alta.
|
|
354
|
+
* Resuelve cuando termina la voz o el tiempo de lectura.
|
|
355
|
+
*/
|
|
356
|
+
async say(text, { speak = true, signal } = {}) {
|
|
357
|
+
const token = ++this._sayToken;
|
|
358
|
+
const box = this.el.querySelector('.b-say');
|
|
359
|
+
const t = String(text || '').trim();
|
|
360
|
+
box.querySelector('.b-say-text').innerHTML = renderMarkdown(t.length > 240 ? `${t.slice(0, 237)}…` : t);
|
|
361
|
+
box.hidden = false;
|
|
362
|
+
this._touch();
|
|
363
|
+
const reading = clamp(t.length * 55, 1800, 9000);
|
|
364
|
+
const voice = speak && this.speak ? this.speak(t).catch(() => {}) : null;
|
|
365
|
+
await Promise.race([Promise.all([voice || sleep(reading), sleep(Math.min(reading, 1500))]), abortPromise(signal)]);
|
|
366
|
+
clearTimeout(this._timers.say);
|
|
367
|
+
this._timers.say = setTimeout(() => {
|
|
368
|
+
if (token === this._sayToken) box.hidden = true;
|
|
369
|
+
}, 2500);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
hush() {
|
|
373
|
+
this._sayToken++;
|
|
374
|
+
this.el.querySelector('.b-say').hidden = true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ───────────────────────────── inactividad ─────────────────────────────
|
|
378
|
+
|
|
379
|
+
_touch() {
|
|
380
|
+
clearTimeout(this._timers.idle);
|
|
381
|
+
this._timers.idle = setTimeout(() => {
|
|
382
|
+
if (this._anchor) this.home();
|
|
383
|
+
}, this.cfg.idleHomeMs);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** De vez en cuando mira a otro lado: parece vivo aunque nadie le hable. */
|
|
387
|
+
_idleLook() {
|
|
388
|
+
clearTimeout(this._timers.look);
|
|
389
|
+
this._timers.look = setTimeout(() => {
|
|
390
|
+
if (!document.hidden && !this.el.classList.contains('walking')) {
|
|
391
|
+
this.avatar?.lookAt?.(Math.random() * innerWidth, Math.random() * innerHeight * 0.7, 1200);
|
|
392
|
+
}
|
|
393
|
+
this._idleLook();
|
|
394
|
+
}, 7000 + Math.random() * 9000);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Acciones del modo compañero.
|
|
400
|
+
* @param {Companion} companion
|
|
401
|
+
* @param {{ t?: Function }} [opt] traductor de los títulos (por defecto, el del compañero)
|
|
402
|
+
*/
|
|
403
|
+
export function createCompanionActions(companion, { t = companion.t || translator() } = {}) {
|
|
404
|
+
const TARGET = { type: 'string', description: 'Ref (p. ej. "e12") o selector CSS del elemento' };
|
|
405
|
+
const need = (context, t) => {
|
|
406
|
+
const el = context.resolve(t);
|
|
407
|
+
if (!el) throw new Error(`No encuentro "${t}" en la página. Usa get_page_context o find_on_page.`);
|
|
408
|
+
return el;
|
|
409
|
+
};
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
name: 'point_at',
|
|
413
|
+
category: 'ui',
|
|
414
|
+
title: () => t('widget.companion.pointAt'), // función: sigue el idioma actual
|
|
415
|
+
description:
|
|
416
|
+
'Tu avatar camina por la página hasta el elemento, lo señala y dice el mensaje en un bocadillo (y en voz alta). ' +
|
|
417
|
+
'Úsalo para enseñar dónde está algo. El mensaje debe ser corto (una frase).',
|
|
418
|
+
parameters: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: { target: TARGET, message: { type: 'string', description: 'Lo que dices al señalarlo (breve)' } },
|
|
421
|
+
required: ['target'],
|
|
422
|
+
},
|
|
423
|
+
handler: async ({ target, message = '' }, { context, signal }) => {
|
|
424
|
+
const el = need(context, target);
|
|
425
|
+
await companion.pointAt(el, message, { signal });
|
|
426
|
+
return { ok: true, data: `Señalado "${label(el)}".` };
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: 'tour',
|
|
431
|
+
category: 'ui',
|
|
432
|
+
title: () => t('widget.companion.tour'), // función: sigue el idioma actual
|
|
433
|
+
description:
|
|
434
|
+
'Visita guiada: tu avatar recorre varios elementos en orden, señalando y comentando cada uno. ' +
|
|
435
|
+
'Ideal para "enséñame la página" o explicar un formulario. Máximo 8 pasos, mensajes breves.',
|
|
436
|
+
parameters: {
|
|
437
|
+
type: 'object',
|
|
438
|
+
properties: {
|
|
439
|
+
steps: {
|
|
440
|
+
type: 'array',
|
|
441
|
+
maxItems: 8,
|
|
442
|
+
items: { type: 'object', properties: { target: TARGET, message: { type: 'string' } }, required: ['target', 'message'] },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
required: ['steps'],
|
|
446
|
+
},
|
|
447
|
+
timeoutMs: 120000,
|
|
448
|
+
handler: async ({ steps }, { context, signal }) => {
|
|
449
|
+
const n = await companion.tour(steps.slice(0, 8), { signal, resolve: (t) => context.resolve(t) });
|
|
450
|
+
companion.home();
|
|
451
|
+
return { ok: true, data: `Recorrido hecho: ${n} de ${steps.length} pasos.` };
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: 'go_home',
|
|
456
|
+
category: 'ui',
|
|
457
|
+
title: () => t('widget.companion.goHome'), // función: sigue el idioma actual
|
|
458
|
+
description: 'Tu avatar vuelve a su rincón de la pantalla.',
|
|
459
|
+
parameters: { type: 'object', properties: {} },
|
|
460
|
+
handler: async () => {
|
|
461
|
+
await companion.home();
|
|
462
|
+
return { ok: true };
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
469
|
+
|
|
470
|
+
function clamp(v, a, b) {
|
|
471
|
+
return Math.max(a, Math.min(b, v));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sleep(ms, signal) {
|
|
475
|
+
return new Promise((r) => {
|
|
476
|
+
const t = setTimeout(r, ms);
|
|
477
|
+
signal?.addEventListener('abort', () => (clearTimeout(t), r()), { once: true });
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function abortPromise(signal) {
|
|
482
|
+
return new Promise((r) => signal?.addEventListener('abort', r, { once: true }));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Espera a que el scroll suave termine (posición estable) o 900 ms. */
|
|
486
|
+
async function settle(el) {
|
|
487
|
+
let last = null;
|
|
488
|
+
for (let i = 0; i < 18; i++) {
|
|
489
|
+
await sleep(50);
|
|
490
|
+
const top = el.getBoundingClientRect().top;
|
|
491
|
+
if (last !== null && Math.abs(top - last) < 1) return;
|
|
492
|
+
last = top;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function label(el) {
|
|
497
|
+
return (el.getAttribute('aria-label') || el.querySelector?.('h1, h2, h3, legend')?.innerText || el.innerText || el.getAttribute('alt') || el.tagName.toLowerCase()).replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
498
|
+
}
|