@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,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acciones incluidas por defecto: las "manos" del agente sobre la página.
|
|
3
|
+
*
|
|
4
|
+
* get_page_context · find_on_page · read_element (percepción)
|
|
5
|
+
* navigate · scroll_to · click · fill_form (ejecución)
|
|
6
|
+
* hover · type_text · press_key · drag_and_drop · scroll (ratón y teclado, con VirtualPointer)
|
|
7
|
+
* highlight · show_card · show_toast · show_modal · open_sidebar · close_panels (interfaz)
|
|
8
|
+
*
|
|
9
|
+
* Todas reciben los elementos como `target`: un ref del snapshot ("e12") o un selector CSS.
|
|
10
|
+
* Se pueden desactivar con config.builtins = { exclude: ['click', ...] }.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { revealElement } from '../ui/UIManager.js';
|
|
14
|
+
import { translator } from '../i18n/index.js';
|
|
15
|
+
import '../i18n/messages/widget.js';
|
|
16
|
+
|
|
17
|
+
const TARGET = {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Ref del elemento según el contexto (p. ej. "e12"). Solo si no hay ref, un selector CSS.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opt
|
|
24
|
+
* @param {object} [opt.navigation] { allowedOrigins: string[], router?: (url) => void|Promise }
|
|
25
|
+
* @param {'submit'|'all'|'none'} [opt.confirmClicks='submit']
|
|
26
|
+
* @param {(url, reason) => void} [opt.beforeNavigate] lo usa AgentWidget para persistir estado
|
|
27
|
+
* @param {import('../ui/VirtualPointer.js').VirtualPointer} [opt.pointer] ratón/teclado visibles
|
|
28
|
+
* @param {(key: string, vars?: object) => string} [opt.t] traductor de lo que ve el usuario
|
|
29
|
+
* (confirmaciones, botones). Las descripciones y resultados son para el LLM y no se traducen.
|
|
30
|
+
*/
|
|
31
|
+
export function createBuiltinActions({ navigation = {}, confirmClicks = 'submit', beforeNavigate = null, pointer = null, t = translator() } = {}) {
|
|
32
|
+
const allowedOrigins = [location.origin, ...(navigation.allowedOrigins || [])];
|
|
33
|
+
|
|
34
|
+
/** @returns {Element} o lanza un error legible para el LLM */
|
|
35
|
+
const need = (context, target) => {
|
|
36
|
+
const el = context.resolve(target);
|
|
37
|
+
if (!el) throw new Error(`No encontré el elemento "${target}". Pide get_page_context o usa find_on_page.`);
|
|
38
|
+
return el;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Campos que el agente no puede escribir ni leer: los rellena el usuario. */
|
|
42
|
+
const assertWritable = (context, el) => {
|
|
43
|
+
if (el.matches('input[type=password], [autocomplete^=cc-], [autocomplete=one-time-code]') || context._isPrivate?.(el)) {
|
|
44
|
+
throw new Error('Es un campo privado (contraseña, tarjeta, código…): pide al usuario que lo escriba él.');
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Enlaces: solo a orígenes permitidos, y guardando el estado si salen de la página. */
|
|
49
|
+
const checkLink = (el) => {
|
|
50
|
+
const a = el.closest?.('a[href]');
|
|
51
|
+
if (!a) return null;
|
|
52
|
+
const dest = new URL(a.getAttribute('href'), location.href);
|
|
53
|
+
if (/^https?:$/.test(dest.protocol) && !allowedOrigins.includes(dest.origin)) {
|
|
54
|
+
return `Ese enlace sale a ${dest.origin}, que no está permitido.`;
|
|
55
|
+
}
|
|
56
|
+
if (dest.origin === location.origin && dest.pathname !== location.pathname) beforeNavigate?.(dest.href, 'click en enlace');
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** ¿Pulsar Enter / este botón envía un formulario? */
|
|
61
|
+
const submits = (el) =>
|
|
62
|
+
!!el?.matches?.('[type=submit], form button:not([type]), [data-ots-confirm]') ||
|
|
63
|
+
(!!el?.form && el.matches?.('input:not([type=checkbox]):not([type=radio]):not([type=button])'));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Confirmación al enviar: el sitio decide con data-ots-confirm en el botón o en su <form>
|
|
67
|
+
* ("false" = no preguntar, texto = la pregunta). Si no dice nada, `fallback`.
|
|
68
|
+
*/
|
|
69
|
+
const submitConfirm = (el, fallback) => {
|
|
70
|
+
const c = el?.dataset?.otsConfirm ?? el?.form?.dataset?.otsConfirm ?? el?.closest?.('form')?.dataset?.otsConfirm;
|
|
71
|
+
if (c === 'false') return false;
|
|
72
|
+
return c && c !== 'true' ? c : fallback;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
// ─────────────── percepción ───────────────
|
|
77
|
+
{
|
|
78
|
+
name: 'get_page_context',
|
|
79
|
+
category: 'perception',
|
|
80
|
+
description: 'Vuelve a leer la página actual (estructura, sección visible, elementos con refs, formularios). Úsalo tras navegar, hacer click o si los refs dejaron de funcionar.',
|
|
81
|
+
parameters: { type: 'object', properties: {} },
|
|
82
|
+
handler: async (_, { context }) => ({ ok: true, data: context.toMarkdown() }),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'find_on_page',
|
|
86
|
+
category: 'perception',
|
|
87
|
+
description: 'Busca en toda la página elementos, títulos o secciones por texto (aunque no estén en el contexto). Devuelve refs.',
|
|
88
|
+
parameters: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: { query: { type: 'string', description: 'Texto a buscar, p. ej. "precios" o "iniciar sesión"' } },
|
|
91
|
+
required: ['query'],
|
|
92
|
+
},
|
|
93
|
+
handler: async ({ query }, { context }) => {
|
|
94
|
+
const found = context.search(query);
|
|
95
|
+
return found.length ? { ok: true, data: found } : { ok: false, error: `Nada coincide con "${query}".` };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'read_element',
|
|
100
|
+
category: 'perception',
|
|
101
|
+
description: 'Lee el texto completo de un elemento o sección (p. ej. una tabla de precios o unos términos).',
|
|
102
|
+
parameters: { type: 'object', properties: { target: TARGET }, required: ['target'] },
|
|
103
|
+
handler: async ({ target }, { context }) => {
|
|
104
|
+
const text = context.readElement(target);
|
|
105
|
+
return text == null ? { ok: false, error: `No encontré "${target}".` } : { ok: true, data: text };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// ─────────────── ejecución ───────────────
|
|
110
|
+
{
|
|
111
|
+
name: 'navigate',
|
|
112
|
+
category: 'execution',
|
|
113
|
+
description: 'Lleva al usuario a otra página del sitio. Acepta rutas relativas ("/precios") o URLs de dominios permitidos.',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
url: { type: 'string', description: 'Ruta o URL destino' },
|
|
118
|
+
reason: { type: 'string', description: 'Qué quieres hacer al llegar (para continuar la guía en la página nueva)' },
|
|
119
|
+
},
|
|
120
|
+
required: ['url'],
|
|
121
|
+
},
|
|
122
|
+
handler: async ({ url, reason = '' }) => {
|
|
123
|
+
let dest;
|
|
124
|
+
try {
|
|
125
|
+
dest = new URL(url, location.href);
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, error: 'URL inválida' };
|
|
128
|
+
}
|
|
129
|
+
if (!/^https?:$/.test(dest.protocol) || !allowedOrigins.includes(dest.origin)) {
|
|
130
|
+
return { ok: false, error: `No tengo permiso para navegar a ${dest.origin}. Solo: ${allowedOrigins.join(', ')}` };
|
|
131
|
+
}
|
|
132
|
+
if (dest.href === location.href) return { ok: true, data: 'Ya estás en esa página.' };
|
|
133
|
+
if (typeof navigation.router === 'function') {
|
|
134
|
+
// SPA: el router del sitio navega sin recargar; el agente sigue vivo.
|
|
135
|
+
await navigation.router(dest.origin === location.origin ? dest.pathname + dest.search + dest.hash : dest.href);
|
|
136
|
+
await new Promise((r) => setTimeout(r, 600)); // deja renderizar la vista nueva
|
|
137
|
+
return { ok: true, data: `Navegado a ${dest.pathname}. Llama a get_page_context para ver la página nueva.` };
|
|
138
|
+
}
|
|
139
|
+
if (dest.origin === location.origin && dest.pathname === location.pathname && dest.search === location.search && dest.hash) {
|
|
140
|
+
location.hash = dest.hash;
|
|
141
|
+
return { ok: true, data: `Desplazado a ${dest.hash}` };
|
|
142
|
+
}
|
|
143
|
+
// Navegación completa: se persiste la conversación y el agente retoma en la página nueva.
|
|
144
|
+
beforeNavigate?.(dest.href, reason);
|
|
145
|
+
setTimeout(() => location.assign(dest.href), 350);
|
|
146
|
+
return { ok: true, data: 'Navegando… la conversación continúa en la página nueva.', terminal: true };
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'scroll_to',
|
|
151
|
+
category: 'execution',
|
|
152
|
+
description: 'Desplaza la página hasta un elemento o sección.',
|
|
153
|
+
parameters: { type: 'object', properties: { target: TARGET }, required: ['target'] },
|
|
154
|
+
handler: async ({ target }, { context }) => {
|
|
155
|
+
revealElement(need(context, target));
|
|
156
|
+
return { ok: true, data: 'Listo' };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'click',
|
|
161
|
+
category: 'execution',
|
|
162
|
+
description: 'Mueve el ratón hasta un botón, enlace, pestaña o control y hace click (o doble click / click derecho) en nombre del usuario.',
|
|
163
|
+
parameters: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
target: TARGET,
|
|
167
|
+
double: { type: 'boolean', description: 'Doble click' },
|
|
168
|
+
button: { type: 'string', enum: ['left', 'right'], description: 'right = menú contextual' },
|
|
169
|
+
},
|
|
170
|
+
required: ['target'],
|
|
171
|
+
},
|
|
172
|
+
confirm: confirmClicks === 'none' ? false : (args, { context }) => {
|
|
173
|
+
// Pide confirmación para envíos (o para todo si confirmClicks === 'all').
|
|
174
|
+
const el = context.resolve(args.target);
|
|
175
|
+
const isSubmit = el?.matches?.('[type=submit], form button:not([type]), [data-ots-confirm]');
|
|
176
|
+
if (isSubmit) return submitConfirm(el, t('widget.confirm.click', { label: labelOf(el) || args.target }));
|
|
177
|
+
if (confirmClicks === 'all') return t('widget.confirm.click', { label: labelOf(el) || args.target });
|
|
178
|
+
return false;
|
|
179
|
+
},
|
|
180
|
+
handler: async ({ target, double = false, button = 'left' }, { context, ui }) => {
|
|
181
|
+
const el = need(context, target);
|
|
182
|
+
const blocked = checkLink(el);
|
|
183
|
+
if (blocked) return { ok: false, error: blocked };
|
|
184
|
+
if (pointer) {
|
|
185
|
+
await pointer.click(el, { double, button });
|
|
186
|
+
} else {
|
|
187
|
+
revealElement(el);
|
|
188
|
+
ui.highlight(el, { duration: 900, scroll: false });
|
|
189
|
+
await sleep(450);
|
|
190
|
+
el.focus?.({ preventScroll: true });
|
|
191
|
+
el.click();
|
|
192
|
+
if (double) el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
193
|
+
}
|
|
194
|
+
await sleep(300);
|
|
195
|
+
return { ok: true, data: `Click en "${labelOf(el)}". Si la página cambió, llama a get_page_context.` };
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'hover',
|
|
200
|
+
category: 'execution',
|
|
201
|
+
description: 'Pasa el ratón por encima de un elemento (menús o tooltips que se abren al pasar el ratón). Si el menú no aparece, prueba con click.',
|
|
202
|
+
parameters: { type: 'object', properties: { target: TARGET }, required: ['target'] },
|
|
203
|
+
enabled: () => !!pointer,
|
|
204
|
+
handler: async ({ target }, { context }) => {
|
|
205
|
+
await pointer.hover(need(context, target));
|
|
206
|
+
await sleep(250);
|
|
207
|
+
return { ok: true, data: 'Ratón encima. Llama a get_page_context si esperabas que apareciera algo.' };
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'type_text',
|
|
212
|
+
category: 'execution',
|
|
213
|
+
description: 'Escribe con el teclado en un campo, tecla a tecla, como lo haría el usuario (buscadores, editores, campos que reaccionan al escribir). Para rellenar varios campos a la vez usa fill_form.',
|
|
214
|
+
parameters: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
target: { ...TARGET, description: 'Campo donde escribir. Si se omite, el que tenga el foco.' },
|
|
218
|
+
text: { type: 'string', description: 'Texto a escribir. "\\n" = salto de línea en áreas de texto.' },
|
|
219
|
+
clear: { type: 'boolean', description: 'Borra el contenido actual antes de escribir' },
|
|
220
|
+
submit: { type: 'boolean', description: 'Pulsa Enter al terminar (envía búsquedas o formularios)' },
|
|
221
|
+
},
|
|
222
|
+
required: ['text'],
|
|
223
|
+
},
|
|
224
|
+
enabled: () => !!pointer,
|
|
225
|
+
confirm: (args, { context }) => {
|
|
226
|
+
if (!args.submit) return false;
|
|
227
|
+
const el = args.target ? context.resolve(args.target) : document.activeElement;
|
|
228
|
+
return el?.form ? submitConfirm(el, t('widget.confirm.typeSubmit')) : false;
|
|
229
|
+
},
|
|
230
|
+
handler: async ({ target, text, clear = false, submit = false }, { context }) => {
|
|
231
|
+
const el = target ? need(context, target) : null;
|
|
232
|
+
const field = el || document.activeElement;
|
|
233
|
+
if (field && field !== document.body) assertWritable(context, field);
|
|
234
|
+
const typed = await pointer.type(el, text, { clear });
|
|
235
|
+
if (submit) await pointer.press('Enter');
|
|
236
|
+
return { ok: true, data: `Escrito en "${labelOf(typed)}"${submit ? ' y pulsado Enter' : ''}.` };
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'press_key',
|
|
241
|
+
category: 'execution',
|
|
242
|
+
description: 'Pulsa una tecla o atajo: Enter, Tab, Shift+Tab, Escape, ArrowDown, ArrowUp, Backspace, Space, PageDown, Control+a… Sobre el elemento indicado o el que tenga el foco.',
|
|
243
|
+
parameters: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: {
|
|
246
|
+
key: { type: 'string', description: 'Tecla o combinación, p. ej. "Enter" o "Shift+Tab"' },
|
|
247
|
+
target: { ...TARGET, description: 'Elemento donde pulsar (opcional: por defecto el que tiene el foco)' },
|
|
248
|
+
times: { type: 'integer', description: 'Repeticiones (1-20), p. ej. ArrowDown 3 veces' },
|
|
249
|
+
},
|
|
250
|
+
required: ['key'],
|
|
251
|
+
},
|
|
252
|
+
enabled: () => !!pointer,
|
|
253
|
+
confirm: (args, { context }) => {
|
|
254
|
+
if (!/^(enter|return)$/i.test(args.key.trim())) return false;
|
|
255
|
+
const el = args.target ? context.resolve(args.target) : document.activeElement;
|
|
256
|
+
return submits(el) ? submitConfirm(el, t('widget.confirm.enterSubmit', { label: labelOf(el) })) : false;
|
|
257
|
+
},
|
|
258
|
+
handler: async ({ key, target, times = 1 }, { context }) => {
|
|
259
|
+
if (!/^((ctrl|control|shift|alt|meta|cmd)\+)*[\w ]{1,12}$/i.test(key.trim())) return { ok: false, error: `Tecla no válida: "${key}"` };
|
|
260
|
+
const el = target ? need(context, target) : null;
|
|
261
|
+
const n = Math.min(20, Math.max(1, Math.floor(times)));
|
|
262
|
+
let focused = null;
|
|
263
|
+
for (let i = 0; i < n; i++) focused = await pointer.press(key, i === 0 ? el : null);
|
|
264
|
+
return { ok: true, data: `Pulsado ${key}${n > 1 ? ` ×${n}` : ''}. Foco en: "${labelOf(focused) || focused?.tagName?.toLowerCase() || 'la página'}".` };
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'drag_and_drop',
|
|
269
|
+
category: 'execution',
|
|
270
|
+
description: 'Arrastra un elemento con el ratón y lo suelta sobre otro (mover tarjetas, archivos a carpetas, reordenar listas).',
|
|
271
|
+
parameters: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
source: { ...TARGET, description: 'Elemento a arrastrar' },
|
|
275
|
+
target: { ...TARGET, description: 'Dónde soltarlo' },
|
|
276
|
+
},
|
|
277
|
+
required: ['source', 'target'],
|
|
278
|
+
},
|
|
279
|
+
enabled: () => !!pointer,
|
|
280
|
+
confirm: (args, { context }) => (context.resolve(args.target)?.dataset?.otsConfirm || false),
|
|
281
|
+
handler: async ({ source, target }, { context }) => {
|
|
282
|
+
const src = need(context, source);
|
|
283
|
+
const dst = need(context, target);
|
|
284
|
+
await pointer.drag(src, dst);
|
|
285
|
+
await sleep(300);
|
|
286
|
+
return { ok: true, data: `Arrastrado "${labelOf(src)}" a "${labelOf(dst)}". Comprueba el resultado con get_page_context.` };
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'scroll',
|
|
291
|
+
category: 'execution',
|
|
292
|
+
description: 'Gira la rueda del ratón: desplaza la página o un panel con scroll propio (listas, tablas, chats) en una dirección.',
|
|
293
|
+
parameters: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
|
297
|
+
amount: { type: 'number', description: 'Pantallas a desplazar (por defecto 1)' },
|
|
298
|
+
target: { ...TARGET, description: 'Panel a desplazar (opcional: por defecto la página)' },
|
|
299
|
+
},
|
|
300
|
+
required: ['direction'],
|
|
301
|
+
},
|
|
302
|
+
enabled: () => !!pointer,
|
|
303
|
+
handler: async ({ direction, amount = 1, target }, { context }) => {
|
|
304
|
+
await pointer.scroll(direction, amount, target ? need(context, target) : null);
|
|
305
|
+
return { ok: true, data: 'Desplazado. Llama a get_page_context para ver lo que hay ahora.' };
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'fill_form',
|
|
310
|
+
category: 'execution',
|
|
311
|
+
description: 'Rellena campos de un formulario con datos que el usuario te dio. Nunca inventes datos personales. No envía el formulario salvo submit=true.',
|
|
312
|
+
parameters: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
fields: {
|
|
316
|
+
type: 'array',
|
|
317
|
+
description: 'Campos a rellenar',
|
|
318
|
+
items: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {
|
|
321
|
+
target: TARGET,
|
|
322
|
+
value: { type: 'string', description: 'Valor. Para checkbox: "true"/"false". Para select: texto u opción.' },
|
|
323
|
+
},
|
|
324
|
+
required: ['target', 'value'],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
submit: { type: 'boolean', description: 'Si true, envía el formulario después (pide confirmación al usuario).' },
|
|
328
|
+
},
|
|
329
|
+
required: ['fields'],
|
|
330
|
+
},
|
|
331
|
+
confirm: (args) => (args.submit ? t('widget.confirm.fillSubmit') : false),
|
|
332
|
+
handler: async ({ fields, submit = false }, { context, ui }) => {
|
|
333
|
+
const report = [];
|
|
334
|
+
let form = null;
|
|
335
|
+
for (const f of fields) {
|
|
336
|
+
const el = context.resolve(f.target);
|
|
337
|
+
if (!el) { report.push(`✗ ${f.target}: no encontrado`); continue; }
|
|
338
|
+
try {
|
|
339
|
+
assertWritable(context, el);
|
|
340
|
+
} catch {
|
|
341
|
+
report.push(`✗ ${f.target}: campo privado, el usuario debe escribirlo`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
if (pointer && isTextField(el)) {
|
|
346
|
+
await pointer.type(el, f.value, { clear: true });
|
|
347
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
348
|
+
} else if (pointer && el.matches('input[type=checkbox], input[type=radio]')) {
|
|
349
|
+
const want = el.type === 'radio' || /^(true|1|s[ií]|yes|on)$/i.test(String(f.value));
|
|
350
|
+
if (el.checked !== want) await pointer.click(el);
|
|
351
|
+
} else {
|
|
352
|
+
if (pointer) await pointer.moveTo(el);
|
|
353
|
+
setFieldValue(el, f.value);
|
|
354
|
+
}
|
|
355
|
+
if (!pointer) ui.highlight(el, { duration: 1200, scroll: false });
|
|
356
|
+
report.push(`✓ ${labelOf(el) || f.target}`);
|
|
357
|
+
form = form || el.form;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
report.push(`✗ ${f.target}: ${err.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (submit) {
|
|
363
|
+
if (!form) return { ok: false, error: 'No encontré el formulario para enviarlo.', data: report };
|
|
364
|
+
form.requestSubmit ? form.requestSubmit() : form.submit();
|
|
365
|
+
report.push('Formulario enviado');
|
|
366
|
+
}
|
|
367
|
+
return { ok: true, data: report.join('\n') };
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// ─────────────── interfaz ───────────────
|
|
372
|
+
{
|
|
373
|
+
name: 'highlight',
|
|
374
|
+
category: 'ui',
|
|
375
|
+
description: 'Resalta visualmente un elemento de la página con un mensaje opcional para guiar al usuario ("haz click aquí").',
|
|
376
|
+
parameters: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
target: TARGET,
|
|
380
|
+
message: { type: 'string', description: 'Texto corto que aparece junto al elemento' },
|
|
381
|
+
seconds: { type: 'number', description: 'Duración (por defecto 6)' },
|
|
382
|
+
},
|
|
383
|
+
required: ['target'],
|
|
384
|
+
},
|
|
385
|
+
handler: async ({ target, message = '', seconds = 6 }, { context, ui }) => {
|
|
386
|
+
ui.highlight(need(context, target), { message, duration: Math.min(30, Math.max(1, seconds)) * 1000 });
|
|
387
|
+
return { ok: true, data: 'Resaltado' };
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: 'show_card',
|
|
392
|
+
category: 'ui',
|
|
393
|
+
description:
|
|
394
|
+
'Despliega una tarjeta de información junto a un elemento de la página (o junto a ti si no das target): ' +
|
|
395
|
+
'título, texto breve, datos clave (p. ej. precio, plazo, límite) y hasta 3 preguntas rápidas que el usuario puede pulsar. ' +
|
|
396
|
+
'Úsala para MOSTRAR información sin que el usuario tenga que leer toda la página. No bloquea: sigue hablando mientras.',
|
|
397
|
+
parameters: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
properties: {
|
|
400
|
+
target: { ...TARGET, description: 'Elemento junto al que aparece (opcional)' },
|
|
401
|
+
title: { type: 'string' },
|
|
402
|
+
content: { type: 'string', description: 'Markdown breve (2-4 líneas o una lista corta)' },
|
|
403
|
+
facts: {
|
|
404
|
+
type: 'array',
|
|
405
|
+
maxItems: 6,
|
|
406
|
+
description: 'Datos clave en formato etiqueta/valor',
|
|
407
|
+
items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['label', 'value'] },
|
|
408
|
+
},
|
|
409
|
+
questions: { type: 'array', maxItems: 3, items: { type: 'string' }, description: 'Preguntas rápidas que el usuario puede pulsar para seguir' },
|
|
410
|
+
tone: { type: 'string', enum: ['info', 'tip', 'warning', 'success'] },
|
|
411
|
+
seconds: { type: 'number', description: 'Se cierra sola tras N segundos (0 = no)' },
|
|
412
|
+
},
|
|
413
|
+
required: ['title'],
|
|
414
|
+
},
|
|
415
|
+
handler: async ({ target, title, content = '', facts = [], questions = [], tone = 'info', seconds = 0 }, { context, ui, agent }) => {
|
|
416
|
+
const el = target ? need(context, target) : null;
|
|
417
|
+
if (el) {
|
|
418
|
+
const r = el.getBoundingClientRect();
|
|
419
|
+
if (r.bottom < 0 || r.top > innerHeight) {
|
|
420
|
+
revealElement(el);
|
|
421
|
+
await sleep(450);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
ui.card({
|
|
425
|
+
title,
|
|
426
|
+
content,
|
|
427
|
+
facts,
|
|
428
|
+
target: el,
|
|
429
|
+
tone,
|
|
430
|
+
duration: Math.min(120, Math.max(0, seconds)) * 1000,
|
|
431
|
+
actions: questions.map((q) => ({ label: q, onClick: () => agent?.ask(q) })),
|
|
432
|
+
});
|
|
433
|
+
return { ok: true, data: 'Tarjeta mostrada' };
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'show_toast',
|
|
438
|
+
category: 'ui',
|
|
439
|
+
description: 'Muestra una notificación breve en la esquina (confirmaciones, avisos).',
|
|
440
|
+
parameters: {
|
|
441
|
+
type: 'object',
|
|
442
|
+
properties: {
|
|
443
|
+
message: { type: 'string' },
|
|
444
|
+
type: { type: 'string', enum: ['info', 'success', 'warning', 'error'] },
|
|
445
|
+
},
|
|
446
|
+
required: ['message'],
|
|
447
|
+
},
|
|
448
|
+
handler: async ({ message, type = 'info' }, { ui }) => {
|
|
449
|
+
ui.toast({ message, type });
|
|
450
|
+
return { ok: true, data: 'Mostrado' };
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: 'show_modal',
|
|
455
|
+
category: 'ui',
|
|
456
|
+
description: 'Abre una ventana emergente con información (markdown) o una plantilla del sitio. Si das "options", espera a que el usuario elija y devuelve su elección.',
|
|
457
|
+
timeoutMs: 180000,
|
|
458
|
+
parameters: {
|
|
459
|
+
type: 'object',
|
|
460
|
+
properties: {
|
|
461
|
+
title: { type: 'string' },
|
|
462
|
+
content: { type: 'string', description: 'Markdown simple (sin HTML)' },
|
|
463
|
+
template: { type: 'string', description: 'Nombre de una plantilla registrada por el sitio (opcional)' },
|
|
464
|
+
data: { type: 'object', description: 'Datos para la plantilla' },
|
|
465
|
+
options: { type: 'array', items: { type: 'string' }, description: 'Botones de elección (máx. 4)' },
|
|
466
|
+
},
|
|
467
|
+
required: ['title'],
|
|
468
|
+
},
|
|
469
|
+
handler: async ({ title, content = '', template, data, options = [] }, { ui }) => {
|
|
470
|
+
const actions = options.slice(0, 4).map((o, i) => ({ label: o, value: o, primary: i === options.length - 1 }));
|
|
471
|
+
if (!actions.length) {
|
|
472
|
+
ui.modal({ title, content, template, data, actions: [{ label: t('widget.dialog.gotIt'), value: 'ok', primary: true }] });
|
|
473
|
+
return { ok: true, data: 'Modal mostrado' };
|
|
474
|
+
}
|
|
475
|
+
const choice = await ui.modal({ title, content, template, data, actions });
|
|
476
|
+
return { ok: true, data: choice ? `El usuario eligió: ${choice}` : 'El usuario cerró sin elegir' };
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: 'open_sidebar',
|
|
481
|
+
category: 'ui',
|
|
482
|
+
description: 'Abre un panel lateral con información extensa (pasos a seguir, comparativas, resultados) sin tapar la página.',
|
|
483
|
+
parameters: {
|
|
484
|
+
type: 'object',
|
|
485
|
+
properties: {
|
|
486
|
+
title: { type: 'string' },
|
|
487
|
+
content: { type: 'string', description: 'Markdown simple' },
|
|
488
|
+
template: { type: 'string' },
|
|
489
|
+
data: { type: 'object' },
|
|
490
|
+
},
|
|
491
|
+
required: ['title'],
|
|
492
|
+
},
|
|
493
|
+
handler: async ({ title, content = '', template, data }, { ui }) => {
|
|
494
|
+
ui.sidebar({ title, content, template, data });
|
|
495
|
+
return { ok: true, data: 'Panel abierto' };
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'close_panels',
|
|
500
|
+
category: 'ui',
|
|
501
|
+
description: 'Cierra modales, paneles laterales y resaltados abiertos por el asistente.',
|
|
502
|
+
parameters: { type: 'object', properties: {} },
|
|
503
|
+
handler: async (_, { ui }) => {
|
|
504
|
+
ui.closeAll();
|
|
505
|
+
return { ok: true, data: 'Cerrado' };
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ───────────────────────────── helpers DOM ─────────────────────────────
|
|
512
|
+
|
|
513
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
514
|
+
|
|
515
|
+
function isTextField(el) {
|
|
516
|
+
return el.tagName === 'TEXTAREA' || el.isContentEditable ||
|
|
517
|
+
(el.tagName === 'INPUT' && /^(text|search|email|url|tel|number)$/.test(el.type || 'text'));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function labelOf(el) {
|
|
521
|
+
if (!el) return '';
|
|
522
|
+
// Campos: su etiqueta, nunca lo escrito dentro. Botones y demás: su texto.
|
|
523
|
+
const field = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) && !/^(submit|button|reset)$/.test(el.type);
|
|
524
|
+
const label = field ? el.labels?.[0]?.innerText : el.innerText || el.value;
|
|
525
|
+
return (el.getAttribute('aria-label') || label || el.placeholder || el.name || '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Asigna un valor como lo haría un usuario, compatible con React/Vue/Angular:
|
|
530
|
+
* usa el setter nativo y dispara input/change para que el framework lo detecte.
|
|
531
|
+
*/
|
|
532
|
+
export function setFieldValue(el, value) {
|
|
533
|
+
const v = String(value);
|
|
534
|
+
if (el.isContentEditable) {
|
|
535
|
+
el.focus();
|
|
536
|
+
el.textContent = v;
|
|
537
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const tag = el.tagName;
|
|
541
|
+
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
542
|
+
const want = el.type === 'radio' ? true : /^(true|1|s[ií]|yes|on)$/i.test(v);
|
|
543
|
+
if (el.checked !== want) el.click();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (tag === 'SELECT') {
|
|
547
|
+
const norm = (s) => s.toLowerCase().trim();
|
|
548
|
+
const opt = [...el.options].find((o) => norm(o.value) === norm(v) || norm(o.text) === norm(v)) ||
|
|
549
|
+
[...el.options].find((o) => norm(o.text).includes(norm(v)));
|
|
550
|
+
if (!opt) throw new Error(`opción "${v}" no existe`);
|
|
551
|
+
setNative(el, HTMLSelectElement.prototype, opt.value);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (tag === 'TEXTAREA') return setNative(el, HTMLTextAreaElement.prototype, v);
|
|
555
|
+
if (tag === 'INPUT') return setNative(el, HTMLInputElement.prototype, v);
|
|
556
|
+
throw new Error('no es un campo editable');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function setNative(el, proto, value) {
|
|
560
|
+
el.focus();
|
|
561
|
+
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value);
|
|
562
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
563
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
564
|
+
el.blur();
|
|
565
|
+
}
|