clavarium 0.1.4 → 0.2.0-next.1
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/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +4 -0
- package/server/conversations.mjs +39 -5
- package/server/names.mjs +31 -0
- package/server/toolbox.mjs +1 -1
- package/server/tools.mjs +13 -0
- package/web/app.js +51 -10
- package/web/index.html +15 -1
- package/web/style.css +7 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.0-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.1
|
|
9
|
+
"version": "0.2.0-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
13
13
|
"@xterm/xterm": "^6.0.0",
|
|
14
|
-
"clavarium-parley": "0.
|
|
14
|
+
"clavarium-parley": "0.10.0-next.2",
|
|
15
15
|
"mermaid": "11.17.2",
|
|
16
16
|
"node-pty": "^1.1.0",
|
|
17
17
|
"pdfjs-dist": "6.4.299",
|
|
@@ -686,9 +686,9 @@
|
|
|
686
686
|
}
|
|
687
687
|
},
|
|
688
688
|
"node_modules/clavarium-parley": {
|
|
689
|
-
"version": "0.
|
|
690
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.
|
|
691
|
-
"integrity": "sha512
|
|
689
|
+
"version": "0.10.0-next.2",
|
|
690
|
+
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.2.tgz",
|
|
691
|
+
"integrity": "sha512-/6pAEMEKgug1FerqBKzqoMcWmnR6eYWtfiGt1Oa1gz9vWFuupxXlTeUFurukgfqLiEVOU6U+g1qJUff8PtTLBQ==",
|
|
692
692
|
"license": "MIT",
|
|
693
693
|
"bin": {
|
|
694
694
|
"parley": "bin/parley.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.0-next.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@xterm/addon-fit": "^0.11.0",
|
|
45
45
|
"@xterm/xterm": "^6.0.0",
|
|
46
|
-
"clavarium-parley": "0.
|
|
46
|
+
"clavarium-parley": "0.10.0-next.2",
|
|
47
47
|
"mermaid": "11.17.2",
|
|
48
48
|
"node-pty": "^1.1.0",
|
|
49
49
|
"pdfjs-dist": "6.4.299",
|
package/server/api.mjs
CHANGED
|
@@ -424,6 +424,10 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
424
424
|
if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
|
|
425
425
|
const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
|
|
426
426
|
if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
|
|
427
|
+
const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
|
|
428
|
+
if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
|
|
429
|
+
const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
|
|
430
|
+
if (hiding) return send(200, conversations.setHidden(decodeURIComponent(hiding[1]), body.on)), true;
|
|
427
431
|
const marking = /^\/api\/conversations\/([^/]+)\/voice-mark$/.exec(pathname);
|
|
428
432
|
if (marking) return send(200, conversations.setVoiceMark(decodeURIComponent(marking[1]), body.on)), true;
|
|
429
433
|
const resuming = /^\/api\/conversations\/([^/]+)\/resume$/.exec(pathname);
|
package/server/conversations.mjs
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* - Sleep is a button, never a guess: telling a long job from an idle tool
|
|
7
7
|
* cannot be done without reading the tool, and this server does not read.
|
|
8
8
|
*/
|
|
9
|
-
import { isConversationName } from './names.mjs';
|
|
9
|
+
import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
|
|
10
10
|
import { attachCommand, SOCKET } from './tmux.mjs';
|
|
11
11
|
import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
|
|
12
|
+
import { resumeOrFresh } from './tools.mjs';
|
|
12
13
|
|
|
13
14
|
export const SESSION = 'main';
|
|
14
15
|
export const STOP_DELAY_MS = 60_000;
|
|
@@ -188,7 +189,8 @@ export class Conversations {
|
|
|
188
189
|
|
|
189
190
|
/* Whether a new machine may be made for this request: what `create`
|
|
190
191
|
checks, and the server's room (capacity.mjs). Asked before `create`. */
|
|
191
|
-
async admit({ name, tool, memoryMb } = {}) {
|
|
192
|
+
async admit({ name, title, tool, memoryMb } = {}) {
|
|
193
|
+
if (!name && typeof title === 'string' && !titleProblem(title)) ({ name } = this.#naming({ title }));
|
|
192
194
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
193
195
|
/* What `create` refuses anyway is said by `create`. */
|
|
194
196
|
if (!chosen || !isConversationName(name) || this.#store.get(name)) return;
|
|
@@ -200,7 +202,17 @@ export class Conversations {
|
|
|
200
202
|
/* `workOnDisk`: the tool works on its machine's own disk instead of in a
|
|
201
203
|
folder kept by the server. Faster for a large code project; but what
|
|
202
204
|
it makes is then not seen from the page, and goes with the machine. */
|
|
203
|
-
|
|
205
|
+
/* A conversation is made from the name a person reads (`title`), its
|
|
206
|
+
identifier drawn from it, or from an identifier alone (the advanced
|
|
207
|
+
options, a blueprint). The identifier never changes afterwards. */
|
|
208
|
+
#naming({ name, title }) {
|
|
209
|
+
if (title !== undefined) { const problem = titleProblem(title); if (problem) throw new Refusal(400, problem); }
|
|
210
|
+
const id = name || (title !== undefined ? identifierFrom(title, (x) => Boolean(this.#store.get(x))) : name);
|
|
211
|
+
return { name: id, title: title !== undefined && title !== id ? title : undefined };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
|
|
215
|
+
const { name, title } = this.#naming({ name: given, title: read });
|
|
204
216
|
if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
|
|
205
217
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
206
218
|
if (!chosen) throw new Refusal(400, 'Unknown tool.');
|
|
@@ -210,7 +222,7 @@ export class Conversations {
|
|
|
210
222
|
}
|
|
211
223
|
if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
|
|
212
224
|
const now = new Date().toISOString();
|
|
213
|
-
const conversation = { name, tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
225
|
+
const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
214
226
|
this.#store.put(conversation);
|
|
215
227
|
this.built = this.#build(name, chosen, memory);
|
|
216
228
|
return conversation;
|
|
@@ -424,7 +436,7 @@ export class Conversations {
|
|
|
424
436
|
first: added at EVERY start, a resume included (a tool does not
|
|
425
437
|
keep it from its last session). */
|
|
426
438
|
const rights = tool.rights ? (c.askFirst === true ? tool.rights.ask : tool.rights.full) : [];
|
|
427
|
-
return
|
|
439
|
+
return resume ? resumeOrFresh([...tool.resume, ...rights], [...tool.command, ...rights]) : [...tool.command, ...rights];
|
|
428
440
|
}
|
|
429
441
|
|
|
430
442
|
/* Whether its tool asks before acting (true), or acts without asking in
|
|
@@ -449,6 +461,28 @@ export class Conversations {
|
|
|
449
461
|
return this.#store.get(name);
|
|
450
462
|
}
|
|
451
463
|
|
|
464
|
+
/* THE NAME A PERSON READS, changed at any time; its identifier stays.
|
|
465
|
+
Kept only when it differs from the identifier: a conversation made
|
|
466
|
+
before free names is read by its identifier, and nothing is rewritten. */
|
|
467
|
+
setTitle(name, title) {
|
|
468
|
+
const c = this.#store.get(name);
|
|
469
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
470
|
+
const problem = titleProblem(title);
|
|
471
|
+
if (problem) throw new Refusal(400, problem);
|
|
472
|
+
this.#store.patch(name, { title: title === name ? undefined : title });
|
|
473
|
+
return this.#store.get(name);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* Hidden from the list, like a hidden file: it runs, keeps its rights,
|
|
477
|
+
and is still in Access; the list says how many are hidden. */
|
|
478
|
+
setHidden(name, on) {
|
|
479
|
+
const c = this.#store.get(name);
|
|
480
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
481
|
+
if (typeof on !== 'boolean') throw new Refusal(400, 'Say true (hidden from the list) or false (shown).');
|
|
482
|
+
this.#store.patch(name, { hidden: on ? true : undefined });
|
|
483
|
+
return this.#store.get(name);
|
|
484
|
+
}
|
|
485
|
+
|
|
452
486
|
/* What is dictated into its terminal starts with a microphone sign, once
|
|
453
487
|
per message (true, the default for a tool that is an AI), or not. */
|
|
454
488
|
setVoiceMark(name, on) {
|
package/server/names.mjs
CHANGED
|
@@ -8,3 +8,34 @@ const NAME = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
|
8
8
|
export function isConversationName(value) {
|
|
9
9
|
return typeof value === 'string' && value.length <= NAME_MAX && NAME.test(value);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/* THE NAME A PERSON READS is free: spaces, capitals, accents, emoji. It never
|
|
13
|
+
reaches a command line (the identifier above does), and the page shows it
|
|
14
|
+
as text only. Refused: what cannot be seen or reorders what is seen
|
|
15
|
+
(control characters, line breaks, zero-width and bidirectional marks), and
|
|
16
|
+
spaces at either end, which would make two names look alike. */
|
|
17
|
+
export const TITLE_MAX = 60;
|
|
18
|
+
const UNSEEN_RANGES = [[0x0, 0x1f], [0x7f, 0x9f], [0xad, 0xad], [0x61c, 0x61c], [0x180e, 0x180e], [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x206f], [0xfeff, 0xfeff], [0xfff9, 0xfffb]];
|
|
19
|
+
const UNSEEN = new RegExp(`[${UNSEEN_RANGES.map(([a, b]) => `\\u{${a.toString(16)}}-\\u{${b.toString(16)}}`).join('')}]`, 'u');
|
|
20
|
+
|
|
21
|
+
/* The reason it is refused, or '' when it may be a name. */
|
|
22
|
+
export function titleProblem(value) {
|
|
23
|
+
if (typeof value !== 'string' || value === '') return 'Give a name.';
|
|
24
|
+
if (UNSEEN.test(value)) return 'A name has no line breaks and no invisible or direction characters.';
|
|
25
|
+
if (value.trim() !== value) return 'A name does not start or end with a space.';
|
|
26
|
+
if ([...value].length > TITLE_MAX) return `A name is ${TITLE_MAX} characters at most.`;
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* The identifier drawn from a name: "Location voiture" -> location-voiture;
|
|
31
|
+
one taken -> location-voiture-2. `taken(id)` says whether it is. */
|
|
32
|
+
export function identifierFrom(title, taken = () => false) {
|
|
33
|
+
let base = String(title ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '');
|
|
35
|
+
if (!base) base = 'conversation';
|
|
36
|
+
for (let n = 1; ; n += 1) {
|
|
37
|
+
const tail = n === 1 ? '' : `-${n}`;
|
|
38
|
+
const id = `${base.slice(0, NAME_MAX - tail.length).replace(/-+$/, '')}${tail}`;
|
|
39
|
+
if (isConversationName(id) && !taken(id)) return id;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/server/toolbox.mjs
CHANGED
|
@@ -89,7 +89,7 @@ export class Toolbox {
|
|
|
89
89
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
90
90
|
const there = existsSync(`${dir}/${file}`) ? createHash('sha256').update(readFileSync(`${dir}/${file}`)).digest('hex') : '';
|
|
91
91
|
if (there !== sha256) { writeFileSync(`${dir}/.${file}.new`, bytes); renameSync(`${dir}/.${file}.new`, `${dir}/${file}`); }
|
|
92
|
-
const version = /-(\d+\.\d+\.\d+)\.tgz$/.exec(file)?.[1] ?? '';
|
|
92
|
+
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\.tgz$/.exec(file)?.[1] ?? '';
|
|
93
93
|
tools.push({ name: source.name, version, file, sha256, usage: source.usage(`${TOOLS_PATH}/${file}`) });
|
|
94
94
|
}
|
|
95
95
|
const kept = new Set([...tools.map((t) => t.file), 'index.json', ...(this.#notes ? [NOTES] : [])]);
|
package/server/tools.mjs
CHANGED
|
@@ -107,3 +107,16 @@ export function readTools(text) {
|
|
|
107
107
|
export function loadTools(path = new URL('../config/tools.json', import.meta.url)) {
|
|
108
108
|
return readTools(readFileSync(path, 'utf8'));
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/* TAKING UP ITS LAST SESSION, OR A NEW ONE: a tool told to resume where it has
|
|
112
|
+
no session of its own (never spoken to, its session files gone) may just say
|
|
113
|
+
so and stop ("No conversation found"), leaving a dead terminal. If the resume
|
|
114
|
+
fails within RESUME_GRACE_S, the tool starts fresh, in the same terminal. A
|
|
115
|
+
tool that ran and is quit stays quit. One argument vector, for tmux. */
|
|
116
|
+
export const RESUME_GRACE_S = 20;
|
|
117
|
+
const shq = (a) => `'${String(a).replace(/'/g, `'\\''`)}'`;
|
|
118
|
+
export function resumeOrFresh(resume, fresh) {
|
|
119
|
+
const script = `t=$(date +%s); ${resume.map(shq).join(' ')}; c=$?; `
|
|
120
|
+
+ `if [ "$c" -ne 0 ] && [ $(( $(date +%s) - t )) -lt ${RESUME_GRACE_S} ]; then echo; echo 'No session to take up here: a new one starts.'; exec ${fresh.map(shq).join(' ')}; fi; exit "$c"`;
|
|
121
|
+
return ['sh', '-c', script];
|
|
122
|
+
}
|
package/web/app.js
CHANGED
|
@@ -650,8 +650,8 @@
|
|
|
650
650
|
function enter(keepSheets = false) {
|
|
651
651
|
if (welcoming) return;
|
|
652
652
|
const c = conversations.find((x) => x.name === current);
|
|
653
|
-
$('name').textContent = current || 'No conversation';
|
|
654
|
-
document.title = current ? `${current} - Clavarium` : 'Clavarium';
|
|
653
|
+
$('name').textContent = (c ? shownName(c) : current) || 'No conversation';
|
|
654
|
+
document.title = current ? `${c ? shownName(c) : current} - Clavarium` : 'Clavarium';
|
|
655
655
|
$('tool').hidden = !c; $('tool').textContent = c?.toolLabel ?? '';
|
|
656
656
|
paintActions();
|
|
657
657
|
$('copy-open').hidden = !current || (home && (!c || c.phase !== 'ready'));
|
|
@@ -731,10 +731,20 @@
|
|
|
731
731
|
if (!r.ok) throw Object.assign(new Error(j.error || 'The request failed.'), { kind: j.kind, retry: j.retry === true });
|
|
732
732
|
return j;
|
|
733
733
|
};
|
|
734
|
+
/* The name a person reads; a conversation made before free names is read by its identifier. */
|
|
735
|
+
const shownName = (c) => (c && typeof c.title === 'string' && c.title ? c.title : c?.name ?? '');
|
|
736
|
+
/* It needs its owner: it asks or waits, its answerer asks, it failed, its machine is unwell. Said even when it is hidden. */
|
|
737
|
+
const needsYou = (c) => Boolean((c.talk && (c.talk.waiting > 0 || c.talk.state === 'waiting')) || c.answerer?.needs || c.phase === 'failed'
|
|
738
|
+
|| (c.phase === 'ready' && !c.asleep && ['warn', 'bad'].includes(c.health?.level)));
|
|
739
|
+
/* Hidden conversations are shown on a click on "N hidden", for this page only. */
|
|
740
|
+
let showHidden = false;
|
|
734
741
|
function paint() {
|
|
735
742
|
const items = $('items');
|
|
736
743
|
items.replaceChildren();
|
|
744
|
+
const hidden = conversations.filter((c) => c.hidden === true);
|
|
737
745
|
for (const c of conversations) {
|
|
746
|
+
/* The one open stays in sight, hidden or not. */
|
|
747
|
+
if (c.hidden === true && !showHidden && c.name !== current) continue;
|
|
738
748
|
const b = document.createElement('button');
|
|
739
749
|
b.type = 'button'; b.className = 'item'; b.dataset.phase = c.phase;
|
|
740
750
|
/* Ready, but its tool not there yet: it still reads as under way. */
|
|
@@ -742,7 +752,9 @@
|
|
|
742
752
|
if (c.name === current) b.setAttribute('aria-current', 'true');
|
|
743
753
|
const title = document.createElement('b');
|
|
744
754
|
const dot = document.createElement('span'); dot.className = 'dot';
|
|
745
|
-
title.append(dot, c
|
|
755
|
+
title.append(dot, shownName(c));
|
|
756
|
+
if (c.title) b.title = c.name;
|
|
757
|
+
if (c.hidden === true) b.dataset.hidden = 'true';
|
|
746
758
|
const sub = document.createElement('small');
|
|
747
759
|
/* Under way: the step the panel shows, in its words (the tool named once). */
|
|
748
760
|
const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
|
|
@@ -761,6 +773,14 @@
|
|
|
761
773
|
b.addEventListener('click', () => select(c.name));
|
|
762
774
|
items.append(b);
|
|
763
775
|
}
|
|
776
|
+
const toggle = $('hidden-toggle');
|
|
777
|
+
toggle.hidden = DEVICE || hidden.length === 0;
|
|
778
|
+
toggle.textContent = showHidden ? `Hide ${hidden.length} hidden` : `${hidden.length} hidden`;
|
|
779
|
+
toggle.setAttribute('aria-expanded', String(showHidden));
|
|
780
|
+
const calling = hidden.some(needsYou);
|
|
781
|
+
toggle.dataset.needs = String(calling);
|
|
782
|
+
toggle.title = calling ? 'A hidden conversation needs you' : '';
|
|
783
|
+
if (calling && !showHidden) { const dot = document.createElement('span'); dot.className = 'needs-dot'; dot.setAttribute('aria-label', 'one needs you'); toggle.append(' ', dot); }
|
|
764
784
|
$('empty').hidden = conversations.length > 0;
|
|
765
785
|
paintDeviceCount();
|
|
766
786
|
}
|
|
@@ -832,16 +852,21 @@
|
|
|
832
852
|
$('create').hidden = false;
|
|
833
853
|
focusField($('create-name'));
|
|
834
854
|
}
|
|
855
|
+
$('hidden-toggle').addEventListener('click', () => { showHidden = !showHidden; paint(); });
|
|
835
856
|
$('new').addEventListener('click', () => { void openCreate(); });
|
|
836
857
|
$('create-cancel').addEventListener('click', () => { $('create').hidden = true; });
|
|
858
|
+
/* The identifier drawn from the name, as the server draws it (names.mjs), shown before it is made; the server decides. */
|
|
859
|
+
const identifierOf = (title) => String(title).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 32).replace(/-+$/, '') || 'conversation';
|
|
860
|
+
$('create-name').addEventListener('input', () => { $('create-id').placeholder = identifierOf($('create-name').value.trim()); });
|
|
837
861
|
$('create').addEventListener('submit', async (event) => {
|
|
838
862
|
event.preventDefault();
|
|
839
|
-
const
|
|
863
|
+
const title = $('create-name').value.trim();
|
|
864
|
+
const id = $('create-id').value.trim();
|
|
840
865
|
try {
|
|
841
|
-
await post(`${API}/conversations`, { name, tool: chosenTool, workOnDisk: $('create-disk').checked });
|
|
842
|
-
$('create').hidden = true; $('create-name').value = ''; $('create-disk').checked = false;
|
|
866
|
+
const made = await post(`${API}/conversations`, { title, ...(id ? { name: id } : {}), tool: chosenTool, workOnDisk: $('create-disk').checked });
|
|
867
|
+
$('create').hidden = true; $('create-name').value = ''; $('create-id').value = ''; $('create-disk').checked = false;
|
|
843
868
|
await refresh();
|
|
844
|
-
select(name);
|
|
869
|
+
select(made.name);
|
|
845
870
|
} catch (error) { $('create-error').textContent = error.message; $('create-error').hidden = false; }
|
|
846
871
|
});
|
|
847
872
|
|
|
@@ -1380,7 +1405,7 @@
|
|
|
1380
1405
|
const facts = $('general-facts'); facts.replaceChildren();
|
|
1381
1406
|
const fact = (term, value) => { const dt = document.createElement('dt'); dt.textContent = term; const dd = document.createElement('dd'); dd.textContent = value; facts.append(dt, dd); };
|
|
1382
1407
|
if (here) {
|
|
1383
|
-
fact('
|
|
1408
|
+
fact('Identifier', here.name);
|
|
1384
1409
|
fact('Tool', here.toolLabel ?? here.tool);
|
|
1385
1410
|
if (here.createdAt) fact('Created', new Date(here.createdAt).toLocaleString());
|
|
1386
1411
|
fact('Machine', `${sizeWords(here.memoryMb)} of memory, ${here.diskGb ? `${here.diskGb} GB` : 'an unknown size of'} disk`);
|
|
@@ -1740,6 +1765,9 @@
|
|
|
1740
1765
|
if (DEVICE) { $('talk-box').hidden = true; $('voice-mark-box').hidden = true; return; }
|
|
1741
1766
|
const here = conversations.find((x) => x.name === current);
|
|
1742
1767
|
if (document.activeElement !== $('chat-name')) $('chat-name').value = here?.chatName ?? '';
|
|
1768
|
+
if (document.activeElement !== $('title-name')) $('title-name').value = shownName(here);
|
|
1769
|
+
$('title-box').hidden = $('hide-box').hidden = DEVICE || !here;
|
|
1770
|
+
for (const b of $('hide-on').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.hidden === true)));
|
|
1743
1771
|
$('voice-mark-box').hidden = !here?.ai;
|
|
1744
1772
|
for (const b of $('voice-mark').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.voiceMark === true)));
|
|
1745
1773
|
await readTalk();
|
|
@@ -1758,6 +1786,19 @@
|
|
|
1758
1786
|
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1759
1787
|
});
|
|
1760
1788
|
}
|
|
1789
|
+
$('title-form').addEventListener('submit', async (event) => {
|
|
1790
|
+
event.preventDefault();
|
|
1791
|
+
$('panel-error').hidden = true;
|
|
1792
|
+
try { await post(`${API}/conversations/${encodeURIComponent(current)}/title`, { title: $('title-name').value.trim() }); $('title-name').blur(); await refresh(); enter(true); }
|
|
1793
|
+
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1794
|
+
});
|
|
1795
|
+
for (const b of $('hide-on').children) {
|
|
1796
|
+
b.addEventListener('click', async () => {
|
|
1797
|
+
$('panel-error').hidden = true;
|
|
1798
|
+
try { await post(`${API}/conversations/${encodeURIComponent(current)}/hidden`, { on: b.dataset.on === 'true' }); await refresh(); for (const x of $('hide-on').children) x.setAttribute('aria-pressed', String(x === b)); }
|
|
1799
|
+
catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1761
1802
|
$('chat-name-form').addEventListener('submit', async (event) => {
|
|
1762
1803
|
event.preventDefault();
|
|
1763
1804
|
$('panel-error').hidden = true;
|
|
@@ -2092,7 +2133,7 @@
|
|
|
2092
2133
|
}
|
|
2093
2134
|
for (const c of ready.filter((x) => x.name !== accessFor)) {
|
|
2094
2135
|
const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
|
|
2095
|
-
const who = document.createElement('span'); who.textContent = c
|
|
2136
|
+
const who = document.createElement('span'); who.textContent = shownName(c);
|
|
2096
2137
|
const options = kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']] : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
|
|
2097
2138
|
line.append(who, modes(options, resource.attachments?.[c.name] ?? 'none', async (access) => { await attachTo(c.name, resource.name, access, 'assets-error'); await paintAssets(); }, `What ${c.name} may do with ${resource.name}`));
|
|
2098
2139
|
inside.append(line);
|
|
@@ -2247,7 +2288,7 @@
|
|
|
2247
2288
|
const inside = document.createElement('div'); inside.className = 'asset-inside';
|
|
2248
2289
|
for (const c of ready.filter((x) => x.name !== accessFor)) {
|
|
2249
2290
|
const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
|
|
2250
|
-
const who = document.createElement('span'); who.textContent = c
|
|
2291
|
+
const who = document.createElement('span'); who.textContent = shownName(c);
|
|
2251
2292
|
line.append(who, modes([['off', 'Off'], ['use', 'Use']], secret.conversations.includes(c.name) ? 'use' : 'off', async (value) => { await handTo(c.name, secret.name, value === 'use', 'assets-error'); await paintAssets(); }, `Whether the tool of ${c.name} is handed ${secret.name}`));
|
|
2252
2293
|
inside.append(line);
|
|
2253
2294
|
}
|
package/web/index.html
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
<div class="top"><b>Conversations</b><button type="button" id="new" class="primary">+ New</button></div>
|
|
16
16
|
<form id="create" hidden autocomplete="off">
|
|
17
17
|
<label for="create-name">Name</label>
|
|
18
|
-
<input id="create-name" name="name" required maxlength="
|
|
18
|
+
<input id="create-name" name="name" required maxlength="60" placeholder="Website redesign" spellcheck="false">
|
|
19
|
+
<details class="advanced"><summary>Advanced options</summary>
|
|
20
|
+
<label for="create-id">Identifier</label>
|
|
21
|
+
<input id="create-id" maxlength="32" pattern="[a-z]([a-z0-9\-]{0,30}[a-z0-9])?" title="Lower-case letters, digits and hyphens; starts with a letter, does not end with a hyphen" placeholder="website-redesign" spellcheck="false" autocapitalize="none">
|
|
22
|
+
<p class="hint">Used for its machine, its folders, its address and what is given to it. Drawn from the name when empty; it cannot be changed later. The name can.</p>
|
|
23
|
+
</details>
|
|
19
24
|
<span class="legend">What runs in it</span>
|
|
20
25
|
<div id="create-tools" class="choices" role="radiogroup" aria-label="What runs in it"></div>
|
|
21
26
|
<label class="check"><input type="checkbox" id="create-disk"> Work on the machine's own disk</label>
|
|
@@ -25,6 +30,8 @@
|
|
|
25
30
|
<span class="hint">a new machine takes a few minutes</span></div>
|
|
26
31
|
</form>
|
|
27
32
|
<div id="items"></div>
|
|
33
|
+
<!-- Hidden conversations still run; this shows them, and says when one needs you. -->
|
|
34
|
+
<button type="button" id="hidden-toggle" class="inline" hidden aria-expanded="false"></button>
|
|
28
35
|
<p id="empty" class="hint" hidden>No conversation yet. A conversation is a machine of its own with one tool running in it.</p>
|
|
29
36
|
<!-- Shown only when this server is short of disk or memory: opens Settings, This server. -->
|
|
30
37
|
<button type="button" id="server-gauge" hidden></button>
|
|
@@ -104,6 +111,13 @@
|
|
|
104
111
|
<summary><span class="chapter-title">General</span><span class="chapter-about">What it is, and how you talk with it.</span></summary>
|
|
105
112
|
<div class="chapter-body">
|
|
106
113
|
<dl id="general-facts" class="facts"></dl>
|
|
114
|
+
<div id="title-box" hidden>
|
|
115
|
+
<form id="title-form" class="row" autocomplete="off"><label for="title-name">Name</label><input id="title-name" maxlength="60" spellcheck="false"><button type="submit">Save</button></form>
|
|
116
|
+
</div>
|
|
117
|
+
<div id="hide-box" hidden>
|
|
118
|
+
<div class="row"><span>In the list</span><div class="levels" id="hide-on" role="group" aria-label="Hidden from the list"><button type="button" data-on="false">Shown</button><button type="button" data-on="true">Hidden</button></div></div>
|
|
119
|
+
<p class="hint">Hidden, it runs as before, keeps what it was given and stays in Access; "hidden" at the bottom of the list shows it.</p>
|
|
120
|
+
</div>
|
|
107
121
|
<!-- WHAT ITS TOOL IS TOLD ABOUT WHERE IT IS: the exact text, made by the
|
|
108
122
|
server from these settings; the owner's own lines; told again on a click. -->
|
|
109
123
|
<div id="guide-box">
|
package/web/style.css
CHANGED
|
@@ -1010,3 +1010,10 @@ button.inline:hover { color: var(--brass); }
|
|
|
1010
1010
|
#server-gauge [data-level="bad"] .gauge-bar > span { background: var(--danger); }
|
|
1011
1011
|
#server-gauge .gauge-n { color: var(--ink2); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
1012
1012
|
#server-gauge [data-level="ok"] .gauge-word { color: var(--ink2); }
|
|
1013
|
+
/* Hidden conversations: a quiet link at the bottom of the list; a dot when one of them needs you. */
|
|
1014
|
+
#hidden-toggle { display: block; margin: .5rem .75rem; color: var(--ink2); font-size: .8rem; }
|
|
1015
|
+
#hidden-toggle[hidden] { display: none; }
|
|
1016
|
+
#hidden-toggle .needs-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--brass); vertical-align: middle; }
|
|
1017
|
+
.item[data-hidden="true"] { opacity: .7; }
|
|
1018
|
+
#create .advanced { margin: .25rem 0; }
|
|
1019
|
+
#create .advanced summary { cursor: pointer; color: var(--ink2); font-size: .8rem; }
|