@gentbajko/slopify 0.7.0 → 0.8.1
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 +4 -0
- package/dist/edge/http/open-folder.js +61 -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/edge/open-folder.js +25 -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 +43 -6
- package/dist/slices/admission/repo.js +13 -2
- 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 +4 -1
- package/dist/slices/control/providers.js +18 -1
- package/dist/slices/estimate/index.js +81 -0
- package/dist/slices/narration/chunk.js +21 -22
- 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-DkC4WXhk.css +1 -0
- package/dist/web/assets/index-GszhBVNn.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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { providers } from "../slices/settings/model.js";
|
|
3
|
+
const money = z.number().finite().nonnegative();
|
|
4
|
+
const base = z.object({
|
|
5
|
+
provider: z.string().regex(/^[a-z0-9-]+$/),
|
|
6
|
+
id: z.string().min(1).max(200),
|
|
7
|
+
name: z.string().min(1).max(120),
|
|
8
|
+
enabled: z.boolean().default(true),
|
|
9
|
+
deprecated: z.boolean().default(false),
|
|
10
|
+
source: z.url(),
|
|
11
|
+
keywords: z.array(z.string().max(60)).default([]),
|
|
12
|
+
pricing: z
|
|
13
|
+
.object({
|
|
14
|
+
inputPerMillionTokens: money.optional(),
|
|
15
|
+
outputPerMillionTokens: money.optional(),
|
|
16
|
+
perMillionCharacters: money.optional(),
|
|
17
|
+
perImage: money.optional(),
|
|
18
|
+
perMinute: money.optional(),
|
|
19
|
+
note: z.string().max(1000).optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.default({}),
|
|
23
|
+
});
|
|
24
|
+
export const llmModelSchema = base
|
|
25
|
+
.extend({
|
|
26
|
+
llm: z
|
|
27
|
+
.object({
|
|
28
|
+
contextTokens: z.number().int().positive().optional(),
|
|
29
|
+
maxOutputTokens: z.number().int().positive().optional(),
|
|
30
|
+
webSearch: z.boolean().default(false),
|
|
31
|
+
thinking: z
|
|
32
|
+
.partialRecord(z.enum(["off", "low", "medium", "high", "xhigh"]), z
|
|
33
|
+
.object({
|
|
34
|
+
budget: z.number().int().min(-1).optional(),
|
|
35
|
+
level: z.enum(["minimal", "low", "medium", "high"]).optional(),
|
|
36
|
+
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict())
|
|
39
|
+
.optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict(),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
export const imageModelSchema = base
|
|
45
|
+
.extend({
|
|
46
|
+
image: z
|
|
47
|
+
.object({
|
|
48
|
+
aspectRatios: z.array(z.enum(["16:9", "9:16"])).min(1),
|
|
49
|
+
resolution: z.string().max(100).optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict(),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
export const ttsModelSchema = base
|
|
55
|
+
.extend({
|
|
56
|
+
tts: z
|
|
57
|
+
.object({
|
|
58
|
+
maxCharacters: z.number().int().min(2).max(1000000),
|
|
59
|
+
streaming: z.boolean(),
|
|
60
|
+
asyncMaxCharacters: z.number().int().positive().optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict(),
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
export const catalogueSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
schemaVersion: z.literal(1),
|
|
68
|
+
updatedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
69
|
+
providers: z.record(z.string().regex(/^[a-z0-9-]+$/), z.object({ maxConcurrent: z.number().int().min(1).max(5) }).strict()),
|
|
70
|
+
llm: z.array(llmModelSchema).max(300),
|
|
71
|
+
image: z.array(imageModelSchema).max(200),
|
|
72
|
+
tts: z.array(ttsModelSchema).max(100),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
.superRefine((data, ctx) => {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const family of ["llm", "image", "tts"])
|
|
78
|
+
for (const model of data[family]) {
|
|
79
|
+
if (!providers.some((p) => p.id === model.provider && p.family === family))
|
|
80
|
+
ctx.addIssue({ code: "custom", message: `Unknown ${family} provider ${model.provider}` });
|
|
81
|
+
const key = `${model.provider}:${model.id}`;
|
|
82
|
+
if (seen.has(key))
|
|
83
|
+
ctx.addIssue({ code: "custom", message: `Duplicate model ${key}` });
|
|
84
|
+
seen.add(key);
|
|
85
|
+
if (!data.providers[model.provider])
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
message: `Missing provider limits for ${model.provider}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { catalogueSchema } from "./schema.js";
|
|
5
|
+
export const catalogueSource = "https://raw.githubusercontent.com/GentBajko/slopify/main/packages/app/src/assets/models.yaml";
|
|
6
|
+
export function parseCatalogue(text) {
|
|
7
|
+
if (Buffer.byteLength(text) > 1024 * 1024)
|
|
8
|
+
throw new Error("Model catalogue exceeds 1 MB");
|
|
9
|
+
return catalogueSchema.parse(parse(text, { maxAliasCount: 20, uniqueKeys: true }));
|
|
10
|
+
}
|
|
11
|
+
export function createCatalogueStore(deps) {
|
|
12
|
+
const path = join(deps.dataDir, "models.yaml");
|
|
13
|
+
const bundled = deps.bundled ?? readFileSync(new URL("../assets/models.yaml", import.meta.url), "utf8");
|
|
14
|
+
let current = parseCatalogue(bundled);
|
|
15
|
+
mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
writeFileSync(path, bundled, { mode: 0o600, flag: "wx" });
|
|
18
|
+
let stamp = "";
|
|
19
|
+
let warning = null;
|
|
20
|
+
let refreshing;
|
|
21
|
+
function read() {
|
|
22
|
+
try {
|
|
23
|
+
const file = statSync(path);
|
|
24
|
+
const next = `${file.mtimeMs}:${file.size}`;
|
|
25
|
+
if (next !== stamp) {
|
|
26
|
+
stamp = next;
|
|
27
|
+
if (file.size > 1024 * 1024)
|
|
28
|
+
throw new Error("Oversized catalogue");
|
|
29
|
+
current = parseCatalogue(readFileSync(path, "utf8"));
|
|
30
|
+
warning = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
warning =
|
|
35
|
+
"Your models.yaml is unavailable or invalid. The last valid catalogue is still in use.";
|
|
36
|
+
}
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
async function refresh() {
|
|
40
|
+
const response = await deps.fetch(catalogueSource, {
|
|
41
|
+
signal: AbortSignal.timeout(15000),
|
|
42
|
+
redirect: "error",
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new Error(`Catalogue update answered ${response.status}`);
|
|
46
|
+
if (!response.body)
|
|
47
|
+
throw new Error("Empty catalogue response");
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
const reader = response.body.getReader();
|
|
51
|
+
try {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const next = await reader.read();
|
|
54
|
+
if (next.done)
|
|
55
|
+
break;
|
|
56
|
+
size += next.value.byteLength;
|
|
57
|
+
if (size > 1024 * 1024)
|
|
58
|
+
throw new Error("Catalogue exceeds 1 MB");
|
|
59
|
+
chunks.push(next.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await reader.cancel().catch(() => { });
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
66
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
67
|
+
const next = parseCatalogue(text);
|
|
68
|
+
mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
|
|
69
|
+
// A manual refresh replaces the local catalogue; retain the previous text for recovery.
|
|
70
|
+
if (existsSync(path))
|
|
71
|
+
writeFileSync(`${path}.previous`, readFileSync(path), { mode: 0o600 });
|
|
72
|
+
writeFileSync(`${path}.next`, text, { mode: 0o600 });
|
|
73
|
+
renameSync(`${path}.next`, path);
|
|
74
|
+
current = next;
|
|
75
|
+
stamp = "";
|
|
76
|
+
warning = null;
|
|
77
|
+
read();
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
read,
|
|
81
|
+
models: (provider, family) => read()[family].filter((m) => m.provider === provider && m.enabled && !m.deprecated),
|
|
82
|
+
status: () => ({ updatedAt: read().updatedAt, path, warning, source: catalogueSource }),
|
|
83
|
+
refresh: () => {
|
|
84
|
+
refreshing ??= refresh().finally(() => {
|
|
85
|
+
refreshing = undefined;
|
|
86
|
+
});
|
|
87
|
+
return refreshing;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function modelFields(draft, catalogue) {
|
|
2
|
+
if (!catalogue)
|
|
3
|
+
return [];
|
|
4
|
+
const fields = [];
|
|
5
|
+
const needLlm = draft.sources.research === "generate" ||
|
|
6
|
+
draft.sources.article === "generate" ||
|
|
7
|
+
draft.sources.thumbnail === "prompt_by_llm" ||
|
|
8
|
+
draft.intro?.mode === "llm" ||
|
|
9
|
+
draft.outro?.mode === "llm";
|
|
10
|
+
const checks = [
|
|
11
|
+
{ field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
|
|
12
|
+
{
|
|
13
|
+
field: "audio",
|
|
14
|
+
family: "tts",
|
|
15
|
+
choice: draft.audio,
|
|
16
|
+
needed: draft.sources.audio === "generate",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
field: "images",
|
|
20
|
+
family: "image",
|
|
21
|
+
choice: draft.images,
|
|
22
|
+
needed: draft.sources.images === "generate" ||
|
|
23
|
+
["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail),
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
for (const { field, family, choice, needed } of checks) {
|
|
27
|
+
if (!needed || !choice)
|
|
28
|
+
continue;
|
|
29
|
+
const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
|
|
30
|
+
if (!model)
|
|
31
|
+
fields.push({ field, message: "Choose an enabled model from the current catalogue." });
|
|
32
|
+
else if ("llm" in model && choice.thinking && !model.llm.thinking?.[choice.thinking])
|
|
33
|
+
fields.push({ field, message: "Choose a supported thinking setting for this model." });
|
|
34
|
+
else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
|
|
35
|
+
fields.push({
|
|
36
|
+
field,
|
|
37
|
+
message: "This model cannot perform web research. Choose another or turn Research off.",
|
|
38
|
+
});
|
|
39
|
+
else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
|
|
40
|
+
fields.push({ field, message: "This image model does not support the selected shape." });
|
|
41
|
+
}
|
|
42
|
+
return fields;
|
|
43
|
+
}
|
|
@@ -80,7 +80,7 @@ export function actionRoutes(deps) {
|
|
|
80
80
|
runner: deps.runner,
|
|
81
81
|
emit: (projectId, event) => deps.hub.emit(projectId, event),
|
|
82
82
|
providers: () => providerStatuses({ db: deps.db, probe: deps.probe }),
|
|
83
|
-
allowsCustomModels: allowsCustomModel,
|
|
83
|
+
allowsCustomModels: deps.catalogue ? () => false : allowsCustomModel,
|
|
84
84
|
modelsFor: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
|
|
85
85
|
};
|
|
86
86
|
const view = (projectId) => {
|
package/dist/edge/http/app.js
CHANGED
|
@@ -7,6 +7,8 @@ import { audioPreviewRoutes } from "./audio-preview.js";
|
|
|
7
7
|
import { entryRoutes } from "./entries.js";
|
|
8
8
|
import { fileRoutes } from "./files.js";
|
|
9
9
|
import { fontsRoutes } from "./fonts.js";
|
|
10
|
+
import { openFolderRoutes } from "./open-folder.js";
|
|
11
|
+
import { planningRoutes } from "./planning.js";
|
|
10
12
|
import { problem, problemFromError, titleOf } from "./problem.js";
|
|
11
13
|
import { projectRoutes } from "./projects.js";
|
|
12
14
|
import { promptRoutes } from "./prompts.js";
|
|
@@ -27,7 +29,9 @@ function apiRoutes(deps, startedAt) {
|
|
|
27
29
|
uptimeMs: deps.clock.now().getTime() - startedAt,
|
|
28
30
|
}))
|
|
29
31
|
.route("/staging", stagingRoutes(deps))
|
|
32
|
+
.route("/projects", planningRoutes(deps))
|
|
30
33
|
.route("/projects", projectRoutes(deps))
|
|
34
|
+
.route("/projects", openFolderRoutes(deps))
|
|
31
35
|
.route("/projects", audioPreviewRoutes(deps))
|
|
32
36
|
.route("/update", updateRoutes(deps))
|
|
33
37
|
// The re-run and cancel actions sit on the same prefix as the project itself; they
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { zValidator } from "@hono/zod-validator";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { assetOf, findDownload } from "../../slices/storage/downloads.js";
|
|
6
|
+
import { outputsOf } from "../../slices/storage/repo.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
export function openFolderRoutes(deps) {
|
|
9
|
+
return new Hono().post("/:id/open-folder", zValidator("param", z.object({
|
|
10
|
+
id: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
15
|
+
}), onInvalid), zValidator("json", z.object({
|
|
16
|
+
asset: z
|
|
17
|
+
.string()
|
|
18
|
+
.max(64)
|
|
19
|
+
.regex(/^(?:[a-z0-9-]+|images\.zip)$/),
|
|
20
|
+
}), onInvalid), async (c) => {
|
|
21
|
+
const origin = c.req.header("origin");
|
|
22
|
+
if (origin !== undefined && origin !== new URL(c.req.url).origin) {
|
|
23
|
+
return problem(c, {
|
|
24
|
+
status: 403,
|
|
25
|
+
title: titleOf(403),
|
|
26
|
+
detail: "Open folders from Slopify itself.",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const { id } = c.req.valid("param");
|
|
30
|
+
const { asset } = c.req.valid("json");
|
|
31
|
+
// The archive is virtual; open the first existing image's folder without building a zip.
|
|
32
|
+
const assets = asset === "images.zip"
|
|
33
|
+
? outputsOf(deps.db, id)
|
|
34
|
+
.filter((output) => output.role === "image" || output.role === "thumbnail")
|
|
35
|
+
.map(assetOf)
|
|
36
|
+
: [asset];
|
|
37
|
+
const download = assets
|
|
38
|
+
.map((name) => findDownload(deps, id, name))
|
|
39
|
+
.find((result) => result.ok);
|
|
40
|
+
if (download === undefined || !download.ok) {
|
|
41
|
+
return problem(c, {
|
|
42
|
+
status: 404,
|
|
43
|
+
title: titleOf(404),
|
|
44
|
+
detail: "No saved file was found for this output. Re-run the stage if the file was removed.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
if (deps.openFolder === undefined)
|
|
49
|
+
throw new Error("No folder opener configured");
|
|
50
|
+
await deps.openFolder(dirname(download.download.path));
|
|
51
|
+
return c.json({ opened: true });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return problem(c, {
|
|
55
|
+
status: 503,
|
|
56
|
+
title: titleOf(503),
|
|
57
|
+
detail: "Could not open the file manager on the machine running Slopify. Make sure a desktop session is available.",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
5
|
+
import { runDraftSchema } from "../../slices/admission/repo.js";
|
|
6
|
+
import { admit } from "../../slices/admission/rules.js";
|
|
7
|
+
import { batchExists, enqueueBatch, pumpQueue, queueEntries } from "../../slices/batch/index.js";
|
|
8
|
+
import { estimateRun } from "../../slices/estimate/index.js";
|
|
9
|
+
import { resolveFont } from "../../slices/fonts/index.js";
|
|
10
|
+
import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
|
|
11
|
+
import { stagedFiles } from "../../slices/storage/repo.js";
|
|
12
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
13
|
+
const planningBody = z.object({
|
|
14
|
+
draft: runDraftSchema,
|
|
15
|
+
expectedWords: z.number().int().min(1).max(100000).default(1500),
|
|
16
|
+
items: z
|
|
17
|
+
.array(z
|
|
18
|
+
.object({
|
|
19
|
+
title: z.string().trim().min(1).max(200),
|
|
20
|
+
values: z.record(z.string().max(200), z.string().max(10000)),
|
|
21
|
+
})
|
|
22
|
+
.strict())
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(50)
|
|
25
|
+
.optional(),
|
|
26
|
+
});
|
|
27
|
+
const batchBody = planningBody.extend({ requestId: z.uuid() });
|
|
28
|
+
export function planningRoutes(deps) {
|
|
29
|
+
const prepare = (input) => {
|
|
30
|
+
const drafts = input.items?.map((item) => ({
|
|
31
|
+
...input.draft,
|
|
32
|
+
title: item.title,
|
|
33
|
+
values: { ...input.draft.values, ...item.values },
|
|
34
|
+
})) ?? [input.draft];
|
|
35
|
+
return drafts.map((draft, index) => {
|
|
36
|
+
const picked = pickTemplates(deps.db, draft);
|
|
37
|
+
const admitted = admit({
|
|
38
|
+
draft: picked.draft,
|
|
39
|
+
staged: stagedFiles(deps.db),
|
|
40
|
+
requiredSlots: picked.requiredSlots,
|
|
41
|
+
});
|
|
42
|
+
const accepted = admitted.ok ? admitted.draft : picked.draft;
|
|
43
|
+
const fields = [
|
|
44
|
+
...picked.missing,
|
|
45
|
+
...(admitted.ok ? [] : admitted.fields),
|
|
46
|
+
...modelFields(accepted, deps.catalogue),
|
|
47
|
+
].map((f) => ({
|
|
48
|
+
...f,
|
|
49
|
+
message: `${drafts.length > 1 ? `Video ${index + 1}: ` : ""}${f.message}`,
|
|
50
|
+
}));
|
|
51
|
+
return { draft: accepted, rendered: renderPicked(picked, accepted.values), fields };
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
return new Hono()
|
|
55
|
+
.get("/queue", (c) => c.json({ queue: queueEntries(deps.db) }))
|
|
56
|
+
.post("/estimate", zValidator("json", planningBody, onInvalid), (c) => {
|
|
57
|
+
const input = c.req.valid("json");
|
|
58
|
+
const runs = prepare(input);
|
|
59
|
+
const fields = runs.flatMap((r) => r.fields);
|
|
60
|
+
if (fields.length)
|
|
61
|
+
return problem(c, {
|
|
62
|
+
status: 400,
|
|
63
|
+
title: titleOf(400),
|
|
64
|
+
detail: "Review the run setup.",
|
|
65
|
+
extensions: { fields },
|
|
66
|
+
});
|
|
67
|
+
return c.json({
|
|
68
|
+
estimates: runs.map((r) => estimateRun(r.draft, r.rendered, input.expectedWords, deps.catalogue)),
|
|
69
|
+
});
|
|
70
|
+
})
|
|
71
|
+
.post("/batch", zValidator("json", batchBody, onInvalid), async (c) => {
|
|
72
|
+
const input = c.req.valid("json");
|
|
73
|
+
if (batchExists(deps.db, input.requestId))
|
|
74
|
+
return c.json({ queue: queueEntries(deps.db, input.requestId) });
|
|
75
|
+
const subtitles = input.draft.subtitles;
|
|
76
|
+
if (subtitles && subtitles.mode !== "off") {
|
|
77
|
+
try {
|
|
78
|
+
await resolveFont(deps.paths, subtitles.fontId);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return problem(c, {
|
|
82
|
+
status: 400,
|
|
83
|
+
title: titleOf(400),
|
|
84
|
+
detail: "Choose an available subtitle font before starting.",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const runs = prepare(input);
|
|
89
|
+
const fields = runs.flatMap((r) => r.fields);
|
|
90
|
+
if (fields.length)
|
|
91
|
+
return problem(c, {
|
|
92
|
+
status: 400,
|
|
93
|
+
title: titleOf(400),
|
|
94
|
+
detail: "No videos were queued. Review the listed fields.",
|
|
95
|
+
extensions: { fields },
|
|
96
|
+
});
|
|
97
|
+
const queue = enqueueBatch({ ...deps, emit: (event) => deps.hub.emitGlobal(event) }, input.requestId, runs);
|
|
98
|
+
pumpQueue(deps.db, deps.runner);
|
|
99
|
+
return c.json({ queue }, 201);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { zValidator } from "@hono/zod-validator";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { modelFields } from "../../catalog/validate.js";
|
|
4
5
|
import { derive, progressOf } from "../../kernel/runner/graph.js";
|
|
5
6
|
import { listProjects, projectById, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
|
|
6
7
|
import { admit } from "../../slices/admission/rules.js";
|
|
@@ -75,13 +76,14 @@ export function projectRoutes(deps) {
|
|
|
75
76
|
staged: stagedFiles(deps.db),
|
|
76
77
|
requiredSlots: picked.requiredSlots,
|
|
77
78
|
});
|
|
78
|
-
|
|
79
|
+
const modelErrors = modelFields(picked.draft, deps.catalogue);
|
|
80
|
+
if (!admitted.ok || picked.missing.length > 0 || modelErrors.length > 0) {
|
|
79
81
|
return problem(c, {
|
|
80
82
|
status: 400,
|
|
81
83
|
title: titleOf(400),
|
|
82
84
|
detail: "This run cannot start yet; the listed fields need attention.",
|
|
83
85
|
extensions: {
|
|
84
|
-
fields: [...picked.missing, ...(admitted.ok ? [] : admitted.fields)],
|
|
86
|
+
fields: [...picked.missing, ...modelErrors, ...(admitted.ok ? [] : admitted.fields)],
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
}
|
|
@@ -25,11 +25,39 @@ export function providerRoutes(deps) {
|
|
|
25
25
|
report: (provider) => deps.log.write("warn", "model-catalog", { detail: `Could not load models for ${provider}` }),
|
|
26
26
|
});
|
|
27
27
|
return (new Hono()
|
|
28
|
+
.get("/catalogue", (c) => c.json(deps.catalogue?.status() ?? { warning: "Catalogue unavailable." }))
|
|
29
|
+
.post("/catalogue/refresh", async (c) => {
|
|
30
|
+
if (!deps.catalogue)
|
|
31
|
+
return problem(c, { status: 503, title: titleOf(503), detail: "Catalogue unavailable." });
|
|
32
|
+
try {
|
|
33
|
+
await deps.catalogue.refresh();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return problem(c, {
|
|
37
|
+
status: 502,
|
|
38
|
+
title: titleOf(502),
|
|
39
|
+
detail: "Could not update the catalogue. The last valid version remains in use.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return c.json(deps.catalogue.status());
|
|
43
|
+
})
|
|
28
44
|
// What Settings draws its rails from and Play its dropdowns: every provider, with
|
|
29
45
|
// the one fact that decides whether it is selectable.
|
|
30
46
|
.get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
|
|
31
47
|
.get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
|
|
32
48
|
const { id } = c.req.valid("param");
|
|
49
|
+
if (deps.catalogue)
|
|
50
|
+
return c.json({
|
|
51
|
+
models: deps.catalogue.models(id, providerById(id).family).map((m) => ({
|
|
52
|
+
...m,
|
|
53
|
+
...("llm" in m && m.llm.thinking
|
|
54
|
+
? { thinkingModes: Object.keys(m.llm.thinking) }
|
|
55
|
+
: {}),
|
|
56
|
+
})),
|
|
57
|
+
allowsCustom: false,
|
|
58
|
+
notice: "Curated active models from models.yaml. Edit it locally or refresh in Settings.",
|
|
59
|
+
warning: deps.catalogue.status().warning ?? undefined,
|
|
60
|
+
});
|
|
33
61
|
const result = await catalog.get(id, providerById(id).family, c.req.query("refresh") === "1");
|
|
34
62
|
c.header("Cache-Control", "no-store");
|
|
35
63
|
return c.json(result);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { isWsl } from "./open-browser.js";
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
export function folderCommand(platform, path, wsl = false) {
|
|
6
|
+
if (platform === "win32" || wsl)
|
|
7
|
+
return ["explorer.exe", [path]];
|
|
8
|
+
return [platform === "darwin" ? "open" : "xdg-open", [path]];
|
|
9
|
+
}
|
|
10
|
+
export async function openFolder(path) {
|
|
11
|
+
const wsl = isWsl(process.platform);
|
|
12
|
+
const target = wsl
|
|
13
|
+
? (await execute("wslpath", ["-w", path], { timeout: 5000 })).stdout.trim()
|
|
14
|
+
: path;
|
|
15
|
+
const [command, args] = folderCommand(process.platform, target, wsl);
|
|
16
|
+
try {
|
|
17
|
+
await execute(command, args, { timeout: 10000 });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
// Explorer commonly reports 1 even when it successfully opens an existing window.
|
|
21
|
+
if (command === "explorer.exe" && error instanceof Error && "code" in error && error.code === 1)
|
|
22
|
+
return;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE batches (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
created_at TEXT NOT NULL
|
|
4
|
+
);
|
|
5
|
+
CREATE TABLE project_queue (
|
|
6
|
+
position INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
batch_id TEXT NOT NULL REFERENCES batches(id),
|
|
9
|
+
state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued','active','finished'))
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX project_queue_state ON project_queue(state, position);
|
package/dist/kernel/ports/llm.js
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Bounded UTF-16 length is conservative for providers counting Unicode characters.
|
|
2
|
+
// Every character survives; the split never cuts a surrogate pair.
|
|
3
|
+
export function splitText(text, limit) {
|
|
4
|
+
if (!Number.isInteger(limit) || limit < 2)
|
|
5
|
+
throw new Error("Text limit must be at least two characters");
|
|
6
|
+
const parts = [];
|
|
7
|
+
let rest = text;
|
|
8
|
+
while (rest.length > limit) {
|
|
9
|
+
const window = rest.slice(0, limit);
|
|
10
|
+
const sentences = [...window.matchAll(/[.!?]\s+|\n/g)];
|
|
11
|
+
const last = sentences.at(-1);
|
|
12
|
+
let cut = last && last.index > limit / 2
|
|
13
|
+
? last.index + last[0].length
|
|
14
|
+
: Math.max(window.lastIndexOf(" "), window.lastIndexOf("\n"), window.lastIndexOf("\t")) + 1;
|
|
15
|
+
if (cut < limit / 2)
|
|
16
|
+
cut = limit;
|
|
17
|
+
const code = rest.charCodeAt(cut - 1);
|
|
18
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
19
|
+
cut--;
|
|
20
|
+
parts.push(rest.slice(0, cut));
|
|
21
|
+
rest = rest.slice(cut);
|
|
22
|
+
}
|
|
23
|
+
if (rest)
|
|
24
|
+
parts.push(rest);
|
|
25
|
+
return parts;
|
|
26
|
+
}
|
|
@@ -11,6 +11,7 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
11
11
|
...(pieceId === undefined ? {} : { pieceId }),
|
|
12
12
|
signal: context.signal,
|
|
13
13
|
};
|
|
14
|
+
const schedule = (provider, work) => deps.queue ? deps.queue.run(provider, context.signal, work) : work();
|
|
14
15
|
return {
|
|
15
16
|
llm: (call, onEvent) => {
|
|
16
17
|
// Resolved once; the adapter reads the stored key per request, so a key replaced
|
|
@@ -26,13 +27,14 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
26
27
|
text,
|
|
27
28
|
...(reset === undefined ? {} : { reset }),
|
|
28
29
|
});
|
|
29
|
-
return attempt(ctx, async (signal, progress) => {
|
|
30
|
+
return schedule(call.provider, () => attempt(ctx, async (signal, progress) => {
|
|
30
31
|
preview("", true);
|
|
31
32
|
let text = "";
|
|
32
33
|
let usage = null;
|
|
33
34
|
let finishReason = null;
|
|
34
35
|
for await (const event of port.complete({
|
|
35
36
|
model: call.model,
|
|
37
|
+
...(call.thinking === undefined ? {} : { thinking: call.thinking }),
|
|
36
38
|
messages: call.messages,
|
|
37
39
|
...(call.webSearch === undefined ? {} : { webSearch: call.webSearch }),
|
|
38
40
|
signal,
|
|
@@ -58,7 +60,7 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
58
60
|
throw new Error(unusable);
|
|
59
61
|
}
|
|
60
62
|
return answer;
|
|
61
|
-
}, { kind: "llm", streaming: port.capabilities.streams });
|
|
63
|
+
}, { kind: "llm", streaming: port.capabilities.streams }));
|
|
62
64
|
},
|
|
63
65
|
tts: (call, observe) => {
|
|
64
66
|
const port = deps.registry.tts(call.provider);
|
|
@@ -76,7 +78,7 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
};
|
|
79
|
-
return attempt(ctx, async (signal, progress) => {
|
|
81
|
+
return schedule(call.provider, () => attempt(ctx, async (signal, progress) => {
|
|
80
82
|
notify({ type: "start" });
|
|
81
83
|
try {
|
|
82
84
|
const spoken = await port.synthesize({
|
|
@@ -123,18 +125,18 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
123
125
|
notify({ type: "interrupted" });
|
|
124
126
|
throw error;
|
|
125
127
|
}
|
|
126
|
-
}, { kind: "tts", streaming: port.capabilities.streams });
|
|
128
|
+
}, { kind: "tts", streaming: port.capabilities.streams }));
|
|
127
129
|
},
|
|
128
130
|
image: (call) => {
|
|
129
131
|
const port = deps.registry.image(call.provider);
|
|
130
|
-
return attempt(ctx, (signal) => port.generate({
|
|
132
|
+
return schedule(call.provider, () => attempt(ctx, (signal) => port.generate({
|
|
131
133
|
model: call.model,
|
|
132
134
|
prompt: call.prompt,
|
|
133
135
|
aspect: call.aspect,
|
|
134
136
|
signal,
|
|
135
137
|
}),
|
|
136
138
|
// One request, one answer: the 300 s runs over the whole call.
|
|
137
|
-
{ kind: "image" });
|
|
139
|
+
{ kind: "image" }));
|
|
138
140
|
},
|
|
139
141
|
forPiece: (piece) => stageProviders(deps, context, piece),
|
|
140
142
|
};
|