@7ots/cli 0.1.5 → 0.1.6
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 +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +365 -22
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +354 -49
- package/cli/pet/pet.html +328 -15
- package/cli/pet/settings.html +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -3
package/cli/pet/pet.html
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
7
|
<title>7ots</title>
|
|
7
8
|
<style>
|
|
@@ -114,21 +115,41 @@
|
|
|
114
115
|
.ask input:focus { border-color: var(--accent); }
|
|
115
116
|
/* ── desktop: transparent frameless window walking along the bottom of the screen ── */
|
|
116
117
|
html:has(body.desktop:not(.opaque)) { background: transparent; } /* html also paints --bg: without this the window is a white box */
|
|
117
|
-
body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden;
|
|
118
|
+
body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden; user-select: none; } /* dragged by hand (see 'drag' below): a drag region would swallow clicks and double clicks */
|
|
118
119
|
body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
|
|
119
120
|
body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
|
|
120
121
|
body.desktop .hero { background: none; padding: 0 0 6px; }
|
|
121
122
|
body.desktop .stage { width: 140px; height: 148px; }
|
|
122
123
|
body.desktop .stage.flip .face { transform: scaleX(-1); }
|
|
124
|
+
/* scenes with other ots (electron/director.cjs): the acts move .act; props (fireballs, beams, sabers, bullets) */
|
|
125
|
+
.actor { position: absolute; inset: 0; z-index: 1; transform-origin: 50% 92%; }
|
|
126
|
+
.props { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: visible; }
|
|
127
|
+
.props > * { position: absolute; }
|
|
128
|
+
.props .ball { left: 50%; top: 42%; width: 34px; height: 34px; margin: -17px; border-radius: 50%; background: radial-gradient(circle at 40% 40%, #fff 0 18%, #8fe3ff 30%, #2a7bff 62%, transparent 72%); box-shadow: 0 0 22px 8px #4cb8ff; }
|
|
129
|
+
.props .beam { left: 50%; top: 40%; height: 26px; margin-top: -13px; border-radius: 13px; background: linear-gradient(180deg, #bfefff, #fff 45%, #fff 55%, #7fd6ff); box-shadow: 0 0 24px 10px #49b6ff, 0 0 60px 18px #1f6bff88; }
|
|
130
|
+
.props .bullet { top: 34%; width: 14px; height: 6px; margin-top: -3px; border-radius: 3px; background: linear-gradient(90deg, #6b5a2b, #e9cf7a); }
|
|
131
|
+
.props .ring { top: 34%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; border: 2px solid rgb(180 220 255 / .8); }
|
|
132
|
+
.props .saber { left: 50%; top: 50%; width: 6px; height: 92px; margin-left: -3px; transform-origin: 50% 100%; border-radius: 4px; background: #fff; }
|
|
133
|
+
.props .flash { top: 44%; width: 30px; height: 30px; margin: -15px; border-radius: 50%; background: radial-gradient(#fff, #ffd35a 40%, #ff7a1a 60%, transparent 70%); }
|
|
134
|
+
.props .pow { left: 50%; top: 6%; font: 900 22px/1 system-ui, sans-serif; color: #ffd400; -webkit-text-stroke: 2px #1a1020; transform: translateX(-50%); white-space: nowrap; }
|
|
135
|
+
/* a visiting ot: its own little menu (play, say goodbye, settings, move); feeding, talking and sleep are the host's */
|
|
136
|
+
body.guest .ask, body.guest #talkBtn, body.guest #micBtn, body.guest #sleepBtn { display: none !important; }
|
|
137
|
+
.bye { display: none; }
|
|
138
|
+
body.desktop.guest .bye { display: flex; }
|
|
123
139
|
body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
|
|
124
|
-
body.desktop .actions { position: absolute; right:
|
|
140
|
+
body.desktop .actions { position: absolute; right: auto; left: calc(50% + 66px); bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
|
|
125
141
|
background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
|
|
126
|
-
opacity: 0; transition:
|
|
127
|
-
body.desktop:hover .actions { opacity: 1; transform: none; }
|
|
142
|
+
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
|
+
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 */
|
|
128
144
|
body.desktop .act { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: transparent; }
|
|
129
145
|
body.desktop .act span { display: none; }
|
|
146
|
+
.grip { display: none; }
|
|
147
|
+
body.desktop .grip { display: flex; cursor: grab; touch-action: none; }
|
|
148
|
+
body.desktop .grip:active { cursor: grabbing; }
|
|
130
149
|
body.desktop .act.primary { background: var(--accent); }
|
|
131
150
|
body.desktop .badge { display: flex; }
|
|
151
|
+
body.listening #micBtn { background: var(--err); color: #fff; animation: rec 1s ease-in-out infinite; }
|
|
152
|
+
@keyframes rec { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--err) 30%, transparent); } }
|
|
132
153
|
body.desktop .ask { display: none; position: absolute; left: 6px; right: 6px; top: 6px; margin: 0; z-index: 4; -webkit-app-region: no-drag; }
|
|
133
154
|
body.desktop.talking .ask { display: flex; }
|
|
134
155
|
.badge { display: none; position: absolute; z-index: 2; left: -6px; bottom: 18px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; align-items: center; justify-content: center;
|
|
@@ -152,7 +173,8 @@
|
|
|
152
173
|
<div class="stage" id="stage">
|
|
153
174
|
<div class="bubble" id="bubble" role="status" aria-live="polite"></div>
|
|
154
175
|
<span class="zz" aria-hidden="true">z</span>
|
|
155
|
-
<div class="face" id="face"></div>
|
|
176
|
+
<div class="actor" id="act"><div class="face" id="face"></div></div>
|
|
177
|
+
<div class="props" id="props" aria-hidden="true"></div>
|
|
156
178
|
<div class="floor" aria-hidden="true"></div>
|
|
157
179
|
<span class="badge" id="badge"></span>
|
|
158
180
|
</div>
|
|
@@ -173,9 +195,11 @@
|
|
|
173
195
|
<nav class="actions">
|
|
174
196
|
<button class="act primary" data-act="feed"><svg viewBox="0 0 24 24"><path d="M12 7c-1.5-2.5-6-2.5-7 1.5S7 20 9.5 20c1.2 0 1.6-.6 2.5-.6s1.3.6 2.5.6C17 20 20 12.5 19 8.5S13.5 4.5 12 7z"/><path d="M12 7c0-2 1-3.5 3-4"/></svg><span data-t="feed"></span></button>
|
|
175
197
|
<button class="act" id="talkBtn" type="button"><svg viewBox="0 0 24 24"><path d="M5 6h14v9H9l-4 3z"/></svg><span data-t="talk"></span></button>
|
|
176
|
-
<button class="act"
|
|
198
|
+
<button class="act" id="micBtn" type="button"><svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg><span data-t="listen"></span></button>
|
|
177
199
|
<button class="act" data-act="sleep" id="sleepBtn"><svg viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span data-t="sleep"></span></button>
|
|
178
200
|
<a class="act" id="setBtn" target="_blank" rel="noopener"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/></svg><span data-t="settings"></span></a>
|
|
201
|
+
<button class="act bye" id="byeBtn" type="button"><svg viewBox="0 0 24 24"><path d="M15 4h4v16h-4M10 8l-4 4 4 4M6 12h10"/></svg></button>
|
|
202
|
+
<button class="act grip" id="moveBtn" type="button" title="⇔"><svg viewBox="0 0 24 24"><path d="M12 3v18M3 12h18M12 3l-3 3M12 3l3 3M12 21l-3-3M12 21l3-3M3 12l3-3M3 12l3 3M21 12l-3-3M21 12l-3 3"/></svg></button>
|
|
179
203
|
</nav>
|
|
180
204
|
|
|
181
205
|
<section class="feed" aria-labelledby="feed-h">
|
|
@@ -189,17 +213,17 @@
|
|
|
189
213
|
<script type="module">
|
|
190
214
|
const BOOT = __BOOT__;
|
|
191
215
|
const T = {
|
|
192
|
-
en: { settings: 'Settings', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: '
|
|
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…',
|
|
193
217
|
activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
|
|
194
218
|
brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
|
|
195
219
|
moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
|
|
196
220
|
kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
|
|
197
|
-
es: { settings: 'Ajustes', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: '
|
|
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…',
|
|
198
222
|
activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
|
|
199
223
|
brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
|
|
200
224
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
|
|
201
225
|
kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
|
|
202
|
-
pt: { settings: 'Ajustes', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: '
|
|
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…',
|
|
203
227
|
activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
|
|
204
228
|
brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
|
|
205
229
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
|
|
@@ -211,6 +235,8 @@
|
|
|
211
235
|
const $ = (s) => document.querySelector(s);
|
|
212
236
|
const id = BOOT.identity;
|
|
213
237
|
const desktop = new URLSearchParams(location.search).has('desktop');
|
|
238
|
+
const guest = BOOT.guest || ''; // a visiting ot: no state, no voice, only the scenes
|
|
239
|
+
document.body.classList.toggle('guest', !!guest);
|
|
214
240
|
|
|
215
241
|
document.documentElement.lang = lang;
|
|
216
242
|
document.body.classList.toggle('desktop', desktop);
|
|
@@ -237,7 +263,7 @@
|
|
|
237
263
|
// three comes from the CDN; without WebGL or network the 2D ot takes over
|
|
238
264
|
try {
|
|
239
265
|
const m = await loadCharacter3D();
|
|
240
|
-
ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, stage: { key: 'pet', zIndex: 3, maxDpr: 2 } });
|
|
266
|
+
ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, bleed: desktop ? 'window' : 0, stage: { key: 'pet', zIndex: 3, maxDpr: 2, minDpr: 1.25, supersample: desktop } });
|
|
241
267
|
if (ch.fallback) console.warn('[7ots pet] no WebGL: 2D ot');
|
|
242
268
|
} catch (err) {
|
|
243
269
|
console.warn('[7ots pet] 3D ot unavailable, using 2D:', err?.message || err);
|
|
@@ -246,14 +272,106 @@
|
|
|
246
272
|
}
|
|
247
273
|
ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
|
|
248
274
|
const is3d = !!ch.character;
|
|
275
|
+
// While a gesture/effect/morph plays the ot needs room around it (wings, hearts, confetti): the desktop
|
|
276
|
+
// shell reads window.__need() and sizes the window to what is really on screen.
|
|
277
|
+
let fxUntil = 0;
|
|
278
|
+
let actUntil = 0; // a scene act is playing: it needs actRoom px on each side (a beam, a saber swing)
|
|
279
|
+
let actRoom = 0;
|
|
280
|
+
for (const k of ['gesture', 'effect', 'morph']) {
|
|
281
|
+
const f = ch[k]?.bind(ch);
|
|
282
|
+
if (!f) continue;
|
|
283
|
+
try {
|
|
284
|
+
ch[k] = (n, o) => ((fxUntil = Math.max(fxUntil, Date.now() + Math.max(2600, o?.ms || 0) + 400)), tellNeed(), f(n, o));
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
// Effects the character starts on its own (a mood's steam, a gesture's sparkles) skip the wrapper: they show
|
|
288
|
+
// up as children of its fx layers, so those count as "playing" too.
|
|
289
|
+
const fxLayers = () => $('#face').querySelectorAll('[data-p="fx"], [data-p="fxback"], [data-p="fxface"]');
|
|
290
|
+
const fxOn = () => Date.now() < fxUntil || [...fxLayers()].some((g) => g.childElementCount);
|
|
291
|
+
// What the figure really covers (arms out, a raised hand, a hop): the union of the drawn parts, which can
|
|
292
|
+
// stick out of the stage. The window is centred on the ot, so the width is twice the widest side.
|
|
293
|
+
const drawn = () => {
|
|
294
|
+
const parts = $('#face').querySelectorAll('svg > g, svg > use, svg > path');
|
|
295
|
+
let l = Infinity, t = Infinity, r = -Infinity;
|
|
296
|
+
for (const e of parts) {
|
|
297
|
+
const b = e.getBoundingClientRect();
|
|
298
|
+
if (!b.width && !b.height) continue;
|
|
299
|
+
l = Math.min(l, b.left), t = Math.min(t, b.top), r = Math.max(r, b.right);
|
|
300
|
+
}
|
|
301
|
+
return l === Infinity ? null : { l, t, r };
|
|
302
|
+
};
|
|
303
|
+
window.__need = () => {
|
|
304
|
+
const st = $('#stage').getBoundingClientRect();
|
|
305
|
+
// playing: room up front (hearts rise, confetti flies), not grown pixel by pixel as they move
|
|
306
|
+
const on = fxOn();
|
|
307
|
+
let w = st.width + (on ? 72 : 12) * 2;
|
|
308
|
+
let h = st.height + 6 + (on ? 96 : 12);
|
|
309
|
+
const d = drawn();
|
|
310
|
+
if (d) {
|
|
311
|
+
const cx = st.left + st.width / 2;
|
|
312
|
+
w = Math.max(w, 2 * Math.max(cx - d.l, d.r - cx) + 24);
|
|
313
|
+
h = Math.max(h, innerHeight - d.t + 12);
|
|
314
|
+
}
|
|
315
|
+
const b = $('#bubble');
|
|
316
|
+
if (b.classList.contains('on')) {
|
|
317
|
+
w = Math.max(w, b.offsetWidth + 24);
|
|
318
|
+
h = Math.max(h, st.height + 6 + b.offsetHeight + 16);
|
|
319
|
+
}
|
|
320
|
+
if (document.body.classList.contains('talking')) (w = Math.max(w, 240)), (h = Math.max(h, st.height + 60));
|
|
321
|
+
if (Date.now() < actUntil) (w = Math.max(w, actRoom * 2)), (h = Math.max(h, st.height + 70));
|
|
322
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
323
|
+
};
|
|
324
|
+
// The shell polls __need every 250 ms; when an effect starts it's told at once (a console line it listens
|
|
325
|
+
// for), so the window is big before the first frame that needs it instead of cutting it off.
|
|
326
|
+
let toldNeed = '';
|
|
327
|
+
function tellNeed() {
|
|
328
|
+
if (!desktop) return;
|
|
329
|
+
requestAnimationFrame(() => {
|
|
330
|
+
const n = JSON.stringify(window.__need());
|
|
331
|
+
if (n !== toldNeed) (toldNeed = n), console.debug('\u00a77need' + n);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (!is3d) {
|
|
335
|
+
const mo = new MutationObserver(tellNeed);
|
|
336
|
+
for (const g of fxLayers()) mo.observe(g, { childList: true });
|
|
337
|
+
}
|
|
338
|
+
// Drag: the page tells the shell when the button goes down and up on the ot; the shell moves the window
|
|
339
|
+
// with the cursor in between. A click that doesn't move stays a click.
|
|
340
|
+
const onOt = (e) => {
|
|
341
|
+
const r = $('#stage').getBoundingClientRect();
|
|
342
|
+
return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
|
343
|
+
};
|
|
344
|
+
if (desktop) {
|
|
345
|
+
let down = false;
|
|
346
|
+
const up = () => down && ((down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false })));
|
|
347
|
+
// on the document: in 3D the ot is drawn on a canvas above the page, so events don't target #stage
|
|
348
|
+
addEventListener('pointerdown', (e) => {
|
|
349
|
+
// on the ot itself, or on the move handle in the menu (a sure grip while the ot is mid-animation)
|
|
350
|
+
const grip = e.target.closest?.('#moveBtn');
|
|
351
|
+
if (e.button !== 0 || (!grip && (e.target.closest?.('.actions, .ask, a, button') || !onOt(e)))) return;
|
|
352
|
+
down = true;
|
|
353
|
+
document.documentElement.setPointerCapture?.(e.pointerId);
|
|
354
|
+
console.debug('\u00a77drag' + JSON.stringify({ on: true }));
|
|
355
|
+
});
|
|
356
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) addEventListener(ev, up);
|
|
357
|
+
addEventListener('blur', up);
|
|
358
|
+
}
|
|
249
359
|
let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
|
|
250
360
|
addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
|
|
251
361
|
$('#face').addEventListener('click', () => ch.gesture('bounce'));
|
|
252
362
|
|
|
253
363
|
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
|
|
254
364
|
let asleep = false;
|
|
365
|
+
// something urgent while asleep: it wakes up for a moment (says it, walks over), then dozes off again
|
|
366
|
+
let stirUntil = 0, stirT = 0;
|
|
367
|
+
function stir() {
|
|
368
|
+
stirUntil = Date.now() + 30000;
|
|
369
|
+
document.body.classList.remove('asleep');
|
|
370
|
+
clearTimeout(stirT);
|
|
371
|
+
stirT = setTimeout(() => last && render(last), 30500);
|
|
372
|
+
}
|
|
255
373
|
document.querySelectorAll('[data-act]').forEach((b) =>
|
|
256
|
-
b.addEventListener('click', () => post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
|
|
374
|
+
b.addEventListener('click', () => guest ? (b.dataset.act === 'feed' && postMessage({ gesture: 'celebrate' })) : post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
|
|
257
375
|
);
|
|
258
376
|
|
|
259
377
|
// ── state ──
|
|
@@ -277,8 +395,9 @@
|
|
|
277
395
|
function render(s) {
|
|
278
396
|
last = s;
|
|
279
397
|
asleep = s.asleep;
|
|
280
|
-
|
|
281
|
-
|
|
398
|
+
const stirred = asleep && Date.now() < stirUntil;
|
|
399
|
+
document.body.classList.toggle('asleep', asleep && !stirred);
|
|
400
|
+
ch.mood(stirred ? 'neutral' : s.mood);
|
|
282
401
|
paintMood();
|
|
283
402
|
$('#sleepBtn [data-t]').textContent = t(asleep ? 'wake' : 'sleep');
|
|
284
403
|
$('#sleepBtn').setAttribute('aria-label', t(asleep ? 'wake' : 'sleep'));
|
|
@@ -432,12 +551,36 @@
|
|
|
432
551
|
|
|
433
552
|
// ── live updates ──
|
|
434
553
|
const live = $('#live');
|
|
554
|
+
// The daemon restarts (another ot): wait for it, then reload with the new identity.
|
|
555
|
+
async function waitAndReload() {
|
|
556
|
+
for (let i = 0; i < 60; i++) {
|
|
557
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
558
|
+
// the new daemon, not the one still letting go of the port (it would serve the old ot again)
|
|
559
|
+
const h = await fetch('/health').then((r) => (r.ok ? r.json() : null), () => null);
|
|
560
|
+
if (h && h.pid !== BOOT.pid) return location.reload();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// The view is in the URL (?3d): on the desktop the shell reloads it (and relaunches when the 3D ot needs
|
|
564
|
+
// the GPU it started without); in a browser tab the page reloads itself with the new URL.
|
|
565
|
+
function setView(on) {
|
|
566
|
+
const q = new URLSearchParams(location.search);
|
|
567
|
+
if (q.has('3d') === on) return false;
|
|
568
|
+
if (desktop) return console.debug('§7view' + (on ? '3d' : '2d')), true;
|
|
569
|
+
on ? q.set('3d', '1') : q.delete('3d');
|
|
570
|
+
location.replace(`${location.pathname}?${q}`);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
435
573
|
function connect() {
|
|
436
574
|
const es = new EventSource(`/events?token=${encodeURIComponent(BOOT.token)}`);
|
|
437
575
|
es.onopen = () => { live.classList.add('on'); live.textContent = t('live'); };
|
|
438
576
|
es.onerror = () => { live.classList.remove('on'); live.textContent = t('off'); };
|
|
439
577
|
es.onmessage = (e) => {
|
|
440
578
|
const m = JSON.parse(e.data);
|
|
579
|
+
if (m.reload) return location.reload(); // e.g. the voice changed in the settings
|
|
580
|
+
if (m.restart) return waitAndReload(); // it became another ot: a new daemon is starting
|
|
581
|
+
if (m.view && setView(m.view === '3d')) return; // 2D ↔ 3D changed in the settings
|
|
582
|
+
if (guest) return; // the host's events are the host's
|
|
583
|
+
if (m.stir || (m.say && asleep && Date.now() < stirUntil)) stir(); // awake until it has said it
|
|
441
584
|
if (m.state) render(m.state);
|
|
442
585
|
if ('thinking' in m) setThinking(!!m.thinking);
|
|
443
586
|
if (m.activity) addActivity(m.activity);
|
|
@@ -445,6 +588,7 @@
|
|
|
445
588
|
if (m.morph) ch.morph?.(m.morph);
|
|
446
589
|
if (m.effect) ch.effect?.(m.effect);
|
|
447
590
|
if (m.say) say(m.say, !m.gesture);
|
|
591
|
+
if (m.point && desktop) window.__goal = m.point; // the desktop shell walks it over there
|
|
448
592
|
};
|
|
449
593
|
}
|
|
450
594
|
live.textContent = t('off');
|
|
@@ -456,12 +600,23 @@
|
|
|
456
600
|
askIn.placeholder = t('askPh');
|
|
457
601
|
$('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
|
|
458
602
|
$('#setBtn').title = t('settings');
|
|
603
|
+
$('#byeBtn').title = t('dismiss');
|
|
604
|
+
$('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
|
|
459
605
|
const talking = (on) => {
|
|
460
606
|
document.body.classList.toggle('talking', on);
|
|
461
607
|
if (on) askIn.focus();
|
|
462
608
|
};
|
|
463
609
|
$('#talkBtn').addEventListener('click', () => talking(!document.body.classList.contains('talking') || !desktop));
|
|
464
|
-
|
|
610
|
+
// double click: the menu stays out (for a while, or until another double click); talking is its 💬 button
|
|
611
|
+
let menuT = 0;
|
|
612
|
+
addEventListener('dblclick', (e) => {
|
|
613
|
+
if (!onOt(e) || e.target.closest?.('.actions, .ask')) return;
|
|
614
|
+
console.log('[7ots] menu');
|
|
615
|
+
const on = !document.body.classList.contains('menu');
|
|
616
|
+
document.body.classList.toggle('menu', on);
|
|
617
|
+
clearTimeout(menuT);
|
|
618
|
+
if (on) menuT = setTimeout(() => document.body.classList.remove('menu'), 8000);
|
|
619
|
+
});
|
|
465
620
|
askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
|
|
466
621
|
ask.addEventListener('submit', async (e) => {
|
|
467
622
|
e.preventDefault();
|
|
@@ -474,6 +629,75 @@
|
|
|
474
629
|
});
|
|
475
630
|
addEventListener('message', (e) => e.data?.talk && talking(true));
|
|
476
631
|
|
|
632
|
+
// ── voice: record until you stop talking, transcribe (/stt), then it's like typing it (/ask) ──
|
|
633
|
+
let rec = null;
|
|
634
|
+
async function listen() {
|
|
635
|
+
if (rec) return rec.state === 'recording' && rec.stop();
|
|
636
|
+
if (!BOOT.stt) return say(t('noStt'));
|
|
637
|
+
let stream;
|
|
638
|
+
try {
|
|
639
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
|
640
|
+
} catch {
|
|
641
|
+
return say(t('noMic'));
|
|
642
|
+
}
|
|
643
|
+
const type = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : '';
|
|
644
|
+
const chunks = [];
|
|
645
|
+
rec = new MediaRecorder(stream, type ? { mimeType: type } : {});
|
|
646
|
+
rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
|
647
|
+
const ac = new AudioContext();
|
|
648
|
+
const an = ac.createAnalyser();
|
|
649
|
+
an.fftSize = 1024;
|
|
650
|
+
ac.createMediaStreamSource(stream).connect(an);
|
|
651
|
+
const buf = new Float32Array(an.fftSize);
|
|
652
|
+
const t0 = Date.now();
|
|
653
|
+
let heard = false;
|
|
654
|
+
let quiet = t0;
|
|
655
|
+
let peak = 0;
|
|
656
|
+
const iv = setInterval(() => {
|
|
657
|
+
an.getFloatTimeDomainData(buf);
|
|
658
|
+
let sum = 0;
|
|
659
|
+
for (const x of buf) sum += x * x;
|
|
660
|
+
const now = Date.now();
|
|
661
|
+
const level = Math.sqrt(sum / buf.length);
|
|
662
|
+
if (level > 0.015) (heard = true), (quiet = now);
|
|
663
|
+
peak = Math.max(peak, level);
|
|
664
|
+
// stops after 1.3 s of silence, 6 s without a word, or 15 s in all
|
|
665
|
+
if ((heard && now - quiet > 1300) || (!heard && now - t0 > 6000) || now - t0 > 15000) rec?.state === 'recording' && rec.stop();
|
|
666
|
+
}, 80);
|
|
667
|
+
rec.onstop = async () => {
|
|
668
|
+
clearInterval(iv);
|
|
669
|
+
stream.getTracks().forEach((x) => x.stop());
|
|
670
|
+
ac.close().catch(() => {});
|
|
671
|
+
rec = null;
|
|
672
|
+
document.body.classList.remove('listening');
|
|
673
|
+
console.log('[7ots voice] stopped', { heard, peak: peak.toFixed(3), chunks: chunks.length, ms: Date.now() - t0 });
|
|
674
|
+
if (!chunks.length) return bubble.classList.remove('on');
|
|
675
|
+
if (!heard && peak < 0.004) return say(t('noMic')); // silence all along: wrong or muted microphone
|
|
676
|
+
setThinking(true);
|
|
677
|
+
const blob = new Blob(chunks, { type: chunks[0].type || 'audio/webm' });
|
|
678
|
+
const r = await fetch('/stt', { method: 'POST', headers: { 'Content-Type': blob.type, 'X-7ots-Token': BOOT.token }, body: blob })
|
|
679
|
+
.then((x) => x.json())
|
|
680
|
+
.catch(() => null);
|
|
681
|
+
setThinking(false);
|
|
682
|
+
const text = r?.text?.trim();
|
|
683
|
+
if (!text) return say(r?.error && !/stt/.test(r.error) ? t('noStt') : t('sttErr'));
|
|
684
|
+
showBubble(document.createTextNode(`🎙️ “${text}”`), 2500);
|
|
685
|
+
post('/ask', { text });
|
|
686
|
+
};
|
|
687
|
+
rec.onerror = (e) => console.warn('[7ots voice] recorder', e.error?.message || e);
|
|
688
|
+
const hard = setTimeout(() => rec?.state === 'recording' && rec.stop(), 15000); // whatever the level meter says
|
|
689
|
+
rec.addEventListener('stop', () => clearTimeout(hard));
|
|
690
|
+
ac.resume().catch(() => {});
|
|
691
|
+
rec.start(250);
|
|
692
|
+
console.log('[7ots voice] listening', rec.mimeType, ac.state);
|
|
693
|
+
document.body.classList.add('listening');
|
|
694
|
+
showBubble(document.createTextNode(t('listening')), 0);
|
|
695
|
+
ch.gesture('nod');
|
|
696
|
+
lookFree = Date.now() + 2000;
|
|
697
|
+
ch.lookAt(0, 0);
|
|
698
|
+
}
|
|
699
|
+
$('#micBtn').addEventListener('click', listen);
|
|
700
|
+
|
|
477
701
|
// The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
|
|
478
702
|
addEventListener('message', (e) => {
|
|
479
703
|
const d = e.data || {};
|
|
@@ -482,11 +706,100 @@
|
|
|
482
706
|
else $('#stage').classList.toggle('flip', d.walk < 0);
|
|
483
707
|
}
|
|
484
708
|
if (d.hop) ch.gesture('bounce');
|
|
709
|
+
if (d.errand) ch.effect?.('exclaim');
|
|
710
|
+
if (d.point) {
|
|
711
|
+
lookFree = Date.now() + 3500;
|
|
712
|
+
ch.lookAtPoint(d.at[0], d.at[1]);
|
|
713
|
+
ch.gesture('point', { ms: 2200 });
|
|
714
|
+
}
|
|
715
|
+
if (d.listen) listen();
|
|
485
716
|
if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
|
|
486
717
|
if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
|
|
487
718
|
if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
|
|
488
719
|
});
|
|
489
720
|
|
|
721
|
+
// ── scenes with other ots: the shell's director sends lines, gestures and acts to each one ──
|
|
722
|
+
const act = $('#act');
|
|
723
|
+
const props = $('#props');
|
|
724
|
+
const prop = (cls, css = {}, text = '') => {
|
|
725
|
+
const e = Object.assign(document.createElement('div'), { className: cls, textContent: text });
|
|
726
|
+
Object.assign(e.style, css);
|
|
727
|
+
props.append(e);
|
|
728
|
+
return e;
|
|
729
|
+
};
|
|
730
|
+
const gone = (e, a) => a.finished.then(() => e.remove(), () => e.remove());
|
|
731
|
+
const ACTS = {
|
|
732
|
+
guard: () => act.animate([{ transform: 'none' }, { transform: 'translateY(-5px) rotate(-3deg)' }, { transform: 'none' }, { transform: 'translateY(-5px) rotate(3deg)' }, { transform: 'none' }], { duration: 700, iterations: 3 }),
|
|
733
|
+
jab: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 30}px) rotate(${d * 7}deg) scaleX(1.06)`, offset: 0.35 }, { transform: 'none' }], { duration: 280, easing: 'ease-out' }),
|
|
734
|
+
uppercut: (d) => act.animate([{ transform: 'none' }, { transform: `translate(${-d * 6}px, 8px) rotate(${-d * 8}deg)`, offset: 0.3 }, { transform: `translate(${d * 22}px, -30px) rotate(${d * 16}deg)`, offset: 0.6 }, { transform: 'none' }], { duration: 650, easing: 'ease-out' }),
|
|
735
|
+
hit: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 16}px) rotate(${-d * 12}deg)`, offset: 0.25 }, { transform: 'none' }], { duration: 420 }),
|
|
736
|
+
pow: (d, o) => {
|
|
737
|
+
const e = prop('pow', {}, o?.text || 'POW!');
|
|
738
|
+
return gone(e, e.animate([{ opacity: 0, transform: 'translateX(-50%) scale(.3)' }, { opacity: 1, transform: 'translateX(-50%) scale(1.25) rotate(-8deg)', offset: 0.3 }, { opacity: 0, transform: 'translate(-50%, -16px) scale(1)' }], { duration: 900 }));
|
|
739
|
+
},
|
|
740
|
+
// Matrix: bending back in slow motion while the bullets go by
|
|
741
|
+
lean: (d) => {
|
|
742
|
+
const a = act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 74}deg) translateX(${-d * 8}px)`, offset: 0.22 }, { transform: `rotate(${-d * 80}deg) translateX(${-d * 10}px)`, offset: 0.78 }, { transform: 'none' }], { duration: 3200, easing: 'cubic-bezier(.3,.7,.3,1)' });
|
|
743
|
+
for (let i = 0; i < 3; i++) {
|
|
744
|
+
const y = `${26 + i * 7}%`;
|
|
745
|
+
const from = d > 0 ? `calc(100% + ${actRoom}px)` : `-${actRoom}px`;
|
|
746
|
+
const to = d > 0 ? `-${actRoom}px` : `calc(100% + ${actRoom}px)`;
|
|
747
|
+
const b = prop('bullet', { top: y, left: from });
|
|
748
|
+
gone(b, b.animate([{ left: from }, { left: to }], { duration: 2400, delay: 500 + i * 260, easing: 'linear', fill: 'both' }));
|
|
749
|
+
const r = prop('ring', { top: y, left: from, opacity: 0 });
|
|
750
|
+
gone(r, r.animate([{ left: from, opacity: 0 }, { left: '50%', opacity: 1, transform: 'scale(1)' }, { left: to, opacity: 0, transform: 'scale(2.6)' }], { duration: 2400, delay: 500 + i * 260, fill: 'both' }));
|
|
751
|
+
}
|
|
752
|
+
return a;
|
|
753
|
+
},
|
|
754
|
+
fall: (d) => act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.2 }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.8 }, { transform: 'none' }], { duration: 2600, easing: 'ease-in-out' }),
|
|
755
|
+
charge: () => act.animate([{ transform: 'none', filter: 'none' }, { transform: 'translate(-2px,1px)', filter: 'drop-shadow(0 0 10px #59c4ff)' }, { transform: 'translate(2px,-1px)', filter: 'drop-shadow(0 0 18px #59c4ff)' }, { transform: 'none', filter: 'none' }], { duration: 160, iterations: 9 }),
|
|
756
|
+
hadouken: (d) => {
|
|
757
|
+
const b = prop('ball');
|
|
758
|
+
gone(b, b.animate([{ transform: 'translateX(0) scale(.4)' }, { transform: `translateX(${d * (actRoom - 10)}px) scale(1.15)` }], { duration: 650, easing: 'ease-in' }));
|
|
759
|
+
return act.animate([{ transform: 'none' }, { transform: `translateX(${d * 12}px)`, offset: 0.2 }, { transform: 'none' }], { duration: 700 });
|
|
760
|
+
},
|
|
761
|
+
beam: (d) => {
|
|
762
|
+
const b = prop('beam', d > 0 ? { left: '50%' } : { right: '50%', left: 'auto' });
|
|
763
|
+
gone(b, b.animate([{ width: '0px', opacity: 1 }, { width: `${actRoom}px`, opacity: 1, offset: 0.25 }, { width: `${actRoom}px`, opacity: 1, offset: 0.8 }, { width: `${actRoom}px`, opacity: 0 }], { duration: 1800 }));
|
|
764
|
+
return act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 6}px)` }], { duration: 120, iterations: 14, direction: 'alternate' });
|
|
765
|
+
},
|
|
766
|
+
saber: (d, o) => {
|
|
767
|
+
const c = o?.color || '#ff3b3b';
|
|
768
|
+
const s = prop('saber', { background: `linear-gradient(90deg, ${c}, #fff 40%, #fff 60%, ${c})`, boxShadow: `0 0 12px 4px ${c}`, left: `calc(50% + ${d * 34}px)` });
|
|
769
|
+
return gone(s, s.animate([{ transform: `rotate(${-d * 40}deg)` }, { transform: `rotate(${d * 50}deg)` }, { transform: `rotate(${-d * 20}deg)` }, { transform: `rotate(${d * 70}deg)` }, { transform: `rotate(${-d * 40}deg)` }], { duration: 2400, easing: 'ease-in-out' }));
|
|
770
|
+
},
|
|
771
|
+
bang: (d) => {
|
|
772
|
+
const f = prop('flash', { left: `calc(50% + ${d * 46}px)` });
|
|
773
|
+
return gone(f, f.animate([{ opacity: 1, transform: 'scale(.4)' }, { opacity: 0, transform: 'scale(1.6)' }], { duration: 260 }));
|
|
774
|
+
},
|
|
775
|
+
twist: () => act.animate([{ transform: 'rotate(-12deg) translateX(-6px)' }, { transform: 'rotate(12deg) translateX(6px)' }], { duration: 330, iterations: 10, direction: 'alternate', easing: 'ease-in-out' }),
|
|
776
|
+
shove: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 24}px) scaleX(1.1)`, offset: 0.4 }, { transform: 'none' }], { duration: 380 }),
|
|
777
|
+
};
|
|
778
|
+
addEventListener('message', (e) => {
|
|
779
|
+
const d = e.data || {};
|
|
780
|
+
if (d.hush) (bubble.classList.remove('on'), clearTimeout(say.t), talk(false)); // the other ot's turn to talk
|
|
781
|
+
if (d.line) {
|
|
782
|
+
showBubble(document.createTextNode(d.line), d.ms || Math.max(2200, d.line.length * 70));
|
|
783
|
+
talk(true, d.line);
|
|
784
|
+
clearTimeout(say.t);
|
|
785
|
+
say.t = setTimeout(() => talk(false), 400 + d.line.length * 55);
|
|
786
|
+
}
|
|
787
|
+
if (d.gesture) ch.gesture(d.gesture, d.gms ? { ms: d.gms } : undefined);
|
|
788
|
+
if (d.effect) ch.effect?.(d.effect);
|
|
789
|
+
if (d.act && ACTS[d.act]) {
|
|
790
|
+
actRoom = d.room || 150;
|
|
791
|
+
actUntil = Date.now() + (d.ms || 3600);
|
|
792
|
+
tellNeed();
|
|
793
|
+
requestAnimationFrame(() => ACTS[d.act](d.dir || 1, d));
|
|
794
|
+
}
|
|
795
|
+
if (d.face != null) {
|
|
796
|
+
if (is3d) ch.character.yaw = -d.face * 0.9;
|
|
797
|
+
else $('#stage').classList.toggle('flip', d.face < 0);
|
|
798
|
+
lookFree = Date.now() + 1500;
|
|
799
|
+
ch.lookAt?.(d.face * 0.8, 0);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
490
803
|
// ── personality: small life between events (glances, fidgets, effects, shape changes) ──
|
|
491
804
|
const pick = (a) => a[Math.floor(Math.random() * a.length)];
|
|
492
805
|
const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
|
|
@@ -528,7 +841,7 @@
|
|
|
528
841
|
}
|
|
529
842
|
(function life() {
|
|
530
843
|
setTimeout(() => {
|
|
531
|
-
if (!asleep && !thinking && !bubble.classList.contains('on') && !document.hidden) fidget();
|
|
844
|
+
if (!asleep && !thinking && !rec && !bubble.classList.contains('on') && !document.hidden) fidget();
|
|
532
845
|
life();
|
|
533
846
|
}, 4000 + Math.random() * 8000);
|
|
534
847
|
})();
|