minecodex 0.1.20 → 0.2.2
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/features/images/codex-feature.json +5 -0
- package/features/images/src/chatgpt-image-source-page.mjs +207 -0
- package/features/images/src/chatgpt-image-source.mjs +42 -0
- package/features/images/src/http-server.mjs +92 -3
- package/features/images/src/image-library.mjs +171 -4
- package/features/images/web/app.js +35 -13
- package/features/model-slider/codex-feature.json +5 -1
- package/package.json +1 -1
- package/packages/cli/src/chatgpt-launch-coordinator.mjs +27 -1
- package/packages/cli/src/commands.mjs +34 -16
- package/packages/cli/src/install-progress.mjs +56 -0
- package/packages/cli/src/platform.mjs +0 -12
- package/packages/cli/src/runtime-manager.mjs +25 -28
- package/packages/runtime-host/src/codex-runtime.mjs +358 -99
- package/packages/runtime-host/src/feature-registry.mjs +56 -10
- package/packages/runtime-host/src/main.mjs +11 -1
- package/packages/runtime-host/src/model-capabilities.mjs +86 -0
|
@@ -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":
|
|
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.
|
|
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(
|
|
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;
|