@7ots/cli 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +24 -11
- package/cli/pet/pet.html +7 -3
- package/cli/pet/settings.html +430 -0
- package/package.json +1 -1
package/cli/7ots.mjs
CHANGED
|
@@ -232,6 +232,14 @@ async function cmdReminders({ flags }) {
|
|
|
232
232
|
if (r.notes.length) console.log(`\n${bold('notes')}\n${r.notes.map((n) => ` · ${n.text}`).join('\n')}`);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/** The settings page (brain, what it may see, notes, reminders), in the browser. --print: only the URL. */
|
|
236
|
+
async function cmdConfig({ flags }) {
|
|
237
|
+
if (!(await petAlive())) fail(t('err.noPet'));
|
|
238
|
+
const url = `http://127.0.0.1:${PET_PORT}/settings?token=${petToken()}`;
|
|
239
|
+
if (flags.print || !graphical() || !openUrl(url)) return console.log(url);
|
|
240
|
+
console.log(t('pet.settings'));
|
|
241
|
+
}
|
|
242
|
+
|
|
235
243
|
async function cmdPet({ flags }) {
|
|
236
244
|
if (flags.daemon) {
|
|
237
245
|
try {
|
|
@@ -457,6 +465,9 @@ async function main() {
|
|
|
457
465
|
return cmdAsk(args);
|
|
458
466
|
case 'reminders':
|
|
459
467
|
return cmdReminders(args);
|
|
468
|
+
case 'config':
|
|
469
|
+
case 'settings':
|
|
470
|
+
return cmdConfig(args);
|
|
460
471
|
case 'status':
|
|
461
472
|
return cmdStatus(args);
|
|
462
473
|
case 'stop':
|
package/cli/lib/assistant.mjs
CHANGED
|
@@ -235,9 +235,11 @@ function notify(title, body) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* @param {{ brain, system: () => string, lang: string, name: string, onFire: (reminder, line) => void, log?: Function }} o
|
|
238
|
+
* @param {{ brain, system: () => string, lang: string, name: string, onFire: (reminder, line) => void, access?: () => object, log?: Function }} o
|
|
239
|
+
* access() config.access: memory (keep/use notes), open (open sites), notify (desktop notifications)
|
|
239
240
|
*/
|
|
240
|
-
export function createAssistant({ brain, system, lang, name, onFire, log = () => {} }) {
|
|
241
|
+
export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
|
|
242
|
+
const may = (k) => access()[k] !== false;
|
|
241
243
|
let L = tr(lang);
|
|
242
244
|
const remF = homeFile('reminders.json');
|
|
243
245
|
const memF = homeFile('memory.json');
|
|
@@ -249,8 +251,8 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
|
|
|
249
251
|
function fire(r) {
|
|
250
252
|
r.done = true;
|
|
251
253
|
const line = tr(r.lang || lang).fire(r);
|
|
252
|
-
notify(`${name} · 7ots`, r.text);
|
|
253
|
-
if (r.open) openUrl(r.open);
|
|
254
|
+
if (may('notify')) notify(`${name} · 7ots`, r.text);
|
|
255
|
+
if (r.open && may('open')) openUrl(r.open);
|
|
254
256
|
onFire(r, line);
|
|
255
257
|
}
|
|
256
258
|
|
|
@@ -269,8 +271,9 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
|
|
|
269
271
|
function run(actions) {
|
|
270
272
|
const out = [];
|
|
271
273
|
for (const a of actions) {
|
|
274
|
+
if ((a.type === 'note' && !may('memory')) || (a.type === 'open' && !may('open'))) continue;
|
|
272
275
|
if (a.type === 'remind') {
|
|
273
|
-
const r = { id: randomBytes(4).toString('hex'), at: a.at, text: a.text, open: a.open || null, lang: L === T.es ? 'es' : L === T.pt ? 'pt' : 'en', created: Date.now() };
|
|
276
|
+
const r = { id: randomBytes(4).toString('hex'), at: a.at, text: a.text, open: (may('open') && a.open) || null, lang: L === T.es ? 'es' : L === T.pt ? 'pt' : 'en', created: Date.now() };
|
|
274
277
|
reminders.push(r);
|
|
275
278
|
reminders.sort((x, y) => x.at - y.at);
|
|
276
279
|
saveR();
|
|
@@ -288,7 +291,7 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
|
|
|
288
291
|
saveR();
|
|
289
292
|
out.push(L.cancelled(n));
|
|
290
293
|
} else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none);
|
|
291
|
-
else if (a.type === 'know') out.push(L.know(notes.slice(-12)));
|
|
294
|
+
else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
|
|
292
295
|
else if (a.type === 'ask_when') out.push(L.when);
|
|
293
296
|
else if (a.type === 'help') out.push(L.help);
|
|
294
297
|
}
|
|
@@ -311,7 +314,7 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
|
|
|
311
314
|
let ai = null;
|
|
312
315
|
if (brain.kind !== 'lines') {
|
|
313
316
|
try {
|
|
314
|
-
const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang: said, notes: notes.slice(-30), reminders, now }) }], timeoutMs: 45000, raw: true });
|
|
317
|
+
const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang: said, notes: may('memory') ? notes.slice(-30) : [], reminders, now }) }], timeoutMs: 45000, raw: true });
|
|
315
318
|
ai = parseAi(raw, now);
|
|
316
319
|
} catch (e) {
|
|
317
320
|
log(`assistant: ${e.message}`);
|
|
@@ -324,6 +327,13 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
|
|
|
324
327
|
const done = run(local);
|
|
325
328
|
return { reply: done.join(' ') || L.help, actions: local };
|
|
326
329
|
},
|
|
330
|
+
/** Forget one note (by its timestamp, stable across the list) or all of them. */
|
|
331
|
+
forget(at) {
|
|
332
|
+
const n = notes.length;
|
|
333
|
+
notes = at === 'all' ? [] : notes.filter((x) => String(x.at) !== String(at));
|
|
334
|
+
saveN();
|
|
335
|
+
return n !== notes.length;
|
|
336
|
+
},
|
|
327
337
|
cancel(id) {
|
|
328
338
|
const n = reminders.length;
|
|
329
339
|
reminders = reminders.filter((r) => r.id !== id);
|
package/cli/lib/config.mjs
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* { kind: '7ots' } the home's /chat (7ots or own server)
|
|
14
14
|
* { kind: 'batuta', model? } Orquesta's Batuta LLM
|
|
15
15
|
* voice how it speaks: { kind: 'none'|'browser'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
|
|
16
|
-
* pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3 }
|
|
16
|
+
* pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3, view?: '2d'|'3d' }
|
|
17
|
+
* access what the pet may see and do (the settings page, /settings):
|
|
18
|
+
* { agent: true, the details of your coding agent / terminal (prompts, commands, tools) go to the brain
|
|
19
|
+
* memory: true, it keeps notes about you and uses them when answering
|
|
20
|
+
* open: true, it may open sites in your browser (reminders, "open my mail")
|
|
21
|
+
* notify: true } desktop notifications when a reminder fires
|
|
17
22
|
*
|
|
18
23
|
* ~/.7ots/keys.json (0600) keeps the keys typed in the wizard under their usual env-var names.
|
|
19
24
|
* Variables already set in the environment always win over the file.
|
|
@@ -30,6 +35,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
30
35
|
brain: { kind: 'lines' },
|
|
31
36
|
voice: { kind: 'browser' },
|
|
32
37
|
pet: { mode: 'ask', annoy: 2 },
|
|
38
|
+
access: { agent: true, memory: true, open: true, notify: true },
|
|
33
39
|
});
|
|
34
40
|
|
|
35
41
|
export function loadConfig() {
|
|
@@ -41,6 +47,7 @@ export function loadConfig() {
|
|
|
41
47
|
brain: { ...CONFIG_DEFAULTS.brain, ...c.brain },
|
|
42
48
|
voice: { ...CONFIG_DEFAULTS.voice, ...c.voice },
|
|
43
49
|
pet: { ...CONFIG_DEFAULTS.pet, ...c.pet },
|
|
50
|
+
access: { ...CONFIG_DEFAULTS.access, ...c.access },
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
|
package/cli/lib/i18n.mjs
CHANGED
|
@@ -18,6 +18,7 @@ const M = {
|
|
|
18
18
|
7ots status [--line|--json] pet state (--line: one line for tmux)
|
|
19
19
|
7ots ask <text> talk to it: "remind me to check email in 10 min", "open my calendar", "remember that…"
|
|
20
20
|
7ots reminders [--cancel <id>] pending reminders and what it remembers
|
|
21
|
+
7ots config [--print] settings page: brain, what it can see, notes
|
|
21
22
|
7ots hooks install|remove [--claude] [--shell] [--global]
|
|
22
23
|
7ots meet --new | "<invite or call URL>" join apuchat meet with this identity
|
|
23
24
|
7ots prompt [identity|install|pet|meet|all] prompts for coding agents
|
|
@@ -82,6 +83,7 @@ Docs: https://7ots.com/llms.txt`,
|
|
|
82
83
|
'login.ok': 'Connected to Orquesta ({org}).',
|
|
83
84
|
'err.unknown': 'Unknown command "{cmd}". Try: 7ots help',
|
|
84
85
|
'err.noPet': 'The pet is not awake. Start it with: 7ots pet',
|
|
86
|
+
'pet.settings': 'Opening the settings in your browser.',
|
|
85
87
|
'usage.ask': '7ots ask <text>\n\n e.g. 7ots ask "remind me to check email in 10 minutes and open it"\n 7ots ask "remember that Fridays we demo" · 7ots ask "my reminders"\n Seeing your screen or processes: coming soon.',
|
|
86
88
|
'err.noIdentity': 'No identity yet. Create one with "7ots new" or run "7ots setup".',
|
|
87
89
|
'w.title': '7ots setup — {name}',
|
|
@@ -187,6 +189,7 @@ Docs: https://7ots.com/llms.txt`,
|
|
|
187
189
|
7ots status [--line|--json] estado (--line: una línea para tmux)
|
|
188
190
|
7ots ask <texto> háblale: "recuérdame ver el correo en 10 min", "abre mi calendario", "anota que…"
|
|
189
191
|
7ots reminders [--cancel <id>] recordatorios pendientes y lo que recuerda
|
|
192
|
+
7ots config [--print] ajustes: cerebro, qué puede ver, notas
|
|
190
193
|
7ots hooks install|remove [--claude] [--shell] [--global]
|
|
191
194
|
7ots meet --new | "<invitación o enlace>" entra a apuchat meet con esta identidad
|
|
192
195
|
7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
|
|
@@ -251,6 +254,7 @@ Docs: https://7ots.com/llms.txt`,
|
|
|
251
254
|
'login.ok': 'Conectado a Orquesta ({org}).',
|
|
252
255
|
'err.unknown': 'Comando desconocido «{cmd}». Prueba: 7ots help',
|
|
253
256
|
'err.noPet': 'La mascota no está despierta. Iníciala con: 7ots pet',
|
|
257
|
+
'pet.settings': 'Abriendo los ajustes en tu navegador.',
|
|
254
258
|
'usage.ask': '7ots ask <texto>\n\n ej. 7ots ask "recuérdame ver el correo en 10 minutos y ábrelo"\n 7ots ask "anota que los viernes hay demo" · 7ots ask "mis recordatorios"\n Ver tu pantalla o tus procesos: muy pronto.',
|
|
255
259
|
'err.noIdentity': 'Aún no hay identidad. Crea una con «7ots new» o corre «7ots setup».',
|
|
256
260
|
'w.title': 'Configuración de 7ots — {name}',
|
|
@@ -356,6 +360,7 @@ Docs: https://7ots.com/llms.txt`,
|
|
|
356
360
|
7ots status [--line|--json] estado (--line: uma linha para tmux)
|
|
357
361
|
7ots ask <texto> fale com ele: "me lembre de ver o e-mail em 10 min", "abra minha agenda", "anote que…"
|
|
358
362
|
7ots reminders [--cancel <id>] lembretes pendentes e o que ele lembra
|
|
363
|
+
7ots config [--print] ajustes: cérebro, o que pode ver, notas
|
|
359
364
|
7ots hooks install|remove [--claude] [--shell] [--global]
|
|
360
365
|
7ots meet --new | "<convite ou link>" entra no apuchat meet com esta identidade
|
|
361
366
|
7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
|
|
@@ -420,6 +425,7 @@ Docs: https://7ots.com/llms.txt`,
|
|
|
420
425
|
'login.ok': 'Conectado ao Orquesta ({org}).',
|
|
421
426
|
'err.unknown': 'Comando desconhecido «{cmd}». Tente: 7ots help',
|
|
422
427
|
'err.noPet': 'O bichinho não está acordado. Inicie com: 7ots pet',
|
|
428
|
+
'pet.settings': 'Abrindo os ajustes no seu navegador.',
|
|
423
429
|
'usage.ask': '7ots ask <texto>\n\n ex. 7ots ask "me lembre de ver o e-mail em 10 minutos e abra"\n 7ots ask "anote que às sextas tem demo" · 7ots ask "meus lembretes"\n Ver sua tela ou processos: em breve.',
|
|
424
430
|
'err.noIdentity': 'Ainda não há identidade. Crie uma com «7ots new» ou rode «7ots setup».',
|
|
425
431
|
'w.title': 'Configuração do 7ots — {name}',
|
package/cli/lib/pet-server.mjs
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* POST /ask X-7ots-Token { text } → { reply, actions } reminders, notes, open a site (assistant.mjs)
|
|
12
12
|
* GET /reminders?token=… { reminders, notes }
|
|
13
13
|
* POST /reminders/cancel { id }
|
|
14
|
+
* GET /settings?token=… the settings page: brain, what it may see and do, notes, reminders
|
|
15
|
+
* GET /config?token=… { config: { brain, pet, access }, clis, keys: { NAME: bool }, … }
|
|
16
|
+
* POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
|
|
17
|
+
* POST /config/test one line from the current brain → { ok, text, ms }
|
|
18
|
+
* POST /notes/forget { at | 'all' }
|
|
14
19
|
* POST /quit X-7ots-Token
|
|
15
20
|
*
|
|
16
21
|
* Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
|
|
@@ -22,8 +27,8 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
22
27
|
import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
|
|
23
28
|
import { join } from 'node:path';
|
|
24
29
|
import { createAssistant } from './assistant.mjs';
|
|
25
|
-
import { createBrain, petSystem } from './brain.mjs';
|
|
26
|
-
import { loadConfig,
|
|
30
|
+
import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
|
|
31
|
+
import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
|
|
27
32
|
import { loadIdentity } from './identity.mjs';
|
|
28
33
|
import { line } from './lines.mjs';
|
|
29
34
|
import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
|
|
@@ -67,6 +72,7 @@ export async function postPet(path, body, port = PET_PORT) {
|
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN'];
|
|
70
76
|
const brainLabel = (b = {}) => (b.kind === 'cli' ? `${b.cli}${b.model ? ` · ${b.model}` : ''}` : b.kind === 'api' ? `${b.provider || 'api'}${b.model ? ` · ${b.model}` : ''}` : b.kind || 'lines');
|
|
71
77
|
|
|
72
78
|
const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
|
|
@@ -75,12 +81,13 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
|
|
|
75
81
|
|
|
76
82
|
export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
77
83
|
const token = petToken();
|
|
78
|
-
|
|
79
|
-
const
|
|
84
|
+
let config = loadConfig();
|
|
85
|
+
const annoyOf = (c) => Math.max(0, Math.min(3, Number(c.pet.annoy ?? 2)));
|
|
86
|
+
let annoy = annoyOf(config);
|
|
80
87
|
const { identity } = loadIdentity();
|
|
81
88
|
if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
|
|
82
89
|
const lang = identity.language || 'en';
|
|
83
|
-
|
|
90
|
+
let brain = createBrain(config.brain, config.home); // swapped live from the settings page
|
|
84
91
|
const pet = tick(loadPet());
|
|
85
92
|
const clients = new Set();
|
|
86
93
|
const history = []; // last lines (so the AI doesn't repeat itself)
|
|
@@ -125,11 +132,11 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
const SITUATION = {
|
|
128
|
-
tool: (e) => `Your human's coding agent just used the tool ${e.tool}${e.detail ? ` (${e.detail})` : ''}.`,
|
|
129
|
-
toolFail: (e) => `Your human's coding agent tried the tool ${e.tool} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
|
|
130
|
-
ok: (e) => `In the terminal, the command \`${e.cmd
|
|
131
|
-
fail: (e) => `In the terminal, the command \`${e.cmd
|
|
132
|
-
prompt: (e) => `Your human just asked their coding agent
|
|
135
|
+
tool: (e) => `Your human's coding agent just used the tool ${e.tool || '?'}${e.detail ? ` (${e.detail})` : ''}.`,
|
|
136
|
+
toolFail: (e) => `Your human's coding agent tried the tool ${e.tool || '?'} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
|
|
137
|
+
ok: (e) => `In the terminal, ${e.cmd ? `the command \`${e.cmd}\`` : 'a command'} just succeeded.`,
|
|
138
|
+
fail: (e) => `In the terminal, ${e.cmd ? `the command \`${e.cmd}\`` : 'a command'} just failed with exit code ${e.code}.`,
|
|
139
|
+
prompt: (e) => `Your human just asked their coding agent${e.text ? `: "${String(e.text).slice(0, 300)}"` : ' something'}.`,
|
|
133
140
|
stop: () => `Your human's coding agent just finished its turn. Encourage them to check the result.`,
|
|
134
141
|
waiting: (e) => `Your human's coding agent is waiting for them (permission or an answer)${e.message ? `: ${e.message}` : ''}. Get their attention.`,
|
|
135
142
|
hello: () => `You just woke up on your human's desktop. Say hi.`,
|
|
@@ -175,7 +182,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
175
182
|
if (!kind) return broadcast({ gesture: r.gesture });
|
|
176
183
|
const routine = !r.important && !['fail', 'toolFail'].includes(kind);
|
|
177
184
|
if (routine && Math.random() >= CHATTY[annoy]) return broadcast({ gesture: r.gesture });
|
|
178
|
-
|
|
185
|
+
// access.agent off: the brain hears that something happened, not what (no prompts, commands or details).
|
|
186
|
+
const seen = config.access.agent ? ev : { type: ev.type, tool: ev.tool, ok: ev.ok, code: ev.code };
|
|
187
|
+
const sit = SITUATION[kind]?.(seen) || '';
|
|
179
188
|
speak(kind, { cmd: ev.cmd || '', tool: ev.tool || '' }, sit, { force: r.important && ev.type !== 'tool', gesture: r.gesture });
|
|
180
189
|
}
|
|
181
190
|
|
|
@@ -195,7 +204,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
195
204
|
|
|
196
205
|
// The assistant: reminders that speak, notify and open the tab; notes it learns.
|
|
197
206
|
const assistant = createAssistant({
|
|
198
|
-
brain,
|
|
207
|
+
brain: { get kind() { return brain.kind; }, think: (o) => brain.think(o) },
|
|
208
|
+
access: () => config.access,
|
|
199
209
|
lang,
|
|
200
210
|
name: identity.name,
|
|
201
211
|
log,
|
|
@@ -230,8 +240,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
230
240
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
231
241
|
};
|
|
232
242
|
|
|
233
|
-
const page = () =>
|
|
234
|
-
readFileSync(join(PKG_ROOT, 'cli', 'pet',
|
|
243
|
+
const page = (file = 'pet.html') =>
|
|
244
|
+
readFileSync(join(PKG_ROOT, 'cli', 'pet', file), 'utf8')
|
|
235
245
|
.replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
|
|
236
246
|
|
|
237
247
|
const server = createServer(async (req, res) => {
|
|
@@ -253,6 +263,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
253
263
|
if (req.method === 'GET' && p === '/pet') {
|
|
254
264
|
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page());
|
|
255
265
|
}
|
|
266
|
+
if (req.method === 'GET' && p === '/settings') {
|
|
267
|
+
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }).end(page('settings.html'));
|
|
268
|
+
}
|
|
269
|
+
if (req.method === 'GET' && p === '/config') return json(200, settingsView());
|
|
256
270
|
if (req.method === 'GET' && p === '/state') return json(200, { identity, state: view(pet), config: { voice: config.voice, pet: config.pet, brain: config.brain.kind } });
|
|
257
271
|
if (req.method === 'GET' && p === '/events') {
|
|
258
272
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
@@ -297,6 +311,23 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
297
311
|
broadcast({ thinking: false, say: r.reply, gesture: r.actions.length ? 'nod' : null });
|
|
298
312
|
return json(200, r);
|
|
299
313
|
}
|
|
314
|
+
if (p === '/config') {
|
|
315
|
+
const bad = applySettings(data);
|
|
316
|
+
return bad ? json(400, { error: bad }) : json(200, settingsView());
|
|
317
|
+
}
|
|
318
|
+
if (p === '/config/test') {
|
|
319
|
+
if (brain.kind === 'lines') return json(200, { ok: true, text: line('hello', lang, { name: identity.name, level: pet.level }), ms: 0 });
|
|
320
|
+
const t0 = Date.now();
|
|
321
|
+
try {
|
|
322
|
+
const text = await brain.think({ system: petSystem(identity, { lang }), messages: [{ role: 'user', content: 'Your human is checking that your brain works. Say hi in one short line.' }], timeoutMs: 45000 });
|
|
323
|
+
if (!text) throw new Error('empty answer');
|
|
324
|
+
broadcast({ say: text, gesture: 'wave' });
|
|
325
|
+
return json(200, { ok: true, text, ms: Date.now() - t0 });
|
|
326
|
+
} catch (e) {
|
|
327
|
+
return json(200, { ok: false, error: e.message, ms: Date.now() - t0 });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
|
|
300
331
|
if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
|
|
301
332
|
if (p === '/quit') {
|
|
302
333
|
json(200, { ok: true });
|
|
@@ -305,6 +336,65 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
305
336
|
return json(404, { error: 'not found' });
|
|
306
337
|
});
|
|
307
338
|
|
|
339
|
+
/** What the settings page shows. Keys only as present/absent, never their value. */
|
|
340
|
+
function settingsView() {
|
|
341
|
+
const keys = env();
|
|
342
|
+
return {
|
|
343
|
+
name: identity.name,
|
|
344
|
+
lang,
|
|
345
|
+
config: { brain: config.brain, pet: config.pet, access: config.access },
|
|
346
|
+
brain: brainLabel(config.brain),
|
|
347
|
+
clis: detectClis(),
|
|
348
|
+
allClis: Object.keys(AI_CLIS),
|
|
349
|
+
keys: Object.fromEntries(SETTABLE_KEYS.map((k) => [k, Boolean(keys[k])])),
|
|
350
|
+
home: { kind: config.home.kind, url: Boolean(config.home.url) },
|
|
351
|
+
reminders: assistant.reminders,
|
|
352
|
+
notes: assistant.notes,
|
|
353
|
+
files: { config: homeFile('config.json'), memory: homeFile('memory.json'), reminders: homeFile('reminders.json') },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Validates and saves a settings change, then applies it without restarting. Returns an error or null. */
|
|
358
|
+
function applySettings(d) {
|
|
359
|
+
const next = loadConfig();
|
|
360
|
+
if (d.brain) {
|
|
361
|
+
const b = d.brain;
|
|
362
|
+
const model = String(b.model || '').trim();
|
|
363
|
+
if (model && !/^[\w.:/@-]{1,80}$/.test(model)) return 'model';
|
|
364
|
+
if (b.kind === 'lines') next.brain = { kind: 'lines' };
|
|
365
|
+
else if (b.kind === 'cli') {
|
|
366
|
+
if (!AI_CLIS[b.cli]) return 'cli'; // 'custom' (a shell command) stays CLI-only: 7ots setup
|
|
367
|
+
next.brain = { kind: 'cli', cli: b.cli, ...(model ? { model } : {}) };
|
|
368
|
+
} else if (b.kind === 'api') {
|
|
369
|
+
if (!['anthropic', 'openai'].includes(b.provider)) return 'provider';
|
|
370
|
+
const baseUrl = String(b.baseUrl || '').trim();
|
|
371
|
+
if (baseUrl && !/^https?:\/\/[^\s"'<>]{1,200}$/i.test(baseUrl)) return 'baseUrl';
|
|
372
|
+
next.brain = { kind: 'api', provider: b.provider, ...(model ? { model } : {}), ...(baseUrl ? { baseUrl } : {}) };
|
|
373
|
+
} else if (b.kind === 'batuta') next.brain = { kind: 'batuta', ...(model ? { model } : {}) };
|
|
374
|
+
else if (b.kind === '7ots') {
|
|
375
|
+
if (!next.home.url) return 'home';
|
|
376
|
+
next.brain = { kind: '7ots' };
|
|
377
|
+
} else return 'kind';
|
|
378
|
+
}
|
|
379
|
+
if (d.pet) {
|
|
380
|
+
if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
|
|
381
|
+
if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
|
|
382
|
+
}
|
|
383
|
+
if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
|
|
384
|
+
if (d.keys) {
|
|
385
|
+
for (const [k, v] of Object.entries(d.keys)) {
|
|
386
|
+
if (!SETTABLE_KEYS.includes(k) || typeof v !== 'string' || v.length > 400) return 'key';
|
|
387
|
+
saveKey(k, v.trim()); // '' removes it
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
saveConfig(next);
|
|
391
|
+
config = loadConfig();
|
|
392
|
+
annoy = annoyOf(config);
|
|
393
|
+
brain = createBrain(config.brain, config.home);
|
|
394
|
+
log(`settings: brain ${brainLabel(config.brain)}`);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
308
398
|
function stop() {
|
|
309
399
|
clearInterval(timer);
|
|
310
400
|
assistant.stop();
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* along the bottom of the screen. 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;
|
|
@@ -21,11 +21,11 @@ const H = 270;
|
|
|
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) {
|
|
@@ -73,7 +73,13 @@ function start() {
|
|
|
73
73
|
win.setAlwaysOnTop(true, 'floating');
|
|
74
74
|
win.setVisibleOnAllWorkspaces?.(true);
|
|
75
75
|
win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
|
|
76
|
-
|
|
76
|
+
const settingsUrl = `${URL_}/settings?token=${encodeURIComponent(TOKEN)}`;
|
|
77
|
+
const openSettings = () => shell.openExternal(settingsUrl).catch(() => {});
|
|
78
|
+
// The page's ⚙️ button opens the settings in the real browser; anything else is denied.
|
|
79
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
80
|
+
if (url.startsWith(`${URL_}/settings`)) openSettings();
|
|
81
|
+
return { action: 'deny' };
|
|
82
|
+
});
|
|
77
83
|
win.webContents.on('will-navigate', (e) => e.preventDefault());
|
|
78
84
|
|
|
79
85
|
let still = false;
|
|
@@ -93,6 +99,7 @@ function start() {
|
|
|
93
99
|
{ label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
|
|
94
100
|
{ label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
|
|
95
101
|
{ type: 'separator' },
|
|
102
|
+
{ label: tr('settings'), click: openSettings },
|
|
96
103
|
{ label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
|
|
97
104
|
{ label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
|
|
98
105
|
]);
|
|
@@ -113,14 +120,16 @@ function start() {
|
|
|
113
120
|
win.on('moved', () => {
|
|
114
121
|
if (Math.abs(win.getPosition()[0] - lastSet) > 4) dragged = Date.now(); // the user dragged it
|
|
115
122
|
});
|
|
116
|
-
const
|
|
117
|
-
|
|
123
|
+
const alive = () => !win.isDestroyed();
|
|
124
|
+
const tell = () => alive() && win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
|
|
125
|
+
const walkTimer = setInterval(() => {
|
|
118
126
|
walking = !still && Math.random() < 0.45;
|
|
119
127
|
if (Math.random() < 0.3) dir = -dir;
|
|
120
128
|
tell();
|
|
121
129
|
}, 4000);
|
|
122
|
-
setInterval(() => {
|
|
123
|
-
|
|
130
|
+
const stepTimer = setInterval(() => {
|
|
131
|
+
// isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
|
|
132
|
+
if (!alive() || !walking || still || !win.isVisible() || Date.now() - dragged < 8000) return;
|
|
124
133
|
const [cx, cy] = win.getPosition();
|
|
125
134
|
x = cx + dir * 2;
|
|
126
135
|
if (x < area.x || x > area.x + area.width - W) {
|
|
@@ -131,6 +140,10 @@ function start() {
|
|
|
131
140
|
lastSet = x;
|
|
132
141
|
win.setPosition(x, cy);
|
|
133
142
|
}, 40);
|
|
143
|
+
win.on('closed', () => {
|
|
144
|
+
clearInterval(walkTimer);
|
|
145
|
+
clearInterval(stepTimer);
|
|
146
|
+
});
|
|
134
147
|
}
|
|
135
148
|
|
|
136
149
|
// 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' },
|
|
@@ -424,6 +426,8 @@
|
|
|
424
426
|
const ask = $('#ask');
|
|
425
427
|
const askIn = $('#askIn');
|
|
426
428
|
askIn.placeholder = t('askPh');
|
|
429
|
+
$('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
|
|
430
|
+
$('#setBtn').title = t('settings');
|
|
427
431
|
const talking = (on) => {
|
|
428
432
|
document.body.classList.toggle('talking', on);
|
|
429
433
|
if (on) askIn.focus();
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>7ots settings</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--ink: #17141f; --ink-2: #4a4458; --muted: #8a8399; --bg: #f3f1f8; --panel: #ffffff; --panel-2: #f8f7fb;
|
|
10
|
+
--line: #e8e4f0; --accent: #6d5dfc; --accent-ink: #fff; --ok: #12a150; --warn: #e08a00; --err: #e5484d;
|
|
11
|
+
--shadow: 0 1px 2px rgb(20 16 40 / .06), 0 12px 40px -12px rgb(20 16 40 / .18);
|
|
12
|
+
--font: ui-sans-serif, -apple-system, 'Segoe UI', Inter, Roboto, system-ui, sans-serif;
|
|
13
|
+
--mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
|
14
|
+
color-scheme: light;
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root { --ink: #f2f0f8; --ink-2: #c9c3d6; --muted: #8d86a0; --bg: #0f0d14; --panel: #18151f; --panel-2: #1e1a27; --line: #2a2535; --shadow: 0 1px 2px rgb(0 0 0 / .4), 0 18px 50px -16px rgb(0 0 0 / .7); color-scheme: dark; }
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
html, body { margin: 0; font: 14px/1.5 var(--font); color: var(--ink); background: var(--bg); -webkit-font-smoothing: antialiased; }
|
|
21
|
+
body { padding: 28px 16px 60px; }
|
|
22
|
+
main { width: min(680px, 100%); margin: 0 auto; display: grid; gap: 16px; }
|
|
23
|
+
header { display: flex; align-items: center; gap: 10px; }
|
|
24
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 750; font-size: 20px; letter-spacing: -.02em; }
|
|
25
|
+
.brand i { width: 26px; height: 26px; border-radius: 8px; background: var(--ink); color: var(--panel); display: grid; place-items: center; font: 800 12px/1 var(--font); font-style: normal; }
|
|
26
|
+
.sp { flex: 1; }
|
|
27
|
+
a { color: var(--accent); text-decoration: none; }
|
|
28
|
+
a:hover { text-decoration: underline; }
|
|
29
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 18px 20px; }
|
|
30
|
+
h2 { margin: 0 0 2px; font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
|
|
31
|
+
.hint { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
|
|
32
|
+
.now { display: inline-flex; gap: 6px; align-items: center; padding: 3px 10px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); font-size: 12px; color: var(--ink-2); }
|
|
33
|
+
.now b { color: var(--ink); }
|
|
34
|
+
|
|
35
|
+
.opts { display: grid; gap: 8px; }
|
|
36
|
+
.opt { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; align-items: start; padding: 11px 12px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--panel-2); }
|
|
37
|
+
.opt:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
|
|
38
|
+
.opt.off { opacity: .5; cursor: not-allowed; }
|
|
39
|
+
.opt input { margin: 3px 0 0; accent-color: var(--accent); }
|
|
40
|
+
.opt b { font-weight: 650; }
|
|
41
|
+
.opt > label { cursor: inherit; }
|
|
42
|
+
.opt small { grid-column: 2; color: var(--muted); font-size: 12.5px; }
|
|
43
|
+
.more { grid-column: 2; display: none; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: flex-end; }
|
|
44
|
+
.more .chips { align-self: center; }
|
|
45
|
+
.opt:has(input:checked) .more { display: flex; }
|
|
46
|
+
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
47
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 99px; border: 1px solid var(--line); background: var(--panel); font-size: 13px; cursor: pointer; }
|
|
48
|
+
.chip:has(input:checked) { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
|
49
|
+
.chip input { display: none; }
|
|
50
|
+
.chip.off { opacity: .45; cursor: not-allowed; }
|
|
51
|
+
label.f { display: grid; gap: 3px; font-size: 12px; color: var(--muted); flex: 1 1 180px; }
|
|
52
|
+
input[type=text], input[type=password], select { font: 13px var(--font); color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 7px 10px; width: 100%; }
|
|
53
|
+
input:focus-visible, select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
54
|
+
|
|
55
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px; }
|
|
56
|
+
button { font: 600 13px var(--font); color: var(--ink); background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
|
|
57
|
+
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
58
|
+
button:disabled { opacity: .55; cursor: wait; }
|
|
59
|
+
button.x { padding: 2px 8px; font-weight: 500; color: var(--muted); }
|
|
60
|
+
.msg { font-size: 13px; color: var(--muted); }
|
|
61
|
+
.msg.ok { color: var(--ok); } .msg.err { color: var(--err); }
|
|
62
|
+
.reply { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--panel-2); border: 1px solid var(--line); font-size: 13.5px; display: none; }
|
|
63
|
+
|
|
64
|
+
.sw { display: grid; grid-template-columns: 1fr auto; gap: 2px 14px; padding: 11px 0; border-top: 1px solid var(--line); align-items: center; }
|
|
65
|
+
.sw:first-of-type { border-top: 0; padding-top: 0; }
|
|
66
|
+
.sw small { color: var(--muted); font-size: 12.5px; grid-column: 1; }
|
|
67
|
+
.tog { grid-row: 1 / span 2; grid-column: 2; position: relative; width: 40px; height: 23px; }
|
|
68
|
+
.tog input { opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; position: relative; z-index: 1; }
|
|
69
|
+
.tog span { position: absolute; inset: 0; border-radius: 99px; background: var(--line); transition: background .15s; }
|
|
70
|
+
.tog span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 17px; height: 17px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .25); transition: transform .15s; }
|
|
71
|
+
.tog input:checked + span { background: var(--accent); }
|
|
72
|
+
.tog input:checked + span::after { transform: translateX(17px); }
|
|
73
|
+
.tog input:disabled { cursor: not-allowed; }
|
|
74
|
+
.soon { opacity: .6; }
|
|
75
|
+
.tag { display: inline-block; margin-left: 6px; padding: 0 7px; border-radius: 99px; font-size: 11px; font-weight: 600; background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); vertical-align: 1px; }
|
|
76
|
+
|
|
77
|
+
.slider { display: grid; gap: 6px; }
|
|
78
|
+
.slider input { width: 100%; accent-color: var(--accent); }
|
|
79
|
+
.ticks { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
|
80
|
+
|
|
81
|
+
ul.list { list-style: none; margin: 0; padding: 0; }
|
|
82
|
+
ul.list li { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13.5px; }
|
|
83
|
+
ul.list li:first-child { border-top: 0; }
|
|
84
|
+
ul.list li span { flex: 1; overflow-wrap: anywhere; }
|
|
85
|
+
ul.list time { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
86
|
+
.empty { color: var(--muted); font-size: 13px; }
|
|
87
|
+
footer { color: var(--muted); font-size: 12px; text-align: center; }
|
|
88
|
+
footer code { font: 11.5px var(--mono); }
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<main>
|
|
93
|
+
<header>
|
|
94
|
+
<span class="brand"><i>7</i><span id="title"></span></span>
|
|
95
|
+
<span class="sp"></span>
|
|
96
|
+
<a id="back"></a>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<section aria-labelledby="hBrain">
|
|
100
|
+
<h2 id="hBrain" data-t="brainH"></h2>
|
|
101
|
+
<p class="hint" data-t="brainHint"></p>
|
|
102
|
+
<div class="opts" id="brains"></div>
|
|
103
|
+
<div class="row">
|
|
104
|
+
<button class="primary" id="saveBrain" data-t="save"></button>
|
|
105
|
+
<button id="test" data-t="test"></button>
|
|
106
|
+
<span class="now" id="now"></span>
|
|
107
|
+
<span class="msg" id="brainMsg" role="status"></span>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="reply" id="reply"></div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section aria-labelledby="hAccess">
|
|
113
|
+
<h2 id="hAccess" data-t="accessH"></h2>
|
|
114
|
+
<p class="hint" data-t="accessHint"></p>
|
|
115
|
+
<div id="access"></div>
|
|
116
|
+
</section>
|
|
117
|
+
|
|
118
|
+
<section aria-labelledby="hPet">
|
|
119
|
+
<h2 id="hPet" data-t="petH"></h2>
|
|
120
|
+
<p class="hint" data-t="annoyHint"></p>
|
|
121
|
+
<div class="slider">
|
|
122
|
+
<input type="range" id="annoy" min="0" max="3" step="1" aria-labelledby="hPet">
|
|
123
|
+
<div class="ticks" id="ticks"></div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="row">
|
|
126
|
+
<span class="msg" data-t="view"></span>
|
|
127
|
+
<div class="chips" id="view"></div>
|
|
128
|
+
<span class="msg" id="petMsg" role="status"></span>
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
|
|
132
|
+
<section aria-labelledby="hNotes">
|
|
133
|
+
<h2 id="hNotes" data-t="notesH"></h2>
|
|
134
|
+
<p class="hint" data-t="notesHint"></p>
|
|
135
|
+
<ul class="list" id="notes"></ul>
|
|
136
|
+
<div class="row"><button id="forgetAll" data-t="forgetAll"></button></div>
|
|
137
|
+
</section>
|
|
138
|
+
|
|
139
|
+
<section aria-labelledby="hRem">
|
|
140
|
+
<h2 id="hRem" data-t="remH"></h2>
|
|
141
|
+
<p class="hint" data-t="remHint"></p>
|
|
142
|
+
<ul class="list" id="rems"></ul>
|
|
143
|
+
</section>
|
|
144
|
+
|
|
145
|
+
<footer id="foot"></footer>
|
|
146
|
+
</main>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
const BOOT = __BOOT__;
|
|
150
|
+
const T = {
|
|
151
|
+
en: {
|
|
152
|
+
title: '{name} · settings', back: '← Back to the pet', save: 'Save', saved: 'Saved ✓', test: 'Try it', testing: 'Thinking…', now: 'Now:',
|
|
153
|
+
brainH: 'Brain', brainHint: 'Who thinks when it talks to you. Your own AI CLI is free with your subscription and runs on this computer.',
|
|
154
|
+
lines: 'No AI', linesD: 'Built-in phrases. Instant, but it can’t understand free text (reminders still work).',
|
|
155
|
+
cli: 'Local AI CLI', cliD: 'An AI command already installed here, run as a separate instance.', notInstalled: 'not installed',
|
|
156
|
+
api: 'Your API key', apiD: 'Anthropic or any OpenAI-compatible endpoint, with your key.',
|
|
157
|
+
batuta: 'Batuta (Orquesta)', batutaD: 'Orquesta’s model router, with your Orquesta token.',
|
|
158
|
+
home: 'The ot’s home', homeD: 'The /chat of its home on 7ots.com or your own server.', noHome: 'no home online',
|
|
159
|
+
model: 'Model (optional)', provider: 'Provider', baseUrl: 'Base URL (optional)', key: 'API key', keySaved: 'saved — type to replace', keyNone: 'not set', orqToken: 'Orquesta token',
|
|
160
|
+
accessH: 'What it can see and do', accessHint: 'Everything stays on this computer, except what goes to the brain you chose above.',
|
|
161
|
+
agent: 'Your coding agent and terminal', agentD: 'Your prompts, commands and tools go to the brain so it can comment. Off: it only knows “something happened”.',
|
|
162
|
+
memory: 'Learn about you', memoryD: 'Keeps notes (“remember that…”) and uses them when answering.',
|
|
163
|
+
open: 'Open sites', openD: 'Opens your mail, calendar… when you ask, or when a reminder fires.',
|
|
164
|
+
notify: 'Notifications', notifyD: 'A desktop notification when a reminder fires.',
|
|
165
|
+
soon: 'coming soon', screen: 'See your screen', screenD: 'Look at what’s open to help with it.', procs: 'Processes and apps', procsD: 'Know what’s running, and act on it (via the Orquesta agent).', inbox: 'Read mail and calendar', inboxD: 'Summaries and replies, not just opening the tab.',
|
|
166
|
+
petH: 'How chatty', annoyHint: 'How often it comments on what you do.', a0: 'Quiet', a1: 'A little', a2: 'Normal', a3: 'Chatty',
|
|
167
|
+
view: 'Look:', v2d: '2D', v3d: '3D plush', viewNext: 'Applies the next time it opens (7ots stop · 7ots pet).',
|
|
168
|
+
notesH: 'What it knows about you', notesHint: 'Its notes. Tell it “remember that…” to add one.', noNotes: 'Nothing yet.', forget: 'Forget', forgetAll: 'Forget everything', sure: 'Click again to forget everything',
|
|
169
|
+
remH: 'Reminders', remHint: 'Tell it “remind me to … in 10 minutes” or “at 15:30”.', noRems: 'No reminders.', cancel: 'Cancel',
|
|
170
|
+
foot: 'Open this page with <code>7ots config</code>, or ⚙️ in the pet’s menu.', err: 'Couldn’t save: {e}',
|
|
171
|
+
},
|
|
172
|
+
es: {
|
|
173
|
+
title: '{name} · ajustes', back: '← Volver a la mascota', save: 'Guardar', saved: 'Guardado ✓', test: 'Probar', testing: 'Pensando…', now: 'Ahora:',
|
|
174
|
+
brainH: 'Cerebro', brainHint: 'Quién piensa cuando te habla. Tu propio CLI de IA es gratis con tu suscripción y corre en este computador.',
|
|
175
|
+
lines: 'Sin IA', linesD: 'Frases incluidas. Instantáneo, pero no entiende texto libre (los recordatorios igual funcionan).',
|
|
176
|
+
cli: 'CLI de IA local', cliD: 'Un comando de IA ya instalado acá, como instancia aparte.', notInstalled: 'no instalado',
|
|
177
|
+
api: 'Tu API key', apiD: 'Anthropic o cualquier endpoint compatible con OpenAI, con tu key.',
|
|
178
|
+
batuta: 'Batuta (Orquesta)', batutaD: 'El router de modelos de Orquesta, con tu token de Orquesta.',
|
|
179
|
+
home: 'La casa del ot', homeD: 'El /chat de su casa en 7ots.com o en tu servidor.', noHome: 'sin casa en línea',
|
|
180
|
+
model: 'Modelo (opcional)', provider: 'Proveedor', baseUrl: 'Base URL (opcional)', key: 'API key', keySaved: 'guardada — escribe para reemplazar', keyNone: 'sin configurar', orqToken: 'Token de Orquesta',
|
|
181
|
+
accessH: 'Qué puede ver y hacer', accessHint: 'Todo queda en este computador, salvo lo que va al cerebro que elegiste arriba.',
|
|
182
|
+
agent: 'Tu agente de código y la terminal', agentD: 'Tus prompts, comandos y herramientas van al cerebro para que comente. Apagado: solo sabe “pasó algo”.',
|
|
183
|
+
memory: 'Aprender de ti', memoryD: 'Guarda notas (“anota que…”) y las usa al responder.',
|
|
184
|
+
open: 'Abrir sitios', openD: 'Abre tu correo, calendario… cuando se lo pides o cuando suena un recordatorio.',
|
|
185
|
+
notify: 'Notificaciones', notifyD: 'Una notificación de escritorio cuando suena un recordatorio.',
|
|
186
|
+
soon: 'muy pronto', screen: 'Ver tu pantalla', screenD: 'Mirar lo que tienes abierto para ayudarte.', procs: 'Procesos y apps', procsD: 'Saber qué corre y actuar (vía el agente de Orquesta).', inbox: 'Leer correo y calendario', inboxD: 'Resúmenes y respuestas, no solo abrir la pestaña.',
|
|
187
|
+
petH: 'Cuánto habla', annoyHint: 'Qué tan seguido comenta lo que haces.', a0: 'Callado', a1: 'Poco', a2: 'Normal', a3: 'Charlatán',
|
|
188
|
+
view: 'Aspecto:', v2d: '2D', v3d: 'Peluche 3D', viewNext: 'Se aplica la próxima vez que se abra (7ots stop · 7ots pet).',
|
|
189
|
+
notesH: 'Lo que sabe de ti', notesHint: 'Sus notas. Dile “anota que…” para agregar una.', noNotes: 'Nada todavía.', forget: 'Olvidar', forgetAll: 'Olvidar todo', sure: 'Haz clic otra vez para olvidar todo',
|
|
190
|
+
remH: 'Recordatorios', remHint: 'Dile “recuérdame … en 10 minutos” o “a las 15:30”.', noRems: 'Sin recordatorios.', cancel: 'Cancelar',
|
|
191
|
+
foot: 'Abre esta página con <code>7ots config</code>, o ⚙️ en el menú de la mascota.', err: 'No se pudo guardar: {e}',
|
|
192
|
+
},
|
|
193
|
+
pt: {
|
|
194
|
+
title: '{name} · ajustes', back: '← Voltar ao bichinho', save: 'Salvar', saved: 'Salvo ✓', test: 'Testar', testing: 'Pensando…', now: 'Agora:',
|
|
195
|
+
brainH: 'Cérebro', brainHint: 'Quem pensa quando ele fala com você. Seu próprio CLI de IA é grátis com sua assinatura e roda neste computador.',
|
|
196
|
+
lines: 'Sem IA', linesD: 'Frases embutidas. Instantâneo, mas não entende texto livre (lembretes funcionam).',
|
|
197
|
+
cli: 'CLI de IA local', cliD: 'Um comando de IA já instalado aqui, como instância separada.', notInstalled: 'não instalado',
|
|
198
|
+
api: 'Sua API key', apiD: 'Anthropic ou qualquer endpoint compatível com OpenAI, com sua key.',
|
|
199
|
+
batuta: 'Batuta (Orquesta)', batutaD: 'O roteador de modelos da Orquesta, com seu token.',
|
|
200
|
+
home: 'A casa do ot', homeD: 'O /chat da casa dele no 7ots.com ou no seu servidor.', noHome: 'sem casa online',
|
|
201
|
+
model: 'Modelo (opcional)', provider: 'Provedor', baseUrl: 'Base URL (opcional)', key: 'API key', keySaved: 'salva — digite para trocar', keyNone: 'não configurada', orqToken: 'Token da Orquesta',
|
|
202
|
+
accessH: 'O que ele pode ver e fazer', accessHint: 'Tudo fica neste computador, menos o que vai para o cérebro escolhido acima.',
|
|
203
|
+
agent: 'Seu agente de código e o terminal', agentD: 'Seus prompts, comandos e ferramentas vão para o cérebro comentar. Desligado: só sabe “algo aconteceu”.',
|
|
204
|
+
memory: 'Aprender sobre você', memoryD: 'Guarda notas (“anote que…”) e usa ao responder.',
|
|
205
|
+
open: 'Abrir sites', openD: 'Abre seu e-mail, agenda… quando você pede ou quando um lembrete toca.',
|
|
206
|
+
notify: 'Notificações', notifyD: 'Uma notificação na área de trabalho quando um lembrete toca.',
|
|
207
|
+
soon: 'em breve', screen: 'Ver sua tela', screenD: 'Olhar o que está aberto para ajudar.', procs: 'Processos e apps', procsD: 'Saber o que roda e agir (via o agente da Orquesta).', inbox: 'Ler e-mail e agenda', inboxD: 'Resumos e respostas, não só abrir a aba.',
|
|
208
|
+
petH: 'Quanto fala', annoyHint: 'Com que frequência comenta o que você faz.', a0: 'Calado', a1: 'Pouco', a2: 'Normal', a3: 'Tagarela',
|
|
209
|
+
view: 'Visual:', v2d: '2D', v3d: 'Pelúcia 3D', viewNext: 'Vale na próxima vez que abrir (7ots stop · 7ots pet).',
|
|
210
|
+
notesH: 'O que ele sabe de você', notesHint: 'As notas dele. Diga “anote que…” para adicionar.', noNotes: 'Nada ainda.', forget: 'Esquecer', forgetAll: 'Esquecer tudo', sure: 'Clique de novo para esquecer tudo',
|
|
211
|
+
remH: 'Lembretes', remHint: 'Diga “me lembre de … em 10 minutos” ou “às 15:30”.', noRems: 'Sem lembretes.', cancel: 'Cancelar',
|
|
212
|
+
foot: 'Abra esta página com <code>7ots config</code>, ou ⚙️ no menu do bichinho.', err: 'Não deu para salvar: {e}',
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
// The browser's language first: the ot may speak English while its human reads Spanish.
|
|
216
|
+
const nav = (navigator.language || '').slice(0, 2);
|
|
217
|
+
const lang = T[nav] ? nav : T[BOOT.lang] ? BOOT.lang : 'en';
|
|
218
|
+
document.documentElement.lang = lang;
|
|
219
|
+
const t = (k, v = {}) => (T[lang][k] ?? T.en[k] ?? k).replace(/\{(\w+)\}/g, (_, n) => v[n] ?? '');
|
|
220
|
+
const $ = (s) => document.querySelector(s);
|
|
221
|
+
const el = (tag, props = {}, ...kids) => {
|
|
222
|
+
const n = Object.assign(document.createElement(tag), props);
|
|
223
|
+
n.append(...kids.filter((k) => k != null));
|
|
224
|
+
return n;
|
|
225
|
+
};
|
|
226
|
+
const api = (path, body) =>
|
|
227
|
+
fetch(path, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => {
|
|
228
|
+
const d = await r.json().catch(() => ({}));
|
|
229
|
+
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
|
230
|
+
return d;
|
|
231
|
+
});
|
|
232
|
+
const flash = (node, text, cls = 'ok') => {
|
|
233
|
+
node.className = `msg ${cls}`;
|
|
234
|
+
node.textContent = text;
|
|
235
|
+
clearTimeout(node._t);
|
|
236
|
+
if (cls === 'ok') node._t = setTimeout(() => (node.textContent = ''), 2500);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const n of document.querySelectorAll('[data-t]')) n.textContent = t(n.dataset.t);
|
|
240
|
+
$('#back').textContent = t('back');
|
|
241
|
+
$('#back').href = `/pet?token=${encodeURIComponent(BOOT.token)}`;
|
|
242
|
+
$('#foot').innerHTML = t('foot'); // own strings only
|
|
243
|
+
$('#ticks').append(...[0, 1, 2, 3].map((i) => el('span', { textContent: t(`a${i}`) })));
|
|
244
|
+
|
|
245
|
+
let S = null;
|
|
246
|
+
|
|
247
|
+
// ── brain ──
|
|
248
|
+
const CLI_MODELS = { claude: ['claude-haiku-4-5-20251001', 'claude-sonnet-5-5', 'claude-opus-5-5'], ollama: ['llama3.2', 'qwen2.5', 'gemma3'] };
|
|
249
|
+
function keyField(name, label) {
|
|
250
|
+
const has = S.keys[name];
|
|
251
|
+
return el('label', { className: 'f' }, `${label} · ${has ? '✓' : t('keyNone')}`, el('input', { type: 'password', name, placeholder: has ? t('keySaved') : '', autocomplete: 'off' }));
|
|
252
|
+
}
|
|
253
|
+
function renderBrains() {
|
|
254
|
+
const b = S.config.brain;
|
|
255
|
+
const box = $('#brains');
|
|
256
|
+
box.replaceChildren();
|
|
257
|
+
const opt = (kind, title, desc, more, off = '') => {
|
|
258
|
+
const radio = el('input', { type: 'radio', name: 'kind', value: kind, checked: b.kind === kind, disabled: !!off });
|
|
259
|
+
radio.id = `k-${kind}`;
|
|
260
|
+
// A div, not a label: the extra fields inside have labels of their own (no nested labels).
|
|
261
|
+
const card = el('div', { className: `opt${off ? ' off' : ''}` }, radio, el('label', { htmlFor: radio.id }, el('b', { textContent: title + (off ? ` — ${off}` : '') })), el('small', { textContent: desc }), more ? el('div', { className: 'more' }, ...more) : null);
|
|
262
|
+
card.addEventListener('click', (e) => {
|
|
263
|
+
if (!off && !e.target.closest('.more') && !radio.checked) radio.checked = true;
|
|
264
|
+
});
|
|
265
|
+
return card;
|
|
266
|
+
};
|
|
267
|
+
// CLI: one chip per known CLI, uninstalled ones greyed.
|
|
268
|
+
const chips = el('div', { className: 'chips' }, ...S.allClis.map((c) => {
|
|
269
|
+
const ok = S.clis.includes(c);
|
|
270
|
+
return el('label', { className: `chip${ok ? '' : ' off'}`, title: ok ? '' : t('notInstalled') }, el('input', { type: 'radio', name: 'cli', value: c, disabled: !ok, checked: b.kind === 'cli' ? b.cli === c : ok && c === S.clis[0] }), c);
|
|
271
|
+
}));
|
|
272
|
+
const cliModel = el('input', { type: 'text', name: 'cliModel', value: b.kind === 'cli' ? b.model || '' : '', placeholder: 'claude-haiku-4-5-20251001', autocomplete: 'off' });
|
|
273
|
+
cliModel.setAttribute('list', 'models');
|
|
274
|
+
const models = el('datalist', { id: 'models' });
|
|
275
|
+
const fillModels = () => {
|
|
276
|
+
const c = box.querySelector('input[name=cli]:checked')?.value;
|
|
277
|
+
models.replaceChildren(...(CLI_MODELS[c] || []).map((m) => el('option', { value: m })));
|
|
278
|
+
cliModel.placeholder = (CLI_MODELS[c] || [''])[0];
|
|
279
|
+
};
|
|
280
|
+
chips.addEventListener('change', fillModels);
|
|
281
|
+
box.append(
|
|
282
|
+
opt('lines', t('lines'), t('linesD')),
|
|
283
|
+
opt('cli', t('cli'), t('cliD'), [chips, el('label', { className: 'f' }, t('model'), cliModel, models)], S.clis.length ? '' : t('notInstalled')),
|
|
284
|
+
opt('api', t('api'), t('apiD'), [
|
|
285
|
+
el('label', { className: 'f' }, t('provider'), el('select', { name: 'provider' }, ...['anthropic', 'openai'].map((p) => el('option', { value: p, textContent: p === 'openai' ? 'OpenAI-compatible' : 'Anthropic', selected: b.provider === p })))),
|
|
286
|
+
el('label', { className: 'f' }, t('model'), el('input', { type: 'text', name: 'apiModel', value: b.kind === 'api' ? b.model || '' : '', autocomplete: 'off' })),
|
|
287
|
+
el('label', { className: 'f' }, t('baseUrl'), el('input', { type: 'text', name: 'baseUrl', value: b.baseUrl || '', placeholder: 'https://…', autocomplete: 'off' })),
|
|
288
|
+
keyField('ANTHROPIC_API_KEY', 'Anthropic ' + t('key')),
|
|
289
|
+
keyField('OPENAI_API_KEY', 'OpenAI ' + t('key')),
|
|
290
|
+
]),
|
|
291
|
+
opt('batuta', t('batuta'), t('batutaD'), [keyField('ORQUESTA_TOKEN', t('orqToken'))]),
|
|
292
|
+
opt('7ots', t('home'), t('homeD'), null, S.home.url ? '' : t('noHome')),
|
|
293
|
+
);
|
|
294
|
+
fillModels();
|
|
295
|
+
$('#now').replaceChildren(t('now'), ' ', el('b', { textContent: S.brain === 'lines' ? t('lines') : S.brain }));
|
|
296
|
+
}
|
|
297
|
+
function brainForm() {
|
|
298
|
+
const box = $('#brains');
|
|
299
|
+
const v = (n) => box.querySelector(`[name=${n}]`)?.value?.trim() || '';
|
|
300
|
+
const kind = box.querySelector('input[name=kind]:checked')?.value || 'lines';
|
|
301
|
+
const brain =
|
|
302
|
+
kind === 'cli' ? { kind, cli: box.querySelector('input[name=cli]:checked')?.value, model: v('cliModel') }
|
|
303
|
+
: kind === 'api' ? { kind, provider: v('provider'), model: v('apiModel'), baseUrl: v('baseUrl') }
|
|
304
|
+
: { kind };
|
|
305
|
+
const keys = {};
|
|
306
|
+
for (const n of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN']) if (v(n)) keys[n] = v(n);
|
|
307
|
+
return { brain, ...(Object.keys(keys).length ? { keys } : {}) };
|
|
308
|
+
}
|
|
309
|
+
async function saveBrain() {
|
|
310
|
+
const btn = $('#saveBrain');
|
|
311
|
+
btn.disabled = true;
|
|
312
|
+
try {
|
|
313
|
+
S = await api('/config', brainForm());
|
|
314
|
+
renderBrains();
|
|
315
|
+
flash($('#brainMsg'), t('saved'));
|
|
316
|
+
return true;
|
|
317
|
+
} catch (e) {
|
|
318
|
+
flash($('#brainMsg'), t('err', { e: e.message }), 'err');
|
|
319
|
+
return false;
|
|
320
|
+
} finally {
|
|
321
|
+
btn.disabled = false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
$('#saveBrain').addEventListener('click', saveBrain);
|
|
325
|
+
$('#test').addEventListener('click', async () => {
|
|
326
|
+
const btn = $('#test');
|
|
327
|
+
if (!(await saveBrain())) return;
|
|
328
|
+
btn.disabled = true;
|
|
329
|
+
btn.textContent = t('testing');
|
|
330
|
+
const out = $('#reply');
|
|
331
|
+
try {
|
|
332
|
+
const r = await api('/config/test', {});
|
|
333
|
+
out.style.display = 'block';
|
|
334
|
+
out.textContent = r.ok ? `“${r.text}” · ${(r.ms / 1000).toFixed(1)} s` : `✗ ${r.error}`;
|
|
335
|
+
out.style.color = r.ok ? '' : 'var(--err)';
|
|
336
|
+
} finally {
|
|
337
|
+
btn.disabled = false;
|
|
338
|
+
btn.textContent = t('test');
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ── access ──
|
|
343
|
+
function renderAccess() {
|
|
344
|
+
const box = $('#access');
|
|
345
|
+
box.replaceChildren();
|
|
346
|
+
const sw = (key, on, disabled, soon) => {
|
|
347
|
+
const input = el('input', { type: 'checkbox', checked: on, disabled });
|
|
348
|
+
input.setAttribute('role', 'switch');
|
|
349
|
+
input.setAttribute('aria-label', t(key));
|
|
350
|
+
if (!disabled) input.addEventListener('change', () => save({ access: { [key]: input.checked } }, $('#accessMsg')));
|
|
351
|
+
return el('div', { className: `sw${soon ? ' soon' : ''}` }, el('b', {}, t(key), soon ? el('span', { className: 'tag', textContent: t('soon') }) : null), el('small', { textContent: t(`${key}D`) }), el('span', { className: 'tog' }, input, el('span')));
|
|
352
|
+
};
|
|
353
|
+
for (const k of ['agent', 'memory', 'open', 'notify']) box.append(sw(k, S.config.access[k] !== false, false, false));
|
|
354
|
+
for (const k of ['screen', 'procs', 'inbox']) box.append(sw(k, false, true, true));
|
|
355
|
+
box.append(el('span', { className: 'msg', id: 'accessMsg', role: 'status' }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── chattiness + look ──
|
|
359
|
+
function renderPet() {
|
|
360
|
+
$('#annoy').value = S.config.pet.annoy ?? 2;
|
|
361
|
+
const v = S.config.pet.view || '2d';
|
|
362
|
+
$('#view').replaceChildren(...['2d', '3d'].map((k) => {
|
|
363
|
+
const r = el('input', { type: 'radio', name: 'view', value: k, checked: v === k });
|
|
364
|
+
r.addEventListener('change', () => save({ pet: { view: k } }, $('#petMsg'), t('viewNext')));
|
|
365
|
+
return el('label', { className: 'chip' }, r, t(`v${k}`));
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
$('#annoy').addEventListener('change', (e) => save({ pet: { annoy: Number(e.target.value) } }, $('#petMsg')));
|
|
369
|
+
|
|
370
|
+
async function save(body, msg, okText = t('saved')) {
|
|
371
|
+
try {
|
|
372
|
+
S = await api('/config', body);
|
|
373
|
+
flash(msg, okText);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
flash(msg, t('err', { e: e.message }), 'err');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── notes + reminders ──
|
|
380
|
+
const fmt = (ms) => new Date(ms).toLocaleString(lang, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
381
|
+
function renderLists() {
|
|
382
|
+
const notes = S.notes.slice().reverse();
|
|
383
|
+
$('#notes').replaceChildren(...(notes.length ? notes.map((n) => {
|
|
384
|
+
const x = el('button', { className: 'x', textContent: t('forget') });
|
|
385
|
+
x.addEventListener('click', async () => {
|
|
386
|
+
await api('/notes/forget', { at: n.at });
|
|
387
|
+
refresh();
|
|
388
|
+
});
|
|
389
|
+
return el('li', {}, el('span', { textContent: n.text }), n.at ? el('time', { textContent: fmt(n.at) }) : null, x);
|
|
390
|
+
}) : [el('li', { className: 'empty', textContent: t('noNotes') })]));
|
|
391
|
+
$('#forgetAll').hidden = !notes.length;
|
|
392
|
+
$('#rems').replaceChildren(...(S.reminders.length ? S.reminders.map((r) => {
|
|
393
|
+
const x = el('button', { className: 'x', textContent: t('cancel') });
|
|
394
|
+
x.addEventListener('click', async () => {
|
|
395
|
+
await api('/reminders/cancel', { id: r.id });
|
|
396
|
+
refresh();
|
|
397
|
+
});
|
|
398
|
+
return el('li', {}, el('time', { textContent: fmt(r.at) }), el('span', { textContent: r.text + (r.open ? ` → ${new URL(r.open).host}` : '') }), x);
|
|
399
|
+
}) : [el('li', { className: 'empty', textContent: t('noRems') })]));
|
|
400
|
+
}
|
|
401
|
+
let armed = false;
|
|
402
|
+
$('#forgetAll').addEventListener('click', async (e) => {
|
|
403
|
+
if (!armed) {
|
|
404
|
+
armed = true;
|
|
405
|
+
e.target.textContent = t('sure');
|
|
406
|
+
setTimeout(() => ((armed = false), (e.target.textContent = t('forgetAll'))), 4000);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
armed = false;
|
|
410
|
+
e.target.textContent = t('forgetAll');
|
|
411
|
+
await api('/notes/forget', { at: 'all' });
|
|
412
|
+
refresh();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
async function refresh(all = false) {
|
|
416
|
+
S = await api('/config');
|
|
417
|
+
if (all) {
|
|
418
|
+
document.title = t('title', { name: S.name });
|
|
419
|
+
$('#title').textContent = t('title', { name: S.name });
|
|
420
|
+
renderBrains();
|
|
421
|
+
renderAccess();
|
|
422
|
+
renderPet();
|
|
423
|
+
}
|
|
424
|
+
renderLists();
|
|
425
|
+
}
|
|
426
|
+
refresh(true);
|
|
427
|
+
setInterval(() => document.visibilityState === 'visible' && refresh(), 15000); // reminders fire, notes get added
|
|
428
|
+
</script>
|
|
429
|
+
</body>
|
|
430
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7ots/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "7ots.com — open-source embeddable web agent: it sees the page, speaks and acts for your visitor. Web Component + self-hosted Node proxy, Claude/OpenAI, 2D/3D avatar with voice, i18n, backoffice — plus a CLI with reproducible identities, a desktop pet and apuchat meet.",
|
|
5
5
|
"homepage": "https://7ots.com",
|
|
6
6
|
"repository": {
|