ima2-gen 1.0.7 → 1.0.8

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
@@ -214,7 +214,7 @@ environment variables > ~/.ima2/config.json > built-in defaults
214
214
  | `IMA2_OAUTH_PROXY_PORT` / `OAUTH_PORT` | `10531` | OAuth proxy port |
215
215
  | `IMA2_SERVER` | — | CLI target override |
216
216
  | `IMA2_CONFIG_DIR` | `~/.ima2` | Config and SQLite location |
217
- | `IMA2_GENERATED_DIR` | `generated/` | Generated image directory |
217
+ | `IMA2_GENERATED_DIR` | `~/.ima2/generated` | Generated image directory |
218
218
  | `IMA2_NO_OAUTH_PROXY` | — | Set `1` to disable auto-starting the OAuth proxy |
219
219
  | `IMA2_INFLIGHT_TERMINAL_TTL_MS` | `30000` | Retention for opt-in terminal in-flight debug jobs |
220
220
  | `VITE_IMA2_NODE_MODE` | enabled | Set `0` at UI build time to hide node mode |
package/config.js CHANGED
@@ -108,8 +108,8 @@ export const config = {
108
108
  storage: {
109
109
  configDir,
110
110
  packageRoot,
111
- generatedDir: pickStr(env.IMA2_GENERATED_DIR, fileCfg.storage?.generatedDir, join(packageRoot, "generated")),
112
- trashDir: pickStr(env.IMA2_TRASH_DIR, fileCfg.storage?.trashDir, join(packageRoot, "generated", ".trash")),
111
+ generatedDir: pickStr(env.IMA2_GENERATED_DIR, fileCfg.storage?.generatedDir, join(configDir, "generated")),
112
+ trashDir: pickStr(env.IMA2_TRASH_DIR, fileCfg.storage?.trashDir, join(configDir, "generated", ".trash")),
113
113
  generatedDirName: pickStr(env.IMA2_GENERATED_DIRNAME, fileCfg.storage?.generatedDirName, "generated"),
114
114
  trashDirName: pickStr(env.IMA2_TRASH_DIRNAME, fileCfg.storage?.trashDirName, ".trash"),
115
115
  dbPath: pickStr(env.IMA2_DB_PATH, fileCfg.storage?.dbPath, join(configDir, "sessions.db")),
@@ -8,6 +8,7 @@ const TRASH = config.storage.trashDirName;
8
8
  const TRASH_TTL_MS = config.trash.ttlMs;
9
9
 
10
10
  function resolveInGenerated(rootDir, relPath) {
11
+ void rootDir;
11
12
  if (typeof relPath !== "string" || relPath.length === 0) {
12
13
  const err = new Error("filename required");
13
14
  err.status = 400;
@@ -20,7 +21,7 @@ function resolveInGenerated(rootDir, relPath) {
20
21
  err.code = "INVALID_FILENAME";
21
22
  throw err;
22
23
  }
23
- const baseDir = resolve(rootDir, DIR);
24
+ const baseDir = resolve(config.storage.generatedDir);
24
25
  const target = resolve(baseDir, relPath);
25
26
  if (target !== baseDir && !target.startsWith(baseDir + sep)) {
26
27
  const err = new Error("filename escapes generated/");
@@ -78,7 +79,7 @@ export async function trashAsset(rootDir, filename) {
78
79
  err.code = "ASSET_NOT_FOUND";
79
80
  throw err;
80
81
  }
81
- const trashDir = resolve(rootDir, DIR, TRASH);
82
+ const trashDir = resolve(config.storage.trashDir);
82
83
  await mkdir(trashDir, { recursive: true });
83
84
  // Flatten filename (subdir separators -> __) so trash is flat & easy to restore
84
85
  const flat = filename.replace(/[\\/]+/g, "__");
@@ -107,7 +108,8 @@ export async function trashAsset(rootDir, filename) {
107
108
  }
108
109
 
109
110
  export async function restoreAsset(rootDir, trashId, originalFilename) {
110
- const trashDir = resolve(rootDir, DIR, TRASH);
111
+ void rootDir;
112
+ const trashDir = resolve(config.storage.trashDir);
111
113
  const src = resolve(trashDir, trashId);
112
114
  if (!src.startsWith(trashDir + sep) && src !== trashDir) {
113
115
  const err = new Error("invalid trashId");
@@ -52,6 +52,7 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
52
52
  nodeId: meta?.nodeId || null,
53
53
  parentNodeId: meta?.parentNodeId || null,
54
54
  clientNodeId: meta?.clientNodeId || null,
55
+ requestId: meta?.requestId || null,
55
56
  kind: meta?.kind || null,
56
57
  refsCount: Number.isFinite(meta?.refsCount) ? meta.refsCount : 0,
57
58
  };
package/lib/nodeStore.js CHANGED
@@ -3,16 +3,15 @@ import { join, resolve, sep } from "path";
3
3
  import { randomBytes } from "crypto";
4
4
  import { config } from "../config.js";
5
5
 
6
- const DIR = config.storage.generatedDirName;
7
-
8
6
  export function newNodeId() {
9
7
  return "n_" + randomBytes(config.ids.nodeHexBytes).toString("hex");
10
8
  }
11
9
 
12
10
  export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png" }) {
11
+ void rootDir;
13
12
  const filename = `${nodeId}.${ext}`;
14
- await writeFile(join(rootDir, DIR, filename), Buffer.from(b64, "base64"));
15
- await writeFile(join(rootDir, DIR, filename + ".json"), JSON.stringify(meta, null, 2));
13
+ await writeFile(join(config.storage.generatedDir, filename), Buffer.from(b64, "base64"));
14
+ await writeFile(join(config.storage.generatedDir, filename + ".json"), JSON.stringify(meta, null, 2));
16
15
  return { filename };
17
16
  }
18
17
 
@@ -29,8 +28,9 @@ export async function loadNodeB64(rootDir, filename) {
29
28
  }
30
29
 
31
30
  export async function loadNodeMeta(rootDir, nodeId, ext = "png") {
31
+ void rootDir;
32
32
  try {
33
- return JSON.parse(await readFile(join(rootDir, DIR, `${nodeId}.${ext}.json`), "utf-8"));
33
+ return JSON.parse(await readFile(join(config.storage.generatedDir, `${nodeId}.${ext}.json`), "utf-8"));
34
34
  } catch {
35
35
  return null;
36
36
  }
@@ -49,13 +49,14 @@ export async function loadAssetB64(rootDir, externalSrc) {
49
49
  }
50
50
 
51
51
  function resolveGeneratedPath(rootDir, relPath) {
52
+ void rootDir;
52
53
  if (typeof relPath !== "string" || relPath.length === 0) {
53
54
  const err = new Error("Asset path is required");
54
55
  err.code = "NODE_SOURCE_INVALID";
55
56
  err.status = 400;
56
57
  throw err;
57
58
  }
58
- const baseDir = resolve(rootDir, DIR);
59
+ const baseDir = resolve(config.storage.generatedDir);
59
60
  const target = resolve(baseDir, relPath);
60
61
  if (target !== baseDir && !target.startsWith(baseDir + sep)) {
61
62
  const err = new Error(`Asset path escapes generated/: ${relPath}`);
package/lib/oauthProxy.js CHANGED
@@ -46,6 +46,24 @@ function extractSseData(block) {
46
46
  return eventData;
47
47
  }
48
48
 
49
+ function extractPartialImage(data) {
50
+ if (typeof data?.type !== "string" || !data.type.includes("partial")) return null;
51
+ const item = data.item || {};
52
+ const b64 =
53
+ data.partial_image ||
54
+ data.image ||
55
+ data.result ||
56
+ item.partial_image ||
57
+ item.image ||
58
+ item.result;
59
+ if (typeof b64 !== "string" || b64.length === 0) return null;
60
+ const index =
61
+ Number.isFinite(data.index) ? data.index :
62
+ Number.isFinite(item.index) ? item.index :
63
+ null;
64
+ return { b64, index, eventType: data.type };
65
+ }
66
+
49
67
  function makeOAuthError(message, { status, code = "OAUTH_UPSTREAM_ERROR", upstreamBodyChars, eventType } = {}) {
50
68
  const err = new Error(message);
51
69
  err.code = code;
@@ -55,7 +73,7 @@ function makeOAuthError(message, { status, code = "OAUTH_UPSTREAM_ERROR", upstre
55
73
  return err;
56
74
  }
57
75
 
58
- async function readImageStream(res, { requestId = null, scope = "oauth" } = {}) {
76
+ async function readImageStream(res, { requestId = null, scope = "oauth", onPartialImage = null } = {}) {
59
77
  const reader = res.body.getReader();
60
78
  const decoder = new TextDecoder();
61
79
  let buffer = "";
@@ -81,6 +99,17 @@ async function readImageStream(res, { requestId = null, scope = "oauth" } = {})
81
99
  const data = JSON.parse(eventData);
82
100
  eventCount++;
83
101
 
102
+ const partial = extractPartialImage(data);
103
+ if (partial) {
104
+ logEvent(scope, "partial", {
105
+ requestId,
106
+ index: partial.index,
107
+ imageChars: partial.b64.length,
108
+ eventType: partial.eventType,
109
+ });
110
+ if (requestId) setJobPhase(requestId, "partial");
111
+ if (typeof onPartialImage === "function") onPartialImage(partial);
112
+ }
84
113
  if (data.type === "response.output_item.done" && data.item?.type === "image_generation_call") {
85
114
  if (data.item.result) {
86
115
  imageB64 = data.item.result;
@@ -123,11 +152,18 @@ export async function generateViaOAuth(
123
152
  requestId = null,
124
153
  mode = "auto",
125
154
  ctx = {},
155
+ options = {},
126
156
  ) {
127
157
  const oauthUrl = getOAuthUrl(ctx);
128
158
  const tools = [
129
159
  { type: "web_search" },
130
- { type: "image_generation", quality, size, moderation },
160
+ {
161
+ type: "image_generation",
162
+ quality,
163
+ size,
164
+ moderation,
165
+ ...(options.partialImages ? { partial_images: options.partialImages } : {}),
166
+ },
131
167
  ];
132
168
 
133
169
  const textPrompt = buildUserTextPrompt(prompt, mode);
@@ -187,7 +223,11 @@ export async function generateViaOAuth(
187
223
  throw new Error("No image data in response (non-stream mode)");
188
224
  }
189
225
 
190
- const { imageB64, usage, webSearchCalls, revisedPrompt, eventCount } = await readImageStream(res, { requestId, scope: "oauth" });
226
+ const { imageB64, usage, webSearchCalls, revisedPrompt, eventCount } = await readImageStream(res, {
227
+ requestId,
228
+ scope: "oauth",
229
+ onPartialImage: options.onPartialImage,
230
+ });
191
231
  logEvent("oauth", "stream_end", { requestId, events: eventCount, hasImage: !!imageB64 });
192
232
 
193
233
  if (!imageB64) {
@@ -0,0 +1,41 @@
1
+ import { mkdir, readdir, copyFile, stat } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+
5
+ async function copyMissingTree(srcDir, dstDir) {
6
+ await mkdir(dstDir, { recursive: true });
7
+ const entries = await readdir(srcDir, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ const src = join(srcDir, entry.name);
10
+ const dst = join(dstDir, entry.name);
11
+ if (entry.isDirectory()) {
12
+ await copyMissingTree(src, dst);
13
+ continue;
14
+ }
15
+ if (!entry.isFile()) continue;
16
+ if (existsSync(dst)) continue;
17
+ await copyFile(src, dst);
18
+ }
19
+ }
20
+
21
+ function isSameOrInside(child, parent) {
22
+ const a = resolve(child);
23
+ const b = resolve(parent);
24
+ return a === b || a.startsWith(b + sep);
25
+ }
26
+
27
+ export async function migrateGeneratedStorage(ctx) {
28
+ const legacyDir = join(ctx.rootDir, "generated");
29
+ const targetDir = ctx.config.storage.generatedDir;
30
+ if (isSameOrInside(legacyDir, targetDir) || isSameOrInside(targetDir, legacyDir)) return;
31
+ try {
32
+ const legacyStat = await stat(legacyDir);
33
+ if (!legacyStat.isDirectory()) return;
34
+ await copyMissingTree(legacyDir, targetDir);
35
+ console.log(`[storage] migrated generated assets to ${targetDir}`);
36
+ } catch (err) {
37
+ if (err?.code !== "ENOENT") {
38
+ console.warn("[storage] generated asset migration skipped:", err.message);
39
+ }
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "GPT Image 2 generator with OAuth & API key support",
5
5
  "type": "module",
6
6
  "bin": {
package/routes/nodes.js CHANGED
@@ -22,9 +22,40 @@ function validateModeration(ctx, moderation) {
22
22
  return { moderation };
23
23
  }
24
24
 
25
+ function wantsSse(req) {
26
+ const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
27
+ return accept.includes("text/event-stream");
28
+ }
29
+
30
+ function writeSse(res, event, data) {
31
+ res.write(`event: ${event}\n`);
32
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
33
+ }
34
+
35
+ function writeNodeError(res, status, code, message, parentNodeId) {
36
+ if (res.headersSent) {
37
+ writeSse(res, "error", {
38
+ error: { code, message },
39
+ parentNodeId,
40
+ status,
41
+ });
42
+ res.end();
43
+ return;
44
+ }
45
+ res.status(status).json({
46
+ error: { code, message },
47
+ parentNodeId,
48
+ });
49
+ }
50
+
51
+ function dataUrlFromB64(format, b64) {
52
+ return `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`;
53
+ }
54
+
25
55
  export function registerNodeRoutes(app, ctx) {
26
56
  app.post("/api/node/generate", async (req, res) => {
27
57
  const body = req.body || {};
58
+ const streamResponse = wantsSse(req);
28
59
  const parentNodeId = body.parentNodeId ?? null;
29
60
  const requestId = typeof body.requestId === "string" ? body.requestId : null;
30
61
  const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
@@ -144,6 +175,15 @@ export function registerNodeRoutes(app, ctx) {
144
175
  styleSheetApplied: !!styleSheetApplied,
145
176
  });
146
177
 
178
+ if (streamResponse) {
179
+ res.writeHead(200, {
180
+ "Content-Type": "text/event-stream; charset=utf-8",
181
+ "Cache-Control": "no-cache, no-transform",
182
+ Connection: "keep-alive",
183
+ });
184
+ writeSse(res, "phase", { requestId, phase: "streaming" });
185
+ }
186
+
147
187
  let b64, usage, webSearchCalls = 0, revisedPrompt = null;
148
188
  const MAX_RETRIES = 1;
149
189
  let lastErr;
@@ -151,7 +191,27 @@ export function registerNodeRoutes(app, ctx) {
151
191
  try {
152
192
  const r = parentB64
153
193
  ? await editViaOAuth(effectivePrompt, parentB64, quality, size, moderation, normalizedPromptMode, ctx, requestId)
154
- : await generateViaOAuth(effectivePrompt, quality, size, moderation, refCheck.refs, requestId, normalizedPromptMode, ctx);
194
+ : await generateViaOAuth(
195
+ effectivePrompt,
196
+ quality,
197
+ size,
198
+ moderation,
199
+ refCheck.refs,
200
+ requestId,
201
+ normalizedPromptMode,
202
+ ctx,
203
+ {
204
+ partialImages: streamResponse ? 2 : 0,
205
+ onPartialImage: streamResponse
206
+ ? (partial) =>
207
+ writeSse(res, "partial", {
208
+ requestId,
209
+ image: dataUrlFromB64(format, partial.b64),
210
+ index: partial.index,
211
+ })
212
+ : null,
213
+ },
214
+ );
155
215
  if (r.b64) {
156
216
  b64 = r.b64;
157
217
  usage = r.usage;
@@ -172,10 +232,13 @@ export function registerNodeRoutes(app, ctx) {
172
232
  finishStatus = "error";
173
233
  finishHttpStatus = 422;
174
234
  finishErrorCode = "SAFETY_REFUSAL";
175
- return res.status(422).json({
176
- error: { code: "SAFETY_REFUSAL", message: lastErr?.message || "Empty response after retry" },
235
+ return writeNodeError(
236
+ res,
237
+ 422,
238
+ "SAFETY_REFUSAL",
239
+ lastErr?.message || "Empty response after retry",
177
240
  parentNodeId,
178
- });
241
+ );
179
242
  }
180
243
 
181
244
  const nodeId = newNodeId();
@@ -199,6 +262,7 @@ export function registerNodeRoutes(app, ctx) {
199
262
  webSearchCalls,
200
263
  provider: "oauth",
201
264
  kind: parentB64 ? "edit" : "generate",
265
+ requestId,
202
266
  refsCount: refCheck.refs.length,
203
267
  quality,
204
268
  size,
@@ -217,11 +281,11 @@ export function registerNodeRoutes(app, ctx) {
217
281
  elapsedMs: Date.now() - startTime,
218
282
  });
219
283
 
220
- res.json({
284
+ const payload = {
221
285
  nodeId,
222
286
  parentNodeId,
223
287
  requestId,
224
- image: `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`,
288
+ image: dataUrlFromB64(format, b64),
225
289
  filename,
226
290
  url: `/generated/${filename}`,
227
291
  elapsed,
@@ -233,17 +297,21 @@ export function registerNodeRoutes(app, ctx) {
233
297
  warnings: qualityWarnings,
234
298
  revisedPrompt,
235
299
  promptMode: normalizedPromptMode,
236
- });
300
+ };
301
+
302
+ if (streamResponse) {
303
+ writeSse(res, "done", payload);
304
+ res.end();
305
+ } else {
306
+ res.json(payload);
307
+ }
237
308
  } catch (err) {
238
309
  const code = err.code || classifyUpstreamError(err.message) || "NODE_GEN_FAILED";
239
310
  finishStatus = "error";
240
311
  finishHttpStatus = err.status || 500;
241
312
  finishErrorCode = code;
242
313
  logError("node", "error", err, { requestId, code, parentNodeId, sessionId, clientNodeId });
243
- res.status(err.status || 500).json({
244
- error: { code, message: err.message },
245
- parentNodeId,
246
- });
314
+ writeNodeError(res, err.status || 500, code, err.message, parentNodeId);
247
315
  } finally {
248
316
  finishJob(requestId, {
249
317
  status: finishStatus,
@@ -266,7 +266,15 @@ export function registerSessionRoutes(app, ctx) {
266
266
  const code = err.code || "DB_ERROR";
267
267
  const payload = { error: { code, message: err.message } };
268
268
  if (typeof err.currentVersion === "number") payload.currentVersion = err.currentVersion;
269
- logError("session", "graph_error", err, { sessionId: req.params.id, code });
269
+ if (code === "GRAPH_VERSION_CONFLICT") {
270
+ logEvent("session", "graph_conflict", {
271
+ sessionId: req.params.id,
272
+ code,
273
+ currentVersion: err.currentVersion,
274
+ });
275
+ } else {
276
+ logError("session", "graph_error", err, { sessionId: req.params.id, code });
277
+ }
270
278
  res.status(err.status || 500).json(payload);
271
279
  }
272
280
  });
package/server.js CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath, pathToFileURL } from "url";
13
13
  import { onShutdown } from "./bin/lib/platform.js";
14
14
  import { ensureDefaultSession } from "./lib/sessionStore.js";
15
15
  import { startOAuthProxy } from "./lib/oauthLauncher.js";
16
+ import { migrateGeneratedStorage } from "./lib/storageMigration.js";
16
17
  import { configureRoutes } from "./routes/index.js";
17
18
  import { config } from "./config.js";
18
19
 
@@ -114,6 +115,7 @@ export async function createRuntimeContext(overrides = {}) {
114
115
 
115
116
  export async function startServer(overrides = {}) {
116
117
  const ctx = await createRuntimeContext(overrides);
118
+ await migrateGeneratedStorage(ctx);
117
119
  const app = buildApp(ctx);
118
120
  const oauthChild =
119
121
  overrides.oauthChild !== undefined