@dickpy/dsh-imagegen 1.2.3 → 1.4.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/LICENSE +201 -201
- package/README.md +203 -181
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +2711 -1318
- package/lib/client.js.map +1 -1
- package/lib/index.js +830 -155
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -316
- package/src/client/ImageGenPanel.tsx +1703 -1476
- package/src/client/SettingsCard.tsx +936 -648
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -0
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +170 -152
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -484
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -536
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -250
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -464
- package/src/gallery-store.ts +286 -280
- package/src/generation-runtime.ts +79 -48
- package/src/history-store.ts +250 -238
- package/src/image-format.ts +11 -0
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -212
- package/src/model-catalog.ts +115 -0
- package/src/presets.ts +71 -0
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -253
- package/src/routes.ts +916 -738
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
package/src/routes.ts
CHANGED
|
@@ -1,738 +1,916 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The /api/dsh-imagegen route family: a loopback-only settings bridge for the
|
|
3
|
-
* plugin's own namespace (describe/mutate, mirroring the dsh-web-ui family
|
|
4
|
-
* bridge wire) and the generate proxy that forwards to the configured
|
|
5
|
-
* OpenAI-compatible endpoint with the API key held host-side.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
-
import { randomUUID } from 'node:crypto'
|
|
10
|
-
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
11
|
-
import {
|
|
12
|
-
import type
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
/** Resolve the
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
readImage: (
|
|
52
|
-
}
|
|
53
|
-
/** Overrideable
|
|
54
|
-
|
|
55
|
-
list: () => Promise<HistoryEntry[]>
|
|
56
|
-
append: (entry: HistoryEntryInput) => Promise<
|
|
57
|
-
remove: (id: string) => Promise<HistoryEntry[]>
|
|
58
|
-
clear: () => Promise<HistoryEntry[]>
|
|
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
|
-
if (
|
|
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
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
writeJson(res,
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (req
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
'
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
path:
|
|
563
|
-
handler: async (req, res) => {
|
|
564
|
-
if (!guard(req, res, 'POST')) return
|
|
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
|
-
writeJson(res, 200, { ok:
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
writeJson(res, 200, { ok:
|
|
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
|
-
'content-
|
|
731
|
-
|
|
732
|
-
'cache-control': 'private, max-age=
|
|
733
|
-
})
|
|
734
|
-
res.end(found.data)
|
|
735
|
-
},
|
|
736
|
-
},
|
|
737
|
-
|
|
738
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-imagegen route family: a loopback-only settings bridge for the
|
|
3
|
+
* plugin's own namespace (describe/mutate, mirroring the dsh-web-ui family
|
|
4
|
+
* bridge wire) and the generate proxy that forwards to the configured
|
|
5
|
+
* OpenAI-compatible endpoint with the API key held host-side.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
11
|
+
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
|
12
|
+
import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
|
13
|
+
import type { UpstreamConfig } from './engine.ts'
|
|
14
|
+
import { enhancePrompt, listImageModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
|
|
15
|
+
import { normalizeImageModels } from './image-models.ts'
|
|
16
|
+
import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
|
|
17
|
+
import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
|
|
18
|
+
import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
|
|
19
|
+
import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
|
|
20
|
+
import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
|
|
21
|
+
import { IMAGE_PRESETS } from './presets.ts'
|
|
22
|
+
import { AGENT_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
|
|
23
|
+
|
|
24
|
+
/** Cap on JSON request bodies (settings ops and generate payloads are small). */
|
|
25
|
+
const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/** Cap on history append bodies (base64 result images can be much larger). */
|
|
28
|
+
const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
/** Settings seam face the bridge needs (the host settings provider). */
|
|
31
|
+
export interface SettingsSeam {
|
|
32
|
+
describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
|
|
33
|
+
mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
|
|
34
|
+
readonly writable?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Route dependencies. */
|
|
38
|
+
export interface ImageGenRoutesDeps {
|
|
39
|
+
/** The settings seam (namespace storage). */
|
|
40
|
+
settings: SettingsSeam
|
|
41
|
+
/** Resolve the current upstream config (legacy single-endpoint path). */
|
|
42
|
+
resolve: () => UpstreamConfig
|
|
43
|
+
/** Resolve the current channel view (the channel-aware path). */
|
|
44
|
+
resolveChannels?: () => ChannelsView
|
|
45
|
+
/** Resolve the optional chat-model configuration for prompt enhancement. */
|
|
46
|
+
resolvePrompt?: () => PromptModelConfig
|
|
47
|
+
/** Models explicitly selected for this image API endpoint (legacy path). */
|
|
48
|
+
resolveImageModels?: () => string[]
|
|
49
|
+
/** Host attachment storage used by Agent tool-result previews. */
|
|
50
|
+
attachments?: {
|
|
51
|
+
readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>
|
|
52
|
+
}
|
|
53
|
+
/** Overrideable history backend, primarily for host integration tests. */
|
|
54
|
+
history?: {
|
|
55
|
+
list: () => Promise<HistoryEntry[]>
|
|
56
|
+
append: (entry: HistoryEntryInput) => Promise<HistoryEntry[]>
|
|
57
|
+
remove: (id: string) => Promise<HistoryEntry[]>
|
|
58
|
+
clear: () => Promise<HistoryEntry[]>
|
|
59
|
+
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
60
|
+
}
|
|
61
|
+
/** Overrideable gallery backend, primarily for host integration tests. */
|
|
62
|
+
gallery?: {
|
|
63
|
+
list: () => Promise<HistoryEntry[]>
|
|
64
|
+
append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
|
|
65
|
+
remove: (id: string) => Promise<HistoryEntry[]>
|
|
66
|
+
clear: () => Promise<HistoryEntry[]>
|
|
67
|
+
updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
|
|
68
|
+
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
69
|
+
}
|
|
70
|
+
/** Overrideable template-library backend, primarily for host integration tests. */
|
|
71
|
+
templates?: {
|
|
72
|
+
list: () => Promise<TemplateListResult>
|
|
73
|
+
refresh: () => Promise<TemplateRefreshResult>
|
|
74
|
+
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
75
|
+
}
|
|
76
|
+
/** Shared host queue, used by Agent tools and browser task endpoints. */
|
|
77
|
+
runtime?: ImageGenerationRuntime
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
|
|
81
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
82
|
+
const address = request.socket.remoteAddress
|
|
83
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
84
|
+
const host = request.headers.host
|
|
85
|
+
if (typeof host !== 'string') return false
|
|
86
|
+
let hostUrl: URL
|
|
87
|
+
try {
|
|
88
|
+
hostUrl = new URL(`http://${host}`)
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
93
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
94
|
+
const origin = request.headers.origin
|
|
95
|
+
if (origin === undefined) return true
|
|
96
|
+
try {
|
|
97
|
+
return new URL(origin).host === hostUrl.host
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One JSON response. */
|
|
104
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
105
|
+
const payload = JSON.stringify(body)
|
|
106
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
107
|
+
res.end(payload)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
111
|
+
async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
|
|
112
|
+
const chunks: Buffer[] = []
|
|
113
|
+
let size = 0
|
|
114
|
+
for await (const chunk of req) {
|
|
115
|
+
const buffer = chunk as Buffer
|
|
116
|
+
size += buffer.length
|
|
117
|
+
if (size > maxBytes) return undefined
|
|
118
|
+
chunks.push(buffer)
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
122
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Human-readable text from an unknown thrown value. */
|
|
129
|
+
function messageOf(error: unknown): string {
|
|
130
|
+
return error instanceof Error ? error.message : String(error)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
|
|
134
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
|
|
135
|
+
if (prompt === '') return undefined
|
|
136
|
+
const comparisonModels = Array.isArray(body.comparisonModels)
|
|
137
|
+
? [...new Set(body.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
|
|
138
|
+
: []
|
|
139
|
+
return {
|
|
140
|
+
mode: body.mode === 'edit' ? 'edit' : 'text',
|
|
141
|
+
model: typeof body.model === 'string' ? body.model : '',
|
|
142
|
+
prompt,
|
|
143
|
+
size: typeof body.size === 'string' ? body.size : 'auto',
|
|
144
|
+
quality: typeof body.quality === 'string' ? body.quality : 'auto',
|
|
145
|
+
n: typeof body.n === 'number' ? body.n : 1,
|
|
146
|
+
detail: typeof body.detail === 'string' ? body.detail : '',
|
|
147
|
+
...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
|
|
148
|
+
...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
|
|
149
|
+
...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
|
|
150
|
+
...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
|
|
151
|
+
...comparisonModels.length > 1 ? { comparisonModels } : {},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Validate a submitted history entry (images carry base64). */
|
|
156
|
+
function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
|
|
157
|
+
const raw = body.entry
|
|
158
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
159
|
+
const entry = raw as Record<string, unknown>
|
|
160
|
+
if (typeof entry.id !== 'string' || typeof entry.createdAt !== 'number') return undefined
|
|
161
|
+
if (entry.mode !== 'text' && entry.mode !== 'edit') return undefined
|
|
162
|
+
if (typeof entry.model !== 'string' || typeof entry.prompt !== 'string') return undefined
|
|
163
|
+
if (typeof entry.size !== 'string' || typeof entry.quality !== 'string' || typeof entry.detail !== 'string') return undefined
|
|
164
|
+
if (typeof entry.n !== 'number') return undefined
|
|
165
|
+
if (!Array.isArray(entry.images)) return undefined
|
|
166
|
+
const images: GeneratedImage[] = []
|
|
167
|
+
for (const item of entry.images) {
|
|
168
|
+
if (item === null || typeof item !== 'object') return undefined
|
|
169
|
+
const image = item as Record<string, unknown>
|
|
170
|
+
if (typeof image.b64 !== 'string' || typeof image.mime !== 'string') return undefined
|
|
171
|
+
images.push({
|
|
172
|
+
b64: image.b64,
|
|
173
|
+
mime: image.mime,
|
|
174
|
+
...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
const comparisonModels = Array.isArray(entry.comparisonModels)
|
|
178
|
+
? [...new Set(entry.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
|
|
179
|
+
: []
|
|
180
|
+
return {
|
|
181
|
+
id: entry.id,
|
|
182
|
+
createdAt: entry.createdAt,
|
|
183
|
+
mode: entry.mode,
|
|
184
|
+
model: entry.model,
|
|
185
|
+
prompt: entry.prompt,
|
|
186
|
+
size: entry.size,
|
|
187
|
+
quality: entry.quality,
|
|
188
|
+
detail: entry.detail,
|
|
189
|
+
n: entry.n,
|
|
190
|
+
images,
|
|
191
|
+
...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
|
|
192
|
+
...typeof entry.channelId === 'string' ? { channelId: entry.channelId } : {},
|
|
193
|
+
...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
|
|
194
|
+
...typeof entry.comparisonId === 'string' ? { comparisonId: entry.comparisonId } : {},
|
|
195
|
+
...comparisonModels.length > 1 ? { comparisonModels } : {},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Extract the image file name from a history-image request URL. */
|
|
200
|
+
function imageFileFrom(rawUrl: string | undefined, basePath: string): string | undefined {
|
|
201
|
+
if (rawUrl === undefined) return undefined
|
|
202
|
+
let pathname: string
|
|
203
|
+
try {
|
|
204
|
+
pathname = new URL(rawUrl, 'http://localhost').pathname
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
if (!pathname.startsWith(`${basePath}/`)) return undefined
|
|
209
|
+
return decodeURIComponent(pathname.slice(basePath.length + 1))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Parse the durable image reference carried by an Agent tool-result view. */
|
|
213
|
+
function agentImageRefFrom(rawUrl: string | undefined): ImageAttachmentRef | undefined {
|
|
214
|
+
if (rawUrl === undefined) return undefined
|
|
215
|
+
let url: URL
|
|
216
|
+
try {
|
|
217
|
+
url = new URL(rawUrl, 'http://localhost')
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
if (url.pathname !== AGENT_IMAGE_API) return undefined
|
|
222
|
+
const attachmentId = url.searchParams.get('attachment_id') ?? ''
|
|
223
|
+
const mediaType = url.searchParams.get('media_type') ?? ''
|
|
224
|
+
const bytes = Number(url.searchParams.get('bytes'))
|
|
225
|
+
const width = Number(url.searchParams.get('width'))
|
|
226
|
+
const height = Number(url.searchParams.get('height'))
|
|
227
|
+
if (attachmentId === '' || !isImageMediaType(mediaType)
|
|
228
|
+
|| !Number.isSafeInteger(bytes) || bytes < 1
|
|
229
|
+
|| !Number.isSafeInteger(width) || width < 1
|
|
230
|
+
|| !Number.isSafeInteger(height) || height < 1) return undefined
|
|
231
|
+
return {
|
|
232
|
+
attachmentId: attachmentId as ImageAttachmentRef['attachmentId'],
|
|
233
|
+
mediaType,
|
|
234
|
+
bytes,
|
|
235
|
+
width,
|
|
236
|
+
height,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isImageMediaType(value: string): value is ImageMediaType {
|
|
241
|
+
return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Project one settings descriptor onto the bridge wire view. */
|
|
245
|
+
function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
|
|
246
|
+
return {
|
|
247
|
+
ns: String(descriptor.ns),
|
|
248
|
+
schema: descriptor.schema,
|
|
249
|
+
value: descriptor.value,
|
|
250
|
+
...descriptor.base === undefined ? {} : { base: descriptor.base },
|
|
251
|
+
...descriptor.user === undefined ? {} : { user: descriptor.user },
|
|
252
|
+
...descriptor.secrets === undefined ? {} : {
|
|
253
|
+
secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
|
|
254
|
+
},
|
|
255
|
+
revision: descriptor.revision,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Map a seam failure onto the bridge refusal envelope. */
|
|
260
|
+
function failureOf(error: unknown): { ok: false; code: string; message: string } {
|
|
261
|
+
if (error instanceof SettingsConflictError) {
|
|
262
|
+
return { ok: false, code: 'settings-conflict', message: error.message }
|
|
263
|
+
}
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
265
|
+
return { ok: false, code: 'settings-rejected', message }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build every /api/dsh-imagegen route.
|
|
270
|
+
* @param deps - settings seam + config resolver.
|
|
271
|
+
* @returns the route registrations.
|
|
272
|
+
*/
|
|
273
|
+
export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
274
|
+
const history = deps.history ?? {
|
|
275
|
+
list: listHistory,
|
|
276
|
+
append: appendHistory,
|
|
277
|
+
remove: removeHistory,
|
|
278
|
+
clear: clearHistory,
|
|
279
|
+
readImage: readHistoryImage,
|
|
280
|
+
}
|
|
281
|
+
const gallery: NonNullable<ImageGenRoutesDeps['gallery']> = deps.gallery ?? {
|
|
282
|
+
list: listGallery,
|
|
283
|
+
append: appendGallery,
|
|
284
|
+
remove: removeGallery,
|
|
285
|
+
clear: clearGallery,
|
|
286
|
+
updateTags: updateGalleryTags,
|
|
287
|
+
readImage: readGalleryImage,
|
|
288
|
+
}
|
|
289
|
+
const templates = deps.templates ?? {
|
|
290
|
+
list: listTemplates,
|
|
291
|
+
refresh: refreshTemplates,
|
|
292
|
+
readImage: readTemplateImage,
|
|
293
|
+
}
|
|
294
|
+
const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
|
|
295
|
+
const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
|
|
296
|
+
|
|
297
|
+
/** The current channel view: the channel-aware resolver, or a synthesized
|
|
298
|
+
* single default channel from the legacy flat upstream config (tests and
|
|
299
|
+
* older hosts). */
|
|
300
|
+
const channelViewOf = (): ChannelsView => {
|
|
301
|
+
if (deps.resolveChannels !== undefined) return deps.resolveChannels()
|
|
302
|
+
const upstream = deps.resolve()
|
|
303
|
+
const models: ModelMapping[] = normalizeImageModels(resolveImageModels()).map(id => ({ alias: id, id }))
|
|
304
|
+
if (upstream.apiUrl.trim() === '' && models.length === 0) return { channels: [], defaultChannelId: '' }
|
|
305
|
+
return {
|
|
306
|
+
channels: [{ id: 'default', preset: '', name: '默认渠道', apiUrl: upstream.apiUrl, apiKey: upstream.apiKey, models }],
|
|
307
|
+
defaultChannelId: 'default',
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf, history)
|
|
311
|
+
|
|
312
|
+
/** Resolve an alias (or the channel fallback) into a concrete generation
|
|
313
|
+
* request: picks the channel (explicit then default), maps alias → upstream
|
|
314
|
+
* id, and fills the channel snapshot kept on history entries. */
|
|
315
|
+
const resolveChannelRequest = (request: GenerateRequest): { ok: true; request: GenerateRequest } | { ok: false; code: string; message: string } => {
|
|
316
|
+
const view = channelViewOf()
|
|
317
|
+
if (view.channels.length === 0) {
|
|
318
|
+
return { ok: false, code: 'no-channels', message: '尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥' }
|
|
319
|
+
}
|
|
320
|
+
const explicit = view.channels.find(candidate => candidate.id === request.channelId)
|
|
321
|
+
const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
|
|
322
|
+
const target = explicit ?? defaults
|
|
323
|
+
const asked = request.model.trim()
|
|
324
|
+
if (asked === '') {
|
|
325
|
+
const alias = target?.models[0]?.alias ?? ''
|
|
326
|
+
if (alias === '') {
|
|
327
|
+
return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型,请先在设置中添加` }
|
|
328
|
+
}
|
|
329
|
+
const mapping = target!.models.find(model => model.alias === alias)!
|
|
330
|
+
return { ok: true, request: { ...request, model: alias, upstream: mapping.id, channelId: target!.id, channel: target!.name } }
|
|
331
|
+
}
|
|
332
|
+
const hosting = view.channels.filter(channel => channel.models.some(model => model.alias === asked))
|
|
333
|
+
if (hosting.length === 0) {
|
|
334
|
+
const available = [...new Set(view.channels.flatMap(channel => channel.models.map(model => model.alias)))]
|
|
335
|
+
return { ok: false, code: 'image-model-not-configured', message: `模型「${asked}」未在任一渠道配置;可用模型:${available.join('、') || '(无)'}` }
|
|
336
|
+
}
|
|
337
|
+
const picked = target !== undefined && target.models.some(model => model.alias === asked) ? target : hosting[0]!
|
|
338
|
+
const mapping = picked.models.find(model => model.alias === asked)!
|
|
339
|
+
return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
|
|
340
|
+
}
|
|
341
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
342
|
+
if (!isLoopbackRequest(req)) {
|
|
343
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
346
|
+
if (req.method !== method) {
|
|
347
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
return true
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return [
|
|
354
|
+
// ------------------------------------ Agent tool-result image (prefix)
|
|
355
|
+
...(deps.attachments === undefined ? [] : [{
|
|
356
|
+
kind: 'prefix' as const,
|
|
357
|
+
path: AGENT_IMAGE_API,
|
|
358
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
359
|
+
if (!isLoopbackRequest(req)) {
|
|
360
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (req.method !== 'GET') {
|
|
364
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
const ref = agentImageRefFrom(req.url)
|
|
368
|
+
if (ref === undefined) {
|
|
369
|
+
writeJson(res, 400, { error: 'invalid image reference' })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const stored = await deps.attachments!.readImage(ref)
|
|
374
|
+
res.writeHead(200, {
|
|
375
|
+
'content-type': stored.ref.mediaType,
|
|
376
|
+
'content-length': stored.data.byteLength,
|
|
377
|
+
'cache-control': 'private, max-age=3600',
|
|
378
|
+
})
|
|
379
|
+
res.end(Buffer.from(stored.data))
|
|
380
|
+
} catch {
|
|
381
|
+
// Do not expose attachment-store details through the browser route.
|
|
382
|
+
writeJson(res, 404, { error: 'image attachment not found' })
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
} satisfies WebRoute]),
|
|
386
|
+
// -------------------------------------------- image model discovery
|
|
387
|
+
// Accepts optional temporary per-channel credentials so the settings card
|
|
388
|
+
// can probe the endpoint the user is *typing* without saving first:
|
|
389
|
+
// { channelId?, apiUrl?, apiKey? } — the channel's stored values are the
|
|
390
|
+
// fallback, and the body's apiUrl/apiKey override them for this call.
|
|
391
|
+
{
|
|
392
|
+
kind: 'exact',
|
|
393
|
+
path: IMAGE_MODEL_API.models,
|
|
394
|
+
handler: async (req, res) => {
|
|
395
|
+
if (!guard(req, res, 'POST')) return
|
|
396
|
+
const body = await readJsonBody(req)
|
|
397
|
+
const view = channelViewOf()
|
|
398
|
+
const stored = view.channels.find(candidate => candidate.id === (typeof body?.channelId === 'string' ? body.channelId : undefined))
|
|
399
|
+
?? view.channels.find(candidate => candidate.id === view.defaultChannelId)
|
|
400
|
+
?? view.channels[0]
|
|
401
|
+
const upstream: UpstreamConfig = {
|
|
402
|
+
apiUrl: typeof body?.apiUrl === 'string' && body.apiUrl.trim() !== '' ? body.apiUrl.trim() : (stored?.apiUrl ?? ''),
|
|
403
|
+
apiKey: typeof body?.apiKey === 'string' && body.apiKey.trim() !== '' ? body.apiKey.trim() : (stored?.apiKey ?? ''),
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
writeJson(res, 200, { ok: true, models: await listImageModels(upstream) })
|
|
407
|
+
} catch (error) {
|
|
408
|
+
writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
// ---------------------------------------------------------- presets
|
|
413
|
+
{
|
|
414
|
+
kind: 'exact',
|
|
415
|
+
path: PRESETS_API,
|
|
416
|
+
handler: async (req, res) => {
|
|
417
|
+
if (!guard(req, res, 'POST')) return
|
|
418
|
+
const presets: PresetProviderView[] = IMAGE_PRESETS.map(preset => ({
|
|
419
|
+
id: preset.id,
|
|
420
|
+
name: preset.name,
|
|
421
|
+
apiUrl: preset.apiUrl,
|
|
422
|
+
hint: preset.hint,
|
|
423
|
+
models: preset.models,
|
|
424
|
+
}))
|
|
425
|
+
writeJson(res, 200, { ok: true, presets })
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
// ----------------------------------------------------------- usage
|
|
429
|
+
{
|
|
430
|
+
kind: 'exact',
|
|
431
|
+
path: USAGE_API,
|
|
432
|
+
handler: async (req, res) => {
|
|
433
|
+
if (!guard(req, res, 'POST')) return
|
|
434
|
+
try {
|
|
435
|
+
const entries = [...await history.list(), ...await gallery.list()]
|
|
436
|
+
// byChannel[channelId | 'name:<name>' | ''] → { alias: count }.
|
|
437
|
+
const byChannel: Record<string, Record<string, number>> = {}
|
|
438
|
+
const totals: Record<string, number> = {}
|
|
439
|
+
for (const entry of entries) {
|
|
440
|
+
const channelKey = entry.channelId !== undefined ? entry.channelId : (entry.channel !== undefined ? `name:${entry.channel}` : '')
|
|
441
|
+
const alias = entry.model
|
|
442
|
+
const bucket = byChannel[channelKey] ?? (byChannel[channelKey] = {})
|
|
443
|
+
bucket[alias] = (bucket[alias] ?? 0) + 1
|
|
444
|
+
totals[alias] = (totals[alias] ?? 0) + 1
|
|
445
|
+
}
|
|
446
|
+
writeJson(res, 200, { ok: true, usage: { byChannel, totals } })
|
|
447
|
+
} catch (error) {
|
|
448
|
+
writeJson(res, 200, { ok: false, code: 'usage-failed', message: messageOf(error) })
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
// ----------------------------------------------- prompt enhancement
|
|
453
|
+
{
|
|
454
|
+
kind: 'exact',
|
|
455
|
+
path: PROMPT_ENHANCE_API.models,
|
|
456
|
+
handler: async (req, res) => {
|
|
457
|
+
if (!guard(req, res, 'POST')) return
|
|
458
|
+
try {
|
|
459
|
+
writeJson(res, 200, { ok: true, models: await listPromptModels(resolvePrompt()) })
|
|
460
|
+
} catch (error) {
|
|
461
|
+
writeJson(res, 200, { ok: false, code: 'prompt-models-failed', message: messageOf(error) })
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
kind: 'exact',
|
|
467
|
+
path: PROMPT_ENHANCE_API.enhance,
|
|
468
|
+
handler: async (req, res) => {
|
|
469
|
+
if (!guard(req, res, 'POST')) return
|
|
470
|
+
const body = await readJsonBody(req)
|
|
471
|
+
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
|
|
472
|
+
if (prompt === '') {
|
|
473
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
writeJson(res, 200, { ok: true, prompt: await enhancePrompt(resolvePrompt(), prompt) })
|
|
478
|
+
} catch (error) {
|
|
479
|
+
writeJson(res, 200, { ok: false, code: 'prompt-enhance-failed', message: messageOf(error) })
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
// -------------------------------------------------- settings describe
|
|
484
|
+
{
|
|
485
|
+
kind: 'exact',
|
|
486
|
+
path: SETTINGS_API.describe,
|
|
487
|
+
handler: async (req, res) => {
|
|
488
|
+
if (!guard(req, res, 'POST')) return
|
|
489
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
490
|
+
.find(candidate => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE)
|
|
491
|
+
writeJson(res, 200, {
|
|
492
|
+
ok: true,
|
|
493
|
+
value: {
|
|
494
|
+
namespaces: descriptor === undefined ? [] : [toView(descriptor)],
|
|
495
|
+
writable: deps.settings.writable !== false,
|
|
496
|
+
},
|
|
497
|
+
})
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
// ----------------------------------------------------- settings mutate
|
|
501
|
+
{
|
|
502
|
+
kind: 'exact',
|
|
503
|
+
path: SETTINGS_API.mutate,
|
|
504
|
+
handler: async (req, res) => {
|
|
505
|
+
if (!guard(req, res, 'POST')) return
|
|
506
|
+
const body = await readJsonBody(req)
|
|
507
|
+
if (body === undefined) {
|
|
508
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
const ns = typeof body.ns === 'string' ? body.ns : ''
|
|
512
|
+
if (ns !== IMAGEGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
|
|
513
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
517
|
+
try {
|
|
518
|
+
await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
|
|
519
|
+
} catch (error) {
|
|
520
|
+
writeJson(res, 200, failureOf(error))
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
524
|
+
.find(candidate => String(candidate.ns) === ns)
|
|
525
|
+
if (descriptor === undefined) {
|
|
526
|
+
writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
writeJson(res, 200, { ok: true, value: toView(descriptor) })
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
// ----------------------------------------------------------- generate
|
|
533
|
+
{
|
|
534
|
+
kind: 'exact',
|
|
535
|
+
path: GENERATE_API,
|
|
536
|
+
handler: async (req, res) => {
|
|
537
|
+
if (!guard(req, res, 'POST')) return
|
|
538
|
+
const body = await readJsonBody(req)
|
|
539
|
+
const parsed = body === undefined ? undefined : parseGenerateRequest(body)
|
|
540
|
+
if (parsed === undefined) {
|
|
541
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
const resolved = resolveChannelRequest(parsed)
|
|
545
|
+
if (!resolved.ok) {
|
|
546
|
+
writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
writeJson(res, 200, { ok: true, ...await runtime.run(resolved.request) })
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
553
|
+
const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
|
|
554
|
+
? (error as { code: string }).code
|
|
555
|
+
: 'generate-failed'
|
|
556
|
+
writeJson(res, 200, { ok: false, code, message })
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
// ------------------------------------------------ generation task queue
|
|
561
|
+
{
|
|
562
|
+
kind: 'exact', path: TASK_API.submit,
|
|
563
|
+
handler: async (req, res) => {
|
|
564
|
+
if (!guard(req, res, 'POST')) return
|
|
565
|
+
const body = await readJsonBody(req)
|
|
566
|
+
const parsed = body === undefined ? undefined : parseGenerateRequest(body)
|
|
567
|
+
if (parsed === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
|
|
568
|
+
const resolved = resolveChannelRequest(parsed)
|
|
569
|
+
if (!resolved.ok) {
|
|
570
|
+
writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
writeJson(res, 200, { ok: true, task: runtime.queue.submit(resolved.request) })
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
kind: 'exact', path: TASK_API.list,
|
|
578
|
+
handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: runtime.queue.list() }) },
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
kind: 'exact', path: TASK_API.cancel,
|
|
582
|
+
handler: async (req, res) => {
|
|
583
|
+
if (!guard(req, res, 'POST')) return
|
|
584
|
+
const body = await readJsonBody(req)
|
|
585
|
+
const task = typeof body?.id === 'string' ? runtime.queue.cancel(body.id) : undefined
|
|
586
|
+
if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
|
|
587
|
+
writeJson(res, 200, { ok: true, task })
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
kind: 'exact', path: TASK_API.retry,
|
|
592
|
+
handler: async (req, res) => {
|
|
593
|
+
if (!guard(req, res, 'POST')) return
|
|
594
|
+
const body = await readJsonBody(req)
|
|
595
|
+
const task = typeof body?.id === 'string' ? runtime.queue.retry(body.id) : undefined
|
|
596
|
+
if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
|
|
597
|
+
writeJson(res, 200, { ok: true, task })
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
// ----------------------------------------------- update check
|
|
601
|
+
{
|
|
602
|
+
kind: 'exact',
|
|
603
|
+
path: UPDATE_API.check,
|
|
604
|
+
handler: async (req, res) => {
|
|
605
|
+
if (!guard(req, res, 'POST')) return
|
|
606
|
+
try {
|
|
607
|
+
writeJson(res, 200, { ok: true, update: await checkForUpdate() })
|
|
608
|
+
} catch (error) {
|
|
609
|
+
writeJson(res, 200, { ok: false, code: 'update-check-failed', message: messageOf(error) })
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
// ----------------------------------------------- update apply
|
|
614
|
+
{
|
|
615
|
+
kind: 'exact',
|
|
616
|
+
path: UPDATE_API.apply,
|
|
617
|
+
handler: async (req, res) => {
|
|
618
|
+
if (!guard(req, res, 'POST')) return
|
|
619
|
+
const body = await readJsonBody(req)
|
|
620
|
+
const version = body !== undefined && typeof body.version === 'string' ? body.version.trim() : ''
|
|
621
|
+
if (version === '') {
|
|
622
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'update version is required' })
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const latest = await checkForUpdate()
|
|
627
|
+
if (!latest.updateAvailable || latest.latestVersion !== version) {
|
|
628
|
+
writeJson(res, 200, { ok: false, code: 'update-not-available', message: `version ${version} is not the latest available release` })
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
await installUpdate(version)
|
|
632
|
+
writeJson(res, 200, { ok: true, currentVersion: CURRENT_VERSION, updatedVersion: version, restartRequired: true })
|
|
633
|
+
} catch (error) {
|
|
634
|
+
writeJson(res, 200, { ok: false, code: 'update-failed', message: messageOf(error) })
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
// ----------------------------------------------------- history list
|
|
639
|
+
{
|
|
640
|
+
kind: 'exact',
|
|
641
|
+
path: HISTORY_API.list,
|
|
642
|
+
handler: async (req, res) => {
|
|
643
|
+
if (!guard(req, res, 'POST')) return
|
|
644
|
+
try {
|
|
645
|
+
writeJson(res, 200, { ok: true, entries: await history.list() })
|
|
646
|
+
} catch (error) {
|
|
647
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
// --------------------------------------------------- history append
|
|
652
|
+
{
|
|
653
|
+
kind: 'exact',
|
|
654
|
+
path: HISTORY_API.append,
|
|
655
|
+
handler: async (req, res) => {
|
|
656
|
+
if (!guard(req, res, 'POST')) return
|
|
657
|
+
const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
|
|
658
|
+
if (body === undefined) {
|
|
659
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
const entry = parseHistoryEntryInput(body)
|
|
663
|
+
if (entry === undefined) {
|
|
664
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed history entry' })
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
writeJson(res, 200, { ok: true, entries: await history.append(entry) })
|
|
669
|
+
} catch (error) {
|
|
670
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
// --------------------------------------------------- history remove
|
|
675
|
+
{
|
|
676
|
+
kind: 'exact',
|
|
677
|
+
path: HISTORY_API.remove,
|
|
678
|
+
handler: async (req, res) => {
|
|
679
|
+
if (!guard(req, res, 'POST')) return
|
|
680
|
+
const body = await readJsonBody(req)
|
|
681
|
+
const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
|
|
682
|
+
if (id === '') {
|
|
683
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'history id is required' })
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
writeJson(res, 200, { ok: true, entries: await history.remove(id) })
|
|
688
|
+
} catch (error) {
|
|
689
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
// ---------------------------------------------------- history clear
|
|
694
|
+
{
|
|
695
|
+
kind: 'exact',
|
|
696
|
+
path: HISTORY_API.clear,
|
|
697
|
+
handler: async (req, res) => {
|
|
698
|
+
if (!guard(req, res, 'POST')) return
|
|
699
|
+
try {
|
|
700
|
+
writeJson(res, 200, { ok: true, entries: await history.clear() })
|
|
701
|
+
} catch (error) {
|
|
702
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
// ------------------------------------------------ history image (prefix)
|
|
707
|
+
{
|
|
708
|
+
kind: 'prefix',
|
|
709
|
+
path: HISTORY_API.image,
|
|
710
|
+
handler: async (req, res) => {
|
|
711
|
+
if (!isLoopbackRequest(req)) {
|
|
712
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
if (req.method !== 'GET') {
|
|
716
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
const file = imageFileFrom(req.url, HISTORY_API.image)
|
|
720
|
+
if (file === undefined) {
|
|
721
|
+
writeJson(res, 404, { error: 'not found' })
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
const found = await history.readImage(file)
|
|
725
|
+
if (found === undefined) {
|
|
726
|
+
writeJson(res, 404, { error: 'not found' })
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
res.writeHead(200, {
|
|
730
|
+
'content-type': found.mime,
|
|
731
|
+
'content-length': found.data.length,
|
|
732
|
+
'cache-control': 'private, max-age=3600',
|
|
733
|
+
})
|
|
734
|
+
res.end(found.data)
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
// ----------------------------------------------------- gallery list
|
|
738
|
+
{
|
|
739
|
+
kind: 'exact',
|
|
740
|
+
path: GALLERY_API.list,
|
|
741
|
+
handler: async (req, res) => {
|
|
742
|
+
if (!guard(req, res, 'POST')) return
|
|
743
|
+
try {
|
|
744
|
+
writeJson(res, 200, { ok: true, entries: await gallery.list() })
|
|
745
|
+
} catch (error) {
|
|
746
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
// --------------------------------------------------- gallery append
|
|
751
|
+
{
|
|
752
|
+
kind: 'exact',
|
|
753
|
+
path: GALLERY_API.append,
|
|
754
|
+
handler: async (req, res) => {
|
|
755
|
+
if (!guard(req, res, 'POST')) return
|
|
756
|
+
const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
|
|
757
|
+
if (body === undefined) {
|
|
758
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
const entry = parseHistoryEntryInput(body)
|
|
762
|
+
if (entry === undefined) {
|
|
763
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed gallery entry' })
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
// The host owns gallery ids: a fresh id per append keeps retries and
|
|
768
|
+
// duplicate submissions from ever reusing a stale filename prefix.
|
|
769
|
+
const result = await gallery.append({ ...entry, id: randomUUID() })
|
|
770
|
+
writeJson(res, 200, { ok: true, entries: result.entries, added: result.added })
|
|
771
|
+
} catch (error) {
|
|
772
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
// --------------------------------------------------- gallery remove
|
|
777
|
+
{
|
|
778
|
+
kind: 'exact',
|
|
779
|
+
path: GALLERY_API.remove,
|
|
780
|
+
handler: async (req, res) => {
|
|
781
|
+
if (!guard(req, res, 'POST')) return
|
|
782
|
+
const body = await readJsonBody(req)
|
|
783
|
+
const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
|
|
784
|
+
if (id === '') {
|
|
785
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id is required' })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
writeJson(res, 200, { ok: true, entries: await gallery.remove(id) })
|
|
790
|
+
} catch (error) {
|
|
791
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
// ----------------------------------------------------- gallery tags
|
|
796
|
+
{
|
|
797
|
+
kind: 'exact',
|
|
798
|
+
path: GALLERY_API.tags,
|
|
799
|
+
handler: async (req, res) => {
|
|
800
|
+
if (!guard(req, res, 'POST')) return
|
|
801
|
+
const body = await readJsonBody(req)
|
|
802
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
803
|
+
const tags = Array.isArray(body?.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined
|
|
804
|
+
if (id === '' || tags === undefined || gallery.updateTags === undefined) {
|
|
805
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id and tags are required' })
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
try { writeJson(res, 200, { ok: true, entries: await gallery.updateTags(id, tags) }) } catch (error) { writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) }) }
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
// ---------------------------------------------------- gallery clear
|
|
812
|
+
{
|
|
813
|
+
kind: 'exact',
|
|
814
|
+
path: GALLERY_API.clear,
|
|
815
|
+
handler: async (req, res) => {
|
|
816
|
+
if (!guard(req, res, 'POST')) return
|
|
817
|
+
try {
|
|
818
|
+
writeJson(res, 200, { ok: true, entries: await gallery.clear() })
|
|
819
|
+
} catch (error) {
|
|
820
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
// ------------------------------------------------ gallery image (prefix)
|
|
825
|
+
{
|
|
826
|
+
kind: 'prefix',
|
|
827
|
+
path: GALLERY_API.image,
|
|
828
|
+
handler: async (req, res) => {
|
|
829
|
+
if (!isLoopbackRequest(req)) {
|
|
830
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
if (req.method !== 'GET') {
|
|
834
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
const file = imageFileFrom(req.url, GALLERY_API.image)
|
|
838
|
+
if (file === undefined) {
|
|
839
|
+
writeJson(res, 404, { error: 'not found' })
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
const found = await gallery.readImage(file)
|
|
843
|
+
if (found === undefined) {
|
|
844
|
+
writeJson(res, 404, { error: 'not found' })
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
res.writeHead(200, {
|
|
848
|
+
'content-type': found.mime,
|
|
849
|
+
'content-length': found.data.length,
|
|
850
|
+
'cache-control': 'private, max-age=3600',
|
|
851
|
+
})
|
|
852
|
+
res.end(found.data)
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
// --------------------------------------------------- templates list
|
|
856
|
+
{
|
|
857
|
+
kind: 'exact',
|
|
858
|
+
path: TEMPLATES_API.list,
|
|
859
|
+
handler: async (req, res) => {
|
|
860
|
+
if (!guard(req, res, 'POST')) return
|
|
861
|
+
try {
|
|
862
|
+
const result = await templates.list()
|
|
863
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
864
|
+
} catch (error) {
|
|
865
|
+
writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
|
|
866
|
+
}
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
// ------------------------------------------------- templates refresh
|
|
870
|
+
{
|
|
871
|
+
kind: 'exact',
|
|
872
|
+
path: TEMPLATES_API.refresh,
|
|
873
|
+
handler: async (req, res) => {
|
|
874
|
+
if (!guard(req, res, 'POST')) return
|
|
875
|
+
try {
|
|
876
|
+
const result = await templates.refresh()
|
|
877
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
878
|
+
} catch (error) {
|
|
879
|
+
writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
// -------------------------------------- templates image (prefix, proxied)
|
|
884
|
+
{
|
|
885
|
+
kind: 'prefix',
|
|
886
|
+
path: TEMPLATES_API.image,
|
|
887
|
+
handler: async (req, res) => {
|
|
888
|
+
if (!isLoopbackRequest(req)) {
|
|
889
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
if (req.method !== 'GET') {
|
|
893
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
const file = imageFileFrom(req.url, TEMPLATES_API.image)
|
|
897
|
+
if (file === undefined) {
|
|
898
|
+
writeJson(res, 404, { error: 'not found' })
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
const found = await templates.readImage(file)
|
|
902
|
+
if (found === undefined) {
|
|
903
|
+
writeJson(res, 404, { error: 'not found' })
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
res.writeHead(200, {
|
|
907
|
+
'content-type': found.mime,
|
|
908
|
+
'content-length': found.data.length,
|
|
909
|
+
// Cached on disk by the host; reference images are immutable per name.
|
|
910
|
+
'cache-control': 'private, max-age=86400',
|
|
911
|
+
})
|
|
912
|
+
res.end(found.data)
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
]
|
|
916
|
+
}
|