ima2-gen 1.0.6 → 1.0.7

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.
@@ -0,0 +1,272 @@
1
+ import { mkdir } from "fs/promises";
2
+ import {
3
+ newNodeId,
4
+ saveNode,
5
+ loadNodeB64,
6
+ loadNodeMeta,
7
+ loadAssetB64,
8
+ } from "../lib/nodeStore.js";
9
+ import { startJob, finishJob } from "../lib/inflight.js";
10
+ import { validateAndNormalizeRefs } from "../lib/refs.js";
11
+ import { classifyUpstreamError } from "../lib/errorClassify.js";
12
+ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
13
+ import { generateViaOAuth, editViaOAuth } from "../lib/oauthProxy.js";
14
+ import { getStyleSheet } from "../lib/sessionStore.js";
15
+ import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
16
+ import { logEvent, logError } from "../lib/logger.js";
17
+
18
+ function validateModeration(ctx, moderation) {
19
+ if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
20
+ return { error: "moderation must be one of: auto, low" };
21
+ }
22
+ return { moderation };
23
+ }
24
+
25
+ export function registerNodeRoutes(app, ctx) {
26
+ app.post("/api/node/generate", async (req, res) => {
27
+ const body = req.body || {};
28
+ const parentNodeId = body.parentNodeId ?? null;
29
+ const requestId = typeof body.requestId === "string" ? body.requestId : null;
30
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
31
+ const clientNodeId = typeof body.clientNodeId === "string" ? body.clientNodeId : null;
32
+ let finishMeta = {};
33
+ let finishStatus = "completed";
34
+ let finishHttpStatus;
35
+ let finishErrorCode;
36
+ startJob({
37
+ requestId,
38
+ kind: "node",
39
+ prompt: body.prompt,
40
+ meta: { kind: "node", sessionId, parentNodeId, clientNodeId },
41
+ });
42
+
43
+ try {
44
+ const {
45
+ prompt,
46
+ quality: rawQuality = "medium",
47
+ size = "1024x1024",
48
+ format = "png",
49
+ moderation = "low",
50
+ references = [],
51
+ externalSrc = null,
52
+ mode: promptMode = "auto",
53
+ } = body;
54
+ const { provider = "oauth" } = body;
55
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
56
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
57
+
58
+ if (provider === "api") {
59
+ finishStatus = "error";
60
+ finishHttpStatus = 403;
61
+ finishErrorCode = "APIKEY_DISABLED";
62
+ return res.status(403).json({
63
+ error: { code: "APIKEY_DISABLED", message: "API key provider is disabled. Use OAuth." },
64
+ parentNodeId,
65
+ });
66
+ }
67
+ if (!prompt || typeof prompt !== "string") {
68
+ finishStatus = "error";
69
+ finishHttpStatus = 400;
70
+ finishErrorCode = "INVALID_PROMPT";
71
+ return res.status(400).json({
72
+ error: { code: "INVALID_PROMPT", message: "Prompt is required" },
73
+ parentNodeId,
74
+ });
75
+ }
76
+ const refCheck = validateAndNormalizeRefs(references);
77
+ if (refCheck.error) {
78
+ finishStatus = "error";
79
+ finishHttpStatus = 400;
80
+ finishErrorCode = refCheck.code;
81
+ return res.status(400).json({
82
+ error: { code: refCheck.code, message: refCheck.error },
83
+ code: refCheck.code,
84
+ parentNodeId,
85
+ });
86
+ }
87
+ if ((parentNodeId || externalSrc) && refCheck.refs.length > 0) {
88
+ finishStatus = "error";
89
+ finishHttpStatus = 400;
90
+ finishErrorCode = "NODE_REFS_UNSUPPORTED_FOR_EDIT";
91
+ return res.status(400).json({
92
+ error: {
93
+ code: "NODE_REFS_UNSUPPORTED_FOR_EDIT",
94
+ message: "Extra references are only supported for root node generation.",
95
+ },
96
+ parentNodeId,
97
+ });
98
+ }
99
+ const moderationCheck = validateModeration(ctx, moderation);
100
+ if (moderationCheck.error) {
101
+ finishStatus = "error";
102
+ finishHttpStatus = 400;
103
+ finishErrorCode = "INVALID_MODERATION";
104
+ return res.status(400).json({
105
+ error: { code: "INVALID_MODERATION", message: moderationCheck.error },
106
+ parentNodeId,
107
+ });
108
+ }
109
+
110
+ let effectivePrompt = prompt;
111
+ let styleSheetApplied = null;
112
+ if (sessionId) {
113
+ try {
114
+ const data = getStyleSheet(sessionId);
115
+ if (data && data.enabled && data.styleSheet) {
116
+ const prefix = renderStyleSheetPrefix(data.styleSheet);
117
+ if (prefix) {
118
+ effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
119
+ styleSheetApplied = data.styleSheet;
120
+ }
121
+ }
122
+ } catch {}
123
+ }
124
+
125
+ const startTime = Date.now();
126
+ let parentB64 = null;
127
+ if (parentNodeId) {
128
+ parentB64 = await loadNodeB64(ctx.rootDir, `${parentNodeId}.png`);
129
+ } else if (typeof externalSrc === "string" && externalSrc.length > 0) {
130
+ parentB64 = await loadAssetB64(ctx.rootDir, externalSrc);
131
+ }
132
+ logEvent("node", "request", {
133
+ requestId,
134
+ operation: parentB64 ? "edit" : "generate",
135
+ sessionId,
136
+ parentNodeId,
137
+ clientNodeId,
138
+ quality,
139
+ size,
140
+ moderation,
141
+ refs: refCheck.refs.length,
142
+ promptChars: prompt.length,
143
+ promptMode: normalizedPromptMode,
144
+ styleSheetApplied: !!styleSheetApplied,
145
+ });
146
+
147
+ let b64, usage, webSearchCalls = 0, revisedPrompt = null;
148
+ const MAX_RETRIES = 1;
149
+ let lastErr;
150
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
151
+ try {
152
+ const r = parentB64
153
+ ? await editViaOAuth(effectivePrompt, parentB64, quality, size, moderation, normalizedPromptMode, ctx, requestId)
154
+ : await generateViaOAuth(effectivePrompt, quality, size, moderation, refCheck.refs, requestId, normalizedPromptMode, ctx);
155
+ if (r.b64) {
156
+ b64 = r.b64;
157
+ usage = r.usage;
158
+ webSearchCalls = r.webSearchCalls || 0;
159
+ revisedPrompt = r.revisedPrompt || null;
160
+ break;
161
+ }
162
+ lastErr = new Error("Empty response (safety refusal)");
163
+ } catch (e) {
164
+ lastErr = e;
165
+ }
166
+ if (attempt < MAX_RETRIES) {
167
+ logEvent("node", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
168
+ }
169
+ }
170
+
171
+ if (!b64) {
172
+ finishStatus = "error";
173
+ finishHttpStatus = 422;
174
+ finishErrorCode = "SAFETY_REFUSAL";
175
+ return res.status(422).json({
176
+ error: { code: "SAFETY_REFUSAL", message: lastErr?.message || "Empty response after retry" },
177
+ parentNodeId,
178
+ });
179
+ }
180
+
181
+ const nodeId = newNodeId();
182
+ const elapsed = +((Date.now() - startTime) / 1000).toFixed(1);
183
+ const meta = {
184
+ nodeId,
185
+ parentNodeId,
186
+ sessionId,
187
+ clientNodeId,
188
+ prompt,
189
+ userPrompt: prompt,
190
+ revisedPrompt,
191
+ promptMode: normalizedPromptMode,
192
+ effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
193
+ styleSheetApplied: styleSheetApplied || undefined,
194
+ options: { quality, size, format, moderation },
195
+ createdAt: Date.now(),
196
+ createdAtIso: new Date().toISOString(),
197
+ elapsed,
198
+ usage: usage || null,
199
+ webSearchCalls,
200
+ provider: "oauth",
201
+ kind: parentB64 ? "edit" : "generate",
202
+ refsCount: refCheck.refs.length,
203
+ quality,
204
+ size,
205
+ format,
206
+ moderation,
207
+ };
208
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
209
+ const { filename } = await saveNode(ctx.rootDir, { nodeId, b64, meta, ext: format });
210
+ finishMeta = { nodeId, filename, imageChars: b64.length };
211
+ finishHttpStatus = 200;
212
+ logEvent("node", "saved", {
213
+ requestId,
214
+ nodeId,
215
+ filename,
216
+ imageChars: b64.length,
217
+ elapsedMs: Date.now() - startTime,
218
+ });
219
+
220
+ res.json({
221
+ nodeId,
222
+ parentNodeId,
223
+ requestId,
224
+ image: `data:image/${format === "jpeg" ? "jpeg" : format};base64,${b64}`,
225
+ filename,
226
+ url: `/generated/${filename}`,
227
+ elapsed,
228
+ usage,
229
+ webSearchCalls,
230
+ provider: "oauth",
231
+ moderation,
232
+ refsCount: refCheck.refs.length,
233
+ warnings: qualityWarnings,
234
+ revisedPrompt,
235
+ promptMode: normalizedPromptMode,
236
+ });
237
+ } catch (err) {
238
+ const code = err.code || classifyUpstreamError(err.message) || "NODE_GEN_FAILED";
239
+ finishStatus = "error";
240
+ finishHttpStatus = err.status || 500;
241
+ finishErrorCode = code;
242
+ 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
+ });
247
+ } finally {
248
+ finishJob(requestId, {
249
+ status: finishStatus,
250
+ httpStatus: finishHttpStatus,
251
+ errorCode: finishErrorCode,
252
+ meta: finishMeta,
253
+ });
254
+ }
255
+ });
256
+
257
+ app.get("/api/node/:nodeId", async (req, res) => {
258
+ try {
259
+ const { nodeId } = req.params;
260
+ const meta = await loadNodeMeta(ctx.rootDir, nodeId);
261
+ if (!meta) {
262
+ return res.status(404).json({ error: { code: "NODE_NOT_FOUND", message: "Node metadata missing" } });
263
+ }
264
+ const ext = meta?.options?.format || meta?.format || "png";
265
+ res.json({ nodeId, meta, url: `/generated/${nodeId}.${ext}` });
266
+ } catch (err) {
267
+ res.status(err.status || 500).json({
268
+ error: { code: err.code || "NODE_FETCH_FAILED", message: err.message },
269
+ });
270
+ }
271
+ });
272
+ }
@@ -0,0 +1,273 @@
1
+ import {
2
+ createSession,
3
+ listSessions,
4
+ getSession,
5
+ renameSession,
6
+ deleteSession,
7
+ saveGraph,
8
+ getStyleSheet,
9
+ setStyleSheet,
10
+ setStyleSheetEnabled,
11
+ } from "../lib/sessionStore.js";
12
+ import { extractStyleSheet } from "../lib/styleSheet.js";
13
+ import { logError, logEvent } from "../lib/logger.js";
14
+
15
+ function safeJsonChars(value) {
16
+ try {
17
+ return JSON.stringify(value ?? null).length;
18
+ } catch {
19
+ return 0;
20
+ }
21
+ }
22
+
23
+ export function registerSessionRoutes(app, ctx) {
24
+ app.get("/api/sessions", (_req, res) => {
25
+ try {
26
+ res.json({ sessions: listSessions() });
27
+ } catch (err) {
28
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
29
+ }
30
+ });
31
+
32
+ app.post("/api/sessions", (req, res) => {
33
+ try {
34
+ const title = (req.body?.title || "Untitled").slice(0, 200);
35
+ const session = createSession({ title });
36
+ logEvent("session", "create", {
37
+ sessionId: session.id,
38
+ titleChars: session.title.length,
39
+ });
40
+ res.status(201).json({ session });
41
+ } catch (err) {
42
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
43
+ }
44
+ });
45
+
46
+ app.get("/api/sessions/:id", (req, res) => {
47
+ try {
48
+ const session = getSession(req.params.id);
49
+ if (!session) {
50
+ return res.status(404).json({
51
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
52
+ });
53
+ }
54
+ res.json({ session });
55
+ } catch (err) {
56
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
57
+ }
58
+ });
59
+
60
+ app.patch("/api/sessions/:id", (req, res) => {
61
+ try {
62
+ const title = req.body?.title;
63
+ if (typeof title !== "string" || !title.trim()) {
64
+ return res.status(400).json({
65
+ error: { code: "INVALID_TITLE", message: "Title required" },
66
+ });
67
+ }
68
+ const ok = renameSession(req.params.id, title.slice(0, 200));
69
+ if (!ok) {
70
+ return res.status(404).json({
71
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
72
+ });
73
+ }
74
+ logEvent("session", "rename", {
75
+ sessionId: req.params.id,
76
+ titleChars: title.slice(0, 200).length,
77
+ });
78
+ res.json({ ok: true });
79
+ } catch (err) {
80
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
81
+ }
82
+ });
83
+
84
+ app.delete("/api/sessions/:id", (req, res) => {
85
+ try {
86
+ const ok = deleteSession(req.params.id);
87
+ if (!ok) {
88
+ return res.status(404).json({
89
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
90
+ });
91
+ }
92
+ logEvent("session", "delete", { sessionId: req.params.id });
93
+ res.json({ ok: true });
94
+ } catch (err) {
95
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
96
+ }
97
+ });
98
+
99
+ app.get("/api/sessions/:id/style-sheet", (req, res) => {
100
+ try {
101
+ const data = getStyleSheet(req.params.id);
102
+ if (!data) {
103
+ return res.status(404).json({
104
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
105
+ });
106
+ }
107
+ logEvent("session", "stylesheet_get", {
108
+ sessionId: req.params.id,
109
+ enabled: data.enabled,
110
+ hasSheet: !!data.styleSheet,
111
+ sheetChars: safeJsonChars(data.styleSheet),
112
+ });
113
+ res.json(data);
114
+ } catch (err) {
115
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
116
+ }
117
+ });
118
+
119
+ app.put("/api/sessions/:id/style-sheet", (req, res) => {
120
+ try {
121
+ const { styleSheet, enabled } = req.body || {};
122
+ if (styleSheet !== null && (typeof styleSheet !== "object" || Array.isArray(styleSheet))) {
123
+ return res.status(400).json({
124
+ error: { code: "INVALID_SHEET", message: "styleSheet must be an object or null" },
125
+ });
126
+ }
127
+ if (enabled !== undefined && typeof enabled !== "boolean") {
128
+ return res.status(400).json({
129
+ error: { code: "INVALID_ENABLED", message: "enabled must be boolean when provided" },
130
+ });
131
+ }
132
+ const ok = setStyleSheet(req.params.id, styleSheet);
133
+ if (!ok) {
134
+ return res.status(404).json({
135
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
136
+ });
137
+ }
138
+ if (typeof enabled === "boolean") setStyleSheetEnabled(req.params.id, enabled);
139
+ logEvent("session", "stylesheet_save", {
140
+ sessionId: req.params.id,
141
+ enabled: typeof enabled === "boolean" ? enabled : undefined,
142
+ hasSheet: !!styleSheet,
143
+ sheetChars: safeJsonChars(styleSheet),
144
+ });
145
+ res.json({ ok: true });
146
+ } catch (err) {
147
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
148
+ }
149
+ });
150
+
151
+ app.patch("/api/sessions/:id/style-sheet/enabled", (req, res) => {
152
+ try {
153
+ const { enabled } = req.body || {};
154
+ if (typeof enabled !== "boolean") {
155
+ return res.status(400).json({
156
+ error: { code: "INVALID_ENABLED", message: "enabled must be boolean" },
157
+ });
158
+ }
159
+ const ok = setStyleSheetEnabled(req.params.id, enabled);
160
+ if (!ok) {
161
+ return res.status(404).json({
162
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
163
+ });
164
+ }
165
+ logEvent("session", "stylesheet_toggle", {
166
+ sessionId: req.params.id,
167
+ enabled,
168
+ });
169
+ res.json({ ok: true, enabled });
170
+ } catch (err) {
171
+ res.status(500).json({ error: { code: "DB_ERROR", message: err.message } });
172
+ }
173
+ });
174
+
175
+ app.post("/api/sessions/:id/style-sheet/extract", async (req, res) => {
176
+ try {
177
+ if (!ctx.openai) {
178
+ return res.status(400).json({
179
+ error: {
180
+ code: "STYLE_SHEET_NO_KEY",
181
+ message: "Style-sheet extraction requires an OpenAI API key. Connect one via setup.",
182
+ },
183
+ });
184
+ }
185
+ const { prompt, referenceDataUrl } = req.body || {};
186
+ if (typeof prompt !== "string" || !prompt.trim()) {
187
+ return res.status(400).json({
188
+ error: { code: "STYLE_SHEET_BAD_INPUT", message: "prompt required" },
189
+ });
190
+ }
191
+ if (!getSession(req.params.id)) {
192
+ return res.status(404).json({
193
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
194
+ });
195
+ }
196
+ logEvent("session", "stylesheet_extract_start", {
197
+ sessionId: req.params.id,
198
+ promptChars: prompt.length,
199
+ hasReference: typeof referenceDataUrl === "string" && referenceDataUrl.length > 0,
200
+ });
201
+ const sheet = await extractStyleSheet(ctx.openai, {
202
+ prompt: prompt.slice(0, 4000),
203
+ referenceDataUrl: typeof referenceDataUrl === "string" ? referenceDataUrl : undefined,
204
+ });
205
+ const persisted = setStyleSheet(req.params.id, sheet);
206
+ if (!persisted) {
207
+ return res.status(404).json({
208
+ error: { code: "SESSION_NOT_FOUND", message: "Session not found" },
209
+ });
210
+ }
211
+ logEvent("session", "stylesheet_extract_done", {
212
+ sessionId: req.params.id,
213
+ sheetChars: safeJsonChars(sheet),
214
+ });
215
+ res.json({ styleSheet: sheet });
216
+ } catch (err) {
217
+ const code = err.code || "STYLE_SHEET_ERROR";
218
+ const status =
219
+ code === "STYLE_SHEET_NO_KEY" || code === "STYLE_SHEET_BAD_INPUT"
220
+ ? 400
221
+ : code === "STYLE_SHEET_EMPTY" || code === "STYLE_SHEET_PARSE" || code === "STYLE_SHEET_SHAPE"
222
+ ? 422
223
+ : 500;
224
+ logError("session", "stylesheet_extract_error", err, { sessionId: req.params.id, code });
225
+ res.status(status).json({ error: { code, message: err.message } });
226
+ }
227
+ });
228
+
229
+ app.put("/api/sessions/:id/graph", (req, res) => {
230
+ try {
231
+ const { nodes, edges } = req.body || {};
232
+ const rawIfMatch = req.get("If-Match");
233
+ if (!Array.isArray(nodes) || !Array.isArray(edges)) {
234
+ return res.status(400).json({
235
+ error: { code: "INVALID_GRAPH", message: "nodes and edges arrays required" },
236
+ });
237
+ }
238
+ if (!rawIfMatch) {
239
+ return res.status(428).json({
240
+ error: { code: "GRAPH_VERSION_REQUIRED", message: "If-Match header required" },
241
+ });
242
+ }
243
+ if (nodes.length > 500 || edges.length > 1000) {
244
+ return res.status(413).json({
245
+ error: {
246
+ code: "GRAPH_TOO_LARGE",
247
+ message: `Graph too large (max 500 nodes / 1000 edges), got ${nodes.length}/${edges.length}`,
248
+ },
249
+ });
250
+ }
251
+ const expectedVersion = Number(String(rawIfMatch).replace(/"/g, ""));
252
+ if (!Number.isFinite(expectedVersion)) {
253
+ return res.status(400).json({
254
+ error: { code: "INVALID_GRAPH_VERSION", message: "If-Match must be a finite integer" },
255
+ });
256
+ }
257
+ const result = saveGraph(req.params.id, { nodes, edges, expectedVersion });
258
+ logEvent("session", "graph_save", {
259
+ sessionId: req.params.id,
260
+ nodes: nodes.length,
261
+ edges: edges.length,
262
+ graphVersion: result.graphVersion,
263
+ });
264
+ res.json({ ok: true, nodes: nodes.length, edges: edges.length, graphVersion: result.graphVersion });
265
+ } catch (err) {
266
+ const code = err.code || "DB_ERROR";
267
+ const payload = { error: { code, message: err.message } };
268
+ if (typeof err.currentVersion === "number") payload.currentVersion = err.currentVersion;
269
+ logError("session", "graph_error", err, { sessionId: req.params.id, code });
270
+ res.status(err.status || 500).json(payload);
271
+ }
272
+ });
273
+ }