@dickpy/dsh-imagegen 1.3.0 → 1.5.0
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/README.md +363 -196
- package/docs/images/ecommerce-mode.png +0 -0
- package/docs/images/image-generation-studio-three-column.png +0 -0
- package/docs/images/imagegen-overview.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/docs/videos/agent-chat-edit.gif +0 -0
- package/docs/videos/agent-chat-edit.mp4 +0 -0
- package/lib/client.js +2589 -884
- package/lib/client.js.map +1 -1
- package/lib/index.js +585 -240
- package/package.json +5 -2
- package/src/agent-image-tools.ts +131 -102
- package/src/client/ImageGenPanel.tsx +2679 -1594
- package/src/client/SettingsCard.tsx +6 -27
- package/src/client/api.ts +11 -1
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/image-toolview.tsx +176 -165
- package/src/client/index.ts +25 -15
- package/src/client/locales.ts +746 -602
- package/src/client/mount.tsx +213 -124
- package/src/client/panel.module.css +2619 -1563
- package/src/client/sidebar-entry.ts +190 -144
- package/src/edit-image-command.ts +110 -0
- package/src/engine.ts +47 -5
- package/src/gallery-store.ts +20 -0
- package/src/generation-runtime.ts +11 -2
- package/src/history-store.ts +26 -0
- package/src/image-models.ts +1 -1
- package/src/index.ts +31 -12
- package/src/model-catalog.ts +19 -2
- package/src/presets.ts +11 -3
- package/src/prompt-enhancer.ts +63 -5
- package/src/protocol.ts +59 -5
- package/src/routes.ts +62 -4
- package/src/task-queue.ts +42 -32
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/docs/images/agent-chat-poster-workflow.png +0 -0
|
@@ -1,1594 +1,2679 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The AI 生图 studio: a three-column layout — left, a card-grouped
|
|
3
|
-
* configuration sidebar (mode tabs, prompt with counter, rounded parameter
|
|
4
|
-
* selectors, model dropdown + generate button); center, the result canvas;
|
|
5
|
-
* right, a persistent generation history column.
|
|
6
|
-
*
|
|
7
|
-
* Controls ride the system UI primitives (@deepseek-ai/dsh-client-ui-primitives,
|
|
8
|
-
* a platform module) so the studio matches the dsh shell look by construction.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { useEffect, useRef, useState } from 'react'
|
|
12
|
-
import { createPortal } from 'react-dom'
|
|
13
|
-
import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** Size
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
} catch {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
window.setTimeout(() => {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if (
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
)
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
<
|
|
1578
|
-
<
|
|
1579
|
-
{
|
|
1580
|
-
</
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The AI 生图 studio: a three-column layout — left, a card-grouped
|
|
3
|
+
* configuration sidebar (mode tabs, prompt with counter, rounded parameter
|
|
4
|
+
* selectors, model dropdown + generate button); center, the result canvas;
|
|
5
|
+
* right, a persistent generation history column.
|
|
6
|
+
*
|
|
7
|
+
* Controls ride the system UI primitives (@deepseek-ai/dsh-client-ui-primitives,
|
|
8
|
+
* a platform module) so the studio matches the dsh shell look by construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
|
|
12
|
+
import { createPortal } from 'react-dom'
|
|
13
|
+
import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
14
|
+
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
15
|
+
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
16
|
+
import type { ImageGenApi } from './api.ts'
|
|
17
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
18
|
+
import { TemplateLibrary } from './TemplateLibrary.tsx'
|
|
19
|
+
import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
|
|
20
|
+
import { AGENT_IMAGE_API } from '../protocol.ts'
|
|
21
|
+
import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
|
|
22
|
+
import { imageModelOptions } from './settings-scope.ts'
|
|
23
|
+
import { normalizeImageModels } from '../image-models.ts'
|
|
24
|
+
import { describeModel } from '../model-catalog.ts'
|
|
25
|
+
import { CHAT_IMAGE_EVENT, type ChatImageEventDetail, type ConversationService } from './conversation-sync.ts'
|
|
26
|
+
import css from './panel.module.css'
|
|
27
|
+
|
|
28
|
+
/** Size options, presented as aspect ratios (auto = let the model decide).
|
|
29
|
+
* The host maps each ratio onto the model's own vocabulary: aspect_ratio for
|
|
30
|
+
* Grok Imagine, the closest pixel size for OpenAI-compatible endpoints. */
|
|
31
|
+
const SIZES = ['auto', '1:1', '3:4', '4:3', '9:16', '2:3', '3:2', '16:9', '21:9'] as const
|
|
32
|
+
|
|
33
|
+
/** Size option keys in the locale dictionary. */
|
|
34
|
+
const SIZE_KEYS: Record<string, 'size.auto' | 'size.square' | 'size.portrait34' | 'size.landscape43' | 'size.portrait916' | 'size.portrait23' | 'size.landscape32' | 'size.wide169' | 'size.ultrawide21'> = {
|
|
35
|
+
auto: 'size.auto',
|
|
36
|
+
'1:1': 'size.square',
|
|
37
|
+
'3:4': 'size.portrait34',
|
|
38
|
+
'4:3': 'size.landscape43',
|
|
39
|
+
'9:16': 'size.portrait916',
|
|
40
|
+
'2:3': 'size.portrait23',
|
|
41
|
+
'3:2': 'size.landscape32',
|
|
42
|
+
'16:9': 'size.wide169',
|
|
43
|
+
'21:9': 'size.ultrawide21',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Quality options, shown as output-resolution tiers (auto = let the model
|
|
47
|
+
* decide). The host maps them: resolution for Grok, quality level for
|
|
48
|
+
* OpenAI-compatible endpoints (1k→low, 2k→medium, 4k→high). */
|
|
49
|
+
const QUALITIES = ['auto', '1k', '2k', '4k'] as const
|
|
50
|
+
|
|
51
|
+
/** Detail options ('' = omit the passthrough). */
|
|
52
|
+
const DETAILS = ['', 'standard', 'high'] as const
|
|
53
|
+
|
|
54
|
+
const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
55
|
+
// The local DSH attachment backend defaults to a 2000px per-side limit. Keep
|
|
56
|
+
// the full-resolution result in the studio, but normalize the conversation
|
|
57
|
+
// copy before it enters the native composer and durable edit staging route.
|
|
58
|
+
const CONVERSATION_IMAGE_MAX_DIMENSION = 2000
|
|
59
|
+
const CONVERSATION_IMAGE_JPEG_QUALITY = 0.9
|
|
60
|
+
const PREVIEW_SCALE_MIN = 0.5
|
|
61
|
+
const PREVIEW_SCALE_MAX = 3
|
|
62
|
+
const PREVIEW_SCALE_STEP = 0.25
|
|
63
|
+
const CONFIG_COLLAPSED_STORAGE_KEY = 'dsh-imagegen-config-collapsed'
|
|
64
|
+
const ECOMMERCE_DRAFT_STORAGE_KEY = 'dsh-imagegen-ecommerce-draft'
|
|
65
|
+
/** Reference roles an uploaded product asset can play (slot selections can
|
|
66
|
+
* also pick 'none'). */
|
|
67
|
+
const ECOMMERCE_ASSET_ROLES = ['product', 'packaging', 'detail', 'style'] as const
|
|
68
|
+
type EcommerceAssetRole = Exclude<EcommerceRefRole, 'none'>
|
|
69
|
+
const MAX_ECOMMERCE_ASSETS = 4
|
|
70
|
+
const ECOMMERCE_ROLE_PROMPT_LABELS: Record<EcommerceAssetRole, string> = {
|
|
71
|
+
product: '商品主体',
|
|
72
|
+
packaging: '包装',
|
|
73
|
+
detail: '细节/角度',
|
|
74
|
+
style: '风格参考',
|
|
75
|
+
}
|
|
76
|
+
/** One uploaded product asset. Session-only: data URLs are far too large for
|
|
77
|
+
* the localStorage draft, so assets never persist across reloads. */
|
|
78
|
+
interface ProductAsset {
|
|
79
|
+
id: string
|
|
80
|
+
dataUrl: string
|
|
81
|
+
name: string
|
|
82
|
+
role: EcommerceAssetRole
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const PRODUCT_SET_SLOTS: ProductSetSlot[] = [
|
|
86
|
+
{ key: 'main', label: '主图', description: '干净背景,突出商品主体', count: 1, enabled: true, refRole: 'product' },
|
|
87
|
+
{ key: 'selling-point', label: '卖点图', description: '用画面展示商品核心卖点', count: 2, enabled: true, refRole: 'product' },
|
|
88
|
+
{ key: 'scene', label: '场景图', description: '真实生活或使用场景', count: 2, enabled: true, refRole: 'product' },
|
|
89
|
+
{ key: 'detail', label: '细节图', description: '材质、结构或工艺特写', count: 1, enabled: true, refRole: 'detail' },
|
|
90
|
+
{ key: 'spec', label: '规格图', description: '尺寸、容量或参数展示', count: 1, enabled: false, refRole: 'product' },
|
|
91
|
+
{ key: 'model', label: '使用图', description: '人物上手或穿戴效果', count: 1, enabled: false, refRole: 'product' },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
/** Legacy pixel sizes saved by older versions, mapped onto the current
|
|
95
|
+
* aspect-ratio vocabulary so restoring old history entries still works. */
|
|
96
|
+
const LEGACY_SIZE_TO_RATIO: Record<string, string> = {
|
|
97
|
+
'512x512': '1:1',
|
|
98
|
+
'1024x1024': '1:1',
|
|
99
|
+
'1536x1024': '3:2',
|
|
100
|
+
'1024x1536': '2:3',
|
|
101
|
+
'1792x1024': '16:9',
|
|
102
|
+
'1024x1792': '9:16',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Legacy quality levels saved by older versions, mapped onto resolution. */
|
|
106
|
+
const LEGACY_QUALITY_TO_RES: Record<string, string> = {
|
|
107
|
+
low: '1k',
|
|
108
|
+
medium: '2k',
|
|
109
|
+
high: '4k',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Normalize a saved size value into a current dropdown option. */
|
|
113
|
+
function normalizeSize(value: string): string {
|
|
114
|
+
if ((SIZES as readonly string[]).includes(value)) return value
|
|
115
|
+
return LEGACY_SIZE_TO_RATIO[value] ?? 'auto'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Normalize a saved quality value into a current dropdown option. */
|
|
119
|
+
function normalizeQuality(value: string): string {
|
|
120
|
+
if ((QUALITIES as readonly string[]).includes(value)) return value
|
|
121
|
+
return LEGACY_QUALITY_TO_RES[value] ?? 'auto'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clampPreviewScale(scale: number): number {
|
|
125
|
+
return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Keep the image canvas preference across panel remounts without making it
|
|
129
|
+
* part of the host settings document. */
|
|
130
|
+
function readConfigCollapsed(): boolean {
|
|
131
|
+
try {
|
|
132
|
+
return window.localStorage.getItem(CONFIG_COLLAPSED_STORAGE_KEY) === 'true'
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const CHAT_COLLAPSED_STORAGE_KEY = 'dsh-imagegen:chat-collapsed'
|
|
139
|
+
const CONFIG_WIDTH_STORAGE_KEY = 'dsh-imagegen:config-width'
|
|
140
|
+
const CONFIG_WIDTH_MIN = 260
|
|
141
|
+
const CONFIG_WIDTH_MAX = 480
|
|
142
|
+
const CONFIG_WIDTH_DEFAULT = 300
|
|
143
|
+
|
|
144
|
+
/** The chat pane starts collapsed unless the user explicitly opened it. */
|
|
145
|
+
function readChatOpen(): boolean {
|
|
146
|
+
try {
|
|
147
|
+
return window.localStorage.getItem(CHAT_COLLAPSED_STORAGE_KEY) === 'open'
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readConfigWidth(): number {
|
|
154
|
+
try {
|
|
155
|
+
const raw = window.localStorage.getItem(CONFIG_WIDTH_STORAGE_KEY)
|
|
156
|
+
if (raw === null) return CONFIG_WIDTH_DEFAULT
|
|
157
|
+
const value = Number(raw)
|
|
158
|
+
if (Number.isFinite(value) && value > 0) return Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, Math.round(value)))
|
|
159
|
+
} catch { /* storage unavailable */ }
|
|
160
|
+
return CONFIG_WIDTH_DEFAULT
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read the current config from the settings scope snapshot. */
|
|
164
|
+
function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
|
|
165
|
+
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
166
|
+
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Track one redacted secret field without exposing its value to the panel. */
|
|
171
|
+
function useSecretSet(scope: ImageGenScope, field: string): boolean {
|
|
172
|
+
const [isSet, setIsSet] = useState(scope.getSecretSetSnapshot(field))
|
|
173
|
+
useEffect(() => scope.subscribeSecretSets(() => { setIsSet(scope.getSecretSetSnapshot(field)) }), [field, scope])
|
|
174
|
+
return isSet
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Tick a seconds counter while `running`. */
|
|
178
|
+
function useElapsed(running: boolean, startedAt: number | null): number {
|
|
179
|
+
const [elapsed, setElapsed] = useState(0)
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!running || startedAt === null) {
|
|
182
|
+
setElapsed(0)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
const update = (): void => {
|
|
186
|
+
setElapsed(Math.max(1, Math.round((Date.now() - startedAt) / 1000)))
|
|
187
|
+
}
|
|
188
|
+
update()
|
|
189
|
+
const timer = window.setInterval(update, 1000)
|
|
190
|
+
return () => window.clearInterval(timer)
|
|
191
|
+
}, [running, startedAt])
|
|
192
|
+
return elapsed
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Data URL for a generated image. */
|
|
196
|
+
function srcOf(image: GeneratedImage): string {
|
|
197
|
+
return `data:${image.mime};base64,${image.b64}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Decode one durable conversation attachment into the panel's image shape. */
|
|
201
|
+
async function attachmentToGenerated(ref: ImageAttachmentRef): Promise<GeneratedImage> {
|
|
202
|
+
const query = new URLSearchParams({
|
|
203
|
+
attachment_id: String(ref.attachmentId),
|
|
204
|
+
media_type: ref.mediaType,
|
|
205
|
+
bytes: String(ref.bytes),
|
|
206
|
+
width: String(ref.width),
|
|
207
|
+
height: String(ref.height),
|
|
208
|
+
})
|
|
209
|
+
const response = await fetch(`${AGENT_IMAGE_API}?${query.toString()}`)
|
|
210
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
211
|
+
const blob = await response.blob()
|
|
212
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
213
|
+
const reader = new FileReader()
|
|
214
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
215
|
+
reader.onerror = () => reject(new Error('image read failed'))
|
|
216
|
+
reader.readAsDataURL(blob)
|
|
217
|
+
})
|
|
218
|
+
const comma = dataUrl.indexOf(',')
|
|
219
|
+
if (comma < 0) throw new Error('image decode failed')
|
|
220
|
+
return { b64: dataUrl.slice(comma + 1), mime: ref.mediaType }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Convert a generated image into the browser-owned draft format. */
|
|
224
|
+
function generatedImageToFile(image: GeneratedImage, index: number): File {
|
|
225
|
+
const binary = atob(image.b64)
|
|
226
|
+
const bytes = new Uint8Array(binary.length)
|
|
227
|
+
for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset)
|
|
228
|
+
return new File([bytes], `dsh-image-${index + 1}.${extensionOf(image.mime)}`, { type: image.mime })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Decode a data URL into a browser File for the native composer. */
|
|
232
|
+
function dataUrlToFile(dataUrl: string, name: string): File {
|
|
233
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.*)$/su.exec(dataUrl)
|
|
234
|
+
if (match === null || match[1] === undefined || match[2] === undefined) throw new Error('image processing returned an invalid data URL')
|
|
235
|
+
const binary = atob(match[2])
|
|
236
|
+
const bytes = new Uint8Array(binary.length)
|
|
237
|
+
for (let offset = 0; offset < binary.length; offset += 1) bytes[offset] = binary.charCodeAt(offset)
|
|
238
|
+
return new File([bytes], name, { type: match[1] })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Read intrinsic dimensions without changing the original preview. */
|
|
242
|
+
function imageDimensions(dataUrl: string): Promise<{ width: number; height: number }> {
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
const image = new Image()
|
|
245
|
+
image.onload = () => {
|
|
246
|
+
const width = image.naturalWidth || image.width
|
|
247
|
+
const height = image.naturalHeight || image.height
|
|
248
|
+
if (width < 1 || height < 1) reject(new Error('image dimensions are unavailable'))
|
|
249
|
+
else resolve({ width, height })
|
|
250
|
+
}
|
|
251
|
+
image.onerror = () => reject(new Error('image decode failed'))
|
|
252
|
+
image.src = dataUrl
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Prepare the smaller conversation copy required by the host attachment policy. */
|
|
257
|
+
async function prepareConversationImage(image: GeneratedImage, index: number): Promise<{ file: File; dataUrl: string }> {
|
|
258
|
+
const dataUrl = srcOf(image)
|
|
259
|
+
const { width, height } = await imageDimensions(dataUrl)
|
|
260
|
+
const longestSide = Math.max(width, height)
|
|
261
|
+
if (longestSide <= CONVERSATION_IMAGE_MAX_DIMENSION) {
|
|
262
|
+
return { file: generatedImageToFile(image, index), dataUrl }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const scale = CONVERSATION_IMAGE_MAX_DIMENSION / longestSide
|
|
266
|
+
const targetWidth = Math.max(1, Math.round(width * scale))
|
|
267
|
+
const targetHeight = Math.max(1, Math.round(height * scale))
|
|
268
|
+
const canvas = document.createElement('canvas')
|
|
269
|
+
canvas.width = targetWidth
|
|
270
|
+
canvas.height = targetHeight
|
|
271
|
+
const context = canvas.getContext('2d')
|
|
272
|
+
if (context === null) throw new Error('image resize is unavailable in this browser')
|
|
273
|
+
const source = await new Promise<HTMLImageElement>((resolve, reject) => {
|
|
274
|
+
const sourceImage = new Image()
|
|
275
|
+
sourceImage.onload = () => resolve(sourceImage)
|
|
276
|
+
sourceImage.onerror = () => reject(new Error('image decode failed'))
|
|
277
|
+
sourceImage.src = dataUrl
|
|
278
|
+
})
|
|
279
|
+
context.drawImage(source, 0, 0, targetWidth, targetHeight)
|
|
280
|
+
const resizedDataUrl = canvas.toDataURL('image/jpeg', CONVERSATION_IMAGE_JPEG_QUALITY)
|
|
281
|
+
return {
|
|
282
|
+
dataUrl: resizedDataUrl,
|
|
283
|
+
file: dataUrlToFile(resizedDataUrl, `dsh-image-${index + 1}.jpg`),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Follow the native session selection while the image panel stays mounted. */
|
|
288
|
+
function useCurrentSessionId(sessions: ISessions | undefined): SessionId | undefined {
|
|
289
|
+
const [sessionId, setSessionId] = useState<SessionId | undefined>(() => sessions?.list.getSnapshot().current)
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (sessions === undefined) {
|
|
292
|
+
setSessionId(undefined)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const sync = (): void => { setSessionId(sessions.list.getSnapshot().current) }
|
|
296
|
+
sync()
|
|
297
|
+
return sessions.list.subscribe(sync)
|
|
298
|
+
}, [sessions])
|
|
299
|
+
return sessionId
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Find the host mounted in the shell's left navigation region. */
|
|
303
|
+
function useSidebarHistoryHost(): HTMLDivElement | null {
|
|
304
|
+
const [host, setHost] = useState<HTMLDivElement | null>(() => (
|
|
305
|
+
document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]')
|
|
306
|
+
))
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const sync = (): void => {
|
|
309
|
+
setHost(document.querySelector<HTMLDivElement>('[data-dsh-imagegen-history-host]'))
|
|
310
|
+
}
|
|
311
|
+
sync()
|
|
312
|
+
const observer = new MutationObserver(sync)
|
|
313
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
314
|
+
return () => observer.disconnect()
|
|
315
|
+
}, [])
|
|
316
|
+
return host
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Fetch persisted history image refs and decode them back to in-memory
|
|
320
|
+
* GeneratedImage[] (base64), so the canvas/preview can reuse the same
|
|
321
|
+
* rendering path as a fresh generation. */
|
|
322
|
+
async function historyImagesToGenerated(refs: HistoryImageRef[]): Promise<GeneratedImage[]> {
|
|
323
|
+
return Promise.all(refs.map(async ref => {
|
|
324
|
+
const response = await fetch(ref.url)
|
|
325
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
326
|
+
const blob = await response.blob()
|
|
327
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
328
|
+
const reader = new FileReader()
|
|
329
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
330
|
+
reader.onerror = () => reject(new Error('image read failed'))
|
|
331
|
+
reader.readAsDataURL(blob)
|
|
332
|
+
})
|
|
333
|
+
const comma = dataUrl.indexOf(',')
|
|
334
|
+
return {
|
|
335
|
+
b64: comma >= 0 ? dataUrl.slice(comma + 1) : '',
|
|
336
|
+
mime: ref.mime,
|
|
337
|
+
...ref.revisedPrompt === undefined ? {} : { revisedPrompt: ref.revisedPrompt },
|
|
338
|
+
}
|
|
339
|
+
}))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Compact, locale-independent timestamp for history entries. */
|
|
343
|
+
function formatTime(timestamp: number): string {
|
|
344
|
+
const d = new Date(timestamp)
|
|
345
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
346
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function defaultEcommerceDraft(): ProductSetDraft {
|
|
350
|
+
return {
|
|
351
|
+
projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', size: '1:1',
|
|
352
|
+
productName: '', sellingPoints: '', protectedFeatures: '', styleHint: '',
|
|
353
|
+
slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
|
|
358
|
+
const points = draft.sellingPoints.trim() || '突出商品真实材质、结构和核心价值'
|
|
359
|
+
const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
|
|
360
|
+
const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
|
|
361
|
+
? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
|
|
362
|
+
: ''
|
|
363
|
+
return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${draft.language}。商品卖点:${points}。必须遵守:${protectedFeatures}。${refClause}整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布;${draft.styleHint.trim()}`
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Consistency prefix for slots generated after the main image exists. */
|
|
367
|
+
function withAnchorNote(prompt: string): string {
|
|
368
|
+
return '商品套图一致性约束:附件是本套商品的主图,图中商品(外形、颜色、材质、Logo、包装文字)必须与附件完全一致,不得重新发明商品。' + prompt
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Studio tabs: the two generation modes plus the gallery view. */
|
|
372
|
+
type PanelTab = GenerateMode | 'gallery'
|
|
373
|
+
|
|
374
|
+
/** Top-level workspaces inside the panel. 'normal' is the classic studio;
|
|
375
|
+
* more task-oriented modes (prototype, …) can join alongside 'ecommerce'. */
|
|
376
|
+
type PanelWorkspace = 'normal' | 'ecommerce'
|
|
377
|
+
|
|
378
|
+
type GalleryFilter = string
|
|
379
|
+
type ComparisonSession = { taskIds: string[]; prompt: string; comparisonId: string }
|
|
380
|
+
type HistoryGroup = { key: string; entries: HistoryEntry[]; models: string[] }
|
|
381
|
+
|
|
382
|
+
/** One image unit in the ecommerce results canvas: a live queue task or a
|
|
383
|
+
* restored history entry of the viewed product set. */
|
|
384
|
+
interface EcommerceResultItem {
|
|
385
|
+
id: string
|
|
386
|
+
label: string
|
|
387
|
+
slotKey: string
|
|
388
|
+
status: GenerationTaskStatus
|
|
389
|
+
model: string
|
|
390
|
+
prompt: string
|
|
391
|
+
error?: string
|
|
392
|
+
images: GeneratedImage[]
|
|
393
|
+
/** The request to resubmit when regenerating this slot. */
|
|
394
|
+
source: GenerateRequest
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function modelsOfHistoryEntry(entry: HistoryEntry): string[] {
|
|
398
|
+
return entry.comparisonModels?.length !== undefined && entry.comparisonModels.length > 1
|
|
399
|
+
? entry.comparisonModels
|
|
400
|
+
: [entry.model]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Comparison runs collapse by comparisonId, product sets by projectId. */
|
|
404
|
+
function historyGroupKey(entry: HistoryEntry): string {
|
|
405
|
+
if (entry.comparisonId !== undefined) return entry.comparisonId
|
|
406
|
+
if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) return `project:${entry.projectId}`
|
|
407
|
+
return entry.id
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Collapse the per-model history rows that belong to one comparison run. */
|
|
411
|
+
function groupHistoryEntries(entries: HistoryEntry[]): HistoryGroup[] {
|
|
412
|
+
const groups = new Map<string, HistoryGroup>()
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
const key = historyGroupKey(entry)
|
|
415
|
+
const existing = groups.get(key)
|
|
416
|
+
if (existing === undefined) {
|
|
417
|
+
groups.set(key, { key, entries: [entry], models: modelsOfHistoryEntry(entry) })
|
|
418
|
+
} else {
|
|
419
|
+
existing.entries.push(entry)
|
|
420
|
+
existing.models = [...new Set([...existing.models, ...modelsOfHistoryEntry(entry)])]
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return [...groups.values()]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function newComparisonId(): string {
|
|
427
|
+
const cryptoApi = globalThis.crypto
|
|
428
|
+
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID()
|
|
429
|
+
return `comparison-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Render the studio. */
|
|
433
|
+
export function ImageGenPanel(props: {
|
|
434
|
+
api: ImageGenApi
|
|
435
|
+
scope: ImageGenScope
|
|
436
|
+
sessions?: ISessions
|
|
437
|
+
conversation?: ConversationService
|
|
438
|
+
}) {
|
|
439
|
+
const { api, scope, sessions, conversation } = props
|
|
440
|
+
const config = useConfig(scope)
|
|
441
|
+
const enabled = config?.enabled ?? true
|
|
442
|
+
// Channel-aware model options: the panel lists every configured alias
|
|
443
|
+
// (default channel first); legacy flat fields remain the upgrade fallback.
|
|
444
|
+
const modelOptions = imageModelOptions(config)
|
|
445
|
+
const hasChannels = (config?.channels ?? []).length > 0
|
|
446
|
+
// With channels configured, the model list is exactly the configured aliases
|
|
447
|
+
// (possibly empty — never fall back to the hardcoded legacy defaults).
|
|
448
|
+
const imageModels = hasChannels ? modelOptions.models : normalizeImageModels(config?.imageModels)
|
|
449
|
+
const defaultChannelId = modelOptions.defaultChannelId
|
|
450
|
+
const apiUrl = defaultChannelId !== undefined && (config?.channels ?? []).length > 0
|
|
451
|
+
? (config!.channels!.find(channel => channel.id === defaultChannelId)?.apiUrl ?? '')
|
|
452
|
+
: (config?.apiUrl ?? '')
|
|
453
|
+
const configured = apiUrl.trim() !== ''
|
|
454
|
+
const legacyKeySet = useSecretSet(scope, 'apiKey')
|
|
455
|
+
const promptKeySet = useSecretSet(scope, 'promptApiKey')
|
|
456
|
+
const channelKeySet = (config?.channels ?? []).some(channel => scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`))
|
|
457
|
+
const apiKeySet = (config?.channels ?? []).length > 0 ? channelKeySet : legacyKeySet
|
|
458
|
+
const connected = enabled && configured && apiKeySet
|
|
459
|
+
|
|
460
|
+
const [tab, setTab] = useState<PanelTab>('text')
|
|
461
|
+
const [workspace, setWorkspace] = useState<PanelWorkspace>('normal')
|
|
462
|
+
/** Switch to a normal-generation tab, leaving any task workspace. */
|
|
463
|
+
const openTab = (next: PanelTab): void => {
|
|
464
|
+
setWorkspace('normal')
|
|
465
|
+
setTab(next)
|
|
466
|
+
}
|
|
467
|
+
const [prompt, setPrompt] = useState('')
|
|
468
|
+
const [size, setSize] = useState<string>('auto')
|
|
469
|
+
const [quality, setQuality] = useState<string>('auto')
|
|
470
|
+
const [count, setCount] = useState(1)
|
|
471
|
+
const [detail, setDetail] = useState('')
|
|
472
|
+
const [model, setModel] = useState<string>('')
|
|
473
|
+
const [compareEnabled, setCompareEnabled] = useState(false)
|
|
474
|
+
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
475
|
+
const [modelOpen, setModelOpen] = useState(false)
|
|
476
|
+
const [refImage, setRefImage] = useState<{ dataUrl: string; name: string } | null>(null)
|
|
477
|
+
const [images, setImages] = useState<GeneratedImage[]>([])
|
|
478
|
+
const [addingToConversation, setAddingToConversation] = useState<number | string | null>(null)
|
|
479
|
+
const [galleryConversationAddingId, setGalleryConversationAddingId] = useState<string | null>(null)
|
|
480
|
+
const [conversationMessage, setConversationMessage] = useState<string | null>(null)
|
|
481
|
+
const [error, setError] = useState<string | null>(null)
|
|
482
|
+
// Submission is brief; actual generation stays visible until the host
|
|
483
|
+
// queue reports that every queued/running task has finished.
|
|
484
|
+
const [submitting, setSubmitting] = useState(false)
|
|
485
|
+
const [enhancing, setEnhancing] = useState(false)
|
|
486
|
+
const [configGuide, setConfigGuide] = useState<'generation' | 'enhancement' | 'disabled' | null>(null)
|
|
487
|
+
const [history, setHistory] = useState<HistoryEntry[]>([])
|
|
488
|
+
const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
|
|
489
|
+
const [gallery, setGallery] = useState<HistoryEntry[]>([])
|
|
490
|
+
const [galleryViewingId, setGalleryViewingId] = useState<string | null>(null)
|
|
491
|
+
const [galleryAdding, setGalleryAdding] = useState(false)
|
|
492
|
+
const [galleryMessage, setGalleryMessage] = useState<string | null>(null)
|
|
493
|
+
const [galleryFilter, setGalleryFilter] = useState<GalleryFilter>('all')
|
|
494
|
+
const [galleryRatio, setGalleryRatio] = useState('all')
|
|
495
|
+
const [galleryTagFilter, setGalleryTagFilter] = useState<string | null>(null)
|
|
496
|
+
const [galleryView, setGalleryView] = useState<'masonry' | 'grid'>('masonry')
|
|
497
|
+
const [gallerySort, setGallerySort] = useState<'newest' | 'oldest'>('newest')
|
|
498
|
+
const [galleryQuery, setGalleryQuery] = useState('')
|
|
499
|
+
const [galleryTagInput, setGalleryTagInput] = useState('')
|
|
500
|
+
const [editingGalleryTagsId, setEditingGalleryTagsId] = useState<string | null>(null)
|
|
501
|
+
const [galleryTagEditInput, setGalleryTagEditInput] = useState('')
|
|
502
|
+
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set())
|
|
503
|
+
const [gallerySelecting, setGallerySelecting] = useState(false)
|
|
504
|
+
const [historyQuery, setHistoryQuery] = useState('')
|
|
505
|
+
const [historyModelFilter, setHistoryModelFilter] = useState('all')
|
|
506
|
+
const [historyRatioFilter, setHistoryRatioFilter] = useState('all')
|
|
507
|
+
const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
|
|
508
|
+
const [previewScale, setPreviewScale] = useState(1)
|
|
509
|
+
const [promptCopied, setPromptCopied] = useState(false)
|
|
510
|
+
const [update, setUpdate] = useState<UpdateInfo | null>(null)
|
|
511
|
+
const [updating, setUpdating] = useState(false)
|
|
512
|
+
const [updateMessage, setUpdateMessage] = useState<string | null>(null)
|
|
513
|
+
const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
|
|
514
|
+
const [libraryOpen, setLibraryOpen] = useState(false)
|
|
515
|
+
const [tasks, setTasks] = useState<GenerationTask[]>([])
|
|
516
|
+
const tasksRef = useRef<GenerationTask[]>([])
|
|
517
|
+
const [taskTrayOpen, setTaskTrayOpen] = useState(false)
|
|
518
|
+
const [comparison, setComparison] = useState<ComparisonSession | null>(null)
|
|
519
|
+
const [comparisonFullscreen, setComparisonFullscreen] = useState(false)
|
|
520
|
+
const [ecommerce, setEcommerce] = useState<ProductSetDraft>(() => {
|
|
521
|
+
try {
|
|
522
|
+
const saved = window.localStorage.getItem(ECOMMERCE_DRAFT_STORAGE_KEY)
|
|
523
|
+
if (saved !== null) {
|
|
524
|
+
const merged: ProductSetDraft = { ...defaultEcommerceDraft(), ...JSON.parse(saved) as Partial<ProductSetDraft> }
|
|
525
|
+
// Drafts saved before reference roles existed keep working: every slot
|
|
526
|
+
// defaults to following the product image.
|
|
527
|
+
if (Array.isArray(merged.slots)) {
|
|
528
|
+
merged.slots = merged.slots.map(slot => ({ ...slot, refRole: slot.refRole ?? 'product' }))
|
|
529
|
+
}
|
|
530
|
+
return merged
|
|
531
|
+
}
|
|
532
|
+
} catch { /* ignore malformed or unavailable storage */ }
|
|
533
|
+
return defaultEcommerceDraft()
|
|
534
|
+
})
|
|
535
|
+
const [ecommercePreview, setEcommercePreview] = useState(false)
|
|
536
|
+
const [ecommerceGenerating, setEcommerceGenerating] = useState(false)
|
|
537
|
+
const [ecommerceProjectId, setEcommerceProjectId] = useState<string | null>(null)
|
|
538
|
+
const [ecommerceAssets, setEcommerceAssets] = useState<ProductAsset[]>([])
|
|
539
|
+
/** History-restored product set currently shown in the results canvas. */
|
|
540
|
+
const [ecommerceRestored, setEcommerceRestored] = useState<{ projectId: string; projectName: string; items: EcommerceResultItem[] } | null>(null)
|
|
541
|
+
/** Pending main-image anchor: the main image task is in flight; once it
|
|
542
|
+
* completes, the remaining slots are resubmitted with it as their shared
|
|
543
|
+
* reference so every image in the set shows the same product. */
|
|
544
|
+
const [ecommerceAnchor, setEcommerceAnchor] = useState<{ projectId: string; mainTaskIds: string[]; remaining: GenerateRequest[] } | null>(null)
|
|
545
|
+
const [ecommerceRefOpen, setEcommerceRefOpen] = useState(false)
|
|
546
|
+
const [configCollapsed, setConfigCollapsed] = useState(readConfigCollapsed)
|
|
547
|
+
const [chatOpen, setChatOpen] = useState(readChatOpen)
|
|
548
|
+
const [configWidth, setConfigWidth] = useState(readConfigWidth)
|
|
549
|
+
const configAsideRef = useRef<HTMLElement>(null)
|
|
550
|
+
const currentSessionId = useCurrentSessionId(sessions)
|
|
551
|
+
const sidebarHistoryHost = useSidebarHistoryHost()
|
|
552
|
+
const modeModels = tab === 'edit'
|
|
553
|
+
? imageModels.filter(candidate => describeModel(candidate).supportsEdit)
|
|
554
|
+
: imageModels
|
|
555
|
+
const fileInput = useRef<HTMLInputElement>(null)
|
|
556
|
+
const previewStage = useRef<HTMLDivElement>(null)
|
|
557
|
+
const activeTasks = tasks.filter(task => task.status === 'queued' || task.status === 'running')
|
|
558
|
+
const activeTask = activeTasks.find(task => task.status === 'running') ?? activeTasks[0]
|
|
559
|
+
const generating = submitting || activeTasks.length > 0
|
|
560
|
+
const generationStartedAt = activeTask?.startedAt ?? activeTask?.createdAt ?? null
|
|
561
|
+
const elapsed = useElapsed(generating, generationStartedAt)
|
|
562
|
+
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
try { window.localStorage.setItem(ECOMMERCE_DRAFT_STORAGE_KEY, JSON.stringify(ecommerce)) } catch { /* optional draft persistence */ }
|
|
565
|
+
}, [ecommerce])
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
try {
|
|
569
|
+
window.localStorage.setItem(CONFIG_COLLAPSED_STORAGE_KEY, String(configCollapsed))
|
|
570
|
+
} catch {
|
|
571
|
+
// Embedded shells may disable local storage; the in-memory toggle still works.
|
|
572
|
+
}
|
|
573
|
+
}, [configCollapsed])
|
|
574
|
+
|
|
575
|
+
// Chat-pane visibility rides a document-level attribute so the center-column
|
|
576
|
+
// grid can drop the conversation entirely. Collapsed by default.
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
if (chatOpen) delete document.documentElement.dataset.dshImagegenChatCollapsed
|
|
579
|
+
else document.documentElement.dataset.dshImagegenChatCollapsed = '1'
|
|
580
|
+
try { window.localStorage.setItem(CHAT_COLLAPSED_STORAGE_KEY, chatOpen ? 'open' : 'collapsed') } catch { /* optional */ }
|
|
581
|
+
}, [chatOpen])
|
|
582
|
+
|
|
583
|
+
useEffect(() => () => { delete document.documentElement.dataset.dshImagegenChatCollapsed }, [])
|
|
584
|
+
|
|
585
|
+
// Main-image anchor chain: when the main image task of a product set
|
|
586
|
+
// completes, resubmit the remaining slots with the generated main image as
|
|
587
|
+
// their shared reference. Cleared up front so a re-render cannot double-
|
|
588
|
+
// submit; failures surface as a canvas error.
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
if (ecommerceAnchor === null) return
|
|
591
|
+
const anchor = ecommerceAnchor
|
|
592
|
+
const mains = tasks.filter(task => anchor.mainTaskIds.includes(task.id))
|
|
593
|
+
if (mains.length === 0) return
|
|
594
|
+
if (mains.every(task => task.status === 'failed' || task.status === 'cancelled')) {
|
|
595
|
+
setEcommerceAnchor(null)
|
|
596
|
+
setError(tt('ecommerce.anchorFailed'))
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
const done = mains.find(task => task.status === 'completed' && task.result !== undefined && task.result.images.length > 0)
|
|
600
|
+
if (done === undefined) return
|
|
601
|
+
setEcommerceAnchor(null)
|
|
602
|
+
const dataUrl = srcOf(done.result!.images[0]!)
|
|
603
|
+
const requests = anchor.remaining.map(request => ({
|
|
604
|
+
...request,
|
|
605
|
+
mode: 'edit' as const,
|
|
606
|
+
image: dataUrl,
|
|
607
|
+
refName: 'set-main-anchor',
|
|
608
|
+
prompt: withAnchorNote(request.prompt),
|
|
609
|
+
}))
|
|
610
|
+
void Promise.all(requests.map(request => api.taskSubmit(request)))
|
|
611
|
+
.then(submitted => { setTasks(previous => [...submitted, ...previous]) })
|
|
612
|
+
.catch(caught => { setError(errorMessage(caught)) })
|
|
613
|
+
}, [api, tasks, ecommerceAnchor])
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
// A saved settings change is authoritative. Keep the active selection and
|
|
617
|
+
// comparison choices in that allow-list without disturbing valid choices.
|
|
618
|
+
const imageModelKey = modeModels.join('\u0000')
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
setModel(previous => modeModels.includes(previous) ? previous : modeModels[0] ?? '')
|
|
621
|
+
setCompareModels(previous => {
|
|
622
|
+
const retained = previous.filter(candidate => modeModels.includes(candidate))
|
|
623
|
+
return retained.length > 0 ? retained : modeModels[0] === undefined ? [] : [modeModels[0]]
|
|
624
|
+
})
|
|
625
|
+
}, [imageModelKey])
|
|
626
|
+
|
|
627
|
+
const filteredGallery = gallery
|
|
628
|
+
.filter(entry => {
|
|
629
|
+
if (galleryFilter === 'all') return true
|
|
630
|
+
if (galleryFilter === 'text' || galleryFilter === 'edit') return entry.mode === galleryFilter
|
|
631
|
+
return entry.model === galleryFilter
|
|
632
|
+
})
|
|
633
|
+
.filter(entry => galleryRatio === 'all' || normalizeSize(entry.size) === galleryRatio)
|
|
634
|
+
.filter(entry => galleryTagFilter === null || (entry.tags ?? []).includes(galleryTagFilter))
|
|
635
|
+
.filter(entry => galleryQuery.trim() === '' || `${entry.prompt} ${entry.model} ${(entry.tags ?? []).join(' ')}`.toLocaleLowerCase().includes(galleryQuery.trim().toLocaleLowerCase()))
|
|
636
|
+
.slice()
|
|
637
|
+
.sort((a, b) => gallerySort === 'newest' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
|
|
638
|
+
|
|
639
|
+
const galleryTagOptions = [...new Set(gallery.flatMap(entry => entry.tags ?? []))].sort((a, b) => a.localeCompare(b))
|
|
640
|
+
const galleryModels = [...new Set([...imageModels, ...gallery.map(entry => entry.model)])]
|
|
641
|
+
|
|
642
|
+
const filteredHistory = groupHistoryEntries(history).filter(group => group.entries.some(entry => {
|
|
643
|
+
const query = historyQuery.trim().toLocaleLowerCase()
|
|
644
|
+
const models = modelsOfHistoryEntry(entry)
|
|
645
|
+
return (query === '' || `${entry.prompt} ${models.join(' ')}`.toLocaleLowerCase().includes(query))
|
|
646
|
+
&& (historyModelFilter === 'all' || models.includes(historyModelFilter))
|
|
647
|
+
&& (historyRatioFilter === 'all' || normalizeSize(entry.size) === historyRatioFilter)
|
|
648
|
+
}))
|
|
649
|
+
|
|
650
|
+
// Load the host-persisted history and gallery once on mount (they live in
|
|
651
|
+
// ~/.dsh on the DSH host, so every browser/device sees the same lists).
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
let disposed = false
|
|
654
|
+
api.historyList()
|
|
655
|
+
.then(entries => { if (!disposed) setHistory(entries) })
|
|
656
|
+
.catch(() => { /* history unavailable — leave the list empty */ })
|
|
657
|
+
api.galleryList()
|
|
658
|
+
.then(entries => { if (!disposed) setGallery(entries) })
|
|
659
|
+
.catch(() => { /* gallery unavailable — leave the list empty */ })
|
|
660
|
+
return () => { disposed = true }
|
|
661
|
+
}, [api])
|
|
662
|
+
|
|
663
|
+
// Chat toolviews publish durable refs after they finish loading. Decode the
|
|
664
|
+
// refs through the same host-authorized route and make them the current
|
|
665
|
+
// canvas result for the selected session.
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
const onChatImages = (event: Event): void => {
|
|
668
|
+
const detail = (event as CustomEvent<ChatImageEventDetail>).detail
|
|
669
|
+
if (detail === undefined || currentSessionId === undefined || detail.sessionId !== currentSessionId) return
|
|
670
|
+
void Promise.all(detail.refs.map(attachmentToGenerated))
|
|
671
|
+
.then(next => {
|
|
672
|
+
openTab('text')
|
|
673
|
+
setImages(next)
|
|
674
|
+
setComparison(null)
|
|
675
|
+
setViewingHistoryId(null)
|
|
676
|
+
setGalleryViewingId(null)
|
|
677
|
+
setError(null)
|
|
678
|
+
})
|
|
679
|
+
.catch(caught => { setError(errorMessage(caught)) })
|
|
680
|
+
}
|
|
681
|
+
document.addEventListener(CHAT_IMAGE_EVENT, onChatImages)
|
|
682
|
+
return () => document.removeEventListener(CHAT_IMAGE_EVENT, onChatImages)
|
|
683
|
+
}, [currentSessionId])
|
|
684
|
+
|
|
685
|
+
useEffect(() => {
|
|
686
|
+
let disposed = false
|
|
687
|
+
const refresh = (): void => {
|
|
688
|
+
void api.taskList().then(next => {
|
|
689
|
+
if (disposed) return
|
|
690
|
+
const newlyCompleted = next.filter(task => task.status === 'completed'
|
|
691
|
+
&& task.result !== undefined
|
|
692
|
+
&& !tasksRef.current.some(old => old.id === task.id && old.status === 'completed'))
|
|
693
|
+
tasksRef.current = next
|
|
694
|
+
setTasks(previous => {
|
|
695
|
+
const completed = next.find(task => task.status === 'completed'
|
|
696
|
+
&& !previous.some(old => old.id === task.id && old.status === 'completed')
|
|
697
|
+
&& !comparison?.taskIds.includes(task.id))
|
|
698
|
+
if (completed?.result !== undefined) {
|
|
699
|
+
setImages(completed.result.images)
|
|
700
|
+
if (completed.result.history !== undefined) setHistory(completed.result.history)
|
|
701
|
+
setError(completed.result.historyError ?? null)
|
|
702
|
+
}
|
|
703
|
+
return next
|
|
704
|
+
})
|
|
705
|
+
if (newlyCompleted.length > 0) {
|
|
706
|
+
void api.historyList().then(entries => {
|
|
707
|
+
if (!disposed) setHistory(entries)
|
|
708
|
+
}).catch(() => {})
|
|
709
|
+
}
|
|
710
|
+
}).catch(() => {})
|
|
711
|
+
}
|
|
712
|
+
refresh()
|
|
713
|
+
const timer = window.setInterval(refresh, 1500)
|
|
714
|
+
return () => { disposed = true; window.clearInterval(timer) }
|
|
715
|
+
}, [api, comparison])
|
|
716
|
+
|
|
717
|
+
// Close the model dropdown when clicking anywhere outside it.
|
|
718
|
+
const modelMenuRef = useRef<HTMLDivElement>(null)
|
|
719
|
+
useEffect(() => {
|
|
720
|
+
if (!modelOpen) return
|
|
721
|
+
const onPointer = (event: MouseEvent | FocusEvent): void => {
|
|
722
|
+
const target = event.target
|
|
723
|
+
if (target instanceof Node && modelMenuRef.current?.contains(target)) return
|
|
724
|
+
setModelOpen(false)
|
|
725
|
+
}
|
|
726
|
+
document.addEventListener('mousedown', onPointer)
|
|
727
|
+
document.addEventListener('focusin', onPointer)
|
|
728
|
+
return () => {
|
|
729
|
+
document.removeEventListener('mousedown', onPointer)
|
|
730
|
+
document.removeEventListener('focusin', onPointer)
|
|
731
|
+
}
|
|
732
|
+
}, [modelOpen])
|
|
733
|
+
|
|
734
|
+
// Release checks are host-mediated and intentionally best-effort: a GitHub
|
|
735
|
+
// outage must never make the image-generation studio unavailable.
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
let disposed = false
|
|
738
|
+
api.updateCheck()
|
|
739
|
+
.then(info => {
|
|
740
|
+
if (!disposed && info.updateAvailable) setUpdate(info)
|
|
741
|
+
})
|
|
742
|
+
.catch(() => { /* update discovery is optional */ })
|
|
743
|
+
return () => { disposed = true }
|
|
744
|
+
}, [api])
|
|
745
|
+
|
|
746
|
+
const applyUpdate = async (): Promise<void> => {
|
|
747
|
+
if (update === null || updating) return
|
|
748
|
+
setUpdating(true)
|
|
749
|
+
setUpdateMessage(null)
|
|
750
|
+
setUpdateResult(null)
|
|
751
|
+
try {
|
|
752
|
+
const result = await api.updateApply(update.latestVersion)
|
|
753
|
+
setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
|
|
754
|
+
setUpdateResult('success')
|
|
755
|
+
} catch {
|
|
756
|
+
setUpdateMessage(tt('update.failed'))
|
|
757
|
+
setUpdateResult('failed')
|
|
758
|
+
} finally {
|
|
759
|
+
setUpdating(false)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const openSettingsGuide = (kind: 'generation' | 'enhancement' | 'disabled'): void => {
|
|
764
|
+
setConfigGuide(kind)
|
|
765
|
+
const openPluginSettings = (): void => {
|
|
766
|
+
const pluginButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(插件|Plugins)$/.test(button.textContent?.trim() ?? ''))
|
|
767
|
+
pluginButton?.click()
|
|
768
|
+
window.setTimeout(() => {
|
|
769
|
+
const imageGenButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /dsh-imagegen/i.test(button.textContent ?? ''))
|
|
770
|
+
if (imageGenButton?.getAttribute('aria-expanded') !== 'true') imageGenButton?.click()
|
|
771
|
+
}, 0)
|
|
772
|
+
}
|
|
773
|
+
const settingsButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => /^(设置|Settings)$/.test(button.textContent?.trim() ?? ''))
|
|
774
|
+
if (settingsButton?.getAttribute('aria-expanded') !== 'true') settingsButton?.click()
|
|
775
|
+
window.setTimeout(openPluginSettings, 0)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const enhanceCurrentPrompt = async (): Promise<void> => {
|
|
779
|
+
if (prompt.trim() === '' || enhancing) return
|
|
780
|
+
const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
|
|
781
|
+
if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
|
|
782
|
+
openSettingsGuide('enhancement')
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
setEnhancing(true)
|
|
786
|
+
setError(null)
|
|
787
|
+
try {
|
|
788
|
+
setPrompt(await api.enhancePrompt(prompt))
|
|
789
|
+
} catch (caught) {
|
|
790
|
+
setError(errorMessage(caught))
|
|
791
|
+
} finally {
|
|
792
|
+
setEnhancing(false)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Read an uploaded reference image into a data URL. */
|
|
797
|
+
const acceptFile = (file: File | undefined): void => {
|
|
798
|
+
if (file === undefined) return
|
|
799
|
+
if (!file.type.startsWith('image/')) {
|
|
800
|
+
setError(tt('edit.uploadHint'))
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (file.size > REF_IMAGE_MAX_BYTES) {
|
|
804
|
+
setError(tt('edit.uploadHint'))
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const reader = new FileReader()
|
|
808
|
+
reader.onload = () => {
|
|
809
|
+
if (typeof reader.result === 'string') setRefImage({ dataUrl: reader.result, name: file.name })
|
|
810
|
+
}
|
|
811
|
+
reader.onerror = () => { setError(tt('edit.uploadHint')) }
|
|
812
|
+
reader.readAsDataURL(file)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Read uploaded product assets into session-only data-URL chips, capped at
|
|
816
|
+
* MAX_ECOMMERCE_ASSETS. Each starts as the product-role reference. */
|
|
817
|
+
const acceptEcommerceFiles = (files: FileList | undefined): void => {
|
|
818
|
+
if (files === undefined) return
|
|
819
|
+
const incoming = Array.from(files).filter(file => file.type.startsWith('image/') && file.size <= REF_IMAGE_MAX_BYTES)
|
|
820
|
+
if (incoming.length === 0) {
|
|
821
|
+
setError(tt('edit.uploadHint'))
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
for (const file of incoming) {
|
|
825
|
+
const reader = new FileReader()
|
|
826
|
+
reader.onload = () => {
|
|
827
|
+
if (typeof reader.result !== 'string') return
|
|
828
|
+
const dataUrl = reader.result
|
|
829
|
+
setEcommerceAssets(previous => {
|
|
830
|
+
if (previous.length >= MAX_ECOMMERCE_ASSETS) {
|
|
831
|
+
setError(tt('ecommerce.assetsFull'))
|
|
832
|
+
return previous
|
|
833
|
+
}
|
|
834
|
+
return [...previous, { id: newComparisonId(), dataUrl, name: file.name, role: 'product' }]
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
reader.onerror = () => { setError(tt('edit.uploadHint')) }
|
|
838
|
+
reader.readAsDataURL(file)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Run one generation. */
|
|
843
|
+
const handleGenerate = async (): Promise<void> => {
|
|
844
|
+
if (submitting) return
|
|
845
|
+
if (!enabled) {
|
|
846
|
+
openSettingsGuide('disabled')
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
if (!configured || !apiKeySet) {
|
|
850
|
+
openSettingsGuide('generation')
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
const promptText = prompt.trim()
|
|
854
|
+
if (promptText === '') {
|
|
855
|
+
setError(tt('prompt.required'))
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
if (tab === 'edit' && refImage === null) {
|
|
859
|
+
setError(tt('edit.required'))
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
const request: GenerateRequest = {
|
|
863
|
+
mode: tab === 'edit' ? 'edit' : 'text',
|
|
864
|
+
model: modeModels.includes(model) ? model : modeModels[0] ?? '',
|
|
865
|
+
prompt: promptText,
|
|
866
|
+
size,
|
|
867
|
+
quality,
|
|
868
|
+
n: count,
|
|
869
|
+
detail,
|
|
870
|
+
...defaultChannelId !== undefined ? { channelId: defaultChannelId } : {},
|
|
871
|
+
...tab === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
|
|
872
|
+
...tab === 'edit' && refImage !== null ? { refName: refImage.name } : {},
|
|
873
|
+
}
|
|
874
|
+
setError(null)
|
|
875
|
+
setSubmitting(true)
|
|
876
|
+
try {
|
|
877
|
+
const targetModels = (compareEnabled ? compareModels : [request.model]).filter(candidate => modeModels.includes(candidate))
|
|
878
|
+
if (targetModels.length === 0) {
|
|
879
|
+
setError(tt('compare.selectRequired'))
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
const comparisonId = targetModels.length > 1 ? newComparisonId() : undefined
|
|
883
|
+
const comparisonFields = comparisonId === undefined ? {} : { comparisonId, comparisonModels: targetModels }
|
|
884
|
+
const submitted = await Promise.all(targetModels.map(targetModel => api.taskSubmit({ ...request, model: targetModel, ...comparisonFields })))
|
|
885
|
+
setTasks(previous => [...submitted, ...previous.filter(item => !submitted.some(task => task.id === item.id))])
|
|
886
|
+
setComparison(comparisonId === undefined ? null : { taskIds: submitted.map(task => task.id), prompt: promptText, comparisonId })
|
|
887
|
+
} catch (caught) {
|
|
888
|
+
setError(errorMessage(caught))
|
|
889
|
+
} finally {
|
|
890
|
+
setSubmitting(false)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const handleEcommerceGenerate = async (): Promise<void> => {
|
|
895
|
+
if (ecommerceGenerateDisabled) return
|
|
896
|
+
if (!enabled || !configured || !apiKeySet) { openSettingsGuide('generation'); return }
|
|
897
|
+
const projectId = ecommerce.projectId || newComparisonId()
|
|
898
|
+
const buildRequest = (slot: ProductSetSlot, index: number): GenerateRequest => {
|
|
899
|
+
// Each slot picks one reference by asset role; a slot without a matching
|
|
900
|
+
// asset (or 'none') falls back to text-to-image.
|
|
901
|
+
const refRole = slot.refRole ?? 'product'
|
|
902
|
+
const asset = refRole === 'none' ? undefined : ecommerceAssets.find(item => item.role === refRole)
|
|
903
|
+
return {
|
|
904
|
+
mode: asset !== undefined ? 'edit' as const : 'text' as const,
|
|
905
|
+
model: modeModels.includes(model) ? model : modeModels[0] ?? '',
|
|
906
|
+
prompt: ecommercePrompt(ecommerce, slot),
|
|
907
|
+
size: ecommerce.size,
|
|
908
|
+
quality,
|
|
909
|
+
n: 1,
|
|
910
|
+
detail,
|
|
911
|
+
...(defaultChannelId !== undefined ? { channelId: defaultChannelId } : {}),
|
|
912
|
+
...(asset !== undefined ? { image: asset.dataUrl, refName: asset.name } : {}),
|
|
913
|
+
workflow: 'ecommerce' as const,
|
|
914
|
+
projectId,
|
|
915
|
+
projectName: ecommerce.projectName.trim() || ecommerce.productName.trim(),
|
|
916
|
+
slotKey: `${slot.key}-${index + 1}`,
|
|
917
|
+
slotLabel: slot.label,
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// Anchor chain: with a main-image slot enabled, only the main image is
|
|
921
|
+
// submitted now; the remaining slots follow once it completes (see the
|
|
922
|
+
// anchor effect) so the whole set shares one product. Without a main
|
|
923
|
+
// slot, every slot submits immediately with its own reference.
|
|
924
|
+
const mainSlots = ecommerceSlots.filter(slot => slot.key === 'main')
|
|
925
|
+
const otherSlots = ecommerceSlots.filter(slot => slot.key !== 'main')
|
|
926
|
+
const anchorChain = mainSlots.length > 0 && otherSlots.length > 0
|
|
927
|
+
const leadSlots = anchorChain ? mainSlots : ecommerceSlots
|
|
928
|
+
const requests = leadSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => buildRequest(slot, index)))
|
|
929
|
+
const remaining = anchorChain
|
|
930
|
+
? otherSlots.flatMap(slot => Array.from({ length: slot.count }, (_, index) => {
|
|
931
|
+
const { image: _image, refName: _refName, ...rest } = buildRequest(slot, index)
|
|
932
|
+
return rest
|
|
933
|
+
}))
|
|
934
|
+
: []
|
|
935
|
+
setEcommerceGenerating(true); setSubmitting(true); setError(null); setEcommerceProjectId(projectId); setEcommerceRestored(null); setEcommerceAnchor(null)
|
|
936
|
+
try {
|
|
937
|
+
const submitted = await Promise.all(requests.map(request => api.taskSubmit(request)))
|
|
938
|
+
setTasks(previous => [...submitted, ...previous])
|
|
939
|
+
setEcommercePreview(false)
|
|
940
|
+
if (anchorChain) setEcommerceAnchor({ projectId, mainTaskIds: submitted.map(task => task.id), remaining })
|
|
941
|
+
} catch (caught) { setError(errorMessage(caught)) } finally { setSubmitting(false); setEcommerceGenerating(false) }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Start over with a fresh product draft (the old results stay in history). */
|
|
945
|
+
const newEcommerceProduct = (): void => {
|
|
946
|
+
setEcommerce(defaultEcommerceDraft())
|
|
947
|
+
setEcommercePreview(false)
|
|
948
|
+
setEcommerceProjectId(null)
|
|
949
|
+
setEcommerceRestored(null)
|
|
950
|
+
setEcommerceAnchor(null)
|
|
951
|
+
setEcommerceAssets([])
|
|
952
|
+
setRefImage(null)
|
|
953
|
+
setError(null)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Re-run every image of one slot with its original request. */
|
|
957
|
+
const regenerateEcommerceSlot = async (label: string): Promise<void> => {
|
|
958
|
+
if (ecommerceGenerating) return
|
|
959
|
+
const group = ecommerceMergedItems.filter(item => item.label === label)
|
|
960
|
+
if (group.length === 0) return
|
|
961
|
+
setEcommerceGenerating(true)
|
|
962
|
+
setError(null)
|
|
963
|
+
try {
|
|
964
|
+
const submitted = await Promise.all(group.map(item => api.taskSubmit({ ...item.source })))
|
|
965
|
+
setTasks(previous => [...submitted, ...previous])
|
|
966
|
+
} catch (caught) {
|
|
967
|
+
setError(errorMessage(caught))
|
|
968
|
+
} finally {
|
|
969
|
+
setEcommerceGenerating(false)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Open one persisted product set from history: rebuild the grouped results
|
|
974
|
+
* canvas from its entries. Reference images are not persisted, so restored
|
|
975
|
+
* edit-mode slots regenerate as text-to-image. */
|
|
976
|
+
const viewEcommerceProject = async (group: HistoryGroup): Promise<void> => {
|
|
977
|
+
const entry = group.entries[0]
|
|
978
|
+
if (entry === undefined || entry.projectId === undefined) return
|
|
979
|
+
try {
|
|
980
|
+
const items: EcommerceResultItem[] = await Promise.all(group.entries.map(async item => ({
|
|
981
|
+
id: item.id,
|
|
982
|
+
label: item.slotLabel ?? '',
|
|
983
|
+
slotKey: item.slotKey ?? '',
|
|
984
|
+
status: 'completed' as const,
|
|
985
|
+
model: item.model,
|
|
986
|
+
prompt: item.prompt,
|
|
987
|
+
images: await historyImagesToGenerated(item.images),
|
|
988
|
+
source: {
|
|
989
|
+
mode: item.mode === 'edit' ? 'text' as const : item.mode,
|
|
990
|
+
model: item.model,
|
|
991
|
+
prompt: item.prompt,
|
|
992
|
+
size: item.size,
|
|
993
|
+
quality: item.quality,
|
|
994
|
+
detail: item.detail,
|
|
995
|
+
n: 1,
|
|
996
|
+
...item.channelId !== undefined ? { channelId: item.channelId } : {},
|
|
997
|
+
workflow: 'ecommerce' as const,
|
|
998
|
+
projectId: entry.projectId!,
|
|
999
|
+
projectName: entry.projectName ?? '',
|
|
1000
|
+
slotKey: item.slotKey ?? '',
|
|
1001
|
+
slotLabel: item.slotLabel ?? '',
|
|
1002
|
+
},
|
|
1003
|
+
})))
|
|
1004
|
+
setWorkspace('ecommerce')
|
|
1005
|
+
setEcommerceRestored({ projectId: entry.projectId, projectName: entry.projectName ?? '', items })
|
|
1006
|
+
setEcommerceProjectId(entry.projectId)
|
|
1007
|
+
setEcommercePreview(false)
|
|
1008
|
+
setError(null)
|
|
1009
|
+
setViewingHistoryId(entry.id)
|
|
1010
|
+
setGalleryViewingId(null)
|
|
1011
|
+
} catch (caught) {
|
|
1012
|
+
setError(errorMessage(caught))
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Download a JSON manifest describing the whole product set (prompts,
|
|
1017
|
+
* slots and task outcomes) so results stay reproducible outside the panel. */
|
|
1018
|
+
const exportEcommerceManifest = (): void => {
|
|
1019
|
+
const manifest = {
|
|
1020
|
+
project: {
|
|
1021
|
+
id: ecommerceProjectId,
|
|
1022
|
+
name: ecommerce.projectName || ecommerce.productName,
|
|
1023
|
+
productName: ecommerce.productName,
|
|
1024
|
+
category: ecommerce.category,
|
|
1025
|
+
platform: ecommerce.platform,
|
|
1026
|
+
language: ecommerce.language,
|
|
1027
|
+
size: ecommerce.size,
|
|
1028
|
+
sellingPoints: ecommerce.sellingPoints,
|
|
1029
|
+
protectedFeatures: ecommerce.protectedFeatures,
|
|
1030
|
+
styleHint: ecommerce.styleHint,
|
|
1031
|
+
},
|
|
1032
|
+
generatedAt: new Date().toISOString(),
|
|
1033
|
+
images: ecommerceMergedItems.map(item => ({
|
|
1034
|
+
slotKey: item.slotKey,
|
|
1035
|
+
slotLabel: item.label,
|
|
1036
|
+
status: item.status,
|
|
1037
|
+
model: item.model,
|
|
1038
|
+
prompt: item.prompt,
|
|
1039
|
+
error: item.error,
|
|
1040
|
+
})),
|
|
1041
|
+
}
|
|
1042
|
+
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' })
|
|
1043
|
+
const url = URL.createObjectURL(blob)
|
|
1044
|
+
const anchor = document.createElement('a')
|
|
1045
|
+
anchor.href = url
|
|
1046
|
+
anchor.download = `dsh-product-set-${(ecommerce.projectName || ecommerce.productName || 'set').replace(/[^\w-]+/g, '-')}.json`
|
|
1047
|
+
anchor.click()
|
|
1048
|
+
URL.revokeObjectURL(url)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const openPreview = (previewImages: GeneratedImage[], index: number): void => {
|
|
1052
|
+
setPreview({ images: previewImages, index })
|
|
1053
|
+
setPreviewScale(1)
|
|
1054
|
+
setPromptCopied(false)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const closePreview = (): void => {
|
|
1058
|
+
setPreview(null)
|
|
1059
|
+
setPreviewScale(1)
|
|
1060
|
+
setPromptCopied(false)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Step the preview by ±1, wrapping around. */
|
|
1064
|
+
const stepPreview = (delta: number): void => {
|
|
1065
|
+
setPreviewScale(1)
|
|
1066
|
+
setPromptCopied(false)
|
|
1067
|
+
setPreview(current => {
|
|
1068
|
+
if (current === null) return null
|
|
1069
|
+
const total = current.images.length
|
|
1070
|
+
return { images: current.images, index: (current.index + delta + total) % total }
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Keyboard navigation for the preview overlay.
|
|
1075
|
+
useEffect(() => {
|
|
1076
|
+
if (preview === null) return
|
|
1077
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
1078
|
+
if (event.key === 'Escape') closePreview()
|
|
1079
|
+
else if (event.key === 'ArrowLeft') stepPreview(-1)
|
|
1080
|
+
else if (event.key === 'ArrowRight') stepPreview(1)
|
|
1081
|
+
else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP))
|
|
1082
|
+
else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP))
|
|
1083
|
+
else if (event.key === '0') setPreviewScale(1)
|
|
1084
|
+
}
|
|
1085
|
+
window.addEventListener('keydown', onKey)
|
|
1086
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1087
|
+
}, [preview])
|
|
1088
|
+
|
|
1089
|
+
// A scaled image owns real scrollable space, rather than being visually
|
|
1090
|
+
// transformed and clipped. Recenter the viewport after every zoom or slide.
|
|
1091
|
+
useEffect(() => {
|
|
1092
|
+
if (preview === null) return
|
|
1093
|
+
const frame = window.requestAnimationFrame(() => {
|
|
1094
|
+
const stage = previewStage.current
|
|
1095
|
+
if (stage === null) return
|
|
1096
|
+
stage.scrollLeft = Math.max(0, (stage.scrollWidth - stage.clientWidth) / 2)
|
|
1097
|
+
stage.scrollTop = Math.max(0, (stage.scrollHeight - stage.clientHeight) / 2)
|
|
1098
|
+
})
|
|
1099
|
+
return () => window.cancelAnimationFrame(frame)
|
|
1100
|
+
}, [preview, previewScale])
|
|
1101
|
+
|
|
1102
|
+
const loadHistoryGroup = async (group: HistoryGroup): Promise<GeneratedImage[]> => {
|
|
1103
|
+
const loaded = await Promise.all(group.entries.map(entry => historyImagesToGenerated(entry.images)))
|
|
1104
|
+
return loaded.flat()
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** View every model result from one comparison as one canvas result set. */
|
|
1108
|
+
const viewHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
1109
|
+
const entry = group.entries[0]
|
|
1110
|
+
if (entry === undefined) return
|
|
1111
|
+
// Product sets rebuild their grouped results canvas instead of the
|
|
1112
|
+
// generic image workspace.
|
|
1113
|
+
if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
|
|
1114
|
+
await viewEcommerceProject(group)
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
// History is also the bridge out of the gallery: show the image workspace
|
|
1118
|
+
// immediately, then hydrate the selected result into its canvas.
|
|
1119
|
+
openTab('text')
|
|
1120
|
+
try {
|
|
1121
|
+
setImages(await loadHistoryGroup(group))
|
|
1122
|
+
setComparison(null)
|
|
1123
|
+
setError(null)
|
|
1124
|
+
setViewingHistoryId(entry.id)
|
|
1125
|
+
setGalleryViewingId(null)
|
|
1126
|
+
} catch (caught) {
|
|
1127
|
+
setError(errorMessage(caught))
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Restore one comparison group, including its selected model set. */
|
|
1132
|
+
const restoreHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
1133
|
+
const entry = group.entries[0]
|
|
1134
|
+
if (entry === undefined) return
|
|
1135
|
+
// The product-set form draft cannot be rebuilt from a compiled prompt, so
|
|
1136
|
+
// restoring a product set reopens its grouped results canvas.
|
|
1137
|
+
if (entry.workflow === 'ecommerce' && entry.projectId !== undefined) {
|
|
1138
|
+
await viewEcommerceProject(group)
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
const restored = await loadHistoryGroup(group)
|
|
1143
|
+
openTab(entry.mode)
|
|
1144
|
+
setPrompt(entry.prompt)
|
|
1145
|
+
setSize(normalizeSize(entry.size))
|
|
1146
|
+
setQuality(normalizeQuality(entry.quality))
|
|
1147
|
+
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
1148
|
+
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
1149
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
1150
|
+
setCompareModels(group.models.filter(candidate => imageModels.includes(candidate)))
|
|
1151
|
+
setCompareEnabled(group.models.filter(candidate => imageModels.includes(candidate)).length > 1)
|
|
1152
|
+
setRefImage(null)
|
|
1153
|
+
setImages(restored)
|
|
1154
|
+
setComparison(null)
|
|
1155
|
+
setError(null)
|
|
1156
|
+
setViewingHistoryId(entry.id)
|
|
1157
|
+
setGalleryViewingId(null)
|
|
1158
|
+
} catch (caught) {
|
|
1159
|
+
setError(errorMessage(caught))
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Remove every persisted row belonging to one comparison group. */
|
|
1164
|
+
const deleteHistoryGroup = async (group: HistoryGroup): Promise<void> => {
|
|
1165
|
+
const ids = new Set(group.entries.map(entry => entry.id))
|
|
1166
|
+
setHistory(previous => previous.filter(entry => !ids.has(entry.id)))
|
|
1167
|
+
if (viewingHistoryId !== null && ids.has(viewingHistoryId)) setViewingHistoryId(null)
|
|
1168
|
+
if (ecommerceRestored !== null && group.entries.some(entry => entry.projectId === ecommerceRestored.projectId)) {
|
|
1169
|
+
setEcommerceRestored(null)
|
|
1170
|
+
setEcommerceProjectId(null)
|
|
1171
|
+
setEcommerceAnchor(null)
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
let next = history
|
|
1175
|
+
for (const id of ids) next = await api.historyRemove(id)
|
|
1176
|
+
setHistory(next)
|
|
1177
|
+
} catch {
|
|
1178
|
+
// Keep the optimistic local removal.
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Reset the workspace for a fresh image-generation run. */
|
|
1183
|
+
const startNewCreation = (): void => {
|
|
1184
|
+
openTab('text')
|
|
1185
|
+
setPrompt('')
|
|
1186
|
+
setRefImage(null)
|
|
1187
|
+
setImages([])
|
|
1188
|
+
setPreview(null)
|
|
1189
|
+
setPreviewScale(1)
|
|
1190
|
+
setPromptCopied(false)
|
|
1191
|
+
setViewingHistoryId(null)
|
|
1192
|
+
setGalleryViewingId(null)
|
|
1193
|
+
setGallerySelecting(false)
|
|
1194
|
+
setSelectedGalleryIds(new Set())
|
|
1195
|
+
setComparison(null)
|
|
1196
|
+
setComparisonFullscreen(false)
|
|
1197
|
+
setEcommerceRestored(null)
|
|
1198
|
+
setError(null)
|
|
1199
|
+
setConversationMessage(null)
|
|
1200
|
+
setGalleryMessage(null)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Remove all history entries. */
|
|
1204
|
+
const clearHistory = async (): Promise<void> => {
|
|
1205
|
+
if (!window.confirm(tt('history.clearConfirm'))) return
|
|
1206
|
+
setHistory([])
|
|
1207
|
+
setViewingHistoryId(null)
|
|
1208
|
+
try {
|
|
1209
|
+
setHistory(await api.historyClear())
|
|
1210
|
+
} catch {
|
|
1211
|
+
// Keep the cleared local state.
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** Add one generated image to the gallery (host deduplicates by content).
|
|
1216
|
+
* `entry` makes the action available from a history/gallery list item (its
|
|
1217
|
+
* metadata + first image are saved); otherwise the current form state is
|
|
1218
|
+
* used. */
|
|
1219
|
+
const addToGallery = async (image: GeneratedImage, entry?: HistoryEntry): Promise<void> => {
|
|
1220
|
+
if (galleryAdding || (workspace === 'normal' && tab === 'gallery')) return
|
|
1221
|
+
const source = entry ?? viewingEntry ?? {
|
|
1222
|
+
mode: tab === 'edit' ? 'edit' as GenerateMode : 'text' as GenerateMode,
|
|
1223
|
+
model,
|
|
1224
|
+
prompt: prompt.trim(),
|
|
1225
|
+
size,
|
|
1226
|
+
quality,
|
|
1227
|
+
detail,
|
|
1228
|
+
...refImage !== null ? { refName: refImage.name } : {},
|
|
1229
|
+
}
|
|
1230
|
+
setGalleryAdding(true)
|
|
1231
|
+
try {
|
|
1232
|
+
const result = await api.galleryAppend({
|
|
1233
|
+
id: '', // the host assigns a fresh id
|
|
1234
|
+
createdAt: Date.now(),
|
|
1235
|
+
mode: source.mode,
|
|
1236
|
+
model: source.model,
|
|
1237
|
+
prompt: source.prompt,
|
|
1238
|
+
size: source.size,
|
|
1239
|
+
quality: source.quality,
|
|
1240
|
+
detail: source.detail,
|
|
1241
|
+
n: 1,
|
|
1242
|
+
images: [image],
|
|
1243
|
+
...source.refName === undefined ? {} : { refName: source.refName },
|
|
1244
|
+
})
|
|
1245
|
+
setGallery(result.entries)
|
|
1246
|
+
setGalleryMessage(result.added ? tt('gallery.added') : tt('gallery.already'))
|
|
1247
|
+
window.setTimeout(() => { setGalleryMessage(null) }, 2200)
|
|
1248
|
+
} catch (caught) {
|
|
1249
|
+
setError(errorMessage(caught))
|
|
1250
|
+
} finally {
|
|
1251
|
+
setGalleryAdding(false)
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Put a generated image into the native conversation composer. */
|
|
1256
|
+
const addImageToConversation = async (image: GeneratedImage, index: number, actionKey: number | string = index): Promise<void> => {
|
|
1257
|
+
if (addingToConversation !== null) return
|
|
1258
|
+
if (conversation === undefined || sessions === undefined || currentSessionId === undefined) {
|
|
1259
|
+
setError(tt('conversation.noSession'))
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
const sessionScope = sessions.scope(currentSessionId)
|
|
1263
|
+
if (sessionScope === undefined) {
|
|
1264
|
+
setError(tt('conversation.unavailable'))
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1267
|
+
setAddingToConversation(actionKey)
|
|
1268
|
+
let attachments: ReturnType<ConversationService['createDraftImages']> = []
|
|
1269
|
+
let added = false
|
|
1270
|
+
try {
|
|
1271
|
+
const prepared = await prepareConversationImage(image, index)
|
|
1272
|
+
const file = prepared.file
|
|
1273
|
+
attachments = conversation.createDraftImages([file])
|
|
1274
|
+
const input = conversation.input.for(sessionScope)
|
|
1275
|
+
if (!input.addImages(attachments.map(attachment => attachment.id))) {
|
|
1276
|
+
throw new Error(tt('conversation.busy'))
|
|
1277
|
+
}
|
|
1278
|
+
added = true
|
|
1279
|
+
try {
|
|
1280
|
+
await api.attachConversationImage(String(currentSessionId), prepared.dataUrl, file.name)
|
|
1281
|
+
} catch (caught) {
|
|
1282
|
+
for (const attachment of attachments) input.removeImage(attachment.id)
|
|
1283
|
+
added = false
|
|
1284
|
+
throw caught
|
|
1285
|
+
}
|
|
1286
|
+
if (input.state.getSnapshot().draft.trim() === '' && prompt.trim() !== '') input.setDraft(prompt.trim())
|
|
1287
|
+
setConversationMessage(tt('conversation.added'))
|
|
1288
|
+
window.setTimeout(() => { setConversationMessage(null) }, 2200)
|
|
1289
|
+
} catch (caught) {
|
|
1290
|
+
if (!added) conversation.releaseDraftImages(attachments)
|
|
1291
|
+
setError(errorMessage(caught))
|
|
1292
|
+
} finally {
|
|
1293
|
+
setAddingToConversation(null)
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** Add one history entry's first image to the gallery (fetches it from the
|
|
1298
|
+
* history image route, then delegates to addToGallery). */
|
|
1299
|
+
const addHistoryEntryToGallery = async (entry: HistoryEntry): Promise<void> => {
|
|
1300
|
+
if (galleryAdding || entry.images.length === 0) return
|
|
1301
|
+
try {
|
|
1302
|
+
const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
|
|
1303
|
+
if (image === undefined) return
|
|
1304
|
+
await addToGallery(image, entry)
|
|
1305
|
+
} catch (caught) {
|
|
1306
|
+
setError(errorMessage(caught))
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Load a persisted gallery image and add it to the current chat draft. */
|
|
1311
|
+
const addGalleryEntryToConversation = async (entry: HistoryEntry): Promise<void> => {
|
|
1312
|
+
if (galleryConversationAddingId !== null || addingToConversation !== null || entry.images.length === 0) return
|
|
1313
|
+
setGalleryConversationAddingId(entry.id)
|
|
1314
|
+
try {
|
|
1315
|
+
const [image] = await historyImagesToGenerated(entry.images.slice(0, 1))
|
|
1316
|
+
if (image === undefined) return
|
|
1317
|
+
await addImageToConversation(image, 0, `gallery:${entry.id}`)
|
|
1318
|
+
} catch (caught) {
|
|
1319
|
+
setError(errorMessage(caught))
|
|
1320
|
+
} finally {
|
|
1321
|
+
setGalleryConversationAddingId(null)
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** View a gallery image in the canvas. */
|
|
1326
|
+
const viewGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
1327
|
+
try {
|
|
1328
|
+
const restored = await historyImagesToGenerated(entry.images)
|
|
1329
|
+
setImages(restored)
|
|
1330
|
+
setError(null)
|
|
1331
|
+
setViewingHistoryId(null)
|
|
1332
|
+
setGalleryViewingId(entry.id)
|
|
1333
|
+
if (restored.length > 0) openPreview(restored, 0)
|
|
1334
|
+
} catch (caught) {
|
|
1335
|
+
setError(errorMessage(caught))
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Restore a gallery entry's parameters (and its images) into the form. */
|
|
1340
|
+
const restoreGalleryEntry = async (entry: HistoryEntry): Promise<void> => {
|
|
1341
|
+
try {
|
|
1342
|
+
const restored = await historyImagesToGenerated(entry.images)
|
|
1343
|
+
openTab(entry.mode)
|
|
1344
|
+
setPrompt(entry.prompt)
|
|
1345
|
+
setSize(normalizeSize(entry.size))
|
|
1346
|
+
setQuality(normalizeQuality(entry.quality))
|
|
1347
|
+
setDetail((DETAILS as readonly string[]).includes(entry.detail) ? entry.detail : '')
|
|
1348
|
+
setCount(entry.n >= 1 && entry.n <= 4 ? entry.n : 1)
|
|
1349
|
+
setModel(imageModels.includes(entry.model) ? entry.model : imageModels[0])
|
|
1350
|
+
setRefImage(null)
|
|
1351
|
+
setImages(restored)
|
|
1352
|
+
setError(null)
|
|
1353
|
+
setViewingHistoryId(null)
|
|
1354
|
+
setGalleryViewingId(null)
|
|
1355
|
+
} catch (caught) {
|
|
1356
|
+
setError(errorMessage(caught))
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Remove one gallery entry. */
|
|
1361
|
+
const deleteGalleryEntry = async (id: string): Promise<void> => {
|
|
1362
|
+
setGallery(gallery.filter(entry => entry.id !== id))
|
|
1363
|
+
if (galleryViewingId === id) setGalleryViewingId(null)
|
|
1364
|
+
try {
|
|
1365
|
+
setGallery(await api.galleryRemove(id))
|
|
1366
|
+
} catch {
|
|
1367
|
+
// Keep the optimistic local removal.
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Remove every gallery entry. */
|
|
1372
|
+
const clearGalleryAll = async (): Promise<void> => {
|
|
1373
|
+
if (!window.confirm(tt('gallery.clearConfirm'))) return
|
|
1374
|
+
setGallery([])
|
|
1375
|
+
setGalleryViewingId(null)
|
|
1376
|
+
try {
|
|
1377
|
+
setGallery(await api.galleryClear())
|
|
1378
|
+
} catch {
|
|
1379
|
+
// Keep the cleared local state.
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const applyGalleryTags = async (): Promise<void> => {
|
|
1384
|
+
const tags = galleryTagInput.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
1385
|
+
if (tags.length === 0 || selectedGalleryIds.size === 0) return
|
|
1386
|
+
try {
|
|
1387
|
+
let next = gallery
|
|
1388
|
+
for (const id of selectedGalleryIds) {
|
|
1389
|
+
const existing = next.find(entry => entry.id === id)?.tags ?? []
|
|
1390
|
+
next = await api.gallerySetTags(id, [...existing, ...tags])
|
|
1391
|
+
}
|
|
1392
|
+
setGallery(next)
|
|
1393
|
+
setGalleryTagInput('')
|
|
1394
|
+
} catch (caught) { setError(errorMessage(caught)) }
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const startEditingGalleryTags = (entry: HistoryEntry): void => {
|
|
1398
|
+
setEditingGalleryTagsId(entry.id)
|
|
1399
|
+
setGalleryTagEditInput((entry.tags ?? []).join(', '))
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const saveGalleryTags = async (id: string): Promise<void> => {
|
|
1403
|
+
const tags = galleryTagEditInput.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
1404
|
+
try {
|
|
1405
|
+
setGallery(await api.gallerySetTags(id, tags))
|
|
1406
|
+
setEditingGalleryTagsId(null)
|
|
1407
|
+
setGalleryTagEditInput('')
|
|
1408
|
+
} catch (caught) { setError(errorMessage(caught)) }
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const toggleGallerySelection = (id: string): void => {
|
|
1412
|
+
setSelectedGalleryIds(previous => {
|
|
1413
|
+
const next = new Set(previous)
|
|
1414
|
+
if (next.has(id)) next.delete(id)
|
|
1415
|
+
else next.add(id)
|
|
1416
|
+
return next
|
|
1417
|
+
})
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const clearGallerySelection = (): void => {
|
|
1421
|
+
setSelectedGalleryIds(new Set())
|
|
1422
|
+
setGallerySelecting(false)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const exportGalleryJson = (): void => {
|
|
1426
|
+
const entries = gallery.filter(entry => selectedGalleryIds.has(entry.id))
|
|
1427
|
+
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
|
1428
|
+
const url = URL.createObjectURL(blob)
|
|
1429
|
+
const anchor = document.createElement('a')
|
|
1430
|
+
anchor.href = url
|
|
1431
|
+
anchor.download = `dsh-imagegen-gallery-${new Date().toISOString().slice(0, 10)}.json`
|
|
1432
|
+
anchor.click()
|
|
1433
|
+
URL.revokeObjectURL(url)
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const downloadGalleryImages = (): void => {
|
|
1437
|
+
gallery.filter(entry => selectedGalleryIds.has(entry.id)).forEach((entry, index) => {
|
|
1438
|
+
const image = entry.images[0]
|
|
1439
|
+
if (image === undefined) return
|
|
1440
|
+
const anchor = document.createElement('a')
|
|
1441
|
+
anchor.href = image.url
|
|
1442
|
+
anchor.download = `dsh-gallery-${index + 1}.${extensionOf(image.mime)}`
|
|
1443
|
+
anchor.click()
|
|
1444
|
+
})
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const generateDisabled = submitting || modeModels.length === 0
|
|
1448
|
+
const ecommerceSlots = ecommerce.slots.filter(slot => slot.enabled && slot.count > 0)
|
|
1449
|
+
const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
|
|
1450
|
+
const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === ''
|
|
1451
|
+
const ecommerceFileInput = useRef<HTMLInputElement>(null)
|
|
1452
|
+
// The results canvas merges live tasks of the active project with restored
|
|
1453
|
+
// history entries of the same project; restored slots that were regenerated
|
|
1454
|
+
// this session are covered by their live counterparts (same slotKey).
|
|
1455
|
+
const ecommerceProjectTasks = ecommerceProjectId === null
|
|
1456
|
+
? []
|
|
1457
|
+
: tasks.filter(task => task.request.workflow === 'ecommerce' && task.request.projectId === ecommerceProjectId)
|
|
1458
|
+
const liveSlotKeys = new Set(ecommerceProjectTasks.map(task => task.request.slotKey ?? task.id))
|
|
1459
|
+
const ecommerceMergedItems: EcommerceResultItem[] = [
|
|
1460
|
+
...ecommerceProjectTasks.map(task => ({
|
|
1461
|
+
id: task.id,
|
|
1462
|
+
label: task.request.slotLabel ?? '',
|
|
1463
|
+
slotKey: task.request.slotKey ?? '',
|
|
1464
|
+
status: task.status,
|
|
1465
|
+
model: task.request.model,
|
|
1466
|
+
prompt: task.request.prompt,
|
|
1467
|
+
...task.error !== undefined ? { error: task.error } : {},
|
|
1468
|
+
images: task.result?.images ?? [],
|
|
1469
|
+
source: task.request,
|
|
1470
|
+
})),
|
|
1471
|
+
...(ecommerceRestored !== null && ecommerceRestored.projectId === ecommerceProjectId
|
|
1472
|
+
? ecommerceRestored.items.filter(item => !liveSlotKeys.has(item.slotKey))
|
|
1473
|
+
: []),
|
|
1474
|
+
]
|
|
1475
|
+
const ecommerceDoneCount = ecommerceMergedItems.filter(item => item.status === 'completed').length
|
|
1476
|
+
const ecommerceFailedCount = ecommerceMergedItems.filter(item => item.status === 'failed' || item.status === 'cancelled').length
|
|
1477
|
+
const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
|
|
1478
|
+
.filter(label => label !== '')
|
|
1479
|
+
.map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
|
|
1480
|
+
const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null
|
|
1481
|
+
const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
|
|
1482
|
+
const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
|
|
1483
|
+
const previewImage = preview === null ? null : preview.images[preview.index] ?? null
|
|
1484
|
+
const comparisonTasks = comparison === null ? [] : comparison.taskIds.map(id => tasks.find(task => task.id === id)).filter((task): task is GenerationTask => task !== undefined)
|
|
1485
|
+
const comparisonResults = comparisonTasks.filter(task => task.status === 'completed' && task.result !== undefined)
|
|
1486
|
+
const previewFrameScale = Math.max(1, previewScale)
|
|
1487
|
+
const previewImageScale = previewScale / previewFrameScale
|
|
1488
|
+
|
|
1489
|
+
/** Drag the config panel's right edge to resize it (persisted per browser). */
|
|
1490
|
+
const onConfigResizeStart = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
|
1491
|
+
if (event.button !== 0) return
|
|
1492
|
+
event.preventDefault()
|
|
1493
|
+
const aside = configAsideRef.current
|
|
1494
|
+
if (aside === null) return
|
|
1495
|
+
const left = aside.getBoundingClientRect().left
|
|
1496
|
+
const onMove = (move: PointerEvent): void => {
|
|
1497
|
+
const width = Math.round(Math.min(CONFIG_WIDTH_MAX, Math.max(CONFIG_WIDTH_MIN, move.clientX - left)))
|
|
1498
|
+
setConfigWidth(width)
|
|
1499
|
+
try { window.localStorage.setItem(CONFIG_WIDTH_STORAGE_KEY, String(width)) } catch { /* optional */ }
|
|
1500
|
+
}
|
|
1501
|
+
const onUp = (): void => {
|
|
1502
|
+
window.removeEventListener('pointermove', onMove)
|
|
1503
|
+
document.documentElement.style.removeProperty('cursor')
|
|
1504
|
+
document.documentElement.style.removeProperty('user-select')
|
|
1505
|
+
}
|
|
1506
|
+
window.addEventListener('pointermove', onMove)
|
|
1507
|
+
window.addEventListener('pointerup', onUp, { once: true })
|
|
1508
|
+
document.documentElement.style.setProperty('cursor', 'col-resize')
|
|
1509
|
+
document.documentElement.style.setProperty('user-select', 'none')
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const copyPreviewPrompt = async (text: string): Promise<void> => {
|
|
1513
|
+
try {
|
|
1514
|
+
if (navigator.clipboard?.writeText !== undefined) {
|
|
1515
|
+
await navigator.clipboard.writeText(text)
|
|
1516
|
+
} else {
|
|
1517
|
+
const textarea = document.createElement('textarea')
|
|
1518
|
+
textarea.value = text
|
|
1519
|
+
textarea.style.position = 'fixed'
|
|
1520
|
+
textarea.style.opacity = '0'
|
|
1521
|
+
document.body.appendChild(textarea)
|
|
1522
|
+
textarea.select()
|
|
1523
|
+
const copied = document.execCommand('copy')
|
|
1524
|
+
textarea.remove()
|
|
1525
|
+
if (!copied) throw new Error('copy failed')
|
|
1526
|
+
}
|
|
1527
|
+
setPromptCopied(true)
|
|
1528
|
+
window.setTimeout(() => { setPromptCopied(false) }, 1800)
|
|
1529
|
+
} catch {
|
|
1530
|
+
setPromptCopied(false)
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const addPreviewToEdit = (): void => {
|
|
1535
|
+
if (previewImage === null || preview === null) return
|
|
1536
|
+
openTab('edit')
|
|
1537
|
+
setRefImage({
|
|
1538
|
+
dataUrl: srcOf(previewImage),
|
|
1539
|
+
name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
|
|
1540
|
+
})
|
|
1541
|
+
if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
|
|
1542
|
+
setError(null)
|
|
1543
|
+
closePreview()
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Render history into the shell sidebar so it remains a separate navigation
|
|
1547
|
+
// surface from both the image workspace and the native conversation.
|
|
1548
|
+
const historyPanel = (
|
|
1549
|
+
<aside className={css.history} data-dsh-imagegen-history>
|
|
1550
|
+
<header className={css.historyHeader}>
|
|
1551
|
+
<span className={css.historyTitle}>{tt('history.title')}</span>
|
|
1552
|
+
<div className={css.historyHeaderActions}>
|
|
1553
|
+
<button
|
|
1554
|
+
type="button"
|
|
1555
|
+
className={css.historyNew}
|
|
1556
|
+
data-history-new=""
|
|
1557
|
+
aria-label={tt('canvas.new')}
|
|
1558
|
+
title={tt('canvas.newHint')}
|
|
1559
|
+
onClick={startNewCreation}
|
|
1560
|
+
>
|
|
1561
|
+
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10" /></svg>
|
|
1562
|
+
</button>
|
|
1563
|
+
{history.length > 0 ? (
|
|
1564
|
+
<button type="button" className={css.historyClear} data-history-clear="" onClick={() => { void clearHistory() }}>
|
|
1565
|
+
{tt('history.clear')}
|
|
1566
|
+
</button>
|
|
1567
|
+
) : null}
|
|
1568
|
+
</div>
|
|
1569
|
+
</header>
|
|
1570
|
+
|
|
1571
|
+
<div className={css.historyFilters}>
|
|
1572
|
+
<input className={css.historySearch} value={historyQuery} onChange={event => { setHistoryQuery(event.target.value) }} placeholder={tt('history.search')} aria-label={tt('history.search')} />
|
|
1573
|
+
<select value={historyModelFilter} onChange={event => { setHistoryModelFilter(event.target.value) }} aria-label={tt('history.model')}>
|
|
1574
|
+
<option value="all">{tt('history.allModels')}</option>
|
|
1575
|
+
{[...new Set(history.flatMap(entry => modelsOfHistoryEntry(entry)))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
1576
|
+
</select>
|
|
1577
|
+
<select value={historyRatioFilter} onChange={event => { setHistoryRatioFilter(event.target.value) }} aria-label={tt('history.ratio')}>
|
|
1578
|
+
<option value="all">{tt('history.allRatios')}</option>
|
|
1579
|
+
{[...new Set(history.map(entry => normalizeSize(entry.size)))].map(option => <option key={option} value={option}>{option}</option>)}
|
|
1580
|
+
</select>
|
|
1581
|
+
</div>
|
|
1582
|
+
|
|
1583
|
+
{filteredHistory.length === 0 ? (
|
|
1584
|
+
<div className={css.historyEmpty}>{tt('history.empty')}</div>
|
|
1585
|
+
) : (
|
|
1586
|
+
<div className={css.historyList}>
|
|
1587
|
+
{filteredHistory.map(group => {
|
|
1588
|
+
const entry = group.entries[0]!
|
|
1589
|
+
const isComparison = group.models.length > 1
|
|
1590
|
+
const imageCount = group.entries.reduce((total, item) => total + item.images.length, 0)
|
|
1591
|
+
return (
|
|
1592
|
+
<div
|
|
1593
|
+
key={group.key}
|
|
1594
|
+
className={css.historyItem}
|
|
1595
|
+
data-active={group.entries.some(item => item.id === viewingHistoryId) ? '' : undefined}
|
|
1596
|
+
data-comparison={isComparison ? '' : undefined}
|
|
1597
|
+
>
|
|
1598
|
+
<button
|
|
1599
|
+
type="button"
|
|
1600
|
+
className={css.historyMain}
|
|
1601
|
+
data-dsh-imagegen-history-main=""
|
|
1602
|
+
onClick={() => { void viewHistoryGroup(group) }}
|
|
1603
|
+
>
|
|
1604
|
+
{entry.images.length > 0 ? (
|
|
1605
|
+
<img className={css.historyThumb} src={entry.images[0]!.url} alt="" />
|
|
1606
|
+
) : (
|
|
1607
|
+
<span className={css.historyThumbPlaceholder} />
|
|
1608
|
+
)}
|
|
1609
|
+
<span className={css.historyInfo}>
|
|
1610
|
+
<span className={css.historyPrompt}>{entry.prompt}</span>
|
|
1611
|
+
<span className={css.historyMeta}>
|
|
1612
|
+
{isComparison
|
|
1613
|
+
? tt('compare.title')
|
|
1614
|
+
: entry.workflow === 'ecommerce'
|
|
1615
|
+
? `${tt('ecommerce.short')}${entry.projectName !== undefined && entry.projectName !== '' ? ` · ${entry.projectName}` : ''}`
|
|
1616
|
+
: tt(`mode.${entry.mode === 'edit' ? 'edit' : 'text'}` as const)}
|
|
1617
|
+
{' · '}{isComparison ? group.models.join(' · ') : entry.model}
|
|
1618
|
+
{' · '}{formatTime(entry.createdAt)}
|
|
1619
|
+
{' · '}{imageCount} {tt('history.images')}
|
|
1620
|
+
</span>
|
|
1621
|
+
</span>
|
|
1622
|
+
</button>
|
|
1623
|
+
<span className={css.historyActions}>
|
|
1624
|
+
{entry.images.length > 0 ? (
|
|
1625
|
+
<button
|
|
1626
|
+
type="button"
|
|
1627
|
+
className={css.historyAction}
|
|
1628
|
+
disabled={galleryAdding}
|
|
1629
|
+
title={tt('gallery.add')}
|
|
1630
|
+
onClick={() => { void addHistoryEntryToGallery(entry) }}
|
|
1631
|
+
>
|
|
1632
|
+
{tt('gallery.add')}
|
|
1633
|
+
</button>
|
|
1634
|
+
) : null}
|
|
1635
|
+
<button type="button" className={css.historyAction} onClick={() => { void restoreHistoryGroup(group) }}>
|
|
1636
|
+
{tt('history.restore')}
|
|
1637
|
+
</button>
|
|
1638
|
+
<button type="button" className={css.historyAction} data-danger onClick={() => { void deleteHistoryGroup(group) }}>
|
|
1639
|
+
{tt('history.delete')}
|
|
1640
|
+
</button>
|
|
1641
|
+
</span>
|
|
1642
|
+
</div>
|
|
1643
|
+
)
|
|
1644
|
+
})}
|
|
1645
|
+
</div>
|
|
1646
|
+
)}
|
|
1647
|
+
</aside>
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
return (
|
|
1651
|
+
<div className={css.panel}>
|
|
1652
|
+
<header className={css.panelHeader}>
|
|
1653
|
+
<span className={css.panelHeading}>
|
|
1654
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
1655
|
+
<a
|
|
1656
|
+
className={css.githubLink}
|
|
1657
|
+
href="https://github.com/dickpy/dsh-imagegen"
|
|
1658
|
+
target="_blank"
|
|
1659
|
+
rel="noreferrer"
|
|
1660
|
+
title={tt('panel.githubTip')}
|
|
1661
|
+
aria-label={tt('panel.githubTip')}
|
|
1662
|
+
>
|
|
1663
|
+
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
1664
|
+
</a>
|
|
1665
|
+
</span>
|
|
1666
|
+
<nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
|
|
1667
|
+
<button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab !== 'gallery' ? '' : undefined} onClick={() => { if (workspace !== 'normal' || tab === 'gallery') openTab('text') }}>{tt('workspace.normal')}</button>
|
|
1668
|
+
<button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
|
|
1669
|
+
<span className={css.topNavDivider} aria-hidden="true" />
|
|
1670
|
+
<button type="button" className={css.topNavItem} data-active={workspace === 'ecommerce' ? '' : undefined} onClick={() => { setWorkspace('ecommerce') }}>{tt('workspace.ecommerce')}<span className={css.previewBadge}>{tt('ecommerce.badge')}</span></button>
|
|
1671
|
+
</nav>
|
|
1672
|
+
<span className={css.panelHeaderActions}>
|
|
1673
|
+
<button
|
|
1674
|
+
type="button"
|
|
1675
|
+
className={css.chatToggle}
|
|
1676
|
+
data-open={chatOpen ? 'true' : 'false'}
|
|
1677
|
+
aria-pressed={chatOpen}
|
|
1678
|
+
title={chatOpen ? tt('chat.collapse') : tt('chat.expand')}
|
|
1679
|
+
onClick={() => { setChatOpen(open => !open) }}
|
|
1680
|
+
>
|
|
1681
|
+
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 3.5h11v7.5H6.8L3.8 13.6v-2.6H2.5z"/></svg>
|
|
1682
|
+
{tt('chat.toggle')}
|
|
1683
|
+
</button>
|
|
1684
|
+
<button
|
|
1685
|
+
type="button"
|
|
1686
|
+
className={css.connectionStatus}
|
|
1687
|
+
data-connected={connected ? 'true' : 'false'}
|
|
1688
|
+
aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
1689
|
+
>
|
|
1690
|
+
<span className={css.connectionDot} aria-hidden="true" />
|
|
1691
|
+
{tt(connected ? 'connection.connected' : 'connection.disconnected')}
|
|
1692
|
+
</button>
|
|
1693
|
+
</span>
|
|
1694
|
+
</header>
|
|
1695
|
+
|
|
1696
|
+
{update !== null ? (
|
|
1697
|
+
<div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
|
|
1698
|
+
<span className={css.updateText}>
|
|
1699
|
+
{updateMessage ?? tt('update.available', { version: update.latestVersion })}
|
|
1700
|
+
</span>
|
|
1701
|
+
<span className={css.updateActions}>
|
|
1702
|
+
<a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
|
|
1703
|
+
<Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
|
|
1704
|
+
{updating ? tt('update.installing') : tt('update.install')}
|
|
1705
|
+
</Button>
|
|
1706
|
+
</span>
|
|
1707
|
+
</div>
|
|
1708
|
+
) : null}
|
|
1709
|
+
|
|
1710
|
+
<div className={css.studio}>
|
|
1711
|
+
{/* ------------------------------- left history + generation workspace */}
|
|
1712
|
+
<div className={css.generation}>
|
|
1713
|
+
{/* ------------------------------------------------ config sidebar */}
|
|
1714
|
+
<aside
|
|
1715
|
+
ref={configAsideRef}
|
|
1716
|
+
className={css.config}
|
|
1717
|
+
style={{ '--dsh-imagegen-config-width': `${configWidth}px` } as CSSProperties}
|
|
1718
|
+
data-collapsed={configCollapsed ? 'true' : 'false'}
|
|
1719
|
+
data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}
|
|
1720
|
+
>
|
|
1721
|
+
<div className={css.configResizer} title={tt('config.resizeHint')} onPointerDown={onConfigResizeStart} />
|
|
1722
|
+
<div className={css.configHeader}>
|
|
1723
|
+
<button
|
|
1724
|
+
type="button"
|
|
1725
|
+
className={css.configToggle}
|
|
1726
|
+
aria-expanded={!configCollapsed}
|
|
1727
|
+
aria-label={tt(configCollapsed ? 'panel.expandConfig' : 'panel.collapseConfig')}
|
|
1728
|
+
title={tt(configCollapsed ? 'panel.expandConfig' : 'panel.collapseConfig')}
|
|
1729
|
+
onClick={() => { setConfigCollapsed(previous => !previous) }}
|
|
1730
|
+
>
|
|
1731
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
1732
|
+
<path d={configCollapsed ? 'M6 3l5 5-5 5' : 'M10 3L5 8l5 5'} />
|
|
1733
|
+
</svg>
|
|
1734
|
+
</button>
|
|
1735
|
+
</div>
|
|
1736
|
+
{workspace === 'normal' && tab === 'gallery' ? (
|
|
1737
|
+
<div className={css.galleryFilters}>
|
|
1738
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.categories')}</div>
|
|
1739
|
+
{[
|
|
1740
|
+
['all', tt('gallery.all')],
|
|
1741
|
+
['text', tt('mode.text')],
|
|
1742
|
+
['edit', tt('mode.edit')],
|
|
1743
|
+
...galleryModels.map(value => [value, value]),
|
|
1744
|
+
].map(([value, label]) => (
|
|
1745
|
+
<button
|
|
1746
|
+
key={value}
|
|
1747
|
+
type="button"
|
|
1748
|
+
className={css.galleryFilter}
|
|
1749
|
+
data-active={galleryFilter === value ? '' : undefined}
|
|
1750
|
+
onClick={() => { setGalleryFilter(value) }}
|
|
1751
|
+
>
|
|
1752
|
+
<span>{label}</span>
|
|
1753
|
+
<span className={css.galleryFilterCount}>{gallery.filter(entry => value === 'all' || value === 'text' || value === 'edit' ? (value === 'all' ? true : entry.mode === value) : entry.model === value).length}</span>
|
|
1754
|
+
</button>
|
|
1755
|
+
))}
|
|
1756
|
+
<div className={css.galleryFilterDivider} />
|
|
1757
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.ratio')}</div>
|
|
1758
|
+
<div className={css.galleryRatioList}>
|
|
1759
|
+
{(['all', '1:1', '3:4', '4:3', '16:9'] as const).map(ratio => (
|
|
1760
|
+
<button key={ratio} type="button" className={css.galleryRatio} data-active={galleryRatio === ratio ? '' : undefined} onClick={() => { setGalleryRatio(ratio) }}>
|
|
1761
|
+
{ratio === 'all' ? tt('gallery.all') : ratio}
|
|
1762
|
+
</button>
|
|
1763
|
+
))}
|
|
1764
|
+
</div>
|
|
1765
|
+
{galleryTagOptions.length > 0 ? (
|
|
1766
|
+
<>
|
|
1767
|
+
<div className={css.galleryFilterDivider} />
|
|
1768
|
+
<div className={css.galleryFilterHeading}>{tt('gallery.tags')}</div>
|
|
1769
|
+
<div className={css.galleryTagFilterList}>
|
|
1770
|
+
{galleryTagOptions.map(tag => (
|
|
1771
|
+
<button key={tag} type="button" className={css.galleryTagFilter} data-active={galleryTagFilter === tag ? '' : undefined} onClick={() => { setGalleryTagFilter(previous => previous === tag ? null : tag) }}>
|
|
1772
|
+
<span>{tag}</span>
|
|
1773
|
+
<span>{gallery.filter(entry => (entry.tags ?? []).includes(tag)).length}</span>
|
|
1774
|
+
</button>
|
|
1775
|
+
))}
|
|
1776
|
+
</div>
|
|
1777
|
+
</>
|
|
1778
|
+
) : null}
|
|
1779
|
+
<div className={css.galleryFilterNote}>{tt('gallery.filterHint')}</div>
|
|
1780
|
+
</div>
|
|
1781
|
+
) : null}
|
|
1782
|
+
<div className={css.configScroll}>
|
|
1783
|
+
{/* generation sub-modes live inside the normal workspace */}
|
|
1784
|
+
{workspace === 'normal' && tab !== 'gallery' ? (
|
|
1785
|
+
<section className={css.card}>
|
|
1786
|
+
<div className={css.modeRow} role="tablist" aria-label={tt('panel.title')}>
|
|
1787
|
+
<Pill active={tab === 'text'} onClick={() => { setTab('text') }} className={css.modePill}>{tt('mode.text')}</Pill>
|
|
1788
|
+
<Pill active={tab === 'edit'} onClick={() => { setTab('edit') }} className={css.modePill}>{tt('mode.edit')}</Pill>
|
|
1789
|
+
</div>
|
|
1790
|
+
</section>
|
|
1791
|
+
) : null}
|
|
1792
|
+
|
|
1793
|
+
{workspace === 'ecommerce' ? (
|
|
1794
|
+
<section className={css.ecommerceWorkspace} data-ecommerce-workspace="">
|
|
1795
|
+
<div className={css.ecommerceSection}>
|
|
1796
|
+
<h3>{tt('ecommerce.product')}</h3>
|
|
1797
|
+
<label className={css.ecommerceField}>
|
|
1798
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
|
|
1799
|
+
<input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
|
|
1800
|
+
</label>
|
|
1801
|
+
<label className={css.ecommerceField}>
|
|
1802
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.projectName')}</span>
|
|
1803
|
+
<input value={ecommerce.projectName} placeholder={tt('ecommerce.projectName')} onChange={event => setEcommerce(previous => ({ ...previous, projectName: event.target.value }))} />
|
|
1804
|
+
</label>
|
|
1805
|
+
{ecommerceAssets.length === 0 ? (
|
|
1806
|
+
<button
|
|
1807
|
+
type="button"
|
|
1808
|
+
className={css.ecommerceUploadHero}
|
|
1809
|
+
data-ecommerce-upload=""
|
|
1810
|
+
onClick={() => { ecommerceFileInput.current?.click() }}
|
|
1811
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
1812
|
+
onDrop={(event) => {
|
|
1813
|
+
event.preventDefault()
|
|
1814
|
+
acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
|
|
1815
|
+
}}
|
|
1816
|
+
>
|
|
1817
|
+
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
|
|
1818
|
+
<span>{tt('ecommerce.uploadRef')}</span>
|
|
1819
|
+
<small>{tt('edit.uploadHint')}</small>
|
|
1820
|
+
</button>
|
|
1821
|
+
) : (
|
|
1822
|
+
<div className={css.ecommerceAssets}>
|
|
1823
|
+
{ecommerceAssets.map(asset => (
|
|
1824
|
+
<div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
|
|
1825
|
+
<img src={asset.dataUrl} alt={asset.name} />
|
|
1826
|
+
<select
|
|
1827
|
+
value={asset.role}
|
|
1828
|
+
data-ecommerce-asset-role=""
|
|
1829
|
+
aria-label={tt('ecommerce.refSelect')}
|
|
1830
|
+
onChange={event => setEcommerceAssets(previous => previous.map(item => item.id === asset.id ? { ...item, role: event.target.value as EcommerceAssetRole } : item))}
|
|
1831
|
+
>
|
|
1832
|
+
{ECOMMERCE_ASSET_ROLES.map(role => (
|
|
1833
|
+
<option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>
|
|
1834
|
+
))}
|
|
1835
|
+
</select>
|
|
1836
|
+
<button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
|
|
1837
|
+
</div>
|
|
1838
|
+
))}
|
|
1839
|
+
{ecommerceAssets.length < MAX_ECOMMERCE_ASSETS ? (
|
|
1840
|
+
<button
|
|
1841
|
+
type="button"
|
|
1842
|
+
className={css.ecommerceAssetAdd}
|
|
1843
|
+
data-ecommerce-upload=""
|
|
1844
|
+
title={tt('ecommerce.uploadRef')}
|
|
1845
|
+
onClick={() => { ecommerceFileInput.current?.click() }}
|
|
1846
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
1847
|
+
onDrop={(event) => {
|
|
1848
|
+
event.preventDefault()
|
|
1849
|
+
acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
|
|
1850
|
+
}}
|
|
1851
|
+
>
|
|
1852
|
+
<span aria-hidden="true">+</span>
|
|
1853
|
+
<small>{tt('ecommerce.uploadShort')}</small>
|
|
1854
|
+
</button>
|
|
1855
|
+
) : null}
|
|
1856
|
+
</div>
|
|
1857
|
+
)}
|
|
1858
|
+
</div>
|
|
1859
|
+
<div className={css.ecommerceSection}>
|
|
1860
|
+
<h3>{tt('ecommerce.params')}</h3>
|
|
1861
|
+
<div className={css.ecommerceParamGrid}>
|
|
1862
|
+
<label className={css.ecommerceField}>
|
|
1863
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.platformLabel')}</span>
|
|
1864
|
+
<select value={ecommerce.platform} onChange={event => setEcommerce(previous => ({ ...previous, platform: event.target.value }))}><option>通用</option><option>淘宝 / 京东</option><option>Amazon</option></select>
|
|
1865
|
+
</label>
|
|
1866
|
+
<label className={css.ecommerceField}>
|
|
1867
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.languageLabel')}</span>
|
|
1868
|
+
<select value={ecommerce.language} onChange={event => setEcommerce(previous => ({ ...previous, language: event.target.value }))}><option>中文</option><option>English</option></select>
|
|
1869
|
+
</label>
|
|
1870
|
+
<label className={css.ecommerceField}>
|
|
1871
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
|
|
1872
|
+
<select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
|
|
1873
|
+
</label>
|
|
1874
|
+
<label className={css.ecommerceField}>
|
|
1875
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.categoryLabel')}</span>
|
|
1876
|
+
<select value={ecommerce.category} onChange={event => setEcommerce(previous => ({ ...previous, category: event.target.value }))}><option>通用商品</option><option>食品饮料</option><option>美妆个护</option><option>服装配饰</option><option>家居用品</option><option>3C 数码</option></select>
|
|
1877
|
+
</label>
|
|
1878
|
+
</div>
|
|
1879
|
+
</div>
|
|
1880
|
+
<div className={css.ecommerceSection}>
|
|
1881
|
+
<h3>{tt('ecommerce.sellingTitle')}</h3>
|
|
1882
|
+
<textarea value={ecommerce.sellingPoints} placeholder={tt('ecommerce.sellingPoints')} onChange={event => setEcommerce(previous => ({ ...previous, sellingPoints: event.target.value }))} />
|
|
1883
|
+
</div>
|
|
1884
|
+
<div className={css.ecommerceSection}>
|
|
1885
|
+
<h3>{tt('ecommerce.setStructure')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.multiSelect')}</small></h3>
|
|
1886
|
+
<div className={css.ecommerceStructureGrid}>
|
|
1887
|
+
{ecommerce.slots.map(slot => (
|
|
1888
|
+
<button
|
|
1889
|
+
key={slot.key}
|
|
1890
|
+
type="button"
|
|
1891
|
+
className={css.ecommerceSlotCard}
|
|
1892
|
+
data-active={slot.enabled ? '' : undefined}
|
|
1893
|
+
title={`${slot.label}:${slot.description}`}
|
|
1894
|
+
onClick={() => setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, enabled: !item.enabled } : item) }))}
|
|
1895
|
+
>
|
|
1896
|
+
{slot.label}
|
|
1897
|
+
{slot.enabled ? (
|
|
1898
|
+
<span
|
|
1899
|
+
className={css.ecommerceSlotCount}
|
|
1900
|
+
title={tt('ecommerce.countHint')}
|
|
1901
|
+
onClick={event => {
|
|
1902
|
+
event.stopPropagation()
|
|
1903
|
+
setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, count: item.count >= 4 ? 1 : item.count + 1 } : item) }))
|
|
1904
|
+
}}
|
|
1905
|
+
>
|
|
1906
|
+
{slot.count}
|
|
1907
|
+
</span>
|
|
1908
|
+
) : null}
|
|
1909
|
+
</button>
|
|
1910
|
+
))}
|
|
1911
|
+
</div>
|
|
1912
|
+
{ecommerceSlots.length > 0 ? (
|
|
1913
|
+
<>
|
|
1914
|
+
<button type="button" className={css.ecommerceAdvancedToggle} aria-expanded={ecommerceRefOpen} onClick={() => { setEcommerceRefOpen(open => !open) }}>
|
|
1915
|
+
{tt('ecommerce.refSettings')}
|
|
1916
|
+
<span className={css.ecommerceAdvancedChevron} aria-hidden="true">{ecommerceRefOpen ? '⌃' : '⌄'}</span>
|
|
1917
|
+
</button>
|
|
1918
|
+
{ecommerceRefOpen ? (
|
|
1919
|
+
<div className={css.ecommerceAdvancedBody}>
|
|
1920
|
+
{ecommerceSlots.map(slot => (
|
|
1921
|
+
<label key={slot.key} className={css.ecommerceRefRow}>
|
|
1922
|
+
<span>{slot.label}</span>
|
|
1923
|
+
<select value={slot.refRole ?? 'product'} data-ecommerce-ref-select="" aria-label={`${tt('ecommerce.refSelect')} · ${slot.label}`} onChange={event => setEcommerce(previous => ({ ...previous, slots: previous.slots.map(item => item.key === slot.key ? { ...item, refRole: event.target.value as EcommerceRefRole } : item) }))}>
|
|
1924
|
+
<option value="none">{tt('ecommerce.refNone')}</option>
|
|
1925
|
+
{ECOMMERCE_ASSET_ROLES.map(role => <option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>)}
|
|
1926
|
+
</select>
|
|
1927
|
+
</label>
|
|
1928
|
+
))}
|
|
1929
|
+
</div>
|
|
1930
|
+
) : null}
|
|
1931
|
+
</>
|
|
1932
|
+
) : null}
|
|
1933
|
+
</div>
|
|
1934
|
+
<div className={css.ecommerceSection}>
|
|
1935
|
+
<h3>{tt('ecommerce.generation')}</h3>
|
|
1936
|
+
<select value={modeModels.includes(model) ? model : modeModels[0] ?? ''} aria-label={tt('model.label')} onChange={event => setModel(event.target.value)}>{modeModels.map(option => <option key={option} value={option}>{option}</option>)}</select>
|
|
1937
|
+
<div className={css.optionRow}>{QUALITIES.map(option => <Pill key={option} active={quality === option} onClick={() => { setQuality(option) }} className={css.optionPill}>{tt(`quality.${option}` as const)}</Pill>)}</div>
|
|
1938
|
+
</div>
|
|
1939
|
+
<div className={css.ecommerceSection}>
|
|
1940
|
+
<h3>{tt('ecommerce.styleTitle')}</h3>
|
|
1941
|
+
<textarea value={ecommerce.styleHint} placeholder={tt('ecommerce.styleHint')} onChange={event => setEcommerce(previous => ({ ...previous, styleHint: event.target.value }))} />
|
|
1942
|
+
<span className={css.ecommerceFieldLabel}>{tt('ecommerce.protectedLabel')}</span>
|
|
1943
|
+
<textarea value={ecommerce.protectedFeatures} placeholder={tt('ecommerce.protectedFeatures')} onChange={event => setEcommerce(previous => ({ ...previous, protectedFeatures: event.target.value }))} />
|
|
1944
|
+
</div>
|
|
1945
|
+
<input
|
|
1946
|
+
ref={ecommerceFileInput}
|
|
1947
|
+
type="file"
|
|
1948
|
+
multiple
|
|
1949
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
1950
|
+
className={css.hiddenFile}
|
|
1951
|
+
onChange={(event) => {
|
|
1952
|
+
acceptEcommerceFiles(event.target.files ?? undefined)
|
|
1953
|
+
event.target.value = ''
|
|
1954
|
+
}}
|
|
1955
|
+
/>
|
|
1956
|
+
</section>
|
|
1957
|
+
) : null}
|
|
1958
|
+
|
|
1959
|
+
{tab === 'edit' ? (
|
|
1960
|
+
<section className={css.card}>
|
|
1961
|
+
{refImage === null
|
|
1962
|
+
? (
|
|
1963
|
+
<button
|
|
1964
|
+
type="button"
|
|
1965
|
+
className={css.uploadBox}
|
|
1966
|
+
onClick={() => { fileInput.current?.click() }}
|
|
1967
|
+
onDragOver={(event) => { event.preventDefault() }}
|
|
1968
|
+
onDrop={(event) => {
|
|
1969
|
+
event.preventDefault()
|
|
1970
|
+
acceptFile(event.dataTransfer.files?.[0])
|
|
1971
|
+
}}
|
|
1972
|
+
>
|
|
1973
|
+
<span className={css.uploadIcon}>
|
|
1974
|
+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5V3"/><path d="M5 5.5l3-3 3 3"/><path d="M2.5 9v3.5h11V9"/></svg>
|
|
1975
|
+
</span>
|
|
1976
|
+
<span>{tt('edit.upload')}</span>
|
|
1977
|
+
<span className={css.uploadHint}>{tt('edit.uploadHint')}</span>
|
|
1978
|
+
</button>
|
|
1979
|
+
)
|
|
1980
|
+
: (
|
|
1981
|
+
<div className={css.reference}>
|
|
1982
|
+
<img className={css.referenceImage} src={refImage.dataUrl} alt={refImage.name} />
|
|
1983
|
+
<div className={css.referenceActions}>
|
|
1984
|
+
<Button variant="outline" size="sm" onClick={() => { fileInput.current?.click() }}>
|
|
1985
|
+
{tt('edit.change')}
|
|
1986
|
+
</Button>
|
|
1987
|
+
<Button variant="outline" size="sm" onClick={() => { setRefImage(null) }}>
|
|
1988
|
+
{tt('edit.remove')}
|
|
1989
|
+
</Button>
|
|
1990
|
+
</div>
|
|
1991
|
+
</div>
|
|
1992
|
+
)}
|
|
1993
|
+
<input
|
|
1994
|
+
ref={fileInput}
|
|
1995
|
+
type="file"
|
|
1996
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
1997
|
+
className={css.hiddenFile}
|
|
1998
|
+
onChange={(event) => {
|
|
1999
|
+
acceptFile(event.target.files?.[0])
|
|
2000
|
+
event.target.value = ''
|
|
2001
|
+
}}
|
|
2002
|
+
/>
|
|
2003
|
+
</section>
|
|
2004
|
+
) : null}
|
|
2005
|
+
|
|
2006
|
+
{/* prompt (normal workspace only — ecommerce has its own form) */}
|
|
2007
|
+
{workspace === 'normal' ? (<>
|
|
2008
|
+
<section className={css.card}>
|
|
2009
|
+
<textarea
|
|
2010
|
+
className={css.prompt}
|
|
2011
|
+
value={prompt}
|
|
2012
|
+
placeholder={tt('prompt.placeholder')}
|
|
2013
|
+
onChange={(event) => { setPrompt(event.target.value) }}
|
|
2014
|
+
/>
|
|
2015
|
+
<div className={css.promptFooter}>
|
|
2016
|
+
<button
|
|
2017
|
+
type="button"
|
|
2018
|
+
className={css.templatesButton}
|
|
2019
|
+
title={tt('templates.title')}
|
|
2020
|
+
onClick={() => { setLibraryOpen(true) }}
|
|
2021
|
+
>
|
|
2022
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2.5 3.5h11M2.5 8h11M2.5 12.5h7"/></svg>
|
|
2023
|
+
{tt('templates.open')}
|
|
2024
|
+
</button>
|
|
2025
|
+
<button
|
|
2026
|
+
type="button"
|
|
2027
|
+
className={css.enhanceButton}
|
|
2028
|
+
disabled={prompt.trim() === '' || enhancing}
|
|
2029
|
+
title={tt('prompt.enhanceHint')}
|
|
2030
|
+
onClick={() => { void enhanceCurrentPrompt() }}
|
|
2031
|
+
>
|
|
2032
|
+
{enhancing ? tt('prompt.enhancing') : tt('prompt.enhance')}
|
|
2033
|
+
</button>
|
|
2034
|
+
<span className={css.promptCount}>{tt('prompt.count', { count: prompt.length })}</span>
|
|
2035
|
+
</div>
|
|
2036
|
+
</section>
|
|
2037
|
+
|
|
2038
|
+
{/* parameters */}
|
|
2039
|
+
<section className={css.card}>
|
|
2040
|
+
<div className={css.paramGroup}>
|
|
2041
|
+
<span className={css.paramLabel}>{tt('params.size')}</span>
|
|
2042
|
+
<div className={css.optionGrid}>
|
|
2043
|
+
{SIZES.map(option => (
|
|
2044
|
+
<Pill
|
|
2045
|
+
key={option}
|
|
2046
|
+
active={size === option}
|
|
2047
|
+
onClick={() => { setSize(option) }}
|
|
2048
|
+
className={css.optionPill}
|
|
2049
|
+
>
|
|
2050
|
+
{tt(SIZE_KEYS[option] ?? 'size.auto')}
|
|
2051
|
+
</Pill>
|
|
2052
|
+
))}
|
|
2053
|
+
</div>
|
|
2054
|
+
</div>
|
|
2055
|
+
<div className={css.paramGroup}>
|
|
2056
|
+
<span className={css.paramLabel}>{tt('params.quality')}</span>
|
|
2057
|
+
<div className={css.optionRow}>
|
|
2058
|
+
{QUALITIES.map(option => (
|
|
2059
|
+
<Pill
|
|
2060
|
+
key={option}
|
|
2061
|
+
active={quality === option}
|
|
2062
|
+
onClick={() => { setQuality(option) }}
|
|
2063
|
+
className={css.optionPill}
|
|
2064
|
+
>
|
|
2065
|
+
{tt(`quality.${option}` as const)}
|
|
2066
|
+
</Pill>
|
|
2067
|
+
))}
|
|
2068
|
+
</div>
|
|
2069
|
+
</div>
|
|
2070
|
+
<div className={css.paramGroup}>
|
|
2071
|
+
<span className={css.paramLabel}>{tt('params.count')}</span>
|
|
2072
|
+
<div className={css.optionRow}>
|
|
2073
|
+
{[1, 2, 3, 4].map(option => (
|
|
2074
|
+
<Pill
|
|
2075
|
+
key={option}
|
|
2076
|
+
active={count === option}
|
|
2077
|
+
onClick={() => { setCount(option) }}
|
|
2078
|
+
className={css.optionPill}
|
|
2079
|
+
>
|
|
2080
|
+
{tt(`count.${option === 1 ? 'one' : option === 2 ? 'two' : option === 3 ? 'three' : 'four'}` as const)}
|
|
2081
|
+
</Pill>
|
|
2082
|
+
))}
|
|
2083
|
+
</div>
|
|
2084
|
+
</div>
|
|
2085
|
+
<div className={css.paramGroup}>
|
|
2086
|
+
<span className={css.paramLabel}>{tt('params.detail')}</span>
|
|
2087
|
+
<div className={css.optionRow}>
|
|
2088
|
+
{DETAILS.map(option => (
|
|
2089
|
+
<Pill
|
|
2090
|
+
key={option === '' ? 'auto' : option}
|
|
2091
|
+
active={detail === option}
|
|
2092
|
+
onClick={() => { setDetail(option) }}
|
|
2093
|
+
className={css.optionPill}
|
|
2094
|
+
>
|
|
2095
|
+
{tt(option === '' ? 'detail.auto' : option === 'standard' ? 'detail.standard' : 'detail.high')}
|
|
2096
|
+
</Pill>
|
|
2097
|
+
))}
|
|
2098
|
+
</div>
|
|
2099
|
+
<span className={css.paramHint}>{tt('detail.hint')}</span>
|
|
2100
|
+
</div>
|
|
2101
|
+
</section>
|
|
2102
|
+
</>) : null}
|
|
2103
|
+
</div>
|
|
2104
|
+
|
|
2105
|
+
{/* footer: model + generate — a fixed sibling of the scroll area, so
|
|
2106
|
+
it never overlaps the cards scrolling above it. */}
|
|
2107
|
+
<section className={css.footer}>
|
|
2108
|
+
{workspace === 'ecommerce' ? (
|
|
2109
|
+
<div className={css.ecommerceFooterBody}>
|
|
2110
|
+
{ecommercePreview ? (
|
|
2111
|
+
<>
|
|
2112
|
+
<div className={css.ecommercePlanMini}>
|
|
2113
|
+
<strong>{tt('ecommerce.planTitle', { count: ecommerceTotal })}</strong>
|
|
2114
|
+
<div className={css.ecommercePlanList}>
|
|
2115
|
+
{ecommerceSlots.map(slot => <div key={slot.key}><span>{slot.label}</span><span>×{slot.count}</span></div>)}
|
|
2116
|
+
</div>
|
|
2117
|
+
<div className={css.ecommercePlanNote}>{tt('ecommerce.anchorNote')}</div>
|
|
2118
|
+
{ecommerceAssets.length === 0 ? <div className={css.ecommercePlanWarn}>{tt('ecommerce.noAssetWarn')}</div> : null}
|
|
2119
|
+
</div>
|
|
2120
|
+
<Button variant="primary" size="md" className={css.ecommercePrimaryAction} disabled={ecommerceGenerateDisabled} onClick={() => { void handleEcommerceGenerate() }}>{ecommerceGenerating ? tt('generating') : tt('ecommerce.confirm')}</Button>
|
|
2121
|
+
<button type="button" className={css.ecommercePlanBack} onClick={() => { setEcommercePreview(false) }}>{tt('gallery.tagsCancel')}</button>
|
|
2122
|
+
</>
|
|
2123
|
+
) : (
|
|
2124
|
+
<>
|
|
2125
|
+
<span className={css.ecommerceFooterHint}>{ecommerceTotal > 0 ? tt('ecommerce.footerReady', { count: ecommerceTotal }) : tt('ecommerce.footerEmpty')}</span>
|
|
2126
|
+
<Button variant="primary" size="md" className={css.ecommercePrimaryAction} disabled={ecommerce.productName.trim() === '' || ecommerceTotal === 0} onClick={() => setEcommercePreview(true)}>{tt('ecommerce.preview')}</Button>
|
|
2127
|
+
</>
|
|
2128
|
+
)}
|
|
2129
|
+
</div>
|
|
2130
|
+
) : null}
|
|
2131
|
+
{workspace === 'ecommerce' ? null : <label className={css.modelWrap}>
|
|
2132
|
+
<span className={css.modelLabel}>{tt('model.label')}</span>
|
|
2133
|
+
<span ref={modelMenuRef} className={css.modelMenu} data-open={modelOpen ? 'true' : 'false'}>
|
|
2134
|
+
<button
|
|
2135
|
+
type="button"
|
|
2136
|
+
className={css.modelSelect}
|
|
2137
|
+
disabled={submitting}
|
|
2138
|
+
aria-haspopup="listbox"
|
|
2139
|
+
aria-expanded={modelOpen}
|
|
2140
|
+
onClick={() => { setModelOpen(open => !open) }}
|
|
2141
|
+
>
|
|
2142
|
+
<span>{model || tt('model.noEditModels')}</span>
|
|
2143
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10.5L4 6h8z"/></svg>
|
|
2144
|
+
</button>
|
|
2145
|
+
{modelOpen ? (
|
|
2146
|
+
<div className={css.modelMenuList} role="listbox" aria-label={tt('model.label')}>
|
|
2147
|
+
{modeModels.map(option => (
|
|
2148
|
+
<button
|
|
2149
|
+
key={option}
|
|
2150
|
+
type="button"
|
|
2151
|
+
role="option"
|
|
2152
|
+
aria-selected={model === option}
|
|
2153
|
+
className={css.modelMenuItem}
|
|
2154
|
+
data-selected={model === option ? '' : undefined}
|
|
2155
|
+
onClick={() => { setModel(option); setModelOpen(false) }}
|
|
2156
|
+
>
|
|
2157
|
+
{option}
|
|
2158
|
+
</button>
|
|
2159
|
+
))}
|
|
2160
|
+
</div>
|
|
2161
|
+
) : null}
|
|
2162
|
+
</span>
|
|
2163
|
+
</label>}
|
|
2164
|
+
{workspace !== 'ecommerce' ? <div className={css.compareControl}>
|
|
2165
|
+
<label className={css.compareToggle}>
|
|
2166
|
+
<input type="checkbox" checked={compareEnabled} onChange={event => { setCompareEnabled(event.target.checked) }} />
|
|
2167
|
+
<span>{tt('compare.enable')}</span>
|
|
2168
|
+
</label>
|
|
2169
|
+
{compareEnabled ? (
|
|
2170
|
+
<div className={css.compareModelChoices} role="group" aria-label={tt('compare.models')}>
|
|
2171
|
+
{modeModels.map(option => (
|
|
2172
|
+
<label key={option}>
|
|
2173
|
+
<input type="checkbox" checked={compareModels.includes(option)} onChange={() => { setCompareModels(previous => previous.includes(option) ? previous.filter(value => value !== option) : [...previous, option]) }} />
|
|
2174
|
+
<span>{option}</span>
|
|
2175
|
+
</label>
|
|
2176
|
+
))}
|
|
2177
|
+
</div>
|
|
2178
|
+
) : null}
|
|
2179
|
+
</div> : null}
|
|
2180
|
+
{workspace !== 'ecommerce' ? <Button
|
|
2181
|
+
variant="primary"
|
|
2182
|
+
size="md"
|
|
2183
|
+
className={css.generateButton}
|
|
2184
|
+
disabled={generateDisabled}
|
|
2185
|
+
onClick={() => { void handleGenerate() }}
|
|
2186
|
+
>
|
|
2187
|
+
{generating ? (
|
|
2188
|
+
<span className={css.generateInner}>
|
|
2189
|
+
<span className={css.spinner} />
|
|
2190
|
+
{tt('generating')}
|
|
2191
|
+
</span>
|
|
2192
|
+
) : tt('generate')}
|
|
2193
|
+
</Button> : null}
|
|
2194
|
+
</section>
|
|
2195
|
+
</aside>
|
|
2196
|
+
|
|
2197
|
+
{/* ------------------------------------------------------- canvas */}
|
|
2198
|
+
<section className={css.canvas} data-gallery={workspace === 'normal' && tab === 'gallery' ? 'true' : undefined}>
|
|
2199
|
+
{workspace === 'normal' && tab === 'gallery' ? (
|
|
2200
|
+
<div className={css.galleryWorkspace}>
|
|
2201
|
+
<header className={css.galleryToolbar}>
|
|
2202
|
+
<div>
|
|
2203
|
+
<h3 className={css.galleryHeading}>{tt('gallery.all')}</h3>
|
|
2204
|
+
<span className={css.galleryCount}>{tt('gallery.count', { count: filteredGallery.length })}</span>
|
|
2205
|
+
</div>
|
|
2206
|
+
<div className={css.galleryToolbarActions}>
|
|
2207
|
+
<input className={css.gallerySearch} value={galleryQuery} onChange={event => { setGalleryQuery(event.target.value) }} placeholder={tt('gallery.search')} aria-label={tt('gallery.search')} />
|
|
2208
|
+
<button type="button" className={css.gallerySelectMode} data-active={gallerySelecting ? '' : undefined} aria-pressed={gallerySelecting} onClick={() => { setGallerySelecting(previous => !previous) }}>
|
|
2209
|
+
{gallerySelecting ? tt('gallery.selectionDone') : tt('gallery.select')}
|
|
2210
|
+
</button>
|
|
2211
|
+
<div className={css.galleryViewToggle} role="group" aria-label={tt('gallery.viewMode')}>
|
|
2212
|
+
<button type="button" data-active={galleryView === 'masonry' ? '' : undefined} onClick={() => { setGalleryView('masonry') }} title={tt('gallery.masonry')}>
|
|
2213
|
+
<span aria-hidden="true">▦</span> {tt('gallery.masonry')}
|
|
2214
|
+
</button>
|
|
2215
|
+
<button type="button" data-active={galleryView === 'grid' ? '' : undefined} onClick={() => { setGalleryView('grid') }} title={tt('gallery.grid')}>
|
|
2216
|
+
<span aria-hidden="true">▤</span> {tt('gallery.grid')}
|
|
2217
|
+
</button>
|
|
2218
|
+
</div>
|
|
2219
|
+
<select className={css.gallerySort} value={gallerySort} onChange={event => { setGallerySort(event.target.value as 'newest' | 'oldest') }} aria-label={tt('gallery.sort')}>
|
|
2220
|
+
<option value="newest">{tt('gallery.newest')}</option>
|
|
2221
|
+
<option value="oldest">{tt('gallery.oldest')}</option>
|
|
2222
|
+
</select>
|
|
2223
|
+
{gallery.length > 0 ? <button type="button" className={css.galleryClear} data-gallery-clear="" onClick={() => { void clearGalleryAll() }}>{tt('gallery.clear')}</button> : null}
|
|
2224
|
+
</div>
|
|
2225
|
+
</header>
|
|
2226
|
+
{selectedGalleryIds.size > 0 ? (
|
|
2227
|
+
<section className={css.gallerySelectionBar} aria-label={tt('gallery.selected', { count: selectedGalleryIds.size })}>
|
|
2228
|
+
<strong>{tt('gallery.selected', { count: selectedGalleryIds.size })}</strong>
|
|
2229
|
+
<input className={css.galleryTagInput} value={galleryTagInput} onChange={event => { setGalleryTagInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} />
|
|
2230
|
+
<button type="button" className={css.galleryBulkButton} disabled={galleryTagInput.trim() === ''} onClick={() => { void applyGalleryTags() }}>{tt('gallery.tagsApply')}</button>
|
|
2231
|
+
<button type="button" className={css.galleryBulkButton} onClick={downloadGalleryImages}>{tt('gallery.downloadSelected')}</button>
|
|
2232
|
+
<button type="button" className={css.galleryBulkButton} onClick={exportGalleryJson}>{tt('gallery.exportJson')}</button>
|
|
2233
|
+
<button type="button" className={css.gallerySelectionClear} onClick={clearGallerySelection}>{tt('gallery.selectionClear')}</button>
|
|
2234
|
+
</section>
|
|
2235
|
+
) : null}
|
|
2236
|
+
{filteredGallery.length === 0 ? (
|
|
2237
|
+
<div className={css.historyEmpty}>{tt('gallery.empty')}</div>
|
|
2238
|
+
) : (
|
|
2239
|
+
<div className={css.galleryMasonry} data-view={galleryView}>
|
|
2240
|
+
{filteredGallery.map(entry => {
|
|
2241
|
+
const image = entry.images[0]
|
|
2242
|
+
if (image === undefined) return null
|
|
2243
|
+
return (
|
|
2244
|
+
<article key={entry.id} className={css.galleryCard} data-selected={selectedGalleryIds.has(entry.id) ? '' : undefined}>
|
|
2245
|
+
<label className={css.gallerySelect} title={tt('gallery.select')}>
|
|
2246
|
+
<input type="checkbox" checked={selectedGalleryIds.has(entry.id)} onChange={() => { setGallerySelecting(true); toggleGallerySelection(entry.id) }} />
|
|
2247
|
+
</label>
|
|
2248
|
+
<button type="button" className={css.galleryImageButton} data-selecting={gallerySelecting ? '' : undefined} onClick={() => { if (gallerySelecting) toggleGallerySelection(entry.id); else void viewGalleryEntry(entry) }} title={gallerySelecting ? tt('gallery.select') : tt('preview.open')}>
|
|
2249
|
+
<img className={css.galleryImage} src={image.url} alt={entry.prompt} />
|
|
2250
|
+
<span className={css.galleryBadge}>{entry.mode === 'edit' ? tt('mode.edit') : tt('mode.text')}</span>
|
|
2251
|
+
</button>
|
|
2252
|
+
<div className={css.galleryCardActions}>
|
|
2253
|
+
<button
|
|
2254
|
+
type="button"
|
|
2255
|
+
className={css.galleryCardAction}
|
|
2256
|
+
data-gallery-add-conversation=""
|
|
2257
|
+
disabled={conversationBusy}
|
|
2258
|
+
title={tt('conversation.addHint')}
|
|
2259
|
+
onClick={(event) => { event.stopPropagation(); void addGalleryEntryToConversation(entry) }}
|
|
2260
|
+
>
|
|
2261
|
+
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5h10v7H3z"/><path d="M5.5 2.5h5M8 6v4M6 8h4"/></svg>
|
|
2262
|
+
{galleryConversationAddingId === entry.id || addingToConversation === `gallery:${entry.id}` ? tt('conversation.adding') : tt('conversation.add')}
|
|
2263
|
+
</button>
|
|
2264
|
+
</div>
|
|
2265
|
+
<div className={css.galleryCardFooter}>
|
|
2266
|
+
<span className={css.galleryAvatar}>{entry.model.toLowerCase().startsWith('nanobanana') ? 'N' : entry.model.toLowerCase().startsWith('seedream') ? 'S' : entry.model.startsWith('grok') ? 'G' : 'D'}</span>
|
|
2267
|
+
<span className={css.galleryCardInfo}>
|
|
2268
|
+
<strong>{entry.prompt || tt('gallery.untitled')}</strong>
|
|
2269
|
+
<small>{entry.model} · {normalizeSize(entry.size)} · {formatTime(entry.createdAt)}</small>
|
|
2270
|
+
<span className={css.galleryTags}>
|
|
2271
|
+
{(entry.tags ?? []).map(tag => <button key={tag} type="button" onClick={() => { setGalleryTagFilter(tag) }}>{tag}</button>)}
|
|
2272
|
+
<button type="button" className={css.galleryTagEdit} onClick={() => { startEditingGalleryTags(entry) }} title={tt('gallery.editTags')}>{tt('gallery.tagsEditShort')}</button>
|
|
2273
|
+
</span>
|
|
2274
|
+
</span>
|
|
2275
|
+
<button type="button" className={css.galleryRemove} onClick={() => { void deleteGalleryEntry(entry.id) }} title={tt('gallery.delete')}>×</button>
|
|
2276
|
+
</div>
|
|
2277
|
+
{editingGalleryTagsId === entry.id ? (
|
|
2278
|
+
<form className={css.galleryTagEditor} onSubmit={event => { event.preventDefault(); void saveGalleryTags(entry.id) }}>
|
|
2279
|
+
<input value={galleryTagEditInput} onChange={event => { setGalleryTagEditInput(event.target.value) }} placeholder={tt('gallery.tagsPlaceholder')} aria-label={tt('gallery.tagsPlaceholder')} autoFocus />
|
|
2280
|
+
<button type="submit">{tt('gallery.tagsSave')}</button>
|
|
2281
|
+
<button type="button" onClick={() => { setEditingGalleryTagsId(null); setGalleryTagEditInput('') }}>{tt('gallery.tagsCancel')}</button>
|
|
2282
|
+
</form>
|
|
2283
|
+
) : null}
|
|
2284
|
+
</article>
|
|
2285
|
+
)
|
|
2286
|
+
})}
|
|
2287
|
+
</div>
|
|
2288
|
+
)}
|
|
2289
|
+
</div>
|
|
2290
|
+
) : null}
|
|
2291
|
+
{workspace === 'ecommerce' ? (
|
|
2292
|
+
<div className={css.ecommerceResults} data-ecommerce-results="">
|
|
2293
|
+
<header className={css.ecommerceResultsHeader}>
|
|
2294
|
+
<div>
|
|
2295
|
+
<h3>{tt('ecommerce.results.title')}</h3>
|
|
2296
|
+
{ecommerceRestored !== null && ecommerceRestored.projectId === ecommerceProjectId && ecommerceRestored.projectName !== '' ? (
|
|
2297
|
+
<span>{ecommerceRestored.projectName}</span>
|
|
2298
|
+
) : null}
|
|
2299
|
+
{ecommerceMergedItems.length > 0 ? (
|
|
2300
|
+
<span>
|
|
2301
|
+
{tt('ecommerce.results.progress', { done: ecommerceDoneCount, total: ecommerceMergedItems.length })}
|
|
2302
|
+
{ecommerceFailedCount > 0 ? ` · ${tt('ecommerce.results.failed', { count: ecommerceFailedCount })}` : ''}
|
|
2303
|
+
</span>
|
|
2304
|
+
) : null}
|
|
2305
|
+
{ecommerceAnchor !== null ? <span data-ecommerce-anchor="">{tt('ecommerce.anchorPending')}</span> : null}
|
|
2306
|
+
</div>
|
|
2307
|
+
<div className={css.ecommerceResultsActions}>
|
|
2308
|
+
{ecommerceMergedItems.length > 0 ? <button type="button" className={css.galleryBulkButton} data-ecommerce-export="" onClick={exportEcommerceManifest}>{tt('ecommerce.results.export')}</button> : null}
|
|
2309
|
+
<button type="button" className={css.galleryBulkButton} data-ecommerce-new="" onClick={newEcommerceProduct}>{tt('ecommerce.results.newProduct')}</button>
|
|
2310
|
+
</div>
|
|
2311
|
+
</header>
|
|
2312
|
+
{ecommerceMergedItems.length === 0 ? (
|
|
2313
|
+
<div className={css.ecommerceResultsEmpty}>{tt('ecommerce.results.empty')}</div>
|
|
2314
|
+
) : (
|
|
2315
|
+
<div className={css.ecommerceGroups}>
|
|
2316
|
+
{ecommerceResultGroups.map(group => (
|
|
2317
|
+
<section key={group.label} className={css.ecommerceGroup} data-ecommerce-group={group.label}>
|
|
2318
|
+
<header>
|
|
2319
|
+
<strong>{group.label}</strong>
|
|
2320
|
+
<span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
|
|
2321
|
+
<button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
|
|
2322
|
+
</header>
|
|
2323
|
+
<div className={css.ecommerceGroupGrid}>
|
|
2324
|
+
{group.items.map(item => (
|
|
2325
|
+
<div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
|
|
2326
|
+
{item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
|
|
2327
|
+
<figure
|
|
2328
|
+
key={imageIndex}
|
|
2329
|
+
className={css.imageCard}
|
|
2330
|
+
role="button"
|
|
2331
|
+
tabIndex={0}
|
|
2332
|
+
title={tt('preview.open')}
|
|
2333
|
+
onClick={() => { openPreview(item.images, imageIndex) }}
|
|
2334
|
+
>
|
|
2335
|
+
<img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
|
|
2336
|
+
<span className={css.ecommerceResultBadge}>{group.label}</span>
|
|
2337
|
+
<span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
|
|
2338
|
+
<a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
|
|
2339
|
+
<button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
|
|
2340
|
+
<button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
|
|
2341
|
+
</span>
|
|
2342
|
+
</figure>
|
|
2343
|
+
)) : (
|
|
2344
|
+
<span className={css.ecommerceTaskState}>
|
|
2345
|
+
<b>{group.label}</b>
|
|
2346
|
+
{tt(`tasks.${item.status}` as never)}
|
|
2347
|
+
{item.error !== undefined ? ` · ${item.error}` : ''}
|
|
2348
|
+
</span>
|
|
2349
|
+
)}
|
|
2350
|
+
</div>
|
|
2351
|
+
))}
|
|
2352
|
+
</div>
|
|
2353
|
+
</section>
|
|
2354
|
+
))}
|
|
2355
|
+
</div>
|
|
2356
|
+
)}
|
|
2357
|
+
</div>
|
|
2358
|
+
) : null}
|
|
2359
|
+
{(workspace === 'ecommerce' || tab !== 'gallery') && tasks.length > 0 ? (
|
|
2360
|
+
<section className={css.taskTray} data-open={taskTrayOpen ? 'true' : 'false'} aria-label={tt('tasks.title')}>
|
|
2361
|
+
<header className={css.taskTrayHeader}>
|
|
2362
|
+
<button type="button" className={css.taskTrayToggle} aria-expanded={taskTrayOpen} onClick={() => { setTaskTrayOpen(open => !open) }}>
|
|
2363
|
+
<span>{tt('tasks.title')}</span>
|
|
2364
|
+
<span className={css.taskTrayCount}>{activeTasks.length}</span>
|
|
2365
|
+
<span className={css.taskTrayChevron} aria-hidden="true">{taskTrayOpen ? '⌃' : '⌄'}</span>
|
|
2366
|
+
</button>
|
|
2367
|
+
{taskTrayOpen ? <button type="button" className={css.taskTrayClose} aria-label={tt('preview.close')} onClick={() => { setTaskTrayOpen(false) }}>×</button> : null}
|
|
2368
|
+
</header>
|
|
2369
|
+
<div className={css.taskRows}>
|
|
2370
|
+
{tasks.slice(0, 5).map(task => (
|
|
2371
|
+
<div key={task.id} className={css.taskRow} data-status={task.status}>
|
|
2372
|
+
<span className={css.taskStatus}>{tt(`tasks.${task.status}` as never)}</span>
|
|
2373
|
+
<span className={css.taskPrompt}>{task.request.prompt}</span>
|
|
2374
|
+
{(task.status === 'queued' || task.status === 'running') ? <button type="button" onClick={() => { void api.taskCancel(task.id) }}>{tt('tasks.cancel')}</button> : null}
|
|
2375
|
+
{task.status === 'failed' || task.status === 'cancelled' ? <button type="button" onClick={() => { void api.taskRetry(task.id) }}>{tt('tasks.retry')}</button> : null}
|
|
2376
|
+
</div>
|
|
2377
|
+
))}
|
|
2378
|
+
</div>
|
|
2379
|
+
</section>
|
|
2380
|
+
) : null}
|
|
2381
|
+
{workspace === 'normal' && tab !== 'gallery' && comparison !== null ? (
|
|
2382
|
+
<section className={css.comparisonBoard} aria-label={tt('compare.title')}>
|
|
2383
|
+
<header><div><strong>{tt('compare.title')}</strong><span>{comparisonResults.length} / {comparisonTasks.length}{generating ? ` · ${tt('canvas.elapsed', { seconds: elapsed })}` : ''}</span></div><button type="button" disabled={comparisonResults.length === 0} onClick={() => { setComparisonFullscreen(true) }}>{tt('compare.fullscreen')}</button></header>
|
|
2384
|
+
<div className={css.comparisonGrid}>
|
|
2385
|
+
{comparisonTasks.map(task => {
|
|
2386
|
+
const taskImages = task.result?.images ?? []
|
|
2387
|
+
const image = taskImages[0]
|
|
2388
|
+
return (
|
|
2389
|
+
<article key={task.id}>
|
|
2390
|
+
<strong>{task.request.model}</strong>
|
|
2391
|
+
{image !== undefined ? (
|
|
2392
|
+
<button
|
|
2393
|
+
type="button"
|
|
2394
|
+
className={css.comparisonImageButton}
|
|
2395
|
+
title={tt('preview.open')}
|
|
2396
|
+
onClick={() => { openPreview(taskImages, 0) }}
|
|
2397
|
+
>
|
|
2398
|
+
<img src={srcOf(image)} alt={task.request.model} />
|
|
2399
|
+
</button>
|
|
2400
|
+
) : <span>{tt(`tasks.${task.status}` as never)}</span>}
|
|
2401
|
+
</article>
|
|
2402
|
+
)
|
|
2403
|
+
})}
|
|
2404
|
+
</div>
|
|
2405
|
+
</section>
|
|
2406
|
+
) : null}
|
|
2407
|
+
{generating && comparison === null && workspace !== 'ecommerce' ? (
|
|
2408
|
+
<div className={css.canvasState} data-generation-state={activeTask?.status ?? 'submitting'} role="status">
|
|
2409
|
+
<span className={css.bigSpinner} />
|
|
2410
|
+
<span className={css.canvasStateTitle}>
|
|
2411
|
+
{submitting && activeTask === undefined
|
|
2412
|
+
? tt('canvas.submitting')
|
|
2413
|
+
: activeTask?.status === 'queued'
|
|
2414
|
+
? tt('canvas.queued')
|
|
2415
|
+
: tt('canvas.generating')}
|
|
2416
|
+
</span>
|
|
2417
|
+
<span className={css.canvasStateHint}>
|
|
2418
|
+
{activeTask?.status === 'queued'
|
|
2419
|
+
? tt('canvas.queueHint', { count: activeTasks.length })
|
|
2420
|
+
: tt('canvas.elapsed', { seconds: elapsed })}
|
|
2421
|
+
</span>
|
|
2422
|
+
</div>
|
|
2423
|
+
) : null}
|
|
2424
|
+
|
|
2425
|
+
{!generating && error !== null ? (
|
|
2426
|
+
<div className={css.canvasError} role="alert">{tt('canvas.error', { error })}</div>
|
|
2427
|
+
) : null}
|
|
2428
|
+
|
|
2429
|
+
{!generating && !error && images.length === 0 && workspace !== 'ecommerce' ? (
|
|
2430
|
+
<div className={css.canvasState}>
|
|
2431
|
+
<span className={css.canvasEmptyIcon}>
|
|
2432
|
+
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
|
2433
|
+
</span>
|
|
2434
|
+
<span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
|
|
2435
|
+
<span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
|
|
2436
|
+
</div>
|
|
2437
|
+
) : null}
|
|
2438
|
+
|
|
2439
|
+
{!generating && images.length > 0 && workspace !== 'ecommerce' ? (
|
|
2440
|
+
<div className={css.canvasBody}>
|
|
2441
|
+
<div className={css.canvasMeta}>
|
|
2442
|
+
<span>{tt('canvas.images', { count: images.length })}</span>
|
|
2443
|
+
{viewingEntry !== null || viewingGalleryEntry !== null ? (
|
|
2444
|
+
<span className={css.canvasHistoryTag}>
|
|
2445
|
+
{viewingEntry !== null
|
|
2446
|
+
? tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })
|
|
2447
|
+
: tt('gallery.viewing', { time: formatTime(viewingGalleryEntry!.createdAt) })}
|
|
2448
|
+
</span>
|
|
2449
|
+
) : null}
|
|
2450
|
+
</div>
|
|
2451
|
+
<div className={css.grid} data-count={images.length}>
|
|
2452
|
+
{images.map((image, index) => (
|
|
2453
|
+
<figure
|
|
2454
|
+
key={index}
|
|
2455
|
+
className={css.imageCard}
|
|
2456
|
+
role="button"
|
|
2457
|
+
tabIndex={0}
|
|
2458
|
+
title={tt('preview.open')}
|
|
2459
|
+
onClick={() => { openPreview(images, index) }}
|
|
2460
|
+
onKeyDown={(event) => {
|
|
2461
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
2462
|
+
event.preventDefault()
|
|
2463
|
+
openPreview(images, index)
|
|
2464
|
+
}
|
|
2465
|
+
}}
|
|
2466
|
+
>
|
|
2467
|
+
<img
|
|
2468
|
+
className={css.image}
|
|
2469
|
+
src={srcOf(image)}
|
|
2470
|
+
alt={image.revisedPrompt ?? `${tt('panel.title')} ${index + 1}`}
|
|
2471
|
+
/>
|
|
2472
|
+
{image.revisedPrompt !== undefined ? (
|
|
2473
|
+
<figcaption className={css.imageCaption} title={image.revisedPrompt}>
|
|
2474
|
+
{tt('revisedPrompt', { prompt: image.revisedPrompt })}
|
|
2475
|
+
</figcaption>
|
|
2476
|
+
) : null}
|
|
2477
|
+
<span className={css.zoomHint}>
|
|
2478
|
+
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="7" cy="7" r="4"/><path d="M13 13l-3.2-3.2"/><path d="M7 5.4v3.2M5.4 7h3.2"/></svg>
|
|
2479
|
+
{tt('preview.open')}
|
|
2480
|
+
</span>
|
|
2481
|
+
<button
|
|
2482
|
+
type="button"
|
|
2483
|
+
className={css.galleryAdd}
|
|
2484
|
+
title={tt('gallery.add')}
|
|
2485
|
+
disabled={galleryAdding}
|
|
2486
|
+
onClick={(event) => { event.stopPropagation(); void addToGallery(image) }}
|
|
2487
|
+
>
|
|
2488
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
2489
|
+
{tt('gallery.add')}
|
|
2490
|
+
</button>
|
|
2491
|
+
<button
|
|
2492
|
+
type="button"
|
|
2493
|
+
className={css.conversationAdd}
|
|
2494
|
+
title={tt('conversation.add')}
|
|
2495
|
+
disabled={conversationBusy}
|
|
2496
|
+
onClick={(event) => { event.stopPropagation(); void addImageToConversation(image, index) }}
|
|
2497
|
+
>
|
|
2498
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5h10v7H3z"/><path d="M5.5 2.5h5M8 6v4M6 8h4"/></svg>
|
|
2499
|
+
{addingToConversation === index ? tt('conversation.adding') : tt('conversation.add')}
|
|
2500
|
+
</button>
|
|
2501
|
+
<a
|
|
2502
|
+
className={css.download}
|
|
2503
|
+
href={srcOf(image)}
|
|
2504
|
+
download={`dsh-image-${index + 1}.${extensionOf(image.mime)}`}
|
|
2505
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
2506
|
+
>
|
|
2507
|
+
{tt('download')}
|
|
2508
|
+
</a>
|
|
2509
|
+
</figure>
|
|
2510
|
+
))}
|
|
2511
|
+
</div>
|
|
2512
|
+
</div>
|
|
2513
|
+
) : null}
|
|
2514
|
+
</section>
|
|
2515
|
+
</div>
|
|
2516
|
+
|
|
2517
|
+
</div>
|
|
2518
|
+
|
|
2519
|
+
{sidebarHistoryHost !== null && historyPanel !== null
|
|
2520
|
+
? createPortal(historyPanel, sidebarHistoryHost)
|
|
2521
|
+
: null}
|
|
2522
|
+
|
|
2523
|
+
{/* ------------------------------------------------ template library */}
|
|
2524
|
+
{libraryOpen ? (
|
|
2525
|
+
<TemplateLibrary
|
|
2526
|
+
api={api}
|
|
2527
|
+
onClose={() => { setLibraryOpen(false) }}
|
|
2528
|
+
onUse={(text) => {
|
|
2529
|
+
openTab('text')
|
|
2530
|
+
setPrompt(text)
|
|
2531
|
+
setError(null)
|
|
2532
|
+
setLibraryOpen(false)
|
|
2533
|
+
}}
|
|
2534
|
+
/>
|
|
2535
|
+
) : null}
|
|
2536
|
+
|
|
2537
|
+
{configGuide !== null ? (
|
|
2538
|
+
<div className={css.configGuide} role="dialog" aria-modal="true" aria-label={tt(`config.${configGuide}Title` as never)}>
|
|
2539
|
+
<div className={css.configGuideBody}>
|
|
2540
|
+
<strong>{tt(`config.${configGuide}Title` as never)}</strong>
|
|
2541
|
+
<span>{tt(`config.${configGuide}Hint` as never)}</span>
|
|
2542
|
+
<button type="button" onClick={() => { setConfigGuide(null) }}>{tt('preview.close')}</button>
|
|
2543
|
+
</div>
|
|
2544
|
+
</div>
|
|
2545
|
+
) : null}
|
|
2546
|
+
|
|
2547
|
+
{comparisonFullscreen && comparison !== null ? createPortal(
|
|
2548
|
+
<div className={css.comparisonFullscreen} role="dialog" aria-modal="true" aria-label={tt('compare.title')} onClick={() => { setComparisonFullscreen(false) }}>
|
|
2549
|
+
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setComparisonFullscreen(false) }}>×</button>
|
|
2550
|
+
<div className={css.comparisonFullscreenGrid} onClick={event => { event.stopPropagation() }}>
|
|
2551
|
+
{comparisonResults.map(task => (
|
|
2552
|
+
<figure key={task.id}><figcaption>{task.request.model}</figcaption>{task.result!.images.map((image, index) => <img key={index} src={srcOf(image)} alt={task.request.model} />)}</figure>
|
|
2553
|
+
))}
|
|
2554
|
+
</div>
|
|
2555
|
+
</div>, document.body) : null}
|
|
2556
|
+
|
|
2557
|
+
{/* -------------------------------------------------- preview overlay */}
|
|
2558
|
+
{preview !== null && previewImage !== null
|
|
2559
|
+
? createPortal(
|
|
2560
|
+
<div
|
|
2561
|
+
className={css.lightbox}
|
|
2562
|
+
role="dialog"
|
|
2563
|
+
aria-modal="true"
|
|
2564
|
+
aria-label={tt('preview.title')}
|
|
2565
|
+
onClick={closePreview}
|
|
2566
|
+
>
|
|
2567
|
+
<button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} title={tt('preview.close')} onClick={closePreview}>
|
|
2568
|
+
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
2569
|
+
</button>
|
|
2570
|
+
{preview.images.length > 1 ? (
|
|
2571
|
+
<>
|
|
2572
|
+
<button type="button" className={css.lightboxNav} data-dir="prev" aria-label={tt('preview.prev')} onClick={(event) => { event.stopPropagation(); stepPreview(-1) }}>
|
|
2573
|
+
<svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10 3l-5 5 5 5"/></svg>
|
|
2574
|
+
</button>
|
|
2575
|
+
<button type="button" className={css.lightboxNav} data-dir="next" aria-label={tt('preview.next')} onClick={(event) => { event.stopPropagation(); stepPreview(1) }}>
|
|
2576
|
+
<svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
|
|
2577
|
+
</button>
|
|
2578
|
+
</>
|
|
2579
|
+
) : null}
|
|
2580
|
+
<figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
|
|
2581
|
+
<div
|
|
2582
|
+
ref={previewStage}
|
|
2583
|
+
className={css.lightboxStage}
|
|
2584
|
+
onWheel={(event) => {
|
|
2585
|
+
event.preventDefault()
|
|
2586
|
+
setPreviewScale(current => clampPreviewScale(current + (event.deltaY < 0 ? PREVIEW_SCALE_STEP : -PREVIEW_SCALE_STEP)))
|
|
2587
|
+
}}
|
|
2588
|
+
>
|
|
2589
|
+
<div
|
|
2590
|
+
className={css.lightboxScaleFrame}
|
|
2591
|
+
style={{ width: `${previewFrameScale * 100}%`, height: `${previewFrameScale * 100}%` }}
|
|
2592
|
+
>
|
|
2593
|
+
<img
|
|
2594
|
+
className={css.lightboxImage}
|
|
2595
|
+
style={{ width: `${previewImageScale * 100}%`, height: `${previewImageScale * 100}%` }}
|
|
2596
|
+
src={srcOf(previewImage)}
|
|
2597
|
+
alt={previewImage.revisedPrompt ?? tt('preview.title')}
|
|
2598
|
+
/>
|
|
2599
|
+
</div>
|
|
2600
|
+
</div>
|
|
2601
|
+
<div className={css.lightboxTools} role="group" aria-label={tt('preview.zoomControls')}>
|
|
2602
|
+
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomOut')} title={tt('preview.zoomOut')} onClick={() => { setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) }}>
|
|
2603
|
+
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
2604
|
+
</button>
|
|
2605
|
+
<button type="button" className={css.lightboxZoomLevel} aria-label={tt('preview.zoomReset')} title={tt('preview.zoomReset')} onClick={() => { setPreviewScale(1) }}>
|
|
2606
|
+
{tt('preview.zoomLevel', { percent: Math.round(previewScale * 100) })}
|
|
2607
|
+
</button>
|
|
2608
|
+
<button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomIn')} title={tt('preview.zoomIn')} onClick={() => { setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) }}>
|
|
2609
|
+
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M7 4.8v4.4M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
|
|
2610
|
+
</button>
|
|
2611
|
+
</div>
|
|
2612
|
+
{previewImage.revisedPrompt !== undefined ? (
|
|
2613
|
+
<div className={css.lightboxCaptionRow}>
|
|
2614
|
+
<figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
|
|
2615
|
+
{tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
|
|
2616
|
+
</figcaption>
|
|
2617
|
+
<button type="button" className={css.lightboxCopy} aria-label={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} title={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} onClick={() => { void copyPreviewPrompt(previewImage.revisedPrompt!) }}>
|
|
2618
|
+
{promptCopied ? (
|
|
2619
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 8l3 3 7-7"/></svg>
|
|
2620
|
+
) : (
|
|
2621
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="5" width="7" height="8" rx="1"/><path d="M3 10V3.8c0-.44.36-.8.8-.8H9"/></svg>
|
|
2622
|
+
)}
|
|
2623
|
+
<span>{tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')}</span>
|
|
2624
|
+
</button>
|
|
2625
|
+
</div>
|
|
2626
|
+
) : null}
|
|
2627
|
+
<div className={css.lightboxMeta}>
|
|
2628
|
+
<span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
|
|
2629
|
+
<span className={css.lightboxActions}>
|
|
2630
|
+
<button type="button" className={css.lightboxEdit} disabled={conversationBusy} title={tt('conversation.addHint')} onClick={() => { void addImageToConversation(previewImage, preview.index) }}>
|
|
2631
|
+
{addingToConversation === preview.index ? tt('conversation.adding') : tt('conversation.add')}
|
|
2632
|
+
</button>
|
|
2633
|
+
<button type="button" className={css.lightboxEdit} disabled={galleryAdding} onClick={() => { void addToGallery(previewImage) }}>
|
|
2634
|
+
{tt('gallery.add')}
|
|
2635
|
+
</button>
|
|
2636
|
+
<button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
|
|
2637
|
+
{tt('preview.addToEdit')}
|
|
2638
|
+
</button>
|
|
2639
|
+
<a
|
|
2640
|
+
className={css.lightboxDownload}
|
|
2641
|
+
href={srcOf(previewImage)}
|
|
2642
|
+
download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
|
|
2643
|
+
>
|
|
2644
|
+
{tt('download')}
|
|
2645
|
+
</a>
|
|
2646
|
+
</span>
|
|
2647
|
+
</div>
|
|
2648
|
+
</figure>
|
|
2649
|
+
</div>,
|
|
2650
|
+
document.body,
|
|
2651
|
+
)
|
|
2652
|
+
: null}
|
|
2653
|
+
|
|
2654
|
+
{/* ------------------------------------------------- gallery toast */}
|
|
2655
|
+
{galleryMessage !== null ? (
|
|
2656
|
+
<div className={css.galleryToast} role="status">
|
|
2657
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M8 5.8v4.4M5.8 8h4.4"/></svg>
|
|
2658
|
+
{galleryMessage}
|
|
2659
|
+
</div>
|
|
2660
|
+
) : null}
|
|
2661
|
+
{conversationMessage !== null ? (
|
|
2662
|
+
<div className={css.conversationToast} role="status">
|
|
2663
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 4.5h10v7H3z"/><path d="M5.5 2.5h5M8 6v4M6 8h4"/></svg>
|
|
2664
|
+
{conversationMessage}
|
|
2665
|
+
</div>
|
|
2666
|
+
) : null}
|
|
2667
|
+
</div>
|
|
2668
|
+
)
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/** File extension for a MIME type (download filenames). */
|
|
2672
|
+
function extensionOf(mime: string): string {
|
|
2673
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
2674
|
+
case 'image/jpeg': return 'jpg'
|
|
2675
|
+
case 'image/webp': return 'webp'
|
|
2676
|
+
case 'image/gif': return 'gif'
|
|
2677
|
+
default: return 'png'
|
|
2678
|
+
}
|
|
2679
|
+
}
|