@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.
Files changed (198) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/alignment/text.js +3 -1
  4. package/dist/adapters/key-probes.js +57 -8
  5. package/dist/adapters/tts/gemini.js +206 -0
  6. package/dist/adapters/tts/pcm-mp3.js +62 -0
  7. package/dist/adapters/tts/system.js +147 -0
  8. package/dist/assets/models.yaml +38 -0
  9. package/dist/assets/style-preview/narration.mp3 +0 -0
  10. package/dist/assets/style-preview/tall-1.jpg +0 -0
  11. package/dist/assets/style-preview/tall-2.jpg +0 -0
  12. package/dist/assets/style-preview/tall-3.jpg +0 -0
  13. package/dist/assets/style-preview/wide-1.jpg +0 -0
  14. package/dist/assets/style-preview/wide-2.jpg +0 -0
  15. package/dist/assets/style-preview/wide-3.jpg +0 -0
  16. package/dist/catalog/registry.js +4 -1
  17. package/dist/catalog/validate.js +2 -2
  18. package/dist/edge/autostart/docker-record.js +2 -2
  19. package/dist/edge/autostart/docker.js +68 -0
  20. package/dist/edge/autostart/prompt.js +7 -1
  21. package/dist/edge/autostart/service.js +18 -9
  22. package/dist/edge/cli-args.js +71 -0
  23. package/dist/edge/cli.js +11 -16
  24. package/dist/edge/docker-install/run.js +7 -1
  25. package/dist/edge/events/hub.js +3 -1
  26. package/dist/edge/http/actions.js +6 -1
  27. package/dist/edge/http/app.js +2 -0
  28. package/dist/edge/http/byte-range.js +42 -0
  29. package/dist/edge/http/diagnostics.js +1 -0
  30. package/dist/edge/http/draft-files.js +2 -4
  31. package/dist/edge/http/files.js +10 -6
  32. package/dist/edge/http/onboarding.js +64 -4
  33. package/dist/edge/http/project-templates.js +19 -2
  34. package/dist/edge/http/projects.js +6 -1
  35. package/dist/edge/http/providers.js +39 -9
  36. package/dist/edge/http/reviews.js +18 -8
  37. package/dist/edge/http/revision-files.js +2 -4
  38. package/dist/edge/http/schedules.js +9 -5
  39. package/dist/edge/http/storage.js +16 -2
  40. package/dist/edge/http/studio.js +111 -31
  41. package/dist/edge/http/style-preview.js +3 -42
  42. package/dist/edge/http/tutorials.js +59 -0
  43. package/dist/edge/http/youtube-edits.js +11 -2
  44. package/dist/extension/slopify-studio-chrome.zip +0 -0
  45. package/dist/extension/slopify-studio-firefox.zip +0 -0
  46. package/dist/host-cli/gemini-login.js +83 -0
  47. package/dist/host-cli/status.js +4 -2
  48. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  49. package/dist/kernel/ports/model.js +5 -3
  50. package/dist/kernel/ports/system-speech.js +203 -0
  51. package/dist/kernel/runner/graph.js +17 -1
  52. package/dist/kernel/runner/index.js +4 -4
  53. package/dist/kernel/runner/providers.js +1 -1
  54. package/dist/main.js +5 -0
  55. package/dist/model-catalog.js +2 -0
  56. package/dist/patch-notes/3.0.1.md +105 -0
  57. package/dist/patch-notes/3.0.2.md +20 -0
  58. package/dist/patch-notes/index.json +12 -0
  59. package/dist/slices/admission/rules.js +3 -3
  60. package/dist/slices/admission/start.js +1 -1
  61. package/dist/slices/channels/cast-match.js +1 -1
  62. package/dist/slices/channels/repo.js +3 -0
  63. package/dist/slices/channels/schema.js +23 -0
  64. package/dist/slices/channels/service.js +6 -6
  65. package/dist/slices/control/index.js +3 -1
  66. package/dist/slices/control/providers.js +4 -1
  67. package/dist/slices/episodes/related.js +2 -2
  68. package/dist/slices/estimate/requests.js +9 -1
  69. package/dist/slices/eta/history.js +56 -0
  70. package/dist/slices/eta/model.js +63 -0
  71. package/dist/slices/eta/view.js +25 -0
  72. package/dist/slices/fixes/rules.js +1 -1
  73. package/dist/slices/fonts/coverage.js +1 -1
  74. package/dist/slices/loudness/model.js +1 -1
  75. package/dist/slices/model-upkeep/usage.js +4 -2
  76. package/dist/slices/narration/pauses-model.js +1 -1
  77. package/dist/slices/narration/pronunciation.js +2 -2
  78. package/dist/slices/notifications/notifier.js +28 -2
  79. package/dist/slices/notifications/rules.js +26 -2
  80. package/dist/slices/onboarding/first-run.js +40 -14
  81. package/dist/slices/onboarding/full-video.js +81 -0
  82. package/dist/slices/onboarding/model.js +21 -0
  83. package/dist/slices/onboarding/quick-short.js +25 -10
  84. package/dist/slices/play-drafts/convert.js +3 -3
  85. package/dist/slices/play-drafts/readiness.js +1 -1
  86. package/dist/slices/project-templates/from-project.js +10 -3
  87. package/dist/slices/project-templates/next-chapter.js +94 -0
  88. package/dist/slices/project-templates/one-off.js +1 -1
  89. package/dist/slices/project-templates/schema.js +7 -1
  90. package/dist/slices/rebuild/recipe-exports.js +5 -0
  91. package/dist/slices/rebuild/recipe-save.js +1 -1
  92. package/dist/slices/rebuild/recipe-voices.js +1 -1
  93. package/dist/slices/rebuild/review-redo.js +15 -0
  94. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  95. package/dist/slices/rebuild/runtime-review.js +35 -5
  96. package/dist/slices/rebuild/runtime-store.js +23 -1
  97. package/dist/slices/rebuild/runtime-voices.js +2 -2
  98. package/dist/slices/rebuild/service-readiness.js +3 -3
  99. package/dist/slices/reviews/repo.js +16 -4
  100. package/dist/slices/run-cost/panel.js +30 -8
  101. package/dist/slices/run-cost/pricing.js +4 -1
  102. package/dist/slices/schedules/agenda.js +66 -2
  103. package/dist/slices/schedules/repo.js +1 -1
  104. package/dist/slices/schedules/scheduler.js +1 -1
  105. package/dist/slices/schedules/schema.js +43 -2
  106. package/dist/slices/schedules/similar.js +41 -3
  107. package/dist/slices/schedules/topic-list.js +2 -2
  108. package/dist/slices/schedules/topics.js +118 -17
  109. package/dist/slices/settings/first-run.js +5 -1
  110. package/dist/slices/settings/health.js +64 -7
  111. package/dist/slices/settings/key-guides.js +17 -7
  112. package/dist/slices/settings/key-test.js +12 -4
  113. package/dist/slices/settings/keys.js +10 -5
  114. package/dist/slices/settings/model-reach.js +52 -0
  115. package/dist/slices/settings/model.js +49 -0
  116. package/dist/slices/settings/models.js +5 -1
  117. package/dist/slices/settings/readiness.js +14 -2
  118. package/dist/slices/settings/tutorial-schema.js +5 -0
  119. package/dist/slices/shorts/render.js +18 -17
  120. package/dist/slices/storage/documents.js +11 -3
  121. package/dist/slices/storage/downloads.js +33 -1
  122. package/dist/slices/storage/portable.js +4 -2
  123. package/dist/slices/studio/model.js +2 -0
  124. package/dist/slices/studio/pack.js +6 -4
  125. package/dist/slices/studio/queue.js +69 -0
  126. package/dist/slices/studio/settings.js +42 -5
  127. package/dist/slices/style-preview/narration.js +33 -0
  128. package/dist/slices/style-preview/render.js +43 -56
  129. package/dist/slices/style-preview/schema.js +17 -3
  130. package/dist/slices/style-preview/service.js +3 -4
  131. package/dist/slices/style-preview/settings.js +75 -25
  132. package/dist/slices/tutorials/anchors.js +57 -0
  133. package/dist/slices/tutorials/library.js +185 -0
  134. package/dist/slices/voices/audio-files.js +7 -2
  135. package/dist/slices/voices/cast.js +26 -8
  136. package/dist/slices/voices/grouping.js +10 -0
  137. package/dist/slices/voices/model.js +49 -10
  138. package/dist/slices/voices/script.js +1 -1
  139. package/dist/slices/youtube/edits-repo.js +19 -4
  140. package/dist/slices/youtube/placeholders.js +2 -2
  141. package/dist/tutorials/AI-CLIs.md +146 -0
  142. package/dist/tutorials/Backups.md +121 -0
  143. package/dist/tutorials/Calendar.md +103 -0
  144. package/dist/tutorials/Cast-Library.md +112 -0
  145. package/dist/tutorials/Channels.md +148 -0
  146. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  147. package/dist/tutorials/Docker.md +171 -0
  148. package/dist/tutorials/Document-Themes.md +207 -0
  149. package/dist/tutorials/Editing-a-Project.md +204 -0
  150. package/dist/tutorials/FAQ.md +134 -0
  151. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  152. package/dist/tutorials/Home-and-Projects.md +174 -0
  153. package/dist/tutorials/Home.md +119 -0
  154. package/dist/tutorials/Install.md +154 -0
  155. package/dist/tutorials/Intros-and-Outros.md +86 -0
  156. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  157. package/dist/tutorials/Library-Overview.md +75 -0
  158. package/dist/tutorials/Models.md +133 -0
  159. package/dist/tutorials/Multiple-Voices.md +161 -0
  160. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  161. package/dist/tutorials/Notifications.md +86 -0
  162. package/dist/tutorials/Other-Languages.md +110 -0
  163. package/dist/tutorials/PDF-Documents.md +100 -0
  164. package/dist/tutorials/Play-Images.md +135 -0
  165. package/dist/tutorials/Play-Narration.md +167 -0
  166. package/dist/tutorials/Play-Outputs.md +109 -0
  167. package/dist/tutorials/Play-Overview.md +216 -0
  168. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  169. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  170. package/dist/tutorials/Project-Page.md +228 -0
  171. package/dist/tutorials/Prompts.md +136 -0
  172. package/dist/tutorials/Providers-and-Keys.md +290 -0
  173. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  174. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  175. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  176. package/dist/tutorials/Schedules.md +154 -0
  177. package/dist/tutorials/Settings-Reference.md +258 -0
  178. package/dist/tutorials/Shorts.md +116 -0
  179. package/dist/tutorials/Start-at-Login.md +126 -0
  180. package/dist/tutorials/Studio-Extension.md +146 -0
  181. package/dist/tutorials/Templates.md +146 -0
  182. package/dist/tutorials/Trash.md +87 -0
  183. package/dist/tutorials/Troubleshooting.md +149 -0
  184. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  185. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  186. package/dist/tutorials/Video-Editing.md +135 -0
  187. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  188. package/dist/tutorials/YouTube-Description.md +132 -0
  189. package/dist/tutorials/Your-First-Short.md +125 -0
  190. package/dist/tutorials/_Footer.md +1 -0
  191. package/dist/tutorials/_Sidebar.md +69 -0
  192. package/dist/web/assets/index-C4McGNke.js +307 -0
  193. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  194. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
  195. package/dist/web/index.html +2 -2
  196. package/package.json +2 -2
  197. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  198. 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 { scheduleChannel } from "../channels/repo.js";
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, every project's title, and
188
- // the titles of the videos its channel made before Slopify (Channel page → Existing videos).
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).title);
199
- const channel = scheduleChannel(deps.db, scheduleId);
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 titles = [];
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
- titles.push(title.data);
353
+ items.push({ title: title.data, values: topic.values });
318
354
  }
319
- if (open.value.items.length + titles.length > queueMax)
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, ...titles.map((title) => ({ title, values: {} }))]), at, scheduleId);
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
- export function editHeldTopic(deps, scheduleId, topicId, title) {
341
- const parsed = topicTitleSchema.safeParse(title);
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 changed = deps.db
345
- .prepare("UPDATE schedule_topics SET title=? WHERE id=? AND schedule_id=? AND state='held'")
346
- .run(parsed.data, topicId, scheduleId);
347
- if (Number(changed.changes) !== 1)
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. Narration still needs a voice provider key, or turn Audio off or upload your own. Keys are optional extras in Settings → Providers.`
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
- export async function checkProviderHealth(deps) {
42
- const statuses = await providerStatuses(deps);
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
- : keyedHealth(deps, status, inUse.get(status.id) ?? [], retired)));
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 && source !== "gemini") {
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: `${name} is not signed in. Open a terminal on the computer running it, run "${signInCommand[source]}" and sign in, then choose Check all again.`,
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 has no sign-in check Slopify can ask. Run "gemini" in a terminal once to confirm it is signed in.`
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
- signUp: { label: "aistudio.google.com", url: "https://aistudio.google.com" },
38
- keyPage: { label: "aistudio.google.com/apikey", url: "https://aistudio.google.com/apikey" },
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: "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.",
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
- export async function testProviderKey(deps, provider) {
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 key = keyOf(deps.db, provider);
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 === "cli") {
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 === "cli") {
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 === "cli") {
43
+ if (providerById(provider).auth !== "key") {
44
44
  return { ok: false, reason: "cli-provider" };
45
45
  }
46
- const key = keyOf(deps.db, provider);
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" && provider !== "replicate" && provider !== "codex-image";
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.",