nixamp 0.27.2 → 0.28.0
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 +39 -16
- package/backtoschool/dist/assets/ar-DK3LI2hP.js +1 -0
- package/backtoschool/dist/assets/cs-C9cckHFh.js +1 -0
- package/backtoschool/dist/assets/da-BpNIZAZo.js +1 -0
- package/backtoschool/dist/assets/de-C1DX_kDk.js +1 -0
- package/backtoschool/dist/assets/es-bymdOEnB.js +1 -0
- package/backtoschool/dist/assets/fi-p1DAJXf3.js +1 -0
- package/backtoschool/dist/assets/fr-w1qH9bbD.js +1 -0
- package/backtoschool/dist/assets/hi-DnZJakpY.js +1 -0
- package/backtoschool/dist/assets/hu-N7uSyLAk.js +1 -0
- package/backtoschool/dist/assets/id-CX3MVU3E.js +1 -0
- package/backtoschool/dist/assets/index-4LdMSur6.js +93 -0
- package/backtoschool/dist/assets/{index-OE2xWZUH.css → index-D06NfY1L.css} +1 -1
- package/backtoschool/dist/assets/it-CQ0H28FI.js +1 -0
- package/backtoschool/dist/assets/nl-DJqCLHMq.js +1 -0
- package/backtoschool/dist/assets/ru-Rk2yagke.js +1 -0
- package/backtoschool/dist/assets/sv-1r9eOl4-.js +1 -0
- package/backtoschool/dist/assets/uk-CrRsLNHV.js +1 -0
- package/backtoschool/dist/assets/vi-B5kxrjP7.js +1 -0
- package/backtoschool/dist/assets/zh-BB_S8QpG.js +1 -0
- package/backtoschool/dist/index.html +50 -38
- package/dist/admin.js +5 -4
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/i18n.d.ts +26 -0
- package/dist/i18n.js +81 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/locales/ar.d.ts +201 -0
- package/dist/locales/ar.js +200 -0
- package/dist/locales/cs.d.ts +201 -0
- package/dist/locales/cs.js +200 -0
- package/dist/locales/da.d.ts +201 -0
- package/dist/locales/da.js +200 -0
- package/dist/locales/de.d.ts +201 -0
- package/dist/locales/de.js +200 -0
- package/dist/locales/en.d.ts +201 -0
- package/dist/locales/en.js +201 -0
- package/dist/locales/es.d.ts +201 -0
- package/dist/locales/es.js +200 -0
- package/dist/locales/fi.d.ts +201 -0
- package/dist/locales/fi.js +200 -0
- package/dist/locales/fr.d.ts +201 -0
- package/dist/locales/fr.js +200 -0
- package/dist/locales/hi.d.ts +201 -0
- package/dist/locales/hi.js +200 -0
- package/dist/locales/hu.d.ts +201 -0
- package/dist/locales/hu.js +200 -0
- package/dist/locales/id.d.ts +201 -0
- package/dist/locales/id.js +200 -0
- package/dist/locales/it.d.ts +201 -0
- package/dist/locales/it.js +200 -0
- package/dist/locales/nl.d.ts +201 -0
- package/dist/locales/nl.js +200 -0
- package/dist/locales/ru.d.ts +201 -0
- package/dist/locales/ru.js +200 -0
- package/dist/locales/sv.d.ts +201 -0
- package/dist/locales/sv.js +200 -0
- package/dist/locales/uk.d.ts +201 -0
- package/dist/locales/uk.js +200 -0
- package/dist/locales/vi.d.ts +201 -0
- package/dist/locales/vi.js +200 -0
- package/dist/locales/zh.d.ts +201 -0
- package/dist/locales/zh.js +200 -0
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/main.js +26 -12
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset.d.ts +2 -3
- package/dist/password-reset.js +4 -2
- package/dist/server.d.ts +2 -0
- package/dist/server.js +13 -1
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +32 -23
- package/dist/ui-aliases.d.ts +4 -0
- package/dist/ui-aliases.js +41 -0
- package/dist/ui-languages.d.ts +62 -0
- package/dist/ui-languages.js +32 -0
- package/package.json +1 -1
- package/src/admin.ts +5 -4
- package/src/event-writer.ts +126 -0
- package/src/i18n.ts +74 -0
- package/src/live-api.ts +26 -2
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/locales/ar.ts +201 -0
- package/src/locales/cs.ts +201 -0
- package/src/locales/da.ts +201 -0
- package/src/locales/de.ts +201 -0
- package/src/locales/en.ts +201 -0
- package/src/locales/es.ts +201 -0
- package/src/locales/fi.ts +201 -0
- package/src/locales/fr.ts +201 -0
- package/src/locales/hi.ts +201 -0
- package/src/locales/hu.ts +201 -0
- package/src/locales/id.ts +201 -0
- package/src/locales/it.ts +201 -0
- package/src/locales/nl.ts +201 -0
- package/src/locales/ru.ts +201 -0
- package/src/locales/sv.ts +201 -0
- package/src/locales/uk.ts +201 -0
- package/src/locales/vi.ts +201 -0
- package/src/locales/zh.ts +201 -0
- package/src/mail-sender.ts +12 -0
- package/src/main.ts +25 -12
- package/src/notify.ts +10 -7
- package/src/password-reset.ts +5 -5
- package/src/server.ts +14 -1
- package/src/shared-translation.ts +27 -19
- package/src/ui-aliases.ts +42 -0
- package/src/ui-languages.ts +31 -0
- package/web/dist/assets/ar-DK3LI2hP.js +1 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/cs-C9cckHFh.js +1 -0
- package/web/dist/assets/da-BpNIZAZo.js +1 -0
- package/web/dist/assets/de-C1DX_kDk.js +1 -0
- package/web/dist/assets/es-bymdOEnB.js +1 -0
- package/web/dist/assets/fi-p1DAJXf3.js +1 -0
- package/web/dist/assets/fr-w1qH9bbD.js +1 -0
- package/web/dist/assets/hi-DnZJakpY.js +1 -0
- package/web/dist/assets/{hls-3VKVEQE3-CAL4qBEq.js → hls-3VKVEQE3-DURNoQ5W.js} +1 -1
- package/web/dist/assets/hu-N7uSyLAk.js +1 -0
- package/web/dist/assets/id-CX3MVU3E.js +1 -0
- package/web/dist/assets/index-BXJlt2r6.js +3 -0
- package/web/dist/assets/index-Brm9OAsr.css +1 -0
- package/web/dist/assets/it-CQ0H28FI.js +1 -0
- package/web/dist/assets/{mpegts-H9oC2fAK.js → mpegts-BdyqUrVC.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Bkd9Sqr6.js → mpegts-LO6RVLD6-yGogIzG9.js} +1 -1
- package/web/dist/assets/nl-DJqCLHMq.js +1 -0
- package/web/dist/assets/ru-Rk2yagke.js +1 -0
- package/web/dist/assets/sv-1r9eOl4-.js +1 -0
- package/web/dist/assets/uk-CrRsLNHV.js +1 -0
- package/web/dist/assets/vi-B5kxrjP7.js +1 -0
- package/web/dist/assets/zh-BB_S8QpG.js +1 -0
- package/web/dist/index.html +132 -131
- package/web/dist/sw.js +24 -7
- package/backtoschool/dist/assets/index-pQ_uH4bZ.js +0 -93
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-CLeEWlms.js +0 -3
- package/web/dist/assets/index-D34dTY18.css +0 -1
package/dist/server.js
CHANGED
|
@@ -66,6 +66,7 @@ import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
|
66
66
|
import { Durable } from "./durable.js";
|
|
67
67
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
68
68
|
import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.js";
|
|
69
|
+
import { EventWriter } from "./event-writer.js";
|
|
69
70
|
import { handleLiveApi } from "./live-api.js";
|
|
70
71
|
import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
|
|
71
72
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
|
|
@@ -1448,6 +1449,7 @@ export function createHandler(engine, options) {
|
|
|
1448
1449
|
return;
|
|
1449
1450
|
if (options.events && await handleLiveApi(request, response, url, {
|
|
1450
1451
|
events: options.events,
|
|
1452
|
+
...(options.eventWriter ? { eventWriter: options.eventWriter } : {}),
|
|
1451
1453
|
...(options.accounts ? { accounts: options.accounts } : {}),
|
|
1452
1454
|
...(options.layouts ? { layouts: options.layouts } : {}),
|
|
1453
1455
|
...(options.rooms ? { rooms: options.rooms } : {}),
|
|
@@ -5599,6 +5601,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5599
5601
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
5600
5602
|
const settingsSync = pool ? new SettingsSync(pool) : undefined;
|
|
5601
5603
|
const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
|
|
5604
|
+
const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
|
|
5605
|
+
const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
|
|
5606
|
+
apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
|
|
5607
|
+
} : undefined;
|
|
5602
5608
|
const events = pool ? new LiveEvents(pool) : undefined;
|
|
5603
5609
|
const layouts = pool ? new Layouts(pool) : undefined;
|
|
5604
5610
|
const rooms = pool ? new Rooms(pool) : undefined;
|
|
@@ -6118,7 +6124,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6118
6124
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
6119
6125
|
...(favorites ? { favorites } : {}),
|
|
6120
6126
|
...(settingsSync ? { settingsSync } : {}),
|
|
6121
|
-
...(events ? { events
|
|
6127
|
+
...(events ? { events, eventWriter: new EventWriter({
|
|
6128
|
+
openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
|
|
6129
|
+
openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
|
|
6130
|
+
}) } : {}),
|
|
6122
6131
|
...(layouts ? { layouts } : {}),
|
|
6123
6132
|
...(rooms ? { rooms } : {}),
|
|
6124
6133
|
...(trollbox ? { trollbox } : {}),
|
|
@@ -6135,6 +6144,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6135
6144
|
resetMail: resendPasswordReset({
|
|
6136
6145
|
apiKey: process.env["RESEND_API_KEY"],
|
|
6137
6146
|
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6147
|
+
backtoschool: schoolMail,
|
|
6138
6148
|
}),
|
|
6139
6149
|
} : {}),
|
|
6140
6150
|
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
@@ -6159,6 +6169,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6159
6169
|
email: resendEmail({
|
|
6160
6170
|
apiKey: process.env["RESEND_API_KEY"],
|
|
6161
6171
|
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6172
|
+
backtoschool: schoolMail,
|
|
6173
|
+
reason: "You received this email because a host invited you to join a live session.",
|
|
6162
6174
|
onEvent: (message) => console.log(message),
|
|
6163
6175
|
}),
|
|
6164
6176
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** One live source/language pipeline, paid access for every listening account.
|
|
2
2
|
* Browsers receive the same PCM, never upload a replacement for a public feed. */
|
|
3
|
+
import { transientAudioError } from "./live-recovery.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { lookup } from "node:dns/promises";
|
|
5
6
|
import { BlockList, isIP } from "node:net";
|
|
@@ -179,15 +180,15 @@ export class SharedTranslations {
|
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
|
-
stop(feed, error) {
|
|
183
|
-
this.broadcast(feed, { type: "error", error });
|
|
183
|
+
stop(feed, error, retryable = false) {
|
|
184
|
+
this.broadcast(feed, { type: "error", error, retryable });
|
|
184
185
|
for (const member of [...feed.members]) {
|
|
185
186
|
this.leave(feed, member);
|
|
186
187
|
member.close();
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
make(id, source, language) {
|
|
190
|
-
const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
|
|
191
|
+
const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
|
|
191
192
|
// Each account uses free access first, then the same published credit rate.
|
|
192
193
|
// Additional listeners do not trigger recognition or synthesis.
|
|
193
194
|
feed.meter = {
|
|
@@ -254,13 +255,10 @@ export class SharedTranslations {
|
|
|
254
255
|
language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
|
|
255
256
|
status: text => { if (!feed.controller.signal.aborted && !feed.members.size)
|
|
256
257
|
this.stop(feed, text); },
|
|
257
|
-
failed:
|
|
258
|
+
failed: message => this.stop(feed, message),
|
|
258
259
|
lines: lines => {
|
|
259
260
|
feed.queue.push(...lines);
|
|
260
|
-
|
|
261
|
-
this.stop(feed, "Live translation fell behind. Enable it again to retry.");
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
261
|
+
feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
|
|
264
262
|
if (!feed.speaking)
|
|
265
263
|
void this.speak(feed);
|
|
266
264
|
},
|
|
@@ -296,7 +294,7 @@ export class SharedTranslations {
|
|
|
296
294
|
samples[index] = pcm.readInt16LE(index * 2) / 32768;
|
|
297
295
|
feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
|
|
298
296
|
}
|
|
299
|
-
}, () => this.stop(feed, "The live source
|
|
297
|
+
}, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
|
|
300
298
|
if (feed.controller.signal.aborted)
|
|
301
299
|
input.stop();
|
|
302
300
|
else
|
|
@@ -312,24 +310,35 @@ export class SharedTranslations {
|
|
|
312
310
|
while (feed.queue.length && !feed.controller.signal.aborted) {
|
|
313
311
|
const line = feed.queue.shift();
|
|
314
312
|
if (Date.now() - line.until > 12_000)
|
|
315
|
-
|
|
316
|
-
const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
|
|
317
|
-
const id = randomUUID();
|
|
318
|
-
this.broadcast(feed, { type: "line", id, line });
|
|
319
|
-
const reader = response.body.getReader();
|
|
313
|
+
continue;
|
|
320
314
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
315
|
+
const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
|
|
316
|
+
const id = randomUUID();
|
|
317
|
+
this.broadcast(feed, { type: "line", id, line });
|
|
318
|
+
const reader = response.body.getReader();
|
|
319
|
+
try {
|
|
320
|
+
while (true) {
|
|
321
|
+
const { done, value } = await reader.read();
|
|
322
|
+
if (done)
|
|
323
|
+
break;
|
|
324
|
+
for (let at = 0; at < value.length; at += 16_384)
|
|
325
|
+
this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
|
|
326
|
+
}
|
|
327
327
|
}
|
|
328
|
+
finally {
|
|
329
|
+
reader.releaseLock();
|
|
330
|
+
this.broadcast(feed, { type: "end", id });
|
|
331
|
+
}
|
|
332
|
+
feed.voiceFailures = 0;
|
|
328
333
|
}
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (feed.controller.signal.aborted)
|
|
336
|
+
return;
|
|
337
|
+
if (!transientAudioError(error) || ++feed.voiceFailures >= 3)
|
|
338
|
+
throw error;
|
|
339
|
+
// A provider failure skips this phrase; the source and other listeners
|
|
340
|
+
// remain connected. Never replay partial speech or retry a charge.
|
|
331
341
|
}
|
|
332
|
-
this.broadcast(feed, { type: "end", id });
|
|
333
342
|
}
|
|
334
343
|
}
|
|
335
344
|
catch (error) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Existing copy can use a concise shared label in translated interfaces.
|
|
2
|
+
* English remains verbatim. Never use these aliases for user-authored content. */
|
|
3
|
+
export const aliases = {
|
|
4
|
+
'■ STOPPED': 'Stopped', '▶ PLAYING': 'Playing', 'LOADING': 'Loading…',
|
|
5
|
+
'no source': 'No source', 'not connected': 'Not connected', 'Now playing': 'Now Playing',
|
|
6
|
+
'● GO LIVE': 'Go live', '● Go live': 'Go live', '⤓ Download': 'Download',
|
|
7
|
+
'A man\'s voice': 'Male voice', 'A woman\'s voice': 'Female voice',
|
|
8
|
+
'Catalogs on this server': 'Catalogs', 'Catalog folders': 'Catalogs',
|
|
9
|
+
'Parties on this server': 'Parties', 'Servers streaming now': 'Directory',
|
|
10
|
+
'Playlist (0)': 'Playlist', 'Playlist folders': 'Playlist', 'Playlist pages': 'Playlist',
|
|
11
|
+
'Filter these files': 'Filter', 'Filter these entries': 'Filter',
|
|
12
|
+
'Filter the entries': 'Filter', 'Filter the playlist': 'Filter',
|
|
13
|
+
'Create a free account': 'Create account', 'Create one': 'Create account',
|
|
14
|
+
'Create your account': 'Create account', 'New here? Create an account': 'Create account',
|
|
15
|
+
'Already have an account? Sign in': 'Sign in', 'Your NixAmp account': 'Your account',
|
|
16
|
+
'Your public handle': 'Handle', 'handle': 'Handle', 'password': 'Password',
|
|
17
|
+
'Phone number for texts': 'Phone number', 'Back to the player': 'Back',
|
|
18
|
+
'Share what is playing': 'Share', 'Share this stream': 'Share',
|
|
19
|
+
'Link to this stream': 'Link', 'Copy this stream\'s URL': 'Copy link',
|
|
20
|
+
'A link to play': 'Link', 'A server\'s link': 'Server', 'Close translated audio purchase': 'Close',
|
|
21
|
+
'Open secure checkout ↗ (new tab)': 'Open checkout', 'Checking…': 'Loading…',
|
|
22
|
+
'Browse the directory': 'Directory', 'See what is live': 'Live now',
|
|
23
|
+
'Join live': 'Join', 'Raise Hand': 'Raise hand', 'Hand Raises': 'Hand raises',
|
|
24
|
+
'Hide this': 'Hide', 'Log': 'Activity log', 'Status:': 'Status',
|
|
25
|
+
'Nixamp player': 'Playback controls', 'Nixamp audio and video player': 'Playback controls',
|
|
26
|
+
'What is being said': 'Transcript', 'The room\'s chat': 'Chat', 'Trollbox': 'Chat',
|
|
27
|
+
'A line for the trollbox': 'Chat message', 'Say something to the room…': 'Chat message',
|
|
28
|
+
'Send it': 'Send', 'Something to add': 'Chat message', 'What to call it': 'Catalog name',
|
|
29
|
+
'call it something': 'Catalog name', 'An m3u catalog to add': 'Catalogs',
|
|
30
|
+
'On this device': 'Play', 'Play on this device': 'Play', 'What this is': 'About',
|
|
31
|
+
'Looking for servers…': 'Loading…', 'Finding today’s conversations…': 'Loading…',
|
|
32
|
+
'Coming up': 'Upcoming', 'Listen after class': 'Replay', 'Schedule live': 'Schedule class',
|
|
33
|
+
'Schedule a class': 'Schedule class', 'Plan a conversation': 'Schedule class',
|
|
34
|
+
'Your classroom': 'Event', 'Join in': 'Join', 'Open a room →': 'Start a room',
|
|
35
|
+
'Chat message': 'Chat message', 'Add to the conversation': 'Chat message',
|
|
36
|
+
'Create live': 'Create live', 'Host stage': 'Stage', 'Your host': 'Host',
|
|
37
|
+
'Class host': 'Host', 'Hand raised ✓': 'Raise hand', 'Copied ✓': 'Copied',
|
|
38
|
+
'Screen share with Pairux ↗': 'Screen share', 'Upload & broadcast in Nixamp ↗': 'Upload and broadcast',
|
|
39
|
+
'Open Music Folder…': 'Open folder', 'Choose a music folder': 'Open folder',
|
|
40
|
+
'Main navigation': 'Panels', 'Skip to a section': 'Panels',
|
|
41
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The UI and audio-translation menus share the same supported targets. */
|
|
2
|
+
export declare const UI_LANGUAGES: readonly [{
|
|
3
|
+
readonly code: 'en';
|
|
4
|
+
readonly label: 'English';
|
|
5
|
+
}, {
|
|
6
|
+
readonly code: 'de';
|
|
7
|
+
readonly label: 'Deutsch';
|
|
8
|
+
}, {
|
|
9
|
+
readonly code: 'sv';
|
|
10
|
+
readonly label: 'Svenska';
|
|
11
|
+
}, {
|
|
12
|
+
readonly code: 'es';
|
|
13
|
+
readonly label: 'Español';
|
|
14
|
+
}, {
|
|
15
|
+
readonly code: 'fr';
|
|
16
|
+
readonly label: 'Français';
|
|
17
|
+
}, {
|
|
18
|
+
readonly code: 'it';
|
|
19
|
+
readonly label: 'Italiano';
|
|
20
|
+
}, {
|
|
21
|
+
readonly code: 'nl';
|
|
22
|
+
readonly label: 'Nederlands';
|
|
23
|
+
}, {
|
|
24
|
+
readonly code: 'da';
|
|
25
|
+
readonly label: 'Dansk';
|
|
26
|
+
}, {
|
|
27
|
+
readonly code: 'fi';
|
|
28
|
+
readonly label: 'Suomi';
|
|
29
|
+
}, {
|
|
30
|
+
readonly code: 'ru';
|
|
31
|
+
readonly label: 'Русский';
|
|
32
|
+
}, {
|
|
33
|
+
readonly code: 'uk';
|
|
34
|
+
readonly label: 'Українська';
|
|
35
|
+
}, {
|
|
36
|
+
readonly code: 'cs';
|
|
37
|
+
readonly label: 'Čeština';
|
|
38
|
+
}, {
|
|
39
|
+
readonly code: 'hu';
|
|
40
|
+
readonly label: 'Magyar';
|
|
41
|
+
}, {
|
|
42
|
+
readonly code: 'zh';
|
|
43
|
+
readonly label: '中文';
|
|
44
|
+
}, {
|
|
45
|
+
readonly code: 'ar';
|
|
46
|
+
readonly label: 'العربية';
|
|
47
|
+
}, {
|
|
48
|
+
readonly code: 'hi';
|
|
49
|
+
readonly label: 'हिन्दी';
|
|
50
|
+
}, {
|
|
51
|
+
readonly code: 'vi';
|
|
52
|
+
readonly label: 'Tiếng Việt';
|
|
53
|
+
}, {
|
|
54
|
+
readonly code: 'id';
|
|
55
|
+
readonly label: 'Bahasa Indonesia';
|
|
56
|
+
}];
|
|
57
|
+
export type UiLanguage = typeof UI_LANGUAGES[number]['code'];
|
|
58
|
+
export declare const UI_LANGUAGE_KEY = "nixamp.ui.language";
|
|
59
|
+
export declare function uiLanguage(value: unknown): UiLanguage;
|
|
60
|
+
/** A saved choice wins; otherwise use the first supported system preference. */
|
|
61
|
+
export declare function preferredUiLanguage(saved: unknown, system?: readonly unknown[]): UiLanguage;
|
|
62
|
+
export declare function terminalUiLanguage(env: Record<string, string | undefined>): UiLanguage;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The UI and audio-translation menus share the same supported targets. */
|
|
2
|
+
export const UI_LANGUAGES = [
|
|
3
|
+
{ code: 'en', label: 'English' }, { code: 'de', label: 'Deutsch' },
|
|
4
|
+
{ code: 'sv', label: 'Svenska' }, { code: 'es', label: 'Español' },
|
|
5
|
+
{ code: 'fr', label: 'Français' }, { code: 'it', label: 'Italiano' },
|
|
6
|
+
{ code: 'nl', label: 'Nederlands' }, { code: 'da', label: 'Dansk' },
|
|
7
|
+
{ code: 'fi', label: 'Suomi' }, { code: 'ru', label: 'Русский' },
|
|
8
|
+
{ code: 'uk', label: 'Українська' }, { code: 'cs', label: 'Čeština' },
|
|
9
|
+
{ code: 'hu', label: 'Magyar' }, { code: 'zh', label: '中文' },
|
|
10
|
+
{ code: 'ar', label: 'العربية' }, { code: 'hi', label: 'हिन्दी' },
|
|
11
|
+
{ code: 'vi', label: 'Tiếng Việt' }, { code: 'id', label: 'Bahasa Indonesia' },
|
|
12
|
+
];
|
|
13
|
+
export const UI_LANGUAGE_KEY = 'nixamp.ui.language';
|
|
14
|
+
export function uiLanguage(value) {
|
|
15
|
+
const code = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_.@]/)[0] : '';
|
|
16
|
+
return UI_LANGUAGES.find(language => language.code === code)?.code ?? 'en';
|
|
17
|
+
}
|
|
18
|
+
/** A saved choice wins; otherwise use the first supported system preference. */
|
|
19
|
+
export function preferredUiLanguage(saved, system = []) {
|
|
20
|
+
const match = (value) => {
|
|
21
|
+
const code = typeof value === 'string' ? value.trim().toLowerCase().split(/[-_.@]/)[0] : '';
|
|
22
|
+
return UI_LANGUAGES.find(language => language.code === code)?.code;
|
|
23
|
+
};
|
|
24
|
+
return match(saved) ?? system.map(match).find(Boolean) ?? 'en';
|
|
25
|
+
}
|
|
26
|
+
export function terminalUiLanguage(env) {
|
|
27
|
+
if (env['NIXAMP_UI_LANGUAGE'])
|
|
28
|
+
return uiLanguage(env['NIXAMP_UI_LANGUAGE']);
|
|
29
|
+
if (env['LC_ALL'])
|
|
30
|
+
return preferredUiLanguage(null, [env['LC_ALL']]);
|
|
31
|
+
return preferredUiLanguage(null, env['LANGUAGE']?.split(':') ?? [env['LC_MESSAGES'] || env['LANG']]);
|
|
32
|
+
}
|
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as uiMessage } from "./i18n.ts";
|
|
1
2
|
/**
|
|
2
3
|
* `nixamp admin` — what the daemon is doing, and who is listening to it.
|
|
3
4
|
*
|
|
@@ -261,7 +262,7 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
261
262
|
const now = report?.now ?? Date.now();
|
|
262
263
|
|
|
263
264
|
ui.row({ size: 7, gap: 1 }, (row) => {
|
|
264
|
-
row.panel({ title: "Server" }, (p) => {
|
|
265
|
+
row.panel({ title: uiMessage("Server") }, (p) => {
|
|
265
266
|
p.text(view.url, { fg: theme.primary });
|
|
266
267
|
p.label(snapshot?.root ?? "—");
|
|
267
268
|
p.keyValues([
|
|
@@ -271,7 +272,7 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
271
272
|
]);
|
|
272
273
|
});
|
|
273
274
|
|
|
274
|
-
row.panel({ title: "Now playing" }, (p) => {
|
|
275
|
+
row.panel({ title: uiMessage("Now playing") }, (p) => {
|
|
275
276
|
const track = snapshot ? snapshot.tracks[snapshot.index] : undefined;
|
|
276
277
|
p.text(track?.title ?? "nothing", { fg: theme.accent });
|
|
277
278
|
p.label(track?.artist || "—");
|
|
@@ -368,9 +369,9 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
368
369
|
|
|
369
370
|
ui.statusBar({
|
|
370
371
|
items: [
|
|
371
|
-
{ key: "a", label: "Add" },
|
|
372
|
+
{ key: "a", label: uiMessage("Add") },
|
|
372
373
|
{ key: "r", label: "Replace" },
|
|
373
|
-
{ key: "q", label: "Quit" },
|
|
374
|
+
{ key: "q", label: uiMessage("Quit") },
|
|
374
375
|
],
|
|
375
376
|
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
|
376
377
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Guard } from "./guard.ts";
|
|
2
|
+
|
|
3
|
+
export interface EventDraft { title: string; description: string; topic: string }
|
|
4
|
+
export interface WriterInput extends EventDraft {
|
|
5
|
+
hostName: string; startsAt: string; timezone: string; duration: string; recurrence: string;
|
|
6
|
+
}
|
|
7
|
+
export interface EventDraftResult { draft: EventDraft; provider: "openai" | "claude" }
|
|
8
|
+
export class EventWriterError extends Error {
|
|
9
|
+
constructor(message: string, readonly status: number, readonly retryAfter?: number) { super(message); }
|
|
10
|
+
}
|
|
11
|
+
class Refused extends EventWriterError {
|
|
12
|
+
constructor() { super("The writer could not draft these notes. Try rephrasing the event details.", 422); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LIMITS = {title: 160, description: 5000, topic: 100, hostName: 100, startsAt: 40, timezone: 100, duration: 20, recurrence: 20} as const;
|
|
16
|
+
export function writerInput(value: unknown): WriterInput {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new EventWriterError("Send event details as an object.", 400);
|
|
18
|
+
const source = value as Record<string, unknown>;
|
|
19
|
+
const result = {} as WriterInput;
|
|
20
|
+
for (const key of Object.keys(LIMITS) as Array<keyof WriterInput>) {
|
|
21
|
+
const field = source[key] ?? "";
|
|
22
|
+
if (typeof field !== "string" || field.length > LIMITS[key]) throw new EventWriterError(`The ${key} field is too long or is not text.`, 422);
|
|
23
|
+
result[key] = field.trim();
|
|
24
|
+
}
|
|
25
|
+
if (!result.title && !result.description && !result.topic) throw new EventWriterError("Add a title, description, or topic first, then click Write with AI.", 422);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SCHEMA = {
|
|
30
|
+
type: "object", properties: { title: {type: "string"}, description: {type: "string"}, topic: {type: "string"} },
|
|
31
|
+
required: ["title", "description", "topic"], additionalProperties: false,
|
|
32
|
+
};
|
|
33
|
+
const INSTRUCTIONS = `You write useful, welcoming event details for BackToSchool.help, a live learning platform.
|
|
34
|
+
Use the supplied form text as the writing prompt. Rough notes and requests in that text describe what the host wants to teach.
|
|
35
|
+
Return a clear title (at most 160 characters), a description (at most 5000 characters, usually 100–250 words), and a short topic (at most 100 characters).
|
|
36
|
+
Write in the language of the host's notes. Describe what students will learn and what will happen, using only information supported by the notes.
|
|
37
|
+
Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
|
|
38
|
+
The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
|
|
39
|
+
|
|
40
|
+
function draftFrom(text: string): EventDraft {
|
|
41
|
+
const value: unknown = JSON.parse(text);
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid draft");
|
|
43
|
+
const row = value as Record<string, unknown>;
|
|
44
|
+
const draft = {} as EventDraft;
|
|
45
|
+
for (const key of ["title", "description", "topic"] as const) {
|
|
46
|
+
const field = row[key];
|
|
47
|
+
if (typeof field !== "string" || !field.trim() || field.length > LIMITS[key]) throw new Error("invalid draft");
|
|
48
|
+
draft[key] = field.trim();
|
|
49
|
+
}
|
|
50
|
+
return draft;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EventWriterOptions {
|
|
54
|
+
openaiKey?: string; anthropicKey?: string;
|
|
55
|
+
openaiModel?: string; claudeModel?: string;
|
|
56
|
+
fetch?: typeof globalThis.fetch;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
61
|
+
export class EventWriter {
|
|
62
|
+
private readonly guard = new Guard();
|
|
63
|
+
private readonly active = new Set<string>();
|
|
64
|
+
constructor(private readonly options: EventWriterOptions) {}
|
|
65
|
+
|
|
66
|
+
async draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult> {
|
|
67
|
+
if (!accountId) throw new EventWriterError("Sign in to use the AI writer.", 401);
|
|
68
|
+
const input = writerInput(value);
|
|
69
|
+
if (!this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
|
|
70
|
+
if (this.active.has(accountId)) throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
|
|
71
|
+
if (this.active.size >= 4) throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
|
|
72
|
+
const limit = this.guard.check(accountId, {allowed: 6, windowMs: 10 * 60_000});
|
|
73
|
+
if (!limit.ok) throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
|
|
74
|
+
this.active.add(accountId);
|
|
75
|
+
try {
|
|
76
|
+
for (const provider of ["openai", "claude"] as const) {
|
|
77
|
+
const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
|
|
78
|
+
if (!key) continue;
|
|
79
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
80
|
+
try {
|
|
81
|
+
const draft = await this.generate(provider, key, input, signal);
|
|
82
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
83
|
+
return {draft, provider};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
87
|
+
// Refusals are a response to the content, not a provider outage.
|
|
88
|
+
if (error instanceof Refused) throw error;
|
|
89
|
+
// Never log prompts, credentials, or a provider's raw response body.
|
|
90
|
+
console.warn(`nixamp: event writer ${provider} request failed`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new EventWriterError("The AI writer could not finish this draft. Your text is unchanged; please try again.", 503);
|
|
94
|
+
} finally { this.active.delete(accountId); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async generate(provider: "openai" | "claude", key: string, input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
|
|
98
|
+
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
|
|
99
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
100
|
+
const prompt = JSON.stringify(input);
|
|
101
|
+
const openai = provider === "openai";
|
|
102
|
+
const response = await (this.options.fetch ?? globalThis.fetch)(openai ? "https://api.openai.com/v1/responses" : "https://api.anthropic.com/v1/messages", {
|
|
103
|
+
method: "POST", signal: requestSignal,
|
|
104
|
+
headers: { "content-type": "application/json", ...(openai ? {authorization: `Bearer ${key}`} : {"x-api-key": key, "anthropic-version": "2023-06-01"}) },
|
|
105
|
+
body: JSON.stringify(openai ? {
|
|
106
|
+
model: this.options.openaiModel ?? "gpt-5-mini", store: false,
|
|
107
|
+
instructions: INSTRUCTIONS, input: [{role: "user", content: prompt}],
|
|
108
|
+
reasoning: {effort: "minimal"}, max_output_tokens: 2048,
|
|
109
|
+
text: {format: {type: "json_schema", name: "event_draft", strict: true, schema: SCHEMA}},
|
|
110
|
+
} : {
|
|
111
|
+
model: this.options.claudeModel ?? "claude-haiku-4-5", max_tokens: 2048,
|
|
112
|
+
system: INSTRUCTIONS, messages: [{role: "user", content: prompt}],
|
|
113
|
+
output_config: {format: {type: "json_schema", schema: SCHEMA}},
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error("provider unavailable"); }
|
|
117
|
+
const body = await response.json() as Record<string, unknown>;
|
|
118
|
+
const blocks = openai
|
|
119
|
+
? (Array.isArray(body["output"]) ? body["output"].flatMap(item => item?.type === "message" && Array.isArray(item.content) ? item.content : []) : [])
|
|
120
|
+
: (Array.isArray(body["content"]) ? body["content"] : []);
|
|
121
|
+
if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal")) throw new Refused();
|
|
122
|
+
if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn") throw new Error("incomplete draft");
|
|
123
|
+
const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
|
|
124
|
+
return draftFrom(text);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import en from './locales/en.ts';
|
|
2
|
+
import { aliases } from './ui-aliases.ts';
|
|
3
|
+
import { uiLanguage, type UiLanguage } from './ui-languages.ts';
|
|
4
|
+
export { UI_LANGUAGES, UI_LANGUAGE_KEY, uiLanguage, preferredUiLanguage, terminalUiLanguage } from './ui-languages.ts';
|
|
5
|
+
export type { UiLanguage } from './ui-languages.ts';
|
|
6
|
+
export type Message = keyof typeof en;
|
|
7
|
+
export type Catalogue = Record<Message, string>;
|
|
8
|
+
const loaders = {
|
|
9
|
+
en: async () => ({ default: en }),
|
|
10
|
+
de: () => import('./locales/de.ts'), sv: () => import('./locales/sv.ts'),
|
|
11
|
+
es: () => import('./locales/es.ts'), fr: () => import('./locales/fr.ts'),
|
|
12
|
+
it: () => import('./locales/it.ts'), nl: () => import('./locales/nl.ts'),
|
|
13
|
+
da: () => import('./locales/da.ts'), fi: () => import('./locales/fi.ts'),
|
|
14
|
+
ru: () => import('./locales/ru.ts'), uk: () => import('./locales/uk.ts'),
|
|
15
|
+
cs: () => import('./locales/cs.ts'), hu: () => import('./locales/hu.ts'),
|
|
16
|
+
zh: () => import('./locales/zh.ts'), ar: () => import('./locales/ar.ts'),
|
|
17
|
+
hi: () => import('./locales/hi.ts'), vi: () => import('./locales/vi.ts'),
|
|
18
|
+
id: () => import('./locales/id.ts'),
|
|
19
|
+
} satisfies Record<UiLanguage, () => Promise<{ default: Catalogue }>>;
|
|
20
|
+
/** Independent instances prevent one server request from changing another's locale. */
|
|
21
|
+
export class I18n {
|
|
22
|
+
private code: UiLanguage = 'en';
|
|
23
|
+
get language(): UiLanguage { return this.code; }
|
|
24
|
+
constructor(private readonly load = async (language: UiLanguage): Promise<Catalogue> => (await loaders[language]()).default) {}
|
|
25
|
+
private messages: Catalogue = en;
|
|
26
|
+
private revision = 0;
|
|
27
|
+
private numberFormats = new Map<string, Intl.NumberFormat>();
|
|
28
|
+
private dateFormats = new Map<string, Intl.DateTimeFormat>();
|
|
29
|
+
private listeners = new Set<() => void>();
|
|
30
|
+
async setLanguage(value: unknown): Promise<boolean> {
|
|
31
|
+
const revision = ++this.revision;
|
|
32
|
+
const language = uiLanguage(value);
|
|
33
|
+
const catalogue = await this.load(language);
|
|
34
|
+
if (revision !== this.revision) return false;
|
|
35
|
+
this.code = language;
|
|
36
|
+
this.messages = catalogue;
|
|
37
|
+
this.numberFormats.clear(); this.dateFormats.clear();
|
|
38
|
+
for (const changed of this.listeners) changed();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
onChange(changed: () => void): () => void {
|
|
42
|
+
this.listeners.add(changed);
|
|
43
|
+
return () => this.listeners.delete(changed);
|
|
44
|
+
}
|
|
45
|
+
text(source: string, values: Record<string, string | number> = {}): string {
|
|
46
|
+
const key = (this.language !== 'en' && Object.hasOwn(aliases, source) ? aliases[source] : source) as Message;
|
|
47
|
+
let translated = Object.hasOwn(this.messages, key) ? this.messages[key] : source;
|
|
48
|
+
if (this.language !== 'en' && Object.hasOwn(aliases, source)) {
|
|
49
|
+
translated = (source.match(/^[▶■●⤓]\s+/)?.[0] ?? '') + translated + (source.endsWith(' ✓') ? ' ✓' : '');
|
|
50
|
+
}
|
|
51
|
+
return translated.replace(/\{([\w]+)\}/g, (original, name: string) => Object.hasOwn(values, name) ? String(values[name]) : original);
|
|
52
|
+
}
|
|
53
|
+
number(value: number, options?: Intl.NumberFormatOptions): string {
|
|
54
|
+
const key = JSON.stringify(options ?? {});
|
|
55
|
+
let formatter = this.numberFormats.get(key);
|
|
56
|
+
if (!formatter) {
|
|
57
|
+
if (this.numberFormats.size >= 64) this.numberFormats.clear();
|
|
58
|
+
formatter = new Intl.NumberFormat(this.language, options); this.numberFormats.set(key, formatter);
|
|
59
|
+
}
|
|
60
|
+
return formatter.format(value);
|
|
61
|
+
}
|
|
62
|
+
date(value: Date | number, options?: Intl.DateTimeFormatOptions): string {
|
|
63
|
+
const key = JSON.stringify(options ?? {});
|
|
64
|
+
let formatter = this.dateFormats.get(key);
|
|
65
|
+
if (!formatter) {
|
|
66
|
+
if (this.dateFormats.size >= 64) this.dateFormats.clear();
|
|
67
|
+
formatter = new Intl.DateTimeFormat(this.language, options); this.dateFormats.set(key, formatter);
|
|
68
|
+
}
|
|
69
|
+
return formatter.format(value);
|
|
70
|
+
}
|
|
71
|
+
get direction(): 'ltr' | 'rtl' { return this.language === 'ar' ? 'rtl' : 'ltr'; }
|
|
72
|
+
}
|
|
73
|
+
export const i18n = new I18n();
|
|
74
|
+
export const t = (source: string, values?: Record<string, string | number>): string => i18n.text(source, values);
|
package/src/live-api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventWriterError, type EventWriter } from "./event-writer.ts";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import { tokenFrom, type Account, type Accounts } from "./accounts.ts";
|
|
3
4
|
import {
|
|
@@ -26,6 +27,7 @@ import { HAND_RAISE_STATES, RoomError, type Rooms } from "./rooms.ts";
|
|
|
26
27
|
|
|
27
28
|
export interface LiveApiOptions {
|
|
28
29
|
events: LiveEvents;
|
|
30
|
+
eventWriter?: EventWriter;
|
|
29
31
|
accounts?: Accounts;
|
|
30
32
|
layouts?: Layouts;
|
|
31
33
|
rooms?: Rooms;
|
|
@@ -52,13 +54,13 @@ function json(response: ServerResponse, code: number, body: unknown): void {
|
|
|
52
54
|
response.end(value);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
57
|
+
async function body(request: IncomingMessage, limit = 64 * 1024): Promise<Record<string, unknown>> {
|
|
56
58
|
const chunks: Buffer[] = [];
|
|
57
59
|
let size = 0;
|
|
58
60
|
for await (const chunk of request) {
|
|
59
61
|
const buffer = chunk as Buffer;
|
|
60
62
|
size += buffer.length;
|
|
61
|
-
if (size >
|
|
63
|
+
if (size > limit) throw new LiveEventError("body too large", 413);
|
|
62
64
|
chunks.push(buffer);
|
|
63
65
|
}
|
|
64
66
|
try {
|
|
@@ -256,6 +258,21 @@ export async function handleLiveApi(
|
|
|
256
258
|
!path.startsWith("/api/v1/layout-presets")) return false;
|
|
257
259
|
|
|
258
260
|
try {
|
|
261
|
+
if (path === "/api/v1/events/ai-draft") {
|
|
262
|
+
if (request.method !== "POST") { json(response, 405, {error: "POST only"}); return true; }
|
|
263
|
+
const account = await requiredAccount(request, response, options);
|
|
264
|
+
if (!account) return true;
|
|
265
|
+
if (!options.eventWriter) { json(response, 503, {error: "The AI writer is temporarily unavailable. You can keep writing here."}); return true; }
|
|
266
|
+
const input = await body(request, 12 * 1024);
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const closed = () => controller.abort();
|
|
269
|
+
response.once("close", closed);
|
|
270
|
+
try {
|
|
271
|
+
const result = await options.eventWriter.draft(account.id, input, controller.signal);
|
|
272
|
+
if (!response.destroyed) json(response, 200, result);
|
|
273
|
+
} finally { response.off("close", closed); }
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
259
276
|
if (path === "/api/v1/events") {
|
|
260
277
|
if (request.method === "GET") {
|
|
261
278
|
const account = await signedIn(request, options.accounts);
|
|
@@ -624,6 +641,13 @@ export async function handleLiveApi(
|
|
|
624
641
|
json(response, 404, { error: "no such endpoint" });
|
|
625
642
|
return true;
|
|
626
643
|
} catch (error) {
|
|
644
|
+
if (error instanceof EventWriterError) {
|
|
645
|
+
if (!response.destroyed) {
|
|
646
|
+
if (error.retryAfter) response.setHeader("retry-after", String(error.retryAfter));
|
|
647
|
+
json(response, error.status, {error: error.message});
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
627
651
|
if (error instanceof LiveEventError || error instanceof LayoutError || error instanceof RoomError) {
|
|
628
652
|
json(response, error.status, { error: error.message });
|
|
629
653
|
return true;
|