ima2-gen 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -7
- package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
- package/assets/card-news/templates/clean-report-square/base.png +0 -0
- package/assets/card-news/templates/clean-report-square/preview.png +0 -0
- package/assets/card-news/templates/clean-report-square/template.json +20 -0
- package/bin/commands/cancel.js +45 -0
- package/bin/commands/edit.js +33 -4
- package/bin/commands/gen.js +26 -3
- package/bin/commands/ps.js +48 -16
- package/bin/ima2.js +56 -12
- package/bin/lib/client.js +4 -1
- package/bin/lib/error-hints.js +23 -0
- package/bin/lib/output.js +10 -0
- package/config.js +19 -1
- package/docs/API.md +67 -0
- package/docs/FAQ.ko.md +248 -0
- package/docs/FAQ.md +256 -0
- package/docs/README.ja.md +4 -0
- package/docs/README.ko.md +14 -1
- package/docs/README.zh-CN.md +4 -0
- package/docs/RECOVER_OLD_IMAGES.md +2 -0
- package/lib/cardNewsGenerator.js +162 -0
- package/lib/cardNewsJobStore.js +107 -0
- package/lib/cardNewsManifestStore.js +112 -0
- package/lib/cardNewsPlanner.js +180 -0
- package/lib/cardNewsPlannerClient.js +112 -0
- package/lib/cardNewsPlannerPrompt.js +60 -0
- package/lib/cardNewsPlannerSchema.js +259 -0
- package/lib/cardNewsRoleTemplateStore.js +47 -0
- package/lib/cardNewsTemplateStore.js +210 -0
- package/lib/db.js +20 -3
- package/lib/errorClassify.js +2 -2
- package/lib/generationErrors.js +51 -0
- package/lib/historyList.js +82 -8
- package/lib/inflight.js +117 -34
- package/lib/logger.js +37 -3
- package/lib/oauthLauncher.js +52 -19
- package/lib/oauthProxy.js +81 -14
- package/lib/requestLogger.js +48 -0
- package/lib/runtimePorts.js +93 -0
- package/lib/sessionStore.js +48 -7
- package/package.json +3 -2
- package/routes/cardNews.js +183 -0
- package/routes/edit.js +1 -1
- package/routes/generate.js +10 -10
- package/routes/health.js +27 -3
- package/routes/index.js +2 -0
- package/routes/nodes.js +93 -26
- package/server.js +91 -18
- package/ui/dist/assets/index-BjX_nzuK.js +23 -0
- package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
- package/ui/dist/assets/index-DHyUax4_.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
- package/ui/dist/assets/index-IHSd1z1a.js +0 -22
- package/ui/dist/assets/index-IHSd1z1a.js.map +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { logEvent } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
5
|
+
const IGNORED_LOG_PATHS = new Set(["/api/health", "/api/inflight"]);
|
|
6
|
+
|
|
7
|
+
export function normalizeRequestId(value) {
|
|
8
|
+
return typeof value === "string" && REQUEST_ID_RE.test(value) ? value : `req_${randomUUID()}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requestPath(req) {
|
|
12
|
+
return String(req.originalUrl || req.url || "").split("?")[0] || "/";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createRequestLogger() {
|
|
16
|
+
return function requestLogger(req, res, next) {
|
|
17
|
+
const path = requestPath(req);
|
|
18
|
+
if (!path.startsWith("/api/")) return next();
|
|
19
|
+
|
|
20
|
+
const requestId = normalizeRequestId(req.get("x-request-id"));
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
req.id = requestId;
|
|
23
|
+
res.setHeader("X-Request-Id", requestId);
|
|
24
|
+
|
|
25
|
+
const ignoreLog = IGNORED_LOG_PATHS.has(path);
|
|
26
|
+
if (!ignoreLog) {
|
|
27
|
+
logEvent("http", "request", {
|
|
28
|
+
requestId,
|
|
29
|
+
method: req.method,
|
|
30
|
+
path,
|
|
31
|
+
client: req.get("x-ima2-client") || "ui",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
res.on("finish", () => {
|
|
36
|
+
if (ignoreLog) return;
|
|
37
|
+
logEvent("http", "response", {
|
|
38
|
+
requestId,
|
|
39
|
+
method: req.method,
|
|
40
|
+
path,
|
|
41
|
+
status: res.statusCode,
|
|
42
|
+
durationMs: Date.now() - startedAt,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
next();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 20;
|
|
4
|
+
|
|
5
|
+
export function parseLocalhostPortFromUrl(url) {
|
|
6
|
+
try {
|
|
7
|
+
const parsed = new URL(url);
|
|
8
|
+
const port = Number(parsed.port);
|
|
9
|
+
return Number.isFinite(port) && port > 0 ? port : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stripV1FromOAuthUrl(url) {
|
|
16
|
+
return String(url || "").replace(/\/v1\/?$/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseOAuthReadyUrl(line) {
|
|
20
|
+
const text = String(line || "");
|
|
21
|
+
const match = text.match(/https?:\/\/(?:127\.0\.0\.1|localhost):\d+(?:\/v1)?/i);
|
|
22
|
+
return match ? stripV1FromOAuthUrl(match[0]) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function checkPort(port, host) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const probe = createServer()
|
|
28
|
+
.once("error", (err) => {
|
|
29
|
+
probe.close(() => {});
|
|
30
|
+
reject(err);
|
|
31
|
+
})
|
|
32
|
+
.once("listening", () => {
|
|
33
|
+
probe.close(() => resolve(true));
|
|
34
|
+
});
|
|
35
|
+
if (host) probe.listen(port, host);
|
|
36
|
+
else probe.listen(port);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function findAvailablePort(startPort, options = {}) {
|
|
41
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
42
|
+
const host = options.host;
|
|
43
|
+
for (let offset = 0; offset <= maxAttempts; offset++) {
|
|
44
|
+
const port = Number(startPort) + offset;
|
|
45
|
+
try {
|
|
46
|
+
await checkPort(port, host);
|
|
47
|
+
return port;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err?.code !== "EADDRINUSE") throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const err = new Error(`No available port found from ${startPort} to ${Number(startPort) + maxAttempts}`);
|
|
53
|
+
err.code = "PORT_RANGE_EXHAUSTED";
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function listenOnce(app, port, host) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const server = host ? app.listen(port, host) : app.listen(port);
|
|
60
|
+
server.once("listening", () => resolve(server));
|
|
61
|
+
server.once("error", (err) => reject(err));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function listenWithPortFallback(app, startPort, options = {}) {
|
|
66
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
67
|
+
const host = options.host;
|
|
68
|
+
const label = options.label || "server";
|
|
69
|
+
for (let offset = 0; offset <= maxAttempts; offset++) {
|
|
70
|
+
const port = Number(startPort) + offset;
|
|
71
|
+
try {
|
|
72
|
+
const server = await listenOnce(app, port, host);
|
|
73
|
+
if (offset > 0 && typeof options.onFallback === "function") {
|
|
74
|
+
options.onFallback({ label, requestedPort: Number(startPort), actualPort: port });
|
|
75
|
+
}
|
|
76
|
+
return server;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err?.code !== "EADDRINUSE") throw err;
|
|
79
|
+
if (offset >= maxAttempts) {
|
|
80
|
+
const exhausted = new Error(`${label} port range exhausted from ${startPort} to ${port}`);
|
|
81
|
+
exhausted.code = "PORT_RANGE_EXHAUSTED";
|
|
82
|
+
exhausted.cause = err;
|
|
83
|
+
throw exhausted;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`${label} failed to bind`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getServerPort(server) {
|
|
91
|
+
const address = server?.address?.();
|
|
92
|
+
return typeof address === "object" && address ? address.port : null;
|
|
93
|
+
}
|
package/lib/sessionStore.js
CHANGED
|
@@ -94,6 +94,51 @@ function cleanData(v) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
function normalizeGraphPayload(nodes, edges) {
|
|
98
|
+
const nodeIds = new Set(nodes.map((n) => n?.id).filter(Boolean).map(String));
|
|
99
|
+
const cleanEdges = edges.filter(
|
|
100
|
+
(e) => e?.id && e?.source && e?.target && nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)),
|
|
101
|
+
);
|
|
102
|
+
const incomingByTarget = new Map();
|
|
103
|
+
for (const edge of cleanEdges) {
|
|
104
|
+
const target = String(edge.target);
|
|
105
|
+
if (incomingByTarget.has(target)) {
|
|
106
|
+
const err = new Error(`Node ${target} has multiple parent edges`);
|
|
107
|
+
err.code = "GRAPH_PARENT_CONFLICT";
|
|
108
|
+
err.status = 409;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
incomingByTarget.set(target, edge);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const nodeDataById = new Map();
|
|
115
|
+
for (const node of nodes) {
|
|
116
|
+
if (!node?.id) continue;
|
|
117
|
+
const data = node.data && typeof node.data === "object" && !Array.isArray(node.data)
|
|
118
|
+
? { ...node.data }
|
|
119
|
+
: {};
|
|
120
|
+
nodeDataById.set(String(node.id), data);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const normalizedNodes = nodes.map((node) => {
|
|
124
|
+
if (!node?.id) return node;
|
|
125
|
+
const id = String(node.id);
|
|
126
|
+
const data = { ...(nodeDataById.get(id) ?? {}) };
|
|
127
|
+
const incoming = incomingByTarget.get(id);
|
|
128
|
+
if (!incoming) {
|
|
129
|
+
data.parentServerNodeId = null;
|
|
130
|
+
} else {
|
|
131
|
+
const parentData = nodeDataById.get(String(incoming.source)) ?? {};
|
|
132
|
+
data.parentServerNodeId = typeof parentData.serverNodeId === "string"
|
|
133
|
+
? parentData.serverNodeId
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
return { ...node, data };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return { nodes: normalizedNodes, edges: cleanEdges };
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion = null }) {
|
|
98
143
|
const db = getDb();
|
|
99
144
|
const sessionExists = db
|
|
@@ -124,11 +169,7 @@ export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion =
|
|
|
124
169
|
throw err;
|
|
125
170
|
}
|
|
126
171
|
|
|
127
|
-
|
|
128
|
-
const nodeIds = new Set(nodes.map((n) => n?.id).filter(Boolean).map(String));
|
|
129
|
-
const cleanEdges = edges.filter(
|
|
130
|
-
(e) => e?.id && e?.source && e?.target && nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)),
|
|
131
|
-
);
|
|
172
|
+
const normalized = normalizeGraphPayload(nodes, edges);
|
|
132
173
|
|
|
133
174
|
const tx = db.transaction(() => {
|
|
134
175
|
db.prepare("DELETE FROM nodes WHERE session_id = ?").run(sessionId);
|
|
@@ -137,7 +178,7 @@ export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion =
|
|
|
137
178
|
const insNode = db.prepare(
|
|
138
179
|
"INSERT INTO nodes (session_id, id, x, y, data) VALUES (?, ?, ?, ?, ?)",
|
|
139
180
|
);
|
|
140
|
-
for (const n of nodes) {
|
|
181
|
+
for (const n of normalized.nodes) {
|
|
141
182
|
if (!n?.id) continue;
|
|
142
183
|
const x = Number(n.x ?? n.position?.x ?? 0);
|
|
143
184
|
const y = Number(n.y ?? n.position?.y ?? 0);
|
|
@@ -153,7 +194,7 @@ export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion =
|
|
|
153
194
|
const insEdge = db.prepare(
|
|
154
195
|
"INSERT INTO edges (session_id, id, source, target, data) VALUES (?, ?, ?, ?, ?)",
|
|
155
196
|
);
|
|
156
|
-
for (const e of
|
|
197
|
+
for (const e of normalized.edges) {
|
|
157
198
|
insEdge.run(
|
|
158
199
|
sessionId,
|
|
159
200
|
cleanStr(String(e.id)),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Local OAuth image generation studio with classic and node workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
"ui:build": "cd ui && npm run build",
|
|
16
16
|
"build": "npm run ui:build",
|
|
17
17
|
"test": "node scripts/run-tests.mjs",
|
|
18
|
+
"test:package-install": "node --test tests/package-install-smoke.mjs",
|
|
18
19
|
"setup": "node bin/ima2.js setup",
|
|
19
|
-
"prepublishOnly": "npm run build && npm run lint:pkg",
|
|
20
|
+
"prepublishOnly": "npm test && npm run build && npm run test:package-install && npm run lint:pkg",
|
|
20
21
|
"lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','routes/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
|
|
21
22
|
"release:patch": "npm version patch && npm publish && git push origin main --tags",
|
|
22
23
|
"release:minor": "npm version minor && npm publish && git push origin main --tags",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { listImageTemplates, readTemplatePreview } from "../lib/cardNewsTemplateStore.js";
|
|
2
|
+
import { listRoleTemplates } from "../lib/cardNewsRoleTemplateStore.js";
|
|
3
|
+
import { createCardNewsDraft } from "../lib/cardNewsPlanner.js";
|
|
4
|
+
import { generateCardNewsSet } from "../lib/cardNewsGenerator.js";
|
|
5
|
+
import {
|
|
6
|
+
createCardNewsJob,
|
|
7
|
+
finishCardNewsJob,
|
|
8
|
+
getCardNewsJob,
|
|
9
|
+
getCardNewsJobPlan,
|
|
10
|
+
retryCardNewsJob,
|
|
11
|
+
updateCardNewsJob,
|
|
12
|
+
updateCardNewsJobCard,
|
|
13
|
+
} from "../lib/cardNewsJobStore.js";
|
|
14
|
+
import { listCardNewsSets, readCardNewsManifest, readCardNewsSetPlan } from "../lib/cardNewsManifestStore.js";
|
|
15
|
+
|
|
16
|
+
function sendError(res, err) {
|
|
17
|
+
const status = err.status || 500;
|
|
18
|
+
res.status(status).json({
|
|
19
|
+
error: {
|
|
20
|
+
code: err.code || "CARD_NEWS_ERROR",
|
|
21
|
+
message: err.message || "Card News request failed",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runCardNewsJob(ctx, jobId, plan) {
|
|
27
|
+
setImmediate(async () => {
|
|
28
|
+
try {
|
|
29
|
+
updateCardNewsJob(jobId, { status: "running" });
|
|
30
|
+
await generateCardNewsSet(ctx, plan, {
|
|
31
|
+
onCardStart: (card) => {
|
|
32
|
+
updateCardNewsJobCard(jobId, card.cardId, { status: "generating", error: undefined });
|
|
33
|
+
},
|
|
34
|
+
onCardDone: (card) => {
|
|
35
|
+
const url = card.imageFilename
|
|
36
|
+
? `/generated/cardnews/${encodeURIComponent(card.setId)}/${encodeURIComponent(card.imageFilename)}`
|
|
37
|
+
: undefined;
|
|
38
|
+
updateCardNewsJobCard(jobId, card.cardId, {
|
|
39
|
+
status: card.status || "generated",
|
|
40
|
+
error: card.error?.message || card.error || undefined,
|
|
41
|
+
headline: card.headline,
|
|
42
|
+
body: card.body,
|
|
43
|
+
textFields: Array.isArray(card.textFields) ? card.textFields : [],
|
|
44
|
+
imageFilename: card.imageFilename || undefined,
|
|
45
|
+
generatedAt: card.generatedAt || undefined,
|
|
46
|
+
url,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
finishCardNewsJob(jobId);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
updateCardNewsJob(jobId, {
|
|
53
|
+
status: "error",
|
|
54
|
+
error: err.message || "Card News job failed",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerCardNewsRoutes(app, ctx) {
|
|
61
|
+
app.get("/api/cardnews/image-templates", async (_req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
res.json({ templates: await listImageTemplates(ctx) });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
sendError(res, err);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get("/api/cardnews/image-templates/:templateId/preview", async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const buf = await readTemplatePreview(ctx, req.params.templateId);
|
|
72
|
+
res.type("image/png").send(buf);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
sendError(res, err);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.get("/api/cardnews/role-templates", (_req, res) => {
|
|
79
|
+
res.json({ templates: listRoleTemplates() });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
app.get("/api/cardnews/sets", async (_req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
res.json({ sets: await listCardNewsSets(ctx) });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
sendError(res, err);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.get("/api/cardnews/sets/:setId", async (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
res.json({ plan: await readCardNewsSetPlan(ctx, req.params.setId) });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
sendError(res, err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
app.get("/api/cardnews/sets/:setId/manifest", async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const manifest = await readCardNewsManifest(ctx, req.params.setId);
|
|
101
|
+
if (req.query.download === "1") {
|
|
102
|
+
res.setHeader("Content-Disposition", `attachment; filename="${req.params.setId}-manifest.json"`);
|
|
103
|
+
}
|
|
104
|
+
res.type("application/json").send(JSON.stringify(manifest, null, 2));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
sendError(res, err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post("/api/cardnews/draft", async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
res.json(await createCardNewsDraft(ctx, req.body || {}));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
sendError(res, err);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
app.post("/api/cardnews/generate", async (req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const result = await generateCardNewsSet(ctx, req.body || {});
|
|
121
|
+
res.json(result);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
sendError(res, err);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
app.post("/api/cardnews/jobs", (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const summary = createCardNewsJob(req.body || {});
|
|
130
|
+
runCardNewsJob(ctx, summary.jobId, req.body || {});
|
|
131
|
+
res.status(202).json(summary);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
sendError(res, err);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.get("/api/cardnews/jobs/:jobId", (req, res) => {
|
|
138
|
+
const job = getCardNewsJob(req.params.jobId);
|
|
139
|
+
if (!job) {
|
|
140
|
+
res.status(404).json({ error: { code: "CARD_NEWS_JOB_NOT_FOUND", message: "Job not found" } });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.json(job);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
app.post("/api/cardnews/jobs/:jobId/retry", (req, res) => {
|
|
147
|
+
const plan = getCardNewsJobPlan(req.params.jobId);
|
|
148
|
+
const job = retryCardNewsJob(req.params.jobId, req.body?.cardIds || []);
|
|
149
|
+
if (!job) {
|
|
150
|
+
res.status(404).json({ error: { code: "CARD_NEWS_JOB_NOT_FOUND", message: "Job not found" } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (plan) {
|
|
154
|
+
const wanted = new Set(req.body?.cardIds || []);
|
|
155
|
+
runCardNewsJob(ctx, req.params.jobId, {
|
|
156
|
+
...plan,
|
|
157
|
+
cards: (plan.cards || []).filter((card) => wanted.has(card.id)),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
res.status(202).json(job);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
app.post("/api/cardnews/cards/:cardId/regenerate", async (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const body = req.body || {};
|
|
166
|
+
const cards = Array.isArray(body.cards)
|
|
167
|
+
? body.cards.filter((card) => card.id === req.params.cardId || card.cardId === req.params.cardId)
|
|
168
|
+
: body.card ? [body.card] : [];
|
|
169
|
+
const result = await generateCardNewsSet(ctx, { ...body, cards });
|
|
170
|
+
res.json(result);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
sendError(res, err);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.post("/api/cardnews/export", (_req, res) => {
|
|
177
|
+
res.status(202).json({
|
|
178
|
+
ok: true,
|
|
179
|
+
status: "planned",
|
|
180
|
+
message: "Card News export is planned after the dev MVP generation slice.",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
package/routes/edit.js
CHANGED
|
@@ -19,7 +19,7 @@ function validateModeration(ctx, moderation) {
|
|
|
19
19
|
|
|
20
20
|
export function registerEditRoutes(app, ctx) {
|
|
21
21
|
app.post("/api/edit", async (req, res) => {
|
|
22
|
-
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId :
|
|
22
|
+
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
|
|
23
23
|
let finishStatus = "completed";
|
|
24
24
|
let finishHttpStatus;
|
|
25
25
|
let finishErrorCode;
|
package/routes/generate.js
CHANGED
|
@@ -6,6 +6,7 @@ import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
|
6
6
|
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
7
7
|
import { normalizeImageModel } from "../lib/imageModels.js";
|
|
8
8
|
import { generateViaOAuth } from "../lib/oauthProxy.js";
|
|
9
|
+
import { normalizeGenerationFailure } from "../lib/generationErrors.js";
|
|
9
10
|
import { startJob, finishJob } from "../lib/inflight.js";
|
|
10
11
|
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
11
12
|
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
@@ -20,7 +21,7 @@ function validateModeration(ctx, moderation) {
|
|
|
20
21
|
|
|
21
22
|
export function registerGenerateRoutes(app, ctx) {
|
|
22
23
|
app.post("/api/generate", async (req, res) => {
|
|
23
|
-
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId :
|
|
24
|
+
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
|
|
24
25
|
let finishStatus = "completed";
|
|
25
26
|
let finishHttpStatus;
|
|
26
27
|
let finishErrorCode;
|
|
@@ -150,11 +151,9 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
150
151
|
logEvent("generate", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
err.cause = lastErr;
|
|
157
|
-
throw err;
|
|
154
|
+
throw normalizeGenerationFailure(lastErr, {
|
|
155
|
+
safetyMessage: "Content generation refused after retries",
|
|
156
|
+
});
|
|
158
157
|
};
|
|
159
158
|
|
|
160
159
|
const results = await Promise.allSettled(Array.from({ length: count }, generateOne));
|
|
@@ -203,11 +202,12 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
203
202
|
|
|
204
203
|
if (images.length === 0) {
|
|
205
204
|
const firstErr = results.find((r) => r.status === "rejected")?.reason;
|
|
206
|
-
if (firstErr?.code
|
|
205
|
+
if (firstErr?.code) {
|
|
206
|
+
const status = firstErr.status || 500;
|
|
207
207
|
finishStatus = "error";
|
|
208
|
-
finishHttpStatus =
|
|
209
|
-
finishErrorCode =
|
|
210
|
-
return res.status(
|
|
208
|
+
finishHttpStatus = status;
|
|
209
|
+
finishErrorCode = firstErr.code;
|
|
210
|
+
return res.status(status).json({ error: firstErr.message, code: firstErr.code, requestId });
|
|
211
211
|
}
|
|
212
212
|
finishStatus = "error";
|
|
213
213
|
finishHttpStatus = 500;
|
package/routes/health.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { listJobs, listTerminalJobs, finishJob } from "../lib/inflight.js";
|
|
2
2
|
|
|
3
3
|
export function registerHealthRoutes(app, ctx) {
|
|
4
|
+
const runtimePorts = () => ({
|
|
5
|
+
backend: {
|
|
6
|
+
configuredPort: Number(ctx.serverConfiguredPort || ctx.config.server.port),
|
|
7
|
+
actualPort: Number(ctx.serverActualPort || ctx.config.server.port),
|
|
8
|
+
url: ctx.serverUrl || `http://localhost:${ctx.serverActualPort || ctx.config.server.port}`,
|
|
9
|
+
},
|
|
10
|
+
oauth: {
|
|
11
|
+
configuredPort: Number(ctx.oauthPort),
|
|
12
|
+
actualPort: Number(ctx.oauthActualPort || ctx.oauthPort),
|
|
13
|
+
url: ctx.oauthUrl,
|
|
14
|
+
status: ctx.oauthReadyState,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
4
18
|
app.get("/api/providers", (_req, res) => {
|
|
5
19
|
res.json({
|
|
6
20
|
apiKey: false,
|
|
7
21
|
oauth: true,
|
|
8
22
|
oauthPort: ctx.oauthPort,
|
|
23
|
+
oauthActualPort: ctx.oauthActualPort || ctx.oauthPort,
|
|
24
|
+
oauthUrl: ctx.oauthUrl,
|
|
9
25
|
apiKeyDisabled: true,
|
|
26
|
+
runtime: runtimePorts(),
|
|
10
27
|
});
|
|
11
28
|
});
|
|
12
29
|
|
|
@@ -19,22 +36,29 @@ export function registerHealthRoutes(app, ctx) {
|
|
|
19
36
|
activeJobs: listJobs().length,
|
|
20
37
|
pid: process.pid,
|
|
21
38
|
startedAt: ctx.startedAt,
|
|
39
|
+
runtime: runtimePorts(),
|
|
22
40
|
});
|
|
23
41
|
});
|
|
24
42
|
|
|
25
43
|
app.get("/api/oauth/status", async (_req, res) => {
|
|
44
|
+
if (ctx.oauthReadyState === "starting") {
|
|
45
|
+
return res.json({ status: "starting", runtime: runtimePorts() });
|
|
46
|
+
}
|
|
47
|
+
if (ctx.oauthReadyState === "failed") {
|
|
48
|
+
return res.json({ status: "offline", runtime: runtimePorts() });
|
|
49
|
+
}
|
|
26
50
|
try {
|
|
27
51
|
const r = await fetch(`${ctx.oauthUrl}/v1/models`, {
|
|
28
52
|
signal: AbortSignal.timeout(ctx.config.oauth.statusTimeoutMs),
|
|
29
53
|
});
|
|
30
54
|
if (r.ok) {
|
|
31
55
|
const data = await r.json();
|
|
32
|
-
res.json({ status: "ready", models: data.data?.map((m) => m.id) || [] });
|
|
56
|
+
res.json({ status: "ready", models: data.data?.map((m) => m.id) || [], runtime: runtimePorts() });
|
|
33
57
|
} else {
|
|
34
|
-
res.json({ status: "auth_required" });
|
|
58
|
+
res.json({ status: "auth_required", runtime: runtimePorts() });
|
|
35
59
|
}
|
|
36
60
|
} catch {
|
|
37
|
-
res.json({ status: "offline" });
|
|
61
|
+
res.json({ status: "offline", runtime: runtimePorts() });
|
|
38
62
|
}
|
|
39
63
|
});
|
|
40
64
|
|
package/routes/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerEditRoutes } from "./edit.js";
|
|
|
5
5
|
import { registerNodeRoutes } from "./nodes.js";
|
|
6
6
|
import { registerGenerateRoutes } from "./generate.js";
|
|
7
7
|
import { registerStorageRoutes } from "./storage.js";
|
|
8
|
+
import { registerCardNewsRoutes } from "./cardNews.js";
|
|
8
9
|
|
|
9
10
|
export function configureRoutes(app, ctx) {
|
|
10
11
|
registerHealthRoutes(app, ctx);
|
|
@@ -13,5 +14,6 @@ export function configureRoutes(app, ctx) {
|
|
|
13
14
|
registerSessionRoutes(app, ctx);
|
|
14
15
|
registerEditRoutes(app, ctx);
|
|
15
16
|
registerNodeRoutes(app, ctx);
|
|
17
|
+
if (ctx.config.features.cardNews) registerCardNewsRoutes(app, ctx);
|
|
16
18
|
registerGenerateRoutes(app, ctx);
|
|
17
19
|
}
|