ima2-gen 1.1.4 → 1.1.5
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/config.js +5 -0
- package/lib/db.js +41 -3
- package/lib/generationErrors.js +24 -0
- package/lib/historyList.js +14 -1
- package/lib/imageMetadata.js +107 -0
- package/lib/imageMetadataStore.js +67 -0
- package/lib/nodeStore.js +13 -1
- package/lib/oauthProxy.js +116 -12
- package/lib/refs.js +65 -2
- package/package.json +1 -1
- package/routes/generate.js +33 -2
- package/routes/history.js +42 -1
- package/routes/index.js +4 -0
- package/routes/metadata.js +71 -0
- package/routes/nodes.js +15 -1
- package/routes/prompts.js +379 -0
- package/ui/dist/assets/index-0SyTGr-u.js +25 -0
- package/ui/dist/assets/index-0SyTGr-u.js.map +1 -0
- package/ui/dist/assets/index-DfiV508Q.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DHeTnSPD.css +0 -1
- package/ui/dist/assets/index-fDTlOt4w.js +0 -23
- package/ui/dist/assets/index-fDTlOt4w.js.map +0 -1
package/routes/generate.js
CHANGED
|
@@ -11,6 +11,7 @@ import { startJob, finishJob } from "../lib/inflight.js";
|
|
|
11
11
|
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
12
12
|
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
13
13
|
import { logEvent, logError } from "../lib/logger.js";
|
|
14
|
+
import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
|
|
14
15
|
|
|
15
16
|
function validateModeration(ctx, moderation) {
|
|
16
17
|
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
@@ -104,6 +105,8 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
104
105
|
return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
|
|
105
106
|
}
|
|
106
107
|
const client = req.get("x-ima2-client") || "ui";
|
|
108
|
+
const referenceDiagnostics = refCheck.referenceDiagnostics || [];
|
|
109
|
+
const referenceMismatchCount = referenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
|
|
107
110
|
logEvent("generate", "request", {
|
|
108
111
|
requestId,
|
|
109
112
|
client,
|
|
@@ -114,6 +117,9 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
114
117
|
moderation,
|
|
115
118
|
n: count,
|
|
116
119
|
refs: refCheck.refs.length,
|
|
120
|
+
referenceMismatchCount,
|
|
121
|
+
refDetectedMimes: [...new Set(referenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
|
|
122
|
+
refDeclaredMimes: [...new Set(referenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
|
|
117
123
|
sessionId,
|
|
118
124
|
clientNodeId,
|
|
119
125
|
promptChars: typeof prompt === "string" ? prompt.length : 0,
|
|
@@ -136,7 +142,7 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
136
142
|
quality,
|
|
137
143
|
size,
|
|
138
144
|
moderation,
|
|
139
|
-
refCheck.refs,
|
|
145
|
+
refCheck.refDetails || refCheck.refs,
|
|
140
146
|
requestId,
|
|
141
147
|
normalizedPromptMode,
|
|
142
148
|
ctx,
|
|
@@ -165,8 +171,11 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
165
171
|
if (r.status === "fulfilled" && r.value.b64) {
|
|
166
172
|
const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
|
|
167
173
|
const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
|
|
168
|
-
await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(r.value.b64, "base64"));
|
|
169
174
|
const meta = {
|
|
175
|
+
kind: "classic",
|
|
176
|
+
requestId,
|
|
177
|
+
sessionId,
|
|
178
|
+
clientNodeId,
|
|
170
179
|
prompt,
|
|
171
180
|
userPrompt: prompt,
|
|
172
181
|
revisedPrompt: r.value.revisedPrompt || null,
|
|
@@ -182,7 +191,21 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
182
191
|
createdAt: Date.now(),
|
|
183
192
|
usage: r.value.usage || null,
|
|
184
193
|
webSearchCalls: r.value.webSearchCalls || 0,
|
|
194
|
+
refsCount: refCheck.refs.length,
|
|
185
195
|
};
|
|
196
|
+
const rawBuffer = Buffer.from(r.value.b64, "base64");
|
|
197
|
+
const embedded = await embedImageMetadataBestEffort(rawBuffer, format, meta, {
|
|
198
|
+
version: ctx.packageVersion,
|
|
199
|
+
});
|
|
200
|
+
if (!embedded.embedded) {
|
|
201
|
+
logEvent("generate", "metadata_embed_skipped", {
|
|
202
|
+
requestId,
|
|
203
|
+
filename,
|
|
204
|
+
code: embedded.code,
|
|
205
|
+
warning: embedded.warning,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
|
|
186
209
|
await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
|
|
187
210
|
images.push({
|
|
188
211
|
image: `data:${mime};base64,${r.value.b64}`,
|
|
@@ -214,6 +237,10 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
214
237
|
upstreamCode: firstErr.upstreamCode || null,
|
|
215
238
|
upstreamType: firstErr.upstreamType || null,
|
|
216
239
|
upstreamParam: firstErr.upstreamParam || null,
|
|
240
|
+
diagnosticReason: firstErr.diagnosticReason || null,
|
|
241
|
+
retryKind: firstErr.retryKind || null,
|
|
242
|
+
referencesDroppedOnRetry: firstErr.referencesDroppedOnRetry ?? null,
|
|
243
|
+
errorEventCount: firstErr.eventCount ?? null,
|
|
217
244
|
requestId,
|
|
218
245
|
});
|
|
219
246
|
}
|
|
@@ -270,6 +297,10 @@ export function registerGenerateRoutes(app, ctx) {
|
|
|
270
297
|
upstreamCode: err.upstreamCode || null,
|
|
271
298
|
upstreamType: err.upstreamType || null,
|
|
272
299
|
upstreamParam: err.upstreamParam || null,
|
|
300
|
+
diagnosticReason: err.diagnosticReason || null,
|
|
301
|
+
retryKind: err.retryKind || null,
|
|
302
|
+
referencesDroppedOnRetry: err.referencesDroppedOnRetry ?? null,
|
|
303
|
+
errorEventCount: err.eventCount ?? null,
|
|
273
304
|
requestId,
|
|
274
305
|
});
|
|
275
306
|
} finally {
|
package/routes/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import { listHistoryRows } from "../lib/historyList.js";
|
|
|
2
2
|
import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
|
|
3
3
|
import { getSessionTitleMap } from "../lib/sessionStore.js";
|
|
4
4
|
import { logError, logEvent } from "../lib/logger.js";
|
|
5
|
+
import { getDb } from "../lib/db.js";
|
|
5
6
|
|
|
6
7
|
export function registerHistoryRoutes(app, ctx) {
|
|
7
8
|
app.get("/api/history", async (req, res) => {
|
|
@@ -16,9 +17,18 @@ export function registerHistoryRoutes(app, ctx) {
|
|
|
16
17
|
const sinceTs = parseInt(req.query.since);
|
|
17
18
|
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
|
|
18
19
|
const groupBy = req.query.groupBy === "session" ? "session" : null;
|
|
20
|
+
const browserId = req.headers["x-ima2-browser-id"] || null;
|
|
19
21
|
|
|
20
22
|
const rows = await listHistoryRows(ctx.config.storage.generatedDir);
|
|
21
23
|
|
|
24
|
+
// Enrich with favorite status
|
|
25
|
+
let favoriteSet = new Set();
|
|
26
|
+
if (browserId) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
const favRows = db.prepare("SELECT filename FROM gallery_favorites WHERE browser_id = ?").all(browserId);
|
|
29
|
+
favoriteSet = new Set(favRows.map((r) => r.filename));
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
let filtered = rows;
|
|
23
33
|
if (Number.isFinite(sinceTs)) {
|
|
24
34
|
filtered = filtered.filter((r) => r.createdAt > sinceTs);
|
|
@@ -34,7 +44,7 @@ export function registerHistoryRoutes(app, ctx) {
|
|
|
34
44
|
filtered = filtered.filter((r) => r.sessionId === sessionId);
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
const page = filtered.slice(0, limit);
|
|
47
|
+
const page = filtered.slice(0, limit).map((r) => ({ ...r, isFavorite: favoriteSet.has(r.filename) }));
|
|
38
48
|
const nextCursor = page.length === limit && filtered.length > limit
|
|
39
49
|
? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
|
|
40
50
|
: null;
|
|
@@ -99,4 +109,35 @@ export function registerHistoryRoutes(app, ctx) {
|
|
|
99
109
|
res.status(err.status || 500).json({ error: err.message });
|
|
100
110
|
}
|
|
101
111
|
});
|
|
112
|
+
|
|
113
|
+
app.post("/api/history/favorite", async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const db = getDb();
|
|
116
|
+
const { filename } = req.body || {};
|
|
117
|
+
const browserId = req.headers["x-ima2-browser-id"];
|
|
118
|
+
|
|
119
|
+
if (!filename || typeof filename !== "string") {
|
|
120
|
+
return res.status(400).json({ error: "filename is required" });
|
|
121
|
+
}
|
|
122
|
+
if (!browserId || typeof browserId !== "string") {
|
|
123
|
+
return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const existing = db.prepare("SELECT id FROM gallery_favorites WHERE browser_id = ? AND filename = ?").get(browserId, filename);
|
|
127
|
+
|
|
128
|
+
if (existing) {
|
|
129
|
+
db.prepare("DELETE FROM gallery_favorites WHERE browser_id = ? AND filename = ?").run(browserId, filename);
|
|
130
|
+
res.json({ isFavorite: false });
|
|
131
|
+
} else {
|
|
132
|
+
const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
133
|
+
db.prepare(
|
|
134
|
+
"INSERT INTO gallery_favorites (id, browser_id, filename, favorited_at) VALUES (?, ?, ?, ?)"
|
|
135
|
+
).run(id, browserId, filename, Math.floor(Date.now() / 1000));
|
|
136
|
+
res.json({ isFavorite: true });
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logError("history", "favorite_error", err);
|
|
140
|
+
res.status(500).json({ error: err.message });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
102
143
|
}
|
package/routes/index.js
CHANGED
|
@@ -6,14 +6,18 @@ import { registerNodeRoutes } from "./nodes.js";
|
|
|
6
6
|
import { registerGenerateRoutes } from "./generate.js";
|
|
7
7
|
import { registerStorageRoutes } from "./storage.js";
|
|
8
8
|
import { registerCardNewsRoutes } from "./cardNews.js";
|
|
9
|
+
import { registerMetadataRoutes } from "./metadata.js";
|
|
10
|
+
import { registerPromptRoutes } from "./prompts.js";
|
|
9
11
|
|
|
10
12
|
export function configureRoutes(app, ctx) {
|
|
11
13
|
registerHealthRoutes(app, ctx);
|
|
12
14
|
registerStorageRoutes(app, ctx);
|
|
15
|
+
registerMetadataRoutes(app, ctx);
|
|
13
16
|
registerHistoryRoutes(app, ctx);
|
|
14
17
|
registerSessionRoutes(app, ctx);
|
|
15
18
|
registerEditRoutes(app, ctx);
|
|
16
19
|
registerNodeRoutes(app, ctx);
|
|
17
20
|
if (ctx.config.features.cardNews) registerCardNewsRoutes(app, ctx);
|
|
18
21
|
registerGenerateRoutes(app, ctx);
|
|
22
|
+
registerPromptRoutes(app, ctx);
|
|
19
23
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import {
|
|
3
|
+
isSupportedMetadataFormat,
|
|
4
|
+
normalizeImageMetadataFormat,
|
|
5
|
+
readEmbeddedImageMetadata,
|
|
6
|
+
} from "../lib/imageMetadataStore.js";
|
|
7
|
+
|
|
8
|
+
const MIME_FORMATS = {
|
|
9
|
+
"image/png": "png",
|
|
10
|
+
"image/jpeg": "jpeg",
|
|
11
|
+
"image/webp": "webp",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function parseDataUrl(dataUrl) {
|
|
15
|
+
if (typeof dataUrl !== "string") return null;
|
|
16
|
+
const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
return { mime: match[1].toLowerCase(), rawB64: match[2] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerMetadataRoutes(app, ctx) {
|
|
22
|
+
app.post("/api/metadata/read", async (req, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = parseDataUrl(req.body?.dataUrl);
|
|
25
|
+
if (!parsed) {
|
|
26
|
+
return res.status(400).json({
|
|
27
|
+
ok: false,
|
|
28
|
+
code: "IMAGE_METADATA_INVALID",
|
|
29
|
+
error: "A base64 image data URL is required.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (parsed.rawB64.length > ctx.config.limits.maxMetadataReadB64Bytes) {
|
|
33
|
+
return res.status(413).json({
|
|
34
|
+
ok: false,
|
|
35
|
+
code: "IMAGE_METADATA_TOO_LARGE",
|
|
36
|
+
error: "Image is too large to inspect for metadata.",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const format = normalizeImageMetadataFormat(MIME_FORMATS[parsed.mime]);
|
|
40
|
+
if (!isSupportedMetadataFormat(format)) {
|
|
41
|
+
return res.status(400).json({
|
|
42
|
+
ok: false,
|
|
43
|
+
code: "IMAGE_METADATA_UNSUPPORTED_FORMAT",
|
|
44
|
+
error: "Only PNG, JPEG, and WebP metadata can be inspected.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const result = await readEmbeddedImageMetadata(Buffer.from(parsed.rawB64, "base64"));
|
|
48
|
+
if (!result.metadata) {
|
|
49
|
+
return res.json({
|
|
50
|
+
ok: true,
|
|
51
|
+
metadata: null,
|
|
52
|
+
source: null,
|
|
53
|
+
code: "IMAGE_METADATA_NOT_FOUND",
|
|
54
|
+
warnings: result.warnings,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return res.json({
|
|
58
|
+
ok: true,
|
|
59
|
+
metadata: result.metadata,
|
|
60
|
+
source: result.source,
|
|
61
|
+
warnings: result.warnings,
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return res.status(400).json({
|
|
65
|
+
ok: false,
|
|
66
|
+
code: error?.code || "IMAGE_METADATA_INVALID",
|
|
67
|
+
error: error?.message || "Could not read image metadata.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
package/routes/nodes.js
CHANGED
|
@@ -181,7 +181,10 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
181
181
|
parentB64 = await loadAssetB64(ctx.rootDir, externalSrc, ctx.config.storage.generatedDir);
|
|
182
182
|
}
|
|
183
183
|
const operation = parentB64 ? "edit" : "generate";
|
|
184
|
-
const
|
|
184
|
+
const referenceDiagnostics = refCheck.referenceDiagnostics || [];
|
|
185
|
+
const generateReferenceDiagnostics = operation === "generate" ? referenceDiagnostics : [];
|
|
186
|
+
const referenceMismatchCount = generateReferenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
|
|
187
|
+
const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
|
|
185
188
|
const webSearchEnabled = !parentB64 || searchMode === "on";
|
|
186
189
|
const parentImagePresent = !!parentB64;
|
|
187
190
|
const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
|
|
@@ -196,6 +199,9 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
196
199
|
size,
|
|
197
200
|
moderation,
|
|
198
201
|
refs: refsForRequest.length,
|
|
202
|
+
referenceMismatchCount,
|
|
203
|
+
refDetectedMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
|
|
204
|
+
refDeclaredMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
|
|
199
205
|
inputImageCount,
|
|
200
206
|
parentImagePresent,
|
|
201
207
|
contextMode,
|
|
@@ -302,6 +308,9 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
302
308
|
upstreamCode: lastErr?.upstreamCode || lastErr?.code,
|
|
303
309
|
errorEventType: lastErr?.eventType,
|
|
304
310
|
errorEventCount: lastErr?.eventCount,
|
|
311
|
+
diagnosticReason: lastErr?.diagnosticReason,
|
|
312
|
+
retryKind: lastErr?.retryKind,
|
|
313
|
+
referencesDroppedOnRetry: lastErr?.referencesDroppedOnRetry,
|
|
305
314
|
attempts: MAX_RETRIES + 1,
|
|
306
315
|
outerHttpAlreadyCommitted: res.headersSent,
|
|
307
316
|
sseErrorSent: streamResponse,
|
|
@@ -318,6 +327,11 @@ export function registerNodeRoutes(app, ctx) {
|
|
|
318
327
|
upstreamParam: lastErr?.upstreamParam || null,
|
|
319
328
|
errorEventType: lastErr?.eventType || null,
|
|
320
329
|
errorEventCount: lastErr?.eventCount ?? null,
|
|
330
|
+
diagnosticReason: finalErr.diagnosticReason || lastErr?.diagnosticReason || null,
|
|
331
|
+
retryKind: finalErr.retryKind || lastErr?.retryKind || null,
|
|
332
|
+
referencesDroppedOnRetry: finalErr.referencesDroppedOnRetry ?? lastErr?.referencesDroppedOnRetry ?? null,
|
|
333
|
+
refsCount: finalErr.refsCount ?? lastErr?.refsCount ?? null,
|
|
334
|
+
inputImageCount: finalErr.inputImageCount ?? lastErr?.inputImageCount ?? null,
|
|
321
335
|
},
|
|
322
336
|
);
|
|
323
337
|
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { logError, logEvent } from "../lib/logger.js";
|
|
2
|
+
import { getDb } from "../lib/db.js";
|
|
3
|
+
|
|
4
|
+
function getPromptsDb() {
|
|
5
|
+
return getDb();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function generateId() {
|
|
9
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerPromptRoutes(app, ctx) {
|
|
13
|
+
// ── Prompts ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
app.get("/api/prompts", async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const db = getPromptsDb();
|
|
18
|
+
const search = typeof req.query.search === "string" ? req.query.search.trim() : "";
|
|
19
|
+
const folderId = typeof req.query.folderId === "string" ? req.query.folderId : null;
|
|
20
|
+
const favoritesOnly = req.query.favoritesOnly === "1" || req.query.favoritesOnly === "true";
|
|
21
|
+
|
|
22
|
+
let where = "WHERE 1=1";
|
|
23
|
+
const params = [];
|
|
24
|
+
|
|
25
|
+
if (folderId) {
|
|
26
|
+
where += " AND p.folder_id = ?";
|
|
27
|
+
params.push(folderId);
|
|
28
|
+
} else {
|
|
29
|
+
where += " AND p.folder_id != '__trash__'";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (favoritesOnly) {
|
|
33
|
+
where += " AND p.is_favorite = 1";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (search) {
|
|
37
|
+
where += " AND (p.name LIKE ? OR p.text LIKE ? OR p.tags LIKE ?)";
|
|
38
|
+
const like = `%${search}%`;
|
|
39
|
+
params.push(like, like, like);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const prompts = db
|
|
43
|
+
.prepare(
|
|
44
|
+
`SELECT p.*, f.name as folder_name
|
|
45
|
+
FROM prompts p
|
|
46
|
+
LEFT JOIN prompt_folders f ON p.folder_id = f.id
|
|
47
|
+
${where}
|
|
48
|
+
ORDER BY p.updated_at DESC`
|
|
49
|
+
)
|
|
50
|
+
.all(...params);
|
|
51
|
+
|
|
52
|
+
const folders = db
|
|
53
|
+
.prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__') ORDER BY name COLLATE NOCASE")
|
|
54
|
+
.all();
|
|
55
|
+
|
|
56
|
+
res.json({ prompts: prompts.map(normalizePrompt), folders: folders.map(normalizeFolder) });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logError("prompts", "list_error", err);
|
|
59
|
+
res.status(500).json({ error: err.message });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
app.post("/api/prompts", async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const db = getPromptsDb();
|
|
66
|
+
const { name, text, tags, folderId, mode } = req.body || {};
|
|
67
|
+
|
|
68
|
+
if (!text || typeof text !== "string") {
|
|
69
|
+
return res.status(400).json({ error: "text is required" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const promptName = typeof name === "string" && name.trim() ? name.trim() : text.slice(0, 30);
|
|
73
|
+
const folder_id = typeof folderId === "string" && folderId ? folderId : "__root__";
|
|
74
|
+
const tagsJson = Array.isArray(tags) ? JSON.stringify(tags) : null;
|
|
75
|
+
const id = generateId();
|
|
76
|
+
const now = Math.floor(Date.now() / 1000);
|
|
77
|
+
|
|
78
|
+
db.prepare(
|
|
79
|
+
`INSERT INTO prompts (id, folder_id, name, text, tags, mode, created_at, updated_at)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
81
|
+
).run(id, folder_id, promptName, text, tagsJson, mode || null, now, now);
|
|
82
|
+
|
|
83
|
+
logEvent("prompts", "created", { id, folder_id });
|
|
84
|
+
res.status(201).json({ prompt: normalizePrompt(db.prepare("SELECT * FROM prompts WHERE id = ?").get(id)) });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logError("prompts", "create_error", err);
|
|
87
|
+
res.status(500).json({ error: err.message });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.get("/api/prompts/:id", async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const db = getPromptsDb();
|
|
94
|
+
const row = db.prepare("SELECT * FROM prompts WHERE id = ?").get(req.params.id);
|
|
95
|
+
if (!row) return res.status(404).json({ error: "Not found" });
|
|
96
|
+
res.json({ prompt: normalizePrompt(row) });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logError("prompts", "get_error", err);
|
|
99
|
+
res.status(500).json({ error: err.message });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
app.patch("/api/prompts/:id", async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const db = getPromptsDb();
|
|
106
|
+
const { name, text, tags, folderId, mode } = req.body || {};
|
|
107
|
+
const sets = [];
|
|
108
|
+
const params = [];
|
|
109
|
+
|
|
110
|
+
if (typeof name === "string") { sets.push("name = ?"); params.push(name); }
|
|
111
|
+
if (typeof text === "string") { sets.push("text = ?"); params.push(text); }
|
|
112
|
+
if (Array.isArray(tags)) { sets.push("tags = ?"); params.push(JSON.stringify(tags)); }
|
|
113
|
+
if (typeof folderId === "string") { sets.push("folder_id = ?"); params.push(folderId); }
|
|
114
|
+
if (typeof mode === "string") { sets.push("mode = ?"); params.push(mode); }
|
|
115
|
+
|
|
116
|
+
if (sets.length === 0) return res.status(400).json({ error: "No fields to update" });
|
|
117
|
+
|
|
118
|
+
sets.push("updated_at = ?");
|
|
119
|
+
params.push(Math.floor(Date.now() / 1000));
|
|
120
|
+
params.push(req.params.id);
|
|
121
|
+
|
|
122
|
+
db.prepare(`UPDATE prompts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
123
|
+
|
|
124
|
+
const row = db.prepare("SELECT * FROM prompts WHERE id = ?").get(req.params.id);
|
|
125
|
+
res.json({ prompt: normalizePrompt(row) });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logError("prompts", "patch_error", err);
|
|
128
|
+
res.status(500).json({ error: err.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.delete("/api/prompts/:id", async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
const db = getPromptsDb();
|
|
135
|
+
db.prepare("UPDATE prompts SET folder_id = '__trash__', updated_at = ? WHERE id = ?").run(
|
|
136
|
+
Math.floor(Date.now() / 1000),
|
|
137
|
+
req.params.id,
|
|
138
|
+
);
|
|
139
|
+
logEvent("prompts", "soft_deleted", { id: req.params.id });
|
|
140
|
+
res.json({ ok: true });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
logError("prompts", "delete_error", err);
|
|
143
|
+
res.status(500).json({ error: err.message });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
app.post("/api/prompts/:id/favorite", async (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const db = getPromptsDb();
|
|
150
|
+
const row = db.prepare("SELECT is_favorite FROM prompts WHERE id = ?").get(req.params.id);
|
|
151
|
+
if (!row) return res.status(404).json({ error: "Not found" });
|
|
152
|
+
|
|
153
|
+
const newVal = row.is_favorite ? 0 : 1;
|
|
154
|
+
const now = Math.floor(Date.now() / 1000);
|
|
155
|
+
db.prepare("UPDATE prompts SET is_favorite = ?, favorited_at = ? WHERE id = ?").run(
|
|
156
|
+
newVal,
|
|
157
|
+
newVal ? now : null,
|
|
158
|
+
req.params.id,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
res.json({ isFavorite: !!newVal, favoritedAt: newVal ? now : null });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logError("prompts", "favorite_error", err);
|
|
164
|
+
res.status(500).json({ error: err.message });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Import / Export ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
app.post("/api/prompts/import", async (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const db = getPromptsDb();
|
|
173
|
+
const { folders: importFolders = [], prompts: importPrompts = [] } = req.body || {};
|
|
174
|
+
|
|
175
|
+
const result = { foldersCreated: 0, promptsImported: 0, duplicatesSkipped: 0 };
|
|
176
|
+
const now = Math.floor(Date.now() / 1000);
|
|
177
|
+
|
|
178
|
+
// Build name→id map for existing folders
|
|
179
|
+
const existingFolders = db.prepare("SELECT * FROM prompt_folders").all();
|
|
180
|
+
const folderMap = new Map(existingFolders.map((f) => [f.id, f]));
|
|
181
|
+
const namePathMap = new Map();
|
|
182
|
+
for (const f of existingFolders) {
|
|
183
|
+
const parent = folderMap.get(f.parent_id);
|
|
184
|
+
const path = parent && parent.id !== "__root__" ? `${parent.name}/${f.name}` : f.name;
|
|
185
|
+
namePathMap.set(path.toLowerCase(), f.id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Import folders
|
|
189
|
+
for (const f of importFolders) {
|
|
190
|
+
if (!f.name) continue;
|
|
191
|
+
const path = f.parentId && f.parentId !== "__root__"
|
|
192
|
+
? `${folderMap.get(f.parentId)?.name || ""}/${f.name}`
|
|
193
|
+
: f.name;
|
|
194
|
+
if (namePathMap.has(path.toLowerCase())) continue;
|
|
195
|
+
|
|
196
|
+
const id = f.id || generateId();
|
|
197
|
+
db.prepare(
|
|
198
|
+
"INSERT INTO prompt_folders (id, parent_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
|
|
199
|
+
).run(id, f.parentId || "__root__", f.name, now, now);
|
|
200
|
+
namePathMap.set(path.toLowerCase(), id);
|
|
201
|
+
folderMap.set(id, { id, parent_id: f.parentId || "__root__", name: f.name });
|
|
202
|
+
result.foldersCreated++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Import prompts
|
|
206
|
+
for (const p of importPrompts) {
|
|
207
|
+
if (!p.text) continue;
|
|
208
|
+
const folderId = p.folderId && folderMap.has(p.folderId) ? p.folderId : "__root__";
|
|
209
|
+
// Check duplicate by text + folder
|
|
210
|
+
const dup = db.prepare("SELECT 1 FROM prompts WHERE text = ? AND folder_id = ? LIMIT 1").get(p.text, folderId);
|
|
211
|
+
if (dup) {
|
|
212
|
+
result.duplicatesSkipped++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const id = p.id || generateId();
|
|
216
|
+
const tagsJson = Array.isArray(p.tags) ? JSON.stringify(p.tags) : null;
|
|
217
|
+
db.prepare(
|
|
218
|
+
`INSERT INTO prompts (id, folder_id, name, text, tags, mode, is_favorite, created_at, updated_at)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
220
|
+
).run(id, folderId, p.name || p.text.slice(0, 30), p.text, tagsJson, p.mode || null, p.isFavorite ? 1 : 0, now, now);
|
|
221
|
+
result.promptsImported++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
logEvent("prompts", "imported", result);
|
|
225
|
+
res.json(result);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logError("prompts", "import_error", err);
|
|
228
|
+
res.status(500).json({ error: err.message });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
app.get("/api/prompts/export", async (req, res) => {
|
|
233
|
+
try {
|
|
234
|
+
const db = getPromptsDb();
|
|
235
|
+
const prompts = db.prepare("SELECT * FROM prompts WHERE folder_id != '__trash__'").all();
|
|
236
|
+
const folders = db.prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__')").all();
|
|
237
|
+
|
|
238
|
+
res.json({
|
|
239
|
+
version: 1,
|
|
240
|
+
exportedAt: new Date().toISOString(),
|
|
241
|
+
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parent_id })),
|
|
242
|
+
prompts: prompts.map((p) => ({
|
|
243
|
+
id: p.id,
|
|
244
|
+
name: p.name,
|
|
245
|
+
text: p.text,
|
|
246
|
+
tags: p.tags ? JSON.parse(p.tags) : [],
|
|
247
|
+
folderId: p.folder_id,
|
|
248
|
+
mode: p.mode,
|
|
249
|
+
isFavorite: !!p.is_favorite,
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logError("prompts", "export_error", err);
|
|
254
|
+
res.status(500).json({ error: err.message });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── Folders ───────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
app.get("/api/prompts/folders", async (req, res) => {
|
|
261
|
+
try {
|
|
262
|
+
const db = getPromptsDb();
|
|
263
|
+
const rows = db.prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__') ORDER BY name COLLATE NOCASE").all();
|
|
264
|
+
res.json({ folders: rows.map(normalizeFolder) });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
logError("prompts", "folders_list_error", err);
|
|
267
|
+
res.status(500).json({ error: err.message });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
app.post("/api/prompts/folders", async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const db = getPromptsDb();
|
|
274
|
+
const { name, parentId } = req.body || {};
|
|
275
|
+
if (!name || typeof name !== "string" || !name.trim()) {
|
|
276
|
+
return res.status(400).json({ error: "name is required" });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parent_id = typeof parentId === "string" && parentId ? parentId : "__root__";
|
|
280
|
+
const now = Math.floor(Date.now() / 1000);
|
|
281
|
+
const id = generateId();
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
db.prepare(
|
|
285
|
+
"INSERT INTO prompt_folders (id, parent_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
|
|
286
|
+
).run(id, parent_id, name.trim(), now, now);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (err.message && err.message.includes("UNIQUE constraint failed")) {
|
|
289
|
+
return res.status(409).json({ error: "Folder name already exists in this parent" });
|
|
290
|
+
}
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
res.status(201).json({ folder: normalizeFolder(db.prepare("SELECT * FROM prompt_folders WHERE id = ?").get(id)) });
|
|
295
|
+
} catch (err) {
|
|
296
|
+
logError("prompts", "folder_create_error", err);
|
|
297
|
+
res.status(500).json({ error: err.message });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
app.patch("/api/prompts/folders/:id", async (req, res) => {
|
|
302
|
+
try {
|
|
303
|
+
const db = getPromptsDb();
|
|
304
|
+
const { name, parentId } = req.body || {};
|
|
305
|
+
const sets = [];
|
|
306
|
+
const params = [];
|
|
307
|
+
|
|
308
|
+
if (typeof name === "string" && name.trim()) { sets.push("name = ?"); params.push(name.trim()); }
|
|
309
|
+
if (typeof parentId === "string") { sets.push("parent_id = ?"); params.push(parentId); }
|
|
310
|
+
if (sets.length === 0) return res.status(400).json({ error: "No fields to update" });
|
|
311
|
+
|
|
312
|
+
sets.push("updated_at = ?");
|
|
313
|
+
params.push(Math.floor(Date.now() / 1000));
|
|
314
|
+
params.push(req.params.id);
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
db.prepare(`UPDATE prompt_folders SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err.message && err.message.includes("UNIQUE constraint failed")) {
|
|
320
|
+
return res.status(409).json({ error: "Folder name already exists in this parent" });
|
|
321
|
+
}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const row = db.prepare("SELECT * FROM prompt_folders WHERE id = ?").get(req.params.id);
|
|
326
|
+
res.json({ folder: normalizeFolder(row) });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logError("prompts", "folder_patch_error", err);
|
|
329
|
+
res.status(500).json({ error: err.message });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
app.delete("/api/prompts/folders/:id", async (req, res) => {
|
|
334
|
+
try {
|
|
335
|
+
const db = getPromptsDb();
|
|
336
|
+
const strategy = req.query.strategy === "deleteItems" ? "deleteItems" : "moveToRoot";
|
|
337
|
+
|
|
338
|
+
if (strategy === "moveToRoot") {
|
|
339
|
+
db.prepare("UPDATE prompts SET folder_id = '__root__' WHERE folder_id = ?").run(req.params.id);
|
|
340
|
+
} else {
|
|
341
|
+
db.prepare("UPDATE prompts SET folder_id = '__trash__' WHERE folder_id = ?").run(req.params.id);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
db.prepare("DELETE FROM prompt_folders WHERE id = ?").run(req.params.id);
|
|
345
|
+
logEvent("prompts", "folder_deleted", { id: req.params.id, strategy });
|
|
346
|
+
res.json({ ok: true });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logError("prompts", "folder_delete_error", err);
|
|
349
|
+
res.status(500).json({ error: err.message });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function normalizePrompt(row) {
|
|
357
|
+
return {
|
|
358
|
+
id: row.id,
|
|
359
|
+
folderId: row.folder_id,
|
|
360
|
+
name: row.name,
|
|
361
|
+
text: row.text,
|
|
362
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
363
|
+
mode: row.mode,
|
|
364
|
+
isFavorite: !!row.is_favorite,
|
|
365
|
+
favoritedAt: row.favorited_at || null,
|
|
366
|
+
createdAt: row.created_at,
|
|
367
|
+
updatedAt: row.updated_at,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function normalizeFolder(row) {
|
|
372
|
+
return {
|
|
373
|
+
id: row.id,
|
|
374
|
+
parentId: row.parent_id,
|
|
375
|
+
name: row.name,
|
|
376
|
+
createdAt: row.created_at,
|
|
377
|
+
updatedAt: row.updated_at,
|
|
378
|
+
};
|
|
379
|
+
}
|