@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
package/server/llm.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptadores de LLM del proxy. Traducen el formato NEUTRAL del widget a cada proveedor
|
|
3
|
+
* y de vuelta. El proxy es sin estado: el historial viaja en cada petición.
|
|
4
|
+
*
|
|
5
|
+
* Entrada: { system: string, messages: Neutral[], tools: [{name, description, parameters}] }
|
|
6
|
+
* Neutral = {role:'user', content}
|
|
7
|
+
* | {role:'assistant', content, toolCalls?:[{id,name,args}], raw?}
|
|
8
|
+
* | {role:'tool', toolCallId, name, content, isError?}
|
|
9
|
+
*
|
|
10
|
+
* Salida: { text, toolCalls:[{id,name,args}], finishReason, provider, raw? }
|
|
11
|
+
* `raw` (solo Anthropic) son los bloques de contenido tal cual: el widget los devuelve
|
|
12
|
+
* intactos en el siguiente paso del MISMO turno para conservar el razonamiento.
|
|
13
|
+
*
|
|
14
|
+
* Proveedores (LLM_PROVIDER):
|
|
15
|
+
* anthropic → Claude vía @anthropic-ai/sdk (por defecto claude-opus-5-5)
|
|
16
|
+
* openai → Chat Completions vía openai SDK (LLM_BASE_URL para compatibles: DeepSeek, Groq, Ollama…)
|
|
17
|
+
* mock → sin claves, para desarrollo y tests: "/tool nombre {json}"
|
|
18
|
+
*
|
|
19
|
+
* `step()` acepta además `locale` (idioma de la petición) para los textos fijos del proxy:
|
|
20
|
+
* la negativa de Anthropic y las respuestas del mock. Sin él, se usa el idioma del servidor.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { translator } from '../src/i18n/index.js';
|
|
24
|
+
import { serverLocale } from './i18n.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Record<string,string|undefined>} [env] configuración (defecto: process.env). En la plataforma,
|
|
28
|
+
* cada ots trae la suya, con sus claves: el SDK nunca lee el entorno del proceso.
|
|
29
|
+
*/
|
|
30
|
+
export function createLLM(env = process.env) {
|
|
31
|
+
const provider = (env.LLM_PROVIDER || (env.ANTHROPIC_API_KEY ? 'anthropic' : env.OPENAI_API_KEY ? 'openai' : 'mock')).toLowerCase();
|
|
32
|
+
if (provider === 'anthropic') return anthropicProvider(env);
|
|
33
|
+
if (provider === 'openai') return openaiProvider(env);
|
|
34
|
+
return mockProvider();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ───────────────────────────── Anthropic (Claude) ─────────────────────────────
|
|
38
|
+
|
|
39
|
+
function anthropicProvider(env) {
|
|
40
|
+
const model = env.LLM_MODEL || 'claude-opus-5-5';
|
|
41
|
+
const effort = env.LLM_EFFORT || 'low'; // chat en vivo: poca latencia. Sube a medium/high si tus evals lo justifican.
|
|
42
|
+
const maxTokens = Number(env.LLM_MAX_TOKENS || 8000);
|
|
43
|
+
// Reintento automático en otro modelo si el clasificador de seguridad rechaza la petición.
|
|
44
|
+
// No disponible en Bedrock/Vertex/Foundry: pon LLM_FALLBACKS=off allí.
|
|
45
|
+
const useFallbacks = env.LLM_FALLBACKS !== 'off';
|
|
46
|
+
let client;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
name: 'anthropic',
|
|
50
|
+
model,
|
|
51
|
+
async step({ system, messages, tools, locale }) {
|
|
52
|
+
if (!client) {
|
|
53
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
54
|
+
client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY ?? null }); // null: nunca cae al entorno del proceso
|
|
55
|
+
}
|
|
56
|
+
const params = {
|
|
57
|
+
model,
|
|
58
|
+
max_tokens: maxTokens,
|
|
59
|
+
output_config: { effort },
|
|
60
|
+
// System y tools son fijos por sesión → se cachean entre pasos y turnos.
|
|
61
|
+
system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
|
|
62
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description || '', input_schema: normalizeSchema(t.parameters) })),
|
|
63
|
+
tool_choice: { type: 'auto' },
|
|
64
|
+
messages: toAnthropicMessages(messages),
|
|
65
|
+
};
|
|
66
|
+
if (!params.tools.length) {
|
|
67
|
+
delete params.tools;
|
|
68
|
+
delete params.tool_choice;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const call = (p) =>
|
|
72
|
+
useFallbacks
|
|
73
|
+
? client.beta.messages.create({ ...p, betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' })
|
|
74
|
+
: client.messages.create(p);
|
|
75
|
+
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await call(params);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Red de seguridad del "preserved thinking": si algún bloque de razonamiento quedó
|
|
81
|
+
// ligado a un prefijo distinto (historial editado), se quitan y se reintenta una vez.
|
|
82
|
+
if (err?.status === 400 && /bound to a different conversation/i.test(String(err?.message))) {
|
|
83
|
+
res = await call({ ...params, messages: stripThinking(params.messages) });
|
|
84
|
+
} else throw err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (res.stop_reason === 'refusal') {
|
|
88
|
+
return {
|
|
89
|
+
text: translator(locale || serverLocale())('server.llm.refusal'),
|
|
90
|
+
toolCalls: [],
|
|
91
|
+
finishReason: 'refusal',
|
|
92
|
+
provider: 'anthropic',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const text = res.content.filter((b) => b.type === 'text').map((b) => b.text).join('').trim();
|
|
97
|
+
let toolCalls = res.content.filter((b) => b.type === 'tool_use').map((b) => ({ id: b.id, name: b.name, args: b.input || {} }));
|
|
98
|
+
// Si se cortó por max_tokens, la última tool puede venir incompleta: no se ejecuta nada.
|
|
99
|
+
if (res.stop_reason === 'max_tokens') toolCalls = [];
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
toolCalls,
|
|
103
|
+
finishReason: res.stop_reason,
|
|
104
|
+
provider: 'anthropic',
|
|
105
|
+
raw: toolCalls.length ? res.content : undefined, // solo hace falta para continuar el loop
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function toAnthropicMessages(messages) {
|
|
112
|
+
const out = [];
|
|
113
|
+
const push = (role, blocks) => {
|
|
114
|
+
if (!blocks.length) return;
|
|
115
|
+
const last = out[out.length - 1];
|
|
116
|
+
// La API exige alternancia user/assistant: se fusionan mensajes seguidos del mismo rol.
|
|
117
|
+
if (last?.role === role) last.content.push(...blocks);
|
|
118
|
+
else out.push({ role, content: [...blocks] });
|
|
119
|
+
};
|
|
120
|
+
for (const m of messages) {
|
|
121
|
+
if (m.role === 'user') {
|
|
122
|
+
push('user', [{ type: 'text', text: String(m.content || '(vacío)') }]);
|
|
123
|
+
} else if (m.role === 'assistant') {
|
|
124
|
+
if (Array.isArray(m.raw) && m.raw.length) {
|
|
125
|
+
push('assistant', m.raw); // tal cual, incluidos los bloques de thinking
|
|
126
|
+
} else {
|
|
127
|
+
const blocks = [];
|
|
128
|
+
if (m.content) blocks.push({ type: 'text', text: String(m.content) });
|
|
129
|
+
for (const c of m.toolCalls || []) blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.args || {} });
|
|
130
|
+
push('assistant', blocks);
|
|
131
|
+
}
|
|
132
|
+
} else if (m.role === 'tool') {
|
|
133
|
+
push('user', [{ type: 'tool_result', tool_use_id: m.toolCallId, content: String(m.content ?? ''), ...(m.isError ? { is_error: true } : {}) }]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Los tool_result deben ir ANTES que el texto dentro de un mismo mensaje de usuario.
|
|
137
|
+
for (const m of out) {
|
|
138
|
+
if (m.role === 'user') m.content.sort((a, b) => (a.type === 'tool_result' ? 0 : 1) - (b.type === 'tool_result' ? 0 : 1));
|
|
139
|
+
}
|
|
140
|
+
// Breakpoint de caché al final: los pasos siguientes del mismo turno reutilizan el prefijo.
|
|
141
|
+
const lastBlock = out[out.length - 1]?.content.at(-1);
|
|
142
|
+
if (lastBlock && out[out.length - 1].role === 'user') lastBlock.cache_control = { type: 'ephemeral' };
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stripThinking(messages) {
|
|
147
|
+
return messages.map((m) =>
|
|
148
|
+
m.role === 'assistant' ? { ...m, content: m.content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking') } : m,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ───────────────────────────── OpenAI (y compatibles) ─────────────────────────────
|
|
153
|
+
|
|
154
|
+
function openaiProvider(env) {
|
|
155
|
+
const model = env.LLM_MODEL || 'gpt-4.1-mini';
|
|
156
|
+
let client;
|
|
157
|
+
return {
|
|
158
|
+
name: 'openai',
|
|
159
|
+
model,
|
|
160
|
+
async step({ system, messages, tools }) {
|
|
161
|
+
if (!client) {
|
|
162
|
+
const { default: OpenAI } = await import('openai');
|
|
163
|
+
client = new OpenAI({ apiKey: env.OPENAI_API_KEY ?? null, baseURL: env.LLM_BASE_URL || undefined });
|
|
164
|
+
}
|
|
165
|
+
const res = await client.chat.completions.create({
|
|
166
|
+
model,
|
|
167
|
+
messages: [{ role: 'system', content: system }, ...toOpenAIMessages(messages)],
|
|
168
|
+
...(tools.length
|
|
169
|
+
? {
|
|
170
|
+
tools: tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: normalizeSchema(t.parameters) } })),
|
|
171
|
+
tool_choice: 'auto',
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
174
|
+
});
|
|
175
|
+
const choice = res.choices[0];
|
|
176
|
+
const msg = choice.message || {};
|
|
177
|
+
return {
|
|
178
|
+
text: (msg.content || '').trim(),
|
|
179
|
+
toolCalls: (msg.tool_calls || [])
|
|
180
|
+
.filter((c) => c.type === 'function')
|
|
181
|
+
.map((c) => ({ id: c.id, name: c.function.name, args: safeJson(c.function.arguments) })),
|
|
182
|
+
finishReason: choice.finish_reason,
|
|
183
|
+
provider: 'openai',
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toOpenAIMessages(messages) {
|
|
190
|
+
return messages.map((m) => {
|
|
191
|
+
if (m.role === 'assistant') {
|
|
192
|
+
return {
|
|
193
|
+
role: 'assistant',
|
|
194
|
+
content: m.content || null,
|
|
195
|
+
...(m.toolCalls?.length
|
|
196
|
+
? { tool_calls: m.toolCalls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.args || {}) } })) }
|
|
197
|
+
: {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (m.role === 'tool') return { role: 'tool', tool_call_id: m.toolCallId, content: String(m.content ?? '') };
|
|
201
|
+
return { role: 'user', content: String(m.content || '') };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ───────────────────────────── Mock (sin claves) ─────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Proveedor falso y determinista para desarrollar la UI y probar acciones sin gastar:
|
|
209
|
+
* "/tool highlight {"target":"#precios","message":"Aquí"}" → llama a esa herramienta
|
|
210
|
+
* "precios" / "persona" / "correo" / "saldo" → atajos de la demo
|
|
211
|
+
* eventos proactivos → saluda la primera vez, NOOP el resto
|
|
212
|
+
*/
|
|
213
|
+
function mockProvider() {
|
|
214
|
+
return {
|
|
215
|
+
name: 'mock',
|
|
216
|
+
model: 'mock',
|
|
217
|
+
async step({ messages, tools, locale }) {
|
|
218
|
+
const t = translator(locale || serverLocale());
|
|
219
|
+
const last = messages[messages.length - 1];
|
|
220
|
+
const has = (n) => tools.some((t) => t.name === n);
|
|
221
|
+
const reply = (text, toolCalls = []) => ({ text, toolCalls, finishReason: toolCalls.length ? 'tool_use' : 'end_turn', provider: 'mock' });
|
|
222
|
+
const call = (name, args) => [{ id: `mock_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`, name, args }];
|
|
223
|
+
|
|
224
|
+
// Guiones de varios pasos (demo del panel): el paso = nº de resultados desde el último mensaje del usuario.
|
|
225
|
+
const lastUserIdx = messages.findLastIndex((m) => m.role === 'user' && !String(m.content).includes('<evento_del_sistema>'));
|
|
226
|
+
const userBody = String(messages[lastUserIdx]?.content || '').split('</user_state>').pop().trim();
|
|
227
|
+
const script = (last.role === 'tool' || messages.length - 1 === lastUserIdx) && panelScript(userBody, has, t);
|
|
228
|
+
if (script) {
|
|
229
|
+
const done = messages.slice(lastUserIdx + 1).filter((m) => m.role === 'tool');
|
|
230
|
+
if (done.at(-1)?.isError) return reply(t('server.mock.failed', { name: done.at(-1).name, error: String(done.at(-1).content).slice(0, 200) }));
|
|
231
|
+
if (done.length < script.steps.length) {
|
|
232
|
+
const [name, args, say = ''] = script.steps[done.length];
|
|
233
|
+
return reply(say, call(name, args));
|
|
234
|
+
}
|
|
235
|
+
return reply(script.end);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (last.role === 'tool') {
|
|
239
|
+
// Tras una intervención proactiva, nada más que decir (ya lo dijo el bocadillo o la tarjeta).
|
|
240
|
+
if (String(messages.findLast((m) => m.role === 'user')?.content || '').includes('<evento_del_sistema>')) return reply('');
|
|
241
|
+
if (!last.isError && /^(tour|point_at|go_home)$/.test(last.name)) return reply(t('server.mock.moreHelp'));
|
|
242
|
+
if (last.name === 'llamar_por_apuchat') return reply(last.isError ? t('server.mock.callFailed', { error: last.content }) : t('server.mock.calling'));
|
|
243
|
+
const short = String(last.content).slice(0, 300);
|
|
244
|
+
return reply(last.isError ? t('server.mock.failed', { name: last.name, error: short }) : t('server.mock.done', { name: last.name, result: short.length < 120 ? short : '' }).trim());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const content = String(last.content || '');
|
|
248
|
+
const body = content.split('</user_state>').pop().trim();
|
|
249
|
+
if (body.startsWith('<evento_del_sistema>')) {
|
|
250
|
+
if (/primera vez/.test(body) && has('tour'))
|
|
251
|
+
return reply(t('server.mock.helloCompanion'));
|
|
252
|
+
if (/primera vez/.test(body) && has('panel_obtener_estado'))
|
|
253
|
+
return reply(t('server.mock.helloPanel'));
|
|
254
|
+
if (/primera vez/.test(body)) return reply(t('server.mock.hello'));
|
|
255
|
+
if (/navegación a .* terminó/.test(body)) return reply(t('server.mock.newPage'));
|
|
256
|
+
const pro = proactiveIntent(body, has, t);
|
|
257
|
+
if (pro) return reply(pro[0], call(pro[1], pro[2]));
|
|
258
|
+
return reply('NOOP');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const m = body.match(/^\/tool\s+([\w-]+)\s*(\{[\s\S]*\})?\s*$/);
|
|
262
|
+
if (m) return reply('', call(m[1], m[2] ? safeJson(m[2]) : {}));
|
|
263
|
+
const q = body.toLowerCase();
|
|
264
|
+
const buddy = has('tour') && companionIntent(q, has, t);
|
|
265
|
+
if (buddy) return reply(buddy[0], call(buddy[1], buddy[2]));
|
|
266
|
+
if (/precio|pre[çc]o|plan|cu[aá]nto cuesta|price|pricing/.test(q) && has('highlight')) return reply(t('server.mock.showPrices'), call('highlight', { target: '#precios', message: t('server.mock.pricesHere') }));
|
|
267
|
+
if (/ll[aá]mame|llamada|hablar por voz|call me|me liga|ligar/.test(q) && has('llamar_por_apuchat')) return reply(t('server.mock.callingNow'), call('llamar_por_apuchat', {}));
|
|
268
|
+
if (/saldo|balance/.test(q) && has('consultar_saldo')) return reply('', call('consultar_saldo', {}));
|
|
269
|
+
if (/persona|humano|agente real|person|human|pessoa/.test(q) && has('escalate_to_human')) return reply('', call('escalate_to_human', { reason: t('server.mock.escalateReason'), summary: body.slice(0, 300) }));
|
|
270
|
+
if (/correo|email|mensaje|message|mensagem/.test(q) && has('send_email_ticket')) return reply('', call('send_email_ticket', { subject: t('server.mock.emailSubject'), message: body.slice(0, 500), category: t('server.mock.emailCategory') }));
|
|
271
|
+
return reply(t('server.mock.echo', { text: body.slice(0, 120) }));
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Proactividad (demo): en nivel atrevido, el mock enseña cosas por su cuenta. */
|
|
277
|
+
function proactiveIntent(body, has, t) {
|
|
278
|
+
const bold = /iniciativa ALTO/.test(body);
|
|
279
|
+
const target = body.match(/\(elemento: ([^)]+)\)/)?.[1];
|
|
280
|
+
const point = (sel, msg) => (has('point_at') ? [msg, 'point_at', { target: sel, message: msg }] : ['', 'highlight', { target: sel, message: msg, seconds: 5 }]);
|
|
281
|
+
if (/varios clicks seguidos/.test(body) && target) return point(target, t('server.mock.pro.clicks'));
|
|
282
|
+
if (!bold || !has('show_card')) return null;
|
|
283
|
+
if (/acaba de entrar al sitio/.test(body))
|
|
284
|
+
return [t('server.mock.pro.welcomeSay'), 'show_card', { target: 'h1', title: t('server.mock.pro.welcomeTitle'), content: t('server.mock.pro.welcomeContent'), questions: [t('server.mock.pro.qTour'), t('server.mock.pro.qPrices')] }];
|
|
285
|
+
const section = body.match(/llegar a la sección "([^"]+)"/)?.[1];
|
|
286
|
+
if (section && target) {
|
|
287
|
+
if (/precio|plan|starter|pro\b|business/i.test(section))
|
|
288
|
+
return [
|
|
289
|
+
t('server.mock.pro.plansSay'),
|
|
290
|
+
'show_card',
|
|
291
|
+
{
|
|
292
|
+
target,
|
|
293
|
+
title: t('server.mock.pro.plansTitle'),
|
|
294
|
+
facts: [
|
|
295
|
+
{ label: 'Starter', value: t('server.mock.pro.perUser', { price: 5 }) },
|
|
296
|
+
{ label: 'Pro', value: t('server.mock.pro.perUser', { price: 12 }) },
|
|
297
|
+
{ label: 'Business', value: t('server.mock.pro.perUser', { price: 25 }) },
|
|
298
|
+
{ label: t('server.mock.pro.trial'), value: t('server.mock.pro.trialValue') },
|
|
299
|
+
],
|
|
300
|
+
questions: [t('server.mock.pro.qWhich')],
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
return point(target, t('server.mock.pro.section', { section }));
|
|
304
|
+
}
|
|
305
|
+
const selected = body.match(/«([^»]+)»/)?.[1];
|
|
306
|
+
if (selected)
|
|
307
|
+
return ['', 'show_card', { target, title: t('server.mock.pro.selTitle'), content: t('server.mock.pro.selContent', { text: selected.slice(0, 80) }), questions: [t('server.mock.pro.qSimpler')], tone: 'tip' }];
|
|
308
|
+
if (/sin decidirse a pulsarlo/.test(body) && target) return point(target, t('server.mock.pro.hesitate'));
|
|
309
|
+
if (/quieto en esta parte/.test(body))
|
|
310
|
+
return ['', 'show_card', { title: t('server.mock.pro.tipTitle'), content: t('server.mock.pro.tipContent'), tone: 'tip', seconds: 12 }];
|
|
311
|
+
if (/salido por arriba/.test(body))
|
|
312
|
+
return [t('server.mock.pro.exitSay'), 'show_card', { title: t('server.mock.pro.exitTitle'), content: t('server.mock.pro.exitContent'), questions: [t('server.mock.pro.qDoubt')], seconds: 15 }];
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Modo compañero (demo): recorrido por la página o señalar algo conocido. */
|
|
317
|
+
function companionIntent(q, has, t) {
|
|
318
|
+
const panel = has('panel_obtener_estado');
|
|
319
|
+
if (/ens[eé]ñame la p[aá]gina|recorrido|tour|vis[ií]ta guiada|qu[eé] hay aqu[ií]|show me the page|mostre a p[aá]gina/.test(q)) {
|
|
320
|
+
const steps = panel
|
|
321
|
+
? [
|
|
322
|
+
{ target: '#tab-resumen', message: t('server.mock.tour.panelSummary') },
|
|
323
|
+
{ target: '#tab-archivos', message: t('server.mock.tour.panelFiles') },
|
|
324
|
+
{ target: '#tab-equipo', message: t('server.mock.tour.panelTeam') },
|
|
325
|
+
{ target: '#tab-ajustes', message: t('server.mock.tour.panelSettings') },
|
|
326
|
+
{ target: '#btn-login', message: t('server.mock.tour.panelLogin') },
|
|
327
|
+
]
|
|
328
|
+
: [
|
|
329
|
+
{ target: 'h1', message: t('server.mock.tour.siteWelcome') },
|
|
330
|
+
{ target: '#funciones', message: t('server.mock.tour.siteFeatures') },
|
|
331
|
+
{ target: '#precios', message: t('server.mock.tour.sitePrices') },
|
|
332
|
+
{ target: '#faq', message: t('server.mock.tour.siteFaq') },
|
|
333
|
+
{ target: '#contacto', message: t('server.mock.tour.siteContact') },
|
|
334
|
+
];
|
|
335
|
+
return [t('server.mock.tour.go'), 'tour', { steps }];
|
|
336
|
+
}
|
|
337
|
+
const m = q.match(/(?:d[oó]nde (?:est[aá]n?|queda|puedo)|se[ñn]ala(?:me)?|mu[eé]strame d[oó]nde)\s+(.+?)[?¿!.]*$/);
|
|
338
|
+
if (!m) return null;
|
|
339
|
+
const known = [
|
|
340
|
+
[/precio|plan/, '#precios', 'prices'],
|
|
341
|
+
[/login|sesi[oó]n|entrar/, '#btn-login', 'login'],
|
|
342
|
+
[/contacto|escrib/, '#contacto', 'contact'],
|
|
343
|
+
[/faq|pregunta|duda/, '#faq', 'faq'],
|
|
344
|
+
[/funci/, '#funciones', 'features'],
|
|
345
|
+
[/ajuste|configura|tema/, '#tab-ajustes', 'settings'],
|
|
346
|
+
[/archivo|carpeta/, '#tab-archivos', 'files'],
|
|
347
|
+
[/equipo|invita|miembro/, '#tab-equipo', 'team'],
|
|
348
|
+
[/busca/, '#buscar', 'search'],
|
|
349
|
+
].find(([re]) => re.test(m[1]));
|
|
350
|
+
return known ? ['', 'point_at', { target: known[1], message: t(`server.mock.point.${known[2]}`) }] : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Frases de la demo del panel → secuencia de herramientas [nombre, args, texto]. */
|
|
354
|
+
function panelScript(body, has, t) {
|
|
355
|
+
if (!has('panel_obtener_estado')) return null;
|
|
356
|
+
const q = body.toLowerCase();
|
|
357
|
+
const mouse = /ens[eé]ñame|mu[eé]strame|con el rat[oó]n|a mano|c[oó]mo se/.test(q);
|
|
358
|
+
const tab = (id) => ['click', { target: `#tab-${id}` }];
|
|
359
|
+
let m;
|
|
360
|
+
if ((m = q.match(/\b(modo|tema)\s+(oscuro|claro|sistema)/))) {
|
|
361
|
+
const tema = m[2];
|
|
362
|
+
if (mouse)
|
|
363
|
+
return {
|
|
364
|
+
steps: [tab('ajustes'), ['click', { target: `input[name=tema][value=${tema}]` }], ['click', { target: '#form-ajustes [type=submit]' }]],
|
|
365
|
+
end: t('server.mock.panel.themeMouse', { theme: tema }),
|
|
366
|
+
};
|
|
367
|
+
return { steps: [['panel_actualizar_ajustes', { tema }]], end: t('server.mock.panel.themeDone', { theme: tema }) };
|
|
368
|
+
}
|
|
369
|
+
if ((m = body.match(/carpeta\s+(?:llamada\s+|que se llame\s+)?[«"']?([\p{L}\p{N} _-]{1,40}?)[»"']?\s*$/iu)) && /crea|nueva|haz/.test(q)) {
|
|
370
|
+
const nombre = m[1].trim();
|
|
371
|
+
if (mouse)
|
|
372
|
+
return {
|
|
373
|
+
steps: [tab('archivos'), ['click', { target: '#btn-nueva-carpeta' }, t('server.mock.panel.openFolder')], ['type_text', { target: '#nombre-carpeta', text: nombre }], ['press_key', { key: 'Enter', target: '#nombre-carpeta' }]],
|
|
374
|
+
end: t('server.mock.panel.folderMouse', { name: nombre }),
|
|
375
|
+
};
|
|
376
|
+
return { steps: [['panel_crear_carpeta', { nombre }]], end: t('server.mock.panel.folderDone', { name: nombre }) };
|
|
377
|
+
}
|
|
378
|
+
if ((m = body.match(/(?:mueve|arrastra|pasa)\s+(?:el archivo\s+)?(\S+\.\w{2,5})\s+a\s+(?:la carpeta\s+)?(.+?)\s*$/i))) {
|
|
379
|
+
const [, archivo, carpeta] = m;
|
|
380
|
+
const folder = carpeta.charAt(0).toUpperCase() + carpeta.slice(1);
|
|
381
|
+
return {
|
|
382
|
+
steps: [tab('archivos'), ['drag_and_drop', { source: `[data-file="${archivo}"]`, target: `[data-folder="${folder}"]` }, t('server.mock.panel.dragging', { file: archivo, folder })]],
|
|
383
|
+
end: t('server.mock.panel.moved', { file: archivo, folder }),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if ((m = body.match(/busca\s+(.+?)\s*$/i)))
|
|
387
|
+
return { steps: [tab('archivos'), ['type_text', { target: '#buscar', text: m[1], clear: true }]], end: t('server.mock.panel.filtered', { query: m[1] }) };
|
|
388
|
+
if ((m = body.match(/invita\s+a\s+(\S+@\S+?)(?:\s+como\s+(lector|editor|admin))?\s*$/i)) && has('invitar_miembro'))
|
|
389
|
+
return { steps: [tab('equipo'), ['invitar_miembro', { email: m[1], rol: m[2] || 'lector' }]], end: t('server.mock.panel.invited', { email: m[1] }) };
|
|
390
|
+
if (/ajustes|configuraci[oó]n/.test(q) && /qu[eé]|c[oó]mo|mis/.test(q))
|
|
391
|
+
return { steps: [['panel_obtener_estado', {}]], end: t('server.mock.panel.state') };
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
396
|
+
|
|
397
|
+
function safeJson(s) {
|
|
398
|
+
try {
|
|
399
|
+
const v = JSON.parse(s || '{}');
|
|
400
|
+
return v && typeof v === 'object' ? v : {};
|
|
401
|
+
} catch {
|
|
402
|
+
return {};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Asegura un JSON Schema de tipo objeto válido para ambos proveedores. */
|
|
407
|
+
function normalizeSchema(schema) {
|
|
408
|
+
const s = schema && typeof schema === 'object' ? { ...schema } : {};
|
|
409
|
+
delete s.$schema;
|
|
410
|
+
if (s.type !== 'object') return { type: 'object', properties: {}, ...(s.type ? {} : s) };
|
|
411
|
+
s.properties ??= {};
|
|
412
|
+
return s;
|
|
413
|
+
}
|