clay-server 3.7.0 → 3.8.0-beta.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.
- package/lib/os-users.js +17 -1
- package/lib/project-http.js +34 -1
- package/lib/project-image.js +87 -1
- package/lib/project.js +2 -1
- package/lib/public/css/generated-images.css +242 -0
- package/lib/public/index.html +1 -1
- package/lib/public/modules/generated-images.js +211 -2
- package/lib/public/style.css +1 -1
- package/package.json +1 -1
package/lib/os-users.js
CHANGED
|
@@ -98,7 +98,7 @@ function resolveOsUserInfo(username) {
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* Run a file system operation as a specific OS user via a helper subprocess.
|
|
101
|
-
* op: "list", "read", "write", "stat"
|
|
101
|
+
* op: "list", "read", "write", "read_binary", "mkdir", "copy", "stat"
|
|
102
102
|
* args: operation-specific arguments
|
|
103
103
|
* osUserInfo: { uid, gid } from resolveOsUserInfo
|
|
104
104
|
* Returns the result object or throws.
|
|
@@ -164,6 +164,22 @@ function fsAsUser(op, args, osUserInfo) {
|
|
|
164
164
|
"fs.writeFileSync(f, content, 'utf8');",
|
|
165
165
|
"process.stdout.write(JSON.stringify({ ok: true }));",
|
|
166
166
|
].join(" ");
|
|
167
|
+
} else if (op === "mkdir") {
|
|
168
|
+
script = [
|
|
169
|
+
"var fs = require('fs');",
|
|
170
|
+
"var dir = " + JSON.stringify(args.dir) + ";",
|
|
171
|
+
"fs.mkdirSync(dir, { recursive: true });",
|
|
172
|
+
"process.stdout.write(JSON.stringify({ ok: true }));",
|
|
173
|
+
].join(" ");
|
|
174
|
+
} else if (op === "copy") {
|
|
175
|
+
script = [
|
|
176
|
+
"var fs = require('fs');",
|
|
177
|
+
"var source = " + JSON.stringify(args.source) + ";",
|
|
178
|
+
"var file = " + JSON.stringify(args.file) + ";",
|
|
179
|
+
"var overwrite = " + JSON.stringify(!!args.overwrite) + ";",
|
|
180
|
+
"fs.copyFileSync(source, file, overwrite ? 0 : fs.constants.COPYFILE_EXCL);",
|
|
181
|
+
"process.stdout.write(JSON.stringify({ ok: true }));",
|
|
182
|
+
].join(" ");
|
|
167
183
|
} else {
|
|
168
184
|
throw new Error("Unknown fsAsUser operation: " + op);
|
|
169
185
|
}
|
package/lib/project-http.js
CHANGED
|
@@ -41,7 +41,7 @@ function parseJsonBody(req) {
|
|
|
41
41
|
* ctx fields:
|
|
42
42
|
* cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule,
|
|
43
43
|
* safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny,
|
|
44
|
-
* _extToken, _browserTabList
|
|
44
|
+
* saveGeneratedImageToProject, _extToken, _browserTabList
|
|
45
45
|
*/
|
|
46
46
|
function attachHTTP(ctx) {
|
|
47
47
|
var cwd = ctx.cwd;
|
|
@@ -51,6 +51,7 @@ function attachHTTP(ctx) {
|
|
|
51
51
|
var gitAttribution = ctx.gitAttribution;
|
|
52
52
|
var send = ctx.send;
|
|
53
53
|
var imagesDir = ctx.imagesDir;
|
|
54
|
+
var saveGeneratedImageToProject = ctx.saveGeneratedImageToProject;
|
|
54
55
|
var osUsers = ctx.osUsers;
|
|
55
56
|
var pushModule = ctx.pushModule;
|
|
56
57
|
var safePath = ctx.safePath;
|
|
@@ -122,6 +123,38 @@ function attachHTTP(ctx) {
|
|
|
122
123
|
return true;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
// Copy a generated image from Clay's session storage into the project.
|
|
127
|
+
if (req.method === "POST" && urlPath === "/api/generated-image/save") {
|
|
128
|
+
if (usersModule.isMultiUser()) {
|
|
129
|
+
var imageSaveUser = req._clayUser;
|
|
130
|
+
var imageSavePermissions = imageSaveUser ? usersModule.getEffectivePermissions(imageSaveUser, osUsers) : null;
|
|
131
|
+
if (!imageSavePermissions || !imageSavePermissions.fileBrowser) {
|
|
132
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
133
|
+
res.end('{"error":"File browser access is not permitted"}');
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
parseJsonBody(req).then(function (body) {
|
|
138
|
+
if (typeof saveGeneratedImageToProject !== "function") {
|
|
139
|
+
res.writeHead(501, { "Content-Type": "application/json" });
|
|
140
|
+
res.end('{"error":"Generated image saving is not available"}');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
var result = saveGeneratedImageToProject(
|
|
144
|
+
body.fileName,
|
|
145
|
+
body.path,
|
|
146
|
+
!!body.overwrite,
|
|
147
|
+
getOsUserInfoForReq(req)
|
|
148
|
+
);
|
|
149
|
+
res.writeHead(result.ok ? 200 : (result.status || 500), { "Content-Type": "application/json" });
|
|
150
|
+
res.end(JSON.stringify(result));
|
|
151
|
+
}).catch(function () {
|
|
152
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
153
|
+
res.end('{"error":"Invalid JSON body"}');
|
|
154
|
+
});
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
125
158
|
// File upload
|
|
126
159
|
if (req.method === "POST" && urlPath === "/api/upload") {
|
|
127
160
|
parseJsonBody(req).then(function (body) {
|
package/lib/project-image.js
CHANGED
|
@@ -12,13 +12,98 @@ var execFileSync = require("child_process").execFileSync;
|
|
|
12
12
|
function attachImage(ctx) {
|
|
13
13
|
var cwd = ctx.cwd;
|
|
14
14
|
var slug = ctx.slug;
|
|
15
|
+
var fsAsUser = ctx.fsAsUser;
|
|
15
16
|
|
|
16
17
|
// --- Chat image storage ---
|
|
17
18
|
var _imgConfig = require("./config");
|
|
18
19
|
var _imgUtils = require("./utils");
|
|
19
20
|
var _imagesBaseDir = path.join(_imgConfig.CONFIG_DIR, "images");
|
|
20
21
|
var _imagesEncodedCwd = _imgUtils.encodeCwd(cwd);
|
|
21
|
-
var imagesDir = path.join(_imagesBaseDir, _imagesEncodedCwd);
|
|
22
|
+
var imagesDir = ctx.imagesDir || path.join(_imagesBaseDir, _imagesEncodedCwd);
|
|
23
|
+
|
|
24
|
+
function isWithin(base, candidate) {
|
|
25
|
+
return candidate === base || candidate.indexOf(base + path.sep) === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveGeneratedImageToProject(fileName, requestedPath, overwrite, osUserInfo) {
|
|
29
|
+
if (typeof fileName !== "string" || !/^\d+-[a-f0-9]+\.\w+$/.test(fileName)) {
|
|
30
|
+
return { ok: false, status: 400, error: "Invalid generated image" };
|
|
31
|
+
}
|
|
32
|
+
if (typeof requestedPath !== "string" || !requestedPath.trim() || requestedPath.length > 500) {
|
|
33
|
+
return { ok: false, status: 400, error: "Enter a project path" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var sourcePath = path.join(imagesDir, fileName);
|
|
37
|
+
var sourceExt = path.extname(fileName).toLowerCase();
|
|
38
|
+
var targetPath = requestedPath.trim();
|
|
39
|
+
if (path.isAbsolute(targetPath)) {
|
|
40
|
+
return { ok: false, status: 400, error: "Use a project-relative path" };
|
|
41
|
+
}
|
|
42
|
+
var targetExt = path.extname(targetPath).toLowerCase();
|
|
43
|
+
if (targetExt !== sourceExt) {
|
|
44
|
+
return { ok: false, status: 400, error: "Keep the " + sourceExt + " file extension" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var projectRoot;
|
|
48
|
+
var targetAbs;
|
|
49
|
+
try {
|
|
50
|
+
projectRoot = fs.realpathSync(cwd);
|
|
51
|
+
targetAbs = path.resolve(projectRoot, targetPath);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { ok: false, status: 400, error: "Project path is not available" };
|
|
54
|
+
}
|
|
55
|
+
if (targetAbs === projectRoot || !isWithin(projectRoot, targetAbs)) {
|
|
56
|
+
return { ok: false, status: 403, error: "Path must stay inside the project" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
var sourceReal = fs.realpathSync(sourcePath);
|
|
61
|
+
var imagesRoot = fs.realpathSync(imagesDir);
|
|
62
|
+
if (!isWithin(imagesRoot, sourceReal) || !fs.statSync(sourceReal).isFile()) {
|
|
63
|
+
return { ok: false, status: 404, error: "Generated image not found" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var parentDir = path.dirname(targetAbs);
|
|
67
|
+
var existingParent = parentDir;
|
|
68
|
+
while (!fs.existsSync(existingParent) && existingParent !== projectRoot) {
|
|
69
|
+
existingParent = path.dirname(existingParent);
|
|
70
|
+
}
|
|
71
|
+
var realExistingParent = fs.realpathSync(existingParent);
|
|
72
|
+
if (!isWithin(projectRoot, realExistingParent)) {
|
|
73
|
+
return { ok: false, status: 403, error: "Path must stay inside the project" };
|
|
74
|
+
}
|
|
75
|
+
if (osUserInfo && typeof fsAsUser === "function") {
|
|
76
|
+
fsAsUser("mkdir", { dir: parentDir }, osUserInfo);
|
|
77
|
+
} else {
|
|
78
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
var realParent = fs.realpathSync(parentDir);
|
|
81
|
+
if (!isWithin(projectRoot, realParent)) {
|
|
82
|
+
return { ok: false, status: 403, error: "Path must stay inside the project" };
|
|
83
|
+
}
|
|
84
|
+
if (fs.existsSync(targetAbs)) {
|
|
85
|
+
if (fs.lstatSync(targetAbs).isSymbolicLink()) {
|
|
86
|
+
return { ok: false, status: 403, error: "Symbolic link targets cannot be replaced" };
|
|
87
|
+
}
|
|
88
|
+
if (!overwrite) {
|
|
89
|
+
return { ok: false, status: 409, error: "A file already exists at this path" };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (osUserInfo && typeof fsAsUser === "function") {
|
|
94
|
+
fsAsUser("copy", { source: sourceReal, file: targetAbs, overwrite: !!overwrite }, osUserInfo);
|
|
95
|
+
} else {
|
|
96
|
+
fs.copyFileSync(sourceReal, targetAbs, overwrite ? 0 : fs.constants.COPYFILE_EXCL);
|
|
97
|
+
}
|
|
98
|
+
try { fs.chmodSync(targetAbs, 0o644); } catch (e2) {}
|
|
99
|
+
return { ok: true, path: path.relative(projectRoot, targetAbs).split(path.sep).join("/") };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e && e.code === "EEXIST") {
|
|
102
|
+
return { ok: false, status: 409, error: "A file already exists at this path" };
|
|
103
|
+
}
|
|
104
|
+
return { ok: false, status: 500, error: "Could not save the generated image" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
22
107
|
|
|
23
108
|
// Convert imageRefs in history entries to images with URLs for the client
|
|
24
109
|
function hydrateImageRefs(entry) {
|
|
@@ -89,6 +174,7 @@ function attachImage(ctx) {
|
|
|
89
174
|
imagesDir: imagesDir,
|
|
90
175
|
hydrateImageRefs: hydrateImageRefs,
|
|
91
176
|
saveImageFile: saveImageFile,
|
|
177
|
+
saveGeneratedImageToProject: saveGeneratedImageToProject,
|
|
92
178
|
};
|
|
93
179
|
}
|
|
94
180
|
|
package/lib/project.js
CHANGED
|
@@ -187,7 +187,7 @@ function createProjectContext(opts) {
|
|
|
187
187
|
// Do NOT write to .claude-local/settings.json -- the SDK reads that too, causing duplicate spawns.
|
|
188
188
|
|
|
189
189
|
// --- Image engine (delegated to project-image.js) ---
|
|
190
|
-
var _image = attachImage({ cwd: cwd, slug: slug });
|
|
190
|
+
var _image = attachImage({ cwd: cwd, slug: slug, fsAsUser: fsAsUser });
|
|
191
191
|
var imagesDir = _image.imagesDir;
|
|
192
192
|
var hydrateImageRefs = _image.hydrateImageRefs;
|
|
193
193
|
var saveImageFile = _image.saveImageFile;
|
|
@@ -1632,6 +1632,7 @@ function createProjectContext(opts) {
|
|
|
1632
1632
|
gitAttribution: _gitAttribution,
|
|
1633
1633
|
send: send,
|
|
1634
1634
|
imagesDir: imagesDir,
|
|
1635
|
+
saveGeneratedImageToProject: _image.saveGeneratedImageToProject,
|
|
1635
1636
|
osUsers: osUsers,
|
|
1636
1637
|
pushModule: pushModule,
|
|
1637
1638
|
safePath: safePath,
|
|
@@ -168,12 +168,27 @@
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
.generated-image-prompt {
|
|
171
|
+
display: block;
|
|
172
|
+
width: 100%;
|
|
173
|
+
padding: 0;
|
|
174
|
+
border: 0;
|
|
171
175
|
overflow: hidden;
|
|
176
|
+
background: transparent;
|
|
172
177
|
color: var(--text-muted);
|
|
178
|
+
font: inherit;
|
|
173
179
|
font-size: 11px;
|
|
174
180
|
line-height: 1.35;
|
|
181
|
+
text-align: left;
|
|
175
182
|
text-overflow: ellipsis;
|
|
176
183
|
white-space: nowrap;
|
|
184
|
+
cursor: pointer;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.generated-image-prompt:hover {
|
|
188
|
+
color: var(--text-secondary);
|
|
189
|
+
text-decoration: underline;
|
|
190
|
+
text-decoration-color: var(--border);
|
|
191
|
+
text-underline-offset: 3px;
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
.generated-image-actions {
|
|
@@ -209,6 +224,213 @@
|
|
|
209
224
|
height: 14px;
|
|
210
225
|
}
|
|
211
226
|
|
|
227
|
+
.generated-image-details-backdrop {
|
|
228
|
+
position: fixed;
|
|
229
|
+
inset: 0;
|
|
230
|
+
z-index: 10050;
|
|
231
|
+
display: grid;
|
|
232
|
+
place-items: center;
|
|
233
|
+
padding: 24px;
|
|
234
|
+
background: rgba(0, 0, 0, 0.58);
|
|
235
|
+
backdrop-filter: blur(5px);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.generated-image-details {
|
|
239
|
+
display: flex;
|
|
240
|
+
width: min(720px, 100%);
|
|
241
|
+
max-height: min(820px, calc(100dvh - 48px));
|
|
242
|
+
overflow: hidden;
|
|
243
|
+
flex-direction: column;
|
|
244
|
+
border: 1px solid var(--border);
|
|
245
|
+
border-radius: 16px;
|
|
246
|
+
background: var(--bg);
|
|
247
|
+
box-shadow: 0 24px 80px rgba(var(--shadow-rgb), 0.42);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.generated-image-details-header {
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
justify-content: space-between;
|
|
254
|
+
padding: 17px 18px 15px 20px;
|
|
255
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.generated-image-details-header h2 {
|
|
259
|
+
margin: 2px 0 0;
|
|
260
|
+
color: var(--text);
|
|
261
|
+
font-size: 17px;
|
|
262
|
+
line-height: 1.2;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.generated-image-details-eyebrow {
|
|
266
|
+
display: block;
|
|
267
|
+
color: var(--accent);
|
|
268
|
+
font-size: 9px;
|
|
269
|
+
font-weight: 700;
|
|
270
|
+
letter-spacing: 0.12em;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.generated-image-details-close {
|
|
274
|
+
width: 32px;
|
|
275
|
+
padding: 0;
|
|
276
|
+
justify-content: center;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.generated-image-details-close span { display: none; }
|
|
280
|
+
|
|
281
|
+
.generated-image-details-body {
|
|
282
|
+
display: grid;
|
|
283
|
+
grid-template-columns: 190px minmax(0, 1fr);
|
|
284
|
+
gap: 18px;
|
|
285
|
+
padding: 20px;
|
|
286
|
+
overflow-y: auto;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.generated-image-details-preview {
|
|
290
|
+
grid-row: span 2;
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
align-self: start;
|
|
293
|
+
border: 1px solid var(--border-subtle);
|
|
294
|
+
border-radius: 11px;
|
|
295
|
+
background: var(--bg-alt);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.generated-image-details-preview img {
|
|
299
|
+
display: block;
|
|
300
|
+
width: 100%;
|
|
301
|
+
max-height: 280px;
|
|
302
|
+
object-fit: contain;
|
|
303
|
+
cursor: zoom-in;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.generated-image-details-section {
|
|
307
|
+
min-width: 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.generated-image-details-section-header {
|
|
311
|
+
display: flex;
|
|
312
|
+
min-height: 28px;
|
|
313
|
+
align-items: center;
|
|
314
|
+
justify-content: space-between;
|
|
315
|
+
gap: 10px;
|
|
316
|
+
margin-bottom: 7px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.generated-image-details-section h3,
|
|
320
|
+
.generated-image-save-section label {
|
|
321
|
+
margin: 0;
|
|
322
|
+
color: var(--text-secondary);
|
|
323
|
+
font-size: 11px;
|
|
324
|
+
font-weight: 650;
|
|
325
|
+
letter-spacing: 0.02em;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.generated-image-details-section-header .generated-image-action {
|
|
329
|
+
height: 27px;
|
|
330
|
+
padding: 0 7px;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.generated-image-details-prompt {
|
|
334
|
+
max-height: 220px;
|
|
335
|
+
padding: 11px 12px;
|
|
336
|
+
overflow-y: auto;
|
|
337
|
+
border: 1px solid var(--border-subtle);
|
|
338
|
+
border-radius: 9px;
|
|
339
|
+
background: var(--bg-alt);
|
|
340
|
+
color: var(--text-secondary);
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
line-height: 1.55;
|
|
343
|
+
white-space: pre-wrap;
|
|
344
|
+
overflow-wrap: anywhere;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.generated-image-save-section {
|
|
348
|
+
display: flex;
|
|
349
|
+
flex-direction: column;
|
|
350
|
+
gap: 7px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.generated-image-save-section input {
|
|
354
|
+
width: 100%;
|
|
355
|
+
height: 38px;
|
|
356
|
+
padding: 0 11px;
|
|
357
|
+
border: 1px solid var(--border);
|
|
358
|
+
border-radius: 9px;
|
|
359
|
+
outline: none;
|
|
360
|
+
background: var(--input-bg);
|
|
361
|
+
color: var(--text);
|
|
362
|
+
font-family: var(--font-mono);
|
|
363
|
+
font-size: 12px;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.generated-image-save-section input:focus {
|
|
367
|
+
border-color: var(--accent);
|
|
368
|
+
box-shadow: 0 0 0 3px var(--accent-12);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.generated-image-save-hint,
|
|
372
|
+
.generated-image-save-error {
|
|
373
|
+
margin: 0;
|
|
374
|
+
font-size: 10px;
|
|
375
|
+
line-height: 1.4;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.generated-image-save-hint { color: var(--text-dimmer); }
|
|
379
|
+
.generated-image-save-error { color: var(--error); }
|
|
380
|
+
.generated-image-save-error.hidden { display: none; }
|
|
381
|
+
|
|
382
|
+
.generated-image-details-footer {
|
|
383
|
+
display: flex;
|
|
384
|
+
justify-content: flex-end;
|
|
385
|
+
gap: 8px;
|
|
386
|
+
padding: 13px 18px;
|
|
387
|
+
border-top: 1px solid var(--border-subtle);
|
|
388
|
+
background: var(--bg-alt);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.generated-image-secondary-button,
|
|
392
|
+
.generated-image-primary-button {
|
|
393
|
+
display: inline-flex;
|
|
394
|
+
height: 36px;
|
|
395
|
+
align-items: center;
|
|
396
|
+
justify-content: center;
|
|
397
|
+
gap: 7px;
|
|
398
|
+
padding: 0 14px;
|
|
399
|
+
border: 1px solid var(--border);
|
|
400
|
+
border-radius: 9px;
|
|
401
|
+
font: inherit;
|
|
402
|
+
font-size: 12px;
|
|
403
|
+
font-weight: 600;
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.generated-image-secondary-button {
|
|
408
|
+
background: transparent;
|
|
409
|
+
color: var(--text-secondary);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.generated-image-primary-button {
|
|
413
|
+
border-color: var(--accent);
|
|
414
|
+
background: var(--accent);
|
|
415
|
+
color: #fff;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.generated-image-primary-button--replace {
|
|
419
|
+
border-color: var(--warning);
|
|
420
|
+
background: var(--warning);
|
|
421
|
+
color: var(--bg);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.generated-image-primary-button:disabled {
|
|
425
|
+
cursor: wait;
|
|
426
|
+
opacity: 0.6;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.generated-image-primary-button .lucide {
|
|
430
|
+
width: 14px;
|
|
431
|
+
height: 14px;
|
|
432
|
+
}
|
|
433
|
+
|
|
212
434
|
@media (max-width: 560px) {
|
|
213
435
|
.generated-image-row { padding: 0 12px; }
|
|
214
436
|
.generated-image-card { border-radius: 12px; }
|
|
@@ -222,4 +444,24 @@
|
|
|
222
444
|
justify-content: center;
|
|
223
445
|
padding: 0;
|
|
224
446
|
}
|
|
447
|
+
|
|
448
|
+
.generated-image-details-backdrop { padding: 10px; }
|
|
449
|
+
|
|
450
|
+
.generated-image-details {
|
|
451
|
+
max-height: calc(100dvh - 20px);
|
|
452
|
+
border-radius: 13px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.generated-image-details-body {
|
|
456
|
+
grid-template-columns: 1fr;
|
|
457
|
+
gap: 16px;
|
|
458
|
+
padding: 16px;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.generated-image-details-preview {
|
|
462
|
+
grid-row: auto;
|
|
463
|
+
width: min(220px, 100%);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.generated-image-details-preview img { max-height: 220px; }
|
|
225
467
|
}
|
package/lib/public/index.html
CHANGED
|
@@ -2282,7 +2282,7 @@
|
|
|
2282
2282
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
|
|
2283
2283
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
|
|
2284
2284
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
|
|
2285
|
-
<script type="module" src="app.js?v=20260829-
|
|
2285
|
+
<script type="module" src="app.js?v=20260829-image-save-details1"></script>
|
|
2286
2286
|
<div id="pwa-install-modal" class="pwa-modal hidden">
|
|
2287
2287
|
<div class="pwa-modal-backdrop"></div>
|
|
2288
2288
|
<div class="pwa-modal-card">
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
import { refreshIcons, iconHtml } from './icons.js';
|
|
4
4
|
import { showImageModal } from './app-misc.js';
|
|
5
5
|
import { addToMessages, scrollToBottom } from './app-rendering.js';
|
|
6
|
+
import { store } from './store.js';
|
|
7
|
+
import { copyToClipboard, showToast } from './utils.js';
|
|
8
|
+
|
|
9
|
+
var detailsModal = null;
|
|
10
|
+
var detailsEscapeHandler = null;
|
|
6
11
|
|
|
7
12
|
function fileNameFromImage(image) {
|
|
8
13
|
if (image.fileName) return image.fileName;
|
|
@@ -24,6 +29,202 @@ function actionButton(icon, label) {
|
|
|
24
29
|
return button;
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
function closeImageDetails() {
|
|
33
|
+
if (detailsModal) detailsModal.remove();
|
|
34
|
+
if (detailsEscapeHandler) document.removeEventListener('keydown', detailsEscapeHandler);
|
|
35
|
+
detailsModal = null;
|
|
36
|
+
detailsEscapeHandler = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setActionState(button, icon, label) {
|
|
40
|
+
if (!button) return;
|
|
41
|
+
button.innerHTML = iconHtml(icon) + '<span>' + label + '</span>';
|
|
42
|
+
button.title = label;
|
|
43
|
+
button.setAttribute('aria-label', label);
|
|
44
|
+
refreshIcons(button);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveGeneratedImage(image, targetPath, overwrite) {
|
|
48
|
+
var basePath = store.get('basePath') || '';
|
|
49
|
+
return fetch(basePath + 'api/generated-image/save', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
credentials: 'same-origin',
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
fileName: fileNameFromImage(image),
|
|
55
|
+
path: targetPath,
|
|
56
|
+
overwrite: !!overwrite,
|
|
57
|
+
}),
|
|
58
|
+
}).then(function (response) {
|
|
59
|
+
return response.text().then(function (text) {
|
|
60
|
+
var data = {};
|
|
61
|
+
try { data = JSON.parse(text); } catch (e) { data.error = text || 'Could not save image'; }
|
|
62
|
+
data.status = response.status;
|
|
63
|
+
data.ok = response.ok && data.ok !== false;
|
|
64
|
+
return data;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function showGeneratedImageDetails(image, promptText, sourceSaveButton, focusPath) {
|
|
70
|
+
closeImageDetails();
|
|
71
|
+
var prompt = promptText || '';
|
|
72
|
+
var fileName = fileNameFromImage(image);
|
|
73
|
+
var overwrite = false;
|
|
74
|
+
|
|
75
|
+
var backdrop = document.createElement('div');
|
|
76
|
+
backdrop.className = 'generated-image-details-backdrop';
|
|
77
|
+
var modal = document.createElement('section');
|
|
78
|
+
modal.className = 'generated-image-details';
|
|
79
|
+
modal.setAttribute('role', 'dialog');
|
|
80
|
+
modal.setAttribute('aria-modal', 'true');
|
|
81
|
+
modal.setAttribute('aria-labelledby', 'generated-image-details-title');
|
|
82
|
+
|
|
83
|
+
var header = document.createElement('header');
|
|
84
|
+
header.className = 'generated-image-details-header';
|
|
85
|
+
var heading = document.createElement('div');
|
|
86
|
+
var eyebrow = document.createElement('span');
|
|
87
|
+
eyebrow.className = 'generated-image-details-eyebrow';
|
|
88
|
+
eyebrow.textContent = 'IMAGE GENERATION';
|
|
89
|
+
var title = document.createElement('h2');
|
|
90
|
+
title.id = 'generated-image-details-title';
|
|
91
|
+
title.textContent = 'Image details';
|
|
92
|
+
heading.appendChild(eyebrow);
|
|
93
|
+
heading.appendChild(title);
|
|
94
|
+
var closeButton = actionButton('x', 'Close');
|
|
95
|
+
closeButton.classList.add('generated-image-details-close');
|
|
96
|
+
closeButton.addEventListener('click', closeImageDetails);
|
|
97
|
+
header.appendChild(heading);
|
|
98
|
+
header.appendChild(closeButton);
|
|
99
|
+
modal.appendChild(header);
|
|
100
|
+
|
|
101
|
+
var body = document.createElement('div');
|
|
102
|
+
body.className = 'generated-image-details-body';
|
|
103
|
+
var preview = document.createElement('div');
|
|
104
|
+
preview.className = 'generated-image-details-preview';
|
|
105
|
+
var previewImage = document.createElement('img');
|
|
106
|
+
previewImage.src = image.url;
|
|
107
|
+
previewImage.alt = prompt ? 'Generated image: ' + prompt : 'Generated image';
|
|
108
|
+
previewImage.addEventListener('click', function () { showImageModal(image.url); });
|
|
109
|
+
preview.appendChild(previewImage);
|
|
110
|
+
body.appendChild(preview);
|
|
111
|
+
|
|
112
|
+
var promptSection = document.createElement('section');
|
|
113
|
+
promptSection.className = 'generated-image-details-section';
|
|
114
|
+
var promptHeader = document.createElement('div');
|
|
115
|
+
promptHeader.className = 'generated-image-details-section-header';
|
|
116
|
+
var promptLabel = document.createElement('h3');
|
|
117
|
+
promptLabel.textContent = 'Prompt';
|
|
118
|
+
promptHeader.appendChild(promptLabel);
|
|
119
|
+
if (prompt) {
|
|
120
|
+
var copyPrompt = actionButton('copy', 'Copy prompt');
|
|
121
|
+
copyPrompt.addEventListener('click', function () { copyToClipboard(prompt); });
|
|
122
|
+
promptHeader.appendChild(copyPrompt);
|
|
123
|
+
}
|
|
124
|
+
var promptBody = document.createElement('div');
|
|
125
|
+
promptBody.className = 'generated-image-details-prompt';
|
|
126
|
+
promptBody.textContent = prompt || 'Prompt details are not available for this image.';
|
|
127
|
+
promptSection.appendChild(promptHeader);
|
|
128
|
+
promptSection.appendChild(promptBody);
|
|
129
|
+
body.appendChild(promptSection);
|
|
130
|
+
|
|
131
|
+
var saveSection = document.createElement('section');
|
|
132
|
+
saveSection.className = 'generated-image-details-section generated-image-save-section';
|
|
133
|
+
var saveLabel = document.createElement('label');
|
|
134
|
+
saveLabel.htmlFor = 'generated-image-project-path';
|
|
135
|
+
saveLabel.textContent = 'Project path';
|
|
136
|
+
var pathInput = document.createElement('input');
|
|
137
|
+
pathInput.id = 'generated-image-project-path';
|
|
138
|
+
pathInput.type = 'text';
|
|
139
|
+
pathInput.value = 'assets/generated/' + fileName;
|
|
140
|
+
pathInput.spellcheck = false;
|
|
141
|
+
pathInput.autocomplete = 'off';
|
|
142
|
+
var saveHint = document.createElement('p');
|
|
143
|
+
saveHint.className = 'generated-image-save-hint';
|
|
144
|
+
saveHint.textContent = 'The folder will be created inside this project if needed.';
|
|
145
|
+
var saveError = document.createElement('p');
|
|
146
|
+
saveError.className = 'generated-image-save-error hidden';
|
|
147
|
+
saveSection.appendChild(saveLabel);
|
|
148
|
+
saveSection.appendChild(pathInput);
|
|
149
|
+
saveSection.appendChild(saveHint);
|
|
150
|
+
saveSection.appendChild(saveError);
|
|
151
|
+
body.appendChild(saveSection);
|
|
152
|
+
modal.appendChild(body);
|
|
153
|
+
|
|
154
|
+
var footer = document.createElement('footer');
|
|
155
|
+
footer.className = 'generated-image-details-footer';
|
|
156
|
+
var cancelButton = document.createElement('button');
|
|
157
|
+
cancelButton.type = 'button';
|
|
158
|
+
cancelButton.className = 'generated-image-secondary-button';
|
|
159
|
+
cancelButton.textContent = 'Cancel';
|
|
160
|
+
cancelButton.addEventListener('click', closeImageDetails);
|
|
161
|
+
var saveButton = document.createElement('button');
|
|
162
|
+
saveButton.type = 'button';
|
|
163
|
+
saveButton.className = 'generated-image-primary-button';
|
|
164
|
+
saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
|
|
165
|
+
footer.appendChild(cancelButton);
|
|
166
|
+
footer.appendChild(saveButton);
|
|
167
|
+
modal.appendChild(footer);
|
|
168
|
+
backdrop.appendChild(modal);
|
|
169
|
+
document.body.appendChild(backdrop);
|
|
170
|
+
detailsModal = backdrop;
|
|
171
|
+
|
|
172
|
+
function resetOverwrite() {
|
|
173
|
+
overwrite = false;
|
|
174
|
+
saveError.classList.add('hidden');
|
|
175
|
+
saveError.textContent = '';
|
|
176
|
+
saveButton.classList.remove('generated-image-primary-button--replace');
|
|
177
|
+
saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
|
|
178
|
+
refreshIcons(saveButton);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
pathInput.addEventListener('input', resetOverwrite);
|
|
182
|
+
saveButton.addEventListener('click', function () {
|
|
183
|
+
var targetPath = pathInput.value.trim();
|
|
184
|
+
if (!targetPath) {
|
|
185
|
+
saveError.textContent = 'Enter a project path.';
|
|
186
|
+
saveError.classList.remove('hidden');
|
|
187
|
+
pathInput.focus();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
saveButton.disabled = true;
|
|
191
|
+
saveError.classList.add('hidden');
|
|
192
|
+
saveGeneratedImage(image, targetPath, overwrite).then(function (result) {
|
|
193
|
+
saveButton.disabled = false;
|
|
194
|
+
if (result.ok) {
|
|
195
|
+
setActionState(sourceSaveButton, 'check', 'Saved');
|
|
196
|
+
if (sourceSaveButton) sourceSaveButton.disabled = true;
|
|
197
|
+
showToast('Saved to ' + result.path);
|
|
198
|
+
closeImageDetails();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
saveError.textContent = result.error || 'Could not save image.';
|
|
202
|
+
saveError.classList.remove('hidden');
|
|
203
|
+
if (result.status === 409) {
|
|
204
|
+
overwrite = true;
|
|
205
|
+
saveButton.classList.add('generated-image-primary-button--replace');
|
|
206
|
+
saveButton.innerHTML = iconHtml('replace') + '<span>Replace file</span>';
|
|
207
|
+
refreshIcons(saveButton);
|
|
208
|
+
}
|
|
209
|
+
}).catch(function () {
|
|
210
|
+
saveButton.disabled = false;
|
|
211
|
+
saveError.textContent = 'Could not save image.';
|
|
212
|
+
saveError.classList.remove('hidden');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
backdrop.addEventListener('click', function (event) {
|
|
217
|
+
if (event.target === backdrop) closeImageDetails();
|
|
218
|
+
});
|
|
219
|
+
detailsEscapeHandler = function (event) {
|
|
220
|
+
if (event.key === 'Escape') closeImageDetails();
|
|
221
|
+
};
|
|
222
|
+
document.addEventListener('keydown', detailsEscapeHandler);
|
|
223
|
+
refreshIcons(backdrop);
|
|
224
|
+
if (focusPath) pathInput.focus();
|
|
225
|
+
else closeButton.focus();
|
|
226
|
+
}
|
|
227
|
+
|
|
27
228
|
function findProgressRow(toolId) {
|
|
28
229
|
var rows = document.querySelectorAll('.generated-image-row[data-image-tool-id]');
|
|
29
230
|
for (var i = 0; i < rows.length; i++) {
|
|
@@ -99,10 +300,12 @@ export function renderGeneratedImage(msg) {
|
|
|
99
300
|
label.innerHTML = iconHtml('sparkles') + '<span>Generated image</span>';
|
|
100
301
|
meta.appendChild(label);
|
|
101
302
|
if (msg.prompt) {
|
|
102
|
-
var prompt = document.createElement('
|
|
303
|
+
var prompt = document.createElement('button');
|
|
304
|
+
prompt.type = 'button';
|
|
103
305
|
prompt.className = 'generated-image-prompt';
|
|
104
306
|
prompt.textContent = msg.prompt;
|
|
105
|
-
prompt.title =
|
|
307
|
+
prompt.title = 'View full prompt';
|
|
308
|
+
prompt.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, null, false); });
|
|
106
309
|
meta.appendChild(prompt);
|
|
107
310
|
}
|
|
108
311
|
footer.appendChild(meta);
|
|
@@ -112,6 +315,12 @@ export function renderGeneratedImage(msg) {
|
|
|
112
315
|
var openButton = actionButton('maximize-2', 'Open');
|
|
113
316
|
openButton.addEventListener('click', function () { showImageModal(image.url); });
|
|
114
317
|
actions.appendChild(openButton);
|
|
318
|
+
var saveButton = actionButton('save', 'Save');
|
|
319
|
+
saveButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, true); });
|
|
320
|
+
var detailsButton = actionButton('info', 'Details');
|
|
321
|
+
detailsButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, false); });
|
|
322
|
+
actions.appendChild(detailsButton);
|
|
323
|
+
actions.appendChild(saveButton);
|
|
115
324
|
var downloadLink = document.createElement('a');
|
|
116
325
|
downloadLink.className = 'generated-image-action';
|
|
117
326
|
downloadLink.href = image.url;
|
package/lib/public/style.css
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
@import url("css/overlays.css?v=20260827-brand3");
|
|
9
9
|
@import url("css/menus.css");
|
|
10
10
|
@import url("css/messages.css?v=20260828-delegated-muted1");
|
|
11
|
-
@import url("css/generated-images.css?v=
|
|
11
|
+
@import url("css/generated-images.css?v=20260829-save-details1");
|
|
12
12
|
@import url("css/rewind.css");
|
|
13
13
|
@import url("css/input.css?v=20260828-composer-depth1");
|
|
14
14
|
@import url("css/filebrowser.css?v=20260829-terminal-canvas1");
|
package/package.json
CHANGED