@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.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 +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -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 +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- 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 +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- 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/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +419 -19
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { LOCALES, N_, liveScreenTranslator, normalizeLocale } from "../../i18n/index.mjs";
|
|
2
|
+
import { LOCALES, N_, intlLocale, liveScreenTranslator, normalizeLocale, translator } from "../../i18n/index.mjs";
|
|
3
3
|
import {
|
|
4
4
|
CombinedAutocompleteProvider,
|
|
5
5
|
Container,
|
|
@@ -24,6 +24,8 @@ import { getPublicPiModelCatalog } from "../../runtime/pi/public-model-catalog.m
|
|
|
24
24
|
import { serviceKeyProviders, supportedAuthProviderIds } from "../../runtime/pi/authentication.mjs";
|
|
25
25
|
import { EFFORT_LEVELS, EFFORT_SCALE } from "../../runtime/pi/runtime-settings.mjs";
|
|
26
26
|
import { runtimeBehindSelection } from "../../runtime/pi/runtime-selection.mjs";
|
|
27
|
+
import { MODEL_ROLE_WORDS } from "./model-roles-command.mjs";
|
|
28
|
+
import { imagesFromText } from "../../runtime/pi/image-input.mjs";
|
|
27
29
|
import {
|
|
28
30
|
formatLocalResult,
|
|
29
31
|
helpText,
|
|
@@ -125,7 +127,9 @@ const slashCommands = [
|
|
|
125
127
|
{ name: "endpoint", description: N_("Add a model service by its address") },
|
|
126
128
|
{ name: "web", description: N_("Look things up on public music sites") },
|
|
127
129
|
{ name: "service", description: N_("Choose where Moondog plays music") },
|
|
130
|
+
{ name: "library", description: N_("Your saved songs and playlists") },
|
|
128
131
|
{ name: "generation", description: N_("Let Moondog make music on this Mac") },
|
|
132
|
+
{ name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
|
|
129
133
|
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
130
134
|
{ name: "skip", description: N_("Skip to the next song preview") },
|
|
131
135
|
{ name: "stop", description: N_("Stop the song previews") },
|
|
@@ -207,6 +211,7 @@ export async function runMoondogTui({
|
|
|
207
211
|
runProfile,
|
|
208
212
|
runProfileAction,
|
|
209
213
|
runWeb,
|
|
214
|
+
runModelRoles,
|
|
210
215
|
checkUpdates,
|
|
211
216
|
runUpdate,
|
|
212
217
|
lyrics,
|
|
@@ -246,8 +251,11 @@ export async function runMoondogTui({
|
|
|
246
251
|
// /skip and /stop exist only where the room controls the previews itself.
|
|
247
252
|
const previewControls = typeof previewPlayer?.subscribe === "function" &&
|
|
248
253
|
typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
|
|
254
|
+
// /onair needs both: the room's own player and music making here.
|
|
255
|
+
const onAirControls = previewControls && typeof generation?.save === "function";
|
|
249
256
|
const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
|
|
250
|
-
(name !== "generation" || typeof generation?.save === "function"))
|
|
257
|
+
(name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls) &&
|
|
258
|
+
(name !== "library" || application.libraryReady?.() === true));
|
|
251
259
|
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
252
260
|
let activeRuntime = runtime;
|
|
253
261
|
application.startNewSession?.("tui_start");
|
|
@@ -314,6 +322,7 @@ export async function runMoondogTui({
|
|
|
314
322
|
let busy = false;
|
|
315
323
|
// A model chosen or a sign-in made in the window while a turn ran here; it applies once the turn ends.
|
|
316
324
|
let modelChangedElsewhere = false;
|
|
325
|
+
let modelAskedFor = null;
|
|
317
326
|
// A reply another Moondog window or terminal is writing in this conversation: { id, response, text }.
|
|
318
327
|
let elsewhere = null;
|
|
319
328
|
let watchTimer = null;
|
|
@@ -326,7 +335,24 @@ export async function runMoondogTui({
|
|
|
326
335
|
: Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
|
|
327
336
|
// The preview now playing sits on its own line above the draft.
|
|
328
337
|
let nowPlaying = null;
|
|
329
|
-
|
|
338
|
+
// Moondog's stream as the core shows it: null, or { phase, started_at, ends_at, tracks_made }.
|
|
339
|
+
let onAir = application.streamView?.() ?? null;
|
|
340
|
+
// Between two of the stream's tracks nothing plays, and the line says the next one is being made.
|
|
341
|
+
const dryGap = () => !nowPlaying && onAir?.phase === "waiting";
|
|
342
|
+
const nowPlayingRows = () => ((nowPlaying || dryGap()) && !profileView && !importView && !firstRunView ? 1 : 0);
|
|
343
|
+
// Stable Audio 3's setup as the core shows it, here or from the window; a running download has its own line too.
|
|
344
|
+
let setupStatus = null;
|
|
345
|
+
const downloading = () => setupStatus?.job && !setupStatus.job.error ? setupStatus.job : null;
|
|
346
|
+
const downloadRows = () => (downloading() && !profileView && !importView && !firstRunView ? 1 : 0);
|
|
347
|
+
// When the stream ends at the latest, as a clock time: an upper bound, so never a countdown. The clock
|
|
348
|
+
// follows the screen's language, as the words around it do.
|
|
349
|
+
const onAirUntil = () => {
|
|
350
|
+
const ends = new Date(onAir?.ends_at);
|
|
351
|
+
let time;
|
|
352
|
+
try { time = new Intl.DateTimeFormat(intlLocale(tr.locale), { hour: "numeric", minute: "2-digit" }).format(ends); }
|
|
353
|
+
catch { time = ends.toTimeString().slice(0, 5); }
|
|
354
|
+
return tr("on air until {time}", { time });
|
|
355
|
+
};
|
|
330
356
|
let cancelWork = null;
|
|
331
357
|
let cancellationRequested = false;
|
|
332
358
|
let workStartedAt = 0;
|
|
@@ -375,7 +401,7 @@ export async function runMoondogTui({
|
|
|
375
401
|
};
|
|
376
402
|
const sleeve = new RecordSleeve({ terminal, getTheme, environment, getState: () => ({
|
|
377
403
|
focused: homeFocused, selected: homeSelected, motionEnabled,
|
|
378
|
-
editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows(),
|
|
404
|
+
editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows() + downloadRows(),
|
|
379
405
|
}), getNextLyric: () => {
|
|
380
406
|
try {
|
|
381
407
|
homeLyric = homeLyricSeeds ? lyrics.selectHome(homeLyricSeeds) : null;
|
|
@@ -441,7 +467,7 @@ export async function runMoondogTui({
|
|
|
441
467
|
render(width) {
|
|
442
468
|
const lines = conversation.render(width);
|
|
443
469
|
const footerRows = terminal.rows >= 20 ? 2 : 1;
|
|
444
|
-
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - (editor.rowCount || 3));
|
|
470
|
+
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - downloadRows() - (editor.rowCount || 3));
|
|
445
471
|
while (lines.length < bodyRows) lines.push(paintBrandLine("", width, theme));
|
|
446
472
|
return lines;
|
|
447
473
|
},
|
|
@@ -481,21 +507,27 @@ export async function runMoondogTui({
|
|
|
481
507
|
render(width) {
|
|
482
508
|
if (profileView || importView || firstRunView) return [];
|
|
483
509
|
const lines = [];
|
|
484
|
-
if (nowPlaying) {
|
|
485
|
-
const { track, position, count, type } = nowPlaying;
|
|
510
|
+
if (nowPlaying || dryGap()) {
|
|
511
|
+
const { track, position, count, type } = nowPlaying ?? {};
|
|
486
512
|
const prefix = ` ${theme.plain ? ">" : "♪"} `;
|
|
487
513
|
// Where the terminal can't open links, the note in the conversation shows the address.
|
|
488
|
-
const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
514
|
+
const apple = nowPlaying && getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
489
515
|
const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
|
|
516
|
+
// Once its first track plays, a made track is the stream's, which goes on to the next with /skip.
|
|
517
|
+
const onAirTrack = track?.generated && onAir && onAir.phase !== "starting";
|
|
490
518
|
// A track Moondog made is its own record, kept on this Mac, with nothing to open elsewhere.
|
|
491
|
-
const controls =
|
|
519
|
+
const controls = !nowPlaying ? ` · ${onAirUntil()} · /stop`
|
|
520
|
+
: onAirTrack ? `${state ? ` ${state}` : ""} · ${onAirUntil()} · /skip · /stop`
|
|
521
|
+
: track.generated ? `${state ? ` ${state}` : ""} · /stop`
|
|
492
522
|
: ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
|
|
493
|
-
const song =
|
|
523
|
+
const song = !nowPlaying ? tr("Cutting the next record")
|
|
524
|
+
: sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
|
|
494
525
|
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
495
526
|
lines.push(paintBrandLine(room >= 12
|
|
496
527
|
? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
|
|
497
528
|
: theme.accent(prefix) + theme.text(truncateToWidth(song, Math.max(1, width - visibleWidth(prefix)), "…")), width, theme));
|
|
498
529
|
}
|
|
530
|
+
if (downloadRows()) lines.push(paintBrandLine(downloadLine(width), width, theme));
|
|
499
531
|
if (messageQueue.length) {
|
|
500
532
|
const hidden = Math.max(0, messageQueue.length - 4);
|
|
501
533
|
const shown = messageQueue.slice(-4);
|
|
@@ -596,6 +628,7 @@ export async function runMoondogTui({
|
|
|
596
628
|
new CombinedAutocompleteProvider(withCommandCompletions(localizedCommands, {
|
|
597
629
|
// Service keys, such as Brave Search, have no models, so only /auth offers them.
|
|
598
630
|
providers: () => [...providers(), ...serviceKeyProviders], models, authProviderIds: supportedAuthProviderIds, tr,
|
|
631
|
+
generationSetup: typeof generation?.setup?.install === "function",
|
|
599
632
|
}), process.cwd(), null),
|
|
600
633
|
);
|
|
601
634
|
tui.setFocus(editor);
|
|
@@ -615,6 +648,7 @@ export async function runMoondogTui({
|
|
|
615
648
|
}
|
|
616
649
|
if (!cleanedUp) tui.requestRender();
|
|
617
650
|
if (!value && modelChangedElsewhere) void followModel();
|
|
651
|
+
if (!value && modelAskedFor) void applyModelAskedFor();
|
|
618
652
|
};
|
|
619
653
|
const setFooter = (text, color = dim) => {
|
|
620
654
|
if (cleanedUp) return;
|
|
@@ -722,24 +756,164 @@ export async function runMoondogTui({
|
|
|
722
756
|
else if (event?.type === "stopped") nowPlaying = null;
|
|
723
757
|
tui.requestRender();
|
|
724
758
|
}) : null;
|
|
759
|
+
// The stream redraws the line above the draft as it moves on. Ending by itself, it goes dark
|
|
760
|
+
// without a word; only a track that couldn't be made says so.
|
|
761
|
+
const stopWatchingStream = application.subscribeStream?.(({ view, ended } = {}) => {
|
|
762
|
+
if (cleanedUp) return;
|
|
763
|
+
onAir = view ?? null;
|
|
764
|
+
if (ended === "failed") setFooter(tr("Off air: the next record couldn't be made."), failure);
|
|
765
|
+
tui.requestRender();
|
|
766
|
+
}) ?? null;
|
|
725
767
|
// These act at once, even while Moondog is answering.
|
|
726
768
|
const controlPreviews = (command) => {
|
|
727
769
|
// /stop also ends Moondog's stream while its next track is still being made and nothing plays.
|
|
728
770
|
const streamStopped = command === "stop" && application.stopStream?.()?.state === "stopped";
|
|
729
771
|
const acted = command === "stop" ? previewPlayer.stop() || streamStopped : previewPlayer.skip();
|
|
730
|
-
|
|
772
|
+
const waiting = !acted && command === "skip" && application.streamView?.()?.phase === "waiting";
|
|
773
|
+
// Off air shows even during a reply: a stream stopped while its first track is made has no row to lose.
|
|
774
|
+
if (!busy || streamStopped) {
|
|
775
|
+
setFooter(waiting ? tr("The next record is still being cut.") : !acted ? tr("No previews are playing.")
|
|
776
|
+
: streamStopped ? tr("Off air.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
|
|
777
|
+
}
|
|
731
778
|
tui.requestRender();
|
|
732
779
|
};
|
|
780
|
+
// /onair goes off air at once while a stream runs, even while Moondog is answering, and says so
|
|
781
|
+
// when music making is off. Otherwise it returns the request to send, in the reply's language.
|
|
782
|
+
const onAirRequest = () => {
|
|
783
|
+
if (application.streamView?.()) {
|
|
784
|
+
application.stopStream();
|
|
785
|
+
// Said even during a reply: a stream stopped while its first track is made has no line to clear.
|
|
786
|
+
setFooter(tr("Off air."));
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
// Music making can be turned off, here or in the window, after the room opened.
|
|
790
|
+
if (!application.streamReady?.()) {
|
|
791
|
+
setFooter(tr("Turn on music making first: /generation on."), warning);
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
return translator(application.locale)("onAir.request");
|
|
795
|
+
};
|
|
733
796
|
|
|
734
797
|
// Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
|
|
735
798
|
// with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
|
|
799
|
+
// Where the core can set Stable Audio 3 up itself (generation.setup), /generation download, use, remove
|
|
800
|
+
// and cancel manage the models Moondog keeps in its own folder. The window runs the same job, so a
|
|
801
|
+
// download started there shows here too.
|
|
736
802
|
const home = homedir();
|
|
737
803
|
const shownFolder = (folder) => sanitizeTerminalText(String(folder ?? "")).replace(home, "~");
|
|
804
|
+
const setup = typeof generation?.setup?.install === "function" ? generation.setup : null;
|
|
805
|
+
// The two models offered, by the word the listener types; sizes are the weights in all, before the
|
|
806
|
+
// setup reports its own.
|
|
807
|
+
const sa3Models = {
|
|
808
|
+
medium: { name: "Medium", choice: "medium", detail: N_("best sound"), total: 5_179_055_990 },
|
|
809
|
+
"small-music": { name: "Small Music", choice: "small", detail: N_("faster, lighter"), total: 1_704_727_702 },
|
|
810
|
+
};
|
|
811
|
+
const modelArgument = (value) => value === "medium" ? "medium" : ["small", "small-music"].includes(value) ? "small-music" : null;
|
|
812
|
+
const modelName = (model) => sa3Models[model]?.name ?? "Medium";
|
|
813
|
+
const gigabytes = (bytes) => {
|
|
814
|
+
const value = Math.max(0, Number(bytes) || 0) / 1e9;
|
|
815
|
+
try { return new Intl.NumberFormat(intlLocale(tr.locale), { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value); }
|
|
816
|
+
catch { return value.toFixed(1); }
|
|
817
|
+
};
|
|
818
|
+
const modelSize = (model, status = setupStatus) => gigabytes(status?.models?.[model]?.total || sa3Models[model]?.total);
|
|
819
|
+
const setupSteps = { uv: N_("getting uv"), code: N_("getting Stable Audio 3's code"), python: N_("setting up Python"),
|
|
820
|
+
weights: N_("downloading the model"), verifying: N_("checking the files") };
|
|
821
|
+
const jobPercent = (job) => job?.total > 0 ? Math.min(100, Math.floor(((job.received ?? 0) / job.total) * 100)) : null;
|
|
822
|
+
const downloadLine = (width) => {
|
|
823
|
+
const job = downloading();
|
|
824
|
+
const prefix = ` ${theme.plain ? "v" : "↓"} `;
|
|
825
|
+
const percent = jobPercent(job);
|
|
826
|
+
const step = setupSteps[job.step] ? tr.marked(setupSteps[job.step]) : tr("getting ready");
|
|
827
|
+
const text = `Stable Audio 3 ${modelName(job.model)} · ${step}${percent === null ? "" : ` ${percent}%`}`;
|
|
828
|
+
const controls = " · /generation cancel";
|
|
829
|
+
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
830
|
+
return room >= 12 ? theme.accent(prefix) + theme.text(truncateToWidth(text, room, "…")) + theme.faint(controls)
|
|
831
|
+
: theme.accent(prefix) + theme.text(truncateToWidth(text, Math.max(1, width - visibleWidth(prefix)), "…"));
|
|
832
|
+
};
|
|
833
|
+
// One short sentence for a download that stopped on an error, by the setup's code, with the way to try again.
|
|
834
|
+
const setupErrorText = (error) => {
|
|
835
|
+
const code = typeof error === "string" ? error : error?.code;
|
|
836
|
+
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.");
|
|
837
|
+
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.");
|
|
838
|
+
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.");
|
|
839
|
+
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.");
|
|
840
|
+
if (code === "setup_busy") return tr("Another Moondog window or terminal is already downloading. /generation shows how far it is.");
|
|
841
|
+
const detail = sanitizeTerminalText(String(typeof error === "string" ? error : error?.message ?? code ?? "")).trim();
|
|
842
|
+
return tr("The download didn't finish: {error}. Use /generation download to try again.", { error: detail || tr("unknown error") });
|
|
843
|
+
};
|
|
844
|
+
// The model this room asked for. The core's setup chooses what makes music once it finishes; the
|
|
845
|
+
// terminal reads that choice and says how to switch when it kept another one.
|
|
846
|
+
let setupStarted = null;
|
|
847
|
+
const setupFinished = async (model) => {
|
|
848
|
+
setupStarted = null;
|
|
849
|
+
const status = await generation.check().catch(() => null);
|
|
850
|
+
if (cleanedUp) return;
|
|
851
|
+
const choice = sa3Models[model]?.choice ?? "medium";
|
|
852
|
+
setFooter(status?.state === "ready" && status.managed && status.model === model
|
|
853
|
+
? tr("Stable Audio 3 {model} is ready. Ask for a track.", { model: modelName(model) })
|
|
854
|
+
: tr("Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.", { model: modelName(model), choice }), success);
|
|
855
|
+
};
|
|
856
|
+
const stopWatchingSetup = setup?.subscribe?.((status) => {
|
|
857
|
+
if (cleanedUp) return;
|
|
858
|
+
// The first snapshot is where things stand, not a change: an old failure waits for /generation.
|
|
859
|
+
const first = setupStatus === null;
|
|
860
|
+
const before = setupStatus?.job ?? null;
|
|
861
|
+
setupStatus = status ?? null;
|
|
862
|
+
const job = setupStatus?.job ?? null;
|
|
863
|
+
if (first) { tui.requestRender(); return; }
|
|
864
|
+
if (job?.error && !before?.error) setFooter(setupErrorText(job.error), failure);
|
|
865
|
+
else if (before && !before.error && !job) {
|
|
866
|
+
if (setupStatus?.models?.[before.model]?.state === "ready") void setupFinished(before.model);
|
|
867
|
+
else {
|
|
868
|
+
if (setupStarted === before.model) setupStarted = null;
|
|
869
|
+
setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[before.model]?.choice ?? "medium" }));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
tui.requestRender();
|
|
873
|
+
}) ?? null;
|
|
874
|
+
if (setup) {
|
|
875
|
+
Promise.resolve().then(() => setup.status()).then((status) => {
|
|
876
|
+
if (cleanedUp || setupStatus) return;
|
|
877
|
+
setupStatus = status ?? null;
|
|
878
|
+
tui.requestRender();
|
|
879
|
+
}, () => {});
|
|
880
|
+
}
|
|
881
|
+
// A fresh read for a command. The line above the draft follows subscribe alone, so a job that ends
|
|
882
|
+
// while a command reads still counts as finished.
|
|
883
|
+
const readSetup = async () => await setup.status();
|
|
884
|
+
// What each model is: its size, whether it's here, in use, or on its way.
|
|
885
|
+
const setupSummary = (status, generationStatus) => {
|
|
886
|
+
const lines = Object.entries(sa3Models).map(([model, { name, detail }]) => {
|
|
887
|
+
const entry = status?.models?.[model] ?? { state: "absent" };
|
|
888
|
+
const job = status?.job?.model === model ? status.job : null;
|
|
889
|
+
const percent = jobPercent(job);
|
|
890
|
+
const state = job?.error ? tr("download stopped on an error")
|
|
891
|
+
: job ? percent === null ? tr("downloading") : tr("downloading, {percent}%", { percent })
|
|
892
|
+
: entry.state === "ready"
|
|
893
|
+
? generationStatus?.managed && generationStatus.model === model ? tr("installed, in use") : tr("installed")
|
|
894
|
+
: entry.state === "partial" ? tr("partly downloaded") : tr("not downloaded");
|
|
895
|
+
return `- Stable Audio 3 ${name} · ${tr.marked(detail)} · ${tr("{size} GB", { size: modelSize(model, status) })} · ${state}`;
|
|
896
|
+
});
|
|
897
|
+
const notes = [];
|
|
898
|
+
if (status?.job?.error) notes.push(setupErrorText(status.job.error));
|
|
899
|
+
if (generationStatus && !generationStatus.managed && generationStatus.install === "ready") {
|
|
900
|
+
notes.push(tr("In use: your own install at {folder}.", { folder: shownFolder(generationStatus.path) }));
|
|
901
|
+
}
|
|
902
|
+
notes.push(tr("Get a model with /generation download medium or small. /generation use, remove and cancel manage them."));
|
|
903
|
+
return [lines.join("\n"), ...notes].join("\n\n");
|
|
904
|
+
};
|
|
738
905
|
const generationMessage = (status) => {
|
|
739
906
|
const folder = shownFolder(status?.path);
|
|
740
907
|
if (status?.install === "unsupported") return tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here.");
|
|
741
908
|
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.");
|
|
909
|
+
// Moondog's own install, or nothing anywhere yet: a download sets it up.
|
|
910
|
+
if (setup && status?.install !== "ready" && (status?.managed || (status?.source === "default" && status?.install === "missing"))) {
|
|
911
|
+
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.");
|
|
912
|
+
const model = status?.model in sa3Models ? status.model : "medium";
|
|
913
|
+
return tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: modelName(model), choice: sa3Models[model].choice });
|
|
914
|
+
}
|
|
742
915
|
if (status?.state === "off") {
|
|
916
|
+
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) });
|
|
743
917
|
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 });
|
|
744
918
|
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 });
|
|
745
919
|
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 });
|
|
@@ -751,16 +925,193 @@ export async function runMoondogTui({
|
|
|
751
925
|
}
|
|
752
926
|
return tr("Stable Audio 3 isn't at {folder}. Use /generation on with the folder that holds its sa3 script.", { folder });
|
|
753
927
|
};
|
|
928
|
+
const generationFooter = (status) => setFooter(status.state === "ready" ? tr("Music making on.") : status.state === "off" ? tr("Music making off.") : tr("Music making isn't ready."),
|
|
929
|
+
status.state === "ready" || status.state === "off" ? dim : warning);
|
|
930
|
+
const setupUsage = () => tr("Use /generation download, use or remove with medium or small, or /generation cancel.");
|
|
931
|
+
// /generation download, use, remove and cancel. A first download asks for the licenses with --agree;
|
|
932
|
+
// a removal asks once with --yes. The download runs in the background, on its own line above the draft.
|
|
933
|
+
const manageSetup = async (action, rest) => {
|
|
934
|
+
const flags = rest.filter((value) => value.startsWith("--"));
|
|
935
|
+
const values = rest.filter((value) => !value.startsWith("--"));
|
|
936
|
+
const allowed = action === "download" ? ["--agree"] : action === "remove" ? ["--yes"] : [];
|
|
937
|
+
const model = modelArgument(values[0]);
|
|
938
|
+
if ((action === "cancel" ? values.length > 0 : values.length !== 1 || !model) || flags.some((flag) => !allowed.includes(flag))) {
|
|
939
|
+
throw new Error(setupUsage());
|
|
940
|
+
}
|
|
941
|
+
const status = await readSetup();
|
|
942
|
+
if (status?.supported === false) {
|
|
943
|
+
addMoondogMessage(tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here."));
|
|
944
|
+
setFooter(tr("Music making isn't ready."), warning);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const running = status?.job && !status.job.error ? status.job : null;
|
|
948
|
+
if (action === "cancel") {
|
|
949
|
+
if (!running) { setFooter(tr("No download is running.")); return; }
|
|
950
|
+
await setup.cancel();
|
|
951
|
+
setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[running.model]?.choice ?? "medium" }));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const name = modelName(model);
|
|
955
|
+
const { choice } = sa3Models[model];
|
|
956
|
+
const entry = status?.models?.[model] ?? { state: "absent" };
|
|
957
|
+
if (action === "download") {
|
|
958
|
+
if (running) {
|
|
959
|
+
setFooter(running.model === model ? tr("Stable Audio 3 {model} is already downloading.", { model: name })
|
|
960
|
+
: tr("Another download is running. /generation cancel stops it."), running.model === model ? dim : warning);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (entry.state === "ready") {
|
|
964
|
+
addMoondogMessage(tr("Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.", { model: name, choice }));
|
|
965
|
+
setFooter(tr("Already downloaded."));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const accepted = flags.includes("--agree");
|
|
969
|
+
if (!status?.licenses?.accepted && !accepted) {
|
|
970
|
+
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", {
|
|
971
|
+
license: status?.licenses?.links?.find(({ id }) => id === "stability-ai-community")?.url ?? "https://stability.ai/license",
|
|
972
|
+
gemma: status?.licenses?.links?.find(({ id }) => id === "gemma-terms")?.url ?? "https://huggingface.co/stabilityai/stable-audio-3-optimized/blob/main/LICENSE_GEMMA.md",
|
|
973
|
+
model: name, size: modelSize(model, status), choice,
|
|
974
|
+
}));
|
|
975
|
+
setFooter(tr("Waiting for your agreement."));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
setupStarted = model;
|
|
979
|
+
Promise.resolve().then(() => setup.install(model, { accepted })).catch((error) => {
|
|
980
|
+
if (setupStarted === model) setupStarted = null;
|
|
981
|
+
// A cancel ends the job too; the setup's own snapshot says so.
|
|
982
|
+
if (cleanedUp || error?.code === "setup_cancelled") return;
|
|
983
|
+
addMoondogMessage(setupErrorText(error));
|
|
984
|
+
setFooter(tr("The download didn't finish."), failure);
|
|
985
|
+
});
|
|
986
|
+
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.", {
|
|
987
|
+
model: name, size: modelSize(model, status),
|
|
988
|
+
}));
|
|
989
|
+
setFooter(tr("Downloading Stable Audio 3 {model}.", { model: name }));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (action === "use") {
|
|
993
|
+
if (entry.state !== "ready") {
|
|
994
|
+
addMoondogMessage(tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: name, choice }));
|
|
995
|
+
setFooter(tr("Music making isn't ready."), warning);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const result = await generation.save({ enabled: true, source: "managed", model });
|
|
999
|
+
addMoondogMessage(generationMessage(result));
|
|
1000
|
+
generationFooter(result);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
// remove
|
|
1004
|
+
if (running) {
|
|
1005
|
+
addMoondogMessage(tr("Wait for the download to finish, or stop it with /generation cancel, before removing a model."));
|
|
1006
|
+
setFooter(tr("A download is running."), warning);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (entry.state === "absent") {
|
|
1010
|
+
setFooter(tr("Stable Audio 3 {model} isn't on this Mac.", { model: name }));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!flags.includes("--yes")) {
|
|
1014
|
+
const last = Object.keys(sa3Models).every((other) => other === model || (status?.models?.[other]?.state ?? "absent") === "absent");
|
|
1015
|
+
const size = gigabytes(entry.bytes || entry.total || sa3Models[model].total);
|
|
1016
|
+
addMoondogMessage(last
|
|
1017
|
+
? 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 })
|
|
1018
|
+
: tr("Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes", { model: name, size, choice }));
|
|
1019
|
+
setFooter(tr("Nothing removed yet."));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
await setup.remove(model);
|
|
1023
|
+
await readSetup().catch(() => null);
|
|
1024
|
+
const after = await generation.check().catch(() => null);
|
|
1025
|
+
addMoondogMessage(tr("Removed Stable Audio 3 {model} from this Mac.", { model: name }));
|
|
1026
|
+
setFooter(after?.state === "ready" ? tr("Removed.") : tr("Removed. Music making isn't ready."), after?.state === "ready" ? success : warning);
|
|
1027
|
+
};
|
|
754
1028
|
const manageGeneration = async (args) => {
|
|
755
1029
|
const [action, ...rest] = args;
|
|
1030
|
+
if (setup && ["download", "use", "remove", "cancel"].includes(action)) {
|
|
1031
|
+
await manageSetup(action, rest);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
756
1034
|
if ((action && !["on", "off"].includes(action)) || (action === "off" && rest.length)) {
|
|
757
|
-
throw new Error(tr("Use /generation, /generation on [folder], or /generation
|
|
1035
|
+
throw new Error(setup ? tr("Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.")
|
|
1036
|
+
: tr("Use /generation, /generation on [folder], or /generation off."));
|
|
758
1037
|
}
|
|
759
1038
|
const status = action ? await generation.save({ enabled: action === "on", ...(rest.length ? { path: rest.join(" ") } : {}) })
|
|
760
1039
|
: await generation.check();
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1040
|
+
const summary = setup && !action && status?.install !== "unsupported" ? await readSetup().then((current) => setupSummary(current, status), () => null) : null;
|
|
1041
|
+
addMoondogMessage(summary ? `${generationMessage(status)}\n\n${summary}` : generationMessage(status));
|
|
1042
|
+
generationFooter(status);
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// /library lists Saved and the listener's folders and playlists, plays one by name, or keeps the song
|
|
1046
|
+
// playing now, in Saved or in a playlist by name (made when there is none). The window shares this library.
|
|
1047
|
+
const manageLibrary = async (args) => {
|
|
1048
|
+
if (!application.libraryReady?.()) throw new Error(tr("The library isn't available here."));
|
|
1049
|
+
const [action, ...rest] = args;
|
|
1050
|
+
const words = rest.join(" ").trim();
|
|
1051
|
+
const nameOf = (playlist) => (playlist.saved ? tr("Saved") : playlist.name);
|
|
1052
|
+
if (!action) {
|
|
1053
|
+
const { folders, playlists } = application.libraryView();
|
|
1054
|
+
const count = (playlist) => tr.n(playlist.count, "{count} song", "{count} songs");
|
|
1055
|
+
const lines = [];
|
|
1056
|
+
const saved = playlists.find((playlist) => playlist.saved);
|
|
1057
|
+
if (saved) lines.push(`- **${nameOf(saved)}** · ${count(saved)}`);
|
|
1058
|
+
const walk = (parentId, depth) => {
|
|
1059
|
+
const nodes = [...folders.filter((folder) => folder.parentId === parentId).map((node) => ["folder", node]),
|
|
1060
|
+
...playlists.filter((playlist) => !playlist.saved && playlist.folderId === parentId).map((node) => ["playlist", node])]
|
|
1061
|
+
.sort((a, b) => a[1].position - b[1].position);
|
|
1062
|
+
for (const [kind, node] of nodes) {
|
|
1063
|
+
const indent = " ".repeat(depth);
|
|
1064
|
+
if (kind === "folder") { lines.push(`${indent}- ${node.name}/`); walk(node.id, depth + 1); }
|
|
1065
|
+
else lines.push(`${indent}- **${node.name}** · ${count(node)}`);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
walk(null, 0);
|
|
1069
|
+
addMoondogMessage(`${lines.join("\n")}\n\n${tr("Play one with `/library play <name>`, or keep the song playing now with `/library save`.")}`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (action === "play") {
|
|
1073
|
+
const [playlist] = application.findLibraryPlaylists(words);
|
|
1074
|
+
if (!playlist || !words) throw new Error(tr("No playlist is called “{name}”.", { name: words }));
|
|
1075
|
+
const played = await application.playLibrary({ playlistId: playlist.id });
|
|
1076
|
+
if (played.state !== "playing") {
|
|
1077
|
+
addMoondogMessage(played.state === "empty" ? tr("{name} has nothing in it yet.", { name: nameOf(playlist) }) : tr("None of its songs could be found."));
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
addMoondogMessage([tr("Playing {name}.", { name: nameOf(playlist) }),
|
|
1081
|
+
played.left_out_made ? tr("Moondog's tracks were left out. They play with Apple Music chosen.") : "",
|
|
1082
|
+
played.not_found ? tr("Some songs couldn't be found.") : ""].filter(Boolean).join(" "));
|
|
1083
|
+
setFooter(tr("Playing {name}.", { name: nameOf(playlist) }), success);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (action === "save") {
|
|
1087
|
+
const target = words ? application.findLibraryPlaylists(words).find((playlist) => playlist.name.toLocaleLowerCase() === words.toLocaleLowerCase() || (playlist.saved && /^saved$/iu.test(words))) : null;
|
|
1088
|
+
const saved = await application.saveToLibrary({ from: "now_playing", ...(target ? { playlistId: target.id } : words ? { playlistName: words } : {}) });
|
|
1089
|
+
const where = saved.playlist.saved ? tr("Saved") : saved.playlist.name;
|
|
1090
|
+
const text = !saved.added ? tr("Already in {name}.", { name: where }) : saved.playlist.saved ? tr("Saved.") : tr("Saved to {name}.", { name: where });
|
|
1091
|
+
addMoondogMessage(text);
|
|
1092
|
+
setFooter(text, success);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
// A playlist link: a copy in the library, and its songs in the listener's taste when they made it or say taste.
|
|
1096
|
+
if (action === "import") {
|
|
1097
|
+
const choice = rest.find((word) => ["taste", "no-taste"].includes(word));
|
|
1098
|
+
const link = rest.filter((word) => word !== choice).join(" ").trim();
|
|
1099
|
+
if (!link) throw new Error(tr("Paste a playlist link after /library import."));
|
|
1100
|
+
setFooter(tr("Reading the playlist…"));
|
|
1101
|
+
const imported = await application.importPlaylistFromLink({ link, ...(choice ? { taste: choice === "taste" } : {}) });
|
|
1102
|
+
const counted = imported.total && imported.total > imported.added
|
|
1103
|
+
? tr("{added} of {total} songs came in.", { added: imported.added, total: imported.total })
|
|
1104
|
+
: tr.n(imported.added, "{count} song came in.", "{count} songs came in.");
|
|
1105
|
+
const taste = imported.taste === "added" ? tr("It now shapes your taste.")
|
|
1106
|
+
: imported.taste === "failed" ? tr("It couldn't shape your taste this time.")
|
|
1107
|
+
: tr("It doesn't shape your taste; add taste after the link if it should.");
|
|
1108
|
+
addMoondogMessage(`${tr("Imported {name}.", { name: imported.playlist.name })} ${counted} ${taste}`);
|
|
1109
|
+
setFooter(tr("Imported {name}.", { name: imported.playlist.name }), success);
|
|
1110
|
+
// A playlist that shapes the taste shows the profile it now reads, as any import into the profile does.
|
|
1111
|
+
if (imported.taste === "added") await openProfile({ imported: true }).catch(() => {});
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
throw new Error(tr("Use /library, /library play <name>, /library save [playlist], or /library import <link>."));
|
|
764
1115
|
};
|
|
765
1116
|
|
|
766
1117
|
const removeLifecycleListeners = () => {
|
|
@@ -778,8 +1129,11 @@ export async function runMoondogTui({
|
|
|
778
1129
|
cleanedUp = true;
|
|
779
1130
|
clearTimeout(watchTimer);
|
|
780
1131
|
stopWatchingModel?.();
|
|
1132
|
+
stopModelSwitch?.();
|
|
781
1133
|
stopWatchingFirstRun?.();
|
|
782
1134
|
stopWatchingPreviews?.();
|
|
1135
|
+
stopWatchingStream?.();
|
|
1136
|
+
stopWatchingSetup?.();
|
|
783
1137
|
clearInterval(indicator);
|
|
784
1138
|
indicator = null;
|
|
785
1139
|
clearInterval(homeMotion);
|
|
@@ -1058,6 +1412,27 @@ export async function runMoondogTui({
|
|
|
1058
1412
|
void refreshFirstRun();
|
|
1059
1413
|
return nextStatus;
|
|
1060
1414
|
};
|
|
1415
|
+
// A model the listener asked the agent for in words, switched to as /model does once the reply ends.
|
|
1416
|
+
// The terminal stays busy while it switches, so a message queued during the reply waits for the new model.
|
|
1417
|
+
const applyModelAskedFor = async () => {
|
|
1418
|
+
const selection = modelAskedFor;
|
|
1419
|
+
modelAskedFor = null;
|
|
1420
|
+
if (!selection || cleanedUp || busy) { modelAskedFor ??= selection; return; }
|
|
1421
|
+
setBusy(true);
|
|
1422
|
+
let status = null;
|
|
1423
|
+
try {
|
|
1424
|
+
status = await replaceRuntime(runtimeSelection(selection.provider, selection.model, selection.effort ?? runtimeStatus.effort)).catch(() => null);
|
|
1425
|
+
} finally {
|
|
1426
|
+
setBusy(false);
|
|
1427
|
+
}
|
|
1428
|
+
if (status?.state === "configured") setFooter(tr("Model ready."), success);
|
|
1429
|
+
settleQueue();
|
|
1430
|
+
};
|
|
1431
|
+
const stopModelSwitch = application.modelSwitch?.subscribe?.(({ application: from, selection }) => {
|
|
1432
|
+
if (from !== application) return;
|
|
1433
|
+
modelAskedFor = selection;
|
|
1434
|
+
if (!busy) void applyModelAskedFor();
|
|
1435
|
+
});
|
|
1061
1436
|
// The window saved another model, or a sign-in that lets this side answer. The terminal's own
|
|
1062
1437
|
// choices rebuild at once, so their saves find this agent already on them.
|
|
1063
1438
|
const followModel = async () => {
|
|
@@ -1068,7 +1443,7 @@ export async function runMoondogTui({
|
|
|
1068
1443
|
// The window's endpoints reach /model here; an agent on an endpoint that changed is rebuilt, since its
|
|
1069
1444
|
// address or key may have. The window's menu list and its per-conversation choices don't move this one.
|
|
1070
1445
|
const stopWatchingModel = watchModelSettings?.((selection, changed) => {
|
|
1071
|
-
if (changed === "models") return;
|
|
1446
|
+
if (changed === "models" || changed === "roles") return;
|
|
1072
1447
|
if (changed === "endpoints") void refreshEndpoints?.();
|
|
1073
1448
|
const onEndpoint = changed === "endpoints" && String(runtimeStatus.provider ?? "").startsWith("endpoint-");
|
|
1074
1449
|
if (!onEndpoint && !runtimeBehindSelection(runtimeStatus, selection)) return;
|
|
@@ -2140,6 +2515,8 @@ export async function runMoondogTui({
|
|
|
2140
2515
|
application.locale = locale;
|
|
2141
2516
|
// A chosen language also becomes the conversation's, so the next short reply follows it.
|
|
2142
2517
|
application.conversationLocale = locale;
|
|
2518
|
+
// A language chosen here ends one the listener asked the agent to keep.
|
|
2519
|
+
application.replyLanguagePin = null;
|
|
2143
2520
|
await saveLanguage?.(locale);
|
|
2144
2521
|
const pinned = normalizeLocale(environment.MOONDOG_LANGUAGE);
|
|
2145
2522
|
transcript.invalidate();
|
|
@@ -2176,6 +2553,13 @@ export async function runMoondogTui({
|
|
|
2176
2553
|
return;
|
|
2177
2554
|
}
|
|
2178
2555
|
if (command === "model") {
|
|
2556
|
+
// /model roles, vision, web and audio set what other models are for; the rest pick the chat model.
|
|
2557
|
+
if (args[0] && Object.hasOwn(MODEL_ROLE_WORDS, args[0].toLowerCase())) {
|
|
2558
|
+
if (typeof runModelRoles !== "function") throw new Error(tr("Model roles aren't available when Moondog is started this way."));
|
|
2559
|
+
addMoondogMessage(await runModelRoles(args));
|
|
2560
|
+
setFooter(tr("Done."), success);
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2179
2563
|
await selectModel(args);
|
|
2180
2564
|
return;
|
|
2181
2565
|
}
|
|
@@ -2199,6 +2583,10 @@ export async function runMoondogTui({
|
|
|
2199
2583
|
await manageGeneration(args);
|
|
2200
2584
|
return;
|
|
2201
2585
|
}
|
|
2586
|
+
if (command === "library") {
|
|
2587
|
+
await manageLibrary(args);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2202
2590
|
if (command === "spotify") {
|
|
2203
2591
|
await runSpotifyCommand(args);
|
|
2204
2592
|
return;
|
|
@@ -2358,6 +2746,12 @@ export async function runMoondogTui({
|
|
|
2358
2746
|
await openCommandPalette();
|
|
2359
2747
|
return;
|
|
2360
2748
|
}
|
|
2749
|
+
// Going on air is a request like any other, which the agent answers by starting the stream.
|
|
2750
|
+
if (command === "onair") {
|
|
2751
|
+
const request = onAirRequest();
|
|
2752
|
+
if (request) await submitListeningTurn(request);
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2361
2755
|
try {
|
|
2362
2756
|
if (!["home", "theme", "art", "motion", "model", "service", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
2363
2757
|
if (command !== "auth") editor.addToHistory(value);
|
|
@@ -2443,8 +2837,12 @@ export async function runMoondogTui({
|
|
|
2443
2837
|
};
|
|
2444
2838
|
|
|
2445
2839
|
try {
|
|
2840
|
+
// An image path in the message goes with it, as Hermes Agent's terminal does; the words stay.
|
|
2841
|
+
const images = await imagesFromText(value);
|
|
2842
|
+
if (images.length) setFooter(tr("Looking at the attached images... Ctrl+C cancels."));
|
|
2446
2843
|
const result = await activeRuntime.prompt(value, {
|
|
2447
2844
|
...(profileSeed ? { profileSeed } : {}),
|
|
2845
|
+
...(images.length ? { images } : {}),
|
|
2448
2846
|
onTextDelta: (delta) => {
|
|
2449
2847
|
if (cleanedUp) return;
|
|
2450
2848
|
work.quiet();
|
|
@@ -2545,9 +2943,11 @@ export async function runMoondogTui({
|
|
|
2545
2943
|
controlPreviews(pending.slice(1));
|
|
2546
2944
|
return;
|
|
2547
2945
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2946
|
+
// /onair stops a stream at once; the request to go on air waits in the queue like any message.
|
|
2947
|
+
const queued = onAirControls && pending === "/onair" ? onAirRequest() : pending;
|
|
2948
|
+
if (queued) {
|
|
2949
|
+
if (messageQueue.length >= 30) editor.setText(queued);
|
|
2950
|
+
else messageQueue.push(queued);
|
|
2551
2951
|
if (rewindPending) queuePaused = true;
|
|
2552
2952
|
}
|
|
2553
2953
|
if (!cleanedUp) tui.requestRender();
|