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 +1 -1
- package/bin/ima2.js +26 -10
- package/bin/lib/platform.js +8 -0
- package/lib/assetLifecycle.js +120 -0
- package/lib/codexDetect.js +69 -0
- package/package.json +3 -2
- package/server.js +142 -20
- package/ui/dist/assets/index-B66MK5qN.css +1 -0
- package/ui/dist/assets/index-BIwLnT0j.js +16 -0
- package/ui/dist/assets/index-BIwLnT0j.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-1wzizazR.css +0 -1
- package/ui/dist/assets/index-C7SQ3J8h.js +0 -16
- package/ui/dist/assets/index-C7SQ3J8h.js.map +0 -1
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 (
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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(`
|
|
164
|
-
console.log(`
|
|
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
|
|
package/bin/lib/platform.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
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
|
+
});
|