clavarium 0.2.0-next.10 → 0.2.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,8 +30,9 @@ Built slice by slice; the plan is in `blueprint/architecture.html`, tab
30
30
 
31
31
  - **5 - hands**: one-click shortcuts (sent, or left to complete), and
32
32
  dictation: the microphone stays open and each phrase is typed into the
33
- terminal; "auto" presses Enter once the person has stopped. Dictation needs
34
- `CLAVARIUM_TRANSCRIPTION_URL` (and `_KEY`) in the environment.
33
+ terminal; "auto" presses Enter once the person has stopped. Its engine is chosen
34
+ in Settings, Dictation (a provider, its key one of the secrets);
35
+ `CLAVARIUM_TRANSCRIPTION_URL` (and `_KEY`) in the environment is the fallback.
35
36
 
36
37
  - **6 - the app and sharing**: the page is installable; a conversation is
37
38
  shared with a one-use QR code; the device that scans it makes its own key,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.10",
3
+ "version": "0.2.0-next.11",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.0-next.10",
9
+ "version": "0.2.0-next.11",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.10",
3
+ "version": "0.2.0-next.11",
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.",
package/server/api.mjs CHANGED
@@ -71,6 +71,10 @@
71
71
  * POST /api/shortcuts/<id>/remove
72
72
  * GET /api/voice is dictation available?
73
73
  * POST /api/voice the body IS a WAV recording -> {text}
74
+ * GET /api/dictation the engine chosen in Settings (never a key: a secret's name)
75
+ * POST /api/dictation {provider, url, model, secret, language}: chosen, at once
76
+ * POST /api/dictation/off the environment's engine, if any, again
77
+ * POST /api/dictation/try?provider=&url=&model=&secret=&language= a WAV -> {text}, nothing saved
74
78
  * GET /api/files?c=<conversation>&r=<resource>&p=<path> a listing
75
79
  * GET /api/flows who gets what: devices, conversations, assets, the rights between them, the flows
76
80
  * POST /api/flows/name {around, name}: a flow named by the owner ('' gives it back its own)
@@ -139,14 +143,18 @@ export async function handleApi(request, response, conversations, headers, resou
139
143
  return resources.folder(any.name);
140
144
  };
141
145
  try {
142
- if (request.method === 'GET' && pathname === '/api/voice') return send(200, { available: voice !== undefined, engine: voice?.model ?? null }), true;
143
- if (request.method === 'POST' && pathname === '/api/voice') {
146
+ if (request.method === 'GET' && pathname === '/api/voice') return send(200, { available: voice?.current() !== undefined, engine: voice?.model ?? null }), true;
147
+ if (request.method === 'GET' && pathname === '/api/dictation' && voice) return send(200, voice.about()), true;
148
+ if (request.method === 'POST' && (pathname === '/api/voice' || (pathname === '/api/dictation/try' && voice))) {
144
149
  if (!originOk(request.headers) || request.headers['content-type'] !== 'audio/wav') {
145
150
  return send(403, { error: 'This request does not come from this site.' }), true;
146
151
  }
147
152
  const parts = []; let size = 0;
148
153
  for await (const chunk of request) { size += chunk.length; if (size > AUDIO_MAX) throw new Refusal(413, 'The recording is too long.'); parts.push(chunk); }
149
- return send(200, { text: await transcribe(voice, Buffer.concat(parts)) }), true;
154
+ const audio = Buffer.concat(parts);
155
+ /* "Try it": the choice in the form, in the address's parameters (no key in it: a secret's name). */
156
+ if (pathname === '/api/dictation/try') return send(200, { text: await voice.try(Object.fromEntries(query), audio) }), true;
157
+ return send(200, { text: voice ? await voice.transcribe(audio) : await transcribe(undefined, audio) }), true;
150
158
  }
151
159
  if (request.method === 'GET' && pathname === '/api/files') return send(200, await list(attached(), query.get('p') ?? '')), true;
152
160
  if (request.method === 'GET' && pathname === '/api/files/machine') {
@@ -438,6 +446,8 @@ export async function handleApi(request, response, conversations, headers, resou
438
446
  if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
439
447
  /* The list: folders and order, the same on every device (conversations.mjs). */
440
448
  if (pathname === '/api/instance' && instance) return send(200, instance.setName(body.name)), true;
449
+ if (pathname === '/api/dictation' && voice) return send(200, voice.set(body)), true;
450
+ if (pathname === '/api/dictation/off' && voice) return send(200, voice.off()), true;
441
451
  if (pathname === '/api/order') return send(200, conversations.setOrder({ order: body.order, groups: body.groups })), true;
442
452
  if (pathname === '/api/groups/rename') return send(200, conversations.renameGroup(body.from, body.to)), true;
443
453
  if (pathname === '/api/groups/move') return send(200, conversations.moveGroup(body.group, body.to)), true;
package/server/bridge.mjs CHANGED
@@ -142,9 +142,10 @@ export function attachBridge(server, { spawn, onLeft = () => {}, onEnded = () =>
142
142
  const terminal = await spawn(name, cols, rows, say);
143
143
  /* While it was being opened another one may have been: the newest wins. */
144
144
  if (live.has(name)) close(name);
145
- const kept = { terminal, sink, cols, rows };
145
+ const kept = { terminal, sink, cols, rows, openedAt: Date.now(), outputAt: Date.now() };
146
146
  live.set(name, kept);
147
147
  terminal.onData((bytes) => {
148
+ kept.outputAt = Date.now();
148
149
  if (kept.sink?.readyState === 1) { kept.sink.send(bytes, { binary: true }); return; }
149
150
  if (!kept.sink) { const answer = answerForAbsentScreen(bytes); if (answer) terminal.write(answer); }
150
151
  });
@@ -267,5 +268,9 @@ export function attachBridge(server, { spawn, onLeft = () => {}, onEnded = () =>
267
268
  /* `type(name, line, ms)`: see above. `droppedAt(name)`: when it was last
268
269
  closed for its machine's sake, or undefined. */
269
270
  const drop = (name) => { dropped.set(name, Date.now()); close(name); };
270
- return { seats, drop, warm, type, open: (name) => live.has(name), droppedAt: (name) => dropped.get(name) };
271
+ /* `settled(name)`: how long its terminal has been open, and how long
272
+ since anything came out of it -- THAT something came, never what: a
273
+ tool still drawing its first screen is not reading its keyboard yet. */
274
+ const settled = (name) => { const kept = live.get(name); return kept ? { openFor: Date.now() - kept.openedAt, quietFor: Date.now() - kept.outputAt } : null; };
275
+ return { seats, drop, warm, type, settled, open: (name) => live.has(name), droppedAt: (name) => dropped.get(name) };
271
276
  }
package/server/door.mjs CHANGED
@@ -158,13 +158,13 @@ export async function handleDoor(request, response, { devices, conversations, re
158
158
  if (request.method === 'GET' && route === '/shortcuts') { need(part.terminal || part.chat === 'write', 'the terminal or the chat'); return send(200, shortcuts.list()); }
159
159
  /* What is dictated goes into the terminal, or into the chat's box: one of the two must be its to write in. */
160
160
  const speaks = part.terminal || part.chat === 'write';
161
- if (request.method === 'GET' && route === '/voice') return send(200, { available: voice !== undefined && speaks, engine: speaks ? voice?.model ?? null : null });
161
+ if (request.method === 'GET' && route === '/voice') return send(200, { available: voice?.current() !== undefined && speaks, engine: speaks ? voice?.model ?? null : null });
162
162
  if (request.method === 'POST' && route === '/voice') {
163
163
  need(speaks, 'the terminal or the chat');
164
164
  if (request.headers['content-type'] !== 'audio/wav') return send(400, { error: 'A recording is a WAV file.' });
165
165
  const parts = []; let size = 0;
166
166
  for await (const chunk of request) { size += chunk.length; if (size > AUDIO_MAX) throw new Refusal(413, 'The recording is too long.'); parts.push(chunk); }
167
- return send(200, { text: await transcribe(voice, Buffer.concat(parts)) });
167
+ return send(200, { text: voice ? await voice.transcribe(Buffer.concat(parts)) : await transcribe(undefined, Buffer.concat(parts)) });
168
168
  }
169
169
  /* Its conversation's chat, whatever conversation the address names: the device's own. */
170
170
  /* Only for a pairing given its chat: to read it, or to write there too. */
package/server/main.mjs CHANGED
@@ -28,7 +28,7 @@ import { Vms, runner } from './vm.mjs';
28
28
  import { Resources } from './resources.mjs';
29
29
  import { makeGate, makeOriginCheck, readOperators } from './gate.mjs';
30
30
  import { Shortcuts } from './shortcuts.mjs';
31
- import { voiceConfig } from './voice.mjs';
31
+ import { Dictation } from './voice.mjs';
32
32
  import QRCode from 'qrcode';
33
33
  import { Devices } from './devices.mjs';
34
34
  import { handleDoor, deviceTerminal } from './door.mjs';
@@ -221,7 +221,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
221
221
  await toolbox.prepare().catch((error) => console.error(error.message));
222
222
  await all.ensureWork?.();
223
223
  const shortcuts = new Shortcuts(new Store(`${dataDir}/shortcuts.json`));
224
- const voice = voiceConfig(env);
224
+ /* The engine of dictation: chosen in Settings, else the environment's (voice.mjs). */
225
+ const voice = new Dictation({ file: new JsonFile(`${dataDir}/dictation.json`, {}), env, secret: (name) => secrets.reveal(name) });
225
226
  /* Read here already: a device paired before a conversation was shared in
226
227
  two parts keeps the chat it reached then (see devices.mjs). */
227
228
  const talkStore = new Store(`${dataDir}/talk.json`);
@@ -342,7 +343,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
342
343
  /* What the VM manager says each machine has is what is recorded (a record
343
344
  may say otherwise: set by hand, or by an earlier version). Not waited for. */
344
345
  void all.reconcileAll?.().catch(() => {});
345
- const platform = { answerer: answerer.about(), operators: [...operators], address: publicOrigin ?? null, version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version, dictation: voice ? (voice.model ?? 'set') : null };
346
+ const platform = { answerer: answerer.about(), operators: [...operators], address: publicOrigin ?? null, version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version, get dictation() { return voice.current() ? (voice.model ?? 'set') : null; } };
346
347
  /* Who gets what, and a whole setup in one file: see setup.mjs. */
347
348
  /* Settings, Updates: what the updater (deploy/updater.mjs, as root) wrote, and how to ask it. */
348
349
  const updates = new Updates({ host: asHost, version: platform.version, store: new Store(`${dataDir}/updates.json`), alerts, ...(updateState ? { stateFile: updateState } : {}), ...(updateConf ? { confFile: updateConf } : {}) });
@@ -66,6 +66,10 @@ export class Secrets {
66
66
  list() { return this.#store.all().map(({ name, createdAt }) => ({ name, conversations: this.#grants.on(`secret:${name}`).map((g) => g.subject.slice('conversation:'.length)), createdAt })); }
67
67
  of(conversation) { return this.#store.all().filter((s) => this.#handed(conversation, s.name)).map((s) => s.name); }
68
68
 
69
+ /* The value, for this server's own use only (the key of the dictation
70
+ engine, put in a request's header): never returned by any route. */
71
+ reveal(name) { const s = this.#store.get(name); return s ? this.#vault.open(s.sealed) : undefined; }
72
+
69
73
  /* Creates, or replaces the value of, a secret. */
70
74
  set({ name, value }) {
71
75
  if (!isSecretName(name)) throw new Refusal(400, 'A secret is named like a variable: capitals, digits and underscores, starting with a letter.');
package/server/talk.mjs CHANGED
@@ -96,6 +96,16 @@ export function typeCommand(line) {
96
96
  export const BRANCHES = 'Check out your branches in the main folder of the repository or under /work, never in /tmp: a branch can be checked out in one folder only, and /tmp is emptied when the machine restarts.';
97
97
  /* After a restart, the tool is given this long to start before REREAD is typed. */
98
98
  export const REREAD_AFTER_MS = 10_000;
99
+ /* READY, NOT JUST STARTED. The tool starts when its terminal is opened, which
100
+ may be well after the restart was said; and a tool still starting does not
101
+ read its keyboard yet: the line and its Enter then wait together, and are
102
+ read at once as a paste -- the Enter becomes a new line in its input box,
103
+ never sent (seen on a real deployment). REREAD is typed once its terminal
104
+ has been open REREAD_AFTER_MS and nothing has come out of it for
105
+ SETTLED_MS; after READY_MAX_MS open, typed anyway (a tool that never stops
106
+ drawing). */
107
+ export const SETTLED_MS = 2_000;
108
+ export const READY_MAX_MS = 60_000;
99
109
  /* Lines of the machine kept for one conversation, at most. */
100
110
  export const SYSTEM_MAX = 50;
101
111
  /* THE THREAD'S OWN LINES, by kind of event, and how much each matters --
@@ -447,6 +457,14 @@ export class Talk {
447
457
  const quiet = this.#now() - (this.#typedAt.get(name) ?? 0);
448
458
  if (quiet < TYPING_QUIET_MS) return later(TYPING_QUIET_MS - quiet + 100);
449
459
  if (!(await this.#runs(name))) return later(RETRY_MS);
460
+ if (this.#terminal?.settled) {
461
+ await this.#warm(name);
462
+ const s = this.#terminal.settled(name);
463
+ if (s) {
464
+ const wait = Math.max(this.#rereadAfter - s.openFor, s.openFor < READY_MAX_MS ? SETTLED_MS - s.quietFor : 0);
465
+ if (wait > 0) return later(wait + 100);
466
+ }
467
+ }
450
468
  if (!(await this.#type(name, REREAD).catch(() => false))) return later(RETRY_MS);
451
469
  /* Reading the whole thread, it reads what waits too: that line is not typed after it. */
452
470
  const waits = this.#parley(name).unread() > 0;
package/server/voice.mjs CHANGED
@@ -10,7 +10,11 @@
10
10
  * (a line break is Enter), direction marks, zero-width characters -- and the
11
11
  * length is bounded: a dictation is a sentence, not a file.
12
12
  */
13
+ import { lookup as dnsLookup } from 'node:dns/promises';
14
+ import { isIP } from 'node:net';
13
15
  import { Refusal } from './conversations.mjs';
16
+ import { JsonFile } from './store.mjs';
17
+ import { isSecretName } from './secrets.mjs';
14
18
 
15
19
  export const AUDIO_MAX = 512 * 1024;
16
20
  export const TEXT_MAX = 2000;
@@ -77,7 +81,7 @@ export async function transcribe(config, audio, fetcher = fetch) {
77
81
  form.append('response_format', 'json');
78
82
  let response;
79
83
  try {
80
- response = await fetcher(config.url, { method: 'POST', headers: config.key ? { authorization: `Bearer ${config.key}` } : {}, body: form, signal: AbortSignal.timeout(TIMEOUT_MS) });
84
+ response = await fetcher(config.url, { method: 'POST', redirect: 'error', headers: config.key ? { authorization: `Bearer ${config.key}` } : {}, body: form, signal: AbortSignal.timeout(TIMEOUT_MS) });
81
85
  } catch {
82
86
  /* The error of a failed request repeats the address it was sent to:
83
87
  it is not passed on. */
@@ -88,3 +92,118 @@ export async function transcribe(config, audio, fetcher = fetch) {
88
92
  if (typeof body?.text !== 'string') throw new Refusal(502, 'The speech-to-text engine gave no text.');
89
93
  return cleanDictation(body.text);
90
94
  }
95
+
96
+ /* ---------- THE ENGINE CHOSEN IN SETTINGS ----------
97
+ * Settings > Dictation: a provider fills in the address and the model (both
98
+ * may be changed); the key is one of the SECRETS already kept (Assets >
99
+ * Secrets), named, never shown, never sent to a page: it is opened here, at
100
+ * the moment of transcribing, and goes nowhere but in the request's header.
101
+ * What is set there wins; the CLAVARIUM_TRANSCRIPTION_* environment stays
102
+ * the fallback. Applied at once, without a restart.
103
+ *
104
+ * WHERE IT MAY SEND: the address is chosen in a page, and this server would
105
+ * then post to it -- with a key. So: HTTPS only, and never an address of the
106
+ * server's own network (loopback, private, link-local), checked again on what
107
+ * the name resolves to at each use; except with "Other (compatible)", made
108
+ * for an engine of one's own, where a local or private address is allowed,
109
+ * plain HTTP included. */
110
+ export const PROVIDERS = {
111
+ ovh: { label: 'OVH AI Endpoints', url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/audio/transcriptions', model: 'whisper-large-v3-turbo' },
112
+ openai: { label: 'OpenAI', url: 'https://api.openai.com/v1/audio/transcriptions', model: 'whisper-1' },
113
+ groq: { label: 'Groq', url: 'https://api.groq.com/openai/v1/audio/transcriptions', model: 'whisper-large-v3-turbo' },
114
+ mistral: { label: 'Mistral', url: 'https://api.mistral.ai/v1/audio/transcriptions', model: 'voxtral-mini-latest' },
115
+ other: { label: 'Other (compatible)', url: '', model: '' },
116
+ };
117
+ const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
118
+ const MODEL = /^[A-Za-z0-9._:/-]{1,100}$/;
119
+
120
+ /* An address of this server's own network, written as such. */
121
+ export function isPrivateHost(host) {
122
+ const h = String(host).toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
123
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.lan') || h.endsWith('.home.arpa')) return true;
124
+ const kind = isIP(h);
125
+ if (kind === 0) return !h.includes('.');
126
+ if (kind === 6) {
127
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h);
128
+ if (mapped) return isPrivateHost(mapped[1]);
129
+ return h === '::' || h === '::1' || /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
130
+ }
131
+ const [a, b] = h.split('.').map(Number);
132
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
133
+ }
134
+
135
+ /* What is wrong with a choice, in a sentence; undefined when it is fine. */
136
+ export function dictationProblem(c) {
137
+ if (!c || typeof c !== 'object') return 'Choose an engine.';
138
+ if (!Object.hasOwn(PROVIDERS, c.provider)) return 'Choose a provider among those offered.';
139
+ let url;
140
+ try { url = new URL(String(c.url ?? '')); } catch { return 'The address is not a web address.'; }
141
+ if (url.username || url.password) return 'The address cannot carry a name or a password: the key is a secret.';
142
+ const own = isPrivateHost(url.hostname);
143
+ if (own && c.provider !== 'other') return 'An address of this server\'s own network is allowed only with "Other (compatible)", for an engine of your own.';
144
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && own)) return 'The address must start with https:// (plain http only for an engine on this server\'s own network).';
145
+ if (!MODEL.test(String(c.model ?? ''))) return 'Give the name of a model.';
146
+ if (c.secret !== undefined && c.secret !== '' && !isSecretName(c.secret)) return 'The key is the name of a secret.';
147
+ if (c.language !== undefined && c.language !== '' && !LANGUAGE.test(String(c.language))) return 'The language is a code such as en or fr, or automatic.';
148
+ return undefined;
149
+ }
150
+
151
+ export class Dictation {
152
+ #file; #env; #secret; #lookup; #fetcher;
153
+ /* `secret(name)`: the value of a secret, or undefined. `lookup(host)`: the addresses a name resolves to. */
154
+ constructor({ file = new JsonFile(undefined, {}), env = process.env, secret = () => undefined, lookup = (host) => dnsLookup(host, { all: true }), fetcher = fetch } = {}) {
155
+ this.#file = file; this.#env = env; this.#secret = secret; this.#lookup = lookup; this.#fetcher = fetcher;
156
+ }
157
+ #saved() { const kept = this.#file.get(); return kept?.on === true && !dictationProblem(kept) ? kept : undefined; }
158
+ #configOf(c) {
159
+ return { url: c.url, model: c.model, language: c.language || undefined, key: c.secret ? this.#secret(c.secret) : undefined, provider: c.provider };
160
+ }
161
+ /* What transcribes now: the one set in Settings, else the environment's. */
162
+ current() {
163
+ const saved = this.#saved();
164
+ return saved ? this.#configOf(saved) : voiceConfig(this.#env);
165
+ }
166
+ get model() { return this.current()?.model; }
167
+ /* For the page: never a key, nor the environment's address (it may carry one). */
168
+ about() {
169
+ const saved = this.#file.get() ?? {};
170
+ const env = voiceConfig(this.#env);
171
+ return {
172
+ on: this.#saved() !== undefined,
173
+ provider: saved.provider ?? null, url: saved.url ?? '', model: saved.model ?? '', secret: saved.secret ?? '', language: saved.language ?? '',
174
+ secretMissing: Boolean(saved.secret) && this.#secret(saved.secret) === undefined,
175
+ env: env ? { model: env.model } : null,
176
+ source: this.#saved() ? 'settings' : env ? 'env' : null,
177
+ providers: Object.entries(PROVIDERS).map(([id, p]) => ({ id, ...p })),
178
+ };
179
+ }
180
+ #checked(body) {
181
+ const c = { provider: body?.provider, url: String(body?.url ?? '').trim(), model: String(body?.model ?? '').trim(), secret: String(body?.secret ?? '').trim(), language: String(body?.language ?? '').trim() };
182
+ const problem = dictationProblem(c);
183
+ if (problem) throw new Refusal(400, problem);
184
+ if (c.secret && this.#secret(c.secret) === undefined) throw new Refusal(400, 'No secret has this name: add it in Assets, Secrets.');
185
+ return c;
186
+ }
187
+ set(body) { this.#file.set({ ...this.#checked(body), on: true }); return this.about(); }
188
+ /* Turned off: the environment's engine, if any, takes over again. The choice is kept, to be turned on again. */
189
+ off() { const kept = this.#file.get() ?? {}; this.#file.set({ ...kept, on: false }); return this.about(); }
190
+ /* A name resolving to this server's own network is refused unless "Other" was chosen. */
191
+ async #guard(config) {
192
+ if (!config.provider || config.provider === 'other') return;
193
+ const host = new URL(config.url).hostname;
194
+ let addresses;
195
+ try { addresses = isIP(host.replace(/^\[|\]$/g, '')) ? [{ address: host }] : await this.#lookup(host); } catch { throw new Refusal(502, 'The speech-to-text engine cannot be reached.'); }
196
+ if (addresses.some((a) => isPrivateHost(a.address))) throw new Refusal(400, 'This address leads into this server\'s own network: only "Other (compatible)" may.');
197
+ }
198
+ async transcribe(audio) {
199
+ const config = this.current();
200
+ if (config) await this.#guard(config);
201
+ return transcribe(config, audio, this.#fetcher);
202
+ }
203
+ /* "Try it": what is in the form, not saved, on one short recording. */
204
+ async try(body, audio) {
205
+ const config = this.#configOf(this.#checked(body));
206
+ await this.#guard(config);
207
+ return transcribe(config, audio, this.#fetcher);
208
+ }
209
+ }
package/web/app.js CHANGED
@@ -2515,7 +2515,6 @@
2515
2515
  opened shows its files (the Files sheet, moved in), a repository its
2516
2516
  branches. On a phone, the list, then one asset with "All assets". */
2517
2517
  const ASSET_KINDS = [['git', 'Git repositories', 'git-branch'], ['folder', 'Folders', 'folder'], ['mailbox', 'Mailboxes', 'mail'], ['secret', 'Secrets', 'key-round']];
2518
- const byKind = (items) => ASSET_KINDS.flatMap(([k]) => items.filter((i) => i.kind === k));
2519
2518
  const getJson = (path, fallback) => fetch(`${API}${path}`).then((r) => (r.ok ? r.json() : fallback)).catch(() => fallback);
2520
2519
  const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
2521
2520
  const kindIcon = (kind) => { const name = ASSET_KINDS.find((k) => k[0] === kind)?.[2]; const holder = el('span', 'as-ic'); holder.dataset.kind = kind; const svg = name ? window.clavariumIcons?.icon(name) : null; if (svg) holder.append(svg); return holder; };
@@ -2644,6 +2643,9 @@
2644
2643
  filesHome.parent.insertBefore(f, filesHome.next);
2645
2644
  }
2646
2645
 
2646
+ /* Nothing is opened by itself: the wide detail says what it is for. */
2647
+ const noAssetChosen = () => el('p', 'hint as-none', 'Select an asset to see its detail.');
2648
+
2647
2649
  /* ---- a conversation's own ---- */
2648
2650
  async function paintRoomAssets() {
2649
2651
  if (!assetsShown) return;
@@ -2660,7 +2662,7 @@
2660
2662
  sub: kind === 'git' ? gitSub(r) : kind === 'mailbox' ? boxSub(box) : 'folder', tone: box?.failure ? 'bad' : '' });
2661
2663
  }
2662
2664
  for (const s2 of secrets.filter((x) => x.conversations.includes(current))) items.push({ key: `secret:${s2.name}`, kind: 'secret', name: s2.name, secret: s2, sub: 'name only · used by its agent' });
2663
- const chosen = items.find((i) => i.key === roomPick) ?? (narrow() ? null : byKind(items)[0] ?? null);
2665
+ const chosen = items.find((i) => i.key === roomPick) ?? null;
2664
2666
  const as = $('cassets-as');
2665
2667
  as.dataset.open = chosen ? 'detail' : 'list';
2666
2668
  paintAsList($('cassets-rows'), items, { chosen: chosen?.key, choose: (key) => pickRoomAsset(key), empty: 'Nothing is given to it yet.' });
@@ -2668,7 +2670,7 @@
2668
2670
  $('cassets-rows').prepend(top);
2669
2671
  if (c?.work === 'disk') $('cassets-rows').append(el('p', 'hint as-disk', 'Its work is on the disk of its machine: nothing of it is kept here.'));
2670
2672
  const detail = $('cassets-detail');
2671
- if (!chosen) { unembedFiles(); detail.replaceChildren(); return; }
2673
+ if (!chosen) { unembedFiles(); detail.replaceChildren(); if (items.length) detail.append(noAssetChosen()); return; }
2672
2674
  const keepFiles = (chosen.kind === 'folder' || chosen.kind === 'mailbox') && $('files').classList.contains('embedded') && detail.contains($('files')) && detail.dataset.asset === chosen.key;
2673
2675
  if (!keepFiles) { unembedFiles(); detail.replaceChildren(); }
2674
2676
  detail.dataset.asset = chosen.key;
@@ -2730,7 +2732,7 @@
2730
2732
  }
2731
2733
  for (const s2 of secrets) items.push({ key: `secret:${s2.name}`, kind: 'secret', name: s2.name, secret: s2, badge: String(s2.conversations.length), badgeTitle: `Used by ${s2.conversations.length} conversation${s2.conversations.length === 1 ? '' : 's'}`, badgeTone: 'count', sub: 'name only' });
2732
2734
  const shown = assetsQuery ? items.filter((i) => i.name.includes(assetsQuery.toLowerCase()) || i.name.toLowerCase().includes(assetsQuery.toLowerCase())) : items;
2733
- const chosen = assetsAdding ? null : items.find((i) => i.key === assetOpen) ?? (narrow() ? null : byKind(shown)[0] ?? null);
2735
+ const chosen = assetsAdding ? null : items.find((i) => i.key === assetOpen) ?? null;
2734
2736
  $('assets-as').dataset.open = chosen || assetsAdding ? 'detail' : 'list';
2735
2737
  paintAsList($('assets-rows'), shown, { chosen: chosen?.key, choose: (key) => { placeAssetStep(); assetOpen = key; assetsAdding = ''; remoteEditing = false; remember(); void paintAssets(); }, add: (kind) => { placeAssetStep(); assetsAdding = kind; void paintAssets(); }, empty: assetsQuery ? 'Nothing by that name.' : '' });
2736
2738
  const detail = $('assets-detail');
@@ -2767,7 +2769,7 @@
2767
2769
  focusField(form.querySelector('input:not([type=radio])'));
2768
2770
  return;
2769
2771
  }
2770
- if (!chosen) return;
2772
+ if (!chosen) { if (items.length) detail.append(noAssetChosen()); return; }
2771
2773
  detail.append(asTitle(chosen, back));
2772
2774
  const ready = conversations.filter((c) => c.phase === 'ready');
2773
2775
  const r = chosen.resource;
@@ -3166,15 +3168,96 @@
3166
3168
  remove.addEventListener('click', async () => { await post(`${API}/shortcuts/${shortcut.id}/remove`).catch(() => {}); await openSettings(); void paintShortcuts(); });
3167
3169
  row.append(label, remove); list.append(row);
3168
3170
  }
3169
- $('settings-dictation').textContent = platform.dictation ? `Speech is turned into text by "${platform.dictation}". It is set on the server (CLAVARIUM_TRANSCRIPTION_URL).` : 'No speech-to-text engine is set on the server: Dictate is not offered.';
3171
+ void paintDictation();
3172
+ /* With no shortcut, one line: how to add one (this is the only place to remove them). */
3173
+ $('settings-shortcuts-hint').textContent = saved.length ? 'The ready-made answers under the terminal. They are added there ("+ Shortcut"); they are removed here.' : 'No shortcut yet: add one under the terminal, with "+ Shortcut".';
3170
3174
  $('settings-people').textContent = (platform.operators ?? []).length
3171
- ? `Who may sign in: ${platform.operators.join(', ')}. Address: ${platform.address ?? 'not declared'}. Both were set when this was installed.`
3175
+ ? `Who may sign in: ${platform.operators.join(', ')} (set when this was installed).`
3172
3176
  : 'Development mode: anyone on this machine is the operator.';
3177
+ $('settings-address').textContent = `Address: ${platform.address ?? 'not declared'}.`;
3173
3178
  $('settings-about').textContent = `Clavarium ${platform.version ?? ''}`;
3174
3179
  void paintInstance();
3175
3180
  await Promise.all([paintHosts(), paintShelf(), paintServer(), paintUpdates()]);
3176
3181
  }
3177
3182
 
3183
+ /* ---------- Dictation, in Settings: the engine, chosen here (server/voice.mjs) ---------- */
3184
+ let dictationProviders = [];
3185
+ const dictationChoice = () => {
3186
+ const lang = $('dictation-language').value;
3187
+ return { provider: $('dictation-provider').value, url: $('dictation-url').value.trim(), model: $('dictation-model').value.trim(), secret: $('dictation-secret').value,
3188
+ language: lang === 'other' ? $('dictation-language-code').value.trim() : lang };
3189
+ };
3190
+ const dictationSay = (error, saved = '') => {
3191
+ $('dictation-error').textContent = error ?? ''; $('dictation-error').hidden = !error;
3192
+ $('dictation-saved').textContent = saved;
3193
+ };
3194
+ const showLanguage = (code) => {
3195
+ const known = [...$('dictation-language').options].some((o) => o.value === code && code !== 'other');
3196
+ $('dictation-language').value = known ? code : 'other';
3197
+ $('dictation-language-code').value = known ? '' : code;
3198
+ $('dictation-language-code').hidden = known;
3199
+ };
3200
+ async function paintDictation() {
3201
+ const [about, secrets] = await Promise.all([getJson('/dictation', null), getJson('/secrets', [])]);
3202
+ if (!about) { $('settings-dictation').textContent = 'Dictation cannot be set from here.'; $('dictation-form').hidden = true; return; }
3203
+ $('settings-dictation').textContent = about.source === 'settings'
3204
+ ? `On: speech is turned into text by "${about.model}", chosen here.${about.env ? ' The engine set on the server (CLAVARIUM_TRANSCRIPTION_URL) is the fallback when this is turned off.' : ''}`
3205
+ : about.source === 'env'
3206
+ ? `On: speech is turned into text by "${about.env.model}", set on the server (CLAVARIUM_TRANSCRIPTION_URL). An engine chosen here takes its place.`
3207
+ : 'Off: no speech-to-text engine, so Dictate is not offered. Choose one below.';
3208
+ if (about.secretMissing) dictationSay(`The secret "${about.secret}" is gone: choose another key.`);
3209
+ dictationProviders = about.providers;
3210
+ const provider = $('dictation-provider');
3211
+ if (provider.options.length === 0) for (const p of about.providers) { const o = document.createElement('option'); o.value = p.id; o.textContent = p.label; provider.append(o); }
3212
+ const secret = $('dictation-secret');
3213
+ secret.replaceChildren(secret.options[0] ?? Object.assign(document.createElement('option'), { value: '', textContent: 'No key' }));
3214
+ for (const s2 of secrets) { const o = document.createElement('option'); o.value = s2.name; o.textContent = s2.name; secret.append(o); }
3215
+ if ($('dictation-form').contains(document.activeElement)) return;
3216
+ provider.value = about.provider ?? 'ovh';
3217
+ const first = dictationProviders.find((p) => p.id === provider.value);
3218
+ $('dictation-url').value = about.provider ? about.url : first?.url ?? '';
3219
+ $('dictation-model').value = about.provider ? about.model : first?.model ?? '';
3220
+ secret.value = [...secret.options].some((o) => o.value === about.secret) ? about.secret : '';
3221
+ showLanguage(about.language ?? '');
3222
+ $('dictation-other-hint').hidden = provider.value !== 'other';
3223
+ $('dictation-off').hidden = about.source !== 'settings';
3224
+ }
3225
+ $('dictation-provider').addEventListener('change', () => {
3226
+ const p = dictationProviders.find((x) => x.id === $('dictation-provider').value);
3227
+ if (p) { $('dictation-url').value = p.url; $('dictation-model').value = p.model; }
3228
+ $('dictation-other-hint').hidden = $('dictation-provider').value !== 'other';
3229
+ });
3230
+ $('dictation-language').addEventListener('change', () => { $('dictation-language-code').hidden = $('dictation-language').value !== 'other'; });
3231
+ $('dictation-form').addEventListener('submit', async (event) => {
3232
+ event.preventDefault();
3233
+ try { await post(`${API}/dictation`, dictationChoice()); document.activeElement?.blur(); dictationSay(null, 'Saved: in use now.'); void paintDictation(); }
3234
+ catch (error) { dictationSay(error.message); }
3235
+ });
3236
+ $('dictation-off').addEventListener('click', async () => {
3237
+ try { await post(`${API}/dictation/off`); dictationSay(null, 'Turned off.'); void paintDictation(); } catch (error) { dictationSay(error.message); }
3238
+ });
3239
+ /* "Try it": a few seconds from this microphone, sent to the engine in the form (nothing saved), its text shown. */
3240
+ let stopTrying = null;
3241
+ const endTry = () => { stopTrying?.(); stopTrying = null; $('dictation-try').textContent = 'Try it'; };
3242
+ $('dictation-try').addEventListener('click', async () => {
3243
+ if (stopTrying) { endTry(); return; }
3244
+ dictationSay(null);
3245
+ const heard = $('dictation-heard');
3246
+ if (!window.clavariumVoice) { dictationSay('The microphone cannot be used on this page.'); return; }
3247
+ const to = `${API}/dictation/try?${new URLSearchParams(dictationChoice())}`;
3248
+ const words = { calibrating: 'Listening to the room...', listening: 'Speak now: a short sentence.', hearing: 'Hearing...', transcribing: 'Writing...' };
3249
+ try {
3250
+ heard.hidden = false; heard.textContent = 'Opening the microphone...';
3251
+ const stop = await window.clavariumVoice.listen({
3252
+ state: (w) => { if (stopTrying) heard.textContent = words[w] ?? w; },
3253
+ phrase: (text) => { endTry(); heard.textContent = `Heard: ${text}`; },
3254
+ error: (message) => { endTry(); heard.hidden = true; dictationSay(message); },
3255
+ }, { to });
3256
+ stopTrying = stop; $('dictation-try').textContent = 'Stop';
3257
+ setTimeout(() => { if (stopTrying === stop) { endTry(); if (!heard.textContent.startsWith('Heard')) heard.textContent = 'Nothing was heard.'; } }, 15_000);
3258
+ } catch { heard.hidden = true; dictationSay('The microphone could not be opened.'); }
3259
+ });
3260
+
3178
3261
  /* ---------- the name of this Clavarium, in Settings ---------- */
3179
3262
  async function paintInstance() {
3180
3263
  const about = await fetch(`${API}/instance`).then((r) => r.json()).catch(() => null);
package/web/index.html CHANGED
@@ -570,7 +570,7 @@
570
570
  <details class="chapter" id="settings-ch-shortcuts" data-chapter="s-shortcuts">
571
571
  <summary><span class="chapter-title">Shortcuts</span><span class="chapter-about">The ready-made answers under the terminal.</span></summary>
572
572
  <div class="chapter-body">
573
- <p class="hint">The ready-made answers under the terminal. They are added there ("+ Shortcut"); they are removed here.</p>
573
+ <p class="hint" id="settings-shortcuts-hint">The ready-made answers under the terminal. They are added there ("+ Shortcut"); they are removed here.</p>
574
574
  <div id="settings-shortcuts"></div>
575
575
  </div>
576
576
  </details>
@@ -603,16 +603,22 @@
603
603
  <summary><span class="chapter-title">Dictation</span><span class="chapter-about">Speech to text, on this server.</span></summary>
604
604
  <div class="chapter-body">
605
605
  <p id="settings-dictation" class="hint"></p>
606
+ <form id="dictation-form" autocomplete="off">
607
+ <div class="row"><label for="dictation-provider">Provider</label><select id="dictation-provider"></select></div>
608
+ <div class="row"><label for="dictation-url">Address</label><input id="dictation-url" type="url" spellcheck="false" placeholder="https://.../v1/audio/transcriptions"></div>
609
+ <div class="row"><label for="dictation-model">Model</label><input id="dictation-model" spellcheck="false" maxlength="100"></div>
610
+ <div class="row"><label for="dictation-secret">Key</label><select id="dictation-secret"><option value="">No key</option></select></div>
611
+ <div class="row"><label for="dictation-language">Language</label><select id="dictation-language"><option value="">Automatic</option><option value="en">English (en)</option><option value="fr">French (fr)</option><option value="de">German (de)</option><option value="es">Spanish (es)</option><option value="it">Italian (it)</option><option value="nl">Dutch (nl)</option><option value="pt">Portuguese (pt)</option><option value="other">Another code...</option></select><input id="dictation-language-code" maxlength="8" spellcheck="false" placeholder="code, such as pl" aria-label="Language code" hidden></div>
612
+ <p class="hint" id="dictation-key-hint">The key is one of your secrets (Assets, Secrets), chosen by its name: it is never shown, and only this server uses it, when it transcribes.</p>
613
+ <p class="hint" id="dictation-other-hint" hidden>"Other (compatible)": any engine with the same interface (<code>/v1/audio/transcriptions</code>). Only with it may the address be on this server's own network (localhost, a private address, plain http), for an engine of your own: the others must be public HTTPS addresses.</p>
614
+ <div class="row"><button type="submit" class="primary" id="dictation-save">Save</button><button type="button" id="dictation-try">Try it</button><button type="button" id="dictation-off">Turn off</button><span class="hint" id="dictation-saved" role="status"></span></div>
615
+ <p class="hint" id="dictation-heard" role="status" hidden></p>
616
+ <p class="error" id="dictation-error" role="alert" hidden></p>
617
+ </form>
606
618
  </div>
607
619
  </details>
608
- <details class="chapter" id="settings-ch-people" data-chapter="s-people">
609
- <summary><span class="chapter-title">People &amp; address</span><span class="chapter-about">Who may sign in, and where.</span></summary>
610
- <div class="chapter-body">
611
- <p id="settings-people" class="hint"></p>
612
620
  <!-- THIS SERVER: its disk and memory, what takes them, and the reserve
613
621
  no machine may eat into (server/capacity.mjs). Nothing here wakes a machine. -->
614
- </div>
615
- </details>
616
622
  <details class="chapter" id="settings-server-top" data-chapter="s-resources">
617
623
  <summary><span class="chapter-title">Resources</span><span class="chapter-about">Disk and memory, what takes them, the reserve.</span></summary>
618
624
  <div class="chapter-body">
@@ -703,9 +709,12 @@
703
709
  </div>
704
710
  </details>
705
711
  <details class="chapter" id="settings-ch-about" data-chapter="s-about">
706
- <summary><span class="chapter-title">About</span><span class="chapter-about">The version that runs here.</span></summary>
712
+ <summary><span class="chapter-title">About</span><span class="chapter-about">The version, who may sign in, the address.</span></summary>
707
713
  <div class="chapter-body">
714
+ <!-- Facts only, one line each: a chapter with nothing to act on is not a chapter. -->
708
715
  <p id="settings-about" class="hint"></p>
716
+ <p id="settings-people" class="hint"></p>
717
+ <p id="settings-address" class="hint"></p>
709
718
  </div>
710
719
  </details>
711
720
  </div>
package/web/style.css CHANGED
@@ -1108,6 +1108,7 @@ header .ic, #folders .ic { width: 18px; height: 18px; fill: none; stroke: curren
1108
1108
  .as-form form { display: flex; flex-direction: column; gap: 6px; }
1109
1109
  #gitview-remote-box > summary { display: none; }
1110
1110
  #gitview-remote { display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
1111
+ .as-none { margin: auto; text-align: center; }
1111
1112
  .as-detail .given-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
1112
1113
  /* A folder of the conversation: the Files sheet, moved into the detail. */
1113
1114
  #files.embedded { position: static; inset: auto; z-index: auto; padding: 0; background: transparent; overflow: visible; flex: 1 1 auto; }
package/web/voice.js CHANGED
@@ -9,8 +9,9 @@ const api = () => (document.documentElement.dataset.door === 'device' ? `/c/api/
9
9
 
10
10
  /* Opens the microphone; resolves with a function that closes it.
11
11
  `on.phrase(text)`, `on.state(word)`: calibrating, listening, hearing,
12
- transcribing; `on.error(message)`. */
13
- async function listen(on) {
12
+ transcribing; `on.error(message)`. `to`: where phrases go, if not the
13
+ usual engine (Settings' "Try it" sends them to the one in its form). */
14
+ async function listen(on, { to } = {}) {
14
15
  const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: false, autoGainControl: false } });
15
16
  const context = new (window.AudioContext ?? window.webkitAudioContext)({ sampleRate: RATE });
16
17
  const source = context.createMediaStreamSource(stream);
@@ -24,7 +25,7 @@ async function listen(on) {
24
25
  onPhrase: async (blocks) => {
25
26
  pending++; tell();
26
27
  try {
27
- const r = await fetch(`${api()}/voice`, { method: 'POST', headers: { 'content-type': 'audio/wav' }, body: toWav(blocks) });
28
+ const r = await fetch(to ?? `${api()}/voice`, { method: 'POST', headers: { 'content-type': 'audio/wav' }, body: toWav(blocks) });
28
29
  const j = await r.json().catch(() => ({}));
29
30
  if (!r.ok) throw new Error(j.error || 'The phrase could not be transcribed.');
30
31
  if (!closed && j.text) on.phrase(j.text);