@7ots/cli 0.1.3 → 0.1.5
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/cli/7ots.mjs +11 -0
- package/cli/lib/assistant.mjs +17 -7
- package/cli/lib/config.mjs +8 -1
- package/cli/lib/i18n.mjs +6 -0
- package/cli/lib/pet-server.mjs +104 -14
- package/cli/pet/electron/main.cjs +205 -41
- package/cli/pet/pet.html +94 -10
- package/cli/pet/settings.html +430 -0
- package/package.json +1 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Desktop mode: the pet page in a transparent, frameless, always-on-top window that strolls
|
|
3
|
-
* along the bottom of the screen. Launched by `7ots pet --mode desktop` with
|
|
3
|
+
* along the bottom of the screen. It resizes to what is in use and lets clicks through its empty parts. Launched by `7ots pet --mode desktop` with
|
|
4
4
|
* ~/.7ots/electron/…/electron cli/pet/electron/main.cjs (installed once by the CLI)
|
|
5
5
|
* and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment.
|
|
6
|
-
* Menu: right click on the pet, or the tray icon (Show / Hide / care / Quit).
|
|
6
|
+
* Menu: right click on the pet, or the tray icon (Show / Hide / Talk / care / Settings / Quit).
|
|
7
7
|
* SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const { app, BrowserWindow, Menu, Tray, nativeImage, screen } = require('electron');
|
|
11
|
+
const { app, BrowserWindow, Menu, Tray, nativeImage, screen, shell } = require('electron');
|
|
12
12
|
|
|
13
13
|
const URL_ = process.env.SEVENOTS_PET_URL;
|
|
14
14
|
const TOKEN = process.env.SEVENOTS_PET_TOKEN;
|
|
@@ -16,16 +16,16 @@ const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
|
|
|
16
16
|
const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
|
|
17
17
|
const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
|
|
18
18
|
const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
|
|
19
|
-
const
|
|
20
|
-
const
|
|
19
|
+
const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
|
|
20
|
+
const SMALL = { w: 160, h: 164 }; // just the ot
|
|
21
21
|
const linux = process.platform === 'linux';
|
|
22
22
|
|
|
23
23
|
const L = {
|
|
24
|
-
en: { talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
|
|
25
|
-
es: { talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
|
|
26
|
-
pt: { talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
|
|
24
|
+
en: { settings: '⚙️ Settings', talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
|
|
25
|
+
es: { settings: '⚙️ Configuración', talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
|
|
26
|
+
pt: { settings: '⚙️ Configurações', talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
|
|
27
27
|
}[LANG] || {};
|
|
28
|
-
const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
28
|
+
const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
29
29
|
|
|
30
30
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
|
|
31
31
|
if (linux && !OPAQUE) {
|
|
@@ -41,8 +41,8 @@ app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the co
|
|
|
41
41
|
|
|
42
42
|
function start() {
|
|
43
43
|
const area = screen.getPrimaryDisplay().workArea;
|
|
44
|
-
const
|
|
45
|
-
|
|
44
|
+
const right = area.x + area.width;
|
|
45
|
+
const floor = area.y + area.height;
|
|
46
46
|
|
|
47
47
|
// Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
|
|
48
48
|
let tray = null;
|
|
@@ -54,14 +54,18 @@ function start() {
|
|
|
54
54
|
}
|
|
55
55
|
} catch {}
|
|
56
56
|
|
|
57
|
+
// The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
|
|
58
|
+
// SMALL is just the body; BIG (room for the bubble, the buttons and the talk box) only while needed.
|
|
59
|
+
const homeAnchor = () => ({ x: right - BIG.w / 2 - 24, y: floor });
|
|
60
|
+
const pos = homeAnchor();
|
|
61
|
+
let size = BIG; // it says hi on start
|
|
62
|
+
const bounds = (s = size, a = pos, lift = 0) => ({ x: Math.round(a.x - s.w / 2), y: Math.round(a.y - s.h - lift), width: s.w, height: s.h });
|
|
63
|
+
|
|
57
64
|
const win = new BrowserWindow({
|
|
58
|
-
|
|
59
|
-
height: H,
|
|
60
|
-
x,
|
|
61
|
-
y,
|
|
65
|
+
...bounds(),
|
|
62
66
|
transparent: !OPAQUE,
|
|
63
67
|
frame: false,
|
|
64
|
-
resizable:
|
|
68
|
+
resizable: true, // resized by code only; frameless, so no edges to grab
|
|
65
69
|
hasShadow: OPAQUE,
|
|
66
70
|
alwaysOnTop: true,
|
|
67
71
|
skipTaskbar: !!tray,
|
|
@@ -73,13 +77,28 @@ function start() {
|
|
|
73
77
|
win.setAlwaysOnTop(true, 'floating');
|
|
74
78
|
win.setVisibleOnAllWorkspaces?.(true);
|
|
75
79
|
win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
|
|
76
|
-
|
|
80
|
+
const settingsUrl = `${URL_}/settings?token=${encodeURIComponent(TOKEN)}`;
|
|
81
|
+
const openSettings = () => shell.openExternal(settingsUrl).catch(() => {});
|
|
82
|
+
// The page's ⚙️ button opens the settings in the real browser; anything else is denied.
|
|
83
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
84
|
+
if (url.startsWith(`${URL_}/settings`)) openSettings();
|
|
85
|
+
return { action: 'deny' };
|
|
86
|
+
});
|
|
77
87
|
win.webContents.on('will-navigate', (e) => e.preventDefault());
|
|
88
|
+
const alive = () => !win.isDestroyed();
|
|
89
|
+
const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
|
|
78
90
|
|
|
79
91
|
let still = false;
|
|
92
|
+
let lastBounds = null;
|
|
93
|
+
const place = (lift = 0) => {
|
|
94
|
+
const b = bounds(size, pos, lift);
|
|
95
|
+
if (lastBounds && b.x === lastBounds.x && b.y === lastBounds.y && b.width === lastBounds.width && b.height === lastBounds.height) return;
|
|
96
|
+
lastBounds = b;
|
|
97
|
+
win.setBounds(b);
|
|
98
|
+
};
|
|
80
99
|
const show = () => {
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
Object.assign(pos, homeAnchor());
|
|
101
|
+
place();
|
|
83
102
|
win.showInactive();
|
|
84
103
|
win.moveTop();
|
|
85
104
|
};
|
|
@@ -87,12 +106,13 @@ function start() {
|
|
|
87
106
|
Menu.buildFromTemplate([
|
|
88
107
|
win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
|
|
89
108
|
{ type: 'separator' },
|
|
90
|
-
{ label: tr('talk'), click: () => { show(); win.focus();
|
|
109
|
+
{ label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
|
|
91
110
|
{ label: tr('feed'), click: () => post('/event', { type: 'feed' }) },
|
|
92
111
|
{ label: tr('play'), click: () => post('/event', { type: 'play' }) },
|
|
93
112
|
{ label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
|
|
94
113
|
{ label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
|
|
95
114
|
{ type: 'separator' },
|
|
115
|
+
{ label: tr('settings'), click: openSettings },
|
|
96
116
|
{ label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
|
|
97
117
|
{ label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
|
|
98
118
|
]);
|
|
@@ -105,32 +125,176 @@ function start() {
|
|
|
105
125
|
tray.on('click', () => (win.isVisible() ? win.hide() : show()));
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
//
|
|
109
|
-
let dir = -1;
|
|
110
|
-
let walking = false;
|
|
128
|
+
// The user dragged it: adopt the new spot as its anchor and let it rest there a while.
|
|
111
129
|
let dragged = 0;
|
|
112
|
-
let lastSet = x;
|
|
113
130
|
win.on('moved', () => {
|
|
114
|
-
if (
|
|
131
|
+
if (!alive() || !lastBounds) return;
|
|
132
|
+
const [wx, wy] = win.getPosition();
|
|
133
|
+
if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
|
|
134
|
+
dragged = Date.now();
|
|
135
|
+
droppedSaid = false;
|
|
136
|
+
pos.x = wx + size.w / 2;
|
|
137
|
+
pos.y = wy + size.h;
|
|
138
|
+
lastBounds = { ...lastBounds, x: wx, y: wy };
|
|
139
|
+
plan = { mode: 'idle', until: Date.now() + 6000 };
|
|
115
140
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
|
|
142
|
+
// ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
|
|
143
|
+
let rects = [];
|
|
144
|
+
let busy = false; // bubble or talk box on screen
|
|
145
|
+
const HIT = `(() => {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
|
|
148
|
+
const cs = getComputedStyle(e);
|
|
149
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
|
|
150
|
+
const r = e.getBoundingClientRect();
|
|
151
|
+
if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
|
|
152
|
+
}
|
|
153
|
+
return { rects: out, busy: document.body.classList.contains('talking') || !!document.querySelector('.bubble.on') };
|
|
154
|
+
})()`;
|
|
155
|
+
const hitTimer = setInterval(() => {
|
|
156
|
+
if (!alive()) return;
|
|
157
|
+
win.webContents.executeJavaScript(HIT).then((r) => ({ rects, busy } = r)).catch(() => {});
|
|
158
|
+
}, 250);
|
|
159
|
+
|
|
160
|
+
// Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
|
|
161
|
+
// clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
|
|
162
|
+
let passing = null;
|
|
163
|
+
let hoverUntil = 0;
|
|
164
|
+
const pass = (p) => {
|
|
165
|
+
if (p === passing || !alive()) return;
|
|
166
|
+
passing = p;
|
|
167
|
+
win.setIgnoreMouseEvents(p, { forward: true });
|
|
168
|
+
};
|
|
169
|
+
let cursorNear = false;
|
|
170
|
+
let lastLook = null;
|
|
171
|
+
let droppedSaid = true;
|
|
172
|
+
const mouseTimer = setInterval(() => {
|
|
173
|
+
if (!alive() || !win.isVisible()) return;
|
|
174
|
+
const c = screen.getCursorScreenPoint();
|
|
175
|
+
const [wx, wy] = win.getPosition();
|
|
176
|
+
const x = c.x - wx;
|
|
177
|
+
const y = c.y - wy;
|
|
178
|
+
const over = rects.some(([l, t, r, b]) => x >= l - 4 && x <= r + 4 && y >= t - 4 && y <= b + 4);
|
|
179
|
+
if (over) hoverUntil = Date.now() + 900;
|
|
180
|
+
// the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
|
|
181
|
+
if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
|
|
182
|
+
lastLook = [x, y];
|
|
183
|
+
page(`postMessage({ look: [${x}, ${y}] })`);
|
|
184
|
+
}
|
|
185
|
+
// dropped after a drag: a little dizzy
|
|
186
|
+
if (dragged && !droppedSaid && Date.now() - dragged > 350) {
|
|
187
|
+
droppedSaid = true;
|
|
188
|
+
page('postMessage({ dropped: true })');
|
|
189
|
+
}
|
|
190
|
+
cursorNear = Math.hypot(c.x - pos.x, c.y - (pos.y - SMALL.h / 2)) < 260;
|
|
191
|
+
pass(!(over || busy) && rects.length > 0);
|
|
192
|
+
// Size: big while it talks, you type, or the cursor is on it (its buttons show on hover).
|
|
193
|
+
const want = busy || Date.now() < hoverUntil ? BIG : SMALL;
|
|
194
|
+
if (want !== size && Date.now() - dragged > 400) {
|
|
195
|
+
size = want;
|
|
196
|
+
place();
|
|
197
|
+
}
|
|
198
|
+
}, 60);
|
|
199
|
+
|
|
200
|
+
// ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
|
|
201
|
+
const rnd = (a, b) => a + Math.random() * (b - a);
|
|
202
|
+
const BAND = 200; // how far above the taskbar it roams
|
|
203
|
+
const minX = area.x + BIG.w / 2;
|
|
204
|
+
const maxX = right - BIG.w / 2;
|
|
205
|
+
const clampY = (y) => Math.max(floor - BAND, Math.min(floor, y));
|
|
206
|
+
const vel = { x: 0, y: 0 };
|
|
207
|
+
let plan = { mode: 'idle', until: Date.now() + 2500 };
|
|
208
|
+
let gait = 0;
|
|
209
|
+
let facing = 0; // what the page last heard: -1..1 (3D yaw follows it smoothly)
|
|
210
|
+
let lastT = Date.now();
|
|
211
|
+
|
|
212
|
+
function next() {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const r = Math.random();
|
|
215
|
+
if (r < 0.38) return { mode: 'idle', until: now + rnd(1800, 7000) };
|
|
216
|
+
if (r < 0.48) return { mode: 'hop', start: now, hops: 1 + Math.floor(Math.random() * 3), then: { mode: 'idle', until: now + 2500 } };
|
|
217
|
+
if (r < 0.6 && cursorNear) return { mode: 'follow', until: now + rnd(3000, 6000), speed: rnd(70, 110) };
|
|
218
|
+
// a stroll: sometimes short and lazy, sometimes across the screen
|
|
219
|
+
const far = Math.random() < 0.3;
|
|
220
|
+
const dx = (far ? rnd(300, 900) : rnd(60, 260)) * (Math.random() < 0.5 ? -1 : 1);
|
|
221
|
+
const tx = Math.max(minX, Math.min(maxX, pos.x + dx));
|
|
222
|
+
return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const stepTimer = setInterval(() => {
|
|
226
|
+
// isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
|
|
227
|
+
if (!alive() || !win.isVisible()) return;
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const dt = Math.min(0.05, (now - lastT) / 1000);
|
|
230
|
+
lastT = now;
|
|
231
|
+
if (still || busy || now - dragged < 6000 || now < hoverUntil) {
|
|
232
|
+
// stop gently
|
|
233
|
+
vel.x *= 0.8;
|
|
234
|
+
vel.y *= 0.8;
|
|
235
|
+
if (Math.abs(vel.x) < 1 && facing !== 0) tellFacing(0);
|
|
129
236
|
return;
|
|
130
237
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
238
|
+
|
|
239
|
+
let lift = 0;
|
|
240
|
+
let tx = pos.x;
|
|
241
|
+
let ty = pos.y;
|
|
242
|
+
let speed = 0;
|
|
243
|
+
if (plan.mode === 'idle' && now > plan.until) {
|
|
244
|
+
plan = next();
|
|
245
|
+
if (plan.mode === 'follow') page('postMessage({ follow: true })');
|
|
246
|
+
}
|
|
247
|
+
if (plan.mode === 'go') ({ tx, ty, speed } = plan);
|
|
248
|
+
else if (plan.mode === 'follow') {
|
|
249
|
+
const c = screen.getCursorScreenPoint();
|
|
250
|
+
// stays a polite distance from the cursor, on the floor band
|
|
251
|
+
tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
|
|
252
|
+
ty = clampY(c.y + SMALL.h / 2);
|
|
253
|
+
speed = plan.speed;
|
|
254
|
+
if (now > plan.until) plan = { mode: 'idle', until: now + rnd(1500, 4000) };
|
|
255
|
+
} else if (plan.mode === 'hop') {
|
|
256
|
+
const t = (now - plan.start) / 480;
|
|
257
|
+
if (t >= plan.hops) plan = plan.then;
|
|
258
|
+
else if (Math.floor(t) !== plan.said) (plan.said = Math.floor(t), page('postMessage({ hop: true })'));
|
|
259
|
+
if (plan.mode === 'hop') lift = Math.sin(Math.PI * (t % 1)) * 34 * (1 - Math.floor(t) * 0.25);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// arrive: full speed far away, easing to a stop in the last 140 px; velocity eases too (no jerks)
|
|
263
|
+
const dx = tx - pos.x;
|
|
264
|
+
const dy = ty - pos.y;
|
|
265
|
+
const dist = Math.hypot(dx, dy);
|
|
266
|
+
const want = speed * Math.min(1, dist / 140);
|
|
267
|
+
const vx = dist > 0.5 ? (dx / dist) * want : 0;
|
|
268
|
+
const vy = dist > 0.5 ? (dy / dist) * want : 0;
|
|
269
|
+
const k = Math.min(1, dt * 2.6);
|
|
270
|
+
vel.x += (vx - vel.x) * k;
|
|
271
|
+
vel.y += (vy - vel.y) * k;
|
|
272
|
+
pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
|
|
273
|
+
pos.y = clampY(pos.y + vel.y * dt);
|
|
274
|
+
if (plan.mode === 'go' && dist < 3 && Math.hypot(vel.x, vel.y) < 6) plan = { mode: 'idle', until: now + rnd(1500, 6000) };
|
|
275
|
+
|
|
276
|
+
// gait: a small bounce per step, scaled by speed
|
|
277
|
+
const v = Math.hypot(vel.x, vel.y);
|
|
278
|
+
gait += dt * (4 + v / 18);
|
|
279
|
+
if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
|
|
280
|
+
|
|
281
|
+
// facing: turn toward where it walks, back to the front when it stops
|
|
282
|
+
const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : 0;
|
|
283
|
+
if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
|
|
284
|
+
|
|
285
|
+
place(Math.round(lift));
|
|
286
|
+
}, 16);
|
|
287
|
+
|
|
288
|
+
function tellFacing(f) {
|
|
289
|
+
facing = f;
|
|
290
|
+
page(`postMessage({ walk: ${f} })`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
win.on('closed', () => {
|
|
294
|
+
clearInterval(stepTimer);
|
|
295
|
+
clearInterval(hitTimer);
|
|
296
|
+
clearInterval(mouseTimer);
|
|
297
|
+
});
|
|
134
298
|
}
|
|
135
299
|
|
|
136
300
|
// Hidden is not closed: the pet lives on in the tray until Quit.
|
package/cli/pet/pet.html
CHANGED
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
/* actions */
|
|
81
81
|
.actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 18px 16px; }
|
|
82
82
|
.act { display: flex; align-items: center; justify-content: center; gap: 7px; height: 38px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-weight: 650; font-size: 13px; cursor: pointer; transition: background .15s, border-color .15s, transform .1s; }
|
|
83
|
+
a.act { color: inherit; text-decoration: none; -webkit-app-region: no-drag; }
|
|
83
84
|
.act svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
84
85
|
.act:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
|
|
85
86
|
.act:active { transform: scale(.97); }
|
|
@@ -174,6 +175,7 @@
|
|
|
174
175
|
<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>
|
|
175
176
|
<button class="act" data-act="play"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M5 8.5c3 1.5 11 1.5 14 0M5 15.5c3-1.5 11-1.5 14 0"/></svg><span data-t="play"></span></button>
|
|
176
177
|
<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
|
+
<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>
|
|
177
179
|
</nav>
|
|
178
180
|
|
|
179
181
|
<section class="feed" aria-labelledby="feed-h">
|
|
@@ -187,17 +189,17 @@
|
|
|
187
189
|
<script type="module">
|
|
188
190
|
const BOOT = __BOOT__;
|
|
189
191
|
const T = {
|
|
190
|
-
en: { talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
|
|
192
|
+
en: { settings: 'Settings', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
|
|
191
193
|
activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
|
|
192
194
|
brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
|
|
193
195
|
moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
|
|
194
196
|
kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
|
|
195
|
-
es: { talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Comida', 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…',
|
|
197
|
+
es: { settings: 'Ajustes', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Comida', 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…',
|
|
196
198
|
activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
|
|
197
199
|
brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
|
|
198
200
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
|
|
199
201
|
kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
|
|
200
|
-
pt: { talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Comida', 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…',
|
|
202
|
+
pt: { settings: 'Ajustes', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Comida', 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…',
|
|
201
203
|
activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
|
|
202
204
|
brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
|
|
203
205
|
moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
|
|
@@ -244,7 +246,8 @@
|
|
|
244
246
|
}
|
|
245
247
|
ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
|
|
246
248
|
const is3d = !!ch.character;
|
|
247
|
-
|
|
249
|
+
let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
|
|
250
|
+
addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
|
|
248
251
|
$('#face').addEventListener('click', () => ch.gesture('bounce'));
|
|
249
252
|
|
|
250
253
|
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
|
|
@@ -384,8 +387,35 @@
|
|
|
384
387
|
clearTimeout(hideT);
|
|
385
388
|
if (ms) hideT = setTimeout(() => bubble.classList.remove('on'), ms);
|
|
386
389
|
}
|
|
387
|
-
|
|
390
|
+
// What it says shows on its face too: a gesture/effect picked from the words (when the server sent none).
|
|
391
|
+
const REACT = [
|
|
392
|
+
[/(ja){2,}|(ha){2,}|jeje|lol|kkk|😂|🤣/i, 'laugh', null],
|
|
393
|
+
[/gracias|thank|obrigad|te quiero|love you|❤|💜|lindo|cute/i, 'bow', 'hearts'],
|
|
394
|
+
[/error|fail|fall[óo]|rompi|broke|oops|ups|ay no|uh.?oh/i, 'shake', 'sweat'],
|
|
395
|
+
[/listo|done|logrado|genial|great|bravo|yay|bien hecho|nice|perfect|✓|✅|🎉/i, 'celebrate', 'confetti'],
|
|
396
|
+
[/hambre|hungry|fome|comida|food|🍎/i, 'point', 'thought'],
|
|
397
|
+
[/sueño|tired|cansad|dormir|sleep|nap|zz/i, 'yawn', null],
|
|
398
|
+
[/miedo|scary|assust|fear|😱/i, 'shiver', 'sweat'],
|
|
399
|
+
[/enojad|angry|bravo con|grr|😠/i, 'stomp', 'anger'],
|
|
400
|
+
[/hola|hello|hi\b|hey|oi\b|buen(os|as) d[ií]as|good morning/i, 'wave', null],
|
|
401
|
+
[/[?¿]\s*$/, 'think', 'question'],
|
|
402
|
+
[/!\s*$/, 'bounce', 'exclaim'],
|
|
403
|
+
];
|
|
404
|
+
function react(text) {
|
|
405
|
+
for (const [re, g, fx] of REACT) {
|
|
406
|
+
if (!re.test(text)) continue;
|
|
407
|
+
if (g) ch.gesture(g);
|
|
408
|
+
if (fx) ch.effect?.(fx);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
ch.gesture(Math.random() < 0.5 ? 'nod' : 'wiggle');
|
|
412
|
+
}
|
|
413
|
+
function say(text, withFace = false) {
|
|
388
414
|
showBubble(document.createTextNode(text), Math.max(4500, text.length * 90));
|
|
415
|
+
talk(true, text); // the mouth moves with the words even without a voice; a real voice re-syncs it
|
|
416
|
+
clearTimeout(say.t);
|
|
417
|
+
say.t = setTimeout(() => talk(false), 500 + text.length * 60);
|
|
418
|
+
if (withFace) react(text);
|
|
389
419
|
speak(text);
|
|
390
420
|
}
|
|
391
421
|
function setThinking(on) {
|
|
@@ -414,7 +444,7 @@
|
|
|
414
444
|
if (m.gesture) ch.gesture(m.gesture);
|
|
415
445
|
if (m.morph) ch.morph?.(m.morph);
|
|
416
446
|
if (m.effect) ch.effect?.(m.effect);
|
|
417
|
-
if (m.say) say(m.say);
|
|
447
|
+
if (m.say) say(m.say, !m.gesture);
|
|
418
448
|
};
|
|
419
449
|
}
|
|
420
450
|
live.textContent = t('off');
|
|
@@ -424,6 +454,8 @@
|
|
|
424
454
|
const ask = $('#ask');
|
|
425
455
|
const askIn = $('#askIn');
|
|
426
456
|
askIn.placeholder = t('askPh');
|
|
457
|
+
$('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
|
|
458
|
+
$('#setBtn').title = t('settings');
|
|
427
459
|
const talking = (on) => {
|
|
428
460
|
document.body.classList.toggle('talking', on);
|
|
429
461
|
if (on) askIn.focus();
|
|
@@ -442,12 +474,64 @@
|
|
|
442
474
|
});
|
|
443
475
|
addEventListener('message', (e) => e.data?.talk && talking(true));
|
|
444
476
|
|
|
445
|
-
// The desktop shell tells us
|
|
477
|
+
// The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
|
|
446
478
|
addEventListener('message', (e) => {
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
479
|
+
const d = e.data || {};
|
|
480
|
+
if (d.walk != null) {
|
|
481
|
+
if (is3d) ch.character.yaw = -d.walk * 0.9; // turns toward where it walks, smoothly
|
|
482
|
+
else $('#stage').classList.toggle('flip', d.walk < 0);
|
|
483
|
+
}
|
|
484
|
+
if (d.hop) ch.gesture('bounce');
|
|
485
|
+
if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
|
|
486
|
+
if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
|
|
487
|
+
if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
|
|
450
488
|
});
|
|
489
|
+
|
|
490
|
+
// ── personality: small life between events (glances, fidgets, effects, shape changes) ──
|
|
491
|
+
const pick = (a) => a[Math.floor(Math.random() * a.length)];
|
|
492
|
+
const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
|
|
493
|
+
const weighted = (list) => {
|
|
494
|
+
let r = Math.random() * list.reduce((a, x) => a + x[0], 0);
|
|
495
|
+
for (const x of list) if ((r -= x[0]) <= 0) return x[1];
|
|
496
|
+
};
|
|
497
|
+
function glance() {
|
|
498
|
+
lookFree = Date.now() + 1400 + Math.random() * 1200;
|
|
499
|
+
ch.lookAt((Math.random() * 2 - 1) * 0.8, (Math.random() * 2 - 1) * 0.5);
|
|
500
|
+
if (Math.random() < 0.4) ch.blink?.();
|
|
501
|
+
}
|
|
502
|
+
function fidget() {
|
|
503
|
+
const s = last || {};
|
|
504
|
+
const tired = s.energy < 35;
|
|
505
|
+
const bored = s.fun < 35;
|
|
506
|
+
const hungry = s.food < 35;
|
|
507
|
+
const happy = s.mood === 'happy';
|
|
508
|
+
weighted([
|
|
509
|
+
[5, glance],
|
|
510
|
+
[2, () => ch.gesture('wiggle')],
|
|
511
|
+
[1.5, () => (ch.gesture('think'), ch.effect?.('question'))],
|
|
512
|
+
[1, () => ch.gesture('shrug')],
|
|
513
|
+
[0.6, () => ch.gesture('hiccup')],
|
|
514
|
+
[0.5, () => (ch.gesture('sneeze'), ch.effect?.('sneeze'))],
|
|
515
|
+
[happy ? 2.5 : 0.6, () => (ch.gesture('dance'), ch.effect?.('notes'))],
|
|
516
|
+
[happy ? 0.8 : 0.2, () => (ch.gesture('celebrate'), ch.effect?.('sparkles'))],
|
|
517
|
+
[0.6, () => ch.gesture('spin')],
|
|
518
|
+
[0.5, () => ch.gesture('clap')],
|
|
519
|
+
[tired ? 3 : 0.4, () => ch.gesture('yawn')],
|
|
520
|
+
[bored ? 1.5 : 0.2, () => (ch.gesture('stomp'), ch.effect?.('steam'))],
|
|
521
|
+
[hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
|
|
522
|
+
[0.6, () => ch.effect?.('blush')],
|
|
523
|
+
[0.3, () => ch.gesture('shiver')],
|
|
524
|
+
[1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch', 'jelly']), { ms: 1600 + Math.random() * 1600 })],
|
|
525
|
+
[0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
|
|
526
|
+
[0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
|
|
527
|
+
])?.();
|
|
528
|
+
}
|
|
529
|
+
(function life() {
|
|
530
|
+
setTimeout(() => {
|
|
531
|
+
if (!asleep && !thinking && !bubble.classList.contains('on') && !document.hidden) fidget();
|
|
532
|
+
life();
|
|
533
|
+
}, 4000 + Math.random() * 8000);
|
|
534
|
+
})();
|
|
451
535
|
</script>
|
|
452
536
|
</body>
|
|
453
537
|
</html>
|