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 +3 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api.mjs +13 -3
- package/server/bridge.mjs +7 -2
- package/server/door.mjs +2 -2
- package/server/main.mjs +4 -3
- package/server/secrets.mjs +4 -0
- package/server/talk.mjs +18 -0
- package/server/voice.mjs +120 -1
- package/web/app.js +90 -7
- package/web/index.html +17 -8
- package/web/style.css +1 -0
- package/web/voice.js +4 -3
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.
|
|
34
|
-
|
|
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,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.0-next.
|
|
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.
|
|
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
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 } : {}) });
|
package/server/secrets.mjs
CHANGED
|
@@ -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) ??
|
|
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) ??
|
|
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
|
-
|
|
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(', ')}
|
|
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 & 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
|
|
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
|
-
|
|
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);
|