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
package/routes/nodes.js CHANGED
@@ -12,6 +12,7 @@ import { classifyUpstreamError } from "../lib/errorClassify.js";
12
12
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
13
13
  import { normalizeImageModel } from "../lib/imageModels.js";
14
14
  import { generateViaOAuth, editViaOAuth } from "../lib/oauthProxy.js";
15
+ import { normalizeGenerationFailure } from "../lib/generationErrors.js";
15
16
  import { getStyleSheet } from "../lib/sessionStore.js";
16
17
  import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
17
18
  import { logEvent, logError } from "../lib/logger.js";
@@ -33,12 +34,13 @@ function writeSse(res, event, data) {
33
34
  res.write(`data: ${JSON.stringify(data)}\n\n`);
34
35
  }
35
36
 
36
- function writeNodeError(res, status, code, message, parentNodeId) {
37
+ function writeNodeError(res, status, code, message, parentNodeId, details = {}) {
37
38
  if (res.headersSent) {
38
39
  writeSse(res, "error", {
39
40
  error: { code, message },
40
41
  parentNodeId,
41
42
  status,
43
+ ...details,
42
44
  });
43
45
  res.end();
44
46
  return;
@@ -46,6 +48,7 @@ function writeNodeError(res, status, code, message, parentNodeId) {
46
48
  res.status(status).json({
47
49
  error: { code, message },
48
50
  parentNodeId,
51
+ ...details,
49
52
  });
50
53
  }
51
54
 
@@ -58,7 +61,7 @@ export function registerNodeRoutes(app, ctx) {
58
61
  const body = req.body || {};
59
62
  const streamResponse = wantsSse(req);
60
63
  const parentNodeId = body.parentNodeId ?? null;
61
- const requestId = typeof body.requestId === "string" ? body.requestId : null;
64
+ const requestId = typeof body.requestId === "string" ? body.requestId : req.id;
62
65
  const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
63
66
  const clientNodeId = typeof body.clientNodeId === "string" ? body.clientNodeId : null;
64
67
  let finishMeta = {};
@@ -82,6 +85,8 @@ export function registerNodeRoutes(app, ctx) {
82
85
  references = [],
83
86
  externalSrc = null,
84
87
  mode: promptMode = "auto",
88
+ contextMode: rawContextMode = "parent-plus-refs",
89
+ searchMode: rawSearchMode = "off",
85
90
  model: rawModel,
86
91
  } = body;
87
92
  const { provider = "oauth" } = body;
@@ -98,6 +103,19 @@ export function registerNodeRoutes(app, ctx) {
98
103
  }
99
104
  const imageModel = modelCheck.model;
100
105
  const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
106
+ const contextMode = ["parent-plus-refs", "parent-only", "ancestry"].includes(rawContextMode)
107
+ ? rawContextMode
108
+ : "parent-plus-refs";
109
+ const searchMode = ["off", "auto", "on"].includes(rawSearchMode) ? rawSearchMode : "off";
110
+ if (contextMode === "ancestry") {
111
+ finishStatus = "error";
112
+ finishHttpStatus = 400;
113
+ finishErrorCode = "CONTEXT_MODE_UNSUPPORTED";
114
+ return res.status(400).json({
115
+ error: { code: "CONTEXT_MODE_UNSUPPORTED", message: "Ancestry context is not supported yet." },
116
+ parentNodeId,
117
+ });
118
+ }
101
119
 
102
120
  if (provider === "api") {
103
121
  finishStatus = "error";
@@ -128,18 +146,6 @@ export function registerNodeRoutes(app, ctx) {
128
146
  parentNodeId,
129
147
  });
130
148
  }
131
- if ((parentNodeId || externalSrc) && refCheck.refs.length > 0) {
132
- finishStatus = "error";
133
- finishHttpStatus = 400;
134
- finishErrorCode = "NODE_REFS_UNSUPPORTED_FOR_EDIT";
135
- return res.status(400).json({
136
- error: {
137
- code: "NODE_REFS_UNSUPPORTED_FOR_EDIT",
138
- message: "Extra references are only supported for root node generation.",
139
- },
140
- parentNodeId,
141
- });
142
- }
143
149
  const moderationCheck = validateModeration(ctx, moderation);
144
150
  if (moderationCheck.error) {
145
151
  finishStatus = "error";
@@ -173,9 +179,14 @@ export function registerNodeRoutes(app, ctx) {
173
179
  } else if (typeof externalSrc === "string" && externalSrc.length > 0) {
174
180
  parentB64 = await loadAssetB64(ctx.rootDir, externalSrc, ctx.config.storage.generatedDir);
175
181
  }
182
+ const operation = parentB64 ? "edit" : "generate";
183
+ const refsForRequest = contextMode === "parent-only" ? [] : refCheck.refs;
184
+ const webSearchEnabled = !parentB64 || searchMode === "on";
185
+ const parentImagePresent = !!parentB64;
186
+ const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
176
187
  logEvent("node", "request", {
177
188
  requestId,
178
- operation: parentB64 ? "edit" : "generate",
189
+ operation,
179
190
  sessionId,
180
191
  parentNodeId,
181
192
  clientNodeId,
@@ -183,7 +194,12 @@ export function registerNodeRoutes(app, ctx) {
183
194
  model: imageModel,
184
195
  size,
185
196
  moderation,
186
- refs: refCheck.refs.length,
197
+ refs: refsForRequest.length,
198
+ inputImageCount,
199
+ parentImagePresent,
200
+ contextMode,
201
+ searchMode,
202
+ webSearchEnabled,
187
203
  promptChars: prompt.length,
188
204
  promptMode: normalizedPromptMode,
189
205
  styleSheetApplied: !!styleSheetApplied,
@@ -203,14 +219,32 @@ export function registerNodeRoutes(app, ctx) {
203
219
  let lastErr;
204
220
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
205
221
  try {
222
+ logEvent("node", "attempt", {
223
+ requestId,
224
+ attempt,
225
+ operation,
226
+ sessionId,
227
+ parentNodeId,
228
+ clientNodeId,
229
+ model: imageModel,
230
+ moderation,
231
+ quality,
232
+ size,
233
+ refs: refsForRequest.length,
234
+ inputImageCount,
235
+ parentImagePresent,
236
+ contextMode,
237
+ searchMode,
238
+ webSearchEnabled,
239
+ });
206
240
  const r = parentB64
207
- ? await editViaOAuth(effectivePrompt, parentB64, quality, size, moderation, normalizedPromptMode, ctx, requestId, { model: imageModel })
241
+ ? await editViaOAuth(effectivePrompt, parentB64, quality, size, moderation, normalizedPromptMode, ctx, requestId, { model: imageModel, references: refsForRequest, searchMode: searchMode === "on" ? "on" : "off" })
208
242
  : await generateViaOAuth(
209
243
  effectivePrompt,
210
244
  quality,
211
245
  size,
212
246
  moderation,
213
- refCheck.refs,
247
+ refsForRequest,
214
248
  requestId,
215
249
  normalizedPromptMode,
216
250
  ctx,
@@ -239,20 +273,48 @@ export function registerNodeRoutes(app, ctx) {
239
273
  lastErr = e;
240
274
  }
241
275
  if (attempt < MAX_RETRIES) {
242
- logEvent("node", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
276
+ logEvent("node", "retry", {
277
+ requestId,
278
+ attempt: attempt + 1,
279
+ operation,
280
+ parentNodeId,
281
+ clientNodeId,
282
+ errorCode: lastErr?.code,
283
+ errorEventType: lastErr?.eventType,
284
+ errorEventCount: lastErr?.eventCount,
285
+ });
243
286
  }
244
287
  }
245
288
 
246
289
  if (!b64) {
290
+ const finalErr = normalizeGenerationFailure(lastErr, {
291
+ safetyMessage: lastErr?.message || "Empty response after retry",
292
+ });
247
293
  finishStatus = "error";
248
- finishHttpStatus = 422;
249
- finishErrorCode = "SAFETY_REFUSAL";
294
+ finishHttpStatus = finalErr.status || 500;
295
+ finishErrorCode = finalErr.code || "NODE_GEN_FAILED";
296
+ logEvent("node", "final_error", {
297
+ requestId,
298
+ operation,
299
+ finalCode: finishErrorCode,
300
+ upstreamCode: lastErr?.code,
301
+ errorEventType: lastErr?.eventType,
302
+ errorEventCount: lastErr?.eventCount,
303
+ attempts: MAX_RETRIES + 1,
304
+ outerHttpAlreadyCommitted: res.headersSent,
305
+ sseErrorSent: streamResponse,
306
+ });
250
307
  return writeNodeError(
251
308
  res,
252
- 422,
253
- "SAFETY_REFUSAL",
254
- lastErr?.message || "Empty response after retry",
309
+ finishHttpStatus,
310
+ finishErrorCode,
311
+ finalErr.message,
255
312
  parentNodeId,
313
+ {
314
+ upstreamCode: lastErr?.code || null,
315
+ errorEventType: lastErr?.eventType || null,
316
+ errorEventCount: lastErr?.eventCount ?? null,
317
+ },
256
318
  );
257
319
  }
258
320
 
@@ -276,10 +338,12 @@ export function registerNodeRoutes(app, ctx) {
276
338
  elapsed,
277
339
  usage: usage || null,
278
340
  webSearchCalls,
341
+ contextMode,
342
+ searchMode,
279
343
  provider: "oauth",
280
344
  kind: parentB64 ? "edit" : "generate",
281
345
  requestId,
282
- refsCount: refCheck.refs.length,
346
+ refsCount: refsForRequest.length,
283
347
  quality,
284
348
  size,
285
349
  format,
@@ -315,8 +379,11 @@ export function registerNodeRoutes(app, ctx) {
315
379
  webSearchCalls,
316
380
  provider: "oauth",
317
381
  model: imageModel,
382
+ size,
318
383
  moderation,
319
- refsCount: refCheck.refs.length,
384
+ refsCount: refsForRequest.length,
385
+ contextMode,
386
+ searchMode,
320
387
  warnings: qualityWarnings,
321
388
  revisedPrompt,
322
389
  promptMode: normalizedPromptMode,
package/server.js CHANGED
@@ -14,8 +14,12 @@ import { onShutdown } from "./bin/lib/platform.js";
14
14
  import { ensureDefaultSession } from "./lib/sessionStore.js";
15
15
  import { startOAuthProxy } from "./lib/oauthLauncher.js";
16
16
  import { migrateGeneratedStorage } from "./lib/storageMigration.js";
17
+ import { purgeStaleJobs } from "./lib/inflight.js";
18
+ import { configureLogger } from "./lib/logger.js";
19
+ import { createRequestLogger } from "./lib/requestLogger.js";
17
20
  import { configureRoutes } from "./routes/index.js";
18
21
  import { config } from "./config.js";
22
+ import { getServerPort, listenWithPortFallback } from "./lib/runtimePorts.js";
19
23
 
20
24
  const rootDir = dirname(fileURLToPath(import.meta.url));
21
25
 
@@ -51,10 +55,28 @@ function readPackageVersion() {
51
55
  }
52
56
  }
53
57
 
58
+ function setUiStaticHeaders(res, filePath) {
59
+ const normalized = filePath.replace(/\\/g, "/");
60
+ if (normalized.endsWith("/index.html")) {
61
+ res.setHeader("Cache-Control", "no-store, max-age=0");
62
+ return;
63
+ }
64
+ if (normalized.includes("/assets/")) {
65
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
66
+ }
67
+ }
68
+
54
69
  export function buildApp(ctx) {
55
70
  const app = express();
71
+ configureLogger({ level: ctx.config.log.level });
72
+ app.use(createRequestLogger());
56
73
  app.use(express.json({ limit: ctx.config.server.bodyLimit }));
57
- app.use(express.static(join(ctx.rootDir, "ui", "dist")));
74
+ app.use(express.static(join(ctx.rootDir, "ui", "dist"), {
75
+ setHeaders: setUiStaticHeaders,
76
+ }));
77
+ app.use("/assets", (_req, res) => {
78
+ res.status(404).type("text/plain").send("Asset not found");
79
+ });
58
80
  app.use("/generated", express.static(ctx.config.storage.generatedDir, {
59
81
  maxAge: ctx.config.storage.staticMaxAge,
60
82
  immutable: true,
@@ -63,16 +85,33 @@ export function buildApp(ctx) {
63
85
  return app;
64
86
  }
65
87
 
88
+ function runtimeHostUrl(host) {
89
+ if (!host || host === "0.0.0.0" || host === "::") return "localhost";
90
+ return host;
91
+ }
92
+
66
93
  function advertise(ctx) {
67
94
  try {
68
95
  mkdirSync(dirname(ctx.config.storage.advertiseFile), { recursive: true });
69
96
  writeFileSync(
70
97
  ctx.config.storage.advertiseFile,
71
98
  JSON.stringify({
72
- port: Number(ctx.config.server.port),
99
+ port: Number(ctx.serverActualPort || ctx.config.server.port),
100
+ url: ctx.serverUrl,
73
101
  pid: process.pid,
74
102
  startedAt: ctx.startedAt,
75
103
  version: ctx.packageVersion,
104
+ backend: {
105
+ configuredPort: Number(ctx.serverConfiguredPort || ctx.config.server.port),
106
+ actualPort: Number(ctx.serverActualPort || ctx.config.server.port),
107
+ url: ctx.serverUrl,
108
+ },
109
+ oauth: {
110
+ configuredPort: Number(ctx.oauthPort),
111
+ actualPort: Number(ctx.oauthActualPort || ctx.oauthPort),
112
+ url: ctx.oauthUrl,
113
+ status: ctx.oauthReadyState,
114
+ },
76
115
  }),
77
116
  );
78
117
  } catch (e) {
@@ -99,11 +138,16 @@ export async function createRuntimeContext(overrides = {}) {
99
138
  const apiKey = loadedKey.apiKey;
100
139
  const openai = overrides.openai ?? await createOpenAI(apiKey);
101
140
  const oauthPort = config.oauth.proxyPort;
102
- return {
141
+ const ctx = {
103
142
  rootDir,
104
143
  config,
144
+ serverConfiguredPort: config.server.port,
145
+ serverActualPort: null,
146
+ serverUrl: `http://${runtimeHostUrl(config.server.host)}:${config.server.port}`,
105
147
  oauthPort,
148
+ oauthActualPort: oauthPort,
106
149
  oauthUrl: `http://127.0.0.1:${oauthPort}`,
150
+ oauthReadyState: config.oauth.autoStart ? "starting" : "disabled",
107
151
  hasApiKey: !!apiKey,
108
152
  apiKey,
109
153
  apiKeySource: loadedKey.apiKeySource,
@@ -111,11 +155,28 @@ export async function createRuntimeContext(overrides = {}) {
111
155
  startedAt: overrides.startedAt ?? Date.now(),
112
156
  packageVersion: overrides.packageVersion ?? readPackageVersion(),
113
157
  };
158
+ let resolveOAuthReady;
159
+ ctx.oauthReadyPromise = new Promise((resolve) => {
160
+ resolveOAuthReady = resolve;
161
+ });
162
+ ctx.markOAuthReady = ({ url, port } = {}) => {
163
+ if (url) ctx.oauthUrl = url;
164
+ if (port) ctx.oauthActualPort = port;
165
+ ctx.oauthReadyState = "ready";
166
+ resolveOAuthReady(ctx.oauthUrl);
167
+ };
168
+ ctx.markOAuthFailed = () => {
169
+ ctx.oauthReadyState = "failed";
170
+ resolveOAuthReady(null);
171
+ };
172
+ if (!config.oauth.autoStart) ctx.markOAuthReady({ url: ctx.oauthUrl, port: ctx.oauthPort });
173
+ return ctx;
114
174
  }
115
175
 
116
176
  export async function startServer(overrides = {}) {
117
177
  const ctx = await createRuntimeContext(overrides);
118
178
  await migrateGeneratedStorage(ctx);
179
+ purgeStaleJobs();
119
180
  const app = buildApp(ctx);
120
181
  const oauthChild =
121
182
  overrides.oauthChild !== undefined
@@ -125,31 +186,43 @@ export async function startServer(overrides = {}) {
125
186
  : startOAuthProxy({
126
187
  oauthPort: ctx.oauthPort,
127
188
  restartDelayMs: ctx.config.oauth.restartDelayMs,
189
+ onReady: ({ url, port }) => {
190
+ ctx.markOAuthReady({ url, port });
191
+ advertise(ctx);
192
+ },
193
+ onExit: () => ctx.markOAuthFailed(),
128
194
  });
195
+ if (overrides.oauthChild !== undefined || !ctx.config.oauth.autoStart) {
196
+ ctx.markOAuthReady({ url: ctx.oauthUrl, port: ctx.oauthPort });
197
+ }
129
198
 
130
199
  onShutdown(() => {
131
200
  unadvertise(ctx);
132
- try { oauthChild?.kill(); } catch {}
201
+ try { oauthChild?.stop?.(); } catch {}
202
+ try { oauthChild?.kill?.(); } catch {}
133
203
  });
134
204
  process.on("exit", () => unadvertise(ctx));
135
205
 
136
- const server = app.listen(ctx.config.server.port, () => {
137
- console.log(`Image Gen running at http://localhost:${ctx.config.server.port}`);
138
- console.log(`Provider policy: OAuth only (API key hard-disabled). OAuth proxy port ${ctx.oauthPort}.`);
139
- advertise(ctx);
140
- try {
141
- const s = ensureDefaultSession();
142
- console.log(`[db] default session: ${s.id} (${s.title})`);
143
- } catch (err) {
144
- console.error("[db] bootstrap failed:", err.message);
145
- }
206
+ const server = await listenWithPortFallback(app, ctx.config.server.port, {
207
+ host: ctx.config.server.host,
208
+ label: "server",
209
+ onFallback: ({ requestedPort, actualPort }) => {
210
+ console.log(`[server.port] requested=${requestedPort} actual=${actualPort} reason=EADDRINUSE`);
211
+ },
146
212
  });
213
+ ctx.serverActualPort = getServerPort(server) || ctx.config.server.port;
214
+ ctx.serverUrl = `http://${runtimeHostUrl(ctx.config.server.host)}:${ctx.serverActualPort}`;
215
+ console.log(`Image Gen running at ${ctx.serverUrl}`);
216
+ console.log(`Provider policy: OAuth only (API key hard-disabled). OAuth proxy port ${ctx.oauthPort}.`);
217
+ advertise(ctx);
218
+ try {
219
+ const s = ensureDefaultSession();
220
+ console.log(`[db] default session: ${s.id} (${s.title})`);
221
+ } catch (err) {
222
+ console.error("[db] bootstrap failed:", err.message);
223
+ }
147
224
 
148
225
  server.on("error", (err) => {
149
- if (err?.code === "EADDRINUSE") {
150
- console.error(`[server] Port ${ctx.config.server.port} is already in use. Stop the existing image_gen server before starting another dev server.`);
151
- process.exit(1);
152
- }
153
226
  console.error("[server] Failed to start:", err?.message || err);
154
227
  process.exit(1);
155
228
  });