@gentbajko/slopify 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/llm/codex.js +3 -0
- package/dist/adapters/llm/gemini-workspace.js +24 -1
- package/dist/adapters/llm/gemini.js +1 -1
- package/dist/adapters/llm/openrouter.js +1 -0
- package/dist/assets/models.yaml +470 -0
- package/dist/catalog/registry.js +147 -0
- package/dist/catalog/schema.js +91 -0
- package/dist/catalog/store.js +90 -0
- package/dist/catalog/validate.js +43 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/planning.js +101 -0
- package/dist/edge/http/projects.js +4 -2
- package/dist/edge/http/providers.js +28 -0
- package/dist/kernel/db/migrations/0003-batch-queue.sql +11 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/text.js +26 -0
- package/dist/kernel/runner/providers.js +8 -6
- package/dist/kernel/runner/queue.js +65 -0
- package/dist/main.js +41 -6
- package/dist/slices/admission/repo.js +6 -1
- package/dist/slices/admission/start.js +9 -8
- package/dist/slices/article/continuation.js +8 -1
- package/dist/slices/article/segments.js +1 -0
- package/dist/slices/batch/index.js +82 -0
- package/dist/slices/control/index.js +5 -1
- package/dist/slices/control/providers.js +14 -1
- package/dist/slices/estimate/index.js +81 -0
- package/dist/slices/narration/plan.js +58 -0
- package/dist/slices/narration/run.js +6 -27
- package/dist/slices/research/run.js +4 -0
- package/dist/slices/storage/staging.js +2 -1
- package/dist/slices/thumbnail/run.js +3 -0
- package/dist/web/assets/index-6zz8telY.css +1 -0
- package/dist/web/assets/index-CbYEcBOa.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-6QIQ-TzO.css +0 -1
- package/dist/web/assets/index-CNgWL-ct.js +0 -130
package/dist/main.js
CHANGED
|
@@ -7,6 +7,8 @@ import { buildRegistry } from "./adapter-registry.js";
|
|
|
7
7
|
import { alignSubtitles } from "./adapters/alignment/index.js";
|
|
8
8
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
9
9
|
import { nodeRunCli } from "./adapters/llm/run-cli.js";
|
|
10
|
+
import { curateRegistry } from "./catalog/registry.js";
|
|
11
|
+
import { createCatalogueStore } from "./catalog/store.js";
|
|
10
12
|
import { createHub } from "./edge/events/hub.js";
|
|
11
13
|
import { createApp } from "./edge/http/app.js";
|
|
12
14
|
import { createAudioPreviewStore } from "./kernel/audio-preview.js";
|
|
@@ -21,11 +23,13 @@ import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
|
|
|
21
23
|
import { dependenciesOf } from "./kernel/runner/graph.js";
|
|
22
24
|
import { createRunner } from "./kernel/runner/index.js";
|
|
23
25
|
import { stageProviders } from "./kernel/runner/providers.js";
|
|
26
|
+
import { createProviderQueue } from "./kernel/runner/queue.js";
|
|
24
27
|
import { readVersion } from "./kernel/version.js";
|
|
25
28
|
import { modelSources } from "./model-catalog.js";
|
|
26
29
|
import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
|
|
27
30
|
import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
|
|
28
31
|
import { runArticle } from "./slices/article/run.js";
|
|
32
|
+
import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
|
|
29
33
|
import { runImages } from "./slices/images/run.js";
|
|
30
34
|
import { runNarration } from "./slices/narration/run.js";
|
|
31
35
|
import { runResearch } from "./slices/research/run.js";
|
|
@@ -78,13 +82,14 @@ export async function boot(config) {
|
|
|
78
82
|
log,
|
|
79
83
|
post: httpPostEvents(collectorEndpoint(process.env), collectorTimeoutMs),
|
|
80
84
|
}, flushDelayMs);
|
|
81
|
-
const
|
|
85
|
+
const catalogue = createCatalogueStore({ dataDir: paths.dataDir, fetch: globalThis.fetch });
|
|
86
|
+
const registry = curateRegistry(buildRegistry({
|
|
82
87
|
db,
|
|
83
88
|
fetch: globalThis.fetch,
|
|
84
89
|
spawn: nodeRunCli,
|
|
85
90
|
clock,
|
|
86
91
|
probe: nodeCliProbe,
|
|
87
|
-
});
|
|
92
|
+
}), catalogue);
|
|
88
93
|
const audioPreviews = createAudioPreviewStore();
|
|
89
94
|
const runner = wire({
|
|
90
95
|
db,
|
|
@@ -96,6 +101,7 @@ export async function boot(config) {
|
|
|
96
101
|
telemetry,
|
|
97
102
|
flusher,
|
|
98
103
|
registry,
|
|
104
|
+
catalogue,
|
|
99
105
|
ffmpeg,
|
|
100
106
|
audioPreviews,
|
|
101
107
|
});
|
|
@@ -170,6 +176,7 @@ export async function boot(config) {
|
|
|
170
176
|
updater,
|
|
171
177
|
audioPreviews,
|
|
172
178
|
...modelSources(registry),
|
|
179
|
+
catalogue,
|
|
173
180
|
clock,
|
|
174
181
|
ids,
|
|
175
182
|
log,
|
|
@@ -179,6 +186,20 @@ export async function boot(config) {
|
|
|
179
186
|
probe: nodeCliProbe,
|
|
180
187
|
});
|
|
181
188
|
const server = await listen(app, config, log);
|
|
189
|
+
const queueTimer = setInterval(() => {
|
|
190
|
+
const release = updater.beginMutation();
|
|
191
|
+
if (!release)
|
|
192
|
+
return;
|
|
193
|
+
try {
|
|
194
|
+
pumpQueue(updateDb, runner);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
log.write("error", "batch.queue", { detail: "The batch queue could not advance." });
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
release();
|
|
201
|
+
}
|
|
202
|
+
}, 1000);
|
|
182
203
|
listeningPort = portOf(server) ?? config.port;
|
|
183
204
|
// Whatever last run left queued goes out at start. Nothing waits for
|
|
184
205
|
// it, and an unreachable collector costs one refused socket.
|
|
@@ -188,6 +209,7 @@ export async function boot(config) {
|
|
|
188
209
|
let stopActivation = () => { };
|
|
189
210
|
shutdown = () => {
|
|
190
211
|
stopping ??= (async () => {
|
|
212
|
+
clearInterval(queueTimer);
|
|
191
213
|
stopActivation();
|
|
192
214
|
audioPreviews.close();
|
|
193
215
|
try {
|
|
@@ -221,7 +243,7 @@ export async function boot(config) {
|
|
|
221
243
|
throw error;
|
|
222
244
|
}
|
|
223
245
|
}
|
|
224
|
-
function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg, }) {
|
|
246
|
+
function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg, }) {
|
|
225
247
|
// A stage counts what it did and the queue is flushed after each new event. `record`
|
|
226
248
|
// swallows its own failures, so this can neither fail a stage nor widen what leaves the
|
|
227
249
|
// machine - the payload allow-list is checked inside it.
|
|
@@ -237,11 +259,17 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
|
|
|
237
259
|
const writing = { db, paths, ids, clock, log, count };
|
|
238
260
|
// A stage slice is handed the wrapped calls, never the registry: every provider call
|
|
239
261
|
// it makes is already inside the retry policy (kernel/runner/providers.ts).
|
|
240
|
-
const providers = {
|
|
262
|
+
const providers = {
|
|
263
|
+
registry,
|
|
264
|
+
attempts: sqliteAttempts(db, ids),
|
|
265
|
+
clock,
|
|
266
|
+
log,
|
|
267
|
+
queue: createProviderQueue((provider) => catalogue.read().providers[provider]?.maxConcurrent ?? 1),
|
|
268
|
+
};
|
|
241
269
|
return createRunner({
|
|
242
270
|
stages: {
|
|
243
271
|
stagesOf: (projectId) => stagesOf(db, projectId),
|
|
244
|
-
paused: (projectId) => projectPaused(db, projectId),
|
|
272
|
+
paused: (projectId) => projectPaused(db, projectId) || queueWaiting(db, projectId),
|
|
245
273
|
dependenciesOf: (projectId, kind) => {
|
|
246
274
|
const project = projectById(db, projectId);
|
|
247
275
|
return project === undefined ? [] : dependenciesOf(kind, project.config.sources);
|
|
@@ -257,7 +285,14 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
|
|
|
257
285
|
audio: async (context) => {
|
|
258
286
|
const wrapped = stageProviders(providers, context);
|
|
259
287
|
await prepareProvidedArticleSegments(writing, context, wrapped);
|
|
260
|
-
await runNarration({
|
|
288
|
+
await runNarration({
|
|
289
|
+
...video,
|
|
290
|
+
audioPreviews,
|
|
291
|
+
maxCharacters: (provider, model) => {
|
|
292
|
+
const entry = catalogue.models(provider, "tts").find((m) => m.id === model);
|
|
293
|
+
return entry && "tts" in entry ? entry.tts.maxCharacters : 4000;
|
|
294
|
+
},
|
|
295
|
+
}, context, wrapped);
|
|
261
296
|
},
|
|
262
297
|
images: (context) => runImages(writing, context, stageProviders(providers, context)),
|
|
263
298
|
thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
|
+
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
3
4
|
import { chunkModes } from "../narration/chunk.js";
|
|
4
5
|
import { subtitleConfigSchema } from "../subtitles/model.js";
|
|
5
6
|
import { entryModes, formats, stageSources } from "./model.js";
|
|
6
|
-
const providerChoice = z.object({
|
|
7
|
+
const providerChoice = z.object({
|
|
8
|
+
provider: z.string(),
|
|
9
|
+
model: z.string(),
|
|
10
|
+
thinking: z.enum(thinkingModes).optional(),
|
|
11
|
+
});
|
|
7
12
|
const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
|
|
8
13
|
// The shape Play posts and the shape `projects.config` holds, in one place: the second
|
|
9
14
|
// is the first plus the rendered prompt texts.
|
|
@@ -13,7 +13,7 @@ export function initialState(source) {
|
|
|
13
13
|
}
|
|
14
14
|
// The project row, its six stages and the provided content all land together or not at
|
|
15
15
|
// all. The caller ticks the runner after this returns, never inside it.
|
|
16
|
-
export function startRun(deps, draft, rendered) {
|
|
16
|
+
export function startRun(deps, draft, rendered, retainStaged = false) {
|
|
17
17
|
const id = deps.ids.next();
|
|
18
18
|
const at = deps.clock.now().toISOString();
|
|
19
19
|
const config = { ...draft, rendered };
|
|
@@ -49,14 +49,14 @@ export function startRun(deps, draft, rendered) {
|
|
|
49
49
|
for (const stage of stages) {
|
|
50
50
|
insertStage(deps.db, stage);
|
|
51
51
|
}
|
|
52
|
-
attachProvided(deps, id, draft, moved);
|
|
52
|
+
attachProvided(deps, id, draft, moved, retainStaged);
|
|
53
53
|
});
|
|
54
|
-
for (const source of moved) {
|
|
54
|
+
for (const source of retainStaged ? [] : moved) {
|
|
55
55
|
dropStagedSource(deps, source);
|
|
56
56
|
}
|
|
57
57
|
return { project, stages };
|
|
58
58
|
}
|
|
59
|
-
function attachProvided(deps, projectId, draft, collected) {
|
|
59
|
+
function attachProvided(deps, projectId, draft, collected, retainStaged) {
|
|
60
60
|
const { sources, provided } = draft;
|
|
61
61
|
if (sources.research === "provide" && provided.research !== undefined) {
|
|
62
62
|
storeText(deps, {
|
|
@@ -73,24 +73,25 @@ function attachProvided(deps, projectId, draft, collected) {
|
|
|
73
73
|
storeArticleText(deps, { projectId, markdown: provided.article.trim() });
|
|
74
74
|
}
|
|
75
75
|
if (sources.audio === "provide") {
|
|
76
|
-
attach(deps, projectId, "audio", provided.audio, "audio_body", collected);
|
|
76
|
+
attach(deps, projectId, "audio", provided.audio, "audio_body", collected, retainStaged);
|
|
77
77
|
}
|
|
78
78
|
if (sources.thumbnail === "provide") {
|
|
79
|
-
attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected);
|
|
79
|
+
attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected, retainStaged);
|
|
80
80
|
}
|
|
81
81
|
if (sources.images === "provide") {
|
|
82
82
|
// Slideshow order is the order the user left the list in.
|
|
83
83
|
for (const [index, stagedFileId] of (provided.images ?? []).entries()) {
|
|
84
|
-
attach(deps, projectId, "images", stagedFileId, "image", collected, index + 1);
|
|
84
|
+
attach(deps, projectId, "images", stagedFileId, "image", collected, retainStaged, index + 1);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
function attach(deps, projectId, kind, stagedFileId, role, collected, index) {
|
|
88
|
+
function attach(deps, projectId, kind, stagedFileId, role, collected, retainStaged, index) {
|
|
89
89
|
if (stagedFileId === undefined) {
|
|
90
90
|
throw new Error(`the ${kind} stage is provided but carries no staged file`);
|
|
91
91
|
}
|
|
92
92
|
const result = attachStagedFile(deps, {
|
|
93
93
|
stagedFileId,
|
|
94
|
+
retainStaged,
|
|
94
95
|
projectId,
|
|
95
96
|
role,
|
|
96
97
|
...(index === undefined ? {} : { index }),
|
|
@@ -43,7 +43,13 @@ export async function writeArticle(providers, choice, brief, onDelta) {
|
|
|
43
43
|
onDelta(event.text);
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
|
-
let answer = await providers.llm({
|
|
46
|
+
let answer = await providers.llm({
|
|
47
|
+
provider: choice.provider,
|
|
48
|
+
model: choice.model,
|
|
49
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
50
|
+
messages: base,
|
|
51
|
+
check: written,
|
|
52
|
+
}, stream);
|
|
47
53
|
const pieces = [answer.text];
|
|
48
54
|
let tokens = plusUsage(noTokens, answer.usage);
|
|
49
55
|
for (let n = 1; truncated(answer) && n <= continuationLimit; n += 1) {
|
|
@@ -52,6 +58,7 @@ export async function writeArticle(providers, choice, brief, onDelta) {
|
|
|
52
58
|
answer = await providers.llm({
|
|
53
59
|
provider: choice.provider,
|
|
54
60
|
model: choice.model,
|
|
61
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
55
62
|
messages,
|
|
56
63
|
// The last continuation allowed is the one that has to end the article: a fourth
|
|
57
64
|
// truncation is a failed attempt, which is the wrapper's to retry. The
|
|
@@ -18,6 +18,7 @@ export async function writeSegment(providers, choice, config, category, article,
|
|
|
18
18
|
const answer = await providers.llm({
|
|
19
19
|
provider: choice.provider,
|
|
20
20
|
model: choice.model,
|
|
21
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
21
22
|
messages,
|
|
22
23
|
check: (given) => given.text.trim() === "" ? `the ${category} answered with nothing` : undefined,
|
|
23
24
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { derive } from "../../kernel/runner/graph.js";
|
|
4
|
+
import { projectPaused, stagesOf } from "../admission/repo.js";
|
|
5
|
+
import { startRun } from "../admission/start.js";
|
|
6
|
+
import { stagingPath } from "../storage/layout.js";
|
|
7
|
+
import { deleteStagedFile, stagedFileById } from "../storage/repo.js";
|
|
8
|
+
import { dropStagedSource } from "../storage/staging.js";
|
|
9
|
+
const queueRow = z.object({
|
|
10
|
+
projectId: z.string(),
|
|
11
|
+
batchId: z.string(),
|
|
12
|
+
position: z.number(),
|
|
13
|
+
state: z.enum(["queued", "active", "finished"]),
|
|
14
|
+
});
|
|
15
|
+
export function queueEntries(db, batchId) {
|
|
16
|
+
return db
|
|
17
|
+
.prepare(`SELECT project_id AS projectId, batch_id AS batchId, position, state
|
|
18
|
+
FROM project_queue ${batchId === undefined ? "WHERE state != 'finished'" : "WHERE batch_id = ?"}
|
|
19
|
+
ORDER BY position`)
|
|
20
|
+
.all(...(batchId === undefined ? [] : [batchId]))
|
|
21
|
+
.map((row) => queueRow.parse(row));
|
|
22
|
+
}
|
|
23
|
+
export function queueWaiting(db, projectId) {
|
|
24
|
+
return (db
|
|
25
|
+
.prepare("SELECT 1 FROM project_queue WHERE project_id = ? AND state = 'queued'")
|
|
26
|
+
.get(projectId) !== undefined);
|
|
27
|
+
}
|
|
28
|
+
export function batchExists(db, id) {
|
|
29
|
+
return db.prepare("SELECT 1 FROM batches WHERE id = ?").get(id) !== undefined;
|
|
30
|
+
}
|
|
31
|
+
export function enqueueBatch(deps, batchId, runs) {
|
|
32
|
+
if (batchExists(deps.db, batchId))
|
|
33
|
+
return queueEntries(deps.db, batchId);
|
|
34
|
+
const sources = new Set();
|
|
35
|
+
transact(deps.db, () => {
|
|
36
|
+
deps.db
|
|
37
|
+
.prepare("INSERT INTO batches(id, created_at) VALUES (?, ?)")
|
|
38
|
+
.run(batchId, deps.clock.now().toISOString());
|
|
39
|
+
const used = new Set();
|
|
40
|
+
for (const { draft, rendered } of runs) {
|
|
41
|
+
const { project } = startRun(deps, draft, rendered, true);
|
|
42
|
+
deps.db
|
|
43
|
+
.prepare("INSERT INTO project_queue(project_id, batch_id) VALUES (?, ?)")
|
|
44
|
+
.run(project.id, batchId);
|
|
45
|
+
if (draft.sources.audio === "provide" && draft.provided.audio)
|
|
46
|
+
used.add(draft.provided.audio);
|
|
47
|
+
if (draft.sources.thumbnail === "provide" && draft.provided.thumbnail)
|
|
48
|
+
used.add(draft.provided.thumbnail);
|
|
49
|
+
if (draft.sources.images === "provide")
|
|
50
|
+
for (const id of draft.provided.images ?? [])
|
|
51
|
+
used.add(id);
|
|
52
|
+
}
|
|
53
|
+
for (const id of used) {
|
|
54
|
+
const file = stagedFileById(deps.db, id);
|
|
55
|
+
if (file)
|
|
56
|
+
sources.add(stagingPath(deps.paths, file.path));
|
|
57
|
+
deleteStagedFile(deps.db, id);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
for (const source of sources)
|
|
61
|
+
dropStagedSource(deps, source);
|
|
62
|
+
return queueEntries(deps.db, batchId);
|
|
63
|
+
}
|
|
64
|
+
// Only one batch video runs at once. A paused item holds its place; a failed or
|
|
65
|
+
// canceled item releases the next video once its provider calls have drained.
|
|
66
|
+
export function pumpQueue(db, runner) {
|
|
67
|
+
for (const entry of queueEntries(db)) {
|
|
68
|
+
const stages = stagesOf(db, entry.projectId);
|
|
69
|
+
const status = derive(stages, projectPaused(db, entry.projectId));
|
|
70
|
+
if (runner.hasInflight?.(entry.projectId))
|
|
71
|
+
return;
|
|
72
|
+
if (["done", "failed", "canceled"].includes(status)) {
|
|
73
|
+
db.prepare("UPDATE project_queue SET state = 'finished' WHERE project_id = ?").run(entry.projectId);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (status === "paused")
|
|
77
|
+
return;
|
|
78
|
+
db.prepare("UPDATE project_queue SET state = 'active' WHERE project_id = ?").run(entry.projectId);
|
|
79
|
+
runner.tick(entry.projectId);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -137,13 +137,17 @@ export function changeProviders(deps, id, changes) {
|
|
|
137
137
|
(changes.audio.provider !== project.config.audio?.provider ||
|
|
138
138
|
changes.audio.model !== project.config.audio?.model ||
|
|
139
139
|
changes.audio.voice !== project.config.audio?.voice);
|
|
140
|
+
const chunkingChanged = changes.chunking !== undefined &&
|
|
141
|
+
(changes.chunking.mode !== (project.config.chunking?.mode ?? "whole") ||
|
|
142
|
+
(changes.chunking.words ?? 500) !== (project.config.chunking?.words ?? 500));
|
|
140
143
|
const orphaned = transact(deps.db, () => {
|
|
141
|
-
const files = audioChanged ? clearUnfinishedAudio(deps, id) : [];
|
|
144
|
+
const files = audioChanged || chunkingChanged ? clearUnfinishedAudio(deps, id) : [];
|
|
142
145
|
updateProjectConfig(deps.db, id, {
|
|
143
146
|
...project.config,
|
|
144
147
|
...(changes.llm === undefined ? {} : { llm: changes.llm }),
|
|
145
148
|
...(changes.audio === undefined ? {} : { audio: changes.audio }),
|
|
146
149
|
...(changes.images === undefined ? {} : { images: changes.images }),
|
|
150
|
+
...(changes.chunking === undefined ? {} : { chunking: changes.chunking }),
|
|
147
151
|
}, deps.clock.now().toISOString());
|
|
148
152
|
return files;
|
|
149
153
|
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
3
|
+
import { chunkModes } from "../narration/chunk.js";
|
|
2
4
|
const choice = z
|
|
3
5
|
.object({
|
|
4
6
|
provider: z.string().trim().min(1).max(200),
|
|
5
7
|
model: z.string().trim().min(1).max(200),
|
|
8
|
+
thinking: z.enum(thinkingModes).optional(),
|
|
6
9
|
})
|
|
7
10
|
.strict();
|
|
8
11
|
export const providerChangesSchema = z
|
|
@@ -10,6 +13,10 @@ export const providerChangesSchema = z
|
|
|
10
13
|
llm: choice.optional(),
|
|
11
14
|
audio: choice.extend({ voice: z.string().trim().min(1).max(200) }).optional(),
|
|
12
15
|
images: choice.optional(),
|
|
16
|
+
chunking: z
|
|
17
|
+
.object({ mode: z.enum(chunkModes), words: z.number().int().min(1).max(10000).optional() })
|
|
18
|
+
.strict()
|
|
19
|
+
.optional(),
|
|
13
20
|
})
|
|
14
21
|
.strict()
|
|
15
22
|
.refine((value) => Object.keys(value).length > 0, "Pick at least one provider to update.");
|
|
@@ -53,9 +60,15 @@ export async function validateProviderChanges(changes, current) {
|
|
|
53
60
|
if (current.allowsCustomModels?.(picked.provider) === true)
|
|
54
61
|
continue;
|
|
55
62
|
const models = await current.modelsFor(picked.provider, families[key]);
|
|
56
|
-
|
|
63
|
+
const model = models.find((model) => model.id === picked.model);
|
|
64
|
+
if (!model) {
|
|
57
65
|
fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
|
|
58
66
|
}
|
|
67
|
+
else if (key === "llm" &&
|
|
68
|
+
picked.thinking &&
|
|
69
|
+
!model.thinkingModes?.includes(picked.thinking)) {
|
|
70
|
+
fields.push({ field: `${key}.thinking`, message: "Choose a supported thinking setting." });
|
|
71
|
+
}
|
|
59
72
|
}
|
|
60
73
|
return fields;
|
|
61
74
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
2
|
+
const rows = [];
|
|
3
|
+
const model = (family, choice) => choice
|
|
4
|
+
? catalogue?.models(choice.provider, family).find((m) => m.id === choice.model)
|
|
5
|
+
: undefined;
|
|
6
|
+
const llm = model("llm", draft.llm);
|
|
7
|
+
const tts = model("tts", draft.audio);
|
|
8
|
+
const image = model("image", draft.images);
|
|
9
|
+
const generatedArticle = draft.sources.article === "generate";
|
|
10
|
+
const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
|
|
11
|
+
const promptChars = Object.values(rendered).reduce((sum, value) => sum + value.length, 0);
|
|
12
|
+
const row = (stage, amount, detail, variable = false) => {
|
|
13
|
+
rows.push({
|
|
14
|
+
stage,
|
|
15
|
+
low: amount === undefined ? null : amount * (variable ? 0.5 : 1),
|
|
16
|
+
high: amount === undefined ? null : amount * (variable ? 1.5 : 1),
|
|
17
|
+
detail,
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
const textCost = (input, output) => {
|
|
21
|
+
const p = llm?.pricing;
|
|
22
|
+
return p?.inputPerMillionTokens === undefined || p.outputPerMillionTokens === undefined
|
|
23
|
+
? undefined
|
|
24
|
+
: ((input / 4) * p.inputPerMillionTokens + (output / 4) * p.outputPerMillionTokens) / 1000000;
|
|
25
|
+
};
|
|
26
|
+
const textNote = llm?.pricing.note ??
|
|
27
|
+
"CLI subscription or model pricing is unavailable; usage may be billed by your account.";
|
|
28
|
+
if (draft.sources.research === "generate")
|
|
29
|
+
row("Research", textCost(promptChars + 6000, 12000), `Assumes about 2,000 words of notes; web search fees are excluded. ${textNote}`, true);
|
|
30
|
+
else
|
|
31
|
+
row("Research", 0, "Provided or off; no generation charge.");
|
|
32
|
+
if (generatedArticle)
|
|
33
|
+
row("Article", textCost(promptChars + (draft.sources.research === "generate" ? 12000 : 0), articleChars), `${expectedWords.toLocaleString()} expected words. ${textNote}`, true);
|
|
34
|
+
else
|
|
35
|
+
row("Article", 0, "Provided article; no generation charge.");
|
|
36
|
+
if (draft.intro?.mode === "llm" || draft.outro?.mode === "llm")
|
|
37
|
+
row("Intro / outro text", textCost(promptChars + articleChars, 2400), textNote, true);
|
|
38
|
+
if (draft.sources.audio === "generate") {
|
|
39
|
+
const extras = ["intro", "outro"].reduce((n, key) => n + (rendered[key]?.length ?? 0), 0);
|
|
40
|
+
const llmExtras = (draft.intro?.mode === "llm" ? 1200 : 0) + (draft.outro?.mode === "llm" ? 1200 : 0);
|
|
41
|
+
const chars = articleChars + extras + llmExtras;
|
|
42
|
+
const p = tts?.pricing;
|
|
43
|
+
const amount = p?.perMillionCharacters === undefined
|
|
44
|
+
? p?.perMinute === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: (chars / 6 / 150) * p.perMinute
|
|
47
|
+
: (chars / 1000000) * p.perMillionCharacters;
|
|
48
|
+
row("Narration", amount, `About ${chars.toLocaleString()} characters. ${p?.note ?? "Account pricing is unknown."}`, generatedArticle || llmExtras > 0 || p?.perMinute !== undefined);
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
row("Narration", 0, "Provided or off; no generation charge.");
|
|
52
|
+
const images = draft.sources.images === "generate" ? draft.imagePrompts.reduce((n, p) => n + p.number, 0) : 0;
|
|
53
|
+
row("Images", images === 0
|
|
54
|
+
? 0
|
|
55
|
+
: image?.pricing.perImage === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: images * image.pricing.perImage, images === 0
|
|
58
|
+
? "Provided or off."
|
|
59
|
+
: `${images} images. ${image?.pricing.note ?? "Model or account pricing is unknown."}`);
|
|
60
|
+
const thumbnail = ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail);
|
|
61
|
+
row("Thumbnail", thumbnail ? image?.pricing.perImage : 0, thumbnail
|
|
62
|
+
? (image?.pricing.note ?? "Model or account pricing is unknown.")
|
|
63
|
+
: "Provided or off.");
|
|
64
|
+
if (draft.sources.thumbnail === "prompt_by_llm")
|
|
65
|
+
row("Thumbnail prompt", textCost(promptChars + articleChars, 1200), textNote, true);
|
|
66
|
+
row("Export / subtitles", 0, "Local processing; no API fee.");
|
|
67
|
+
return {
|
|
68
|
+
currency: "USD",
|
|
69
|
+
rows,
|
|
70
|
+
expectedWords,
|
|
71
|
+
catalogueDate: catalogue?.status().updatedAt ?? null,
|
|
72
|
+
low: rows.reduce((n, r) => n + (r.low ?? 0), 0),
|
|
73
|
+
high: rows.reduce((n, r) => n + (r.high ?? 0), 0),
|
|
74
|
+
unknown: rows.filter((r) => r.low === null).length,
|
|
75
|
+
assumptions: [
|
|
76
|
+
"Planning estimate, not a spending limit. Generated lengths use a ±50% range; actual output can exceed it.",
|
|
77
|
+
"Assumes roughly 6 characters per word, 4 characters per token, and 150 spoken words per minute.",
|
|
78
|
+
"Retries, extra reasoning tokens, web tools, taxes, discounts and included credits are excluded. Unknown charges are not counted as zero.",
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { splitText } from "../../kernel/ports/text.js";
|
|
4
|
+
import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
|
|
5
|
+
const payload = z.object({ text: z.string(), file: z.string().optional() });
|
|
6
|
+
// Old whole-text plans can exceed a newly known account limit. Preserve finished
|
|
7
|
+
// pieces and their paths, and split only unfinished work before any request starts.
|
|
8
|
+
export function planNarration(deps, stageId, texts, maxCharacters, finished) {
|
|
9
|
+
const old = piecesOf(deps.db, stageId, "chunk");
|
|
10
|
+
const source = old.length
|
|
11
|
+
? old
|
|
12
|
+
: texts.map((text, idx) => ({
|
|
13
|
+
id: deps.ids.next(),
|
|
14
|
+
stageId,
|
|
15
|
+
kind: "chunk",
|
|
16
|
+
idx: idx + 1,
|
|
17
|
+
state: "pending",
|
|
18
|
+
payload: JSON.stringify({ text }),
|
|
19
|
+
}));
|
|
20
|
+
const planned = [];
|
|
21
|
+
for (const piece of source) {
|
|
22
|
+
const data = payload.parse(JSON.parse(piece.payload ?? "null"));
|
|
23
|
+
if (finished(piece)) {
|
|
24
|
+
planned.push({ ...piece, idx: planned.length + 1 });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
for (const [part, text] of splitText(data.text, maxCharacters).entries())
|
|
28
|
+
planned.push({
|
|
29
|
+
...piece,
|
|
30
|
+
id: part === 0 ? piece.id : deps.ids.next(),
|
|
31
|
+
idx: planned.length + 1,
|
|
32
|
+
state: "pending",
|
|
33
|
+
payload: JSON.stringify({ text }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (old.length &&
|
|
37
|
+
planned.length === old.length &&
|
|
38
|
+
planned.every((p, i) => p.id === old[i]?.id &&
|
|
39
|
+
payload.parse(JSON.parse(p.payload ?? "null")).text ===
|
|
40
|
+
payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
|
|
41
|
+
return old;
|
|
42
|
+
transact(deps.db, () => {
|
|
43
|
+
// Negative temporary indices avoid colliding with a retained completed piece.
|
|
44
|
+
deps.db
|
|
45
|
+
.prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
|
|
46
|
+
.run(stageId);
|
|
47
|
+
const known = new Set(old.map((p) => p.id));
|
|
48
|
+
for (const piece of planned) {
|
|
49
|
+
if (known.has(piece.id))
|
|
50
|
+
deps.db
|
|
51
|
+
.prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
|
|
52
|
+
.run(piece.idx, piece.state, piece.payload, piece.id);
|
|
53
|
+
else
|
|
54
|
+
insertPiece(deps.db, piece);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return planned;
|
|
58
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
6
5
|
import { entryModes } from "../admission/model.js";
|
|
7
6
|
import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
|
|
8
7
|
import { entryCategories } from "../library/model.js";
|
|
@@ -12,6 +11,7 @@ import { probeDurationMs } from "../video/ffmpeg.js";
|
|
|
12
11
|
import { chunkNarration, defaultChunking } from "./chunk.js";
|
|
13
12
|
import { joinNarration } from "./concat.js";
|
|
14
13
|
import { observeNarration } from "./live.js";
|
|
14
|
+
import { planNarration } from "./plan.js";
|
|
15
15
|
// An empty narration source fails the stage immediately with no retries. Thrown from the
|
|
16
16
|
// slice rather than from a provider call, so the attempt wrapper never sees it.
|
|
17
17
|
export const nothingToNarrate = "nothing to narrate";
|
|
@@ -53,7 +53,8 @@ export async function runNarration(deps, context, providers) {
|
|
|
53
53
|
if (texts.length === 0) {
|
|
54
54
|
throw new Error(nothingToNarrate);
|
|
55
55
|
}
|
|
56
|
-
const
|
|
56
|
+
const planned = planNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
|
|
57
|
+
const files = await speakChunks(deps, context, providers, choice, planned);
|
|
57
58
|
await storeBody(deps, context, choice, files, outputs);
|
|
58
59
|
await speakSegments(deps, context, providers, choice, outputs);
|
|
59
60
|
deps.log.write("info", "narration.done", {
|
|
@@ -71,29 +72,7 @@ function narrationSource(deps, outputs, projectId) {
|
|
|
71
72
|
}
|
|
72
73
|
return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
|
|
73
74
|
}
|
|
74
|
-
//
|
|
75
|
-
// run wrote and narrates only the ones that did not finish.
|
|
76
|
-
function plan(deps, context, texts) {
|
|
77
|
-
const existing = piecesOf(deps.db, context.stage.id, "chunk");
|
|
78
|
-
if (existing.length > 0) {
|
|
79
|
-
return existing;
|
|
80
|
-
}
|
|
81
|
-
const planned = texts.map((text, index) => ({
|
|
82
|
-
id: deps.ids.next(),
|
|
83
|
-
stageId: context.stage.id,
|
|
84
|
-
kind: "chunk",
|
|
85
|
-
idx: index + 1,
|
|
86
|
-
state: "pending",
|
|
87
|
-
payload: JSON.stringify({ text }),
|
|
88
|
-
}));
|
|
89
|
-
transact(deps.db, () => {
|
|
90
|
-
for (const piece of planned) {
|
|
91
|
-
insertPiece(deps.db, piece);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
return planned;
|
|
95
|
-
}
|
|
96
|
-
// Every chunk is synthesized in parallel. A chunk a previous run finished is not spoken
|
|
75
|
+
// Chunks are scheduled through the shared provider queue. A chunk a previous run finished is not spoken
|
|
97
76
|
// again, and one that fails takes down the whole stage while its siblings finish and keep their
|
|
98
77
|
// audio for the next resume.
|
|
99
78
|
async function speakChunks(deps, context, providers, choice, pieces) {
|
|
@@ -107,7 +86,7 @@ async function speakChunks(deps, context, providers, choice, pieces) {
|
|
|
107
86
|
if (kept !== undefined) {
|
|
108
87
|
return { ok: true, file: kept };
|
|
109
88
|
}
|
|
110
|
-
const file = `${chunkDir}/${
|
|
89
|
+
const file = `${chunkDir}/${piece.id}.mp3`;
|
|
111
90
|
setPiece(deps.db, piece.id, "running", piece.payload);
|
|
112
91
|
try {
|
|
113
92
|
const spoken = await providers.forPiece(piece.id).tts({
|
|
@@ -52,6 +52,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
52
52
|
const answer = await providers.llm({
|
|
53
53
|
provider: choice.provider,
|
|
54
54
|
model: choice.model,
|
|
55
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
55
56
|
messages: synthesisMessages(brief, findings),
|
|
56
57
|
previewLabel: "Writing research notes",
|
|
57
58
|
check: (given) => sourcedAnswer("the synthesis", given.text),
|
|
@@ -71,6 +72,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
71
72
|
stage: "research",
|
|
72
73
|
provider: choice.provider,
|
|
73
74
|
model: choice.model,
|
|
75
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
74
76
|
...tokens,
|
|
75
77
|
});
|
|
76
78
|
deps.log.write("info", "research.done", {
|
|
@@ -89,6 +91,7 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
89
91
|
const answer = await providers.llm({
|
|
90
92
|
provider: choice.provider,
|
|
91
93
|
model: choice.model,
|
|
94
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
92
95
|
messages: plannerMessages(brief),
|
|
93
96
|
previewLabel: "Planning research",
|
|
94
97
|
// An empty answer, or one with no chapter in it, is a failed attempt.
|
|
@@ -130,6 +133,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
130
133
|
const answer = await providers.forPiece(piece.id).llm({
|
|
131
134
|
provider: choice.provider,
|
|
132
135
|
model: choice.model,
|
|
136
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
133
137
|
messages: subAgentMessages(brief, kept.title, outline),
|
|
134
138
|
previewLabel: kept.title,
|
|
135
139
|
// Grounding is asked for explicitly, so a model without it says so
|
|
@@ -140,7 +140,8 @@ export function attachStagedFile(deps, input) {
|
|
|
140
140
|
try {
|
|
141
141
|
transact(deps.db, () => {
|
|
142
142
|
insertOutput(deps.db, output);
|
|
143
|
-
|
|
143
|
+
if (!input.retainStaged)
|
|
144
|
+
deleteStagedFile(deps.db, staged.id);
|
|
144
145
|
});
|
|
145
146
|
}
|
|
146
147
|
catch (error) {
|
|
@@ -81,6 +81,7 @@ async function byLlm(deps, context, providers, project) {
|
|
|
81
81
|
const answer = await providers.llm({
|
|
82
82
|
provider: llm.provider,
|
|
83
83
|
model: llm.model,
|
|
84
|
+
...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
|
|
84
85
|
messages,
|
|
85
86
|
// An empty answer is a failed attempt, and the wrapper is what retries it.
|
|
86
87
|
check: (given) => writtenPrompt(given.text),
|
|
@@ -151,6 +152,7 @@ async function make(deps, context, providers, project, choice, prompt) {
|
|
|
151
152
|
const made = await providers.image({
|
|
152
153
|
provider: choice.provider,
|
|
153
154
|
model: choice.model,
|
|
155
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
154
156
|
prompt,
|
|
155
157
|
aspect: project.format,
|
|
156
158
|
});
|
|
@@ -175,6 +177,7 @@ async function make(deps, context, providers, project, choice, prompt) {
|
|
|
175
177
|
prompt,
|
|
176
178
|
provider: choice.provider,
|
|
177
179
|
model: choice.model,
|
|
180
|
+
...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
|
|
178
181
|
},
|
|
179
182
|
createdAt: deps.clock.now().toISOString(),
|
|
180
183
|
};
|