ima2-gen 1.0.5 → 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.
package/routes/edit.js ADDED
@@ -0,0 +1,171 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { editViaOAuth } from "../lib/oauthProxy.js";
5
+ import { classifyUpstreamError } from "../lib/errorClassify.js";
6
+ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
+ import { getStyleSheet } from "../lib/sessionStore.js";
8
+ import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
9
+ import { startJob, finishJob } from "../lib/inflight.js";
10
+ import { logEvent, logError } from "../lib/logger.js";
11
+
12
+ function validateModeration(ctx, moderation) {
13
+ if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
14
+ return { error: "moderation must be one of: auto, low" };
15
+ }
16
+ return { moderation };
17
+ }
18
+
19
+ export function registerEditRoutes(app, ctx) {
20
+ app.post("/api/edit", async (req, res) => {
21
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : null;
22
+ let finishStatus = "completed";
23
+ let finishHttpStatus;
24
+ let finishErrorCode;
25
+ let finishMeta = {};
26
+ try {
27
+ const {
28
+ prompt,
29
+ image: imageB64,
30
+ quality: rawQuality = "medium",
31
+ size = "1024x1024",
32
+ moderation = "low",
33
+ provider = "oauth",
34
+ mode: promptMode = "auto",
35
+ } = req.body;
36
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
37
+ const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
38
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
39
+
40
+ startJob({
41
+ requestId,
42
+ kind: "classic",
43
+ prompt,
44
+ meta: {
45
+ kind: "edit",
46
+ sessionId,
47
+ quality,
48
+ size,
49
+ styleSheetApplied: false,
50
+ },
51
+ });
52
+
53
+ if (!prompt || !imageB64) {
54
+ finishStatus = "error";
55
+ finishHttpStatus = 400;
56
+ finishErrorCode = "INVALID_EDIT_INPUT";
57
+ return res.status(400).json({ error: "Prompt and image are required" });
58
+ }
59
+ const moderationCheck = validateModeration(ctx, moderation);
60
+ if (moderationCheck.error) {
61
+ finishStatus = "error";
62
+ finishHttpStatus = 400;
63
+ finishErrorCode = "INVALID_MODERATION";
64
+ return res.status(400).json({ error: moderationCheck.error });
65
+ }
66
+ if (provider === "api") {
67
+ finishStatus = "error";
68
+ finishHttpStatus = 403;
69
+ finishErrorCode = "APIKEY_DISABLED";
70
+ return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
71
+ }
72
+
73
+ let effectivePrompt = prompt;
74
+ let styleSheetApplied = null;
75
+ if (sessionId) {
76
+ try {
77
+ const data = getStyleSheet(sessionId);
78
+ if (data && data.enabled && data.styleSheet) {
79
+ const prefix = renderStyleSheetPrefix(data.styleSheet);
80
+ if (prefix) {
81
+ effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
82
+ styleSheetApplied = data.styleSheet;
83
+ }
84
+ }
85
+ } catch {}
86
+ }
87
+
88
+ logEvent("edit", "request", {
89
+ requestId,
90
+ client: req.get("x-ima2-client") || "ui",
91
+ provider: "oauth",
92
+ quality,
93
+ size,
94
+ moderation,
95
+ sessionId,
96
+ promptChars: typeof prompt === "string" ? prompt.length : 0,
97
+ promptMode: normalizedPromptMode,
98
+ styleSheetApplied: !!styleSheetApplied,
99
+ inputImageChars: typeof imageB64 === "string" ? imageB64.length : 0,
100
+ });
101
+ const startTime = Date.now();
102
+ const { b64: resultB64, usage, revisedPrompt } = await editViaOAuth(
103
+ effectivePrompt,
104
+ imageB64,
105
+ quality,
106
+ size,
107
+ moderation,
108
+ normalizedPromptMode,
109
+ ctx,
110
+ requestId,
111
+ );
112
+
113
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
114
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
115
+ const filename = `${Date.now()}_${randomBytes(ctx.config.ids.generatedHexBytes).toString("hex")}.png`;
116
+ await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(resultB64, "base64"));
117
+ const meta = {
118
+ prompt,
119
+ userPrompt: prompt,
120
+ revisedPrompt: revisedPrompt || null,
121
+ promptMode: normalizedPromptMode,
122
+ effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
123
+ styleSheetApplied: styleSheetApplied || undefined,
124
+ quality,
125
+ size,
126
+ moderation,
127
+ format: "png",
128
+ provider: "oauth",
129
+ kind: "edit",
130
+ createdAt: Date.now(),
131
+ usage: usage || null,
132
+ webSearchCalls: 0,
133
+ };
134
+ await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
135
+ finishHttpStatus = 200;
136
+ finishMeta = { filename, imageChars: resultB64.length };
137
+ logEvent("edit", "saved", {
138
+ requestId,
139
+ filename,
140
+ imageChars: resultB64.length,
141
+ elapsedMs: Date.now() - startTime,
142
+ });
143
+
144
+ res.json({
145
+ image: `data:image/png;base64,${resultB64}`,
146
+ elapsed,
147
+ filename,
148
+ usage,
149
+ provider: "oauth",
150
+ moderation,
151
+ warnings: qualityWarnings,
152
+ revisedPrompt: revisedPrompt || null,
153
+ promptMode: normalizedPromptMode,
154
+ });
155
+ } catch (err) {
156
+ const fallbackCode = err.code || classifyUpstreamError(err.message);
157
+ finishStatus = "error";
158
+ finishHttpStatus = err.status || 500;
159
+ finishErrorCode = fallbackCode || "EDIT_FAILED";
160
+ logError("edit", "error", err, { requestId, code: finishErrorCode });
161
+ res.status(err.status || 500).json({ error: err.message, code: fallbackCode });
162
+ } finally {
163
+ finishJob(requestId, {
164
+ status: finishStatus,
165
+ httpStatus: finishHttpStatus,
166
+ errorCode: finishErrorCode,
167
+ meta: finishMeta,
168
+ });
169
+ }
170
+ });
171
+ }
@@ -0,0 +1,254 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { validateAndNormalizeRefs } from "../lib/refs.js";
5
+ import { classifyUpstreamError } from "../lib/errorClassify.js";
6
+ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
+ import { generateViaOAuth } from "../lib/oauthProxy.js";
8
+ import { startJob, finishJob } from "../lib/inflight.js";
9
+ import { getStyleSheet } from "../lib/sessionStore.js";
10
+ import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
11
+ import { logEvent, logError } from "../lib/logger.js";
12
+
13
+ function validateModeration(ctx, moderation) {
14
+ if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
15
+ return { error: "moderation must be one of: auto, low" };
16
+ }
17
+ return { moderation };
18
+ }
19
+
20
+ export function registerGenerateRoutes(app, ctx) {
21
+ app.post("/api/generate", async (req, res) => {
22
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : null;
23
+ let finishStatus = "completed";
24
+ let finishHttpStatus;
25
+ let finishErrorCode;
26
+ let finishMeta = {};
27
+ try {
28
+ const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
29
+ const clientNodeId = typeof req.body?.clientNodeId === "string" ? req.body.clientNodeId : null;
30
+ const {
31
+ prompt,
32
+ quality: rawQuality = "medium",
33
+ size = "1024x1024",
34
+ format = "png",
35
+ moderation = "low",
36
+ provider = "auto",
37
+ n = 1,
38
+ references = [],
39
+ mode: promptMode = "auto",
40
+ } = req.body;
41
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
42
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
43
+
44
+ if (!prompt) return res.status(400).json({ error: "Prompt is required" });
45
+ const moderationCheck = validateModeration(ctx, moderation);
46
+ if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
47
+ const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
48
+
49
+ let effectivePrompt = prompt;
50
+ let styleSheetApplied = null;
51
+ if (sessionId) {
52
+ try {
53
+ const data = getStyleSheet(sessionId);
54
+ if (data && data.enabled && data.styleSheet) {
55
+ const prefix = renderStyleSheetPrefix(data.styleSheet);
56
+ if (prefix) {
57
+ effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
58
+ styleSheetApplied = data.styleSheet;
59
+ }
60
+ }
61
+ } catch {}
62
+ }
63
+
64
+ startJob({
65
+ requestId,
66
+ kind: "classic",
67
+ prompt: effectivePrompt,
68
+ meta: {
69
+ kind: "classic",
70
+ sessionId,
71
+ parentNodeId: null,
72
+ clientNodeId,
73
+ quality,
74
+ size,
75
+ n: count,
76
+ styleSheetApplied: !!styleSheetApplied,
77
+ },
78
+ });
79
+
80
+ const refCheck = validateAndNormalizeRefs(references);
81
+ if (refCheck.error) {
82
+ finishStatus = "error";
83
+ finishHttpStatus = 400;
84
+ finishErrorCode = refCheck.code;
85
+ return res.status(400).json({ error: refCheck.error, code: refCheck.code });
86
+ }
87
+
88
+ if (provider === "api") {
89
+ finishStatus = "error";
90
+ finishHttpStatus = 403;
91
+ finishErrorCode = "APIKEY_DISABLED";
92
+ return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
93
+ }
94
+ const client = req.get("x-ima2-client") || "ui";
95
+ logEvent("generate", "request", {
96
+ requestId,
97
+ client,
98
+ provider: "oauth",
99
+ quality,
100
+ size,
101
+ moderation,
102
+ n: count,
103
+ refs: refCheck.refs.length,
104
+ sessionId,
105
+ clientNodeId,
106
+ promptChars: typeof prompt === "string" ? prompt.length : 0,
107
+ promptMode: normalizedPromptMode,
108
+ styleSheetApplied: !!styleSheetApplied,
109
+ });
110
+ const startTime = Date.now();
111
+
112
+ const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
113
+ const mime = mimeMap[format] || "image/png";
114
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
115
+
116
+ const generateOne = async () => {
117
+ const MAX_RETRIES = 1;
118
+ let lastErr;
119
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
120
+ try {
121
+ const r = await generateViaOAuth(
122
+ effectivePrompt,
123
+ quality,
124
+ size,
125
+ moderation,
126
+ refCheck.refs,
127
+ requestId,
128
+ normalizedPromptMode,
129
+ ctx,
130
+ );
131
+ if (r.b64) return r;
132
+ lastErr = new Error("Empty response (safety refusal)");
133
+ } catch (e) {
134
+ lastErr = e;
135
+ }
136
+ if (attempt < MAX_RETRIES) {
137
+ logEvent("generate", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
138
+ }
139
+ }
140
+ const err = new Error("Content generation refused after retries");
141
+ err.code = "SAFETY_REFUSAL";
142
+ err.status = 422;
143
+ err.cause = lastErr;
144
+ throw err;
145
+ };
146
+
147
+ const results = await Promise.allSettled(Array.from({ length: count }, generateOne));
148
+ const images = [];
149
+ let totalUsage = null;
150
+ let totalWebSearchCalls = 0;
151
+ for (const r of results) {
152
+ if (r.status === "fulfilled" && r.value.b64) {
153
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
154
+ const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
155
+ await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(r.value.b64, "base64"));
156
+ const meta = {
157
+ prompt,
158
+ userPrompt: prompt,
159
+ revisedPrompt: r.value.revisedPrompt || null,
160
+ promptMode: normalizedPromptMode,
161
+ effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
162
+ styleSheetApplied: styleSheetApplied || undefined,
163
+ quality,
164
+ size,
165
+ format,
166
+ moderation,
167
+ provider: "oauth",
168
+ createdAt: Date.now(),
169
+ usage: r.value.usage || null,
170
+ webSearchCalls: r.value.webSearchCalls || 0,
171
+ };
172
+ await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
173
+ images.push({
174
+ image: `data:${mime};base64,${r.value.b64}`,
175
+ filename,
176
+ revisedPrompt: r.value.revisedPrompt || null,
177
+ });
178
+ if (r.value.usage) {
179
+ if (!totalUsage) totalUsage = { ...r.value.usage };
180
+ else Object.keys(r.value.usage).forEach((k) => {
181
+ if (typeof r.value.usage[k] === "number") totalUsage[k] = (totalUsage[k] || 0) + r.value.usage[k];
182
+ });
183
+ }
184
+ if (typeof r.value.webSearchCalls === "number") totalWebSearchCalls += r.value.webSearchCalls;
185
+ } else if (r.status === "rejected") {
186
+ logError("generate", "parallel_failed", r.reason, { requestId });
187
+ }
188
+ }
189
+
190
+ if (images.length === 0) {
191
+ const firstErr = results.find((r) => r.status === "rejected")?.reason;
192
+ if (firstErr?.code === "SAFETY_REFUSAL") {
193
+ finishStatus = "error";
194
+ finishHttpStatus = 422;
195
+ finishErrorCode = "SAFETY_REFUSAL";
196
+ return res.status(422).json({ error: firstErr.message, code: "SAFETY_REFUSAL" });
197
+ }
198
+ finishStatus = "error";
199
+ finishHttpStatus = 500;
200
+ finishErrorCode = "GENERATE_ALL_FAILED";
201
+ return res.status(500).json({ error: "All generation attempts failed" });
202
+ }
203
+
204
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
205
+ const firstRevised = images[0]?.revisedPrompt || null;
206
+ const extra = {
207
+ usage: totalUsage,
208
+ provider: "oauth",
209
+ webSearchCalls: totalWebSearchCalls,
210
+ quality,
211
+ size,
212
+ moderation,
213
+ warnings: qualityWarnings,
214
+ revisedPrompt: firstRevised,
215
+ promptMode: normalizedPromptMode,
216
+ };
217
+
218
+ if (count === 1) {
219
+ finishHttpStatus = 200;
220
+ finishMeta = { filenames: [images[0].filename], imageCount: 1 };
221
+ logEvent("generate", "saved", {
222
+ requestId,
223
+ imageCount: 1,
224
+ elapsedMs: Date.now() - startTime,
225
+ filename: images[0].filename,
226
+ });
227
+ res.json({ image: images[0].image, elapsed, filename: images[0].filename, requestId, ...extra });
228
+ } else {
229
+ finishHttpStatus = 200;
230
+ finishMeta = { filenames: images.map((image) => image.filename), imageCount: images.length };
231
+ logEvent("generate", "saved", {
232
+ requestId,
233
+ imageCount: images.length,
234
+ elapsedMs: Date.now() - startTime,
235
+ });
236
+ res.json({ images, elapsed, count: images.length, requestId, ...extra });
237
+ }
238
+ } catch (err) {
239
+ const fallbackCode = err.code || classifyUpstreamError(err.message);
240
+ finishStatus = "error";
241
+ finishHttpStatus = err.status || 500;
242
+ finishErrorCode = fallbackCode || "GENERATE_FAILED";
243
+ logError("generate", "error", err, { requestId, code: finishErrorCode });
244
+ res.status(err.status || 500).json({ error: err.message, code: fallbackCode, requestId });
245
+ } finally {
246
+ finishJob(requestId, {
247
+ status: finishStatus,
248
+ httpStatus: finishHttpStatus,
249
+ errorCode: finishErrorCode,
250
+ meta: finishMeta,
251
+ });
252
+ }
253
+ });
254
+ }
@@ -0,0 +1,89 @@
1
+ import { listJobs, listTerminalJobs, finishJob } from "../lib/inflight.js";
2
+
3
+ export function registerHealthRoutes(app, ctx) {
4
+ app.get("/api/providers", (_req, res) => {
5
+ res.json({
6
+ apiKey: false,
7
+ oauth: true,
8
+ oauthPort: ctx.oauthPort,
9
+ apiKeyDisabled: true,
10
+ });
11
+ });
12
+
13
+ app.get("/api/health", (_req, res) => {
14
+ res.json({
15
+ ok: true,
16
+ version: ctx.packageVersion,
17
+ provider: "oauth",
18
+ uptimeSec: Math.round(process.uptime()),
19
+ activeJobs: listJobs().length,
20
+ pid: process.pid,
21
+ startedAt: ctx.startedAt,
22
+ });
23
+ });
24
+
25
+ app.get("/api/oauth/status", async (_req, res) => {
26
+ try {
27
+ const r = await fetch(`${ctx.oauthUrl}/v1/models`, {
28
+ signal: AbortSignal.timeout(ctx.config.oauth.statusTimeoutMs),
29
+ });
30
+ if (r.ok) {
31
+ const data = await r.json();
32
+ res.json({ status: "ready", models: data.data?.map((m) => m.id) || [] });
33
+ } else {
34
+ res.json({ status: "auth_required" });
35
+ }
36
+ } catch {
37
+ res.json({ status: "offline" });
38
+ }
39
+ });
40
+
41
+ app.get("/api/inflight", (req, res) => {
42
+ const kind =
43
+ typeof req.query.kind === "string" && req.query.kind.length > 0
44
+ ? req.query.kind
45
+ : undefined;
46
+ const sessionId =
47
+ typeof req.query.sessionId === "string" && req.query.sessionId.length > 0
48
+ ? req.query.sessionId
49
+ : undefined;
50
+ const includeTerminal =
51
+ req.query.includeTerminal === "1" || req.query.includeTerminal === "true";
52
+ const jobs = listJobs({ kind, sessionId });
53
+ if (!includeTerminal) return res.json({ jobs });
54
+ return res.json({
55
+ jobs,
56
+ terminalJobs: listTerminalJobs({ kind, sessionId }),
57
+ });
58
+ });
59
+
60
+ app.delete("/api/inflight/:requestId", (req, res) => {
61
+ finishJob(req.params.requestId, { canceled: true });
62
+ res.status(204).end();
63
+ });
64
+
65
+ app.get("/api/billing", async (_req, res) => {
66
+ if (!ctx.hasApiKey) {
67
+ return res.json({ oauth: true, apiKeyValid: false, apiKeySource: "none" });
68
+ }
69
+
70
+ try {
71
+ const headers = { Authorization: `Bearer ${ctx.apiKey}`, "Content-Type": "application/json" };
72
+ const start = Math.floor(new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000);
73
+ const end = Math.floor(Date.now() / 1000);
74
+ const [subRes, usageRes, modelsRes] = await Promise.allSettled([
75
+ fetch(`https://api.openai.com/v1/organization/costs?start_time=${start}&end_time=${end}&bucket_width=1d&limit=31`, { headers }),
76
+ fetch("https://api.openai.com/dashboard/billing/credit_grants", { headers }),
77
+ fetch("https://api.openai.com/v1/models", { headers }),
78
+ ]);
79
+
80
+ const billing = { apiKeySource: ctx.apiKeySource ?? "env" };
81
+ if (subRes.status === "fulfilled" && subRes.value.ok) billing.costs = await subRes.value.json();
82
+ if (usageRes.status === "fulfilled" && usageRes.value.ok) billing.credits = await usageRes.value.json();
83
+ billing.apiKeyValid = modelsRes.status === "fulfilled" && modelsRes.value.ok === true;
84
+ res.json(billing);
85
+ } catch (err) {
86
+ res.status(500).json({ error: err.message, apiKeyValid: false });
87
+ }
88
+ });
89
+ }
@@ -0,0 +1,102 @@
1
+ import { listHistoryRows } from "../lib/historyList.js";
2
+ import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
3
+ import { getSessionTitleMap } from "../lib/sessionStore.js";
4
+ import { logError, logEvent } from "../lib/logger.js";
5
+
6
+ export function registerHistoryRoutes(app, ctx) {
7
+ app.get("/api/history", async (req, res) => {
8
+ try {
9
+ const limitRaw = parseInt(req.query.limit);
10
+ const limit = Math.min(
11
+ Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : ctx.config.history.defaultPageSize,
12
+ ctx.config.history.maxPageCap,
13
+ );
14
+ const beforeTs = parseInt(req.query.before);
15
+ const beforeFn = typeof req.query.beforeFilename === "string" ? req.query.beforeFilename : null;
16
+ const sinceTs = parseInt(req.query.since);
17
+ const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
18
+ const groupBy = req.query.groupBy === "session" ? "session" : null;
19
+
20
+ const rows = await listHistoryRows(ctx.config.storage.generatedDir);
21
+
22
+ let filtered = rows;
23
+ if (Number.isFinite(sinceTs)) {
24
+ filtered = filtered.filter((r) => r.createdAt > sinceTs);
25
+ }
26
+ if (Number.isFinite(beforeTs)) {
27
+ filtered = filtered.filter((r) => {
28
+ if (r.createdAt < beforeTs) return true;
29
+ if (r.createdAt === beforeTs && beforeFn) return r.filename < beforeFn;
30
+ return false;
31
+ });
32
+ }
33
+ if (sessionId) {
34
+ filtered = filtered.filter((r) => r.sessionId === sessionId);
35
+ }
36
+
37
+ const page = filtered.slice(0, limit);
38
+ const nextCursor = page.length === limit && filtered.length > limit
39
+ ? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
40
+ : null;
41
+
42
+ if (groupBy === "session") {
43
+ const groups = new Map();
44
+ const loose = [];
45
+ for (const row of page) {
46
+ if (row.sessionId) {
47
+ let group = groups.get(row.sessionId);
48
+ if (!group) {
49
+ group = { sessionId: row.sessionId, items: [], lastUsedAt: row.createdAt };
50
+ groups.set(row.sessionId, group);
51
+ }
52
+ group.items.push(row);
53
+ if (row.createdAt > group.lastUsedAt) group.lastUsedAt = row.createdAt;
54
+ } else {
55
+ loose.push(row);
56
+ }
57
+ }
58
+ const titleMap = getSessionTitleMap(Array.from(groups.keys()));
59
+ const sessions = Array.from(groups.values())
60
+ .map((group) => ({
61
+ ...group,
62
+ title: titleMap.get(group.sessionId) || null,
63
+ label: titleMap.get(group.sessionId) || group.sessionId.slice(0, 8),
64
+ }))
65
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
66
+ logEvent("history", "grouped", {
67
+ sessions: sessions.length,
68
+ loose: loose.length,
69
+ total: rows.length,
70
+ });
71
+ return res.json({ sessions, loose, total: rows.length, nextCursor });
72
+ }
73
+
74
+ res.json({ items: page, total: rows.length, nextCursor });
75
+ } catch (err) {
76
+ logError("history", "error", err);
77
+ res.status(500).json({ error: err.message });
78
+ }
79
+ });
80
+
81
+ app.delete("/api/history/:filename", async (req, res) => {
82
+ try {
83
+ const filename = decodeURIComponent(req.params.filename);
84
+ const result = await trashAsset(ctx.rootDir, filename);
85
+ res.json(result);
86
+ } catch (err) {
87
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
88
+ }
89
+ });
90
+
91
+ app.post("/api/history/:filename/restore", async (req, res) => {
92
+ try {
93
+ const filename = decodeURIComponent(req.params.filename);
94
+ const trashId = typeof req.body?.trashId === "string" ? req.body.trashId : null;
95
+ if (!trashId) return res.status(400).json({ error: "trashId required" });
96
+ const result = await restoreAsset(ctx.rootDir, trashId, filename);
97
+ res.json(result);
98
+ } catch (err) {
99
+ res.status(err.status || 500).json({ error: err.message });
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,16 @@
1
+ import { registerHealthRoutes } from "./health.js";
2
+ import { registerHistoryRoutes } from "./history.js";
3
+ import { registerSessionRoutes } from "./sessions.js";
4
+ import { registerEditRoutes } from "./edit.js";
5
+ import { registerNodeRoutes } from "./nodes.js";
6
+ import { registerGenerateRoutes } from "./generate.js";
7
+
8
+ export function configureRoutes(app, ctx) {
9
+ registerHealthRoutes(app, ctx);
10
+ registerHistoryRoutes(app, ctx);
11
+ registerSessionRoutes(app, ctx);
12
+ registerEditRoutes(app, ctx);
13
+ registerNodeRoutes(app, ctx);
14
+ registerGenerateRoutes(app, ctx);
15
+ }
16
+