@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
|
@@ -18,7 +18,9 @@ const URL_ = process.env.SEVENOTS_PET_URL;
|
|
|
18
18
|
const TOKEN = process.env.SEVENOTS_PET_TOKEN;
|
|
19
19
|
const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
|
|
20
20
|
const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
|
|
21
|
-
|
|
21
|
+
// menus in the human's language: SEVENOTS_PET_LANG, else the system's (es_CL.UTF-8 → es)
|
|
22
|
+
const LANG = [process.env.SEVENOTS_PET_LANG, process.env.SEVENOTS_LANG, process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
|
|
23
|
+
.map((l) => String(l || '').slice(0, 2).toLowerCase()).find((l) => ['en', 'es', 'pt'].includes(l)) || 'en';
|
|
22
24
|
const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
|
|
23
25
|
const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
|
|
24
26
|
const SMALL = { w: 160, h: 164 }; // just the ot
|
|
@@ -47,6 +49,69 @@ const post = (p, body) =>
|
|
|
47
49
|
|
|
48
50
|
app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the compositor needs a moment
|
|
49
51
|
|
|
52
|
+
// The shell lives as long as the daemon that started it: when that one stops (7ots stop) or another takes
|
|
53
|
+
// its place (a relaunch), this shell leaves too, so there are never two shells (and two directors) at once.
|
|
54
|
+
{
|
|
55
|
+
let pid = 0;
|
|
56
|
+
let misses = 0;
|
|
57
|
+
setInterval(async () => {
|
|
58
|
+
const h = await fetch(new URL('/health', URL_), { signal: AbortSignal.timeout(3000) }).then((r) => r.json(), () => null);
|
|
59
|
+
if (!h?.pid) return ++misses > 4 && app.quit(); // gone for ~25 s
|
|
60
|
+
misses = 0;
|
|
61
|
+
pid ||= h.pid;
|
|
62
|
+
if (h.pid !== pid) app.quit();
|
|
63
|
+
}, 5000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The cursor. On X11 Chromium answers getCursorScreenPoint() from the last mouse event its own windows got,
|
|
67
|
+
// and a click-through window gets none: the point froze wherever the mouse last left the ot, so hovering it
|
|
68
|
+
// often did nothing (no menu, no grab). There a tiny python3 + libX11 helper asks the X server ~60 times a
|
|
69
|
+
// second instead; without python3/libX11 (or off X11) it stays Electron's answer.
|
|
70
|
+
let xCursor = null; // { x, y, at } in physical pixels
|
|
71
|
+
function watchX11Cursor() {
|
|
72
|
+
if (!linux || !process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return;
|
|
73
|
+
const py = [
|
|
74
|
+
'import ctypes, ctypes.util, sys, time',
|
|
75
|
+
"x = ctypes.cdll.LoadLibrary(ctypes.util.find_library('X11') or 'libX11.so.6')",
|
|
76
|
+
'x.XOpenDisplay.restype = ctypes.c_void_p',
|
|
77
|
+
'x.XDefaultRootWindow.argtypes = [ctypes.c_void_p]; x.XDefaultRootWindow.restype = ctypes.c_ulong',
|
|
78
|
+
'x.XQueryPointer.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + [ctypes.c_void_p] * 7',
|
|
79
|
+
'd = x.XOpenDisplay(None)',
|
|
80
|
+
'if not d: sys.exit(1)',
|
|
81
|
+
'r = x.XDefaultRootWindow(d)',
|
|
82
|
+
'w = ctypes.c_ulong(); i = [ctypes.c_int() for _ in range(4)]; m = ctypes.c_uint(); last = None; n = 0',
|
|
83
|
+
'while True:',
|
|
84
|
+
' x.XQueryPointer(d, r, ctypes.byref(w), ctypes.byref(w), *[ctypes.byref(v) for v in i], ctypes.byref(m))',
|
|
85
|
+
' p = (i[0].value, i[1].value); n += 1',
|
|
86
|
+
' if p != last or n % 30 == 0:',
|
|
87
|
+
" last = p; sys.stdout.write('%d %d\\n' % p); sys.stdout.flush()",
|
|
88
|
+
' time.sleep(0.016)',
|
|
89
|
+
].join('\n');
|
|
90
|
+
let child;
|
|
91
|
+
try {
|
|
92
|
+
child = require('node:child_process').spawn('python3', ['-c', py], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
child.on('error', () => {});
|
|
97
|
+
let buf = '';
|
|
98
|
+
child.stdout.on('data', (d) => {
|
|
99
|
+
buf += d;
|
|
100
|
+
const lines = buf.split('\n');
|
|
101
|
+
buf = lines.pop();
|
|
102
|
+
const m = /^(-?\d+) (-?\d+)$/.exec(lines.pop() || '');
|
|
103
|
+
if (m) xCursor = { x: +m[1], y: +m[2], at: Date.now() };
|
|
104
|
+
});
|
|
105
|
+
app.on('will-quit', () => child.kill());
|
|
106
|
+
}
|
|
107
|
+
const cursorPoint = () => {
|
|
108
|
+
if (xCursor && Date.now() - xCursor.at < 2000) {
|
|
109
|
+
const k = screen.getPrimaryDisplay().scaleFactor || 1; // X11 pixels → Electron's DIP
|
|
110
|
+
return { x: Math.round(xCursor.x / k), y: Math.round(xCursor.y / k) };
|
|
111
|
+
}
|
|
112
|
+
return screen.getCursorScreenPoint();
|
|
113
|
+
};
|
|
114
|
+
|
|
50
115
|
// Every ot on the desktop: the one that lives here ('host') and visitors from the same account.
|
|
51
116
|
const DEBUG = !!process.env.SEVENOTS_PET_DEBUG;
|
|
52
117
|
const ots = new Map();
|
|
@@ -54,6 +119,7 @@ let tray = null;
|
|
|
54
119
|
let relaunching = false; // a 2D → 3D switch that needs the GPU (see setView)
|
|
55
120
|
|
|
56
121
|
function start() {
|
|
122
|
+
watchX11Cursor();
|
|
57
123
|
// Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
|
|
58
124
|
try {
|
|
59
125
|
const img = nativeImage.createFromPath(ICON);
|
|
@@ -73,7 +139,7 @@ function start() {
|
|
|
73
139
|
};
|
|
74
140
|
setTimeout(syncVisitors, 2500);
|
|
75
141
|
setInterval(syncVisitors, 4000);
|
|
76
|
-
require('./director.cjs').createDirector({ ots, lang: LANG });
|
|
142
|
+
require('./director.cjs').createDirector({ ots, lang: LANG, url: URL_, token: TOKEN });
|
|
77
143
|
}
|
|
78
144
|
|
|
79
145
|
// Settings in a window of our own (not a browser tab); links elsewhere open in the browser.
|
|
@@ -240,19 +306,27 @@ function makeOt(opt = {}) {
|
|
|
240
306
|
// button is down on the ot. Past 4 px it is a drag: the drop spot becomes home, as with a system drag.
|
|
241
307
|
let grabbing = null;
|
|
242
308
|
let grabTimer = null;
|
|
243
|
-
function grab(on) {
|
|
309
|
+
function grab(on, resizing = false) {
|
|
244
310
|
clearInterval(grabTimer);
|
|
311
|
+
// stretching from a corner (pet.html): the window keeps the mouse and stays put; the page grows the ot
|
|
312
|
+
if (on && resizing) {
|
|
313
|
+
grabbing = { resize: true, moved: false };
|
|
314
|
+
grabTimer = setInterval(() => (grabbing ? (stillUntil = Date.now() + 1200) : clearInterval(grabTimer)), 200); // no wandering off mid-stretch
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
245
317
|
if (!on) {
|
|
246
318
|
if (grabbing?.moved) (setHome(pos), (droppedSaid = false), (plan = { mode: 'idle', until: Date.now() + 6000 }));
|
|
247
319
|
grabbing = null;
|
|
248
320
|
return;
|
|
249
321
|
}
|
|
322
|
+
// while grabbed the window has the mouse, so Electron's own cursor is live (and finer than the X11 poll)
|
|
250
323
|
const c = screen.getCursorScreenPoint();
|
|
251
324
|
grabbing = { dx: pos.x - c.x, dy: pos.y - c.y, x: c.x, y: c.y, moved: false };
|
|
252
325
|
grabTimer = setInterval(() => {
|
|
253
326
|
if (!alive() || !grabbing) return clearInterval(grabTimer);
|
|
254
327
|
const p = screen.getCursorScreenPoint();
|
|
255
328
|
if (!grabbing.moved && Math.hypot(p.x - grabbing.x, p.y - grabbing.y) < 4) return;
|
|
329
|
+
if (!grabbing.moved) page('postMessage({ carried: true })'); // the page stretches it toward the pull
|
|
256
330
|
grabbing.moved = true;
|
|
257
331
|
dragged = Date.now();
|
|
258
332
|
pos.x = Math.max(area.x + size.w / 2 - 40, Math.min(right - size.w / 2 + 40, p.x + grabbing.dx));
|
|
@@ -269,7 +343,7 @@ function makeOt(opt = {}) {
|
|
|
269
343
|
let asleep = false; // asleep it stays put (an urgent stir wakes it for a moment, see pet.html)
|
|
270
344
|
const HIT = `(() => {
|
|
271
345
|
const out = [];
|
|
272
|
-
for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
|
|
346
|
+
for (const e of document.querySelectorAll('#stage, .actions, .ask, .bubble.asking')) {
|
|
273
347
|
const cs = getComputedStyle(e);
|
|
274
348
|
// the menu counts while faded out too: reaching for it must not let the mouse through (and hide it)
|
|
275
349
|
if (cs.display === 'none' || cs.visibility === 'hidden' || (+cs.opacity < 0.3 && !e.classList.contains('actions'))) continue;
|
|
@@ -308,7 +382,7 @@ function makeOt(opt = {}) {
|
|
|
308
382
|
let dbgT = 0;
|
|
309
383
|
const mouseTimer = setInterval(() => {
|
|
310
384
|
if (!alive() || !win.isVisible()) return;
|
|
311
|
-
const c =
|
|
385
|
+
const c = cursorPoint();
|
|
312
386
|
const [wx, wy] = win.getPosition();
|
|
313
387
|
const x = c.x - wx;
|
|
314
388
|
const y = c.y - wy;
|
|
@@ -353,7 +427,8 @@ function makeOt(opt = {}) {
|
|
|
353
427
|
const differs = want.w !== size.w || want.h !== size.h;
|
|
354
428
|
if (!differs) (shrinkAt = 0), (shrinkTo = null);
|
|
355
429
|
else if (!grow) shrinkTo = shrinkTo ? { w: Math.max(shrinkTo.w, want.w), h: Math.max(shrinkTo.h, want.h) } : want; // the most it wanted while waiting
|
|
356
|
-
|
|
430
|
+
// growing never waits (a stretch while dragged would be cut off); shrinking waits for the drop
|
|
431
|
+
if (differs && (grow || (Date.now() - dragged > 400 && !held && (shrinkAt ||= Date.now() + 6000) < Date.now()))) {
|
|
357
432
|
size = grow ? { w: Math.max(want.w, size.w), h: Math.max(want.h, size.h) } : shrinkTo || want;
|
|
358
433
|
shrinkAt = 0;
|
|
359
434
|
shrinkTo = null;
|
|
@@ -377,8 +452,12 @@ function makeOt(opt = {}) {
|
|
|
377
452
|
// An effect just started: the page says how much room it needs right away (see tellNeed in pet.html).
|
|
378
453
|
win.webContents.on('console-message', (e, level, msg) => {
|
|
379
454
|
const m = String(e?.message ?? msg ?? '');
|
|
380
|
-
if (m.startsWith('\u00a77drag'))
|
|
455
|
+
if (m.startsWith('\u00a77drag')) {
|
|
456
|
+
const d = JSON.parse(m.slice(6));
|
|
457
|
+
return grab(d.on, d.resize);
|
|
458
|
+
}
|
|
381
459
|
if (m.startsWith('\u00a77bye')) return HOST || dismiss();
|
|
460
|
+
if (m.startsWith('\u00a77ctx')) return (FULL ? menu() : guestMenu()).popup({ window: win });
|
|
382
461
|
if (m.startsWith('\u00a77view')) return setView(m.slice(6) === '3d');
|
|
383
462
|
if (!m.startsWith('\u00a77need')) return;
|
|
384
463
|
try {
|
|
@@ -456,7 +535,7 @@ function makeOt(opt = {}) {
|
|
|
456
535
|
}
|
|
457
536
|
}
|
|
458
537
|
else if (plan.mode === 'follow') {
|
|
459
|
-
const c =
|
|
538
|
+
const c = cursorPoint();
|
|
460
539
|
// stays a polite distance from the cursor, on the floor band
|
|
461
540
|
tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
|
|
462
541
|
ty = clampY(c.y + SMALL.h / 2);
|
|
@@ -531,9 +610,9 @@ function makeOt(opt = {}) {
|
|
|
531
610
|
return null;
|
|
532
611
|
}
|
|
533
612
|
async function goTo(goal) {
|
|
534
|
-
if (still) return;
|
|
613
|
+
if (still || plan.mode === 'scene' || plan.mode === 'fling') return; // a scene with another ot goes first
|
|
535
614
|
if (goal === 'browser') await new Promise((r) => setTimeout(r, 900)); // let the site open first
|
|
536
|
-
const c =
|
|
615
|
+
const c = cursorPoint();
|
|
537
616
|
const rect = (goal === 'agent' ? await findWindow(TERM) : goal === 'browser' ? await findWindow(BROWSER) : null) || { x: c.x - 1, y: c.y - 1, w: 2, h: 2, cursor: true };
|
|
538
617
|
const cx = rect.x + rect.w / 2;
|
|
539
618
|
// stand beside the window if there is room, otherwise inside it, near its lower corner closest to us
|
|
@@ -582,6 +661,9 @@ function makeOt(opt = {}) {
|
|
|
582
661
|
bounds: () => ({ minX, maxX, floor }),
|
|
583
662
|
close,
|
|
584
663
|
// free to join a scene: shown, loaded, not held still, dragged, hovered, talking or on an errand
|
|
664
|
+
daemon: { url: base, token: opt.token || TOKEN }, // its own daemon (settings there can ask for a scene)
|
|
665
|
+
// for a scene asked from settings: errands (pointing at the terminal while an agent works) may be cut short
|
|
666
|
+
ready: () => alive() && win.isVisible() && !win.webContents.isLoading() && !asleep && Date.now() - dragged > 3000 && Date.now() > hoverUntil + 1500 && plan.mode !== 'scene' && plan.mode !== 'fling',
|
|
585
667
|
free: () => alive() && win.isVisible() && !win.webContents.isLoading() && !still && !asleep && Date.now() - dragged > 8000 && Date.now() > hoverUntil + 1500 && !busy && /idle|go|hop/.test(plan.mode),
|
|
586
668
|
walkTo: (x, y = pos.y, speed = 120) => (plan = { mode: 'scene', tx: Math.max(minX, Math.min(maxX, x)), ty: Math.max(minY, Math.min(floor, y)), speed, dir: plan.dir || 0 }),
|
|
587
669
|
arrivedAt: () => plan.mode === 'scene' && Math.abs(plan.tx - pos.x) < 6 && Math.abs(plan.ty - pos.y) < 6 && Math.hypot(vel.x, vel.y) < 10,
|
package/cli/pet/pet.html
CHANGED
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
opacity: 0; transform: translate(-50%, 6px) scale(.97); transform-origin: 50% 100%; transition: opacity .22s, transform .22s; pointer-events: none; }
|
|
46
46
|
.bubble.on { opacity: 1; transform: translate(-50%, 0) scale(1); }
|
|
47
47
|
.bubble::after { content: ''; position: absolute; left: 50%; top: 100%; width: 12px; height: 12px; margin: -6px 0 0 -6px; background: inherit; border: inherit; border-width: 0 1px 1px 0; transform: rotate(45deg); }
|
|
48
|
+
.bubble.asking { pointer-events: auto; -webkit-app-region: no-drag; border-color: var(--accent); }
|
|
49
|
+
.bubble .q { display: block; margin-bottom: 8px; }
|
|
50
|
+
.bubble .yn { display: flex; gap: 6px; justify-content: flex-end; }
|
|
51
|
+
.bubble .yn button { font: inherit; font-size: 12.5px; font-weight: 650; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--ink); cursor: pointer; }
|
|
52
|
+
.bubble .yn button.ok { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
53
|
+
.bubble .yn button:hover { filter: brightness(1.08); }
|
|
48
54
|
.dots { display: inline-flex; gap: 4px; padding: 2px 0; }
|
|
49
55
|
.dots b { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: dot 1s infinite ease-in-out; }
|
|
50
56
|
.dots b:nth-child(2) { animation-delay: .15s; } .dots b:nth-child(3) { animation-delay: .3s; }
|
|
@@ -119,8 +125,11 @@
|
|
|
119
125
|
body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
|
|
120
126
|
body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
|
|
121
127
|
body.desktop .hero { background: none; padding: 0 0 6px; }
|
|
122
|
-
body.desktop .stage { width: 140px; height: 148px; }
|
|
128
|
+
body.desktop .stage { width: calc(140px * var(--pet-scale, 1)); height: calc(148px * var(--pet-scale, 1)); } /* stretched from a corner (see 'resize' below) */
|
|
123
129
|
body.desktop .stage.flip .face { transform: scaleX(-1); }
|
|
130
|
+
/* the 3D ot is framed with room under its feet (~34px at 1x) that grows with the stretch: the figure is drawn
|
|
131
|
+
lower by what that room grew, so a bigger ot still stands where it stood; the stage (hit area) stays put */
|
|
132
|
+
body.desktop.ot3d .face { top: calc(34px * (var(--pet-scale, 1) - 1)); }
|
|
124
133
|
/* scenes with other ots (electron/director.cjs): the acts move .act; props (fireballs, beams, sabers, bullets) */
|
|
125
134
|
.actor { position: absolute; inset: 0; z-index: 1; transform-origin: 50% 92%; }
|
|
126
135
|
.props { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: visible; }
|
|
@@ -137,7 +146,7 @@
|
|
|
137
146
|
.bye { display: none; }
|
|
138
147
|
body.desktop.guest .bye { display: flex; }
|
|
139
148
|
body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
|
|
140
|
-
body.desktop .actions { position: absolute; right: auto; left: calc(50% +
|
|
149
|
+
body.desktop .actions { position: absolute; right: auto; left: calc(50% + 70px * var(--pet-scale, 1) - 4px); /* beside the ot at any size: over it, it hid the corners that stretch it */ bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
|
|
141
150
|
background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
|
|
142
151
|
opacity: 0; transition: none; /* a transition here never finished painting in the transparent window: the menu stayed invisible */ -webkit-app-region: no-drag; z-index: 5; } /* above the 3D stage canvas (z 3) */
|
|
143
152
|
body.desktop:hover .actions, body.desktop.hover .actions, body.desktop.menu .actions { opacity: 1; transform: none; } /* .hover: set by the shell, which sees the cursor even when no mouse event reaches the page */
|
|
@@ -213,17 +222,17 @@
|
|
|
213
222
|
<script type="module">
|
|
214
223
|
const BOOT = __BOOT__;
|
|
215
224
|
const T = {
|
|
216
|
-
en: { settings: 'Settings', dismiss: 'Send home', listen: 'Listen', listening: 'Listening… 🎙️', noStt: 'Voice control needs an OpenAI or ElevenLabs key (Settings).', noMic: 'I can’t reach the microphone.', sttErr: 'I didn’t catch that.', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Treat', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
|
|
225
|
+
en: { settings: 'Settings', dismiss: 'Send home', listen: 'Listen', listening: 'Listening… 🎙️', noStt: 'Voice control needs an OpenAI or ElevenLabs key (Settings).', noMic: 'I can’t reach the microphone.', sttErr: 'I didn’t catch that.', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Treat', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…', approve: 'Approve', reject: 'Reject',
|
|
217
226
|
activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
|
|
218
227
|
brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
|
|
219
228
|
moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
|
|
220
229
|
kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
|
|
221
|
-
es: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Escuchar', listening: 'Te escucho… 🎙️', noStt: 'Para hablarme por voz falta una key de OpenAI o ElevenLabs (Ajustes).', noMic: 'No puedo usar el micrófono.', sttErr: 'No te entendí.', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Mimar', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
|
|
230
|
+
es: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Escuchar', listening: 'Te escucho… 🎙️', noStt: 'Para hablarme por voz falta una key de OpenAI o ElevenLabs (Ajustes).', noMic: 'No puedo usar el micrófono.', sttErr: 'No te entendí.', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Mimar', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…', approve: 'Aprobar', reject: 'Rechazar',
|
|
222
231
|
activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
|
|
223
232
|
brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
|
|
224
233
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
|
|
225
234
|
kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
|
|
226
|
-
pt: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Ouvir', listening: 'Estou ouvindo… 🎙️', noStt: 'Para falar por voz falta uma key da OpenAI ou ElevenLabs (Ajustes).', noMic: 'Não consigo usar o microfone.', sttErr: 'Não entendi.', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Mimar', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
|
|
235
|
+
pt: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Ouvir', listening: 'Estou ouvindo… 🎙️', noStt: 'Para falar por voz falta uma key da OpenAI ou ElevenLabs (Ajustes).', noMic: 'Não consigo usar o microfone.', sttErr: 'Não entendi.', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Mimar', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…', approve: 'Aprovar', reject: 'Recusar',
|
|
227
236
|
activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
|
|
228
237
|
brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
|
|
229
238
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
|
|
@@ -272,6 +281,7 @@
|
|
|
272
281
|
}
|
|
273
282
|
ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
|
|
274
283
|
const is3d = !!ch.character;
|
|
284
|
+
document.body.classList.toggle('ot3d', is3d);
|
|
275
285
|
// While a gesture/effect/morph plays the ot needs room around it (wings, hearts, confetti): the desktop
|
|
276
286
|
// shell reads window.__need() and sizes the window to what is really on screen.
|
|
277
287
|
let fxUntil = 0;
|
|
@@ -319,6 +329,7 @@
|
|
|
319
329
|
}
|
|
320
330
|
if (document.body.classList.contains('talking')) (w = Math.max(w, 240)), (h = Math.max(h, st.height + 60));
|
|
321
331
|
if (Date.now() < actUntil) (w = Math.max(w, actRoom * 2)), (h = Math.max(h, st.height + 70));
|
|
332
|
+
w = Math.max(w, st.width + 2 * 58); // the menu beside it (shown on hover)
|
|
322
333
|
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
323
334
|
};
|
|
324
335
|
// The shell polls __need every 250 ms; when an effect starts it's told at once (a console line it listens
|
|
@@ -341,9 +352,53 @@
|
|
|
341
352
|
const r = $('#stage').getBoundingClientRect();
|
|
342
353
|
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
|
343
354
|
};
|
|
355
|
+
// Size: grabbing the ot by a corner stretches it (bigger or smaller) instead of moving it; remembered per ot.
|
|
356
|
+
const SCALE_KEY = `7ots.petScale${guest ? `.${guest}` : ''}`;
|
|
357
|
+
let scale = 1;
|
|
358
|
+
try {
|
|
359
|
+
scale = Math.max(0.6, Math.min(2, Number(localStorage.getItem(SCALE_KEY)) || 1));
|
|
360
|
+
} catch {}
|
|
361
|
+
const setScale = (k) => ((scale = k), document.documentElement.style.setProperty('--pet-scale', k.toFixed(3)), tellNeed());
|
|
362
|
+
if (desktop && scale !== 1) setScale(scale);
|
|
363
|
+
const CORNER = 22;
|
|
364
|
+
// which corner of the ot the pointer is on ('nw' | 'ne' | 'sw' | 'se'), or ''
|
|
365
|
+
const cornerAt = (e) => {
|
|
366
|
+
if (!desktop || !onOt(e)) return '';
|
|
367
|
+
const r = $('#stage').getBoundingClientRect();
|
|
368
|
+
const v = e.clientY - r.top < CORNER ? 'n' : r.bottom - e.clientY < CORNER ? 's' : '';
|
|
369
|
+
const h = e.clientX - r.left < CORNER ? 'w' : r.right - e.clientX < CORNER ? 'e' : '';
|
|
370
|
+
return v && h ? v + h : '';
|
|
371
|
+
};
|
|
344
372
|
if (desktop) {
|
|
345
373
|
let down = false;
|
|
346
|
-
|
|
374
|
+
let sizing = null;
|
|
375
|
+
let pulled = null; // { m, on }: a drag that started off the corners
|
|
376
|
+
const up = () => {
|
|
377
|
+
if (pulled?.on) {
|
|
378
|
+
ch.unmorph?.(pulled.m); // let go: back into shape
|
|
379
|
+
fxUntil = Date.now() + 600; // room while it eases back
|
|
380
|
+
}
|
|
381
|
+
pulled = null;
|
|
382
|
+
if (sizing) {
|
|
383
|
+
sizing = null;
|
|
384
|
+
try {
|
|
385
|
+
localStorage.setItem(SCALE_KEY, String(scale));
|
|
386
|
+
} catch {}
|
|
387
|
+
}
|
|
388
|
+
if (down) (down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false }));
|
|
389
|
+
};
|
|
390
|
+
addEventListener('pointermove', (e) => {
|
|
391
|
+
if (sizing) {
|
|
392
|
+
// the ot stays anchored at its feet, centred: sideways it grows on both sides, so a corner moves half as much
|
|
393
|
+
const dx = (e.screenX - sizing.x) * (sizing.c[1] === 'e' ? 1 : -1) * 2;
|
|
394
|
+
const dy = (e.screenY - sizing.y) * (sizing.c[0] === 's' ? 1 : -1);
|
|
395
|
+
const k = sizing.k * ((140 * sizing.k + dx) / (140 * sizing.k) + (148 * sizing.k + dy) / (148 * sizing.k)) / 2;
|
|
396
|
+
return setScale(Math.max(0.6, Math.min(2, k)));
|
|
397
|
+
}
|
|
398
|
+
if (down) return;
|
|
399
|
+
const c = cornerAt(e);
|
|
400
|
+
document.body.style.cursor = c ? (c === 'nw' || c === 'se' ? 'nwse-resize' : 'nesw-resize') : '';
|
|
401
|
+
});
|
|
347
402
|
// on the document: in 3D the ot is drawn on a canvas above the page, so events don't target #stage
|
|
348
403
|
addEventListener('pointerdown', (e) => {
|
|
349
404
|
// on the ot itself, or on the move handle in the menu (a sure grip while the ot is mid-animation)
|
|
@@ -351,14 +406,50 @@
|
|
|
351
406
|
if (e.button !== 0 || (!grip && (e.target.closest?.('.actions, .ask, a, button') || !onOt(e)))) return;
|
|
352
407
|
down = true;
|
|
353
408
|
document.documentElement.setPointerCapture?.(e.pointerId);
|
|
354
|
-
|
|
409
|
+
const c = !grip && cornerAt(e);
|
|
410
|
+
if (c) sizing = { c, x: e.screenX, y: e.screenY, k: scale };
|
|
411
|
+
else if (!grip) {
|
|
412
|
+
const r = $('#stage').getBoundingClientRect();
|
|
413
|
+
const side = Math.abs(e.clientX - (r.left + r.width / 2)) / r.width > Math.abs(e.clientY - (r.top + r.height / 2)) / r.height;
|
|
414
|
+
pulled = { m: side ? 'squish' : 'stretch', on: false };
|
|
415
|
+
}
|
|
416
|
+
// resizing: the shell keeps the mouse but leaves the window where it is
|
|
417
|
+
console.debug('\u00a77drag' + JSON.stringify({ on: true, resize: !!c }));
|
|
418
|
+
});
|
|
419
|
+
// pulled by a side or the top: once the shell says the window is really being carried (the page sees
|
|
420
|
+
// almost no pointer motion then: the window moves with the hand), it stretches toward the pull — wider
|
|
421
|
+
// from a side, taller from above or below — until it's let go
|
|
422
|
+
addEventListener('message', (e) => {
|
|
423
|
+
if (!e.data?.carried || !pulled || pulled.on) return;
|
|
424
|
+
pulled.on = true;
|
|
425
|
+
fxUntil = Infinity; // room around it for as long as it's held stretched
|
|
426
|
+
ch.morph?.(pulled.m, { hold: true });
|
|
427
|
+
tellNeed();
|
|
355
428
|
});
|
|
356
429
|
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) addEventListener(ev, up);
|
|
357
430
|
addEventListener('blur', up);
|
|
358
431
|
}
|
|
359
432
|
let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
|
|
360
433
|
addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
|
|
361
|
-
|
|
434
|
+
// A click on the ot pokes it: it gets squished and, poked again and again, more and more annoyed.
|
|
435
|
+
// Waits a beat so a double click (the menu) doesn't count as two pokes; a drag or a stretch isn't a poke.
|
|
436
|
+
let pressAt = null, pokeT = 0, pokes = 0, pokedAt = 0;
|
|
437
|
+
addEventListener('pointerdown', (e) => (pressAt = { x: e.screenX, y: e.screenY }));
|
|
438
|
+
addEventListener('click', (e) => {
|
|
439
|
+
clearTimeout(pokeT);
|
|
440
|
+
if (e.detail > 1 || !onOt(e) || e.target.closest?.('.actions, .ask, .bubble.asking, a, button')) return;
|
|
441
|
+
if (pressAt && Math.hypot(e.screenX - pressAt.x, e.screenY - pressAt.y) > 4) return;
|
|
442
|
+
pokeT = setTimeout(poke, 240);
|
|
443
|
+
});
|
|
444
|
+
function poke() {
|
|
445
|
+
pokes = Date.now() - pokedAt < 2500 ? pokes + 1 : 1;
|
|
446
|
+
pokedAt = Date.now();
|
|
447
|
+
lookFree = 0;
|
|
448
|
+
ch.morph?.('squish', { ms: 420 });
|
|
449
|
+
if (pokes === 1) ch.gesture('bounce');
|
|
450
|
+
else if (pokes <= 3) (ch.gesture('surprise'), ch.effect?.('exclaim'));
|
|
451
|
+
else (ch.gesture(pokes % 2 ? 'stomp' : 'shake'), ch.effect?.('anger'), pokes >= 6 && ch.effect?.('steam'));
|
|
452
|
+
}
|
|
362
453
|
|
|
363
454
|
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
|
|
364
455
|
let asleep = false;
|
|
@@ -501,6 +592,8 @@
|
|
|
501
592
|
speechSynthesis.speak(u);
|
|
502
593
|
}
|
|
503
594
|
function showBubble(content, ms) {
|
|
595
|
+
if (asking && !content.classList?.contains('askq')) return; // a question waiting for you stays until answered
|
|
596
|
+
bubble.classList.remove('asking');
|
|
504
597
|
bubble.replaceChildren(content);
|
|
505
598
|
bubble.classList.add('on');
|
|
506
599
|
clearTimeout(hideT);
|
|
@@ -537,6 +630,42 @@
|
|
|
537
630
|
if (withFace) react(text);
|
|
538
631
|
speak(text);
|
|
539
632
|
}
|
|
633
|
+
// It asks before acting for you (Orquesta…): Approve / Reject in the bubble, answered on POST /approve.
|
|
634
|
+
let asking = null;
|
|
635
|
+
function ask4(a) {
|
|
636
|
+
if (a.done) {
|
|
637
|
+
if (asking !== a.id) return;
|
|
638
|
+
asking = null;
|
|
639
|
+
bubble.classList.remove('on', 'asking');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
asking = null;
|
|
643
|
+
const box = document.createElement('div');
|
|
644
|
+
box.className = 'askq';
|
|
645
|
+
const q = document.createElement('span');
|
|
646
|
+
q.className = 'q';
|
|
647
|
+
q.textContent = a.text;
|
|
648
|
+
const yn = document.createElement('div');
|
|
649
|
+
yn.className = 'yn';
|
|
650
|
+
const btn = (ok) => {
|
|
651
|
+
const b = document.createElement('button');
|
|
652
|
+
b.type = 'button';
|
|
653
|
+
b.textContent = t(ok ? 'approve' : 'reject');
|
|
654
|
+
if (ok) b.className = 'ok';
|
|
655
|
+
b.addEventListener('click', () => {
|
|
656
|
+
yn.querySelectorAll('button').forEach((x) => (x.disabled = true));
|
|
657
|
+
post('/approve', { id: a.id, ok }).catch(() => {});
|
|
658
|
+
});
|
|
659
|
+
return b;
|
|
660
|
+
};
|
|
661
|
+
yn.append(btn(false), btn(true));
|
|
662
|
+
box.append(q, yn);
|
|
663
|
+
showBubble(box, 0);
|
|
664
|
+
bubble.classList.add('asking');
|
|
665
|
+
asking = a.id;
|
|
666
|
+
ch.gesture('think');
|
|
667
|
+
speak(a.text);
|
|
668
|
+
}
|
|
540
669
|
function setThinking(on) {
|
|
541
670
|
if (on === thinking) return;
|
|
542
671
|
thinking = on;
|
|
@@ -546,7 +675,7 @@
|
|
|
546
675
|
d.className = 'dots';
|
|
547
676
|
d.innerHTML = '<b></b><b></b><b></b>';
|
|
548
677
|
showBubble(d, 0);
|
|
549
|
-
} else if (bubble.querySelector('.dots')) bubble.classList.remove('on');
|
|
678
|
+
} else if (!asking && bubble.querySelector('.dots')) bubble.classList.remove('on');
|
|
550
679
|
}
|
|
551
680
|
|
|
552
681
|
// ── live updates ──
|
|
@@ -582,6 +711,7 @@
|
|
|
582
711
|
if (guest) return; // the host's events are the host's
|
|
583
712
|
if (m.stir || (m.say && asleep && Date.now() < stirUntil)) stir(); // awake until it has said it
|
|
584
713
|
if (m.state) render(m.state);
|
|
714
|
+
if (m.ask) ask4(m.ask); // before thinking: an approved ask hands the bubble back to the dots
|
|
585
715
|
if ('thinking' in m) setThinking(!!m.thinking);
|
|
586
716
|
if (m.activity) addActivity(m.activity);
|
|
587
717
|
if (m.gesture) ch.gesture(m.gesture);
|
|
@@ -600,6 +730,7 @@
|
|
|
600
730
|
askIn.placeholder = t('askPh');
|
|
601
731
|
$('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
|
|
602
732
|
$('#setBtn').title = t('settings');
|
|
733
|
+
if (!desktop) $('#setBtn').removeAttribute('target'); // in a browser tab, Settings opens in place (its Back returns here); the desktop window opens a tab
|
|
603
734
|
$('#byeBtn').title = t('dismiss');
|
|
604
735
|
$('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
|
|
605
736
|
const talking = (on) => {
|
|
@@ -617,6 +748,14 @@
|
|
|
617
748
|
clearTimeout(menuT);
|
|
618
749
|
if (on) menuT = setTimeout(() => document.body.classList.remove('menu'), 8000);
|
|
619
750
|
});
|
|
751
|
+
// right click: the shell's menu (settings, talk, sleep, hide…) at the cursor. Asked for by name: Electron's own
|
|
752
|
+
// 'context-menu' event didn't reach the shell from this transparent click-through window on Linux.
|
|
753
|
+
if (desktop)
|
|
754
|
+
addEventListener('contextmenu', (e) => {
|
|
755
|
+
if (e.target.closest?.('.ask input')) return; // the talk box keeps copy/paste
|
|
756
|
+
e.preventDefault();
|
|
757
|
+
console.debug('\u00a77ctx');
|
|
758
|
+
});
|
|
620
759
|
askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
|
|
621
760
|
ask.addEventListener('submit', async (e) => {
|
|
622
761
|
e.preventDefault();
|
|
@@ -834,7 +973,7 @@
|
|
|
834
973
|
[hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
|
|
835
974
|
[0.6, () => ch.effect?.('blush')],
|
|
836
975
|
[0.3, () => ch.gesture('shiver')],
|
|
837
|
-
[1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch'
|
|
976
|
+
[1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch']), { ms: 1600 + Math.random() * 1600 })],
|
|
838
977
|
[0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
|
|
839
978
|
[0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
|
|
840
979
|
])?.();
|