ima2-gen 1.0.4 → 1.0.6

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 CHANGED
@@ -51,7 +51,7 @@ Both indicators shown live in the left panel (green dot = ready, red dot = disab
51
51
  | **Quality** | Low (fast) · Medium (balanced) · High (best) |
52
52
  | **Size** | `1024²` `1536×1024` `1024×1536` `1360×1024` `1024×1360` `1824×1024` `1024×1824` `2048²` `2048×1152` `1152×2048` `3824×2160` `2160×3824` · `auto` · custom |
53
53
  | **Format** | PNG · JPEG · WebP |
54
- | **Moderation** | Low (less restrictive) · Auto (standard) |
54
+ | **Moderation** | Low (relaxed filter, default) · Auto (standard filter) |
55
55
  | **Count** | 1 · 2 · 4 parallel |
56
56
 
57
57
  All sizes respect gpt-image-2 constraints: every side is a multiple of 16, long:short ratio ≤ 3:1, 655,360–8,294,400 total pixels.
package/bin/ima2.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
6
6
  import { spawn, execSync } from "child_process";
7
7
  import { networkInterfaces, homedir } from "os";
8
8
  import { openUrl, resolveBin } from "./lib/platform.js";
9
+ import { detectCodexAuth } from "../lib/codexDetect.js";
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const ROOT = join(__dirname, "..");
@@ -67,12 +68,16 @@ async function setup() {
67
68
  saveConfig(config);
68
69
  console.log("\n Starting OAuth login...\n");
69
70
 
70
- // Check if codex auth exists
71
- const hasAuth =
72
- existsSync(join(HOME, ".codex", "auth.json")) ||
73
- existsSync(join(HOME, ".chatgpt-local", "auth.json"));
71
+ // Check if codex auth exists (file OR keyring via `codex login status`)
72
+ const auth = detectCodexAuth();
73
+ const hasAuth = auth.authed;
74
74
 
75
75
  if (!hasAuth) {
76
+ if (auth.platform === "win32") {
77
+ console.log(
78
+ " Windows note: OpenAI Codex has no documented native installer. Use WSL2 for best results.\n",
79
+ );
80
+ }
76
81
  console.log(" Running 'codex login' — follow the browser prompt.\n");
77
82
  try {
78
83
  execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
@@ -82,7 +87,8 @@ async function setup() {
82
87
  process.exit(1);
83
88
  }
84
89
  } else {
85
- console.log(" Existing OAuth session found.\n");
90
+ const how = auth.probe === "authed" ? "codex CLI" : "auth file";
91
+ console.log(` Existing OAuth session found (${how}).\n`);
86
92
  }
87
93
 
88
94
  saveConfig(config);
@@ -156,12 +162,22 @@ async function showStatus() {
156
162
  console.log(" Run 'ima2 setup' to configure.\n");
157
163
  }
158
164
 
159
- // Check OAuth auth files
160
- const hasCodexAuth = existsSync(join(HOME, ".codex", "auth.json"));
161
- const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
165
+ // Check OAuth auth files + codex CLI probe
166
+ const auth = detectCodexAuth();
162
167
  console.log(` OAuth sessions:`);
163
- console.log(` ~/.codex/auth.json ${hasCodexAuth ? "✓" : "✗"}`);
164
- console.log(` ~/.chatgpt-local/auth.json ${hasChatgptAuth ? "✓" : "✗"}`);
168
+ console.log(` ${auth.files.codex} ${auth.fileHits.codex ? "✓" : "✗"}`);
169
+ console.log(` ${auth.files.chatgpt} ${auth.fileHits.chatgpt ? "✓" : "✗"}`);
170
+ if (auth.fileHits.xdgCodex) {
171
+ console.log(` ${auth.files.xdgCodex} ✓`);
172
+ }
173
+ const probeLabel =
174
+ auth.probe === "authed" ? "✓ authed"
175
+ : auth.probe === "unauthed" ? "✗ not logged in"
176
+ : "– codex CLI not found";
177
+ console.log(` codex login status ${probeLabel}`);
178
+ if (auth.platform === "win32") {
179
+ console.log(" (Windows: no native codex installer — use WSL2)");
180
+ }
165
181
  console.log("");
166
182
  }
167
183
 
@@ -37,6 +37,14 @@ export function resolveBin(name) {
37
37
  * spawn() wrapper that works for npm/npx/any PATH-resolved exe on Windows.
38
38
  */
39
39
  export function spawnBin(name, args, opts = {}) {
40
+ if (isWin) {
41
+ // Node 24 on Windows can throw EINVAL when spawning PATH-resolved .cmd
42
+ // shims directly with piped stdio. Routing through cmd.exe avoids that.
43
+ return spawn("cmd.exe", ["/d", "/s", "/c", `${name} ${args.join(" ")}`], {
44
+ windowsHide: true,
45
+ ...opts,
46
+ });
47
+ }
40
48
  return spawn(resolveBin(name), args, { windowsHide: true, ...opts });
41
49
  }
42
50
 
@@ -0,0 +1,120 @@
1
+ import { getDb } from "./db.js";
2
+ import { rename, unlink, mkdir, access } from "fs/promises";
3
+ import { join, resolve, sep } from "path";
4
+
5
+ const DIR = "generated";
6
+ const TRASH = ".trash";
7
+ const TRASH_TTL_MS = 10_000;
8
+
9
+ function resolveInGenerated(rootDir, relPath) {
10
+ if (typeof relPath !== "string" || relPath.length === 0) {
11
+ const err = new Error("filename required");
12
+ err.status = 400;
13
+ err.code = "INVALID_FILENAME";
14
+ throw err;
15
+ }
16
+ if (relPath.includes("\0")) {
17
+ const err = new Error("invalid filename");
18
+ err.status = 400;
19
+ err.code = "INVALID_FILENAME";
20
+ throw err;
21
+ }
22
+ const baseDir = resolve(rootDir, DIR);
23
+ const target = resolve(baseDir, relPath);
24
+ if (target !== baseDir && !target.startsWith(baseDir + sep)) {
25
+ const err = new Error("filename escapes generated/");
26
+ err.status = 400;
27
+ err.code = "INVALID_FILENAME";
28
+ throw err;
29
+ }
30
+ return target;
31
+ }
32
+
33
+ function nodesReferencingFilename(filename) {
34
+ // The client stores imageUrl as `/generated/<encoded filename>` in node data JSON.
35
+ // We scan all sessions' nodes for substring match on the decoded and encoded forms.
36
+ const db = getDb();
37
+ const encoded = encodeURIComponent(filename);
38
+ const rows = db
39
+ .prepare("SELECT session_id AS sessionId, id, data FROM nodes WHERE data LIKE ? OR data LIKE ?")
40
+ .all(`%${filename}%`, `%${encoded}%`);
41
+ return rows;
42
+ }
43
+
44
+ function markNodesAssetMissing(filename) {
45
+ const db = getDb();
46
+ const rows = nodesReferencingFilename(filename);
47
+ if (rows.length === 0) return { sessionsTouched: 0, nodesTouched: 0 };
48
+ const touchedSessions = new Set();
49
+ const update = db.prepare("UPDATE nodes SET data = ? WHERE session_id = ? AND id = ?");
50
+ const bumpSession = db.prepare("UPDATE sessions SET graph_version = graph_version + 1, updated_at = ? WHERE id = ?");
51
+ const tx = db.transaction(() => {
52
+ for (const r of rows) {
53
+ let data;
54
+ try { data = JSON.parse(r.data); } catch { data = {}; }
55
+ const imgRef = data?.imageUrl || "";
56
+ if (imgRef.includes(filename) || imgRef.includes(encodeURIComponent(filename))) {
57
+ data.imageUrl = null;
58
+ data.status = "asset-missing";
59
+ update.run(JSON.stringify(data), r.sessionId, r.id);
60
+ touchedSessions.add(r.sessionId);
61
+ }
62
+ }
63
+ const t = Date.now();
64
+ for (const sid of touchedSessions) bumpSession.run(t, sid);
65
+ });
66
+ tx();
67
+ return { sessionsTouched: touchedSessions.size, nodesTouched: rows.length };
68
+ }
69
+
70
+ export async function trashAsset(rootDir, filename) {
71
+ const src = resolveInGenerated(rootDir, filename);
72
+ try {
73
+ await access(src);
74
+ } catch {
75
+ const err = new Error("Asset not found");
76
+ err.status = 404;
77
+ err.code = "ASSET_NOT_FOUND";
78
+ throw err;
79
+ }
80
+ const trashDir = resolve(rootDir, DIR, TRASH);
81
+ await mkdir(trashDir, { recursive: true });
82
+ // Flatten filename (subdir separators -> __) so trash is flat & easy to restore
83
+ const flat = filename.replace(/[\\/]+/g, "__");
84
+ const trashPath = join(trashDir, `${Date.now()}_${flat}`);
85
+ await rename(src, trashPath);
86
+ // Move sidecar too (best-effort)
87
+ await rename(src + ".json", trashPath + ".json").catch(() => {});
88
+
89
+ const summary = markNodesAssetMissing(filename);
90
+
91
+ // Schedule hard delete after TTL
92
+ const unlinkAt = Date.now() + TRASH_TTL_MS;
93
+ setTimeout(async () => {
94
+ await unlink(trashPath).catch(() => {});
95
+ await unlink(trashPath + ".json").catch(() => {});
96
+ }, TRASH_TTL_MS).unref?.();
97
+
98
+ return {
99
+ ok: true,
100
+ trashId: trashPath.slice(trashDir.length + 1),
101
+ filename,
102
+ unlinkAt,
103
+ sessionsTouched: summary.sessionsTouched,
104
+ nodesTouched: summary.nodesTouched,
105
+ };
106
+ }
107
+
108
+ export async function restoreAsset(rootDir, trashId, originalFilename) {
109
+ const trashDir = resolve(rootDir, DIR, TRASH);
110
+ const src = resolve(trashDir, trashId);
111
+ if (!src.startsWith(trashDir + sep) && src !== trashDir) {
112
+ const err = new Error("invalid trashId");
113
+ err.status = 400;
114
+ throw err;
115
+ }
116
+ const dst = resolveInGenerated(rootDir, originalFilename);
117
+ await rename(src, dst);
118
+ await rename(src + ".json", dst + ".json").catch(() => {});
119
+ return { ok: true };
120
+ }
@@ -0,0 +1,69 @@
1
+ // Codex CLI / OAuth auth detection across platforms.
2
+ // References:
3
+ // - OpenAI Codex stores auth under CODEX_HOME (default ~/.codex/auth.json).
4
+ // - Legacy chatgpt-local stores auth under ~/.chatgpt-local/auth.json.
5
+ // - Auth may live in OS keyring instead of a file (file absence ≠ unauth).
6
+ // - Windows has no documented native install path; WSL is the supported path.
7
+ import { existsSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const HOME = homedir();
13
+
14
+ export function codexAuthPaths() {
15
+ const codexHome = process.env.CODEX_HOME || join(HOME, ".codex");
16
+ return {
17
+ codex: join(codexHome, "auth.json"),
18
+ chatgpt: join(HOME, ".chatgpt-local", "auth.json"),
19
+ xdgCodex: join(HOME, ".config", "codex", "auth.json"),
20
+ };
21
+ }
22
+
23
+ export function hasAuthFile() {
24
+ const p = codexAuthPaths();
25
+ return existsSync(p.codex) || existsSync(p.chatgpt) || existsSync(p.xdgCodex);
26
+ }
27
+
28
+ // Non-invasive probe: `codex login status` returns 0 when authed (file OR keyring).
29
+ // Returns: "authed" | "unauthed" | "missing" (codex binary not found)
30
+ export function codexLoginStatus(timeoutMs = 2000) {
31
+ const candidates =
32
+ process.platform === "win32"
33
+ ? ["codex.cmd", "codex.exe", "codex"]
34
+ : ["codex"];
35
+ for (const bin of candidates) {
36
+ try {
37
+ execFileSync(bin, ["login", "status"], {
38
+ stdio: "ignore",
39
+ timeout: timeoutMs,
40
+ windowsHide: true,
41
+ });
42
+ return "authed";
43
+ } catch (err) {
44
+ if (err && err.code === "ENOENT") continue;
45
+ // non-zero exit = binary exists but not authed
46
+ if (err && typeof err.status === "number") return "unauthed";
47
+ }
48
+ }
49
+ return "missing";
50
+ }
51
+
52
+ export function detectCodexAuth() {
53
+ const files = codexAuthPaths();
54
+ const fileHits = {
55
+ codex: existsSync(files.codex),
56
+ chatgpt: existsSync(files.chatgpt),
57
+ xdgCodex: existsSync(files.xdgCodex),
58
+ };
59
+ const probe = codexLoginStatus();
60
+ const authed = probe === "authed" || fileHits.codex || fileHits.chatgpt || fileHits.xdgCodex;
61
+ return {
62
+ authed,
63
+ probe,
64
+ files,
65
+ fileHits,
66
+ platform: process.platform,
67
+ wslHint: process.platform === "win32",
68
+ };
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "GPT Image 2 generator with OAuth & API key support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/ima2.js serve",
11
- "dev": "node --watch server.js",
11
+ "dev": "node scripts/dev.mjs",
12
+ "dev:server": "node --watch server.js",
12
13
  "ui:install": "cd ui && npm install",
13
14
  "ui:dev": "cd ui && npm run dev",
14
15
  "ui:build": "cd ui && npm run build",
package/server.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from "child_process";
7
7
  import { spawnBin, onShutdown } from "./bin/lib/platform.js";
8
8
  import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync as fsReadFileSync } from "fs";
9
9
  import { homedir } from "os";
10
+ import { randomBytes } from "crypto";
10
11
  import { newNodeId, saveNode, loadNodeB64, loadNodeMeta, loadAssetB64 } from "./lib/nodeStore.js";
11
12
  import { startJob, finishJob, listJobs, setJobPhase } from "./lib/inflight.js";
12
13
  import {
@@ -18,6 +19,7 @@ import {
18
19
  saveGraph,
19
20
  ensureDefaultSession,
20
21
  } from "./lib/sessionStore.js";
22
+ import { trashAsset, restoreAsset } from "./lib/assetLifecycle.js";
21
23
 
22
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
25
  const app = express();
@@ -60,6 +62,7 @@ app.use("/generated", express.static(join(__dirname, "generated"), {
60
62
  // ── Reference validation ──
61
63
  const MAX_REF_B64_BYTES = 7 * 1024 * 1024; // ~5.2MB binary after base64 decode
62
64
  const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
65
+ const VALID_MODERATION = new Set(["auto", "low"]);
63
66
  function validateAndNormalizeRefs(references) {
64
67
  if (!Array.isArray(references)) return { error: "references must be an array" };
65
68
  if (references.length > 5) return { error: "references may not exceed 5 items" };
@@ -80,6 +83,13 @@ function validateAndNormalizeRefs(references) {
80
83
  return { refs: out };
81
84
  }
82
85
 
86
+ function validateModeration(moderation) {
87
+ if (typeof moderation !== "string" || !VALID_MODERATION.has(moderation)) {
88
+ return { error: "moderation must be one of: auto, low" };
89
+ }
90
+ return { moderation };
91
+ }
92
+
83
93
  // ── OAuth proxy: generate via Responses API (stream mode) ──
84
94
  // Research mode is ALWAYS ON for OAuth — web_search is included in tools, GPT
85
95
  // decides per-prompt whether to actually invoke it. Simple prompts skip web_search
@@ -87,10 +97,10 @@ function validateAndNormalizeRefs(references) {
87
97
  const RESEARCH_SUFFIX =
88
98
  "\n\n필요하면 먼저 웹에서 이 주제의 정확한 레퍼런스(얼굴/제품/장소/최신 정보)를 검색한 뒤 그걸 토대로 이미지를 생성해. 단순한 주제는 곧바로 생성해도 돼.";
89
99
 
90
- async function generateViaOAuth(prompt, quality, size, references = [], requestId = null) {
100
+ async function generateViaOAuth(prompt, quality, size, moderation = "low", references = [], requestId = null) {
91
101
  const tools = [
92
102
  { type: "web_search" },
93
- { type: "image_generation", quality, size },
103
+ { type: "image_generation", quality, size, moderation },
94
104
  ];
95
105
 
96
106
  const textPrompt = `Generate an image: ${prompt}${RESEARCH_SUFFIX}`;
@@ -218,7 +228,7 @@ async function generateViaOAuth(prompt, quality, size, references = [], requestI
218
228
  body: JSON.stringify({
219
229
  model: "gpt-5.4",
220
230
  input: [{ role: "user", content: prompt }],
221
- tools: [{ type: "image_generation", quality, size }],
231
+ tools: [{ type: "image_generation", quality, size, moderation }],
222
232
  stream: false,
223
233
  }),
224
234
  });
@@ -278,6 +288,7 @@ async function listImages(baseDir) {
278
288
  async function walk(dir, depth) {
279
289
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
280
290
  for (const e of entries) {
291
+ if (e.name === ".trash") continue;
281
292
  const full = join(dir, e.name);
282
293
  if (e.isDirectory() && depth > 0) {
283
294
  await walk(full, depth - 1);
@@ -294,7 +305,14 @@ app.get("/api/history", async (req, res) => {
294
305
  try {
295
306
  const dir = join(__dirname, "generated");
296
307
  await mkdir(dir, { recursive: true });
297
- const limit = Math.min(parseInt(req.query.limit) || 50, 200);
308
+ const limitRaw = parseInt(req.query.limit);
309
+ const limit = Math.min(Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 50, 500);
310
+ const beforeTs = parseInt(req.query.before);
311
+ const beforeFn = typeof req.query.beforeFilename === "string" ? req.query.beforeFilename : null;
312
+ const sinceTs = parseInt(req.query.since);
313
+ const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
314
+ const groupBy = req.query.groupBy === "session" ? "session" : null;
315
+
298
316
  const imgs = await listImages(dir);
299
317
  const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
300
318
  const st = await stat(full).catch(() => null);
@@ -316,16 +334,91 @@ app.get("/api/history", async (req, res) => {
316
334
  provider: meta?.provider || "oauth",
317
335
  usage: meta?.usage || null,
318
336
  webSearchCalls: meta?.webSearchCalls || 0,
337
+ sessionId: meta?.sessionId || null,
338
+ nodeId: meta?.nodeId || null,
339
+ parentNodeId: meta?.parentNodeId || null,
340
+ clientNodeId: meta?.clientNodeId || null,
341
+ kind: meta?.kind || null,
319
342
  };
320
343
  }));
321
- rows.sort((a, b) => b.createdAt - a.createdAt);
322
- res.json({ items: rows.slice(0, limit), total: rows.length });
344
+
345
+ // composite sort: createdAt DESC, filename DESC (stable tiebreaker)
346
+ rows.sort((a, b) => {
347
+ if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
348
+ return b.filename < a.filename ? -1 : b.filename > a.filename ? 1 : 0;
349
+ });
350
+
351
+ let filtered = rows;
352
+ if (Number.isFinite(sinceTs)) {
353
+ filtered = filtered.filter((r) => r.createdAt > sinceTs);
354
+ }
355
+ if (Number.isFinite(beforeTs)) {
356
+ filtered = filtered.filter((r) => {
357
+ if (r.createdAt < beforeTs) return true;
358
+ if (r.createdAt === beforeTs && beforeFn) return r.filename < beforeFn;
359
+ return false;
360
+ });
361
+ }
362
+ if (sessionId) {
363
+ filtered = filtered.filter((r) => r.sessionId === sessionId);
364
+ }
365
+
366
+ const page = filtered.slice(0, limit);
367
+ const nextCursor = page.length === limit && filtered.length > limit
368
+ ? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
369
+ : null;
370
+
371
+ if (groupBy === "session") {
372
+ // Group by sessionId while preserving createdAt DESC order overall.
373
+ const groups = new Map(); // sessionId|null -> { sessionId, items, lastUsedAt }
374
+ const loose = [];
375
+ for (const r of page) {
376
+ if (r.sessionId) {
377
+ let g = groups.get(r.sessionId);
378
+ if (!g) {
379
+ g = { sessionId: r.sessionId, items: [], lastUsedAt: r.createdAt };
380
+ groups.set(r.sessionId, g);
381
+ }
382
+ g.items.push(r);
383
+ if (r.createdAt > g.lastUsedAt) g.lastUsedAt = r.createdAt;
384
+ } else {
385
+ loose.push(r);
386
+ }
387
+ }
388
+ const sessions = Array.from(groups.values()).sort((a, b) => b.lastUsedAt - a.lastUsedAt);
389
+ return res.json({ sessions, loose, total: rows.length, nextCursor });
390
+ }
391
+
392
+ res.json({ items: page, total: rows.length, nextCursor });
323
393
  } catch (err) {
324
394
  console.error("[history] error:", err.message);
325
395
  res.status(500).json({ error: err.message });
326
396
  }
327
397
  });
328
398
 
399
+ // ── Asset lifecycle: soft-delete to .trash/, auto-purge after TTL ──
400
+ app.delete("/api/history/:filename", async (req, res) => {
401
+ try {
402
+ const filename = decodeURIComponent(req.params.filename);
403
+ const result = await trashAsset(__dirname, filename);
404
+ res.json(result);
405
+ } catch (err) {
406
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
407
+ }
408
+ });
409
+
410
+ app.post("/api/history/:filename/restore", async (req, res) => {
411
+ try {
412
+ const filename = decodeURIComponent(req.params.filename);
413
+ const trashId = typeof req.body?.trashId === "string" ? req.body.trashId : null;
414
+ if (!trashId) return res.status(400).json({ error: "trashId required" });
415
+ const result = await restoreAsset(__dirname, trashId, filename);
416
+ res.json(result);
417
+ } catch (err) {
418
+ res.status(err.status || 500).json({ error: err.message });
419
+ }
420
+ });
421
+
329
422
  // ── OAuth status ──
330
423
  app.get("/api/oauth/status", async (_req, res) => {
331
424
  try {
@@ -371,6 +464,8 @@ app.post("/api/generate", async (req, res) => {
371
464
  req.body;
372
465
 
373
466
  if (!prompt) return res.status(400).json({ error: "Prompt is required" });
467
+ const moderationCheck = validateModeration(moderation);
468
+ if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
374
469
  const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
375
470
  startJob({
376
471
  requestId,
@@ -399,7 +494,7 @@ app.post("/api/generate", async (req, res) => {
399
494
  }
400
495
  const useOAuth = true;
401
496
  const __client = req.get("x-ima2-client") || "ui";
402
- console.log(`[generate][${__client}] provider=oauth quality=${quality} size=${size} n=${count} refs=${refB64s.length}`);
497
+ console.log(`[generate][${__client}] provider=oauth quality=${quality} size=${size} moderation=${moderation} n=${count} refs=${refB64s.length}`);
403
498
  const startTime = Date.now();
404
499
 
405
500
  const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
@@ -411,7 +506,7 @@ app.post("/api/generate", async (req, res) => {
411
506
  let lastErr;
412
507
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
413
508
  try {
414
- const r = await generateViaOAuth(prompt, quality, size, refB64s, requestId);
509
+ const r = await generateViaOAuth(prompt, quality, size, moderation, refB64s, requestId);
415
510
  if (r.b64) return r;
416
511
  lastErr = new Error("Empty response (safety refusal)");
417
512
  } catch (e) {
@@ -433,7 +528,8 @@ app.post("/api/generate", async (req, res) => {
433
528
  let totalWebSearchCalls = 0;
434
529
  for (const r of results) {
435
530
  if (r.status === "fulfilled" && r.value.b64) {
436
- const filename = `${Date.now()}_${images.length}.${format}`;
531
+ const rand = randomBytes(4).toString("hex");
532
+ const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
437
533
  await writeFile(join(__dirname, "generated", filename), Buffer.from(r.value.b64, "base64"));
438
534
  // Sidecar metadata for /api/history reconstruction
439
535
  const meta = {
@@ -441,6 +537,7 @@ app.post("/api/generate", async (req, res) => {
441
537
  quality,
442
538
  size,
443
539
  format,
540
+ moderation,
444
541
  provider: "oauth",
445
542
  createdAt: Date.now(),
446
543
  usage: r.value.usage || null,
@@ -476,6 +573,7 @@ app.post("/api/generate", async (req, res) => {
476
573
  webSearchCalls: totalWebSearchCalls,
477
574
  quality,
478
575
  size,
576
+ moderation,
479
577
  };
480
578
 
481
579
  if (count === 1) {
@@ -492,7 +590,7 @@ app.post("/api/generate", async (req, res) => {
492
590
  });
493
591
 
494
592
  // ── OAuth edit: send image as input to Responses API ──
495
- async function editViaOAuth(prompt, imageB64, quality, size) {
593
+ async function editViaOAuth(prompt, imageB64, quality, size, moderation = "low") {
496
594
  const res = await fetch(`${OAUTH_URL}/v1/responses`, {
497
595
  method: "POST",
498
596
  headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
@@ -508,7 +606,7 @@ async function editViaOAuth(prompt, imageB64, quality, size) {
508
606
  ],
509
607
  },
510
608
  ],
511
- tools: [{ type: "image_generation", quality, size }],
609
+ tools: [{ type: "image_generation", quality, size, moderation }],
512
610
  tool_choice: "required",
513
611
  stream: true,
514
612
  }),
@@ -564,29 +662,32 @@ async function editViaOAuth(prompt, imageB64, quality, size) {
564
662
  // ── Edit image (inpainting) ──
565
663
  app.post("/api/edit", async (req, res) => {
566
664
  try {
567
- const { prompt, image: imageB64, mask: maskB64, quality = "low", size = "1024x1024", provider = "oauth" } =
665
+ const { prompt, image: imageB64, mask: maskB64, quality = "low", size = "1024x1024", moderation = "low", provider = "oauth" } =
568
666
  req.body;
569
667
 
570
668
  if (!prompt || !imageB64)
571
669
  return res.status(400).json({ error: "Prompt and image are required" });
670
+ const moderationCheck = validateModeration(moderation);
671
+ if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
572
672
 
573
673
  if (provider === "api") {
574
674
  return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
575
675
  }
576
- console.log(`[edit][${req.get("x-ima2-client") || "ui"}] provider=oauth quality=${quality} size=${size}`);
676
+ console.log(`[edit][${req.get("x-ima2-client") || "ui"}] provider=oauth quality=${quality} size=${size} moderation=${moderation}`);
577
677
  const startTime = Date.now();
578
678
 
579
- const { b64: resultB64, usage } = await editViaOAuth(prompt, imageB64, quality, size);
679
+ const { b64: resultB64, usage } = await editViaOAuth(prompt, imageB64, quality, size, moderation);
580
680
 
581
681
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
582
682
 
583
683
  await mkdir(join(__dirname, "generated"), { recursive: true });
584
- const filename = `${Date.now()}.png`;
684
+ const filename = `${Date.now()}_${randomBytes(4).toString("hex")}.png`;
585
685
  await writeFile(join(__dirname, "generated", filename), Buffer.from(resultB64, "base64"));
586
686
  const meta = {
587
687
  prompt,
588
688
  quality,
589
689
  size,
690
+ moderation,
590
691
  format: "png",
591
692
  provider: "oauth",
592
693
  kind: "edit",
@@ -602,6 +703,7 @@ app.post("/api/edit", async (req, res) => {
602
703
  filename,
603
704
  usage,
604
705
  provider: "oauth",
706
+ moderation,
605
707
  });
606
708
  } catch (err) {
607
709
  console.error("Edit error:", err.message);
@@ -634,6 +736,7 @@ app.post("/api/node/generate", async (req, res) => {
634
736
  quality = "low",
635
737
  size = "1024x1024",
636
738
  format = "png",
739
+ moderation = "low",
637
740
  references = [],
638
741
  externalSrc = null,
639
742
  } = body;
@@ -664,6 +767,13 @@ app.post("/api/node/generate", async (req, res) => {
664
767
  parentNodeId,
665
768
  });
666
769
  }
770
+ const moderationCheck = validateModeration(moderation);
771
+ if (moderationCheck.error) {
772
+ return res.status(400).json({
773
+ error: { code: "INVALID_MODERATION", message: moderationCheck.error },
774
+ parentNodeId,
775
+ });
776
+ }
667
777
  const refB64s = refCheck.refs;
668
778
 
669
779
  const startTime = Date.now();
@@ -683,8 +793,8 @@ app.post("/api/node/generate", async (req, res) => {
683
793
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
684
794
  try {
685
795
  const r = parentB64
686
- ? await editViaOAuth(prompt, parentB64, quality, size)
687
- : await generateViaOAuth(prompt, quality, size, refB64s, requestId);
796
+ ? await editViaOAuth(prompt, parentB64, quality, size, moderation)
797
+ : await generateViaOAuth(prompt, quality, size, moderation, refB64s, requestId);
688
798
  if (r.b64) {
689
799
  b64 = r.b64;
690
800
  usage = r.usage;
@@ -712,8 +822,10 @@ app.post("/api/node/generate", async (req, res) => {
712
822
  const meta = {
713
823
  nodeId,
714
824
  parentNodeId,
825
+ sessionId,
826
+ clientNodeId,
715
827
  prompt,
716
- options: { quality, size, format },
828
+ options: { quality, size, format, moderation },
717
829
  createdAt: Date.now(),
718
830
  createdAtIso: new Date().toISOString(),
719
831
  elapsed,
@@ -722,7 +834,7 @@ app.post("/api/node/generate", async (req, res) => {
722
834
  provider: "oauth",
723
835
  kind: parentB64 ? "edit" : "generate",
724
836
  // Fields consumed by /api/history flat scan (so node images appear in history too)
725
- quality, size, format,
837
+ quality, size, format, moderation,
726
838
  };
727
839
  await mkdir(join(__dirname, "generated"), { recursive: true });
728
840
  const { filename } = await saveNode(__dirname, { nodeId, b64, meta, ext: format });
@@ -738,6 +850,7 @@ app.post("/api/node/generate", async (req, res) => {
738
850
  usage,
739
851
  webSearchCalls,
740
852
  provider: "oauth",
853
+ moderation,
741
854
  });
742
855
  } catch (err) {
743
856
  console.error("[node/generate] error:", err.message);
@@ -989,7 +1102,7 @@ onShutdown(() => {
989
1102
  });
990
1103
  process.on("exit", __unadvertise);
991
1104
 
992
- app.listen(PORT, () => {
1105
+ const server = app.listen(PORT, () => {
993
1106
  console.log(`Image Gen running at http://localhost:${PORT}`);
994
1107
  console.log(`Provider policy: OAuth only (API key hard-disabled). OAuth proxy port ${OAUTH_PORT}.`);
995
1108
  __advertise();
@@ -1000,3 +1113,12 @@ app.listen(PORT, () => {
1000
1113
  console.error("[db] bootstrap failed:", err.message);
1001
1114
  }
1002
1115
  });
1116
+
1117
+ server.on("error", (err) => {
1118
+ if (err?.code === "EADDRINUSE") {
1119
+ console.error(`[server] Port ${PORT} is already in use. Stop the existing image_gen server before starting another dev server.`);
1120
+ process.exit(1);
1121
+ }
1122
+ console.error("[server] Failed to start:", err?.message || err);
1123
+ process.exit(1);
1124
+ });