@gentbajko/slopify 3.0.0 → 3.0.2
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 +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/alignment/text.js +3 -1
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/3.0.2.md +20 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-save.js +1 -1
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-C4McGNke.js +307 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- package/dist/web/assets/index-CX590kE-.js +0 -303
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { transact } from "../../kernel/db/tx.js";
|
|
3
3
|
import { liveProject } from "../admission/repo.js";
|
|
4
|
-
import {
|
|
4
|
+
import { defaultChannelId } from "../channels/model.js";
|
|
5
|
+
import { projectChannels, scheduleChannel } from "../channels/repo.js";
|
|
5
6
|
import { channelVideoTitles } from "../channels/videos.js";
|
|
7
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
6
8
|
import { scheduleById } from "./repo.js";
|
|
7
|
-
import { heldTopicSchema, queueMax, topicMoveSchema, topicTitleSchema, topicTransferSchema, } from "./schema.js";
|
|
9
|
+
import { heldTopicEditSchema, heldTopicSchema, queueMax, topicMoveSchema, topicQueueSchema, topicTitleSchema, topicTransferSchema, } from "./schema.js";
|
|
8
10
|
import { newTopics } from "./similar.js";
|
|
11
|
+
import { templateKeywords, topicRowProblems } from "./topic-list.js";
|
|
9
12
|
// A schedule that finds its own topics asks an LLM whenever its queue holds fewer than
|
|
10
13
|
// `keepAtLeast`. Only one generation per schedule runs at a time: a lease on the schedule row
|
|
11
14
|
// (`topics_generating_at`) is taken before the LLM is asked and cleared after, so a tick and a
|
|
@@ -184,19 +187,24 @@ function nextRank(deps, scheduleId) {
|
|
|
184
187
|
const rank = z.object({ rank: z.number().nullable() }).parse(row).rank;
|
|
185
188
|
return rank === null ? 0 : rank + 1;
|
|
186
189
|
}
|
|
187
|
-
// Everything this schedule has queued, held, turned down or used,
|
|
188
|
-
// the titles of the videos its channel made before Slopify (Channel page →
|
|
190
|
+
// Everything this schedule has queued, held, turned down or used, the titles of the projects in
|
|
191
|
+
// its channel, and the titles of the videos its channel made before Slopify (Channel page →
|
|
192
|
+
// Existing videos). Another channel's videos are its own business: a sleep channel may cover a
|
|
193
|
+
// subject a lore channel already did.
|
|
189
194
|
export function knownTitles(deps, scheduleId) {
|
|
190
195
|
const schedule = scheduleById(deps.db, scheduleId);
|
|
191
196
|
const logged = deps.db
|
|
192
197
|
.prepare("SELECT title FROM schedule_topics WHERE schedule_id=? ORDER BY created_at DESC")
|
|
193
198
|
.all(scheduleId)
|
|
194
199
|
.map((row) => z.object({ title: z.string() }).parse(row).title);
|
|
200
|
+
const channel = scheduleChannel(deps.db, scheduleId);
|
|
201
|
+
const channels = projectChannels(deps.db);
|
|
195
202
|
const projects = deps.db
|
|
196
|
-
.prepare(`SELECT title FROM projects WHERE ${liveProject()} ORDER BY created_at DESC`)
|
|
203
|
+
.prepare(`SELECT id,title FROM projects WHERE ${liveProject()} ORDER BY created_at DESC`)
|
|
197
204
|
.all()
|
|
198
|
-
.map((row) => z.object({ title: z.string() }).parse(row)
|
|
199
|
-
|
|
205
|
+
.map((row) => z.object({ id: z.string(), title: z.string() }).parse(row))
|
|
206
|
+
.filter((row) => channel === undefined || (channels.get(row.id) ?? defaultChannelId) === channel.id)
|
|
207
|
+
.map((row) => row.title);
|
|
200
208
|
const existing = channel === undefined ? [] : channelVideoTitles(deps.db, channel.id);
|
|
201
209
|
return {
|
|
202
210
|
topics: [...new Set([...(schedule?.items.map((item) => item.title) ?? []), ...logged])],
|
|
@@ -273,7 +281,34 @@ export function parseTopics(answer) {
|
|
|
273
281
|
.trim())
|
|
274
282
|
.filter((line) => line !== "" && line !== "[" && line !== "]" && line.length <= 200);
|
|
275
283
|
}
|
|
284
|
+
// The keywords set on held topics, by topic id. Kept in a settings row, since a held topic's row
|
|
285
|
+
// has only its title; an entry leaves when its topic is approved or turned down.
|
|
286
|
+
export const heldValuesKey = "schedules.held-topic-values";
|
|
287
|
+
const heldValuesSchema = z.record(z.string(), z.record(z.string(), z.string()));
|
|
288
|
+
function readHeldValues(db) {
|
|
289
|
+
const raw = readSetting(db, heldValuesKey);
|
|
290
|
+
if (raw === undefined)
|
|
291
|
+
return {};
|
|
292
|
+
try {
|
|
293
|
+
const parsed = heldValuesSchema.safeParse(JSON.parse(raw));
|
|
294
|
+
return parsed.success ? parsed.data : {};
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return {};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Entries of topics that are no longer held (approved, turned down, or gone with their
|
|
301
|
+
// schedule) are dropped on every write.
|
|
302
|
+
function writeHeldValues(db, change = (values) => values) {
|
|
303
|
+
const held = new Set(db
|
|
304
|
+
.prepare("SELECT id FROM schedule_topics WHERE state='held'")
|
|
305
|
+
.all()
|
|
306
|
+
.map((row) => String(row.id)));
|
|
307
|
+
const next = Object.fromEntries(Object.entries(change(readHeldValues(db))).filter(([id, values]) => held.has(id) && Object.keys(values).length > 0));
|
|
308
|
+
writeSetting(db, heldValuesKey, JSON.stringify(next));
|
|
309
|
+
}
|
|
276
310
|
export function heldTopics(deps, scheduleId) {
|
|
311
|
+
const stored = readHeldValues(deps.db);
|
|
277
312
|
return deps.db
|
|
278
313
|
.prepare(`SELECT id,title,rank,created_at FROM schedule_topics
|
|
279
314
|
WHERE schedule_id=? AND state='held' ORDER BY rank,created_at,id`)
|
|
@@ -285,6 +320,7 @@ export function heldTopics(deps, scheduleId) {
|
|
|
285
320
|
return heldTopicSchema.parse({
|
|
286
321
|
id: value.id,
|
|
287
322
|
title: value.title,
|
|
323
|
+
values: stored[value.id] ?? {},
|
|
288
324
|
rank: value.rank,
|
|
289
325
|
createdAt: value.created_at,
|
|
290
326
|
});
|
|
@@ -309,21 +345,22 @@ export function approveHeldTopics(deps, scheduleId, ids, edits = {}) {
|
|
|
309
345
|
const chosen = ids === "all" ? held : held.filter((topic) => ids.includes(topic.id));
|
|
310
346
|
if (ids !== "all" && chosen.length !== ids.length)
|
|
311
347
|
return { ok: false, reason: "topic-not-found" };
|
|
312
|
-
const
|
|
348
|
+
const items = [];
|
|
313
349
|
for (const topic of chosen) {
|
|
314
350
|
const title = topicTitleSchema.safeParse(edits[topic.id] ?? topic.title);
|
|
315
351
|
if (!title.success)
|
|
316
352
|
return { ok: false, reason: "invalid-input" };
|
|
317
|
-
|
|
353
|
+
items.push({ title: title.data, values: topic.values });
|
|
318
354
|
}
|
|
319
|
-
if (open.value.items.length +
|
|
355
|
+
if (open.value.items.length + items.length > queueMax)
|
|
320
356
|
return { ok: false, reason: "queue-full" };
|
|
321
357
|
const at = deps.clock.now().toISOString();
|
|
322
358
|
for (const topic of chosen)
|
|
323
359
|
deps.db.prepare("DELETE FROM schedule_topics WHERE id=?").run(topic.id);
|
|
324
360
|
deps.db
|
|
325
361
|
.prepare("UPDATE schedules SET items_json=?,version=version+1,updated_at=? WHERE id=?")
|
|
326
|
-
.run(JSON.stringify([...open.value.items, ...
|
|
362
|
+
.run(JSON.stringify([...open.value.items, ...items]), at, scheduleId);
|
|
363
|
+
writeHeldValues(deps.db);
|
|
327
364
|
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
328
365
|
});
|
|
329
366
|
}
|
|
@@ -335,16 +372,53 @@ export function rejectHeldTopic(deps, scheduleId, topicId) {
|
|
|
335
372
|
.run(deps.clock.now().toISOString(), topicId, scheduleId);
|
|
336
373
|
if (Number(changed.changes) !== 1)
|
|
337
374
|
return { ok: false, reason: "topic-not-found" };
|
|
375
|
+
writeHeldValues(deps.db);
|
|
338
376
|
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
339
377
|
}
|
|
340
|
-
|
|
341
|
-
|
|
378
|
+
// Edit on a held topic: its title, and the keywords it sets for its run, checked against the
|
|
379
|
+
// template the way the queue's are. Without `values` the ones it had are kept; an empty value
|
|
380
|
+
// is dropped, so the every-run value applies.
|
|
381
|
+
export function editHeldTopic(deps, scheduleId, topicId, input) {
|
|
382
|
+
const edit = heldTopicEditSchema.safeParse(input);
|
|
383
|
+
if (!edit.success)
|
|
384
|
+
return { ok: false, reason: "invalid-input" };
|
|
385
|
+
const parsed = topicTitleSchema.safeParse(edit.data.title);
|
|
342
386
|
if (!parsed.success)
|
|
343
387
|
return { ok: false, reason: "invalid-input" };
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
.
|
|
347
|
-
|
|
388
|
+
const values = edit.data.values === undefined
|
|
389
|
+
? undefined
|
|
390
|
+
: Object.fromEntries(Object.entries(edit.data.values)
|
|
391
|
+
.map(([name, value]) => [name, value.trim()])
|
|
392
|
+
.filter(([, value]) => value !== ""));
|
|
393
|
+
if (values !== undefined && Object.keys(values).length > 0) {
|
|
394
|
+
const schedule = scheduleById(deps.db, scheduleId);
|
|
395
|
+
if (schedule === undefined)
|
|
396
|
+
return { ok: false, reason: "not-found" };
|
|
397
|
+
const template = deps.template(schedule.templateId, schedule.templateVersion);
|
|
398
|
+
if (template === undefined)
|
|
399
|
+
return { ok: false, reason: "missing-template" };
|
|
400
|
+
const problems = topicRowProblems([{ title: parsed.data, values }], {
|
|
401
|
+
keywords: templateKeywords(template.document.form).filter((name) => name !== schedule.topicKeyword),
|
|
402
|
+
topicKeyword: schedule.topicKeyword,
|
|
403
|
+
});
|
|
404
|
+
if (problems.length > 0)
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
reason: "invalid-topics",
|
|
408
|
+
message: `The topic wasn't saved. ${problems.join(" ")}`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const changed = transact(deps.db, () => {
|
|
412
|
+
const row = deps.db
|
|
413
|
+
.prepare("UPDATE schedule_topics SET title=? WHERE id=? AND schedule_id=? AND state='held'")
|
|
414
|
+
.run(parsed.data, topicId, scheduleId);
|
|
415
|
+
if (Number(row.changes) !== 1)
|
|
416
|
+
return false;
|
|
417
|
+
if (values !== undefined)
|
|
418
|
+
writeHeldValues(deps.db, (stored) => ({ ...stored, [topicId]: values }));
|
|
419
|
+
return true;
|
|
420
|
+
});
|
|
421
|
+
if (!changed)
|
|
348
422
|
return { ok: false, reason: "topic-not-found" };
|
|
349
423
|
const topic = heldTopics(deps, scheduleId).find((row) => row.id === topicId);
|
|
350
424
|
return topic === undefined
|
|
@@ -376,6 +450,33 @@ export function moveTopic(deps, scheduleId, input) {
|
|
|
376
450
|
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
377
451
|
});
|
|
378
452
|
}
|
|
453
|
+
// Saves the queue as sent, without touching the schedule's other settings or its next run.
|
|
454
|
+
// Topics already queued are not checked again; a new or renamed one must suit the template.
|
|
455
|
+
export function replaceTopics(deps, scheduleId, input) {
|
|
456
|
+
const parsed = topicQueueSchema.safeParse(input);
|
|
457
|
+
if (!parsed.success)
|
|
458
|
+
return { ok: false, reason: "invalid-input" };
|
|
459
|
+
return transact(deps.db, () => {
|
|
460
|
+
const open = openSchedule(deps, scheduleId);
|
|
461
|
+
if (!open.ok)
|
|
462
|
+
return open;
|
|
463
|
+
const schedule = open.value;
|
|
464
|
+
if (schedule.version !== parsed.data.baseVersion)
|
|
465
|
+
return { ok: false, reason: "conflict" };
|
|
466
|
+
const template = deps.template(schedule.templateId, schedule.templateVersion);
|
|
467
|
+
if (!template)
|
|
468
|
+
return { ok: false, reason: "missing-template" };
|
|
469
|
+
const problems = topicRowProblems(parsed.data.items, {
|
|
470
|
+
keywords: templateKeywords(template.document.form),
|
|
471
|
+
topicKeyword: schedule.topicKeyword,
|
|
472
|
+
kept: schedule.items,
|
|
473
|
+
});
|
|
474
|
+
if (problems.length > 0)
|
|
475
|
+
return { ok: false, reason: "invalid-topics", message: problems.slice(0, 5).join(" ") };
|
|
476
|
+
writeItems(deps, scheduleId, schedule.version, parsed.data.items);
|
|
477
|
+
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
478
|
+
});
|
|
479
|
+
}
|
|
379
480
|
// Takes one queued topic off this schedule and puts it on another's queue.
|
|
380
481
|
export function transferTopic(deps, scheduleId, input) {
|
|
381
482
|
const parsed = topicTransferSchema.safeParse(input);
|
|
@@ -70,6 +70,10 @@ export async function firstRunStatus(deps, statuses) {
|
|
|
70
70
|
const names = detected
|
|
71
71
|
.filter((cli) => cli.usable && cli.id !== "codex-image")
|
|
72
72
|
.map((cli) => cli.displayName);
|
|
73
|
+
const systemVoice = statuses.some((status) => status.readiness.kind === "local" && status.readiness.available);
|
|
74
|
+
const narration = systemVoice
|
|
75
|
+
? "Narration can use your computer's built-in voice (System voice in Settings → Voices); add an ElevenLabs or OpenAI key later for a better one."
|
|
76
|
+
: "Narration still needs a voice provider key, or turn Audio off or upload your own.";
|
|
73
77
|
return {
|
|
74
78
|
firstRun,
|
|
75
79
|
detected,
|
|
@@ -82,7 +86,7 @@ export async function firstRunStatus(deps, statuses) {
|
|
|
82
86
|
detail: !firstRun
|
|
83
87
|
? null
|
|
84
88
|
: names.length > 0
|
|
85
|
-
? `Slopify found ${list(names)} and picked ${names.length === 1 ? "it" : "them"} on Play.
|
|
89
|
+
? `Slopify found ${list(names)} and picked ${names.length === 1 ? "it" : "them"} on Play. ${narration} Keys are optional extras in Settings → Providers.`
|
|
86
90
|
: "Install Claude Code, Codex or Gemini CLI and sign in once in a terminal, then reload this page. Or paste an API key in Settings → Providers.",
|
|
87
91
|
};
|
|
88
92
|
}
|
|
@@ -3,6 +3,7 @@ import { cliPathStatus } from "./cli-paths.js";
|
|
|
3
3
|
import { keyGuides } from "./key-guides.js";
|
|
4
4
|
import { testProviderKey } from "./key-test.js";
|
|
5
5
|
import { isLocalCliProvider } from "./model.js";
|
|
6
|
+
import { modelReach } from "./model-reach.js";
|
|
6
7
|
import { providerStatuses } from "./readiness.js";
|
|
7
8
|
const signInCommand = {
|
|
8
9
|
"claude-code": "claude auth login",
|
|
@@ -37,17 +38,38 @@ function overall(checks, used, setUp) {
|
|
|
37
38
|
return setUp || used ? "ok" : "unused";
|
|
38
39
|
}
|
|
39
40
|
// "Check all" on Settings → Providers: each command-line tool found and signed in, each saved
|
|
40
|
-
// key accepted by its provider, and each chosen model still offered.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// key accepted by its provider, and each chosen model still offered and answering for the key.
|
|
42
|
+
// `only` checks one provider: its row's Check again, or a sign-in fix-it's.
|
|
43
|
+
export async function checkProviderHealth(deps, only) {
|
|
44
|
+
const statuses = (await providerStatuses(deps)).filter((status) => only === undefined || status.id === only);
|
|
43
45
|
const inUse = uses(deps.db);
|
|
44
46
|
const catalogue = deps.catalogue?.();
|
|
45
47
|
const retired = catalogue === undefined ? [] : retiredModelUsage(deps.db, catalogue);
|
|
46
48
|
const providers = await Promise.all(statuses.map((status) => status.readiness.kind === "cli"
|
|
47
49
|
? cliHealth(deps, status, inUse.get(status.id) ?? [])
|
|
48
|
-
:
|
|
50
|
+
: status.readiness.kind === "local"
|
|
51
|
+
? Promise.resolve(localHealth(status, inUse.get(status.id) ?? []))
|
|
52
|
+
: keyedHealth(deps, status, inUse.get(status.id) ?? [], retired)));
|
|
49
53
|
return { checkedAt: deps.clock.now().toISOString(), providers };
|
|
50
54
|
}
|
|
55
|
+
// The system voice: a speech program was found, or not. Nothing to sign in to or pay for.
|
|
56
|
+
function localHealth(status, used) {
|
|
57
|
+
const { readiness } = status;
|
|
58
|
+
const base = { id: status.id, displayName: status.displayName, family: status.family };
|
|
59
|
+
if (readiness.kind !== "local")
|
|
60
|
+
throw new Error("Expected the system voice.");
|
|
61
|
+
const found = readiness.available;
|
|
62
|
+
const checks = [
|
|
63
|
+
{
|
|
64
|
+
label: "Speech program found",
|
|
65
|
+
state: found ? "ok" : used.length > 0 ? "problem" : "skipped",
|
|
66
|
+
detail: found
|
|
67
|
+
? `Speaks with ${readiness.engine ?? "this computer's speech program"}.`
|
|
68
|
+
: `${readiness.issue ?? "No speech program was found on this computer."}${used.length > 0 ? ` It is chosen for ${describe(used)}.` : ""}`,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
return { ...base, state: overall(checks, used.length > 0, found), checks };
|
|
72
|
+
}
|
|
51
73
|
async function cliHealth(deps, status, used) {
|
|
52
74
|
const { readiness } = status;
|
|
53
75
|
if (readiness.kind !== "cli" || !isLocalCliProvider(status.id))
|
|
@@ -79,7 +101,7 @@ async function cliHealth(deps, status, used) {
|
|
|
79
101
|
if (status.cliPath?.managedOnHost === true && deps.hostCliStatus !== undefined) {
|
|
80
102
|
login = (await deps.hostCliStatus(status.id)).login;
|
|
81
103
|
}
|
|
82
|
-
else if (deps.login !== undefined
|
|
104
|
+
else if (deps.login !== undefined) {
|
|
83
105
|
try {
|
|
84
106
|
login = await deps.login(cliPathStatus(deps.db, status.id).command, source, AbortSignal.timeout(15_000));
|
|
85
107
|
}
|
|
@@ -93,13 +115,15 @@ async function cliHealth(deps, status, used) {
|
|
|
93
115
|
? {
|
|
94
116
|
label: "Signed in",
|
|
95
117
|
state: "problem",
|
|
96
|
-
detail:
|
|
118
|
+
detail: source === "gemini"
|
|
119
|
+
? `Gemini CLI is not signed in: ~/.gemini has no Google sign-in and no GEMINI_API_KEY is set. Open a terminal on the computer running it, run "gemini" and choose Login with Google (or set GEMINI_API_KEY), then choose Check all again.`
|
|
120
|
+
: `${name} is not signed in. Open a terminal on the computer running it, run "${signInCommand[source]}" and sign in, then choose Check all again.`,
|
|
97
121
|
}
|
|
98
122
|
: {
|
|
99
123
|
label: "Signed in",
|
|
100
124
|
state: "skipped",
|
|
101
125
|
detail: source === "gemini"
|
|
102
|
-
? `Gemini CLI
|
|
126
|
+
? `Slopify could not tell from the Gemini CLI's files (~/.gemini/settings.json) whether it is signed in. Run "gemini" in a terminal once to confirm it is signed in.`
|
|
103
127
|
: `Slopify could not tell whether ${name} is signed in. Run "${signInCommand[source]}" in a terminal if runs fail with a sign-in error.`,
|
|
104
128
|
});
|
|
105
129
|
if (used.length > 0)
|
|
@@ -138,6 +162,37 @@ async function cliModels(deps, status, used) {
|
|
|
138
162
|
detail: `${status.displayName} does not offer ${describe(missing)}. Update ${status.displayName}, or pick another model there and save.`,
|
|
139
163
|
};
|
|
140
164
|
}
|
|
165
|
+
// Asks the provider, with the saved key, for each chosen model: a model the account can't use
|
|
166
|
+
// fails here before a run does.
|
|
167
|
+
async function reachable(deps, status, used) {
|
|
168
|
+
const name = status.displayName;
|
|
169
|
+
const found = await modelReach(deps, status.id, used.map((use) => use.model));
|
|
170
|
+
if (found === undefined)
|
|
171
|
+
return {
|
|
172
|
+
label: "Model reachable",
|
|
173
|
+
state: "skipped",
|
|
174
|
+
detail: `${name} has no model check Slopify can ask without generating something. The key works; the first run shows whether the model does.`,
|
|
175
|
+
};
|
|
176
|
+
const missing = used.filter((use) => found.get(use.model) === "missing");
|
|
177
|
+
if (missing.length > 0)
|
|
178
|
+
return {
|
|
179
|
+
label: "Model reachable",
|
|
180
|
+
state: "problem",
|
|
181
|
+
detail: `${name} does not offer ${describe(missing)} to this key: the model id is wrong, retired, or the account has no access to it. Pick another model there and save, or enable the model for your account, then choose Check all again.`,
|
|
182
|
+
};
|
|
183
|
+
const unknown = [...found].filter(([, reach]) => reach === "unknown").map(([model]) => model);
|
|
184
|
+
if (unknown.length > 0)
|
|
185
|
+
return {
|
|
186
|
+
label: "Model reachable",
|
|
187
|
+
state: "warning",
|
|
188
|
+
detail: `${name} didn't answer for ${unknown.join(", ")}, so Slopify couldn't confirm the key can use ${unknown.length === 1 ? "it" : "them"}. Check your internet connection, then choose Check all again.`,
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
label: "Model reachable",
|
|
192
|
+
state: "ok",
|
|
193
|
+
detail: `${name} answered for ${[...found.keys()].join(", ")} with this key.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
141
196
|
function describe(missing) {
|
|
142
197
|
const shown = missing.slice(0, 3).map((use) => `${use.model} (${use.where})`);
|
|
143
198
|
return missing.length > 3
|
|
@@ -186,5 +241,7 @@ async function keyedHealth(deps, status, used, retired) {
|
|
|
186
241
|
state: "ok",
|
|
187
242
|
detail: "Every chosen model is still offered.",
|
|
188
243
|
});
|
|
244
|
+
if (used.length > 0 && test.ok)
|
|
245
|
+
checks.push(await reachable(deps, status, used));
|
|
189
246
|
return { ...base, state: overall(checks, used.length > 0, true), checks };
|
|
190
247
|
}
|
|
@@ -17,6 +17,19 @@ const openAi = {
|
|
|
17
17
|
url: "https://developers.openai.com/api/docs/guides/error-codes",
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
|
+
const google = {
|
|
21
|
+
signUp: { label: "aistudio.google.com", url: "https://aistudio.google.com" },
|
|
22
|
+
keyPage: { label: "aistudio.google.com/apikey", url: "https://aistudio.google.com/apikey" },
|
|
23
|
+
billing: { label: "Projects → Set up billing", url: "https://aistudio.google.com/projects" },
|
|
24
|
+
steps: [
|
|
25
|
+
"Sign in to Google AI Studio with your Google account.",
|
|
26
|
+
"Open Projects and choose Set up billing next to your project: Gemini image models are not available on the free tier.",
|
|
27
|
+
"Open API keys, choose Create API key, and copy it.",
|
|
28
|
+
"Paste it here, choose Save, then Test.",
|
|
29
|
+
],
|
|
30
|
+
permissions: "Use a key made in AI Studio. An older Google Cloud key must be set to Restrict to Gemini API only, or Google rejects it.",
|
|
31
|
+
docs: { label: "Gemini API keys", url: "https://ai.google.dev/gemini-api/docs/api-key" },
|
|
32
|
+
};
|
|
20
33
|
export const keyGuides = {
|
|
21
34
|
openrouter: {
|
|
22
35
|
signUp: { label: "openrouter.ai/sign-up", url: "https://openrouter.ai/sign-up" },
|
|
@@ -33,18 +46,15 @@ export const keyGuides = {
|
|
|
33
46
|
},
|
|
34
47
|
"openai-tts": openAi,
|
|
35
48
|
"openai-image": openAi,
|
|
36
|
-
"google-image":
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
billing: { label: "Projects → Set up billing", url: "https://aistudio.google.com/projects" },
|
|
49
|
+
"google-image": google,
|
|
50
|
+
"google-tts": {
|
|
51
|
+
...google,
|
|
40
52
|
steps: [
|
|
41
53
|
"Sign in to Google AI Studio with your Google account.",
|
|
42
|
-
"Open Projects and choose Set up billing next to your project: Gemini image models are not available on the free tier.",
|
|
43
54
|
"Open API keys, choose Create API key, and copy it.",
|
|
44
55
|
"Paste it here, choose Save, then Test.",
|
|
45
56
|
],
|
|
46
|
-
permissions:
|
|
47
|
-
docs: { label: "Gemini API keys", url: "https://ai.google.dev/gemini-api/docs/api-key" },
|
|
57
|
+
permissions: `The same Gemini API key as Google images: with one saved there, Gemini voices use it and nothing is needed here. ${google.permissions}`,
|
|
48
58
|
},
|
|
49
59
|
fal: {
|
|
50
60
|
signUp: { label: "fal.ai/login", url: "https://fal.ai/login" },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { keyGuides } from "./key-guides.js";
|
|
2
|
+
import { keyWithShared } from "./keys.js";
|
|
2
3
|
import { providerById } from "./model.js";
|
|
3
|
-
import { keyOf } from "./repo.js";
|
|
4
4
|
// What the Test button found. `ok` is the only thing the screen needs to colour the line;
|
|
5
5
|
// `message` says what happened and, when it failed, exactly what to do.
|
|
6
6
|
export const keyTestResults = [
|
|
@@ -54,15 +54,23 @@ export function keyTestOutcome(provider, answer, checkedAt, badKey) {
|
|
|
54
54
|
return done("unexpected", `${name} answered the key check with HTTP ${status}, which Slopify did not expect. Check the key at ${keyPage}, then choose Test again; if it keeps happening, use Download diagnostics in Settings and report it.`);
|
|
55
55
|
}
|
|
56
56
|
// The Test button. The key is read for this one request, sent only to its own provider, and
|
|
57
|
-
// never appears in the answer, a log line or an error.
|
|
58
|
-
|
|
57
|
+
// never appears in the answer, a log line or an error. `pasted` is a key typed into the field
|
|
58
|
+
// and not saved yet: it is tried instead of the saved one and is not stored.
|
|
59
|
+
export async function testProviderKey(deps, provider, pasted) {
|
|
59
60
|
const at = () => deps.clock.now().toISOString();
|
|
60
61
|
const probe = Object.hasOwn(deps.probes, provider) ? deps.probes[provider] : undefined;
|
|
61
62
|
if (probe === undefined || providerById(provider).auth !== "key")
|
|
62
63
|
throw new Error(`${provider} has no key to test`);
|
|
63
|
-
const
|
|
64
|
+
const candidate = pasted?.trim();
|
|
65
|
+
const key = candidate === undefined || candidate === "" ? keyWithShared(deps.db, provider) : candidate;
|
|
64
66
|
if (key === undefined)
|
|
65
67
|
return keyTestOutcome(provider, { failure: "no-key" }, at());
|
|
68
|
+
const outcome = await probeKey(deps, provider, probe, key, at);
|
|
69
|
+
return candidate === undefined || candidate === "" || !outcome.ok
|
|
70
|
+
? outcome
|
|
71
|
+
: { ...outcome, message: `${outcome.message} It is not saved yet: choose Save to keep it.` };
|
|
72
|
+
}
|
|
73
|
+
async function probeKey(deps, provider, probe, key, at) {
|
|
66
74
|
let status;
|
|
67
75
|
let body = "";
|
|
68
76
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { providerById } from "./model.js";
|
|
1
|
+
import { providerById, sharedKeyOf } from "./model.js";
|
|
2
2
|
import { deleteKey, hasKey, keyOf, upsertKey } from "./repo.js";
|
|
3
3
|
// The Settings field shows a stored key masked, with nothing legible in it, and a key never
|
|
4
4
|
// appears outside a provider call. So the mask is a constant: it carries no character of
|
|
@@ -12,7 +12,7 @@ export function keyStatus(deps, provider) {
|
|
|
12
12
|
// Trimmed, stored as given, overwriting any previous one. No format
|
|
13
13
|
// check and no test call.
|
|
14
14
|
export function saveProviderKey(deps, provider, key) {
|
|
15
|
-
if (providerById(provider).auth
|
|
15
|
+
if (providerById(provider).auth !== "key") {
|
|
16
16
|
return { ok: false, reason: "cli-provider" };
|
|
17
17
|
}
|
|
18
18
|
const trimmed = key.trim();
|
|
@@ -31,7 +31,7 @@ export function saveProviderKey(deps, provider, key) {
|
|
|
31
31
|
return { ok: true };
|
|
32
32
|
}
|
|
33
33
|
export function removeProviderKey(deps, provider) {
|
|
34
|
-
if (providerById(provider).auth
|
|
34
|
+
if (providerById(provider).auth !== "key") {
|
|
35
35
|
return { ok: false, reason: "cli-provider" };
|
|
36
36
|
}
|
|
37
37
|
return deleteKey(deps.db, provider) ? { ok: true } : { ok: false, reason: "absent" };
|
|
@@ -40,12 +40,17 @@ export function removeProviderKey(deps, provider) {
|
|
|
40
40
|
// every time and hands back a plain string, so an attempt holds the value it started
|
|
41
41
|
// with and a save or a remove landing mid-run reaches the next attempt only.
|
|
42
42
|
export function keyForAttempt(deps, provider) {
|
|
43
|
-
if (providerById(provider).auth
|
|
43
|
+
if (providerById(provider).auth !== "key") {
|
|
44
44
|
return { ok: false, reason: "cli-provider" };
|
|
45
45
|
}
|
|
46
|
-
const key =
|
|
46
|
+
const key = keyWithShared(deps.db, provider);
|
|
47
47
|
return key === undefined ? { ok: false, reason: "key-missing" } : { ok: true, key };
|
|
48
48
|
}
|
|
49
|
+
// The provider's own key, else the key of the provider it shares one with (`sharedKeyOf`).
|
|
50
|
+
export function keyWithShared(db, provider) {
|
|
51
|
+
const shared = sharedKeyOf[provider];
|
|
52
|
+
return keyOf(db, provider) ?? (shared === undefined ? undefined : keyOf(db, shared));
|
|
53
|
+
}
|
|
49
54
|
function codeOf(error) {
|
|
50
55
|
if (error instanceof Error && "errcode" in error && typeof error.errcode === "number") {
|
|
51
56
|
return `sqlite error ${String(error.errcode)}`;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { keyOf } from "./repo.js";
|
|
2
|
+
// ceiling: a provider chosen in many templates still costs a handful of reads, not one per
|
|
3
|
+
// model anywhere in the library.
|
|
4
|
+
const modelsChecked = 5;
|
|
5
|
+
export async function modelReach(deps, provider, models) {
|
|
6
|
+
const probe = Object.hasOwn(deps.probes, provider) ? deps.probes[provider] : undefined;
|
|
7
|
+
const key = keyOf(deps.db, provider);
|
|
8
|
+
if (probe?.model === undefined || key === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const reads = new Map();
|
|
11
|
+
const read = (url) => {
|
|
12
|
+
const known = reads.get(url);
|
|
13
|
+
if (known !== undefined)
|
|
14
|
+
return known;
|
|
15
|
+
const started = (async () => {
|
|
16
|
+
try {
|
|
17
|
+
const response = await deps.fetch(url, {
|
|
18
|
+
headers: probe.headers(key),
|
|
19
|
+
signal: AbortSignal.timeout(15_000),
|
|
20
|
+
redirect: "error",
|
|
21
|
+
});
|
|
22
|
+
// A list is read whole; a single model's page only for its status.
|
|
23
|
+
const body = probe.model?.lists === undefined ? "" : await response.text();
|
|
24
|
+
if (probe.model?.lists === undefined)
|
|
25
|
+
await response.body?.cancel().catch(() => { });
|
|
26
|
+
return { status: response.status, body };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
reads.set(url, started);
|
|
33
|
+
return started;
|
|
34
|
+
};
|
|
35
|
+
const found = new Map();
|
|
36
|
+
for (const model of [...new Set(models)].slice(0, modelsChecked)) {
|
|
37
|
+
const answer = await read(probe.model.url(model));
|
|
38
|
+
found.set(model, verdict(answer, model, probe.model.lists));
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
function verdict(answer, model, lists) {
|
|
43
|
+
if (answer === undefined)
|
|
44
|
+
return "unknown";
|
|
45
|
+
if (answer.status >= 200 && answer.status < 300) {
|
|
46
|
+
if (lists === undefined)
|
|
47
|
+
return "found";
|
|
48
|
+
const listed = lists(answer.body, model);
|
|
49
|
+
return listed === undefined ? "unknown" : listed ? "found" : "missing";
|
|
50
|
+
}
|
|
51
|
+
return answer.status === 404 && lists === undefined ? "missing" : "unknown";
|
|
52
|
+
}
|
|
@@ -14,6 +14,8 @@ export const providerIds = [
|
|
|
14
14
|
"openai-tts",
|
|
15
15
|
"cartesia",
|
|
16
16
|
"inworld",
|
|
17
|
+
"system-voice",
|
|
18
|
+
"google-tts",
|
|
17
19
|
"fal",
|
|
18
20
|
"replicate",
|
|
19
21
|
"openai-image",
|
|
@@ -28,6 +30,12 @@ export function isLocalCliProvider(id) {
|
|
|
28
30
|
export function localCliConcurrency(id) {
|
|
29
31
|
return id === "codex-image" ? 4 : 3;
|
|
30
32
|
}
|
|
33
|
+
export const systemVoiceProvider = "system-voice";
|
|
34
|
+
// A provider Slopify's model list (models.yaml) does not carry: an agent CLI lists its own
|
|
35
|
+
// models, and the system voice's "models" are the speech programs found on this computer.
|
|
36
|
+
export function isUncataloguedProvider(id) {
|
|
37
|
+
return isLocalCliProvider(id) || id === systemVoiceProvider;
|
|
38
|
+
}
|
|
31
39
|
export const providers = [
|
|
32
40
|
{ id: "openrouter", family: "llm", displayName: "OpenRouter", auth: "key" },
|
|
33
41
|
{
|
|
@@ -58,6 +66,8 @@ export const providers = [
|
|
|
58
66
|
{ id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
|
|
59
67
|
{ id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
|
|
60
68
|
{ id: "inworld", family: "tts", displayName: "Inworld", auth: "key" },
|
|
69
|
+
{ id: "system-voice", family: "tts", displayName: "System voice", auth: "local" },
|
|
70
|
+
{ id: "google-tts", family: "tts", displayName: "Google Gemini", auth: "key" },
|
|
61
71
|
{ id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
|
|
62
72
|
{ id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
|
|
63
73
|
{ id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
|
|
@@ -71,6 +81,45 @@ export const providers = [
|
|
|
71
81
|
versionArgs: ["--version"],
|
|
72
82
|
},
|
|
73
83
|
];
|
|
84
|
+
// One Gemini API key pays for Google's speech and its images, so Gemini TTS uses the key saved
|
|
85
|
+
// for Google images until one of its own is saved.
|
|
86
|
+
export const sharedKeyOf = {
|
|
87
|
+
"google-tts": "google-image",
|
|
88
|
+
};
|
|
89
|
+
// The 30 prebuilt voices Gemini TTS speaks with (https://ai.google.dev/gemini-api/docs/speech-generation#voices),
|
|
90
|
+
// each with the style Google gives it. Settings → Voices offers them for Google Gemini.
|
|
91
|
+
export const geminiVoices = [
|
|
92
|
+
{ name: "Zephyr", style: "Bright" },
|
|
93
|
+
{ name: "Puck", style: "Upbeat" },
|
|
94
|
+
{ name: "Charon", style: "Informative" },
|
|
95
|
+
{ name: "Kore", style: "Firm" },
|
|
96
|
+
{ name: "Fenrir", style: "Excitable" },
|
|
97
|
+
{ name: "Leda", style: "Youthful" },
|
|
98
|
+
{ name: "Orus", style: "Firm" },
|
|
99
|
+
{ name: "Aoede", style: "Breezy" },
|
|
100
|
+
{ name: "Callirrhoe", style: "Easy-going" },
|
|
101
|
+
{ name: "Autonoe", style: "Bright" },
|
|
102
|
+
{ name: "Enceladus", style: "Breathy" },
|
|
103
|
+
{ name: "Iapetus", style: "Clear" },
|
|
104
|
+
{ name: "Umbriel", style: "Easy-going" },
|
|
105
|
+
{ name: "Algieba", style: "Smooth" },
|
|
106
|
+
{ name: "Despina", style: "Smooth" },
|
|
107
|
+
{ name: "Erinome", style: "Clear" },
|
|
108
|
+
{ name: "Algenib", style: "Gravelly" },
|
|
109
|
+
{ name: "Rasalgethi", style: "Informative" },
|
|
110
|
+
{ name: "Laomedeia", style: "Upbeat" },
|
|
111
|
+
{ name: "Achernar", style: "Soft" },
|
|
112
|
+
{ name: "Alnilam", style: "Firm" },
|
|
113
|
+
{ name: "Schedar", style: "Even" },
|
|
114
|
+
{ name: "Gacrux", style: "Mature" },
|
|
115
|
+
{ name: "Pulcherrima", style: "Forward" },
|
|
116
|
+
{ name: "Achird", style: "Friendly" },
|
|
117
|
+
{ name: "Zubenelgenubi", style: "Casual" },
|
|
118
|
+
{ name: "Vindemiatrix", style: "Gentle" },
|
|
119
|
+
{ name: "Sadachbia", style: "Lively" },
|
|
120
|
+
{ name: "Sadaltager", style: "Knowledgeable" },
|
|
121
|
+
{ name: "Sulafat", style: "Warm" },
|
|
122
|
+
];
|
|
74
123
|
export function providerById(id) {
|
|
75
124
|
const found = providers.find((provider) => provider.id === id);
|
|
76
125
|
if (found === undefined) {
|
|
@@ -2,7 +2,10 @@ import { isLocalCliProvider } from "./model.js";
|
|
|
2
2
|
// These marketplaces host models with different input schemas. Discovery alone cannot
|
|
3
3
|
// make an arbitrary endpoint compatible with our image request.
|
|
4
4
|
export function allowsCustomModel(provider) {
|
|
5
|
-
return provider !== "fal" &&
|
|
5
|
+
return (provider !== "fal" &&
|
|
6
|
+
provider !== "replicate" &&
|
|
7
|
+
provider !== "codex-image" &&
|
|
8
|
+
provider !== "system-voice");
|
|
6
9
|
}
|
|
7
10
|
const ttlMs = 5 * 60_000;
|
|
8
11
|
const retryMs = 30_000;
|
|
@@ -86,6 +89,7 @@ function catalogNotice(provider) {
|
|
|
86
89
|
gemini: "Models come from your installed Gemini CLI, not an account access check. Missing a new model? Update Gemini CLI, rerun the Docker launcher if applicable, then refresh this list.",
|
|
87
90
|
"claude-code": "Models come from Claude Code. Missing a new model? Update Claude Code, rerun the Docker launcher if applicable, then refresh this list. You can also enter an exact model ID.",
|
|
88
91
|
inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
|
|
92
|
+
"system-voice": "These are the speech programs found on this computer, best first. Install another (for example espeak-ng) and refresh to see it.",
|
|
89
93
|
cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
|
|
90
94
|
fal: "These models have input formats supported by Slopify's fal adapter.",
|
|
91
95
|
replicate: "These models have input formats supported by Slopify's Replicate adapter.",
|