@7ots/cli 0.1.7 → 0.1.9
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/README.md +1 -1
- package/cli/7ots.mjs +28 -4
- package/cli/lib/account.mjs +152 -2
- package/cli/lib/assistant.mjs +106 -41
- package/cli/lib/brain.mjs +13 -2
- package/cli/lib/config.mjs +3 -0
- package/cli/lib/inferences.mjs +30 -6
- package/cli/lib/integrations.mjs +69 -5
- package/cli/lib/orquesta.mjs +44 -1
- package/cli/lib/pet-server.mjs +344 -11
- package/cli/pet/electron/director.cjs +37 -5
- package/cli/pet/electron/main.cjs +92 -10
- package/cli/pet/pet.html +149 -10
- package/cli/pet/settings.html +545 -42
- package/dist/7ots.esm.js +216 -137
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +216 -137
- package/dist/7ots.iife.js.map +4 -4
- package/package.json +1 -1
- package/server/channels/agent.mjs +7 -1
- package/server/channels/index.mjs +3 -2
- package/server/platform/connect.mjs +4 -1
- package/server/platform/db.mjs +9 -1
- package/server/platform/device.mjs +101 -4
- package/server/platform/mods.mjs +222 -0
- package/server/platform/orquesta.mjs +78 -14
- package/server/platform/profile.mjs +161 -0
- package/server/platform/routes.mjs +38 -33
- package/server/platform/runtime.mjs +3 -1
- package/server/platform/store.mjs +1 -1
- package/server/platform/vm.mjs +370 -91
- package/server/server.mjs +12 -2
- package/src/avatar/three.js +3 -2
- package/src/character/AvatarEditor.js +95 -7
- package/src/character/Character.js +116 -17
- package/src/character/TradingCard.js +226 -0
- package/src/character/mods/index.js +4 -0
- package/src/character/mods/orc.js +64 -0
- package/src/character/mods.js +251 -0
- package/src/character/more/acc-head.js +213 -0
- package/src/character/more/acc-rest.js +342 -0
- package/src/character/more/brows.js +39 -0
- package/src/character/more/cheeks.js +78 -0
- package/src/character/more/eyes.js +288 -0
- package/src/character/more/mouths.js +259 -0
- package/src/character/more/patterns.js +120 -0
- package/src/character/parts.js +90 -0
- package/src/character/skin.js +166 -1
- package/src/character/styles.js +1285 -9
- package/src/character3d/Character3D.js +148 -35
- package/src/character3d/index.js +7 -1
- package/src/character3d/model.js +367 -0
- package/src/i18n/messages/character.js +34 -22
- package/src/i18n/messages/platform.js +15 -0
- package/src/identity/random.js +17 -3
- package/src/identity/schema.js +10 -0
- package/src/index.js +1 -0
package/cli/lib/pet-server.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
|
|
27
27
|
* POST /config/test one line from the current brain → { ok, text, ms }
|
|
28
28
|
* POST /notes/forget { at | 'all' }
|
|
29
|
+
* POST /conversation/forget drop the conversation memory (turns + summary)
|
|
29
30
|
* POST /quit X-7ots-Token
|
|
30
31
|
*
|
|
31
32
|
* Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
|
|
@@ -33,28 +34,39 @@
|
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
import { createServer } from 'node:http';
|
|
36
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
37
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
37
38
|
import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, symlinkSync } from 'node:fs';
|
|
38
39
|
import { cardShot } from './card-shot.mjs';
|
|
39
40
|
import { join } from 'node:path';
|
|
40
41
|
import { createAssistant, notify, tr } from './assistant.mjs';
|
|
41
42
|
import { createWatchers, processes, processSummary } from './watchers.mjs';
|
|
42
|
-
import { createInferenceLog } from './inferences.mjs';
|
|
43
|
+
import { createInferenceLog, otherInferences } from './inferences.mjs';
|
|
44
|
+
|
|
45
|
+
const MAX_INF = 300;
|
|
43
46
|
import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
|
|
44
47
|
import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
|
|
45
48
|
import { loadIdentity } from './identity.mjs';
|
|
49
|
+
import { readJson, writeJson } from './paths.mjs';
|
|
46
50
|
import { sttProvider, transcribe } from './stt.mjs';
|
|
47
51
|
import { localVoices, synthesizeLocal, warmLocal } from './tts-local.mjs';
|
|
48
52
|
import { createScreenWatcher } from './screen.mjs';
|
|
49
53
|
import { createSiteContext } from './prowl.mjs';
|
|
50
|
-
import { SEVENOTS_URL, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.mjs';
|
|
54
|
+
import { SEVENOTS_URL, community, sync as syncOt, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.mjs';
|
|
51
55
|
import { openUrl } from './open.mjs';
|
|
52
56
|
import { VM_KINDS } from './vm.mjs';
|
|
53
57
|
import { cloudStatus, createExtras, lettaSync, normalizeCharacter, normalizeIntegrations } from './integrations.mjs';
|
|
54
58
|
import { spawn } from 'node:child_process';
|
|
55
59
|
import { openSync } from 'node:fs';
|
|
56
60
|
import { line } from './lines.mjs';
|
|
57
|
-
import { defineIdentity } from '../../src/identity/schema.js';
|
|
61
|
+
import { defineIdentity, GROK_VOICES, OPENAI_VOICES } from '../../src/identity/schema.js';
|
|
62
|
+
import { normalizeMod, normalizeMods, MAX_MODS } from '../../src/character/mods.js';
|
|
63
|
+
import { MOD_CATALOG } from '../../src/character/mods/index.js';
|
|
64
|
+
|
|
65
|
+
/** Mods importados en este pet (~/.7ots/mods/library.json), normalizados. */
|
|
66
|
+
// scenes between ots on the same desktop (pet/electron/director.cjs plays them)
|
|
67
|
+
const SCENE_NAMES = ['rocky', 'sf', 'dbz', 'matrix', 'duel', 'sw', 'fight', 'twist'];
|
|
68
|
+
const SCENE_EVERY = ['rare', 'normal', 'often'];
|
|
69
|
+
const modLibrary = () => (readJson(homeFile('mods/library.json')) || []).map(normalizeMod).filter(Boolean);
|
|
58
70
|
import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
|
|
59
71
|
import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
|
|
60
72
|
|
|
@@ -96,6 +108,7 @@ export async function postPet(path, body, port = PET_PORT) {
|
|
|
96
108
|
}
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
const VOICE_PROVIDERS = ['elevenlabs', 'openai', 'apuchat', 'grok', 'fish'];
|
|
99
112
|
const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN', 'ELEVENLABS_API_KEY', 'APUCHAT_VOICE_TOKEN', 'XAI_API_KEY', 'FISH_API_KEY', 'LETTA_API_KEY', 'APUMAIL_TOKEN'];
|
|
100
113
|
// The settings page speaks the human's language: SEVENOTS_LANG, config.lang, the system's, then the ot's.
|
|
101
114
|
const uiLangOf = (c, fallback) => [process.env.SEVENOTS_LANG, c.lang, String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').slice(0, 2).toLowerCase(), fallback].find((l) => ['en', 'es', 'pt'].includes(l)) || 'en';
|
|
@@ -108,11 +121,20 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
|
|
|
108
121
|
export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
109
122
|
const token = petToken();
|
|
110
123
|
let config = loadConfig();
|
|
124
|
+
let scenePlay = null; // { scene, at }: asked from settings, taken by the director on its next poll
|
|
125
|
+
const sceneConf = (c = config) => ({ on: true, every: 'normal', list: SCENE_NAMES, ...c.pet?.scenes });
|
|
111
126
|
const annoyOf = (c) => Math.max(0, Math.min(3, Number(c.pet.annoy ?? 2)));
|
|
112
127
|
let annoy = annoyOf(config);
|
|
113
128
|
const { identity } = loadIdentity();
|
|
114
129
|
if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
|
|
115
130
|
const lang = identity.language || 'en';
|
|
131
|
+
/** The voice for a provider: what its settings card saved, else the identity's (only if it is that provider's). */
|
|
132
|
+
const voiceFor = (kind) => {
|
|
133
|
+
const iv = identity.voice || {};
|
|
134
|
+
const own = config.voice.providers?.[kind] || {};
|
|
135
|
+
// the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
|
|
136
|
+
return { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}), ...own };
|
|
137
|
+
};
|
|
116
138
|
let brainImpl = createBrain(config.brain, config.home); // swapped live from the settings page
|
|
117
139
|
const inferences = createInferenceLog();
|
|
118
140
|
if (config.voice.kind === 'local') warmLocal();
|
|
@@ -131,6 +153,51 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
131
153
|
for (const res of clients) res.write(data);
|
|
132
154
|
};
|
|
133
155
|
|
|
156
|
+
// Approve / Reject in its bubble before acting for you (Orquesta…). Unanswered in 2 min = rejected.
|
|
157
|
+
const asks = new Map(); // id → { text, done }
|
|
158
|
+
function confirm(text, ms = 120_000) {
|
|
159
|
+
const id = randomBytes(6).toString('hex');
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const done = (ok) => {
|
|
162
|
+
if (!asks.delete(id)) return;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
log(`approval ${ok ? 'yes' : 'no'}: ${text.slice(0, 80)}`);
|
|
165
|
+
broadcast({ ask: { id, done: true }, thinking: ok });
|
|
166
|
+
resolve(ok);
|
|
167
|
+
};
|
|
168
|
+
const timer = setTimeout(() => done(false), ms);
|
|
169
|
+
asks.set(id, { text, done });
|
|
170
|
+
lastSpoke = Date.now();
|
|
171
|
+
if (config.access.notify !== false) notify(`${identity.name} · 7ots`, text);
|
|
172
|
+
broadcast({ ask: { id, text }, thinking: false, gesture: 'think', point: 'user', stir: true });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Orquesta sign-in in the browser; once in, its integration is on
|
|
176
|
+
let connecting = null;
|
|
177
|
+
function connectOrquesta() {
|
|
178
|
+
return (connecting ||= import('./orquesta.mjs')
|
|
179
|
+
.then(async (o) => {
|
|
180
|
+
if (o.via() !== '7ots') return o.login({ open: openUrl });
|
|
181
|
+
// signed in to 7ots.com: connect it there (once for the ot everywhere); its token stays there
|
|
182
|
+
openUrl(`${SEVENOTS_URL}/app/#ots/${config.account.otsId}/orquesta`);
|
|
183
|
+
for (const end = Date.now() + 10 * 60_000; Date.now() < end; ) {
|
|
184
|
+
await new Promise((r) => setTimeout(r, 4000));
|
|
185
|
+
if ((await o.cloudOrquesta().catch(() => null))?.connected) return { organizationName: '' };
|
|
186
|
+
}
|
|
187
|
+
throw new Error('not connected on 7ots.com in time');
|
|
188
|
+
})
|
|
189
|
+
.then((r) => {
|
|
190
|
+
const next = loadConfig();
|
|
191
|
+
next.integrations = { ...next.integrations, orquesta: { ...next.integrations?.orquesta, on: true } };
|
|
192
|
+
saveConfig(next);
|
|
193
|
+
config = loadConfig();
|
|
194
|
+
log(`orquesta: connected${r.organizationName ? ` (${r.organizationName})` : ''}`);
|
|
195
|
+
broadcast({ orquesta: true });
|
|
196
|
+
return r;
|
|
197
|
+
})
|
|
198
|
+
.finally(() => (connecting = null)));
|
|
199
|
+
}
|
|
200
|
+
|
|
134
201
|
async function speak(kind, vars = {}, situation = '', { force = false, gesture = null } = {}) {
|
|
135
202
|
const now = Date.now();
|
|
136
203
|
if (!force && (pet.asleep || now - lastSpoke < GAP[annoy])) {
|
|
@@ -238,6 +305,28 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
238
305
|
broadcast({});
|
|
239
306
|
}, 60_000);
|
|
240
307
|
|
|
308
|
+
// Sync with its ot on 7ots.com (account.mjs sync): same look, mods, personality and sheet on both sides.
|
|
309
|
+
let syncing = false;
|
|
310
|
+
let syncSoon = null;
|
|
311
|
+
let syncErr = '';
|
|
312
|
+
async function cloudSync() {
|
|
313
|
+
if (syncing) return;
|
|
314
|
+
syncing = true;
|
|
315
|
+
try {
|
|
316
|
+
const r = await syncOt();
|
|
317
|
+
syncErr = '';
|
|
318
|
+
if (r.action === 'push' || r.action === 'pull') log(`sync: ${r.action} 7ots.com`);
|
|
319
|
+
if (r.action === 'pull') restart(); // its new look/personality, everywhere
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e.message !== syncErr) log(`sync: ${(syncErr = e.message)}`);
|
|
322
|
+
} finally {
|
|
323
|
+
syncing = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const syncTimer = setInterval(cloudSync, 60_000);
|
|
327
|
+
const syncFirst = setTimeout(cloudSync, 8000);
|
|
328
|
+
const syncLater = () => (clearTimeout(syncSoon), (syncSoon = setTimeout(cloudSync, 3000)));
|
|
329
|
+
|
|
241
330
|
// Watchers: a price or a program it waits for, then it tells you (like a reminder, on a condition).
|
|
242
331
|
const watchers = createWatchers({
|
|
243
332
|
log,
|
|
@@ -254,6 +343,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
254
343
|
const assistant = createAssistant({
|
|
255
344
|
brain,
|
|
256
345
|
access: () => config.access,
|
|
346
|
+
memoryTokens: () => config.memory.tokens,
|
|
257
347
|
lang,
|
|
258
348
|
name: identity.name,
|
|
259
349
|
log,
|
|
@@ -280,6 +370,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
280
370
|
brain,
|
|
281
371
|
log,
|
|
282
372
|
system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
|
|
373
|
+
confirm,
|
|
374
|
+
connect: connectOrquesta,
|
|
283
375
|
onLater: (text) => {
|
|
284
376
|
lastSpoke = Date.now();
|
|
285
377
|
pet.lastTalk = lastSpoke;
|
|
@@ -406,6 +498,12 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
406
498
|
if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
|
|
407
499
|
return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
|
|
408
500
|
}
|
|
501
|
+
if (req.method === 'GET' && /^\/mods\/[a-f0-9]{16}\.(vrm|glb)$/.test(p)) {
|
|
502
|
+
// 3D models of full-body mods, uploaded from settings (content-addressed, so cacheable)
|
|
503
|
+
const f = homeFile(p.slice(1));
|
|
504
|
+
if (!existsSync(f)) return res.writeHead(404).end();
|
|
505
|
+
return res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'max-age=31536000, immutable' }).end(readFileSync(f));
|
|
506
|
+
}
|
|
409
507
|
if (req.method === 'GET' && p === '/favicon.svg') return res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }).end(readFileSync(join(PKG_ROOT, 'brand', 'favicon.svg')));
|
|
410
508
|
const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
|
|
411
509
|
if (!authed) return json(401, { error: 'token' });
|
|
@@ -448,12 +546,27 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
448
546
|
if (req.method === 'GET' && p === '/events') {
|
|
449
547
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
450
548
|
res.write(`data: ${JSON.stringify({ state: view(pet), ...(viewSet ? { view: viewSet } : {}) })}\n\n`);
|
|
549
|
+
for (const [id, a] of asks) res.write(`data: ${JSON.stringify({ ask: { id, text: a.text } })}\n\n`); // still waiting for you
|
|
451
550
|
clients.add(res);
|
|
452
551
|
req.on('close', () => clients.delete(res));
|
|
453
552
|
return;
|
|
454
553
|
}
|
|
455
|
-
if (req.method === 'GET' && p === '/
|
|
456
|
-
|
|
554
|
+
if (req.method === 'GET' && p === '/scenes') {
|
|
555
|
+
// polled by the desktop shell's director: how often ots play scenes, which ones, and a pending "play now"
|
|
556
|
+
const play = scenePlay;
|
|
557
|
+
scenePlay = null;
|
|
558
|
+
return json(200, { ...sceneConf(), play });
|
|
559
|
+
}
|
|
560
|
+
if (req.method === 'GET' && p === '/inferences') {
|
|
561
|
+
// this ot's calls plus those of the ots visiting this desktop (the host sees all), each tagged with its ot
|
|
562
|
+
const n = Number(url.searchParams.get('n')) || 100;
|
|
563
|
+
const me = { id: config.account?.otsId || 'local', name: identity.name || 'ot', self: true };
|
|
564
|
+
const all = [...inferences.list(MAX_INF).map((r) => ({ ...r, ot: me })), ...otherInferences()].sort((a, b) => b.at - a.at);
|
|
565
|
+
const ots = [...new Map(all.map((r) => [r.ot.id, r.ot])).values()];
|
|
566
|
+
const want = url.searchParams.get('ot');
|
|
567
|
+
return json(200, { ots, inferences: (want ? all.filter((r) => r.ot.id === want) : all).slice(0, n) });
|
|
568
|
+
}
|
|
569
|
+
if (req.method === 'GET' && p === '/memory') return json(200, { notes: assistant.notes, reminders: assistant.reminders, seen: watcher.seen, said: history, conversation: assistant.conversation, activity, pet: view(pet) });
|
|
457
570
|
if (req.method === 'GET' && p === '/account') {
|
|
458
571
|
const base = { url: SEVENOTS_URL, current: config.account || null, signedIn: signedIn() };
|
|
459
572
|
if (!base.signedIn) return json(200, base);
|
|
@@ -473,13 +586,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
473
586
|
if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
|
|
474
587
|
if (req.method === 'GET' && p === '/cloud') return json(200, await cloudStatus(config));
|
|
475
588
|
if (req.method === 'GET' && (p === '/orquesta/projects' || p === '/orquesta/prompt')) {
|
|
476
|
-
|
|
589
|
+
const o = await import('./orquesta.mjs');
|
|
590
|
+
if (!o.via()) return json(200, { connected: false });
|
|
477
591
|
try {
|
|
478
|
-
const o = await import('./orquesta.mjs');
|
|
479
592
|
if (p === '/orquesta/projects') return json(200, { connected: true, projects: await o.agentProjects() });
|
|
480
593
|
return json(200, { connected: true, ...(await o.agentTask(String(url.searchParams.get('id') || '').slice(0, 80))) });
|
|
481
594
|
} catch (e) {
|
|
482
|
-
|
|
595
|
+
const off = [401, 409].includes(e.status); // no token / not connected on 7ots.com
|
|
596
|
+
return json(off ? 200 : 502, { connected: !off, error: off ? undefined : e.message });
|
|
483
597
|
}
|
|
484
598
|
}
|
|
485
599
|
if (req.method !== 'POST') return json(405, { error: 'method' });
|
|
@@ -500,6 +614,23 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
500
614
|
return json(e.status || 502, { error: e.message });
|
|
501
615
|
}
|
|
502
616
|
}
|
|
617
|
+
if (p === '/mods/upload') {
|
|
618
|
+
// a .vrm/.glb for a full-body mod: stored in ~/.7ots/mods/<sha256-16>.<ext>
|
|
619
|
+
const chunks = [];
|
|
620
|
+
let n = 0;
|
|
621
|
+
for await (const c of req) {
|
|
622
|
+
if ((n += c.length) > 60 * 1024 * 1024) return json(413, { error: 'too large' });
|
|
623
|
+
chunks.push(c);
|
|
624
|
+
}
|
|
625
|
+
const buf = Buffer.concat(chunks);
|
|
626
|
+
if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return json(400, { error: 'model' });
|
|
627
|
+
const ext = url.searchParams.get('ext') === 'vrm' ? 'vrm' : 'glb';
|
|
628
|
+
const name = `${createHash('sha256').update(buf).digest('hex').slice(0, 16)}.${ext}`;
|
|
629
|
+
mkdirSync(homeFile('mods'), { recursive: true });
|
|
630
|
+
writeFileSync(homeFile(`mods/${name}`), buf);
|
|
631
|
+
log(`mods: model ${name} ${Math.round(buf.length / 1024)}KB`);
|
|
632
|
+
return json(200, { ok: true, url: `/mods/${name}` });
|
|
633
|
+
}
|
|
503
634
|
let body = '';
|
|
504
635
|
for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
|
|
505
636
|
let data = {};
|
|
@@ -508,6 +639,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
508
639
|
} catch {
|
|
509
640
|
return json(400, { error: 'json' });
|
|
510
641
|
}
|
|
642
|
+
if (p.startsWith('/identity/') || p === '/config') syncLater(); // a local change goes up to 7ots.com
|
|
511
643
|
if (p === '/event') {
|
|
512
644
|
onEvent(data);
|
|
513
645
|
savePet(pet);
|
|
@@ -524,7 +656,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
524
656
|
const { synthesize } = await import('../../server/tts.mjs');
|
|
525
657
|
// the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
|
|
526
658
|
const iv = identity.voice || {};
|
|
527
|
-
const voice =
|
|
659
|
+
const voice = voiceFor(kind);
|
|
528
660
|
// process.env too: XAI_TTS_VOICE, FISH_VOICE_ID, APUCHAT_VOICE_URL… (the keys still come from env())
|
|
529
661
|
const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: { ...process.env, ...env() } });
|
|
530
662
|
return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
|
|
@@ -532,6 +664,20 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
532
664
|
return json(e.status || 502, { error: e.message });
|
|
533
665
|
}
|
|
534
666
|
}
|
|
667
|
+
if (p === '/voice/test') {
|
|
668
|
+
// try a provider's voice before saving it: { kind, voiceId, model, style, text }
|
|
669
|
+
const kind = String(data.kind || '');
|
|
670
|
+
if (!VOICE_PROVIDERS.includes(kind)) return json(400, { error: 'kind' });
|
|
671
|
+
try {
|
|
672
|
+
const { synthesize } = await import('../../server/tts.mjs');
|
|
673
|
+
const over = Object.fromEntries(['voiceId', 'model', 'style'].map((f) => [f, String(data[f] ?? '').trim().slice(0, 600)]));
|
|
674
|
+
const text = String(data.text || '').trim().slice(0, 300) || ({ es: `Hola, soy ${identity.name}. ¿Así me quieres oír?`, pt: `Oi, eu sou ${identity.name}. Quer me ouvir assim?` }[lang] || `Hi, I'm ${identity.name}. Is this how you want me to sound?`);
|
|
675
|
+
const { audio, contentType } = await synthesize({ text, voice: { ...voiceFor(kind), ...over }, env: { ...process.env, ...env() } });
|
|
676
|
+
return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
|
|
677
|
+
} catch (e) {
|
|
678
|
+
return json(e.status || 502, { error: e.message });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
535
681
|
if (p === '/ask') {
|
|
536
682
|
const text = String(data.text || '').trim().slice(0, 800);
|
|
537
683
|
if (!text) return json(400, { error: 'text' });
|
|
@@ -603,10 +749,88 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
603
749
|
}
|
|
604
750
|
return restart(); // identity, language and name are read once at start
|
|
605
751
|
}
|
|
752
|
+
if (p.startsWith('/community/')) {
|
|
753
|
+
// the 7ots.com gallery (server/platform/mods.mjs): browse without an account; share, rate and report with one
|
|
754
|
+
const act = p.slice('/community/'.length);
|
|
755
|
+
try {
|
|
756
|
+
if (act === 'list') return json(200, { signedIn: signedIn(), ...(await community.list(data)) });
|
|
757
|
+
if (!signedIn() && act !== 'install') return json(401, { error: 'login' });
|
|
758
|
+
if (act === 'mine') return json(200, await community.mine());
|
|
759
|
+
if (act === 'install') {
|
|
760
|
+
const mod = await community.install(data.id);
|
|
761
|
+
const [status, out] = changeMods({ mod });
|
|
762
|
+
return json(status, out);
|
|
763
|
+
}
|
|
764
|
+
if (act === 'rate') return json(200, await community.rate(data.id, data.stars));
|
|
765
|
+
if (act === 'report') return json(200, await community.report(data.id, data.reason));
|
|
766
|
+
if (act === 'remove') return json(200, await community.remove(data.id));
|
|
767
|
+
if (act === 'share') {
|
|
768
|
+
const mod = modLibrary().find((m) => m.id === data.id) || normalizeMods(identity.look?.character?.mods).find((m) => m.id === data.id);
|
|
769
|
+
if (!mod) return json(404, { error: 'mod' });
|
|
770
|
+
return json(200, await community.share(mod));
|
|
771
|
+
}
|
|
772
|
+
} catch (e) {
|
|
773
|
+
log(`community ${act}: ${e.message}`);
|
|
774
|
+
return json(e.status || 502, { error: e.message });
|
|
775
|
+
}
|
|
776
|
+
return json(404, { error: 'not found' });
|
|
777
|
+
}
|
|
778
|
+
if (p === '/identity/mods') {
|
|
779
|
+
const [status, out] = changeMods(data);
|
|
780
|
+
return json(status, out);
|
|
781
|
+
}
|
|
782
|
+
/** mods of the ot's look (a catalog one by id, or an imported .json); the pet reloads with them */
|
|
783
|
+
function changeMods(data) {
|
|
784
|
+
const { file } = loadIdentity();
|
|
785
|
+
const raw = readJson(file) || {};
|
|
786
|
+
const ch = raw.look?.character || {};
|
|
787
|
+
let mods = normalizeMods(ch.mods);
|
|
788
|
+
if (data.remove) mods = mods.filter((m) => m.id !== data.remove);
|
|
789
|
+
if (data.add || data.mod) {
|
|
790
|
+
const m = data.add ? MOD_CATALOG.find((c) => c.id === data.add) : data.mod;
|
|
791
|
+
const n = m && normalizeMod(m);
|
|
792
|
+
if (!n) return [400, { error: 'mod' }];
|
|
793
|
+
if (!mods.some((x) => x.id === n.id) && mods.length >= MAX_MODS) return [400, { error: 'maxMods' }];
|
|
794
|
+
// un muñeco completo reemplaza al ot entero: los accesorios 2D no se verían encima, así que
|
|
795
|
+
// poner uno quita el otro (si no, «poner orc» sobre un modelo no cambiaba nada)
|
|
796
|
+
mods = n.model ? [n] : [...mods.filter((x) => x.id !== n.id && !x.model), n];
|
|
797
|
+
// los importados quedan en la biblioteca local aunque después se quiten (o los reemplace otro)
|
|
798
|
+
if (data.mod) writeJson(homeFile('mods/library.json'), [...modLibrary().filter((x) => x.id !== n.id), n].slice(-60));
|
|
799
|
+
}
|
|
800
|
+
if (data.forget) writeJson(homeFile('mods/library.json'), modLibrary().filter((x) => x.id !== data.forget));
|
|
801
|
+
const look = { ...raw.look, character: { ...ch, mods } };
|
|
802
|
+
writeJson(file, { ...raw, look });
|
|
803
|
+
identity.look = defineIdentity({ ...raw, look }).look;
|
|
804
|
+
log(`identity: mods ${mods.map((m) => m.id).join(', ') || 'none'}`);
|
|
805
|
+
setTimeout(() => broadcast({ reload: true }), 100);
|
|
806
|
+
return [200, { ok: true, mods: mods.map((m) => m.id) }];
|
|
807
|
+
}
|
|
808
|
+
if (p === '/identity/lang') {
|
|
809
|
+
// the language the ot itself speaks (bubbles, brain, meet), kept in its identity file
|
|
810
|
+
if (!['en', 'es', 'pt'].includes(data.lang)) return json(400, { error: 'lang' });
|
|
811
|
+
const { file } = loadIdentity();
|
|
812
|
+
const raw = readJson(file) || {};
|
|
813
|
+
writeJson(file, { ...raw, language: data.lang, voice: { ...raw.voice, lang: { es: 'es-ES', en: 'en-US', pt: 'pt-BR' }[data.lang] } });
|
|
814
|
+
log(`identity: speaks ${data.lang}`);
|
|
815
|
+
json(200, { ok: true });
|
|
816
|
+
return restart();
|
|
817
|
+
}
|
|
606
818
|
if (p === '/inferences/clear') return inferences.clear(), json(200, { ok: true });
|
|
819
|
+
if (p === '/conversation/forget') return json(200, { ok: assistant.forgetConversation() });
|
|
607
820
|
if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
|
|
608
821
|
if (p === '/watchers/cancel') return json(200, { ok: watchers.cancel(String(data.id || '')) });
|
|
609
822
|
if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
|
|
823
|
+
if (p === '/approve') {
|
|
824
|
+
const a = asks.get(String(data.id || ''));
|
|
825
|
+
if (!a) return json(404, { error: 'gone' });
|
|
826
|
+
a.done(data.ok === true);
|
|
827
|
+
return json(200, { ok: true });
|
|
828
|
+
}
|
|
829
|
+
if (p === '/orquesta/connect') {
|
|
830
|
+
const r = connectOrquesta();
|
|
831
|
+
r.catch((e) => log(`orquesta: ${e.message}`));
|
|
832
|
+
return json(200, { ok: true });
|
|
833
|
+
}
|
|
610
834
|
if (p === '/orquesta/send') {
|
|
611
835
|
const projectId = String(data.projectId || '');
|
|
612
836
|
const text = String(data.text || '').trim();
|
|
@@ -617,6 +841,78 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
617
841
|
return json(e.status === 400 ? 400 : 502, { error: e.message });
|
|
618
842
|
}
|
|
619
843
|
}
|
|
844
|
+
if (p === '/voice/list') {
|
|
845
|
+
// the voices a provider offers with the saved key (ElevenLabs: yours + saved from the library; Fish: your models)
|
|
846
|
+
const kind = String(data.kind || '');
|
|
847
|
+
const keys = env();
|
|
848
|
+
try {
|
|
849
|
+
if (kind === 'elevenlabs') {
|
|
850
|
+
if (!keys.ELEVENLABS_API_KEY) return json(400, { error: 'key' });
|
|
851
|
+
const r = await fetch('https://api.elevenlabs.io/v1/voices', { headers: { 'xi-api-key': keys.ELEVENLABS_API_KEY }, signal: AbortSignal.timeout(15000) });
|
|
852
|
+
if (!r.ok) return json(502, { error: `ElevenLabs ${r.status}` });
|
|
853
|
+
const j = await r.json();
|
|
854
|
+
return json(200, { voices: (j.voices || []).map((v) => ({ id: v.voice_id, name: v.name, note: [v.category, v.labels?.accent, v.labels?.gender, v.labels?.age].filter(Boolean).join(' · '), preview: v.preview_url || '' })) });
|
|
855
|
+
}
|
|
856
|
+
if (kind === 'fish') {
|
|
857
|
+
if (!keys.FISH_API_KEY) return json(400, { error: 'key' });
|
|
858
|
+
const r = await fetch('https://api.fish.audio/model?self=true&page_size=50', { headers: { Authorization: `Bearer ${keys.FISH_API_KEY}` }, signal: AbortSignal.timeout(15000) });
|
|
859
|
+
if (!r.ok) return json(502, { error: `Fish Audio ${r.status}` });
|
|
860
|
+
const j = await r.json();
|
|
861
|
+
return json(200, { voices: (j.items || []).map((v) => ({ id: v._id, name: v.title, note: (v.languages || []).join(', ') })) });
|
|
862
|
+
}
|
|
863
|
+
if (kind === 'openai') return json(200, { voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) });
|
|
864
|
+
if (kind === 'grok') return json(200, { voices: GROK_VOICES.map((v) => ({ id: v, name: v })) });
|
|
865
|
+
return json(200, { voices: [] });
|
|
866
|
+
} catch (e) {
|
|
867
|
+
return json(502, { error: e.message });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (p === '/character/draft') {
|
|
871
|
+
// the brain drafts the character sheet from the identity (+ an optional idea); nothing is saved
|
|
872
|
+
if (brain.kind === 'lines') return json(400, { error: 'brain' });
|
|
873
|
+
const cur = config.character || {};
|
|
874
|
+
const KEYS = ['backstory', 'style', 'quirks', 'scenario', 'greeting', 'examples'];
|
|
875
|
+
const who = { name: identity.name, role: identity.role, tagline: identity.tagline, bio: identity.bio, tone: identity.personality?.tone, traits: identity.personality?.traits, look: (identity.look?.character?.mods || []).map((m) => m.id) };
|
|
876
|
+
const L = ['es', 'en', 'pt'].includes(data.lang) ? data.lang : lang;
|
|
877
|
+
const LN = { es: 'Spanish (español)', pt: 'Brazilian Portuguese (português)', en: 'English' }[L];
|
|
878
|
+
const FIELDS = {
|
|
879
|
+
backstory: 'backstory (4-6 sentences: where it comes from, what it wants, what it fears)',
|
|
880
|
+
style: 'style (how it talks: rhythm, words it likes, what it never does)',
|
|
881
|
+
quirks: 'quirks (5-8 short habits, one per line)',
|
|
882
|
+
scenario: 'scenario (1-2 sentences: where it lives and what it does all day)',
|
|
883
|
+
greeting: 'greeting (one short line)',
|
|
884
|
+
examples: `examples (3-4 tiny exchanges, lines «Human: …» and «${identity.name}: …»)`,
|
|
885
|
+
};
|
|
886
|
+
const ask = (keys) => `Write a character sheet for a desktop pet called ${identity.name}.
|
|
887
|
+
LANGUAGE: write every field entirely in ${LN}, even if the profile below is in another language (translate it).
|
|
888
|
+
Who it is (from its profile): ${JSON.stringify(who)}
|
|
889
|
+
${KEYS.some((k) => cur[k]) ? `Current sheet (keep what is good, improve the rest): ${JSON.stringify(Object.fromEntries(KEYS.map((k) => [k, cur[k] || ''])))}` : ''}
|
|
890
|
+
${data.idea ? `The owner's idea for it: «${String(data.idea).slice(0, 600)}»` : ''}
|
|
891
|
+
Make it varied and specific so it never gets stuck on one or two topics (it is a companion that comments on the owner's coding work, chats and keeps them company): many interests, opinions, running jokes, small habits.
|
|
892
|
+
Answer ONLY a flat JSON object with exactly these keys, each a non-empty string in ${LN}:
|
|
893
|
+
${keys.map((k) => FIELDS[k]).join('\n')}`;
|
|
894
|
+
// models drift: other casing, arrays, nested objects or a wrapper ({ sheet: {…} })
|
|
895
|
+
const textOf = (v) => (Array.isArray(v) ? v.map(textOf).join('\n') : v && typeof v === 'object' ? Object.entries(v).map(([k, x]) => (typeof x === 'object' ? textOf(x) : /^(human|user|q|a|\d+)$/i.test(k) ? x : `${k}: ${x}`)).join('\n') : String(v ?? ''));
|
|
896
|
+
const pick = (j) => {
|
|
897
|
+
const o = j && typeof j === 'object' ? (Object.keys(j).length === 1 && typeof Object.values(j)[0] === 'object' && !Array.isArray(Object.values(j)[0]) ? Object.values(j)[0] : j) : {};
|
|
898
|
+
const low = Object.fromEntries(Object.entries(o).map(([k, v]) => [k.toLowerCase().replace(/[^a-z]/g, ''), v]));
|
|
899
|
+
const alias = { backstory: ['backstory', 'history', 'background', 'story', 'description'], style: ['style', 'speakingstyle', 'voice'], quirks: ['quirks', 'habits'], scenario: ['scenario', 'setting'], greeting: ['greeting', 'firstmessage', 'hello'], examples: ['examples', 'exampledialogue', 'dialogues', 'exchanges'] };
|
|
900
|
+
return Object.fromEntries(KEYS.map((k) => [k, textOf(alias[k].map((a) => low[a]).find((v) => v != null && v !== '') ?? '').trim()]));
|
|
901
|
+
};
|
|
902
|
+
const run = async (keys) => {
|
|
903
|
+
const raw = String(await brain.think({ purpose: 'character', system: `You are a character writer. You write in ${LN}. Output strict JSON only.`, messages: [{ role: 'user', content: ask(keys) }], timeoutMs: 120000, raw: true }));
|
|
904
|
+
return pick(JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)));
|
|
905
|
+
};
|
|
906
|
+
try {
|
|
907
|
+
const out = await run(KEYS);
|
|
908
|
+
const missing = KEYS.filter((k) => !out[k]);
|
|
909
|
+
if (missing.length) Object.assign(out, Object.fromEntries(Object.entries(await run(missing).catch(() => ({}))).filter(([k, v]) => missing.includes(k) && v)));
|
|
910
|
+
const lim = { backstory: 3000, style: 1500, quirks: 1500, scenario: 1500, greeting: 600, examples: 4000 };
|
|
911
|
+
return json(200, { lang: L, sheet: Object.fromEntries(KEYS.map((k) => [k, (out[k] || '').slice(0, lim[k])])) });
|
|
912
|
+
} catch (e) {
|
|
913
|
+
return json(502, { error: e.message });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
620
916
|
if (p === '/character/letta') {
|
|
621
917
|
try {
|
|
622
918
|
const r = await lettaSync(identity, config.character);
|
|
@@ -631,6 +927,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
631
927
|
return json(e.status === 400 ? 400 : 502, { error: e.message });
|
|
632
928
|
}
|
|
633
929
|
}
|
|
930
|
+
if (p === '/scenes/play') {
|
|
931
|
+
scenePlay = { scene: SCENE_NAMES.includes(data.scene) ? data.scene : '', at: Date.now() };
|
|
932
|
+
return json(200, { ok: true });
|
|
933
|
+
}
|
|
634
934
|
if (p === '/quit') {
|
|
635
935
|
json(200, { ok: true });
|
|
636
936
|
await Promise.all(fullOnes().map(quitCompanion)); // the second ot leaves with it (a restart keeps it)
|
|
@@ -645,7 +945,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
645
945
|
return {
|
|
646
946
|
name: identity.name,
|
|
647
947
|
lang,
|
|
648
|
-
|
|
948
|
+
visitor: !!process.env.SEVENOTS_COMPANION, // a visiting ot: the host's settings rule shared things (scenes)
|
|
949
|
+
config: { brain: config.brain, pet: { ...config.pet, scenes: sceneConf() }, access: config.access, voice: config.voice, screen: config.screen, memory: config.memory },
|
|
649
950
|
brain: brainLabel(config.brain),
|
|
650
951
|
clis: detectClis(),
|
|
651
952
|
stt: sttProvider(keys),
|
|
@@ -653,6 +954,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
653
954
|
voices: { local: localVoices(), keys: { elevenlabs: Boolean(keys.ELEVENLABS_API_KEY), openai: Boolean(keys.OPENAI_API_KEY), apuchat: Boolean(keys.APUCHAT_VOICE_TOKEN), grok: Boolean(keys.XAI_API_KEY), fish: Boolean(keys.FISH_API_KEY) } },
|
|
654
955
|
uiLang: uiLangOf(config, lang),
|
|
655
956
|
configLang: config.lang || '',
|
|
957
|
+
otLook: identity.look?.character || null,
|
|
958
|
+
// the catalog without its 3D models (the page only draws the 2D previews)
|
|
959
|
+
modLibrary: modLibrary(),
|
|
960
|
+
modCatalog: MOD_CATALOG.map((m) => normalizeMod(m)).map((m) => ({ ...m, parts: m.parts.map((x) => ({ ...x, glb: x.glb ? '3d' : '' })) })),
|
|
656
961
|
integrations: config.integrations,
|
|
657
962
|
character: config.character,
|
|
658
963
|
account: config.account || null,
|
|
@@ -693,6 +998,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
693
998
|
if (d.pet) {
|
|
694
999
|
if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
|
|
695
1000
|
if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
|
|
1001
|
+
if (d.pet.scenes) {
|
|
1002
|
+
const c = { ...sceneConf(next), ...d.pet.scenes };
|
|
1003
|
+
next.pet.scenes = {
|
|
1004
|
+
on: c.on !== false,
|
|
1005
|
+
every: SCENE_EVERY.includes(c.every) ? c.every : 'normal',
|
|
1006
|
+
list: Array.isArray(c.list) ? [...new Set(c.list.filter((n) => SCENE_NAMES.includes(n)))] : SCENE_NAMES,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
696
1009
|
}
|
|
697
1010
|
if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
|
|
698
1011
|
if (Array.isArray(d.access?.vmKinds)) next.access.vmKinds = [...new Set(d.access.vmKinds.filter((k) => VM_KINDS.includes(k)))];
|
|
@@ -706,12 +1019,29 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
706
1019
|
} catch (e) {
|
|
707
1020
|
return e.message;
|
|
708
1021
|
}
|
|
1022
|
+
if (d.memory?.tokens !== undefined) {
|
|
1023
|
+
const n = Math.round(Number(d.memory.tokens)) || 0;
|
|
1024
|
+
next.memory.tokens = n <= 0 ? 0 : Math.max(2000, Math.min(200000, n));
|
|
1025
|
+
}
|
|
709
1026
|
if (d.screen?.every !== undefined) next.screen.every = Math.max(1, Math.min(60, Math.round(Number(d.screen.every)) || 5));
|
|
710
1027
|
let reload = false;
|
|
711
1028
|
if (d.voice) {
|
|
712
1029
|
if (!['none', 'browser', 'local', 'elevenlabs', 'openai', 'apuchat', 'grok', 'fish'].includes(d.voice.kind)) return 'voice';
|
|
713
1030
|
reload = d.voice.kind !== next.voice.kind;
|
|
714
1031
|
next.voice = { ...next.voice, kind: d.voice.kind };
|
|
1032
|
+
// each provider's own voice, model and style ({ elevenlabs: { voiceId, model }, openai: { voiceId, model, style } … })
|
|
1033
|
+
const pv = d.voice.providers;
|
|
1034
|
+
if (pv && typeof pv === 'object') {
|
|
1035
|
+
const providers = { ...(next.voice.providers || {}) };
|
|
1036
|
+
for (const [k, v] of Object.entries(pv)) {
|
|
1037
|
+
if (!VOICE_PROVIDERS.includes(k) || !v || typeof v !== 'object') continue;
|
|
1038
|
+
const clean = Object.fromEntries(['voiceId', 'model', 'style', 'name'].map((f) => [f, String(v[f] ?? '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, f === 'style' ? 600 : 120)]).filter(([, x]) => x));
|
|
1039
|
+
if (Object.keys(clean).length) providers[k] = clean;
|
|
1040
|
+
else delete providers[k];
|
|
1041
|
+
}
|
|
1042
|
+
next.voice.providers = providers;
|
|
1043
|
+
reload = true;
|
|
1044
|
+
}
|
|
715
1045
|
}
|
|
716
1046
|
if (d.keys) {
|
|
717
1047
|
for (const [k, v] of Object.entries(d.keys)) {
|
|
@@ -744,6 +1074,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
744
1074
|
|
|
745
1075
|
function stop(then) {
|
|
746
1076
|
clearInterval(timer);
|
|
1077
|
+
clearInterval(syncTimer);
|
|
1078
|
+
clearTimeout(syncFirst);
|
|
1079
|
+
clearTimeout(syncSoon);
|
|
747
1080
|
assistant.stop();
|
|
748
1081
|
watchers.stop();
|
|
749
1082
|
stopWatch();
|
|
@@ -98,12 +98,36 @@ const SCENES = {
|
|
|
98
98
|
|
|
99
99
|
const GAP = 170; // how far apart they stand (their windows grow for what flies between them)
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// minutes between scenes, per the "how often" setting
|
|
102
|
+
const EVERY = { rare: [5, 10], normal: [1.5, 3.5], often: [0.5, 1] };
|
|
103
|
+
|
|
104
|
+
exports.createDirector = ({ ots, lang = 'en', url, token }) => {
|
|
102
105
|
const L = LINES[lang] || LINES.en;
|
|
103
106
|
let running = false;
|
|
104
107
|
const SOON = process.env.SEVENOTS_PET_SCENES_SOON === '1'; // testing: one scene after another
|
|
105
108
|
let nextAt = Date.now() + (SOON ? 4000 : 25000);
|
|
106
109
|
let last = '';
|
|
110
|
+
// settings (daemon GET /scenes): on/off, how often, which scenes; plus a "play one now" from settings
|
|
111
|
+
let conf = { on: true, every: 'normal', list: Object.keys(SCENES) };
|
|
112
|
+
let want = null; // scene asked for ('' = any), played as soon as two ots are free
|
|
113
|
+
const gap = () => {
|
|
114
|
+
const [lo, hi] = EVERY[conf.every] || EVERY.normal;
|
|
115
|
+
return (lo + Math.random() * (hi - lo)) * 60000;
|
|
116
|
+
};
|
|
117
|
+
// the host's daemon holds the settings; a "play now" may come from any ot's settings
|
|
118
|
+
const get = (u, tk) => fetch(new URL('/scenes', u), { headers: { 'X-7ots-Token': tk } }).then((x) => (x.ok ? x.json() : null), () => null);
|
|
119
|
+
if (url && token)
|
|
120
|
+
setInterval(async () => {
|
|
121
|
+
const others = [...ots.values()].map((o) => o.daemon).filter((d) => d?.token && d.url !== url);
|
|
122
|
+
const [r, ...rest] = await Promise.all([get(url, token), ...others.map((d) => get(d.url, d.token))]);
|
|
123
|
+
if (r) {
|
|
124
|
+
const changed = r.every !== conf.every;
|
|
125
|
+
conf = { on: r.on !== false, every: r.every, list: (r.list || []).filter((n) => SCENES[n]) };
|
|
126
|
+
if (changed) nextAt = Math.min(nextAt, Date.now() + gap()); // more often: don't wait out the old, longer gap
|
|
127
|
+
}
|
|
128
|
+
const asked = [r, ...rest].find((x) => x?.play)?.play;
|
|
129
|
+
if (asked) want = asked.scene || '';
|
|
130
|
+
}, 2000);
|
|
107
131
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
108
132
|
// waits, but gives up as soon as the mouse is on either of them (to click or drag it): true = interrupted
|
|
109
133
|
const pause = async (ms, ...pair) => {
|
|
@@ -111,7 +135,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
|
|
|
111
135
|
return pair.some((o) => o.interrupted());
|
|
112
136
|
};
|
|
113
137
|
|
|
114
|
-
async function play(x, y) {
|
|
138
|
+
async function play(x, y, asked = null) {
|
|
115
139
|
running = true;
|
|
116
140
|
// they take turns: a line from one ends the other's (one speech bubble at a time)
|
|
117
141
|
const turns = (o, other) => ({ ...o, say: (m) => (m?.line && other.say({ hush: true }), o.say(m)) });
|
|
@@ -132,8 +156,10 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
|
|
|
132
156
|
a.face(1);
|
|
133
157
|
b.face(-1);
|
|
134
158
|
if (await pause(500, a, b)) return;
|
|
135
|
-
const
|
|
136
|
-
const
|
|
159
|
+
const pool = conf.list.length ? conf.list : Object.keys(SCENES);
|
|
160
|
+
const names = pool.length > 1 ? pool.filter((n) => n !== last) : pool;
|
|
161
|
+
if (asked !== null) want = null; // the request is served only once the scene really starts
|
|
162
|
+
const name = process.env.SEVENOTS_PET_SCENE || asked || names[Math.floor(Math.random() * names.length)];
|
|
137
163
|
last = name;
|
|
138
164
|
console.log(`[7ots director] ${name}`);
|
|
139
165
|
// half the time the right one starts (scenes are written for the left one)
|
|
@@ -146,7 +172,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
|
|
|
146
172
|
} finally {
|
|
147
173
|
for (const o of [x, y]) (o.hold(null), o.inScene() && o.release());
|
|
148
174
|
running = false;
|
|
149
|
-
nextAt = Date.now() + (SOON ? 3000 :
|
|
175
|
+
nextAt = Date.now() + (SOON ? 3000 : gap());
|
|
150
176
|
}
|
|
151
177
|
}
|
|
152
178
|
|
|
@@ -158,8 +184,14 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
|
|
|
158
184
|
|
|
159
185
|
setInterval(() => {
|
|
160
186
|
if (running) return;
|
|
187
|
+
if (want !== null) {
|
|
188
|
+
const s = [...ots.values()].filter((o) => o.ready()).sort(() => Math.random() - 0.5);
|
|
189
|
+
if (s.length >= 2) play(s[0], s[1], want).catch((e) => console.log('[7ots director]', e.message));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
161
192
|
const free = [...ots.values()].filter((o) => o.free());
|
|
162
193
|
if (free.length < 2) return;
|
|
194
|
+
if (!conf.on || !conf.list.length) return;
|
|
163
195
|
// two that wandered close start right away; otherwise now and then
|
|
164
196
|
let pair = null;
|
|
165
197
|
for (let i = 0; i < free.length && !pair; i++)
|