@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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rutas de la plataforma 7ots.com (PLATFORM=true).
|
|
3
|
+
*
|
|
4
|
+
* Públicas, una API de agente por ots (el widget las llama igual que /api/agent/*):
|
|
5
|
+
* GET /api/o/:id/config · /embed.js · /identity · /identity.vcf (cualquier origen)
|
|
6
|
+
* POST /api/o/:id/chat · /tts · /identity/call · /contact/* (solo desde sus dominios)
|
|
7
|
+
*
|
|
8
|
+
* Dashboard (sesión de /auth/*; escrituras con X-7ots-Admin: 1 y mismo origen):
|
|
9
|
+
* GET /api/platform/me
|
|
10
|
+
* GET /api/platform/ots POST (crear)
|
|
11
|
+
* GET /api/platform/ots/:id PATCH { name, status, domains } · DELETE
|
|
12
|
+
* POST /api/platform/ots/:id/duplicate
|
|
13
|
+
* GET /api/platform/ots/:id/conversations[?before] GET|DELETE …/conversations/:cid
|
|
14
|
+
* GET /api/platform/ots/:id/usage
|
|
15
|
+
* * /api/platform/ots/:id/admin/* misma forma que /api/agent/admin/* (el backoffice
|
|
16
|
+
* edita un ots con ?ots=<id>)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { MEET_AVATARS, MEET_SCENES, OPENAI_VOICES, GROK_VOICES, defineIdentity, identityPrompt, publicIdentity, toVCard } from '../identity.mjs';
|
|
20
|
+
import { errorText, reqT, requestLocale } from '../i18n.mjs';
|
|
21
|
+
import { monthOf } from './db.mjs';
|
|
22
|
+
import { randomIdentity, cleanSeed } from '../../src/identity/random.js';
|
|
23
|
+
import { currentAccount } from './auth.mjs';
|
|
24
|
+
import {
|
|
25
|
+
OTS_KEYS, MAX_OTS_PER_ACCOUNT, accountUsage, addUsage, cleanDomains, countOts, createOts, deleteConversation, deleteOts, duplicateOts,
|
|
26
|
+
getConversation, getOts, getOwnOts, listConversations, listOts, logTurn, originAllowed, publicAccount, updateOts, usageByOts, usageHistory,
|
|
27
|
+
} from './store.mjs';
|
|
28
|
+
import { freeMessages, freeTts, otsChannels, otsEnv, otsLlmInfo, otsStep, otsSynthesize, otsTtsProvider, stopOtsChannels } from './runtime.mjs';
|
|
29
|
+
import '../../src/i18n/messages/platform.js';
|
|
30
|
+
|
|
31
|
+
const penv = process.env;
|
|
32
|
+
const OTS_RATE = () => Number(penv.PLATFORM_OTS_RATE_PER_MIN || 120);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} deps helpers HTTP de server.mjs
|
|
36
|
+
*/
|
|
37
|
+
export function createPlatform({ send, readJson, rateLimit, callLimit, originOf, embedScript, bundleVersion, relay, sendApumail, startApuchat }) {
|
|
38
|
+
const selfOrigins = (req) => new Set([originOf(req), penv.PLATFORM_URL && new URL(penv.PLATFORM_URL).origin].filter(Boolean));
|
|
39
|
+
|
|
40
|
+
// ───────────────────────────── API pública de cada ots ─────────────────────────────
|
|
41
|
+
|
|
42
|
+
const otsBuckets = new Map();
|
|
43
|
+
function otsRate(id) {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const rate = OTS_RATE();
|
|
46
|
+
const b = otsBuckets.get(id) || { tokens: rate, at: now };
|
|
47
|
+
b.tokens = Math.min(rate, b.tokens + ((now - b.at) / 60000) * rate);
|
|
48
|
+
b.at = now;
|
|
49
|
+
if (b.tokens < 1) return false;
|
|
50
|
+
b.tokens -= 1;
|
|
51
|
+
otsBuckets.set(id, b);
|
|
52
|
+
if (otsBuckets.size > 10000) otsBuckets.clear();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function otsCors(req, res, ots) {
|
|
57
|
+
const origin = req.headers.origin;
|
|
58
|
+
if (!origin) return false; // el widget siempre manda Origin; sin él no se sirve la IA
|
|
59
|
+
if (!selfOrigins(req).has(origin) && !originAllowed(ots, origin)) return false;
|
|
60
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
61
|
+
res.setHeader('Vary', 'Origin');
|
|
62
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
63
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Site-Key, Accept, Accept-Language');
|
|
64
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function publicApi(req, res, id, path) {
|
|
69
|
+
const t = reqT(req);
|
|
70
|
+
const ots = getOts(id);
|
|
71
|
+
const open = ['/config', '/embed.js', '/identity', '/identity.vcf'].includes(path);
|
|
72
|
+
if (open) {
|
|
73
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
74
|
+
if (req.method === 'OPTIONS') return send(res, 204);
|
|
75
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { error: t('server.http.methodNotAllowed') });
|
|
76
|
+
if (!ots) return send(res, 404, { error: t('platform.api.notFound') });
|
|
77
|
+
if (path === '/config') return send(res, 200, ots.widget);
|
|
78
|
+
if (path === '/embed.js') {
|
|
79
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
80
|
+
if (ots.status !== 'on') return res.end('/* 7ots: ots en pausa */');
|
|
81
|
+
return res.end(embedScript(ots.widget, await bundleVersion(), `/api/o/${ots.id}`));
|
|
82
|
+
}
|
|
83
|
+
const card = publicIdentity(ots.identity, otsChannels(ots).live());
|
|
84
|
+
if (path === '/identity.vcf') {
|
|
85
|
+
res.writeHead(200, { 'Content-Type': 'text/vcard; charset=utf-8', 'Content-Disposition': `inline; filename="${card.id}.vcf"`, 'Cache-Control': 'no-cache' });
|
|
86
|
+
return res.end(toVCard(card, { baseUrl: `${originOf(req)}/api/o/${ots.id}/identity.vcf` }));
|
|
87
|
+
}
|
|
88
|
+
return send(res, 200, card);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!ots) return send(res, 404, { error: t('platform.api.notFound') });
|
|
92
|
+
if (!otsCors(req, res, ots)) return send(res, 403, { error: t('platform.api.originNotAllowed') });
|
|
93
|
+
if (req.method === 'OPTIONS') return send(res, 204);
|
|
94
|
+
if (req.method !== 'POST') return send(res, 405, { error: t('server.http.methodNotAllowed') });
|
|
95
|
+
if (ots.status !== 'on') return send(res, 403, { error: t('platform.api.paused') });
|
|
96
|
+
if (!rateLimit(req)) return send(res, 429, { error: t('server.http.rateLimited') });
|
|
97
|
+
if (!otsRate(ots.id)) return send(res, 429, { error: t('platform.api.otsRateLimited') });
|
|
98
|
+
|
|
99
|
+
if (path === '/chat') {
|
|
100
|
+
const { system, messages, tools = [], session } = await readJson(req, 1024 * 1024);
|
|
101
|
+
if (typeof system !== 'string' || !Array.isArray(messages) || !messages.length || !Array.isArray(tools)) return send(res, 400, { error: t('server.http.badRequest') });
|
|
102
|
+
if (messages.length > 300 || tools.length > 128 || system.length > 30000) return send(res, 413, { error: t('server.http.tooLarge') });
|
|
103
|
+
const pre = [ots.settings.SERVER_INSTRUCTIONS, identityPrompt(ots.identity)].filter(Boolean).join('\n\n');
|
|
104
|
+
let out;
|
|
105
|
+
try {
|
|
106
|
+
out = await otsStep(ots, { system: `${pre}\n\n${system}`, messages, tools, locale: requestLocale(req) });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Sin cupo: el visitante ve un aviso normal en el chat, no un error.
|
|
109
|
+
if (e.status !== 402) throw e;
|
|
110
|
+
out = { text: errorText(e, t), toolCalls: [], finishReason: 'quota', provider: '7ots' };
|
|
111
|
+
}
|
|
112
|
+
const last = messages[messages.length - 1];
|
|
113
|
+
const key = typeof session === 'string' && /^[\w-]{8,64}$/.test(session) ? session : null;
|
|
114
|
+
if (key) {
|
|
115
|
+
const seen = last?.role === 'user' ? visibleTurn(last.content) : { text: '', page: '' };
|
|
116
|
+
const { created } = logTurn(ots, { key, page: seen.page, origin: req.headers.origin || '', user: seen.text, reply: out.text || '' });
|
|
117
|
+
if (created) addUsage(ots, 'conversation');
|
|
118
|
+
}
|
|
119
|
+
return send(res, 200, out);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (path === '/tts') {
|
|
123
|
+
const { text } = await readJson(req, 16 * 1024);
|
|
124
|
+
const { audio, contentType } = await otsSynthesize(ots, { text });
|
|
125
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': audio.length, 'Cache-Control': 'no-store' });
|
|
126
|
+
return res.end(audio);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (path === '/identity/call') {
|
|
130
|
+
if (!callLimit(req)) return send(res, 429, { error: t('server.http.callLimited') });
|
|
131
|
+
const { motivo } = await readJson(req, 4 * 1024);
|
|
132
|
+
const call = await otsChannels(ots).openCall({ reason: String(motivo || '').slice(0, 200) });
|
|
133
|
+
addUsage(ots, 'call');
|
|
134
|
+
return send(res, 200, { call_url: call.call_url, expires_at: call.expires_at });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { env } = otsEnv(ots);
|
|
138
|
+
if (path === '/contact/apumail') return send(res, 200, await sendApumail(await readJson(req, 128 * 1024), env));
|
|
139
|
+
if (path === '/contact/apuchat') return send(res, 200, await startApuchat(await readJson(req, 128 * 1024), env));
|
|
140
|
+
if (path === '/contact/apuchat/send') return send(res, 200, await relay.relaySend(await readJson(req, 16 * 1024)));
|
|
141
|
+
if (path === '/contact/apuchat/wait') return send(res, 200, await relay.relayWait(await readJson(req, 4 * 1024)));
|
|
142
|
+
if (path === '/contact/apuchat/end') return send(res, 200, await relay.relayEnd(await readJson(req, 4 * 1024)));
|
|
143
|
+
send(res, 404, { error: t('server.http.unknownRoute') });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ───────────────────────────── dashboard ─────────────────────────────
|
|
147
|
+
|
|
148
|
+
function otsSummary(ots, usage = {}) {
|
|
149
|
+
const llm = otsLlmInfo(ots);
|
|
150
|
+
return {
|
|
151
|
+
id: ots.id,
|
|
152
|
+
name: ots.name,
|
|
153
|
+
status: ots.status,
|
|
154
|
+
domains: ots.domains,
|
|
155
|
+
identity: { name: ots.identity.name, role: ots.identity.role, look: ots.identity.look },
|
|
156
|
+
llm: `${llm.name}:${llm.model}`,
|
|
157
|
+
platformLlm: llm.platform,
|
|
158
|
+
tts: otsTtsProvider(ots),
|
|
159
|
+
usage,
|
|
160
|
+
createdAt: ots.createdAt,
|
|
161
|
+
updatedAt: ots.updatedAt,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Vista de ajustes con la forma de serverView() (el backoffice la pinta igual). */
|
|
166
|
+
function serverViewOf(ots) {
|
|
167
|
+
const values = {};
|
|
168
|
+
const secrets = {};
|
|
169
|
+
for (const [k, meta] of Object.entries(OTS_KEYS)) {
|
|
170
|
+
if (meta.secret) secrets[k] = { set: !!ots.secrets[k], source: ots.secrets[k] ? 'backoffice' : null, restart: false };
|
|
171
|
+
else values[k] = { value: ots.settings[k] ?? '', source: ots.settings[k] != null ? 'backoffice' : null, restart: false };
|
|
172
|
+
}
|
|
173
|
+
return { values, secrets };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function statusOf(ots) {
|
|
177
|
+
const ch = otsChannels(ots);
|
|
178
|
+
const { env, platformLlm, platformTts } = otsEnv(ots);
|
|
179
|
+
const llm = otsLlmInfo(ots);
|
|
180
|
+
return {
|
|
181
|
+
llm: `${llm.name}:${llm.model}`,
|
|
182
|
+
tts: otsTtsProvider(ots),
|
|
183
|
+
platform: { llm: platformLlm, tts: platformTts },
|
|
184
|
+
channels: ch.status(),
|
|
185
|
+
live: ch.live(),
|
|
186
|
+
contact: {
|
|
187
|
+
apumail: !!(env.APUMAIL_INBOX && env.APUMAIL_INBOX_TOKEN && env.APUMAIL_TO),
|
|
188
|
+
apuchatOperator: !!(env.APUCHAT_NOTIFIER_IDENTITY_KEY && env.APUCHAT_OPERATOR_HANDLE),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Guarda un ots y arranca/para sus canales según haga falta. */
|
|
194
|
+
async function save(ots, patch) {
|
|
195
|
+
const next = updateOts(ots.id, patch);
|
|
196
|
+
if (next.status !== 'on') await stopOtsChannels(next.id);
|
|
197
|
+
else otsChannels(next); // reinicia solo si cambió su configuración de canales
|
|
198
|
+
return next;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function adminApi(req, res, ots, route) {
|
|
202
|
+
const t = reqT(req);
|
|
203
|
+
if (route === '/session' && req.method === 'GET') return send(res, 200, { authed: true, mode: 'platform' });
|
|
204
|
+
if (route === '/login' || route === '/logout') return send(res, 400, { error: t('platform.api.login') });
|
|
205
|
+
if (route === '/state' && req.method === 'GET') {
|
|
206
|
+
return send(res, 200, {
|
|
207
|
+
ots: otsSummary(ots),
|
|
208
|
+
identity: ots.identity,
|
|
209
|
+
widget: ots.widget,
|
|
210
|
+
server: serverViewOf(ots),
|
|
211
|
+
status: statusOf(ots),
|
|
212
|
+
options: { meetAvatars: MEET_AVATARS, meetScenes: MEET_SCENES, openaiVoices: OPENAI_VOICES, grokVoices: GROK_VOICES },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (route === '/identity' && req.method === 'PUT') {
|
|
216
|
+
const identity = defineIdentity(await readJson(req, 256 * 1024), ots.identity);
|
|
217
|
+
return send(res, 200, { identity: (await save(ots, { identity })).identity });
|
|
218
|
+
}
|
|
219
|
+
if (route === '/widget' && req.method === 'PUT') {
|
|
220
|
+
const w = await readJson(req, 256 * 1024);
|
|
221
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return send(res, 400, { error: t('server.admin.invalidWidget') });
|
|
222
|
+
return send(res, 200, { widget: (await save(ots, { widget: sanitizeWidget(w) })).widget });
|
|
223
|
+
}
|
|
224
|
+
if (route === '/server' && req.method === 'PUT') {
|
|
225
|
+
const { values = {}, secrets = {} } = await readJson(req, 256 * 1024);
|
|
226
|
+
const settings = { ...ots.settings };
|
|
227
|
+
const sec = { ...ots.secrets };
|
|
228
|
+
const changed = [];
|
|
229
|
+
for (const [k, v] of Object.entries(values || {})) {
|
|
230
|
+
const meta = OTS_KEYS[k];
|
|
231
|
+
if (!meta || meta.secret) continue;
|
|
232
|
+
const before = settings[k];
|
|
233
|
+
if (v === null || v === '') delete settings[k];
|
|
234
|
+
else settings[k] = String(v).slice(0, 20000);
|
|
235
|
+
if (before !== settings[k]) changed.push(k);
|
|
236
|
+
}
|
|
237
|
+
for (const [k, v] of Object.entries(secrets || {})) {
|
|
238
|
+
const meta = OTS_KEYS[k];
|
|
239
|
+
if (!meta?.secret || v === '') continue;
|
|
240
|
+
const before = sec[k];
|
|
241
|
+
if (v === null) delete sec[k];
|
|
242
|
+
else sec[k] = String(v).trim().slice(0, 4000);
|
|
243
|
+
if (before !== sec[k]) changed.push(k);
|
|
244
|
+
}
|
|
245
|
+
const next = await save(ots, { settings, secrets: sec });
|
|
246
|
+
return send(res, 200, { changed, channels: changed.some((k) => OTS_KEYS[k].channels), restart: [], server: serverViewOf(next), status: statusOf(next) });
|
|
247
|
+
}
|
|
248
|
+
if (route === '/test/llm' && req.method === 'POST') {
|
|
249
|
+
const t0 = Date.now();
|
|
250
|
+
try {
|
|
251
|
+
const out = await otsStep(ots, { system: t('server.admin.testSystem'), messages: [{ role: 'user', content: t('server.admin.testPrompt') }], tools: [], locale: requestLocale(req) });
|
|
252
|
+
const llm = otsLlmInfo(ots);
|
|
253
|
+
return send(res, 200, { ok: true, text: out.text, ms: Date.now() - t0, llm: `${llm.name}:${llm.model}` });
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return send(res, 200, { ok: false, error: errorText(e, t).slice(0, 300), ms: Date.now() - t0 });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (route === '/test/tts' && req.method === 'POST') {
|
|
259
|
+
const { text, voice } = await readJson(req, 16 * 1024);
|
|
260
|
+
const { audio, contentType } = await otsSynthesize(ots, { text: String(text || t('server.admin.ttsSample')).slice(0, 300), voice: voice || ots.identity.voice });
|
|
261
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': audio.length, 'Cache-Control': 'no-store' });
|
|
262
|
+
return res.end(audio);
|
|
263
|
+
}
|
|
264
|
+
if (route === '/status' && req.method === 'GET') return send(res, 200, statusOf(ots));
|
|
265
|
+
send(res, 404, { error: t('server.admin.unknownRoute') });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function dashboardApi(req, res, path) {
|
|
269
|
+
const t = reqT(req);
|
|
270
|
+
const origin = req.headers.origin;
|
|
271
|
+
if (origin && !selfOrigins(req).has(origin)) return send(res, 403, { error: t('server.admin.sameOrigin') });
|
|
272
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
273
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.headers['x-7ots-admin'] !== '1') return send(res, 403, { error: t('server.admin.missingHeader') });
|
|
274
|
+
const account = currentAccount(req);
|
|
275
|
+
if (!account) return send(res, 401, { error: t('platform.api.login') });
|
|
276
|
+
|
|
277
|
+
if (path === '/me' && req.method === 'GET') {
|
|
278
|
+
return send(res, 200, {
|
|
279
|
+
account: publicAccount(account),
|
|
280
|
+
limits: { maxOts: MAX_OTS_PER_ACCOUNT(), freeMessages: freeMessages(), freeTts: freeTts(), platformLlm: !!process.env.PLATFORM_ANTHROPIC_API_KEY },
|
|
281
|
+
usage: { month: monthOf(), llm_platform: accountUsage(account.id, 'llm_platform'), tts_platform: accountUsage(account.id, 'tts_platform') },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (path === '/ots' && req.method === 'GET') {
|
|
286
|
+
const usage = usageByOts(account.id);
|
|
287
|
+
return send(res, 200, { ots: listOts(account.id).map((o) => otsSummary(o, usage[o.id] || {})) });
|
|
288
|
+
}
|
|
289
|
+
if (path === '/ots' && req.method === 'POST') {
|
|
290
|
+
if (countOts(account.id) >= MAX_OTS_PER_ACCOUNT()) return send(res, 403, { error: t('platform.api.tooManyOts', { max: MAX_OTS_PER_ACCOUNT() }) });
|
|
291
|
+
const { name, language, seed, otName } = await readJson(req, 16 * 1024);
|
|
292
|
+
const lang = ['es', 'en', 'pt'].includes(language) ? language : requestLocale(req);
|
|
293
|
+
// From the 7ots.com hero: the same ot the visitor just rolled (seed + optional name).
|
|
294
|
+
const rolled = cleanSeed(seed) ? randomIdentity(cleanSeed(seed), { lang, name: String(otName || '').trim().slice(0, 40) || undefined }) : null;
|
|
295
|
+
const identity = rolled ? { ...STARTER[lang], ...rolled } : { language: lang, voice: { lang: { es: 'es-ES', en: 'en-US', pt: 'pt-BR' }[lang] }, ...STARTER[lang] };
|
|
296
|
+
const ots = createOts(account.id, { name, identity });
|
|
297
|
+
return send(res, 201, { ots: otsSummary(ots) });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const m = path.match(/^\/ots\/([a-z0-9_]{4,40})(\/.*)?$/);
|
|
301
|
+
if (!m) return send(res, 404, { error: t('server.http.unknownRoute') });
|
|
302
|
+
const ots = getOwnOts(account.id, m[1]);
|
|
303
|
+
if (!ots) return send(res, 404, { error: t('platform.api.notFound') });
|
|
304
|
+
const sub = m[2] || '';
|
|
305
|
+
|
|
306
|
+
if (sub.startsWith('/admin/')) return adminApi(req, res, ots, sub.slice('/admin'.length));
|
|
307
|
+
|
|
308
|
+
if (sub === '' && req.method === 'GET') return send(res, 200, { ots: otsSummary(ots, usageByOts(account.id)[ots.id] || {}) });
|
|
309
|
+
if (sub === '' && req.method === 'PATCH') {
|
|
310
|
+
const body = await readJson(req, 16 * 1024);
|
|
311
|
+
const patch = {};
|
|
312
|
+
if (typeof body.name === 'string') patch.name = body.name;
|
|
313
|
+
if (body.status === 'on' || body.status === 'off') patch.status = body.status;
|
|
314
|
+
if (body.domains !== undefined) {
|
|
315
|
+
const list = Array.isArray(body.domains) ? body.domains : String(body.domains).split(/[\s,]+/).filter(Boolean);
|
|
316
|
+
patch.domains = cleanDomains(list);
|
|
317
|
+
if (patch.domains.length < list.length) return send(res, 400, { error: t('platform.api.badDomains') });
|
|
318
|
+
}
|
|
319
|
+
return send(res, 200, { ots: otsSummary(await save(ots, patch)) });
|
|
320
|
+
}
|
|
321
|
+
if (sub === '' && req.method === 'DELETE') {
|
|
322
|
+
await stopOtsChannels(ots.id);
|
|
323
|
+
deleteOts(ots.id);
|
|
324
|
+
return send(res, 200, { ok: true });
|
|
325
|
+
}
|
|
326
|
+
if (sub === '/duplicate' && req.method === 'POST') {
|
|
327
|
+
if (countOts(account.id) >= MAX_OTS_PER_ACCOUNT()) return send(res, 403, { error: t('platform.api.tooManyOts', { max: MAX_OTS_PER_ACCOUNT() }) });
|
|
328
|
+
return send(res, 201, { ots: otsSummary(duplicateOts(account.id, ots)) });
|
|
329
|
+
}
|
|
330
|
+
if (sub === '/usage' && req.method === 'GET') return send(res, 200, { months: usageHistory(ots.id) });
|
|
331
|
+
if (sub === '/conversations' && req.method === 'GET') {
|
|
332
|
+
const url = new URL(req.url, 'http://x');
|
|
333
|
+
return send(res, 200, { conversations: listConversations(ots.id, { before: url.searchParams.get('before'), limit: url.searchParams.get('limit') }) });
|
|
334
|
+
}
|
|
335
|
+
const c = sub.match(/^\/conversations\/(c_[A-Za-z0-9_-]{10,40})$/);
|
|
336
|
+
if (c && req.method === 'GET') {
|
|
337
|
+
const conv = getConversation(ots.id, c[1]);
|
|
338
|
+
return conv ? send(res, 200, { conversation: conv }) : send(res, 404, { error: t('server.http.notFound') });
|
|
339
|
+
}
|
|
340
|
+
if (c && req.method === 'DELETE') return send(res, 200, { ok: deleteConversation(ots.id, c[1]) });
|
|
341
|
+
send(res, 404, { error: t('server.http.unknownRoute') });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @returns {Promise<boolean>} true si atendió la ruta */
|
|
345
|
+
return async function handle(req, res, url) {
|
|
346
|
+
const p = url.pathname;
|
|
347
|
+
const m = p.match(/^\/api\/o\/([a-z0-9_]{4,40})(\/.*)$/);
|
|
348
|
+
if (m) {
|
|
349
|
+
await publicApi(req, res, m[1], m[2]);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
if (p.startsWith('/api/platform/')) {
|
|
353
|
+
await dashboardApi(req, res, p.slice('/api/platform'.length));
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Rol y tono de partida de un ots nuevo, en su idioma (los defectos del esquema son en español). */
|
|
361
|
+
const STARTER = {
|
|
362
|
+
es: { role: 'asistente virtual', personality: { tone: 'cercano y claro' } },
|
|
363
|
+
en: { role: 'virtual assistant', personality: { tone: 'friendly and clear' } },
|
|
364
|
+
pt: { role: 'assistente virtual', personality: { tone: 'próximo e claro' } },
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Lo que de verdad escribió el visitante en un mensaje del widget: sin el <page_context> ni el
|
|
369
|
+
* <user_state> que añade AgentBrain. Los eventos proactivos (<evento_del_sistema>) y los
|
|
370
|
+
* resultados de herramientas (contenido no textual) no son texto del visitante.
|
|
371
|
+
*/
|
|
372
|
+
function visibleTurn(content) {
|
|
373
|
+
if (typeof content !== 'string') return { text: '', page: '' };
|
|
374
|
+
const page = /<page_context>[\s\S]*?^URL: (\S+)/m.exec(content)?.[1] || '';
|
|
375
|
+
if (content.includes('<evento_del_sistema>')) return { text: '', page };
|
|
376
|
+
const text = content
|
|
377
|
+
.replace(/<page_context>[\s\S]*?<\/page_context>/g, '')
|
|
378
|
+
.replace(/<user_state>[\s\S]*?<\/user_state>/g, '')
|
|
379
|
+
.trim();
|
|
380
|
+
return { text, page: page.slice(0, 500) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Igual que settings.mjs: solo datos y nada que tenga que fijar el servidor. */
|
|
384
|
+
function sanitizeWidget(w) {
|
|
385
|
+
const out = JSON.parse(JSON.stringify(w));
|
|
386
|
+
for (const k of ['endpoint', 'llm', 'auth', 'templates', 'actions', 'identity']) delete out[k];
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime de cada ots en la plataforma: su entorno, su LLM, su voz y sus canales.
|
|
3
|
+
*
|
|
4
|
+
* Cada ots tiene un "entorno" propio (un objeto, nunca process.env) hecho de:
|
|
5
|
+
* 1. lo que fija la plataforma (hub de apuchat, API de apumail, servicio de voz),
|
|
6
|
+
* 2. sus ajustes y claves (solo las de OTS_KEYS),
|
|
7
|
+
* 3. si no trae clave de IA o de voz, la de la plataforma, con cupo gratis por cuenta y mes:
|
|
8
|
+
* PLATFORM_ANTHROPIC_API_KEY · PLATFORM_LLM_MODEL (defecto claude-sonnet-5-5)
|
|
9
|
+
* PLATFORM_FREE_MESSAGES respuestas de IA gratis por cuenta y mes (defecto 100)
|
|
10
|
+
* PLATFORM_APUCHAT_VOICE_TOKEN · PLATFORM_FREE_TTS (audios gratis por cuenta y mes, defecto 100)
|
|
11
|
+
* Así un ots nunca ve las claves del proceso ni las de otro ots.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createLLM } from '../llm.mjs';
|
|
15
|
+
import { synthesize as synthesizeWith, ttsProvider } from '../tts.mjs';
|
|
16
|
+
import { startAgentChannels } from '../channels/index.mjs';
|
|
17
|
+
import { apumailConfigFromEnv } from '../channels/apumail.mjs';
|
|
18
|
+
import { apuchatConfigFromEnv } from '../channels/apuchat.mjs';
|
|
19
|
+
import { i18nError } from '../i18n.mjs';
|
|
20
|
+
import { OTS_KEYS, accountUsage, addUsage, allLiveOts, getOts } from './store.mjs';
|
|
21
|
+
|
|
22
|
+
const penv = process.env;
|
|
23
|
+
const FIXED = ['APUCHAT_HUB', 'APUMAIL_API', 'APUCHAT_VOICE_URL'];
|
|
24
|
+
const LLM_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
|
25
|
+
const TTS_KEYS = ['APUCHAT_VOICE_TOKEN', 'ELEVENLABS_API_KEY', 'XAI_API_KEY', 'FISH_API_KEY', 'OPENAI_API_KEY'];
|
|
26
|
+
|
|
27
|
+
export const freeMessages = () => Number(penv.PLATFORM_FREE_MESSAGES ?? 100);
|
|
28
|
+
export const freeTts = () => Number(penv.PLATFORM_FREE_TTS ?? 100);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Entorno del ots.
|
|
32
|
+
* @returns {{ env: object, platformLlm: boolean, platformTts: boolean }}
|
|
33
|
+
*/
|
|
34
|
+
export function otsEnv(ots) {
|
|
35
|
+
const env = {};
|
|
36
|
+
for (const k of FIXED) if (penv[k]) env[k] = penv[k];
|
|
37
|
+
for (const [k, meta] of Object.entries(OTS_KEYS)) {
|
|
38
|
+
const v = meta.secret ? ots.secrets[k] : ots.settings[k];
|
|
39
|
+
if (v == null || v === '') continue;
|
|
40
|
+
env[k] = meta.max ? String(Math.min(meta.max, Math.max(0, Number(v) || 0))) : String(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// IA: la suya o la de la plataforma (modelo fijo para controlar el coste).
|
|
44
|
+
let platformLlm = false;
|
|
45
|
+
const provider = (env.LLM_PROVIDER || '').toLowerCase();
|
|
46
|
+
const ownLlm = provider === 'openai' ? !!env.OPENAI_API_KEY : provider === 'anthropic' ? !!env.ANTHROPIC_API_KEY : LLM_KEYS.some((k) => env[k]);
|
|
47
|
+
if (provider === 'mock') {
|
|
48
|
+
// Modo de pruebas: sin IA real.
|
|
49
|
+
} else if (!ownLlm && penv.PLATFORM_ANTHROPIC_API_KEY) {
|
|
50
|
+
platformLlm = true;
|
|
51
|
+
Object.assign(env, { LLM_PROVIDER: 'anthropic', ANTHROPIC_API_KEY: penv.PLATFORM_ANTHROPIC_API_KEY, LLM_MODEL: penv.PLATFORM_LLM_MODEL || 'claude-sonnet-5-5', LLM_EFFORT: 'low', LLM_MAX_TOKENS: '4000' });
|
|
52
|
+
} else if (!ownLlm) {
|
|
53
|
+
env.LLM_PROVIDER = 'mock';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Voz: la suya o la de apuchat de la plataforma.
|
|
57
|
+
let platformTts = false;
|
|
58
|
+
if (!env.TTS_PROVIDER && !TTS_KEYS.some((k) => env[k]) && penv.PLATFORM_APUCHAT_VOICE_TOKEN) {
|
|
59
|
+
platformTts = true;
|
|
60
|
+
Object.assign(env, { TTS_PROVIDER: 'apuchat', APUCHAT_VOICE_TOKEN: penv.PLATFORM_APUCHAT_VOICE_TOKEN });
|
|
61
|
+
}
|
|
62
|
+
return { env, platformLlm, platformTts };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ───────────────────────────── LLM con cupo ─────────────────────────────
|
|
66
|
+
|
|
67
|
+
const llms = new Map(); // ots.id → { stamp, llm }
|
|
68
|
+
|
|
69
|
+
/** LLM del ots (se rehace cuando cambia el ots). */
|
|
70
|
+
function rawLlm(ots) {
|
|
71
|
+
const stamp = ots.updatedAt;
|
|
72
|
+
const hit = llms.get(ots.id);
|
|
73
|
+
if (hit?.stamp === stamp) return hit;
|
|
74
|
+
const { env, platformLlm } = otsEnv(ots);
|
|
75
|
+
const entry = { stamp, llm: createLLM(env), platformLlm };
|
|
76
|
+
if (llms.size > 2000) llms.clear();
|
|
77
|
+
llms.set(ots.id, entry);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** ¿Le queda cupo gratis a la cuenta? */
|
|
82
|
+
export function llmAllowed(ots) {
|
|
83
|
+
const { platformLlm } = rawLlm(ots);
|
|
84
|
+
return !platformLlm || accountUsage(ots.accountId, 'llm_platform') < freeMessages();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Un paso de LLM para este ots, contando el uso.
|
|
89
|
+
* Sin cupo lanza 402 con clave `platform.quota.llm` (la ruta lo convierte en una respuesta amable).
|
|
90
|
+
*/
|
|
91
|
+
export async function otsStep(ots, args) {
|
|
92
|
+
const { llm, platformLlm } = rawLlm(ots);
|
|
93
|
+
if (platformLlm && accountUsage(ots.accountId, 'llm_platform') >= freeMessages()) throw i18nError(402, 'platform.quota.llm');
|
|
94
|
+
const out = await llm.step(args);
|
|
95
|
+
addUsage(ots, 'llm');
|
|
96
|
+
if (platformLlm) addUsage(ots, 'llm_platform');
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function otsLlmInfo(ots) {
|
|
101
|
+
const { llm, platformLlm } = rawLlm(ots);
|
|
102
|
+
return { name: llm.name, model: llm.model, platform: platformLlm };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ───────────────────────────── voz ─────────────────────────────
|
|
106
|
+
|
|
107
|
+
export async function otsSynthesize(ots, { text, voice = ots.identity.voice }) {
|
|
108
|
+
const { env, platformTts } = otsEnv(ots);
|
|
109
|
+
if (platformTts && accountUsage(ots.accountId, 'tts_platform') >= freeTts()) throw i18nError(402, 'platform.quota.tts');
|
|
110
|
+
const out = await synthesizeWith({ text, voice, env });
|
|
111
|
+
addUsage(ots, 'tts');
|
|
112
|
+
if (platformTts) addUsage(ots, 'tts_platform');
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function otsTtsProvider(ots) {
|
|
117
|
+
return ttsProvider(otsEnv(ots).env);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ───────────────────────────── canales ─────────────────────────────
|
|
121
|
+
|
|
122
|
+
const channels = new Map(); // ots.id → { stamp, ch }
|
|
123
|
+
const NO_CHANNELS = {
|
|
124
|
+
handle: async () => false,
|
|
125
|
+
live: () => ({ email: false, apuchat: null, meet: false }),
|
|
126
|
+
openCall: () => {
|
|
127
|
+
throw i18nError(501, 'server.channels.noApuchat');
|
|
128
|
+
},
|
|
129
|
+
status: () => ({ apumail: false, apuchat: false }),
|
|
130
|
+
stop: async () => {},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Clave que identifica la configuración de canales (si no cambia, no se reinician). */
|
|
134
|
+
function channelStamp(ots) {
|
|
135
|
+
const { env } = otsEnv(ots);
|
|
136
|
+
return JSON.stringify([ots.status, ...Object.keys(OTS_KEYS).filter((k) => OTS_KEYS[k].channels).map((k) => env[k] || '')]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Canales del ots (apumail/apuchat), arrancándolos o reiniciándolos si hace falta. */
|
|
140
|
+
export function otsChannels(ots) {
|
|
141
|
+
if (!ots || ots.status !== 'on') return NO_CHANNELS;
|
|
142
|
+
const stamp = channelStamp(ots);
|
|
143
|
+
const hit = channels.get(ots.id);
|
|
144
|
+
if (hit?.stamp === stamp) return hit.ch;
|
|
145
|
+
hit?.ch.stop().catch(() => {});
|
|
146
|
+
const { env } = otsEnv(ots);
|
|
147
|
+
const id = ots.id;
|
|
148
|
+
// Siempre la versión vigente del ots: identidad e instrucciones cambian sin reiniciar.
|
|
149
|
+
const current = () => getOts(id) || ots;
|
|
150
|
+
const ch = startAgentChannels({
|
|
151
|
+
llm: { get name() { return otsLlmInfo(current()).name; }, get model() { return otsLlmInfo(current()).model; }, step: (a) => otsStep(current(), a) },
|
|
152
|
+
identity: () => current().identity,
|
|
153
|
+
instructions: () => current().settings.SERVER_INSTRUCTIONS || '',
|
|
154
|
+
config: { apumail: { ...apumailConfigFromEnv(env), webhookSecret: '' }, apuchat: apuchatConfigFromEnv(env) },
|
|
155
|
+
log: (...a) => console.log(`[7ots:${id}]`, ...a),
|
|
156
|
+
});
|
|
157
|
+
channels.set(id, { stamp, ch });
|
|
158
|
+
return ch;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Para los canales de un ots (al pausarlo o borrarlo). */
|
|
162
|
+
export async function stopOtsChannels(id) {
|
|
163
|
+
const hit = channels.get(id);
|
|
164
|
+
channels.delete(id);
|
|
165
|
+
llms.delete(id);
|
|
166
|
+
await hit?.ch.stop().catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Arranca los canales de todos los ots encendidos que los tengan configurados. */
|
|
170
|
+
export function startAllChannels() {
|
|
171
|
+
for (const ots of allLiveOts()) {
|
|
172
|
+
const s = ots.settings;
|
|
173
|
+
const sec = ots.secrets;
|
|
174
|
+
if ((s.APUMAIL_AGENT_INBOX && sec.APUMAIL_AGENT_TOKEN) || sec.APUCHAT_AGENT_IDENTITY_KEY) otsChannels(ots);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function stopAllChannels() {
|
|
179
|
+
await Promise.all([...channels.keys()].map(stopOtsChannels));
|
|
180
|
+
}
|