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.
Files changed (58) hide show
  1. package/README.md +47 -7
  2. package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
  3. package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
  4. package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
  5. package/assets/card-news/templates/clean-report-square/base.png +0 -0
  6. package/assets/card-news/templates/clean-report-square/preview.png +0 -0
  7. package/assets/card-news/templates/clean-report-square/template.json +20 -0
  8. package/bin/commands/cancel.js +45 -0
  9. package/bin/commands/edit.js +33 -4
  10. package/bin/commands/gen.js +26 -3
  11. package/bin/commands/ps.js +48 -16
  12. package/bin/ima2.js +56 -12
  13. package/bin/lib/client.js +4 -1
  14. package/bin/lib/error-hints.js +23 -0
  15. package/bin/lib/output.js +10 -0
  16. package/config.js +19 -1
  17. package/docs/API.md +67 -0
  18. package/docs/FAQ.ko.md +248 -0
  19. package/docs/FAQ.md +256 -0
  20. package/docs/README.ja.md +4 -0
  21. package/docs/README.ko.md +14 -1
  22. package/docs/README.zh-CN.md +4 -0
  23. package/docs/RECOVER_OLD_IMAGES.md +2 -0
  24. package/lib/cardNewsGenerator.js +162 -0
  25. package/lib/cardNewsJobStore.js +107 -0
  26. package/lib/cardNewsManifestStore.js +112 -0
  27. package/lib/cardNewsPlanner.js +180 -0
  28. package/lib/cardNewsPlannerClient.js +112 -0
  29. package/lib/cardNewsPlannerPrompt.js +60 -0
  30. package/lib/cardNewsPlannerSchema.js +259 -0
  31. package/lib/cardNewsRoleTemplateStore.js +47 -0
  32. package/lib/cardNewsTemplateStore.js +210 -0
  33. package/lib/db.js +20 -3
  34. package/lib/errorClassify.js +2 -2
  35. package/lib/generationErrors.js +51 -0
  36. package/lib/historyList.js +82 -8
  37. package/lib/inflight.js +117 -34
  38. package/lib/logger.js +37 -3
  39. package/lib/oauthLauncher.js +52 -19
  40. package/lib/oauthProxy.js +81 -14
  41. package/lib/requestLogger.js +48 -0
  42. package/lib/runtimePorts.js +93 -0
  43. package/lib/sessionStore.js +48 -7
  44. package/package.json +3 -2
  45. package/routes/cardNews.js +183 -0
  46. package/routes/edit.js +1 -1
  47. package/routes/generate.js +10 -10
  48. package/routes/health.js +27 -3
  49. package/routes/index.js +2 -0
  50. package/routes/nodes.js +93 -26
  51. package/server.js +91 -18
  52. package/ui/dist/assets/index-BjX_nzuK.js +23 -0
  53. package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
  54. package/ui/dist/assets/index-DHyUax4_.css +1 -0
  55. package/ui/dist/index.html +2 -2
  56. package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
  57. package/ui/dist/assets/index-IHSd1z1a.js +0 -22
  58. 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
+ }
@@ -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
- // Validate edges reference existing nodes (drop dangling).
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 cleanEdges) {
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.0",
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 : null;
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;
@@ -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 : null;
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
- const err = new Error("Content generation refused after retries");
154
- err.code = "SAFETY_REFUSAL";
155
- err.status = 422;
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 === "SAFETY_REFUSAL") {
205
+ if (firstErr?.code) {
206
+ const status = firstErr.status || 500;
207
207
  finishStatus = "error";
208
- finishHttpStatus = 422;
209
- finishErrorCode = "SAFETY_REFUSAL";
210
- return res.status(422).json({ error: firstErr.message, code: "SAFETY_REFUSAL" });
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
  }