minecodex 0.1.19 → 0.2.1

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.
@@ -12,6 +12,11 @@
12
12
  },
13
13
  "surfaceUrl": "http://127.0.0.1:47831/",
14
14
  "healthUrl": "http://127.0.0.1:47831/api/health",
15
+ "runtimePortEnvironment": "CODEX_IMAGE_HOST_PORT",
16
+ "pageScript": {
17
+ "resource": "src/chatgpt-image-source-page.mjs"
18
+ },
19
+ "hostActions": ["import-generated-image"],
15
20
  "placement": {
16
21
  "after": "sites",
17
22
  "order": 10
@@ -0,0 +1,207 @@
1
+ const MAX_BYTES = 32 * 1024 * 1024;
2
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
3
+ const MARKER_SELECTORS = [
4
+ "[data-image-generation-result]",
5
+ "[data-generated-image]",
6
+ '[data-testid="image-generation-result"]',
7
+ '[data-testid="generated-image"]',
8
+ '[data-testid="image-generation"]',
9
+ ];
10
+
11
+ function importUrl() {
12
+ try {
13
+ const origin = new URL(config?.serviceOrigin);
14
+ if (
15
+ origin.protocol !== "http:"
16
+ || !LOOPBACK_HOSTS.has(origin.hostname)
17
+ || origin.username
18
+ || origin.password
19
+ ) return null;
20
+ return new URL("/api/import-generated-image", origin.origin).href;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ const IMPORT_URL = importUrl();
27
+ if (!IMPORT_URL) return () => {};
28
+
29
+ function sendImport(payload) {
30
+ if (
31
+ typeof config?.bindingName === "string"
32
+ && typeof config?.bindingToken === "string"
33
+ && typeof config?.featureId === "string"
34
+ && typeof window[config.bindingName] === "function"
35
+ ) {
36
+ window[config.bindingName](JSON.stringify({
37
+ token: config.bindingToken,
38
+ featureId: config.featureId,
39
+ requestId: `${payload.itemId}:${Date.now()}`,
40
+ action: "import-generated-image",
41
+ payload,
42
+ }));
43
+ return Promise.resolve();
44
+ }
45
+ return window.fetch(IMPORT_URL, {
46
+ method: "POST",
47
+ credentials: "omit",
48
+ referrerPolicy: "no-referrer",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify(payload),
51
+ signal,
52
+ }).then((result) => {
53
+ if (!result.ok) throw new Error(`Images import returned ${result.status}`);
54
+ });
55
+ }
56
+
57
+ function sourceKind() {
58
+ const selectedConversation = document.querySelector?.('[role="button"][aria-current="page"]');
59
+ const selectedLabel = selectedConversation?.textContent?.trim() ?? "";
60
+ if (/work$/i.test(selectedLabel)) return "work";
61
+ if (/chat$/i.test(selectedLabel)) return "chat";
62
+ const active = Array.from(document.querySelectorAll('[aria-selected="true"], [data-state="active"]'))
63
+ .map((element) => `${element.getAttribute("aria-label") ?? ""} ${element.textContent ?? ""}`)
64
+ .join(" ");
65
+ const context = `${window.location.pathname} ${active}`.toLowerCase();
66
+ if (/\bwork\b/.test(context)) return "work";
67
+ if (/\bchat\b/.test(context)) return "chat";
68
+ return null;
69
+ }
70
+
71
+ function selectedConversationTitle() {
72
+ const label = document.querySelector?.('[role="button"][aria-current="page"]')?.textContent?.trim() ?? "";
73
+ return label.replace(/(?:chat|work)$/i, "").trim() || null;
74
+ }
75
+
76
+ function generatedGalleryMetadata(element) {
77
+ const preview = element.closest?.('[data-testid="generated-image-preview"]');
78
+ const gallery = preview?.closest?.('[data-testid="generated-image-gallery"]');
79
+ if (!gallery) return null;
80
+
81
+ const previews = Array.from(gallery.querySelectorAll?.('[data-testid="generated-image-preview"] img') ?? []);
82
+ const imageIndex = previews.indexOf(element);
83
+ if (imageIndex < 0) return null;
84
+
85
+ const fiberKey = Object.getOwnPropertyNames(gallery).find((key) => key.startsWith("__reactFiber$"));
86
+ let fiber = fiberKey ? gallery[fiberKey] : null;
87
+ for (let depth = 0; fiber && depth < 8; depth += 1, fiber = fiber.return) {
88
+ for (const props of [fiber.pendingProps, fiber.memoizedProps]) {
89
+ if (!props || !Array.isArray(props.images) || typeof props.conversationId !== "string") continue;
90
+ const image = props.images[imageIndex];
91
+ const itemId = typeof image?.id === "string" ? image.id : null;
92
+ if (!itemId || typeof image.src !== "string" || !image.src.startsWith("sediment://file_")) return null;
93
+ return {
94
+ itemId,
95
+ conversationId: props.conversationId,
96
+ conversationTitle: selectedConversationTitle(),
97
+ };
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function closestValue(element, attribute) {
104
+ return element.closest?.(`[${attribute}]`)?.getAttribute(attribute)?.trim() || null;
105
+ }
106
+
107
+ function descriptor(element) {
108
+ const source = element.currentSrc || element.src || "";
109
+ let protocol;
110
+ try { protocol = new URL(source, window.location.href).protocol.slice(0, -1); } catch { protocol = null; }
111
+ const gallery = generatedGalleryMetadata(element);
112
+ if (gallery && new Set(["https", "blob", "data"]).has(protocol)) {
113
+ return {
114
+ source,
115
+ ...gallery,
116
+ sourceKind: sourceKind(),
117
+ prompt: null,
118
+ createdAt: null,
119
+ };
120
+ }
121
+ const marker = MARKER_SELECTORS.some((selector) => element.matches?.(selector) || element.closest?.(selector));
122
+ const assistant = Boolean(element.closest?.('[data-message-author-role="assistant"], [data-message-author-role="tool"]'));
123
+ const itemId = element.dataset.imageId
124
+ || element.dataset.itemId
125
+ || closestValue(element, "data-image-id")
126
+ || closestValue(element, "data-item-id")
127
+ || closestValue(element, "data-message-id");
128
+ if (!marker || !assistant || !itemId || itemId.length > 256 || /[\\/\u0000]/.test(itemId)) return null;
129
+ if (!new Set(["https", "blob", "data"]).has(protocol)) return null;
130
+ return {
131
+ source,
132
+ itemId,
133
+ sourceKind: sourceKind(),
134
+ conversationId: closestValue(element, "data-conversation-id"),
135
+ conversationTitle: closestValue(element, "data-conversation-title"),
136
+ prompt: element.getAttribute("data-image-prompt") || null,
137
+ createdAt: element.getAttribute("data-created-at") || null,
138
+ };
139
+ }
140
+
141
+ function base64(bytes) {
142
+ let output = "";
143
+ const chunkSize = 0x8000;
144
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
145
+ output += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
146
+ }
147
+ return window.btoa(output);
148
+ }
149
+
150
+ async function imagePayload(image, element, signal) {
151
+ try {
152
+ const response = await window.fetch(image.source, {
153
+ credentials: "same-origin",
154
+ redirect: "error",
155
+ referrerPolicy: "no-referrer",
156
+ signal,
157
+ });
158
+ if (!response.ok) throw new Error(`generated image fetch returned ${response.status}`);
159
+ const mimeType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || null;
160
+ if (!mimeType) throw new Error("generated image response has no Content-Type");
161
+ const length = Number(response.headers.get("content-length") || 0);
162
+ if (length > MAX_BYTES) throw new Error("generated image exceeds the import limit");
163
+ return { mimeType, bytes: new Uint8Array(await response.arrayBuffer()) };
164
+ } catch (error) {
165
+ if (!image.source.startsWith("blob:") || !element?.complete || !element.naturalWidth) throw error;
166
+ const canvas = document.createElement("canvas");
167
+ canvas.width = element.naturalWidth;
168
+ canvas.height = element.naturalHeight;
169
+ canvas.getContext("2d").drawImage(element, 0, 0);
170
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
171
+ if (!blob) throw error;
172
+ return { mimeType: "image/png", bytes: new Uint8Array(await blob.arrayBuffer()) };
173
+ }
174
+ }
175
+
176
+ async function importImage(image, element, signal) {
177
+ const { mimeType, bytes } = await imagePayload(image, element, signal);
178
+ if (!bytes.length || bytes.length > MAX_BYTES) throw new Error("generated image exceeds the import limit");
179
+ const payload = {
180
+ ...image,
181
+ mimeType,
182
+ bytesBase64: base64(bytes),
183
+ };
184
+ delete payload.source;
185
+ await sendImport(payload);
186
+ }
187
+
188
+ const seen = new Set();
189
+ const inFlight = new Set();
190
+ const scan = () => {
191
+ if (!sourceKind()) return;
192
+ for (const element of document.querySelectorAll("img")) {
193
+ const image = descriptor(element);
194
+ if (!image || !image.sourceKind || seen.has(image.itemId) || inFlight.has(image.itemId)) continue;
195
+ inFlight.add(image.itemId);
196
+ void importImage(image, element, signal)
197
+ .then(() => seen.add(image.itemId))
198
+ .catch(() => {})
199
+ .finally(() => inFlight.delete(image.itemId));
200
+ }
201
+ };
202
+
203
+ const observer = new MutationObserver(scan);
204
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "srcset", "data-image-id", "data-item-id"] });
205
+ scan();
206
+ signal.addEventListener("abort", () => observer.disconnect(), { once: true });
207
+ return () => observer.disconnect();
@@ -0,0 +1,42 @@
1
+ const SUPPORTED_SURFACES = new Set(["chat", "work"]);
2
+
3
+ export function sourceKindForSurface({ pathname = "", activeLabel = "" } = {}) {
4
+ const value = `${pathname} ${activeLabel}`.toLowerCase();
5
+ if (/\bwork\b/.test(value)) return "work";
6
+ if (/\bchat\b/.test(value)) return "chat";
7
+ return null;
8
+ }
9
+
10
+ export function classifyGeneratedImage({
11
+ hasGenerationMarker = false,
12
+ isAssistantResult = false,
13
+ itemId = null,
14
+ sourceProtocol = null,
15
+ } = {}) {
16
+ if (!hasGenerationMarker || !isAssistantResult) return { accepted: false, reason: "NOT_GENERATED_RESULT" };
17
+ if (!itemId || typeof itemId !== "string" || itemId.length > 256 || /[\\/\u0000]/.test(itemId)) {
18
+ return { accepted: false, reason: "MISSING_STABLE_ITEM_ID" };
19
+ }
20
+ if (!new Set(["https", "blob", "data"]).has(sourceProtocol)) {
21
+ return { accepted: false, reason: "UNSUPPORTED_SOURCE" };
22
+ }
23
+ return { accepted: true };
24
+ }
25
+
26
+ export function buildImportMetadata({
27
+ sourceKind,
28
+ itemId,
29
+ conversationId = null,
30
+ conversationTitle = null,
31
+ prompt = null,
32
+ createdAt = null,
33
+ } = {}) {
34
+ return {
35
+ sourceKind: SUPPORTED_SURFACES.has(sourceKind) ? sourceKind : null,
36
+ itemId: typeof itemId === "string" ? itemId.slice(0, 256) : null,
37
+ conversationId: typeof conversationId === "string" ? conversationId.slice(0, 256) : null,
38
+ conversationTitle: typeof conversationTitle === "string" ? conversationTitle.slice(0, 512) : null,
39
+ prompt: typeof prompt === "string" ? prompt.slice(0, 8_192) : null,
40
+ createdAt: typeof createdAt === "string" ? createdAt.slice(0, 64) : null,
41
+ };
42
+ }
@@ -2,6 +2,7 @@ import { createReadStream } from "node:fs";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { createServer as createNodeServer } from "node:http";
4
4
  import path from "node:path";
5
+ import { MAX_IMPORTED_IMAGE_BYTES } from "./image-library.mjs";
5
6
  import { saveImageAs } from "./save-as.mjs";
6
7
 
7
8
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
@@ -9,6 +10,7 @@ const CODEX_EMBED_ORIGIN = "app://-";
9
10
  const DEFAULT_PAGE_LIMIT = 36;
10
11
  const MAX_PAGE_LIMIT = 72;
11
12
  const MAX_PAGE_OFFSET = Number.MAX_SAFE_INTEGER;
13
+ const MAX_IMPORT_BODY_BYTES = Math.ceil(MAX_IMPORTED_IMAGE_BYTES * 4 / 3) + 64 * 1024;
12
14
 
13
15
  const STATIC_FILES = new Map([
14
16
  ["/", ["index.html", "text/html; charset=utf-8"]],
@@ -41,7 +43,7 @@ function publicImage(image, { includePrompt = false } = {}) {
41
43
  archived: image.archived,
42
44
  fileUrl: `/api/images/${image.id}/file`,
43
45
  downloadUrl: `/api/images/${image.id}/download`,
44
- sourceUrl: `codex://threads/${image.threadId}`,
46
+ sourceUrl: image.threadId.startsWith("chatgpt:") ? null : `codex://threads/${image.threadId}`,
45
47
  };
46
48
  if (includePrompt) result.prompt = image.prompt;
47
49
  return result;
@@ -70,6 +72,82 @@ function mutationError(message, code) {
70
72
  return Object.assign(new Error(message), { status: 403, code });
71
73
  }
72
74
 
75
+ async function readRequestBody(request, maxBytes) {
76
+ const declaredLength = Number(request.headers["content-length"] ?? 0);
77
+ if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
78
+ throw Object.assign(new Error("Request body exceeds the Images import limit"), {
79
+ status: 413,
80
+ code: "BODY_TOO_LARGE",
81
+ });
82
+ }
83
+ const chunks = [];
84
+ let total = 0;
85
+ for await (const chunk of request) {
86
+ total += chunk.length;
87
+ if (total > maxBytes) {
88
+ throw Object.assign(new Error("Request body exceeds the Images import limit"), {
89
+ status: 413,
90
+ code: "BODY_TOO_LARGE",
91
+ });
92
+ }
93
+ chunks.push(chunk);
94
+ }
95
+ return Buffer.concat(chunks, total);
96
+ }
97
+
98
+ async function readImportPayload(request) {
99
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
100
+ throw Object.assign(new Error("Images import requires application/json"), {
101
+ status: 415,
102
+ code: "UNSUPPORTED_CONTENT_TYPE",
103
+ });
104
+ }
105
+ let payload;
106
+ try {
107
+ payload = JSON.parse((await readRequestBody(request, MAX_IMPORT_BODY_BYTES)).toString("utf8"));
108
+ } catch (error) {
109
+ if (error.code === "BODY_TOO_LARGE") throw error;
110
+ throw Object.assign(new Error("Images import body must be valid JSON"), {
111
+ status: 400,
112
+ code: "INVALID_JSON",
113
+ });
114
+ }
115
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
116
+ throw Object.assign(new Error("Images import body must be an object"), {
117
+ status: 400,
118
+ code: "INVALID_BODY",
119
+ });
120
+ }
121
+ if (typeof payload.bytesBase64 !== "string" || payload.bytesBase64.length > MAX_IMPORT_BODY_BYTES) {
122
+ throw Object.assign(new Error("Images import requires bounded base64 image bytes"), {
123
+ status: 400,
124
+ code: "INVALID_IMAGE_BYTES",
125
+ });
126
+ }
127
+ const bytes = Buffer.from(payload.bytesBase64, "base64");
128
+ if (!bytes.length || bytes.toString("base64") !== payload.bytesBase64.replace(/\s/g, "")) {
129
+ throw Object.assign(new Error("Images import image bytes are not valid base64"), {
130
+ status: 400,
131
+ code: "INVALID_IMAGE_BYTES",
132
+ });
133
+ }
134
+ return { ...payload, bytes };
135
+ }
136
+
137
+ function contentTypeForPath(sourcePath) {
138
+ const extension = path.extname(sourcePath).toLowerCase();
139
+ return extension === ".jpg" || extension === ".jpeg"
140
+ ? "image/jpeg"
141
+ : extension === ".webp" ? "image/webp" : "image/png";
142
+ }
143
+
144
+ function downloadFilename(image, sourcePath) {
145
+ const stem = String(image.imageGenerationItemId ?? "generated-image")
146
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
147
+ .slice(0, 128) || "generated-image";
148
+ return `${stem}${path.extname(sourcePath)}`;
149
+ }
150
+
73
151
  function applyCodexEmbedCors(request, response) {
74
152
  if (request.headers.origin !== CODEX_EMBED_ORIGIN) return false;
75
153
  response.setHeader("Access-Control-Allow-Origin", CODEX_EMBED_ORIGIN);
@@ -235,6 +313,17 @@ export async function createHttpServer({
235
313
  return;
236
314
  }
237
315
 
316
+ if (request.method === "POST" && url.pathname === "/api/import-generated-image") {
317
+ const payload = await readImportPayload(request);
318
+ const result = await library.importExternalImage({ ...payload, bytes: payload.bytes });
319
+ if (!result.imported && result.reason !== "DUPLICATE") {
320
+ json(response, 400, { ...result, imported: false });
321
+ return;
322
+ }
323
+ json(response, result.reason === "DUPLICATE" ? 200 : 201, result);
324
+ return;
325
+ }
326
+
238
327
  const saveAsMatch = url.pathname.match(/^\/api\/images\/([a-f0-9]{24})\/save-as$/);
239
328
  if (request.method === "POST" && saveAsMatch) {
240
329
  const image = library.get(saveAsMatch[1]);
@@ -266,12 +355,12 @@ export async function createHttpServer({
266
355
  }
267
356
  const sourceStat = await stat(image.sourcePath);
268
357
  const headers = {
269
- "Content-Type": "image/png",
358
+ "Content-Type": contentTypeForPath(image.sourcePath),
270
359
  "Content-Length": sourceStat.size,
271
360
  "Cache-Control": "public, max-age=31536000, immutable",
272
361
  };
273
362
  if (imageMatch[2] === "download") {
274
- headers["Content-Disposition"] = `attachment; filename="${image.imageGenerationItemId}.png"`;
363
+ headers["Content-Disposition"] = `attachment; filename="${downloadFilename(image, image.sourcePath)}"`;
275
364
  }
276
365
  response.writeHead(200, headers);
277
366
  createReadStream(image.sourcePath).pipe(response);
@@ -2,10 +2,14 @@ import { watch } from "node:fs";
2
2
  import {
3
3
  mkdir,
4
4
  open,
5
+ readFile,
5
6
  readdir,
7
+ rename,
6
8
  stat,
9
+ unlink,
10
+ writeFile,
7
11
  } from "node:fs/promises";
8
- import { createHash } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
9
13
  import path from "node:path";
10
14
  import { DatabaseSync } from "node:sqlite";
11
15
  import { ThreadPromptIndex } from "./prompt-index.mjs";
@@ -13,6 +17,8 @@ import { ThreadPromptIndex } from "./prompt-index.mjs";
13
17
  const PNG_EXTENSION = ".png";
14
18
  const FALLBACK_RESCAN_INITIAL_DELAY_MS = 30_000;
15
19
  const FALLBACK_RESCAN_MAX_DELAY_MS = 5 * 60_000;
20
+ export const MAX_IMPORTED_IMAGE_BYTES = 32 * 1024 * 1024;
21
+ export const MAX_IMPORTED_IMAGE_DIMENSION = 16_384;
16
22
 
17
23
  function isPng(filePath) {
18
24
  return path.extname(filePath).toLowerCase() === PNG_EXTENSION;
@@ -31,8 +37,8 @@ function buildWhere({ query = "", source = "all", threadId = null } = {}) {
31
37
  parameters.push(threadId);
32
38
  }
33
39
 
34
- if (source === "chat") {
35
- clauses.push("thread_sources.source_kind = 'chat'");
40
+ if (source === "chat" || source === "work") {
41
+ clauses.push(`thread_sources.source_kind = '${source}'`);
36
42
  } else if (source.startsWith("project:")) {
37
43
  clauses.push("thread_sources.source_kind = 'project' AND thread_sources.project_id = ?");
38
44
  parameters.push(source.slice("project:".length));
@@ -76,6 +82,53 @@ async function readPngSize(filePath) {
76
82
  }
77
83
  }
78
84
 
85
+ function imageSizeFromBuffer(bytes, mimeType) {
86
+ if (mimeType === "image/png") {
87
+ if (bytes.length < 24 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") return null;
88
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
89
+ }
90
+ if (mimeType === "image/jpeg") {
91
+ if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return null;
92
+ let offset = 2;
93
+ while (offset + 9 < bytes.length) {
94
+ if (bytes[offset] !== 0xff) return null;
95
+ const marker = bytes[offset + 1];
96
+ offset += 2;
97
+ if (marker === 0xd8 || marker === 0xd9) continue;
98
+ if (offset + 2 > bytes.length) return null;
99
+ const segmentLength = bytes.readUInt16BE(offset);
100
+ if (segmentLength < 2 || offset + segmentLength > bytes.length) return null;
101
+ if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7)
102
+ || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
103
+ if (segmentLength < 7) return null;
104
+ return { width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) };
105
+ }
106
+ offset += segmentLength;
107
+ }
108
+ return null;
109
+ }
110
+ if (mimeType === "image/webp") {
111
+ if (bytes.length < 30 || bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") {
112
+ return null;
113
+ }
114
+ if (bytes.toString("ascii", 12, 16) !== "VP8X") return null;
115
+ return {
116
+ width: 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16),
117
+ height: 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16),
118
+ };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function normalizedImportMime(mimeType) {
124
+ const normalized = String(mimeType ?? "").split(";", 1)[0].trim().toLowerCase();
125
+ return new Set(["image/png", "image/jpeg", "image/webp"]).has(normalized) ? normalized : null;
126
+ }
127
+
128
+ function extensionForMime(mimeType) {
129
+ return mimeType === "image/jpeg" ? "jpg" : mimeType.slice("image/".length);
130
+ }
131
+
79
132
  async function collectPngFiles(directory, output = []) {
80
133
  let entries;
81
134
  try {
@@ -282,6 +335,12 @@ export class ImageLibrary {
282
335
  JOIN thread_sources ON thread_sources.thread_id = images.thread_id
283
336
  WHERE thread_sources.source_kind = 'chat'
284
337
  `).get().count;
338
+ const works = this.database.prepare(`
339
+ SELECT COUNT(*) AS count
340
+ FROM images
341
+ JOIN thread_sources ON thread_sources.thread_id = images.thread_id
342
+ WHERE thread_sources.source_kind = 'work'
343
+ `).get().count;
285
344
  const projects = this.database.prepare(`
286
345
  SELECT thread_sources.project_name,
287
346
  COUNT(*) AS count, MAX(images.created_at) AS latest_created_at
@@ -296,7 +355,7 @@ export class ImageLibrary {
296
355
  count: row.count,
297
356
  latestCreatedAt: row.latest_created_at,
298
357
  }));
299
- return { total: this.count(), chats, projects };
358
+ return { total: this.count(), chats, works, projects };
300
359
  }
301
360
 
302
361
  countGroups({ source = "all" } = {}) {
@@ -562,6 +621,114 @@ export class ImageLibrary {
562
621
  return true;
563
622
  }
564
623
 
624
+ async importExternalImage({
625
+ bytes,
626
+ mimeType,
627
+ itemId,
628
+ conversationId = null,
629
+ conversationTitle = null,
630
+ sourceKind,
631
+ prompt = null,
632
+ createdAt = null,
633
+ } = {}) {
634
+ if (!this.isLifecycleActive() || !Buffer.isBuffer(bytes)) return { imported: false, reason: "INVALID_BYTES" };
635
+ const normalizedMime = normalizedImportMime(mimeType);
636
+ if (!normalizedMime) return { imported: false, reason: "UNSUPPORTED_MIME" };
637
+ if (bytes.length === 0 || bytes.length > MAX_IMPORTED_IMAGE_BYTES) {
638
+ return { imported: false, reason: "IMAGE_TOO_LARGE" };
639
+ }
640
+ const size = imageSizeFromBuffer(bytes, normalizedMime);
641
+ if (!size || size.width < 1 || size.height < 1
642
+ || size.width > MAX_IMPORTED_IMAGE_DIMENSION || size.height > MAX_IMPORTED_IMAGE_DIMENSION) {
643
+ return { imported: false, reason: "INVALID_DIMENSIONS" };
644
+ }
645
+ if (!new Set(["chat", "work"]).has(sourceKind)) {
646
+ return { imported: false, reason: "INVALID_SOURCE_KIND" };
647
+ }
648
+ const safeItemId = String(itemId ?? "").trim();
649
+ if (!safeItemId || safeItemId.length > 256 || /[\\/\u0000]/.test(safeItemId)) {
650
+ return { imported: false, reason: "INVALID_ITEM_ID" };
651
+ }
652
+ const safeConversationId = conversationId == null ? null : String(conversationId).trim().slice(0, 256) || null;
653
+ const safeTitle = conversationTitle == null ? "" : String(conversationTitle).trim().slice(0, 512);
654
+ const safePrompt = prompt == null ? null : String(prompt).trim().slice(0, 8_192) || null;
655
+ const logicalThreadId = safeConversationId?.startsWith("chatgpt:")
656
+ ? safeConversationId
657
+ : `chatgpt:${sourceKind}:${safeConversationId ?? "unknown"}`;
658
+ const id = imageIdFor(`external:${sourceKind}:${safeConversationId ?? ""}:${safeItemId}`);
659
+ const extension = extensionForMime(normalizedMime);
660
+ const assetDir = path.join(this.dataDir, "external-images");
661
+ const sourcePath = path.join(assetDir, `${id}.${extension}`);
662
+ const existing = this.database.prepare("SELECT source_path, byte_length FROM images WHERE id = ?").get(id);
663
+ if (existing && existing.byte_length === bytes.length) {
664
+ try {
665
+ const existingBytes = await readFile(existing.source_path);
666
+ if (existingBytes.equals(bytes)) return { imported: false, id, reason: "DUPLICATE" };
667
+ } catch (error) {
668
+ if (error.code !== "ENOENT") throw error;
669
+ }
670
+ }
671
+ await mkdir(assetDir, { recursive: true });
672
+ const temporaryPath = path.join(assetDir, `.${id}.${randomUUID()}.tmp`);
673
+ try {
674
+ await writeFile(temporaryPath, bytes, { flag: "wx", mode: 0o600 });
675
+ await rename(temporaryPath, sourcePath);
676
+ } finally {
677
+ await unlink(temporaryPath).catch((error) => {
678
+ if (error.code !== "ENOENT") throw error;
679
+ });
680
+ }
681
+ const importedAt = new Date().toISOString();
682
+ const normalizedCreatedAt = createdAt && !Number.isNaN(Date.parse(createdAt))
683
+ ? new Date(createdAt).toISOString()
684
+ : importedAt;
685
+ this.database
686
+ .prepare(`
687
+ INSERT INTO images (
688
+ id, thread_id, image_generation_item_id, source_path,
689
+ prompt, width, height, byte_length, source_mtime_ms,
690
+ created_at, imported_at
691
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
692
+ ON CONFLICT(id) DO UPDATE SET
693
+ source_path = excluded.source_path,
694
+ prompt = excluded.prompt,
695
+ width = excluded.width,
696
+ height = excluded.height,
697
+ byte_length = excluded.byte_length,
698
+ source_mtime_ms = excluded.source_mtime_ms,
699
+ created_at = excluded.created_at,
700
+ imported_at = excluded.imported_at
701
+ `)
702
+ .run(
703
+ id,
704
+ logicalThreadId,
705
+ safeItemId,
706
+ sourcePath,
707
+ safePrompt,
708
+ size.width,
709
+ size.height,
710
+ bytes.length,
711
+ 0,
712
+ normalizedCreatedAt,
713
+ importedAt,
714
+ );
715
+ this.database
716
+ .prepare(`
717
+ INSERT INTO thread_sources (
718
+ thread_id, source_kind, project_id, project_name, thread_title, archived, updated_at
719
+ ) VALUES (?, ?, NULL, NULL, ?, 0, ?)
720
+ ON CONFLICT(thread_id) DO UPDATE SET
721
+ source_kind = excluded.source_kind,
722
+ thread_title = CASE
723
+ WHEN excluded.thread_title <> '' THEN excluded.thread_title
724
+ ELSE thread_sources.thread_title
725
+ END,
726
+ updated_at = excluded.updated_at
727
+ `)
728
+ .run(logicalThreadId, sourceKind, safeTitle, importedAt);
729
+ return { imported: true, id };
730
+ }
731
+
565
732
  close() {
566
733
  this.closed = true;
567
734
  this.lifecycleVersion += 1;