@audiofool/moondog 0.2.2 → 0.3.0-beta.10
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/NOTICE +12 -0
- package/README.md +4 -2
- package/licenses/stability-ai-community-license.md +58 -0
- package/package.json +3 -1
- package/scripts/moondog.mjs +171 -7
- package/src/core/apple-projection-domain-services.mjs +14 -0
- package/src/core/capability-catalog.mjs +104 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +12 -0
- package/src/core/moondog-application.mjs +754 -23
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +3 -0
- package/src/i18n/messages/en.mjs +4 -0
- package/src/i18n/messages/es.mjs +3 -0
- package/src/i18n/messages/fr.mjs +3 -0
- package/src/i18n/messages/it.mjs +3 -0
- package/src/i18n/messages/ja.mjs +3 -0
- package/src/i18n/messages/ko.mjs +3 -0
- package/src/i18n/messages/pt.mjs +3 -0
- package/src/i18n/messages/zh-Hant.mjs +3 -0
- package/src/i18n/messages/zh.mjs +3 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +118 -0
- package/src/i18n/screen/ja.mjs +118 -0
- package/src/i18n/screen/pt.mjs +118 -0
- package/src/i18n/screen/zh.mjs +118 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +68 -11
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +96 -0
- package/src/integrations/public-playlists.mjs +10 -4
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +855 -0
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/profile/listening-history-store.mjs +1 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +9 -0
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +206 -0
- package/src/runtime/pi/agent-runtime.mjs +600 -38
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -0
- package/src/surfaces/cli/format-output.mjs +1 -0
- package/src/surfaces/cli/tui.mjs +467 -19
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { LOCALES, N_, intlLocale, liveScreenTranslator, normalizeLocale, translator } from "../../i18n/index.mjs";
|
|
2
3
|
import {
|
|
3
4
|
CombinedAutocompleteProvider,
|
|
4
5
|
Container,
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
setBrandText,
|
|
54
55
|
} from "./brand-components.mjs";
|
|
55
56
|
|
|
57
|
+
// What making a track says in the footer, stage by stage (src/integrations/stable-audio).
|
|
58
|
+
const generationStages = { starting: N_("warming up"), reading: N_("reading your taste"), composing: N_("composing"),
|
|
59
|
+
decoding: N_("pressing the record"), writing: N_("saving it") };
|
|
60
|
+
|
|
56
61
|
// The lines Moondog shows, as a radio host, while the model thinks; the window has the same ones.
|
|
57
62
|
export const hostLines = (tr) => [tr("Reading the room"), tr("Checking the request line"), tr("Finding the groove"), tr("Working the decks"),
|
|
58
63
|
tr("Planning the segue"), tr("Mic check"), tr("Riding the faders"), tr("Finding the cue")];
|
|
@@ -120,6 +125,9 @@ const slashCommands = [
|
|
|
120
125
|
{ name: "endpoint", description: N_("Add a model service by its address") },
|
|
121
126
|
{ name: "web", description: N_("Look things up on public music sites") },
|
|
122
127
|
{ name: "service", description: N_("Choose where Moondog plays music") },
|
|
128
|
+
{ name: "library", description: N_("Your saved songs and playlists") },
|
|
129
|
+
{ name: "generation", description: N_("Let Moondog make music on this Mac") },
|
|
130
|
+
{ name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
|
|
123
131
|
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
124
132
|
{ name: "skip", description: N_("Skip to the next song preview") },
|
|
125
133
|
{ name: "stop", description: N_("Stop the song previews") },
|
|
@@ -223,6 +231,8 @@ export async function runMoondogTui({
|
|
|
223
231
|
// The terminal's preview player. The room shows what it plays (subscribe) and
|
|
224
232
|
// gives the listener /skip and /stop.
|
|
225
233
|
previewPlayer,
|
|
234
|
+
// Making music on this Mac with Stable Audio 3: { status, check, save }, or nothing where the room can't play it.
|
|
235
|
+
generation,
|
|
226
236
|
environment = process.env,
|
|
227
237
|
}) {
|
|
228
238
|
if (providers === listPiProviders && models === listPiModels) {
|
|
@@ -238,7 +248,11 @@ export async function runMoondogTui({
|
|
|
238
248
|
// /skip and /stop exist only where the room controls the previews itself.
|
|
239
249
|
const previewControls = typeof previewPlayer?.subscribe === "function" &&
|
|
240
250
|
typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
|
|
241
|
-
|
|
251
|
+
// /onair needs both: the room's own player and music making here.
|
|
252
|
+
const onAirControls = previewControls && typeof generation?.save === "function";
|
|
253
|
+
const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
|
|
254
|
+
(name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls) &&
|
|
255
|
+
(name !== "library" || application.libraryReady?.() === true));
|
|
242
256
|
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
243
257
|
let activeRuntime = runtime;
|
|
244
258
|
application.startNewSession?.("tui_start");
|
|
@@ -282,6 +296,8 @@ export async function runMoondogTui({
|
|
|
282
296
|
let firstRunNote = null;
|
|
283
297
|
let firstRunSetup = null;
|
|
284
298
|
let firstRunTicket = 0;
|
|
299
|
+
// The skip being saved, which the room waits for before it closes.
|
|
300
|
+
let firstRunSaving = null;
|
|
285
301
|
let stopWatchingFirstRun = null;
|
|
286
302
|
let importPage = "start";
|
|
287
303
|
let importReturnHome = false;
|
|
@@ -303,6 +319,7 @@ export async function runMoondogTui({
|
|
|
303
319
|
let busy = false;
|
|
304
320
|
// A model chosen or a sign-in made in the window while a turn ran here; it applies once the turn ends.
|
|
305
321
|
let modelChangedElsewhere = false;
|
|
322
|
+
let modelAskedFor = null;
|
|
306
323
|
// A reply another Moondog window or terminal is writing in this conversation: { id, response, text }.
|
|
307
324
|
let elsewhere = null;
|
|
308
325
|
let watchTimer = null;
|
|
@@ -315,7 +332,24 @@ export async function runMoondogTui({
|
|
|
315
332
|
: Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
|
|
316
333
|
// The preview now playing sits on its own line above the draft.
|
|
317
334
|
let nowPlaying = null;
|
|
318
|
-
|
|
335
|
+
// Moondog's stream as the core shows it: null, or { phase, started_at, ends_at, tracks_made }.
|
|
336
|
+
let onAir = application.streamView?.() ?? null;
|
|
337
|
+
// Between two of the stream's tracks nothing plays, and the line says the next one is being made.
|
|
338
|
+
const dryGap = () => !nowPlaying && onAir?.phase === "waiting";
|
|
339
|
+
const nowPlayingRows = () => ((nowPlaying || dryGap()) && !profileView && !importView && !firstRunView ? 1 : 0);
|
|
340
|
+
// Stable Audio 3's setup as the core shows it, here or from the window; a running download has its own line too.
|
|
341
|
+
let setupStatus = null;
|
|
342
|
+
const downloading = () => setupStatus?.job && !setupStatus.job.error ? setupStatus.job : null;
|
|
343
|
+
const downloadRows = () => (downloading() && !profileView && !importView && !firstRunView ? 1 : 0);
|
|
344
|
+
// When the stream ends at the latest, as a clock time: an upper bound, so never a countdown. The clock
|
|
345
|
+
// follows the screen's language, as the words around it do.
|
|
346
|
+
const onAirUntil = () => {
|
|
347
|
+
const ends = new Date(onAir?.ends_at);
|
|
348
|
+
let time;
|
|
349
|
+
try { time = new Intl.DateTimeFormat(intlLocale(tr.locale), { hour: "numeric", minute: "2-digit" }).format(ends); }
|
|
350
|
+
catch { time = ends.toTimeString().slice(0, 5); }
|
|
351
|
+
return tr("on air until {time}", { time });
|
|
352
|
+
};
|
|
319
353
|
let cancelWork = null;
|
|
320
354
|
let cancellationRequested = false;
|
|
321
355
|
let workStartedAt = 0;
|
|
@@ -364,7 +398,7 @@ export async function runMoondogTui({
|
|
|
364
398
|
};
|
|
365
399
|
const sleeve = new RecordSleeve({ terminal, getTheme, environment, getState: () => ({
|
|
366
400
|
focused: homeFocused, selected: homeSelected, motionEnabled,
|
|
367
|
-
editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows(),
|
|
401
|
+
editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows() + downloadRows(),
|
|
368
402
|
}), getNextLyric: () => {
|
|
369
403
|
try {
|
|
370
404
|
homeLyric = homeLyricSeeds ? lyrics.selectHome(homeLyricSeeds) : null;
|
|
@@ -430,7 +464,7 @@ export async function runMoondogTui({
|
|
|
430
464
|
render(width) {
|
|
431
465
|
const lines = conversation.render(width);
|
|
432
466
|
const footerRows = terminal.rows >= 20 ? 2 : 1;
|
|
433
|
-
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - (editor.rowCount || 3));
|
|
467
|
+
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - downloadRows() - (editor.rowCount || 3));
|
|
434
468
|
while (lines.length < bodyRows) lines.push(paintBrandLine("", width, theme));
|
|
435
469
|
return lines;
|
|
436
470
|
},
|
|
@@ -470,19 +504,27 @@ export async function runMoondogTui({
|
|
|
470
504
|
render(width) {
|
|
471
505
|
if (profileView || importView || firstRunView) return [];
|
|
472
506
|
const lines = [];
|
|
473
|
-
if (nowPlaying) {
|
|
474
|
-
const { track, position, count, type } = nowPlaying;
|
|
507
|
+
if (nowPlaying || dryGap()) {
|
|
508
|
+
const { track, position, count, type } = nowPlaying ?? {};
|
|
475
509
|
const prefix = ` ${theme.plain ? ">" : "♪"} `;
|
|
476
510
|
// Where the terminal can't open links, the note in the conversation shows the address.
|
|
477
|
-
const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
511
|
+
const apple = nowPlaying && getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
478
512
|
const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
|
|
479
|
-
|
|
480
|
-
const
|
|
513
|
+
// Once its first track plays, a made track is the stream's, which goes on to the next with /skip.
|
|
514
|
+
const onAirTrack = track?.generated && onAir && onAir.phase !== "starting";
|
|
515
|
+
// A track Moondog made is its own record, kept on this Mac, with nothing to open elsewhere.
|
|
516
|
+
const controls = !nowPlaying ? ` · ${onAirUntil()} · /stop`
|
|
517
|
+
: onAirTrack ? `${state ? ` ${state}` : ""} · ${onAirUntil()} · /skip · /stop`
|
|
518
|
+
: track.generated ? `${state ? ` ${state}` : ""} · /stop`
|
|
519
|
+
: ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
|
|
520
|
+
const song = !nowPlaying ? tr("Cutting the next record")
|
|
521
|
+
: sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
|
|
481
522
|
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
482
523
|
lines.push(paintBrandLine(room >= 12
|
|
483
524
|
? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
|
|
484
525
|
: theme.accent(prefix) + theme.text(truncateToWidth(song, Math.max(1, width - visibleWidth(prefix)), "…")), width, theme));
|
|
485
526
|
}
|
|
527
|
+
if (downloadRows()) lines.push(paintBrandLine(downloadLine(width), width, theme));
|
|
486
528
|
if (messageQueue.length) {
|
|
487
529
|
const hidden = Math.max(0, messageQueue.length - 4);
|
|
488
530
|
const shown = messageQueue.slice(-4);
|
|
@@ -583,6 +625,7 @@ export async function runMoondogTui({
|
|
|
583
625
|
new CombinedAutocompleteProvider(withCommandCompletions(localizedCommands, {
|
|
584
626
|
// Service keys, such as Brave Search, have no models, so only /auth offers them.
|
|
585
627
|
providers: () => [...providers(), ...serviceKeyProviders], models, authProviderIds: supportedAuthProviderIds, tr,
|
|
628
|
+
generationSetup: typeof generation?.setup?.install === "function",
|
|
586
629
|
}), process.cwd(), null),
|
|
587
630
|
);
|
|
588
631
|
tui.setFocus(editor);
|
|
@@ -602,6 +645,7 @@ export async function runMoondogTui({
|
|
|
602
645
|
}
|
|
603
646
|
if (!cleanedUp) tui.requestRender();
|
|
604
647
|
if (!value && modelChangedElsewhere) void followModel();
|
|
648
|
+
if (!value && modelAskedFor) void applyModelAskedFor();
|
|
605
649
|
};
|
|
606
650
|
const setFooter = (text, color = dim) => {
|
|
607
651
|
if (cleanedUp) return;
|
|
@@ -702,17 +746,369 @@ export async function runMoondogTui({
|
|
|
702
746
|
};
|
|
703
747
|
const stopWatchingPreviews = previewControls ? previewPlayer.subscribe((event) => {
|
|
704
748
|
if (cleanedUp) return;
|
|
705
|
-
|
|
749
|
+
// A track Moondog made needs no preview note: the reply names it, and the host's line says where it was made.
|
|
750
|
+
if (event?.type === "queue") { if (!event.tracks?.some((track) => track.generated)) addPreviewNote(event); }
|
|
706
751
|
else if (event?.type === "failed") markPreviewFailed(event);
|
|
707
752
|
else if (["loading", "playing", "paused"].includes(event?.type)) nowPlaying = event;
|
|
708
753
|
else if (event?.type === "stopped") nowPlaying = null;
|
|
709
754
|
tui.requestRender();
|
|
710
755
|
}) : null;
|
|
756
|
+
// The stream redraws the line above the draft as it moves on. Ending by itself, it goes dark
|
|
757
|
+
// without a word; only a track that couldn't be made says so.
|
|
758
|
+
const stopWatchingStream = application.subscribeStream?.(({ view, ended } = {}) => {
|
|
759
|
+
if (cleanedUp) return;
|
|
760
|
+
onAir = view ?? null;
|
|
761
|
+
if (ended === "failed") setFooter(tr("Off air: the next record couldn't be made."), failure);
|
|
762
|
+
tui.requestRender();
|
|
763
|
+
}) ?? null;
|
|
711
764
|
// These act at once, even while Moondog is answering.
|
|
712
765
|
const controlPreviews = (command) => {
|
|
713
|
-
|
|
714
|
-
|
|
766
|
+
// /stop also ends Moondog's stream while its next track is still being made and nothing plays.
|
|
767
|
+
const streamStopped = command === "stop" && application.stopStream?.()?.state === "stopped";
|
|
768
|
+
const acted = command === "stop" ? previewPlayer.stop() || streamStopped : previewPlayer.skip();
|
|
769
|
+
const waiting = !acted && command === "skip" && application.streamView?.()?.phase === "waiting";
|
|
770
|
+
// Off air shows even during a reply: a stream stopped while its first track is made has no row to lose.
|
|
771
|
+
if (!busy || streamStopped) {
|
|
772
|
+
setFooter(waiting ? tr("The next record is still being cut.") : !acted ? tr("No previews are playing.")
|
|
773
|
+
: streamStopped ? tr("Off air.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
|
|
774
|
+
}
|
|
775
|
+
tui.requestRender();
|
|
776
|
+
};
|
|
777
|
+
// /onair goes off air at once while a stream runs, even while Moondog is answering, and says so
|
|
778
|
+
// when music making is off. Otherwise it returns the request to send, in the reply's language.
|
|
779
|
+
const onAirRequest = () => {
|
|
780
|
+
if (application.streamView?.()) {
|
|
781
|
+
application.stopStream();
|
|
782
|
+
// Said even during a reply: a stream stopped while its first track is made has no line to clear.
|
|
783
|
+
setFooter(tr("Off air."));
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
// Music making can be turned off, here or in the window, after the room opened.
|
|
787
|
+
if (!application.streamReady?.()) {
|
|
788
|
+
setFooter(tr("Turn on music making first: /generation on."), warning);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
return translator(application.locale)("onAir.request");
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
|
|
795
|
+
// with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
|
|
796
|
+
// Where the core can set Stable Audio 3 up itself (generation.setup), /generation download, use, remove
|
|
797
|
+
// and cancel manage the models Moondog keeps in its own folder. The window runs the same job, so a
|
|
798
|
+
// download started there shows here too.
|
|
799
|
+
const home = homedir();
|
|
800
|
+
const shownFolder = (folder) => sanitizeTerminalText(String(folder ?? "")).replace(home, "~");
|
|
801
|
+
const setup = typeof generation?.setup?.install === "function" ? generation.setup : null;
|
|
802
|
+
// The two models offered, by the word the listener types; sizes are the weights in all, before the
|
|
803
|
+
// setup reports its own.
|
|
804
|
+
const sa3Models = {
|
|
805
|
+
medium: { name: "Medium", choice: "medium", detail: N_("best sound"), total: 5_179_055_990 },
|
|
806
|
+
"small-music": { name: "Small Music", choice: "small", detail: N_("faster, lighter"), total: 1_704_727_702 },
|
|
807
|
+
};
|
|
808
|
+
const modelArgument = (value) => value === "medium" ? "medium" : ["small", "small-music"].includes(value) ? "small-music" : null;
|
|
809
|
+
const modelName = (model) => sa3Models[model]?.name ?? "Medium";
|
|
810
|
+
const gigabytes = (bytes) => {
|
|
811
|
+
const value = Math.max(0, Number(bytes) || 0) / 1e9;
|
|
812
|
+
try { return new Intl.NumberFormat(intlLocale(tr.locale), { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value); }
|
|
813
|
+
catch { return value.toFixed(1); }
|
|
814
|
+
};
|
|
815
|
+
const modelSize = (model, status = setupStatus) => gigabytes(status?.models?.[model]?.total || sa3Models[model]?.total);
|
|
816
|
+
const setupSteps = { uv: N_("getting uv"), code: N_("getting Stable Audio 3's code"), python: N_("setting up Python"),
|
|
817
|
+
weights: N_("downloading the model"), verifying: N_("checking the files") };
|
|
818
|
+
const jobPercent = (job) => job?.total > 0 ? Math.min(100, Math.floor(((job.received ?? 0) / job.total) * 100)) : null;
|
|
819
|
+
const downloadLine = (width) => {
|
|
820
|
+
const job = downloading();
|
|
821
|
+
const prefix = ` ${theme.plain ? "v" : "↓"} `;
|
|
822
|
+
const percent = jobPercent(job);
|
|
823
|
+
const step = setupSteps[job.step] ? tr.marked(setupSteps[job.step]) : tr("getting ready");
|
|
824
|
+
const text = `Stable Audio 3 ${modelName(job.model)} · ${step}${percent === null ? "" : ` ${percent}%`}`;
|
|
825
|
+
const controls = " · /generation cancel";
|
|
826
|
+
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
827
|
+
return room >= 12 ? theme.accent(prefix) + theme.text(truncateToWidth(text, room, "…")) + theme.faint(controls)
|
|
828
|
+
: theme.accent(prefix) + theme.text(truncateToWidth(text, Math.max(1, width - visibleWidth(prefix)), "…"));
|
|
829
|
+
};
|
|
830
|
+
// One short sentence for a download that stopped on an error, by the setup's code, with the way to try again.
|
|
831
|
+
const setupErrorText = (error) => {
|
|
832
|
+
const code = typeof error === "string" ? error : error?.code;
|
|
833
|
+
if (code === "setup_disk_full") return tr("There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.");
|
|
834
|
+
if (code === "setup_verification_failed") return tr("A downloaded file didn't match the one Stability AI publishes, so Moondog deleted it. Use /generation download to try again.");
|
|
835
|
+
if (code === "setup_offline" || code === "setup_download_failed") return tr("The download couldn't reach its source. Check your connection, then use /generation download to try again.");
|
|
836
|
+
if (code === "setup_python_failed") return tr("Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.");
|
|
837
|
+
if (code === "setup_busy") return tr("Another Moondog window or terminal is already downloading. /generation shows how far it is.");
|
|
838
|
+
const detail = sanitizeTerminalText(String(typeof error === "string" ? error : error?.message ?? code ?? "")).trim();
|
|
839
|
+
return tr("The download didn't finish: {error}. Use /generation download to try again.", { error: detail || tr("unknown error") });
|
|
840
|
+
};
|
|
841
|
+
// The model this room asked for. The core's setup chooses what makes music once it finishes; the
|
|
842
|
+
// terminal reads that choice and says how to switch when it kept another one.
|
|
843
|
+
let setupStarted = null;
|
|
844
|
+
const setupFinished = async (model) => {
|
|
845
|
+
setupStarted = null;
|
|
846
|
+
const status = await generation.check().catch(() => null);
|
|
847
|
+
if (cleanedUp) return;
|
|
848
|
+
const choice = sa3Models[model]?.choice ?? "medium";
|
|
849
|
+
setFooter(status?.state === "ready" && status.managed && status.model === model
|
|
850
|
+
? tr("Stable Audio 3 {model} is ready. Ask for a track.", { model: modelName(model) })
|
|
851
|
+
: tr("Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.", { model: modelName(model), choice }), success);
|
|
852
|
+
};
|
|
853
|
+
const stopWatchingSetup = setup?.subscribe?.((status) => {
|
|
854
|
+
if (cleanedUp) return;
|
|
855
|
+
// The first snapshot is where things stand, not a change: an old failure waits for /generation.
|
|
856
|
+
const first = setupStatus === null;
|
|
857
|
+
const before = setupStatus?.job ?? null;
|
|
858
|
+
setupStatus = status ?? null;
|
|
859
|
+
const job = setupStatus?.job ?? null;
|
|
860
|
+
if (first) { tui.requestRender(); return; }
|
|
861
|
+
if (job?.error && !before?.error) setFooter(setupErrorText(job.error), failure);
|
|
862
|
+
else if (before && !before.error && !job) {
|
|
863
|
+
if (setupStatus?.models?.[before.model]?.state === "ready") void setupFinished(before.model);
|
|
864
|
+
else {
|
|
865
|
+
if (setupStarted === before.model) setupStarted = null;
|
|
866
|
+
setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[before.model]?.choice ?? "medium" }));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
715
869
|
tui.requestRender();
|
|
870
|
+
}) ?? null;
|
|
871
|
+
if (setup) {
|
|
872
|
+
Promise.resolve().then(() => setup.status()).then((status) => {
|
|
873
|
+
if (cleanedUp || setupStatus) return;
|
|
874
|
+
setupStatus = status ?? null;
|
|
875
|
+
tui.requestRender();
|
|
876
|
+
}, () => {});
|
|
877
|
+
}
|
|
878
|
+
// A fresh read for a command. The line above the draft follows subscribe alone, so a job that ends
|
|
879
|
+
// while a command reads still counts as finished.
|
|
880
|
+
const readSetup = async () => await setup.status();
|
|
881
|
+
// What each model is: its size, whether it's here, in use, or on its way.
|
|
882
|
+
const setupSummary = (status, generationStatus) => {
|
|
883
|
+
const lines = Object.entries(sa3Models).map(([model, { name, detail }]) => {
|
|
884
|
+
const entry = status?.models?.[model] ?? { state: "absent" };
|
|
885
|
+
const job = status?.job?.model === model ? status.job : null;
|
|
886
|
+
const percent = jobPercent(job);
|
|
887
|
+
const state = job?.error ? tr("download stopped on an error")
|
|
888
|
+
: job ? percent === null ? tr("downloading") : tr("downloading, {percent}%", { percent })
|
|
889
|
+
: entry.state === "ready"
|
|
890
|
+
? generationStatus?.managed && generationStatus.model === model ? tr("installed, in use") : tr("installed")
|
|
891
|
+
: entry.state === "partial" ? tr("partly downloaded") : tr("not downloaded");
|
|
892
|
+
return `- Stable Audio 3 ${name} · ${tr.marked(detail)} · ${tr("{size} GB", { size: modelSize(model, status) })} · ${state}`;
|
|
893
|
+
});
|
|
894
|
+
const notes = [];
|
|
895
|
+
if (status?.job?.error) notes.push(setupErrorText(status.job.error));
|
|
896
|
+
if (generationStatus && !generationStatus.managed && generationStatus.install === "ready") {
|
|
897
|
+
notes.push(tr("In use: your own install at {folder}.", { folder: shownFolder(generationStatus.path) }));
|
|
898
|
+
}
|
|
899
|
+
notes.push(tr("Get a model with /generation download medium or small. /generation use, remove and cancel manage them."));
|
|
900
|
+
return [lines.join("\n"), ...notes].join("\n\n");
|
|
901
|
+
};
|
|
902
|
+
const generationMessage = (status) => {
|
|
903
|
+
const folder = shownFolder(status?.path);
|
|
904
|
+
if (status?.install === "unsupported") return tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here.");
|
|
905
|
+
if (status?.state === "ready") return tr("Moondog makes music on this Mac with Stable Audio 3, powered by Stability AI. Ask for a track. Stable Audio 3 runs offline, and the track stays on this Mac.");
|
|
906
|
+
// Moondog's own install, or nothing anywhere yet: a download sets it up.
|
|
907
|
+
if (setup && status?.install !== "ready" && (status?.managed || (status?.source === "default" && status?.install === "missing"))) {
|
|
908
|
+
if (status?.state === "off") return tr("Making music on this Mac is off. Get Stable Audio 3 with /generation download medium, or small for a lighter model; music making turns on when it's done.");
|
|
909
|
+
const model = status?.model in sa3Models ? status.model : "medium";
|
|
910
|
+
return tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: modelName(model), choice: sa3Models[model].choice });
|
|
911
|
+
}
|
|
912
|
+
if (status?.state === "off") {
|
|
913
|
+
if (status.install === "ready" && status.managed) return tr("Making music on this Mac is off. Stable Audio 3 {model} is ready; turn it on with /generation on.", { model: modelName(status.model) });
|
|
914
|
+
if (status.install === "ready") return tr("Making music on this Mac is off. Stable Audio 3 is ready at {folder}; turn it on with /generation on.", { folder });
|
|
915
|
+
if (status.install === "incomplete") return tr("Making music on this Mac is off, and Stable Audio 3 at {folder} isn't fully installed. Run ./install.sh --download medium there, then use /generation on.", { folder });
|
|
916
|
+
return tr("Making music on this Mac is off, and Stable Audio 3 isn't at {folder}. Install Stability AI's MLX build from github.com/Stability-AI/stable-audio-3, then use /generation on with its folder.", { folder });
|
|
917
|
+
}
|
|
918
|
+
if (status?.state === "incomplete") {
|
|
919
|
+
return status.missing === "weights"
|
|
920
|
+
? tr("Stable Audio 3 at {folder} doesn't have the Medium model yet. Run ./install.sh --download medium there, then use /generation again.", { folder })
|
|
921
|
+
: tr("Stable Audio 3 at {folder} isn't installed yet. Run ./install.sh --download medium there, then use /generation again.", { folder });
|
|
922
|
+
}
|
|
923
|
+
return tr("Stable Audio 3 isn't at {folder}. Use /generation on with the folder that holds its sa3 script.", { folder });
|
|
924
|
+
};
|
|
925
|
+
const generationFooter = (status) => setFooter(status.state === "ready" ? tr("Music making on.") : status.state === "off" ? tr("Music making off.") : tr("Music making isn't ready."),
|
|
926
|
+
status.state === "ready" || status.state === "off" ? dim : warning);
|
|
927
|
+
const setupUsage = () => tr("Use /generation download, use or remove with medium or small, or /generation cancel.");
|
|
928
|
+
// /generation download, use, remove and cancel. A first download asks for the licenses with --agree;
|
|
929
|
+
// a removal asks once with --yes. The download runs in the background, on its own line above the draft.
|
|
930
|
+
const manageSetup = async (action, rest) => {
|
|
931
|
+
const flags = rest.filter((value) => value.startsWith("--"));
|
|
932
|
+
const values = rest.filter((value) => !value.startsWith("--"));
|
|
933
|
+
const allowed = action === "download" ? ["--agree"] : action === "remove" ? ["--yes"] : [];
|
|
934
|
+
const model = modelArgument(values[0]);
|
|
935
|
+
if ((action === "cancel" ? values.length > 0 : values.length !== 1 || !model) || flags.some((flag) => !allowed.includes(flag))) {
|
|
936
|
+
throw new Error(setupUsage());
|
|
937
|
+
}
|
|
938
|
+
const status = await readSetup();
|
|
939
|
+
if (status?.supported === false) {
|
|
940
|
+
addMoondogMessage(tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here."));
|
|
941
|
+
setFooter(tr("Music making isn't ready."), warning);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const running = status?.job && !status.job.error ? status.job : null;
|
|
945
|
+
if (action === "cancel") {
|
|
946
|
+
if (!running) { setFooter(tr("No download is running.")); return; }
|
|
947
|
+
await setup.cancel();
|
|
948
|
+
setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[running.model]?.choice ?? "medium" }));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const name = modelName(model);
|
|
952
|
+
const { choice } = sa3Models[model];
|
|
953
|
+
const entry = status?.models?.[model] ?? { state: "absent" };
|
|
954
|
+
if (action === "download") {
|
|
955
|
+
if (running) {
|
|
956
|
+
setFooter(running.model === model ? tr("Stable Audio 3 {model} is already downloading.", { model: name })
|
|
957
|
+
: tr("Another download is running. /generation cancel stops it."), running.model === model ? dim : warning);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (entry.state === "ready") {
|
|
961
|
+
addMoondogMessage(tr("Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.", { model: name, choice }));
|
|
962
|
+
setFooter(tr("Already downloaded."));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const accepted = flags.includes("--agree");
|
|
966
|
+
if (!status?.licenses?.accepted && !accepted) {
|
|
967
|
+
addMoondogMessage(tr("Stable Audio 3 comes under the Stability AI Community License ({license}), and its text encoder, T5Gemma, under the Gemma Terms of Use ({gemma}). Moondog downloads it only from Stability AI's official sources and checks every file.\n\nTo agree and download {model} ({size} GB): /generation download {choice} --agree", {
|
|
968
|
+
license: status?.licenses?.links?.find(({ id }) => id === "stability-ai-community")?.url ?? "https://stability.ai/license",
|
|
969
|
+
gemma: status?.licenses?.links?.find(({ id }) => id === "gemma-terms")?.url ?? "https://huggingface.co/stabilityai/stable-audio-3-optimized/blob/main/LICENSE_GEMMA.md",
|
|
970
|
+
model: name, size: modelSize(model, status), choice,
|
|
971
|
+
}));
|
|
972
|
+
setFooter(tr("Waiting for your agreement."));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
setupStarted = model;
|
|
976
|
+
Promise.resolve().then(() => setup.install(model, { accepted })).catch((error) => {
|
|
977
|
+
if (setupStarted === model) setupStarted = null;
|
|
978
|
+
// A cancel ends the job too; the setup's own snapshot says so.
|
|
979
|
+
if (cleanedUp || error?.code === "setup_cancelled") return;
|
|
980
|
+
addMoondogMessage(setupErrorText(error));
|
|
981
|
+
setFooter(tr("The download didn't finish."), failure);
|
|
982
|
+
});
|
|
983
|
+
addMoondogMessage(tr("Downloading Stable Audio 3 {model} ({size} GB) from Stability AI's official sources. It runs in the background; /generation cancel stops it, and a later download picks up where it left off.", {
|
|
984
|
+
model: name, size: modelSize(model, status),
|
|
985
|
+
}));
|
|
986
|
+
setFooter(tr("Downloading Stable Audio 3 {model}.", { model: name }));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (action === "use") {
|
|
990
|
+
if (entry.state !== "ready") {
|
|
991
|
+
addMoondogMessage(tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: name, choice }));
|
|
992
|
+
setFooter(tr("Music making isn't ready."), warning);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const result = await generation.save({ enabled: true, source: "managed", model });
|
|
996
|
+
addMoondogMessage(generationMessage(result));
|
|
997
|
+
generationFooter(result);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
// remove
|
|
1001
|
+
if (running) {
|
|
1002
|
+
addMoondogMessage(tr("Wait for the download to finish, or stop it with /generation cancel, before removing a model."));
|
|
1003
|
+
setFooter(tr("A download is running."), warning);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (entry.state === "absent") {
|
|
1007
|
+
setFooter(tr("Stable Audio 3 {model} isn't on this Mac.", { model: name }));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (!flags.includes("--yes")) {
|
|
1011
|
+
const last = Object.keys(sa3Models).every((other) => other === model || (status?.models?.[other]?.state ?? "absent") === "absent");
|
|
1012
|
+
const size = gigabytes(entry.bytes || entry.total || sa3Models[model].total);
|
|
1013
|
+
addMoondogMessage(last
|
|
1014
|
+
? tr("Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. It's the last model, so the rest of Moondog's Stable Audio 3 setup goes too. To go ahead: /generation remove {choice} --yes", { model: name, size, choice })
|
|
1015
|
+
: tr("Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes", { model: name, size, choice }));
|
|
1016
|
+
setFooter(tr("Nothing removed yet."));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
await setup.remove(model);
|
|
1020
|
+
await readSetup().catch(() => null);
|
|
1021
|
+
const after = await generation.check().catch(() => null);
|
|
1022
|
+
addMoondogMessage(tr("Removed Stable Audio 3 {model} from this Mac.", { model: name }));
|
|
1023
|
+
setFooter(after?.state === "ready" ? tr("Removed.") : tr("Removed. Music making isn't ready."), after?.state === "ready" ? success : warning);
|
|
1024
|
+
};
|
|
1025
|
+
const manageGeneration = async (args) => {
|
|
1026
|
+
const [action, ...rest] = args;
|
|
1027
|
+
if (setup && ["download", "use", "remove", "cancel"].includes(action)) {
|
|
1028
|
+
await manageSetup(action, rest);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if ((action && !["on", "off"].includes(action)) || (action === "off" && rest.length)) {
|
|
1032
|
+
throw new Error(setup ? tr("Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.")
|
|
1033
|
+
: tr("Use /generation, /generation on [folder], or /generation off."));
|
|
1034
|
+
}
|
|
1035
|
+
const status = action ? await generation.save({ enabled: action === "on", ...(rest.length ? { path: rest.join(" ") } : {}) })
|
|
1036
|
+
: await generation.check();
|
|
1037
|
+
const summary = setup && !action && status?.install !== "unsupported" ? await readSetup().then((current) => setupSummary(current, status), () => null) : null;
|
|
1038
|
+
addMoondogMessage(summary ? `${generationMessage(status)}\n\n${summary}` : generationMessage(status));
|
|
1039
|
+
generationFooter(status);
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// /library lists Saved and the listener's folders and playlists, plays one by name, or keeps the song
|
|
1043
|
+
// playing now, in Saved or in a playlist by name (made when there is none). The window shares this library.
|
|
1044
|
+
const manageLibrary = async (args) => {
|
|
1045
|
+
if (!application.libraryReady?.()) throw new Error(tr("The library isn't available here."));
|
|
1046
|
+
const [action, ...rest] = args;
|
|
1047
|
+
const words = rest.join(" ").trim();
|
|
1048
|
+
const nameOf = (playlist) => (playlist.saved ? tr("Saved") : playlist.name);
|
|
1049
|
+
if (!action) {
|
|
1050
|
+
const { folders, playlists } = application.libraryView();
|
|
1051
|
+
const count = (playlist) => tr.n(playlist.count, "{count} song", "{count} songs");
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
const saved = playlists.find((playlist) => playlist.saved);
|
|
1054
|
+
if (saved) lines.push(`- **${nameOf(saved)}** · ${count(saved)}`);
|
|
1055
|
+
const walk = (parentId, depth) => {
|
|
1056
|
+
const nodes = [...folders.filter((folder) => folder.parentId === parentId).map((node) => ["folder", node]),
|
|
1057
|
+
...playlists.filter((playlist) => !playlist.saved && playlist.folderId === parentId).map((node) => ["playlist", node])]
|
|
1058
|
+
.sort((a, b) => a[1].position - b[1].position);
|
|
1059
|
+
for (const [kind, node] of nodes) {
|
|
1060
|
+
const indent = " ".repeat(depth);
|
|
1061
|
+
if (kind === "folder") { lines.push(`${indent}- ${node.name}/`); walk(node.id, depth + 1); }
|
|
1062
|
+
else lines.push(`${indent}- **${node.name}** · ${count(node)}`);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
walk(null, 0);
|
|
1066
|
+
addMoondogMessage(`${lines.join("\n")}\n\n${tr("Play one with `/library play <name>`, or keep the song playing now with `/library save`.")}`);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (action === "play") {
|
|
1070
|
+
const [playlist] = application.findLibraryPlaylists(words);
|
|
1071
|
+
if (!playlist || !words) throw new Error(tr("No playlist is called “{name}”.", { name: words }));
|
|
1072
|
+
const played = await application.playLibrary({ playlistId: playlist.id });
|
|
1073
|
+
if (played.state !== "playing") {
|
|
1074
|
+
addMoondogMessage(played.state === "empty" ? tr("{name} has nothing in it yet.", { name: nameOf(playlist) }) : tr("None of its songs could be found."));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
addMoondogMessage([tr("Playing {name}.", { name: nameOf(playlist) }),
|
|
1078
|
+
played.left_out_made ? tr("Moondog's tracks were left out. They play with Apple Music chosen.") : "",
|
|
1079
|
+
played.not_found ? tr("Some songs couldn't be found.") : ""].filter(Boolean).join(" "));
|
|
1080
|
+
setFooter(tr("Playing {name}.", { name: nameOf(playlist) }), success);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (action === "save") {
|
|
1084
|
+
const target = words ? application.findLibraryPlaylists(words).find((playlist) => playlist.name.toLocaleLowerCase() === words.toLocaleLowerCase() || (playlist.saved && /^saved$/iu.test(words))) : null;
|
|
1085
|
+
const saved = await application.saveToLibrary({ from: "now_playing", ...(target ? { playlistId: target.id } : words ? { playlistName: words } : {}) });
|
|
1086
|
+
const where = saved.playlist.saved ? tr("Saved") : saved.playlist.name;
|
|
1087
|
+
const text = !saved.added ? tr("Already in {name}.", { name: where }) : saved.playlist.saved ? tr("Saved.") : tr("Saved to {name}.", { name: where });
|
|
1088
|
+
addMoondogMessage(text);
|
|
1089
|
+
setFooter(text, success);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
// A playlist link: a copy in the library, and its songs in the listener's taste when they made it or say taste.
|
|
1093
|
+
if (action === "import") {
|
|
1094
|
+
const choice = rest.find((word) => ["taste", "no-taste"].includes(word));
|
|
1095
|
+
const link = rest.filter((word) => word !== choice).join(" ").trim();
|
|
1096
|
+
if (!link) throw new Error(tr("Paste a playlist link after /library import."));
|
|
1097
|
+
setFooter(tr("Reading the playlist…"));
|
|
1098
|
+
const imported = await application.importPlaylistFromLink({ link, ...(choice ? { taste: choice === "taste" } : {}) });
|
|
1099
|
+
const counted = imported.total && imported.total > imported.added
|
|
1100
|
+
? tr("{added} of {total} songs came in.", { added: imported.added, total: imported.total })
|
|
1101
|
+
: tr.n(imported.added, "{count} song came in.", "{count} songs came in.");
|
|
1102
|
+
const taste = imported.taste === "added" ? tr("It now shapes your taste.")
|
|
1103
|
+
: imported.taste === "failed" ? tr("It couldn't shape your taste this time.")
|
|
1104
|
+
: tr("It doesn't shape your taste; add taste after the link if it should.");
|
|
1105
|
+
addMoondogMessage(`${tr("Imported {name}.", { name: imported.playlist.name })} ${counted} ${taste}`);
|
|
1106
|
+
setFooter(tr("Imported {name}.", { name: imported.playlist.name }), success);
|
|
1107
|
+
// A playlist that shapes the taste shows the profile it now reads, as any import into the profile does.
|
|
1108
|
+
if (imported.taste === "added") await openProfile({ imported: true }).catch(() => {});
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
throw new Error(tr("Use /library, /library play <name>, /library save [playlist], or /library import <link>."));
|
|
716
1112
|
};
|
|
717
1113
|
|
|
718
1114
|
const removeLifecycleListeners = () => {
|
|
@@ -730,8 +1126,11 @@ export async function runMoondogTui({
|
|
|
730
1126
|
cleanedUp = true;
|
|
731
1127
|
clearTimeout(watchTimer);
|
|
732
1128
|
stopWatchingModel?.();
|
|
1129
|
+
stopModelSwitch?.();
|
|
733
1130
|
stopWatchingFirstRun?.();
|
|
734
1131
|
stopWatchingPreviews?.();
|
|
1132
|
+
stopWatchingStream?.();
|
|
1133
|
+
stopWatchingSetup?.();
|
|
735
1134
|
clearInterval(indicator);
|
|
736
1135
|
indicator = null;
|
|
737
1136
|
clearInterval(homeMotion);
|
|
@@ -760,6 +1159,8 @@ export async function runMoondogTui({
|
|
|
760
1159
|
};
|
|
761
1160
|
|
|
762
1161
|
const shutdown = () => {
|
|
1162
|
+
// A reply still running stops with the room, and so does a track it is making.
|
|
1163
|
+
try { activeRuntime.abort(); } catch { /* Nothing was running. */ }
|
|
763
1164
|
cleanup();
|
|
764
1165
|
// The spoken line that closes Eclipse, the last thing on The Dark Side of the Moon.
|
|
765
1166
|
try {
|
|
@@ -1008,6 +1409,27 @@ export async function runMoondogTui({
|
|
|
1008
1409
|
void refreshFirstRun();
|
|
1009
1410
|
return nextStatus;
|
|
1010
1411
|
};
|
|
1412
|
+
// A model the listener asked the agent for in words, switched to as /model does once the reply ends.
|
|
1413
|
+
// The terminal stays busy while it switches, so a message queued during the reply waits for the new model.
|
|
1414
|
+
const applyModelAskedFor = async () => {
|
|
1415
|
+
const selection = modelAskedFor;
|
|
1416
|
+
modelAskedFor = null;
|
|
1417
|
+
if (!selection || cleanedUp || busy) { modelAskedFor ??= selection; return; }
|
|
1418
|
+
setBusy(true);
|
|
1419
|
+
let status = null;
|
|
1420
|
+
try {
|
|
1421
|
+
status = await replaceRuntime(runtimeSelection(selection.provider, selection.model, selection.effort ?? runtimeStatus.effort)).catch(() => null);
|
|
1422
|
+
} finally {
|
|
1423
|
+
setBusy(false);
|
|
1424
|
+
}
|
|
1425
|
+
if (status?.state === "configured") setFooter(tr("Model ready."), success);
|
|
1426
|
+
settleQueue();
|
|
1427
|
+
};
|
|
1428
|
+
const stopModelSwitch = application.modelSwitch?.subscribe?.(({ application: from, selection }) => {
|
|
1429
|
+
if (from !== application) return;
|
|
1430
|
+
modelAskedFor = selection;
|
|
1431
|
+
if (!busy) void applyModelAskedFor();
|
|
1432
|
+
});
|
|
1011
1433
|
// The window saved another model, or a sign-in that lets this side answer. The terminal's own
|
|
1012
1434
|
// choices rebuild at once, so their saves find this agent already on them.
|
|
1013
1435
|
const followModel = async () => {
|
|
@@ -1804,10 +2226,11 @@ export async function runMoondogTui({
|
|
|
1804
2226
|
? tr("All set. /spotify help shows how to connect Spotify.")
|
|
1805
2227
|
: tr("All set. Say anything, or press Tab to look around."), success);
|
|
1806
2228
|
};
|
|
1807
|
-
// The view closes at once; the saved skip then reaches the window too
|
|
2229
|
+
// The view closes at once; the saved skip then reaches the window too, and a room closed right
|
|
2230
|
+
// after Esc still saves it.
|
|
1808
2231
|
const skipFirstRun = () => {
|
|
1809
2232
|
closeFirstRun(tr("Skipped. /service and /model are here when you want them."));
|
|
1810
|
-
|
|
2233
|
+
firstRunSaving = firstRun.finish({ skipped: true }).catch(() => {});
|
|
1811
2234
|
};
|
|
1812
2235
|
const handleFirstRunAction = async ({ type, value } = {}) => {
|
|
1813
2236
|
if (busy || cleanedUp || !firstRunView) return;
|
|
@@ -2089,6 +2512,8 @@ export async function runMoondogTui({
|
|
|
2089
2512
|
application.locale = locale;
|
|
2090
2513
|
// A chosen language also becomes the conversation's, so the next short reply follows it.
|
|
2091
2514
|
application.conversationLocale = locale;
|
|
2515
|
+
// A language chosen here ends one the listener asked the agent to keep.
|
|
2516
|
+
application.replyLanguagePin = null;
|
|
2092
2517
|
await saveLanguage?.(locale);
|
|
2093
2518
|
const pinned = normalizeLocale(environment.MOONDOG_LANGUAGE);
|
|
2094
2519
|
transcript.invalidate();
|
|
@@ -2144,6 +2569,14 @@ export async function runMoondogTui({
|
|
|
2144
2569
|
await pickService(args);
|
|
2145
2570
|
return;
|
|
2146
2571
|
}
|
|
2572
|
+
if (command === "generation") {
|
|
2573
|
+
await manageGeneration(args);
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
if (command === "library") {
|
|
2577
|
+
await manageLibrary(args);
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2147
2580
|
if (command === "spotify") {
|
|
2148
2581
|
await runSpotifyCommand(args);
|
|
2149
2582
|
return;
|
|
@@ -2303,6 +2736,12 @@ export async function runMoondogTui({
|
|
|
2303
2736
|
await openCommandPalette();
|
|
2304
2737
|
return;
|
|
2305
2738
|
}
|
|
2739
|
+
// Going on air is a request like any other, which the agent answers by starting the stream.
|
|
2740
|
+
if (command === "onair") {
|
|
2741
|
+
const request = onAirRequest();
|
|
2742
|
+
if (request) await submitListeningTurn(request);
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2306
2745
|
try {
|
|
2307
2746
|
if (!["home", "theme", "art", "motion", "model", "service", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
2308
2747
|
if (command !== "auth") editor.addToHistory(value);
|
|
@@ -2312,7 +2751,7 @@ export async function runMoondogTui({
|
|
|
2312
2751
|
setFooter(`/${command}...`);
|
|
2313
2752
|
const argumentText = value.slice(1).trim().slice(rawCommand.length).trim();
|
|
2314
2753
|
const parsedArgs = command === "import" ? argumentText ? [argumentText] : []
|
|
2315
|
-
: ["profile", "spotify", "web"].includes(command)
|
|
2754
|
+
: ["profile", "spotify", "web", "generation"].includes(command)
|
|
2316
2755
|
? parseTuiArguments(argumentText)
|
|
2317
2756
|
: args;
|
|
2318
2757
|
await runLocalCommand(command, parsedArgs);
|
|
@@ -2408,6 +2847,12 @@ export async function runMoondogTui({
|
|
|
2408
2847
|
if (!cancellationRequested) setFooter(tr("Can't reach the model. Trying again ({attempt}/{max})...", { attempt, max: maxRetries }), warning);
|
|
2409
2848
|
},
|
|
2410
2849
|
onToolStart: (tool) => updateTool(tool, "running"),
|
|
2850
|
+
// Making a track says where it is, from warming up to saving it.
|
|
2851
|
+
onToolUpdate: (tool) => {
|
|
2852
|
+
const stage = generationStages[tool?.stage];
|
|
2853
|
+
if (cleanedUp || cancellationRequested || !stage) return;
|
|
2854
|
+
setFooter(`${sanitizeTerminalText(tr.marked(tool.label ?? "Music tool"))} · ${tr.marked(stage)}...`);
|
|
2855
|
+
},
|
|
2411
2856
|
onToolEnd: (tool) => updateTool(tool, tool?.isError ? "failed" : "completed"),
|
|
2412
2857
|
// The model names a new conversation after its first reply; the header shows that name from then on.
|
|
2413
2858
|
onConversationNamed: ({ session_id, title }) => {
|
|
@@ -2484,9 +2929,11 @@ export async function runMoondogTui({
|
|
|
2484
2929
|
controlPreviews(pending.slice(1));
|
|
2485
2930
|
return;
|
|
2486
2931
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2932
|
+
// /onair stops a stream at once; the request to go on air waits in the queue like any message.
|
|
2933
|
+
const queued = onAirControls && pending === "/onair" ? onAirRequest() : pending;
|
|
2934
|
+
if (queued) {
|
|
2935
|
+
if (messageQueue.length >= 30) editor.setText(queued);
|
|
2936
|
+
else messageQueue.push(queued);
|
|
2490
2937
|
if (rewindPending) queuePaused = true;
|
|
2491
2938
|
}
|
|
2492
2939
|
if (!cleanedUp) tui.requestRender();
|
|
@@ -2739,6 +3186,7 @@ export async function runMoondogTui({
|
|
|
2739
3186
|
} finally {
|
|
2740
3187
|
cleanup();
|
|
2741
3188
|
await lyricTask;
|
|
3189
|
+
await firstRunSaving;
|
|
2742
3190
|
}
|
|
2743
3191
|
return exitRequest;
|
|
2744
3192
|
}
|