@7ots/cli 0.1.1 → 0.1.4
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 +17 -1
- package/cli/7ots.mjs +144 -36
- package/cli/lib/assistant.mjs +347 -0
- package/cli/lib/brain.mjs +10 -7
- package/cli/lib/config.mjs +8 -1
- package/cli/lib/i18n.mjs +84 -11
- package/cli/lib/install.mjs +29 -1
- package/cli/lib/pet-core.mjs +3 -2
- package/cli/lib/pet-server.mjs +133 -13
- package/cli/lib/prompts.mjs +199 -7
- package/cli/lib/terminal.mjs +27 -6
- package/cli/pet/electron/main.cjs +96 -32
- package/cli/pet/pet.html +59 -6
- package/cli/pet/settings.html +430 -0
- package/dist/7ots.esm.js +370 -142
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +370 -142
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +198 -6
- package/package.json +1 -1
- package/server/channels/apuchat.mjs +8 -5
- package/server/channels/apumail.mjs +13 -3
- package/server/channels/index.mjs +5 -3
- package/server/platform/apuchat.mjs +266 -0
- package/server/platform/auth.mjs +39 -10
- package/server/platform/connect.mjs +347 -0
- package/server/platform/db.mjs +55 -0
- package/server/platform/look.mjs +20 -0
- package/server/platform/orquesta.mjs +499 -0
- package/server/platform/routes.mjs +57 -8
- package/server/platform/runtime.mjs +75 -15
- package/server/platform/store.mjs +130 -9
- package/server/server.mjs +14 -3
- package/src/avatar/AvatarStage.js +54 -9
- package/src/avatar/three.js +62 -0
- package/src/character/AvatarEditor.js +174 -50
- package/src/character/Character.js +164 -26
- package/src/character/motion.js +95 -34
- package/src/character/parts.js +43 -12
- package/src/character/skin.js +412 -0
- package/src/character/styles.js +1429 -0
- package/src/character3d/Character3D.js +1693 -0
- package/src/character3d/body.js +154 -0
- package/src/character3d/fx.js +323 -0
- package/src/character3d/index.js +281 -0
- package/src/character3d/materials.js +429 -0
- package/src/character3d/stage.js +187 -0
- package/src/i18n/messages/character.js +18 -42
- package/src/i18n/messages/platform.js +135 -0
- package/src/identity/schema.js +16 -8
- package/src/index.js +1 -0
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 1. lo que fija la plataforma (hub de apuchat, API de apumail, servicio de voz),
|
|
6
6
|
* 2. sus ajustes y claves (solo las de OTS_KEYS),
|
|
7
7
|
* 3. si no trae clave de IA o de voz, la de la plataforma, con cupo gratis por cuenta y mes:
|
|
8
|
-
*
|
|
8
|
+
* PLATFORM_LLM_PROVIDER (anthropic | openai, compatibles como DeepSeek) · PLATFORM_LLM_API_KEY
|
|
9
|
+
* PLATFORM_LLM_BASE_URL (openai compatibles) · PLATFORM_LLM_MODEL
|
|
10
|
+
* (PLATFORM_ANTHROPIC_API_KEY sigue valiendo: equivale a provider anthropic + esa clave)
|
|
9
11
|
* PLATFORM_FREE_MESSAGES respuestas de IA gratis por cuenta y mes (defecto 100)
|
|
10
12
|
* PLATFORM_APUCHAT_VOICE_TOKEN · PLATFORM_FREE_TTS (audios gratis por cuenta y mes, defecto 100)
|
|
11
13
|
* Así un ots nunca ve las claves del proceso ni las de otro ots.
|
|
@@ -18,22 +20,38 @@ import { apumailConfigFromEnv } from '../channels/apumail.mjs';
|
|
|
18
20
|
import { apuchatConfigFromEnv } from '../channels/apuchat.mjs';
|
|
19
21
|
import { i18nError } from '../i18n.mjs';
|
|
20
22
|
import { OTS_KEYS, accountUsage, addUsage, allLiveOts, getOts } from './store.mjs';
|
|
23
|
+
import { orquestaTools } from './orquesta.mjs';
|
|
21
24
|
|
|
22
25
|
const penv = process.env;
|
|
23
26
|
const FIXED = ['APUCHAT_HUB', 'APUMAIL_API', 'APUCHAT_VOICE_URL'];
|
|
27
|
+
// Where tenants' tokens are sent: read once at boot, so nothing that writes process.env later can redirect them.
|
|
28
|
+
const BOOT_FIXED = Object.freeze(Object.fromEntries(FIXED.filter((k) => penv[k]).map((k) => [k, penv[k]])));
|
|
29
|
+
// Platform-paid calls in flight per account (counted with usage, so parallel requests can't overrun the quota).
|
|
30
|
+
const inflight = new Map(); // `${accountId}:${kind}` → n
|
|
31
|
+
const MAX_INFLIGHT = () => Number(penv.PLATFORM_MAX_INFLIGHT || 4);
|
|
24
32
|
const LLM_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
|
25
33
|
const TTS_KEYS = ['APUCHAT_VOICE_TOKEN', 'ELEVENLABS_API_KEY', 'XAI_API_KEY', 'FISH_API_KEY', 'OPENAI_API_KEY'];
|
|
26
34
|
|
|
35
|
+
/** IA de la plataforma, o null si no hay. Nunca se mezcla con ajustes del ots (ni base URL ni modelo). */
|
|
36
|
+
export function platformLlmEnv() {
|
|
37
|
+
const key = penv.PLATFORM_LLM_API_KEY || penv.PLATFORM_ANTHROPIC_API_KEY;
|
|
38
|
+
if (!key) return null;
|
|
39
|
+
const provider = (penv.PLATFORM_LLM_PROVIDER || (penv.PLATFORM_LLM_API_KEY ? 'openai' : 'anthropic')).toLowerCase();
|
|
40
|
+
const base = { LLM_EFFORT: 'low', LLM_MAX_TOKENS: '4000' };
|
|
41
|
+
if (provider === 'openai') return { ...base, LLM_PROVIDER: 'openai', OPENAI_API_KEY: key, LLM_BASE_URL: penv.PLATFORM_LLM_BASE_URL || '', LLM_MODEL: penv.PLATFORM_LLM_MODEL || 'gpt-4.1-mini' };
|
|
42
|
+
return { ...base, LLM_PROVIDER: 'anthropic', ANTHROPIC_API_KEY: key, LLM_MODEL: penv.PLATFORM_LLM_MODEL || 'claude-sonnet-5-5' };
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
export const freeMessages = () => Number(penv.PLATFORM_FREE_MESSAGES ?? 100);
|
|
28
46
|
export const freeTts = () => Number(penv.PLATFORM_FREE_TTS ?? 100);
|
|
29
47
|
|
|
30
48
|
/**
|
|
31
49
|
* Entorno del ots.
|
|
32
|
-
* @returns {{ env: object, platformLlm: boolean, platformTts: boolean }}
|
|
50
|
+
* @returns {{ env: object, llmEnv: object, platformLlm: boolean, platformTts: boolean }}
|
|
33
51
|
*/
|
|
34
52
|
export function otsEnv(ots) {
|
|
35
53
|
const env = {};
|
|
36
|
-
|
|
54
|
+
Object.assign(env, BOOT_FIXED);
|
|
37
55
|
for (const [k, meta] of Object.entries(OTS_KEYS)) {
|
|
38
56
|
const v = meta.secret ? ots.secrets[k] : ots.settings[k];
|
|
39
57
|
if (v == null || v === '') continue;
|
|
@@ -46,20 +64,25 @@ export function otsEnv(ots) {
|
|
|
46
64
|
const ownLlm = provider === 'openai' ? !!env.OPENAI_API_KEY : provider === 'anthropic' ? !!env.ANTHROPIC_API_KEY : LLM_KEYS.some((k) => env[k]);
|
|
47
65
|
if (provider === 'mock') {
|
|
48
66
|
// Modo de pruebas: sin IA real.
|
|
49
|
-
} else if (!ownLlm &&
|
|
67
|
+
} else if (!ownLlm && platformLlmEnv()) {
|
|
50
68
|
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
69
|
} else if (!ownLlm) {
|
|
53
70
|
env.LLM_PROVIDER = 'mock';
|
|
54
71
|
}
|
|
72
|
+
// The platform key lives only in llmEnv (never in env, which voice and channels see), and none of the
|
|
73
|
+
// ot's own settings (provider, model, base URL) reach it.
|
|
74
|
+
const llmEnv = platformLlm ? platformLlmEnv() : env;
|
|
55
75
|
|
|
56
76
|
// Voz: la suya o la de apuchat de la plataforma.
|
|
57
77
|
let platformTts = false;
|
|
58
|
-
if (!
|
|
78
|
+
if (!TTS_KEYS.some((k) => env[k]) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
|
|
59
79
|
platformTts = true;
|
|
80
|
+
// On the platform's token the engine and model are the platform's, not the ot's.
|
|
81
|
+
delete env.APUCHAT_VOICE_PROVIDER;
|
|
82
|
+
delete env.APUCHAT_VOICE_MODEL;
|
|
60
83
|
Object.assign(env, { TTS_PROVIDER: 'apuchat', APUCHAT_VOICE_TOKEN: penv.PLATFORM_APUCHAT_VOICE_TOKEN });
|
|
61
84
|
}
|
|
62
|
-
return { env, platformLlm, platformTts };
|
|
85
|
+
return { env, llmEnv, platformLlm, platformTts };
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
// ───────────────────────────── LLM con cupo ─────────────────────────────
|
|
@@ -71,8 +94,8 @@ function rawLlm(ots) {
|
|
|
71
94
|
const stamp = ots.updatedAt;
|
|
72
95
|
const hit = llms.get(ots.id);
|
|
73
96
|
if (hit?.stamp === stamp) return hit;
|
|
74
|
-
const {
|
|
75
|
-
const entry = { stamp, llm: createLLM(
|
|
97
|
+
const { llmEnv, platformLlm } = otsEnv(ots);
|
|
98
|
+
const entry = { stamp, llm: createLLM(llmEnv), platformLlm };
|
|
76
99
|
if (llms.size > 2000) llms.clear();
|
|
77
100
|
llms.set(ots.id, entry);
|
|
78
101
|
return entry;
|
|
@@ -84,16 +107,45 @@ export function llmAllowed(ots) {
|
|
|
84
107
|
return !platformLlm || accountUsage(ots.accountId, 'llm_platform') < freeMessages();
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Takes one unit of the account's free quota before a platform-paid call (synchronous, so it is atomic in Node).
|
|
112
|
+
* Returns release(spent): on success the unit becomes usage; on failure it is given back.
|
|
113
|
+
*/
|
|
114
|
+
function reserve(ots, kind, limit, errorKey) {
|
|
115
|
+
const k = `${ots.accountId}:${kind}`;
|
|
116
|
+
const n = inflight.get(k) || 0;
|
|
117
|
+
if (accountUsage(ots.accountId, kind) + n >= limit) throw i18nError(402, errorKey);
|
|
118
|
+
if (n >= MAX_INFLIGHT()) throw i18nError(429, 'server.http.rateLimited');
|
|
119
|
+
inflight.set(k, n + 1);
|
|
120
|
+
return (spent) => {
|
|
121
|
+
const left = (inflight.get(k) || 1) - 1;
|
|
122
|
+
if (left > 0) inflight.set(k, left);
|
|
123
|
+
else inflight.delete(k);
|
|
124
|
+
if (spent) addUsage(ots, kind);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
87
128
|
/**
|
|
88
129
|
* Un paso de LLM para este ots, contando el uso.
|
|
89
130
|
* Sin cupo lanza 402 con clave `platform.quota.llm` (la ruta lo convierte en una respuesta amable).
|
|
90
131
|
*/
|
|
91
132
|
export async function otsStep(ots, args) {
|
|
92
133
|
const { llm, platformLlm } = rawLlm(ots);
|
|
93
|
-
|
|
94
|
-
|
|
134
|
+
const release = platformLlm ? reserve(ots, 'llm_platform', freeMessages(), 'platform.quota.llm') : null;
|
|
135
|
+
let out;
|
|
136
|
+
try {
|
|
137
|
+
out = await llm.step(args);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
release?.(false);
|
|
140
|
+
// The platform's provider errors stay in our logs (they can echo parts of the platform key).
|
|
141
|
+
if (platformLlm) {
|
|
142
|
+
console.error('[7ots] platform llm', e?.status || '', e?.message || e);
|
|
143
|
+
throw i18nError(502, 'server.http.internal');
|
|
144
|
+
}
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
release?.(true);
|
|
95
148
|
addUsage(ots, 'llm');
|
|
96
|
-
if (platformLlm) addUsage(ots, 'llm_platform');
|
|
97
149
|
return out;
|
|
98
150
|
}
|
|
99
151
|
|
|
@@ -106,10 +158,16 @@ export function otsLlmInfo(ots) {
|
|
|
106
158
|
|
|
107
159
|
export async function otsSynthesize(ots, { text, voice = ots.identity.voice }) {
|
|
108
160
|
const { env, platformTts } = otsEnv(ots);
|
|
109
|
-
|
|
110
|
-
|
|
161
|
+
const release = platformTts ? reserve(ots, 'tts_platform', freeTts(), 'platform.quota.tts') : null;
|
|
162
|
+
let out;
|
|
163
|
+
try {
|
|
164
|
+
out = await synthesizeWith({ text, voice: platformTts && voice && typeof voice === 'object' ? { ...voice, model: undefined } : voice, env });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
release?.(false);
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
release?.(true);
|
|
111
170
|
addUsage(ots, 'tts');
|
|
112
|
-
if (platformTts) addUsage(ots, 'tts_platform');
|
|
113
171
|
return out;
|
|
114
172
|
}
|
|
115
173
|
|
|
@@ -153,6 +211,8 @@ export function otsChannels(ots) {
|
|
|
153
211
|
instructions: () => current().settings.SERVER_INSTRUCTIONS || '',
|
|
154
212
|
config: { apumail: { ...apumailConfigFromEnv(env), webhookSecret: '' }, apuchat: apuchatConfigFromEnv(env) },
|
|
155
213
|
log: (...a) => console.log(`[7ots:${id}]`, ...a),
|
|
214
|
+
// Orquesta tasks: only for senders on the ot's own list (see orquesta.mjs), read on every message.
|
|
215
|
+
tools: (ctx) => orquestaTools(current(), ctx),
|
|
156
216
|
});
|
|
157
217
|
channels.set(id, { stamp, ch });
|
|
158
218
|
return ch;
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { defineIdentity } from '../../src/identity/schema.js';
|
|
7
7
|
import { getDb, monthOf, tx } from './db.mjs';
|
|
8
|
+
import { lookPrint } from './look.mjs';
|
|
9
|
+
import { randomIdentity, newSeed } from '../../src/identity/random.js';
|
|
10
|
+
import { i18nError } from '../i18n.mjs';
|
|
8
11
|
import { newId, open, seal, sha256, token } from './crypto.mjs';
|
|
9
12
|
|
|
10
13
|
const now = () => Date.now();
|
|
@@ -26,6 +29,8 @@ export const MAX_OTS_PER_ACCOUNT = () => Number(process.env.PLATFORM_MAX_OTS ||
|
|
|
26
29
|
* secret: se guarda cifrado y nunca se devuelve.
|
|
27
30
|
* max: tope numérico.
|
|
28
31
|
* channels: al cambiar, se reinician los canales de ese ots.
|
|
32
|
+
* managed: only the dashboard's own routes write it (apuchat identity, Orquesta panel);
|
|
33
|
+
* the backoffice's /server PUT ignores it and a duplicate doesn't copy it.
|
|
29
34
|
*/
|
|
30
35
|
export const OTS_KEYS = {
|
|
31
36
|
LLM_PROVIDER: {}, LLM_MODEL: {}, LLM_EFFORT: {}, LLM_MAX_TOKENS: { max: 16000 },
|
|
@@ -42,6 +47,13 @@ export const OTS_KEYS = {
|
|
|
42
47
|
APUMAIL_AGENT_DAILY: { channels: true, max: 100 },
|
|
43
48
|
APUCHAT_AGENT_IDENTITY_KEY: { secret: true, channels: true }, APUCHAT_AGENT_ALLOW: { channels: true },
|
|
44
49
|
AGENT_MAX_CALLS: { channels: true, max: 3 }, AGENT_CALL_MAX_MINUTES: { channels: true, max: 30 }, AGENT_CALL_MAX_TURNS: { channels: true, max: 60 },
|
|
50
|
+
// apuchat identity made from the dashboard: its @callsign, '1' while it is a free one, and who holds it
|
|
51
|
+
// ('managed' = the platform's anonymous apuchat account for this 7ots account, 'linked' = the user's own).
|
|
52
|
+
APUCHAT_AGENT_CALLSIGN: { managed: true }, APUCHAT_AGENT_FREE: { managed: true }, APUCHAT_AGENT_OWNER: { managed: true },
|
|
53
|
+
// Orquesta tasks: project, on/off, who may ask outside the dashboard (explicit list, empty = nobody), daily cap.
|
|
54
|
+
// Read on every message, so changing them never restarts the channels.
|
|
55
|
+
ORQUESTA_PROJECT: { managed: true }, ORQUESTA_PROJECT_NAME: { managed: true }, ORQUESTA_TASKS: { managed: true },
|
|
56
|
+
ORQUESTA_ALLOW: { managed: true }, ORQUESTA_DAILY: { managed: true, max: 200 },
|
|
45
57
|
};
|
|
46
58
|
|
|
47
59
|
// ───────────────────────────── cuentas ─────────────────────────────
|
|
@@ -59,6 +71,8 @@ export function upsertAccount({ email = null, notloginSub = null, name = '' }) {
|
|
|
59
71
|
return tx((d) => {
|
|
60
72
|
let acc = notloginSub ? d.prepare('SELECT * FROM accounts WHERE notlogin_sub = ?').get(notloginSub) : null;
|
|
61
73
|
if (!acc && email) acc = d.prepare('SELECT * FROM accounts WHERE email = ?').get(email);
|
|
74
|
+
// An email account already tied to a different notlogin identity is not taken over.
|
|
75
|
+
if (acc && notloginSub && acc.notlogin_sub && acc.notlogin_sub !== notloginSub) throw i18nError(409, 'platform.api.accountLinked');
|
|
62
76
|
if (acc) {
|
|
63
77
|
if (notloginSub && !acc.notlogin_sub) d.prepare('UPDATE accounts SET notlogin_sub = ? WHERE id = ?').run(notloginSub, acc.id);
|
|
64
78
|
if (email && !acc.email && !d.prepare('SELECT 1 FROM accounts WHERE email = ?').get(email)) d.prepare('UPDATE accounts SET email = ? WHERE id = ?').run(email, acc.id);
|
|
@@ -94,6 +108,17 @@ export function deleteSession(raw) {
|
|
|
94
108
|
if (raw) getDb().prepare('DELETE FROM sessions WHERE id = ?').run(sha256(raw));
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
/** Closes every session of the account (sign out everywhere). */
|
|
112
|
+
export function deleteAccountSessions(accountId) {
|
|
113
|
+
getDb().prepare('DELETE FROM sessions WHERE account_id = ?').run(accountId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Does a login (email or notlogin sub) already have an account? */
|
|
117
|
+
export function accountExists({ email = null, notloginSub = null }) {
|
|
118
|
+
const d = getDb();
|
|
119
|
+
return !!((notloginSub && d.prepare('SELECT 1 FROM accounts WHERE notlogin_sub = ?').get(notloginSub)) || (email && d.prepare('SELECT 1 FROM accounts WHERE email = ?').get(String(email).trim().toLowerCase())));
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
// ───────────────────────────── enlaces por email ─────────────────────────────
|
|
98
123
|
|
|
99
124
|
export const LOGIN_TOKEN_TTL = 15 * 60_000;
|
|
@@ -136,6 +161,7 @@ function hydrate(row) {
|
|
|
136
161
|
widget: parse(row.widget, {}),
|
|
137
162
|
settings: parse(row.settings, {}),
|
|
138
163
|
secrets: open(row.secrets),
|
|
164
|
+
lookPrint: row.look_print || null,
|
|
139
165
|
createdAt: row.created_at,
|
|
140
166
|
updatedAt: row.updated_at,
|
|
141
167
|
};
|
|
@@ -159,30 +185,64 @@ export function countOts(accountId) {
|
|
|
159
185
|
return getDb().prepare('SELECT COUNT(*) n FROM ots WHERE account_id = ?').get(accountId).n;
|
|
160
186
|
}
|
|
161
187
|
|
|
188
|
+
// Cada ot es único (ver look.mjs): la base rechaza una cara repetida con este error.
|
|
189
|
+
const isLookClash = (e) => /UNIQUE/i.test(e?.message || '') && /look_print/.test(e.message);
|
|
190
|
+
const lookTakenError = () => i18nError(409, 'platform.api.lookTaken');
|
|
191
|
+
|
|
192
|
+
/** ¿Esa cara ya la tiene otro ot? (`exceptId`: el propio, al guardarlo). */
|
|
193
|
+
export function lookTaken(identity, exceptId = '') {
|
|
194
|
+
const p = lookPrint(defineIdentity(identity));
|
|
195
|
+
return !!p && !!getDb().prepare('SELECT 1 FROM ots WHERE look_print = ? AND id != ?').get(p, String(exceptId));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* La misma identidad con una cara nueva que nadie tenga: la de la semilla `seed` si está libre y si no,
|
|
200
|
+
* semillas al azar. Lo usan crear (un ot sin cara elegida) y duplicar.
|
|
201
|
+
*/
|
|
202
|
+
export function withFreeLook(identity, seed = null) {
|
|
203
|
+
for (let i = 0; i < 40; i++) {
|
|
204
|
+
const look = randomIdentity(i === 0 && seed ? seed : newSeed()).look;
|
|
205
|
+
const next = { ...identity, look: { ...identity.look, kind: 'character', character: look.character } };
|
|
206
|
+
if (!lookTaken(next)) return next;
|
|
207
|
+
}
|
|
208
|
+
throw lookTakenError();
|
|
209
|
+
}
|
|
210
|
+
|
|
162
211
|
export function createOts(accountId, { name, identity = {}, widget = {}, settings = {}, secrets = {}, domains = [] } = {}) {
|
|
163
212
|
const id = newId('ots');
|
|
164
213
|
const nm = cleanName(name) || 'Ots';
|
|
165
214
|
const ident = defineIdentity({ ...identity, name: identity.name || nm });
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
try {
|
|
216
|
+
getDb()
|
|
217
|
+
.prepare('INSERT INTO ots (id, account_id, name, status, domains, identity, widget, settings, secrets, look_print, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
218
|
+
.run(id, accountId, nm, 'on', JSON.stringify(cleanDomains(domains)), JSON.stringify(ident), JSON.stringify(widget), JSON.stringify(settings), seal(secrets), lookPrint(ident), now(), now());
|
|
219
|
+
} catch (e) {
|
|
220
|
+
throw isLookClash(e) ? lookTakenError() : e;
|
|
221
|
+
}
|
|
169
222
|
return getOts(id);
|
|
170
223
|
}
|
|
171
224
|
|
|
172
|
-
/** Copia un ots (sin sus claves ni canales propios: un @handle no puede estar en dos ots). */
|
|
225
|
+
/** Copia un ots (sin sus claves ni canales propios: un @handle no puede estar en dos ots) con otra cara: no hay dos iguales. */
|
|
173
226
|
export function duplicateOts(accountId, src) {
|
|
174
227
|
const settings = { ...src.settings };
|
|
175
|
-
for (const k of Object.keys(settings)) if (OTS_KEYS[k]?.channels) delete settings[k];
|
|
176
|
-
return createOts(accountId, { name: `${src.name} (copia)`, identity: src.identity, widget: src.widget, settings, domains: src.domains });
|
|
228
|
+
for (const k of Object.keys(settings)) if (OTS_KEYS[k]?.channels || OTS_KEYS[k]?.managed) delete settings[k];
|
|
229
|
+
return createOts(accountId, { name: `${src.name} (copia)`, identity: withFreeLook(src.identity), widget: src.widget, settings, domains: src.domains });
|
|
177
230
|
}
|
|
178
231
|
|
|
179
232
|
export function updateOts(id, patch) {
|
|
180
233
|
const cur = getOts(id);
|
|
181
234
|
if (!cur) return null;
|
|
182
235
|
const next = { ...cur, ...patch };
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
236
|
+
// Un ot viejo que ya repetía cara (sin huella) se puede seguir guardando mientras no la cambie.
|
|
237
|
+
let print = lookPrint(next.identity);
|
|
238
|
+
if (!cur.lookPrint && print === lookPrint(cur.identity) && lookTaken(next.identity, id)) print = null;
|
|
239
|
+
try {
|
|
240
|
+
getDb()
|
|
241
|
+
.prepare('UPDATE ots SET name = ?, status = ?, domains = ?, identity = ?, widget = ?, settings = ?, secrets = ?, look_print = ?, updated_at = ? WHERE id = ?')
|
|
242
|
+
.run(cleanName(next.name) || cur.name, next.status === 'off' ? 'off' : 'on', JSON.stringify(cleanDomains(next.domains)), JSON.stringify(next.identity), JSON.stringify(next.widget), JSON.stringify(next.settings), seal(next.secrets), print, now(), id);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
throw isLookClash(e) ? lookTakenError() : e;
|
|
245
|
+
}
|
|
186
246
|
return getOts(id);
|
|
187
247
|
}
|
|
188
248
|
|
|
@@ -329,3 +389,64 @@ export function usageHistory(otsId, months = 6) {
|
|
|
329
389
|
}
|
|
330
390
|
return [...by.values()].slice(0, months);
|
|
331
391
|
}
|
|
392
|
+
|
|
393
|
+
// ───────────────────────────── integraciones ─────────────────────────────
|
|
394
|
+
|
|
395
|
+
/** Connected service of an account ('apuchat' | 'orquesta'): its tokens, decrypted. null if none. */
|
|
396
|
+
export function getIntegration(accountId, kind) {
|
|
397
|
+
const row = getDb().prepare('SELECT data FROM integrations WHERE account_id = ? AND kind = ?').get(accountId, kind);
|
|
398
|
+
return row ? open(row.data) : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Saves (sealed) or, with null, forgets an account's integration. */
|
|
402
|
+
export function setIntegration(accountId, kind, data) {
|
|
403
|
+
if (data == null) return void getDb().prepare('DELETE FROM integrations WHERE account_id = ? AND kind = ?').run(accountId, kind);
|
|
404
|
+
getDb()
|
|
405
|
+
.prepare('INSERT INTO integrations (account_id, kind, data, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, kind) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at')
|
|
406
|
+
.run(accountId, kind, seal(data), now());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function getPlatformState(key) {
|
|
410
|
+
return getDb().prepare('SELECT value FROM platform_state WHERE key = ?').get(key)?.value ?? null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function setPlatformState(key, value) {
|
|
414
|
+
getDb().prepare('INSERT INTO platform_state (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at').run(key, String(value), now());
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ───────────────────────────── tareas de Orquesta ─────────────────────────────
|
|
418
|
+
|
|
419
|
+
const taskOf = (r) => r && { id: r.id, otsId: r.ots_id, projectId: r.project_id, who: r.who, channel: r.channel, task: r.task, status: r.status, result: r.result, error: r.error, createdAt: r.created_at, updatedAt: r.updated_at };
|
|
420
|
+
export const TASK_FINAL = ['completed', 'failed', 'cancelled'];
|
|
421
|
+
|
|
422
|
+
export function addTask(ots, { id, projectId, who, channel, task, status = 'pending' }) {
|
|
423
|
+
getDb()
|
|
424
|
+
.prepare('INSERT INTO ots_tasks (id, ots_id, account_id, project_id, who, channel, task, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
425
|
+
.run(String(id), ots.id, ots.accountId, String(projectId || ''), String(who).slice(0, 200), String(channel).slice(0, 20), String(task).slice(0, 8000), status, now(), now());
|
|
426
|
+
return getTask(ots.id, id);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function updateTask(id, { status, result, error }) {
|
|
430
|
+
getDb()
|
|
431
|
+
.prepare('UPDATE ots_tasks SET status = COALESCE(?, status), result = COALESCE(?, result), error = COALESCE(?, error), updated_at = ? WHERE id = ?')
|
|
432
|
+
.run(status ?? null, result == null ? null : String(result).slice(0, 20000), error == null ? null : String(error).slice(0, 2000), now(), String(id));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The task only if it belongs to that ot. */
|
|
436
|
+
export function getTask(otsId, id) {
|
|
437
|
+
return taskOf(getDb().prepare('SELECT * FROM ots_tasks WHERE id = ? AND ots_id = ?').get(String(id || ''), otsId)) || null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function listTasks(otsId, limit = 20) {
|
|
441
|
+
return getDb().prepare('SELECT * FROM ots_tasks WHERE ots_id = ? ORDER BY created_at DESC LIMIT ?').all(otsId, Math.min(100, Math.max(1, Number(limit) || 20))).map(taskOf);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Tasks the ot sent in the last 24 h (its daily cap). */
|
|
445
|
+
export function tasksLastDay(otsId) {
|
|
446
|
+
return getDb().prepare('SELECT COUNT(*) n FROM ots_tasks WHERE ots_id = ? AND created_at > ?').get(otsId, now() - 24 * 3600_000).n;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Tasks not finished yet (newest first). */
|
|
450
|
+
export function openTasks(otsId) {
|
|
451
|
+
return getDb().prepare(`SELECT * FROM ots_tasks WHERE ots_id = ? AND status NOT IN (${TASK_FINAL.map(() => '?').join(', ')}) ORDER BY created_at DESC LIMIT 5`).all(otsId, ...TASK_FINAL).map(taskOf);
|
|
452
|
+
}
|
package/server/server.mjs
CHANGED
|
@@ -119,10 +119,14 @@ const server = createServer(async (req, res) => {
|
|
|
119
119
|
if (publicIdentityRoute(req, res, url)) return; // tarjeta pública: cualquier origen puede leerla
|
|
120
120
|
if (await publicWidgetRoute(req, res, url)) return; // config pública y embed.js
|
|
121
121
|
if (url.pathname.startsWith(`${PREFIX}/admin/`)) {
|
|
122
|
+
// In platform mode each ot has its own admin under /api/platform; the single-tenant one would rule them all.
|
|
123
|
+
if (PLATFORM) return send(res, 404, { error: t('server.http.notFound') });
|
|
122
124
|
if (await admin(req, res, url.pathname.slice(PREFIX.length))) return;
|
|
123
125
|
}
|
|
124
126
|
if (url.pathname === '/backoffice' || url.pathname.startsWith('/backoffice/')) return await serveBackoffice(req, res, url.pathname);
|
|
125
127
|
if (!cors(req, res)) return send(res, 403, { error: t('server.http.originNotAllowed') });
|
|
128
|
+
// In platform mode the operator's /api/agent (the landing demo) only answers the platform's own pages.
|
|
129
|
+
if (PLATFORM && url.pathname.startsWith(PREFIX) && req.method === 'POST' && !sameOrigin(req)) return send(res, 403, { error: t('server.http.originNotAllowed') });
|
|
126
130
|
if (req.method === 'OPTIONS') return send(res, 204);
|
|
127
131
|
|
|
128
132
|
if (url.pathname.startsWith(PREFIX)) return await api(req, res, url.pathname.slice(PREFIX.length));
|
|
@@ -130,7 +134,8 @@ const server = createServer(async (req, res) => {
|
|
|
130
134
|
if (SERVE_STATIC && req.method === 'GET') return await serveStatic(req, res, url.pathname);
|
|
131
135
|
send(res, 404, { error: t('server.http.notFound') });
|
|
132
136
|
} catch (err) {
|
|
133
|
-
|
|
137
|
+
// Only our own (i18n) errors reach the visitor as text; provider/SDK errors can echo keys or bodies.
|
|
138
|
+
const status = err.i18n ? err.status || 500 : err.status ? 502 : 500;
|
|
134
139
|
if (status >= 500 && status !== 501) console.error('[7ots]', req.method, url.pathname, err);
|
|
135
140
|
if (!res.headersSent) send(res, status, { error: status >= 500 && status !== 501 ? t('server.http.internal') : errorText(err, t) });
|
|
136
141
|
}
|
|
@@ -358,6 +363,11 @@ function cors(req, res) {
|
|
|
358
363
|
return true;
|
|
359
364
|
}
|
|
360
365
|
|
|
366
|
+
function sameOrigin(req) {
|
|
367
|
+
const origin = req.headers.origin;
|
|
368
|
+
return !!origin && (origin === originOf(req) || (env.PLATFORM_URL && origin === new URL(env.PLATFORM_URL).origin));
|
|
369
|
+
}
|
|
370
|
+
|
|
361
371
|
function send(res, status, body) {
|
|
362
372
|
if (body === undefined) {
|
|
363
373
|
res.writeHead(status);
|
|
@@ -386,7 +396,8 @@ async function readJson(req, limit) {
|
|
|
386
396
|
|
|
387
397
|
const buckets = new Map();
|
|
388
398
|
function clientIp(req) {
|
|
389
|
-
|
|
399
|
+
// The right-most X-Forwarded-For entry is the one our proxy added; the ones before it are client-controlled.
|
|
400
|
+
return (env.TRUST_PROXY === 'true' && String(req.headers['x-forwarded-for'] || '').split(',').pop().trim()) || req.socket.remoteAddress;
|
|
390
401
|
}
|
|
391
402
|
|
|
392
403
|
function rateLimit(req) {
|
|
@@ -412,7 +423,7 @@ const STATIC_DIRS = ['site', 'brand', 'examples', 'src', 'dist'];
|
|
|
412
423
|
|
|
413
424
|
async function serveStatic(req, res, pathname) {
|
|
414
425
|
if (pathname === '/') {
|
|
415
|
-
res.writeHead(302, { Location: '/site/' });
|
|
426
|
+
res.writeHead(302, { Location: '/site/start/' });
|
|
416
427
|
return res.end();
|
|
417
428
|
}
|
|
418
429
|
let rel = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, '');
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* gestos; o el retrato (`image`) con un halo que late al hablar; o una cara SVG mínima.
|
|
11
11
|
* `mode: '2d'` fuerza el modo 2D aunque haya WebGL (también si hay `character` y no `url`).
|
|
12
12
|
*
|
|
13
|
+
* Modo '3d-plush': el MISMO personaje (`character`) como peluche/vinilo 3D (src/character3d),
|
|
14
|
+
* con la misma API (habla, ánimos, gestos, metamorfosis y efectos). `material`: 'plush'|'vinyl'.
|
|
15
|
+
* three se trae del CDN aunque la página no tenga importmap (src/avatar/three.js); ante
|
|
16
|
+
* cualquier fallo (sin WebGL, CSP, red) se queda en 2D.
|
|
17
|
+
*
|
|
13
18
|
* Requisito del modo 3D en la página anfitriona (TalkingHead importa "three" por nombre):
|
|
14
19
|
*
|
|
15
20
|
* <script type="importmap">
|
|
@@ -24,7 +29,7 @@
|
|
|
24
29
|
* await stage.mimic({ text, durationMs }) // sin audio (speechSynthesis): solo mueve la boca
|
|
25
30
|
* stage.mimicSync(charIndex) // resincroniza con SpeechSynthesisUtterance.onboundary
|
|
26
31
|
* stage.stop(); stage.setMood('happy'); stage.gesture('thumbup');
|
|
27
|
-
* stage.morph('wings') / stage.effect('hearts') //
|
|
32
|
+
* stage.morph('wings') / stage.effect('hearts') // ot 2D y 3D-plush (transformaciones y efectos)
|
|
28
33
|
*
|
|
29
34
|
* Gestos: en 2D valen todos los de CHARACTER_GESTURES (y sus alias: handup, index, ok…);
|
|
30
35
|
* en 3D se traducen al gesto de TalkingHead más parecido (GESTURE_3D).
|
|
@@ -35,6 +40,7 @@ export const DEFAULT_AVATAR_URL = 'https://cdn.jsdelivr.net/gh/met4citizen/Talki
|
|
|
35
40
|
|
|
36
41
|
/** Enums cerrados: el LLM solo puede pedir estos valores (TalkingHead lanza con otros). */
|
|
37
42
|
import { createCharacter, CHARACTER_GESTURES, CHARACTER_GESTURE_ALIAS } from '../character/Character.js';
|
|
43
|
+
import { loadCharacter3D, ensureImportMap } from './three.js';
|
|
38
44
|
|
|
39
45
|
export const MOODS = ['neutral', 'happy', 'angry', 'sad', 'fear', 'disgust', 'love', 'sleep'];
|
|
40
46
|
export const GESTURES = ['handup', 'index', 'ok', 'thumbup', 'thumbdown', 'side', 'shrug', 'namaste'];
|
|
@@ -59,7 +65,7 @@ export class AvatarStage {
|
|
|
59
65
|
/**
|
|
60
66
|
* @param {object} o
|
|
61
67
|
* @param {HTMLElement} o.container nodo dentro del Shadow DOM del widget
|
|
62
|
-
* @param {object|false} o.config { url, body:'F'|'M', mood, cameraView, lipsyncLang, module, name, image, face, mode }
|
|
68
|
+
* @param {object|false} o.config { url, body:'F'|'M', mood, cameraView, lipsyncLang, module, name, image, face, mode:'2d'|'3d'|'3d-plush', material }
|
|
63
69
|
* @param {string} [o.language] idioma del agente (para elegir lip-sync)
|
|
64
70
|
*/
|
|
65
71
|
constructor({ container, config = {}, language = document.documentElement.lang || 'es' }) {
|
|
@@ -84,12 +90,14 @@ export class AvatarStage {
|
|
|
84
90
|
|
|
85
91
|
/** Carga el avatar 3D o cae al 2D. Nunca lanza. */
|
|
86
92
|
async load(onProgress) {
|
|
87
|
-
if (this.cfg
|
|
93
|
+
if (this.cfg && this.cfg.mode === '3d-plush' && this.cfg.character && !this.cfg.image) return this._loadPlush();
|
|
94
|
+
if (this.cfg === false || this.cfg.mode === '2d' || this.cfg.mode === '3d-plush' || (this.cfg.character && !this.cfg.url && this.cfg.mode !== '3d')) return this._load2D();
|
|
88
95
|
if (!hasWebGL()) {
|
|
89
96
|
console.info('[7ots] el navegador no tiene WebGL: avatar 2D');
|
|
90
97
|
return this._load2D();
|
|
91
98
|
}
|
|
92
99
|
try {
|
|
100
|
+
ensureImportMap(); // TalkingHead importa "three" por nombre
|
|
93
101
|
const { TalkingHead } = await import(/* @vite-ignore */ /* webpackIgnore: true */ this.cfg.module);
|
|
94
102
|
this.head = new TalkingHead(this.container, {
|
|
95
103
|
ttsEndpoint: '', // no usamos el TTS de Google de TalkingHead: el audio viene de VoiceEngine
|
|
@@ -117,6 +125,37 @@ export class AvatarStage {
|
|
|
117
125
|
return this.mode;
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/** El ot como peluche 3D (misma spec que el 2D). Nunca lanza: ante cualquier fallo, 2D. */
|
|
129
|
+
async _loadPlush() {
|
|
130
|
+
const token = (this._plushToken = {});
|
|
131
|
+
if (!hasWebGL()) return this._load2D();
|
|
132
|
+
try {
|
|
133
|
+
const m = await loadCharacter3D();
|
|
134
|
+
if (this._plushToken !== token || !this.cfg) return this.mode; // otro load/dispose entre medias
|
|
135
|
+
this._ch?.destroy();
|
|
136
|
+
this.container.replaceChildren();
|
|
137
|
+
this._mouth = null;
|
|
138
|
+
this._face = null;
|
|
139
|
+
const ch = m.createCharacter3D(this.container, this.cfg.character, {
|
|
140
|
+
material: this.cfg.material === 'vinyl' ? 'vinyl' : 'plush',
|
|
141
|
+
orbit: this.cfg.orbit === true,
|
|
142
|
+
quality: this.cfg.quality || 'high',
|
|
143
|
+
// escenario propio por encima del panel del widget (z-index 2147483000)
|
|
144
|
+
stage: { key: 'overlay', zIndex: 2147483001, maxDpr: 1.5 },
|
|
145
|
+
});
|
|
146
|
+
this._ch = ch;
|
|
147
|
+
this.mode = ch.fallback ? '2d' : '3d-plush';
|
|
148
|
+
if (this.cfg.mood) ch.mood(this.cfg.mood, { fx: false });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.warn('[7ots] ot 3D no disponible, uso 2D:', err?.message || err);
|
|
151
|
+
this._ch?.destroy?.();
|
|
152
|
+
this._ch = null;
|
|
153
|
+
this.container.replaceChildren();
|
|
154
|
+
this._load2D();
|
|
155
|
+
}
|
|
156
|
+
return this.mode;
|
|
157
|
+
}
|
|
158
|
+
|
|
120
159
|
_load2D() {
|
|
121
160
|
this.mode = '2d';
|
|
122
161
|
this._ch?.destroy();
|
|
@@ -174,7 +213,7 @@ export class AvatarStage {
|
|
|
174
213
|
if (image !== undefined) this.cfg.image = image;
|
|
175
214
|
if (face !== undefined) this.cfg.face = face;
|
|
176
215
|
if (character !== undefined) this.cfg.character = character;
|
|
177
|
-
if (this._ch && character && image === undefined) return void this._ch.update(character);
|
|
216
|
+
if (this._ch && character && image === undefined) return void this._ch.update(character, { material: this.cfg.material });
|
|
178
217
|
if (this.mode === '2d') this._load2D();
|
|
179
218
|
}
|
|
180
219
|
|
|
@@ -184,17 +223,22 @@ export class AvatarStage {
|
|
|
184
223
|
*/
|
|
185
224
|
async applyLook(look = {}) {
|
|
186
225
|
if (!this.cfg) return this.mode;
|
|
187
|
-
const
|
|
226
|
+
const plush = look.mode === '3d-plush' && !!look.character && !look.image;
|
|
227
|
+
const want3d = !plush && (look.mode === '3d' || (look.mode !== '2d' && !!look.url));
|
|
188
228
|
if (this.mode === 'none') {
|
|
189
229
|
Object.assign(this.cfg, { image: '', character: null }, look);
|
|
190
230
|
return this.mode;
|
|
191
231
|
}
|
|
192
232
|
const next = { image: '', character: null, ...look };
|
|
193
|
-
|
|
233
|
+
const isPlush = this.mode === '3d-plush';
|
|
234
|
+
if (plush !== isPlush || want3d !== (this.mode === '3d') || (this.mode === '3d' && ((look.url || '') !== (this.cfg.url || '') || (look.body && look.body !== this.cfg.body)))) {
|
|
194
235
|
return this.reload(next);
|
|
195
236
|
}
|
|
196
237
|
Object.assign(this.cfg, next);
|
|
197
|
-
if (
|
|
238
|
+
if (isPlush) {
|
|
239
|
+
this._ch.update(next.character, { material: next.material === 'vinyl' ? 'vinyl' : 'plush' });
|
|
240
|
+
if (next.mood) this.setMood(next.mood);
|
|
241
|
+
} else if (this.mode === '2d') {
|
|
198
242
|
if (this._ch && next.character && !next.image) this._ch.update(next.character);
|
|
199
243
|
else this._load2D();
|
|
200
244
|
if (next.mood) this.setMood(next.mood);
|
|
@@ -358,12 +402,12 @@ export class AvatarStage {
|
|
|
358
402
|
return this._ch.gesture(name, { mirror, ...(seconds ? { ms: seconds * 1000 } : {}) });
|
|
359
403
|
}
|
|
360
404
|
|
|
361
|
-
/** Transformación temporal del
|
|
405
|
+
/** Transformación temporal del ot ('wings', 'spikes', 'hands', 'shape:heart'…), 2D o 3D-plush. No hace nada con TalkingHead. */
|
|
362
406
|
morph(name, opts) {
|
|
363
407
|
return this._ch ? this._ch.morph(name, opts) : false;
|
|
364
408
|
}
|
|
365
409
|
|
|
366
|
-
/** Efecto
|
|
410
|
+
/** Efecto del ot ('hearts', 'sparkles', 'zzz'…), 2D o 3D-plush. No hace nada con TalkingHead. */
|
|
367
411
|
effect(name, opts) {
|
|
368
412
|
return this._ch ? this._ch.effect(name, opts) : false;
|
|
369
413
|
}
|
|
@@ -409,6 +453,7 @@ export class AvatarStage {
|
|
|
409
453
|
}
|
|
410
454
|
|
|
411
455
|
dispose() {
|
|
456
|
+
this._plushToken = null;
|
|
412
457
|
this.stop();
|
|
413
458
|
if (this._vis) document.removeEventListener('visibilitychange', this._vis);
|
|
414
459
|
try { this.head?.dispose?.(); } catch {}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carga de three para los avatares 3D, sin depender de que la página anfitriona declare el
|
|
3
|
+
* importmap (el widget vive en webs de terceros).
|
|
4
|
+
*
|
|
5
|
+
* - Bundle (dist/7ots.*.js): scripts/build.mjs cambia `import 'three'` y sus addons por
|
|
6
|
+
* lecturas de globalThis.__OTS_THREE__, y src/character3d queda dentro del bundle con
|
|
7
|
+
* evaluación perezosa. loadThree() rellena ese global desde el CDN (+esm de jsDelivr: los
|
|
8
|
+
* addons importan la MISMA instancia de three) antes del import dinámico.
|
|
9
|
+
* - Fuentes sin empaquetar (site/3d, desarrollo): `import 'three'` lo resuelve el importmap;
|
|
10
|
+
* si la página no lo tiene, ensureImportMap() lo inyecta (vale mientras no se haya cargado
|
|
11
|
+
* ningún módulo todavía, o en navegadores con varios importmaps).
|
|
12
|
+
*
|
|
13
|
+
* Este archivo NO importa three: se puede cargar siempre.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const THREE_VERSION = '0.170.0';
|
|
17
|
+
const CDN = `https://cdn.jsdelivr.net/npm/three@${THREE_VERSION}`;
|
|
18
|
+
const base = () => String(globalThis.OTS_THREE_BASE || CDN).replace(/\/$/, '');
|
|
19
|
+
|
|
20
|
+
/** Importmap de three (el mismo que declaran site/3d, backoffice, app y site/start). */
|
|
21
|
+
export function threeImportMap() {
|
|
22
|
+
return { imports: { three: `${base()}/build/three.module.js`, 'three/addons/': `${base()}/examples/jsm/` } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ¿Resuelve ya la página el especificador 'three'? Si no, inyecta el importmap. */
|
|
26
|
+
export function ensureImportMap() {
|
|
27
|
+
if (typeof document === 'undefined') return false;
|
|
28
|
+
for (const s of document.querySelectorAll('script[type="importmap"]')) {
|
|
29
|
+
try { if (JSON.parse(s.textContent || '{}').imports?.three) return true; } catch {}
|
|
30
|
+
}
|
|
31
|
+
const s = document.createElement('script');
|
|
32
|
+
s.type = 'importmap';
|
|
33
|
+
s.textContent = JSON.stringify(threeImportMap());
|
|
34
|
+
(document.head || document.documentElement).prepend(s);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let threeP = null;
|
|
39
|
+
|
|
40
|
+
/** three + los addons que usa src/character3d, en globalThis.__OTS_THREE__. */
|
|
41
|
+
export function loadThree() {
|
|
42
|
+
threeP ??= (async () => {
|
|
43
|
+
if (globalThis.__OTS_THREE__?.three) return globalThis.__OTS_THREE__;
|
|
44
|
+
const u = (p) => `${base()}/${p}/+esm`;
|
|
45
|
+
const [three, BufferGeometryUtils, RoomEnvironment] = await Promise.all([
|
|
46
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ `${base()}/+esm`),
|
|
47
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/utils/BufferGeometryUtils.js')),
|
|
48
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ u('examples/jsm/environments/RoomEnvironment.js')),
|
|
49
|
+
]);
|
|
50
|
+
return (globalThis.__OTS_THREE__ = { three, BufferGeometryUtils, RoomEnvironment });
|
|
51
|
+
})();
|
|
52
|
+
threeP.catch(() => { threeP = null; });
|
|
53
|
+
return threeP;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Módulo de los ots 3D (src/character3d/index.js) listo para usar. Lanza si no se puede. */
|
|
57
|
+
export async function loadCharacter3D() {
|
|
58
|
+
ensureImportMap();
|
|
59
|
+
// __OTS_BUNDLE__ lo define scripts/build.mjs; sin empaquetar, three viene del importmap
|
|
60
|
+
if (typeof __OTS_BUNDLE__ !== 'undefined') await loadThree();
|
|
61
|
+
return import('../character3d/index.js');
|
|
62
|
+
}
|