@dickpy/dsh-imagegen 1.0.20 → 1.1.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/src/client/api.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * data access path the panel uses — plain fetch, same origin.
4
4
  */
5
5
 
6
- import { GALLERY_API, GENERATE_API, HISTORY_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
6
+ import { GALLERY_API, GENERATE_API, HISTORY_API, PROMPT_ENHANCE_API, TASK_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type GenerationTask, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -73,6 +73,37 @@ export class ImageGenApi {
73
73
  }
74
74
  }
75
75
 
76
+ /** Ask the configured chat model to expand a concise image prompt. */
77
+ async enhancePrompt(prompt: string): Promise<string> {
78
+ const response = await fetch(PROMPT_ENHANCE_API.enhance, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ prompt }),
82
+ })
83
+ const body = await readEnvelope<{ ok: true; prompt: string }>(response)
84
+ return body.prompt
85
+ }
86
+
87
+ async taskSubmit(request: GenerateRequest): Promise<GenerationTask> {
88
+ const response = await fetch(TASK_API.submit, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) })
89
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
90
+ }
91
+
92
+ async taskList(): Promise<GenerationTask[]> {
93
+ const response = await fetch(TASK_API.list, { method: 'POST' })
94
+ return (await readEnvelope<{ ok: true; tasks: GenerationTask[] }>(response)).tasks
95
+ }
96
+
97
+ async taskCancel(id: string): Promise<GenerationTask> {
98
+ const response = await fetch(TASK_API.cancel, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) })
99
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
100
+ }
101
+
102
+ async taskRetry(id: string): Promise<GenerationTask> {
103
+ const response = await fetch(TASK_API.retry, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) })
104
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
105
+ }
106
+
76
107
  /** List the host-persisted history (newest first). */
77
108
  async historyList(): Promise<HistoryEntry[]> {
78
109
  const response = await fetch(HISTORY_API.list, { method: 'POST' })
@@ -135,6 +166,11 @@ export class ImageGenApi {
135
166
  return body.entries
136
167
  }
137
168
 
169
+ async gallerySetTags(id: string, tags: string[]): Promise<HistoryEntry[]> {
170
+ const response = await fetch(GALLERY_API.tags, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, tags }) })
171
+ return (await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)).entries
172
+ }
173
+
138
174
  /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
139
175
  async templatesList(): Promise<TemplateListResult> {
140
176
  const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
@@ -13,7 +13,20 @@ export const zh = {
13
13
  // prompt
14
14
  'prompt.placeholder': '描述你想要的画面,例如:一只戴着宇航员头盔的橘猫,在月球上举起望远镜,水彩风格,柔和光线…',
15
15
  'prompt.required': '请输入提示词',
16
- 'prompt.count': '{count}/2000',
16
+ 'prompt.count': '{count}',
17
+ 'prompt.enhance': '增强',
18
+ 'prompt.enhancing': '增强中…',
19
+ 'prompt.enhanceHint': '使用已配置的对话模型扩写提示词',
20
+ 'prompt.configTitle': '请先配置提示词增强模型',
21
+ 'prompt.configHint': '已打开设置。进入「插件 → AI 生图」,填写或复用 API 地址和密钥,选择对话模型并保存。',
22
+ 'tasks.title': '生成任务',
23
+ 'tasks.queued': '排队中',
24
+ 'tasks.running': '生成中',
25
+ 'tasks.completed': '已完成',
26
+ 'tasks.failed': '失败',
27
+ 'tasks.cancelled': '已取消',
28
+ 'tasks.cancel': '取消',
29
+ 'tasks.retry': '重试',
17
30
  // parameters
18
31
  'params.size': '尺寸',
19
32
  'params.quality': '清晰度',
@@ -42,6 +55,11 @@ export const zh = {
42
55
  'detail.hint': '透传参数,部分 gpt-image-2 网关支持;官方接口请保持「自动」',
43
56
  // footer
44
57
  'model.label': '模型',
58
+ 'compare.enable': '多模型对比',
59
+ 'compare.models': '参与对比的模型',
60
+ 'compare.title': '多模型结果对比',
61
+ 'compare.fullscreen': '全屏对比',
62
+ 'compare.selectRequired': '请至少选择一个对比模型',
45
63
  'generate': '开始生成',
46
64
  'generating': '生成中…',
47
65
  // edit reference image
@@ -67,6 +85,11 @@ export const zh = {
67
85
  'history.delete': '删除',
68
86
  'history.images': '张',
69
87
  'history.viewing': '历史 · {time}',
88
+ 'history.search': '搜索提示词或模型…',
89
+ 'history.model': '模型筛选',
90
+ 'history.ratio': '比例筛选',
91
+ 'history.allModels': '全部模型',
92
+ 'history.allRatios': '全部比例',
70
93
  // gallery
71
94
  'gallery.title': '画廊',
72
95
  'gallery.categories': '分类',
@@ -74,6 +97,7 @@ export const zh = {
74
97
  'gallery.gpt': 'gpt-image-2',
75
98
  'gallery.grok': 'grok-imagine-image',
76
99
  'gallery.ratio': '画面比例',
100
+ 'gallery.tags': '标签',
77
101
  'gallery.filterHint': '按生成模式、模型和比例筛选画廊',
78
102
  'gallery.count': '· 共 {count} 幅',
79
103
  'gallery.viewMode': '视图模式',
@@ -83,6 +107,19 @@ export const zh = {
83
107
  'gallery.newest': '最新发布',
84
108
  'gallery.oldest': '最早发布',
85
109
  'gallery.untitled': '未命名作品',
110
+ 'gallery.search': '搜索作品或模型…',
111
+ 'gallery.tagsPlaceholder': '标签,用逗号分隔',
112
+ 'gallery.tagsApply': '添加标签',
113
+ 'gallery.editTags': '编辑标签',
114
+ 'gallery.tagsEditShort': '编辑',
115
+ 'gallery.tagsSave': '保存',
116
+ 'gallery.tagsCancel': '取消',
117
+ 'gallery.selected': '已选 {count} 项',
118
+ 'gallery.selectionDone': '完成选择',
119
+ 'gallery.selectionClear': '取消选择',
120
+ 'gallery.downloadSelected': '下载所选',
121
+ 'gallery.exportJson': '导出 JSON',
122
+ 'gallery.select': '选择作品',
86
123
  'gallery.add': '加入画廊',
87
124
  'gallery.added': '已加入画廊',
88
125
  'gallery.already': '已在画廊中',
@@ -107,6 +144,12 @@ export const zh = {
107
144
  'preview.addToEdit': '添加到图生图',
108
145
  // config banner
109
146
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
147
+ 'config.generationTitle': '请先配置生图 API',
148
+ 'config.generationHint': '已自动打开 DSH「设置 → 插件 → AI 生图」。填写生图 API 地址与 API 密钥,保存后即可开始生成。',
149
+ 'config.enhancementTitle': '请先配置提示词增强模型',
150
+ 'config.enhancementHint': '已自动打开 DSH「设置 → 插件 → AI 生图」。填写或复用对话 API 地址和密钥,选择对话模型并保存。',
151
+ 'config.disabledTitle': 'AI 生图插件已停用',
152
+ 'config.disabledHint': '已自动打开 DSH「设置 → 插件 → AI 生图」。开启「启用插件」后即可生成图片。',
110
153
  'config.configured': '已连接 {url}',
111
154
  'config.disabled': '插件已停用,请在设置中重新启用。',
112
155
  'connection.connected': '已连接',
@@ -128,6 +171,17 @@ export const zh = {
128
171
  'settings.apiKeyHint': 'Bearer 密钥,明文存于本机设置文档;界面只显示是否已设置',
129
172
  'settings.apiKeySet': '已保存密钥;输入新值可更换,点击「清除」可删除',
130
173
  'settings.apiKeyClear': '清除',
174
+ 'settings.promptEnhanceTitle': '提示词增强模型',
175
+ 'settings.promptEnhanceHint': '用于将简短描述扩写为更完整的生图提示词;留空的地址和密钥会复用生图配置。',
176
+ 'settings.promptApiUrl': '对话 API 地址(可选)',
177
+ 'settings.promptApiUrlHint': 'OpenAI 兼容基址;留空则复用图像 API 地址。',
178
+ 'settings.promptApiKey': '对话 API 密钥(可选)',
179
+ 'settings.promptApiKeyHint': '留空则复用图像 API 密钥。',
180
+ 'settings.promptModel': '对话模型',
181
+ 'settings.promptModelHint': '选择或填写支持 /chat/completions 的模型。',
182
+ 'settings.promptModelsFetch': '获取可用模型',
183
+ 'settings.promptModelsLoading': '正在获取…',
184
+ 'settings.promptModelsSelect': '选择一个模型',
131
185
  'settings.announceToAgent': '向 Agent 播报本插件',
132
186
  'settings.announceToAgentHint': '开启后,本插件的存在与能力会写入每个 Agent 的系统提示词',
133
187
  'settings.enabled': '启用插件',
@@ -186,7 +240,20 @@ export const en: Record<keyof typeof zh, string> = {
186
240
  'mode.edit': 'Image to Image',
187
241
  'prompt.placeholder': 'Describe the picture you want, e.g. an orange cat in an astronaut helmet raising a telescope on the moon, watercolor style, soft light…',
188
242
  'prompt.required': 'Enter a prompt first',
189
- 'prompt.count': '{count}/2000',
243
+ 'prompt.count': '{count}',
244
+ 'prompt.enhance': 'Enhance',
245
+ 'prompt.enhancing': 'Enhancing…',
246
+ 'prompt.enhanceHint': 'Expand the prompt with the configured chat model',
247
+ 'prompt.configTitle': 'Configure a prompt enhancement model first',
248
+ 'prompt.configHint': 'Settings has opened. Go to Plugins → AI Image, configure or reuse an API URL and key, choose a chat model, then save.',
249
+ 'tasks.title': 'Generation tasks',
250
+ 'tasks.queued': 'Queued',
251
+ 'tasks.running': 'Generating',
252
+ 'tasks.completed': 'Completed',
253
+ 'tasks.failed': 'Failed',
254
+ 'tasks.cancelled': 'Cancelled',
255
+ 'tasks.cancel': 'Cancel',
256
+ 'tasks.retry': 'Retry',
190
257
  'params.size': 'Size',
191
258
  'params.quality': 'Quality',
192
259
  'params.count': 'Count',
@@ -213,6 +280,11 @@ export const en: Record<keyof typeof zh, string> = {
213
280
  'detail.high': 'High',
214
281
  'detail.hint': 'Passthrough parameter supported by some gpt-image-2 gateways; keep "Auto" for official endpoints',
215
282
  'model.label': 'Model',
283
+ 'compare.enable': 'Multi-model comparison',
284
+ 'compare.models': 'Models to compare',
285
+ 'compare.title': 'Multi-model comparison',
286
+ 'compare.fullscreen': 'Fullscreen comparison',
287
+ 'compare.selectRequired': 'Select at least one model',
216
288
  'generate': 'Generate',
217
289
  'generating': 'Generating…',
218
290
  'edit.upload': 'Click or drag to upload a reference image',
@@ -235,12 +307,18 @@ export const en: Record<keyof typeof zh, string> = {
235
307
  'history.delete': 'Delete',
236
308
  'history.images': 'images',
237
309
  'history.viewing': 'History · {time}',
310
+ 'history.search': 'Search prompt or model…',
311
+ 'history.model': 'Model filter',
312
+ 'history.ratio': 'Ratio filter',
313
+ 'history.allModels': 'All models',
314
+ 'history.allRatios': 'All ratios',
238
315
  'gallery.title': 'Gallery',
239
316
  'gallery.categories': 'Categories',
240
317
  'gallery.all': 'All works',
241
318
  'gallery.gpt': 'gpt-image-2',
242
319
  'gallery.grok': 'grok-imagine-image',
243
320
  'gallery.ratio': 'Aspect ratio',
321
+ 'gallery.tags': 'Tags',
244
322
  'gallery.filterHint': 'Filter by mode, model, and aspect ratio',
245
323
  'gallery.count': '· {count} works',
246
324
  'gallery.viewMode': 'View mode',
@@ -250,6 +328,19 @@ export const en: Record<keyof typeof zh, string> = {
250
328
  'gallery.newest': 'Newest',
251
329
  'gallery.oldest': 'Oldest',
252
330
  'gallery.untitled': 'Untitled work',
331
+ 'gallery.search': 'Search works or models…',
332
+ 'gallery.tagsPlaceholder': 'Tags, comma separated',
333
+ 'gallery.tagsApply': 'Add tags',
334
+ 'gallery.editTags': 'Edit tags',
335
+ 'gallery.tagsEditShort': 'Edit',
336
+ 'gallery.tagsSave': 'Save',
337
+ 'gallery.tagsCancel': 'Cancel',
338
+ 'gallery.selected': '{count} selected',
339
+ 'gallery.selectionDone': 'Done selecting',
340
+ 'gallery.selectionClear': 'Clear selection',
341
+ 'gallery.downloadSelected': 'Download selected',
342
+ 'gallery.exportJson': 'Export JSON',
343
+ 'gallery.select': 'Select work',
253
344
  'gallery.add': 'Add to gallery',
254
345
  'gallery.added': 'Added to gallery',
255
346
  'gallery.already': 'Already in gallery',
@@ -272,6 +363,12 @@ export const en: Record<keyof typeof zh, string> = {
272
363
  'preview.copied': 'Copied',
273
364
  'preview.addToEdit': 'Add to image to image',
274
365
  'config.missing': 'API not configured: open "Settings → Plugins → Configurable" and fill in api_url and api_key for AI Image.',
366
+ 'config.generationTitle': 'Configure the image API first',
367
+ 'config.generationHint': 'DSH Settings → Plugins → AI Image has opened. Enter the image API URL and API key, then save before generating.',
368
+ 'config.enhancementTitle': 'Configure a prompt enhancement model first',
369
+ 'config.enhancementHint': 'DSH Settings → Plugins → AI Image has opened. Configure or reuse a chat API URL and key, choose a chat model, then save.',
370
+ 'config.disabledTitle': 'The AI Image plugin is disabled',
371
+ 'config.disabledHint': 'DSH Settings → Plugins → AI Image has opened. Enable the plugin, then generate images.',
275
372
  'config.configured': 'Connected to {url}',
276
373
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
277
374
  'connection.connected': 'Connected',
@@ -291,6 +388,17 @@ export const en: Record<keyof typeof zh, string> = {
291
388
  'settings.apiKeyHint': 'Bearer key, stored in plaintext in the local settings document; the UI only shows whether it is set',
292
389
  'settings.apiKeySet': 'A key is stored; type a new value to replace it, or click "Clear" to remove it',
293
390
  'settings.apiKeyClear': 'Clear',
391
+ 'settings.promptEnhanceTitle': 'Prompt enhancement model',
392
+ 'settings.promptEnhanceHint': 'Expands short requests into complete image prompts. Blank URL and key reuse the image API configuration.',
393
+ 'settings.promptApiUrl': 'Chat API URL (optional)',
394
+ 'settings.promptApiUrlHint': 'OpenAI-compatible base URL. Leave blank to reuse the image API URL.',
395
+ 'settings.promptApiKey': 'Chat API key (optional)',
396
+ 'settings.promptApiKeyHint': 'Leave blank to reuse the image API key.',
397
+ 'settings.promptModel': 'Chat model',
398
+ 'settings.promptModelHint': 'Select or enter a model supporting /chat/completions.',
399
+ 'settings.promptModelsFetch': 'Fetch available models',
400
+ 'settings.promptModelsLoading': 'Fetching…',
401
+ 'settings.promptModelsSelect': 'Select a model',
294
402
  'settings.announceToAgent': 'Announce this plugin to agents',
295
403
  'settings.announceToAgentHint': 'When on, the plugin presence and capabilities are written into every agent system prompt',
296
404
  'settings.enabled': 'Enable plugin',
@@ -319,6 +319,35 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
319
319
  overflow: hidden;
320
320
  }
321
321
 
322
+ .taskTray {
323
+ position: absolute;
324
+ z-index: 6;
325
+ top: 12px;
326
+ right: 12px;
327
+ width: min(360px, calc(100% - 24px));
328
+ overflow: hidden;
329
+ border: 1px solid var(--dsw-alias-border-l2);
330
+ border-radius: 9px;
331
+ background: color-mix(in srgb, var(--dsw-alias-bg-layer-1) 92%, transparent);
332
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
333
+ backdrop-filter: blur(10px);
334
+ }
335
+
336
+ .taskTrayHeader { display: flex; justify-content: space-between; padding: 8px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--dsw-alias-border-l1); }
337
+ .taskRow { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; align-items: center; padding: 7px 10px; border-top: 1px solid var(--dsw-alias-border-l1); }
338
+ .taskRow:first-of-type { border-top: 0; }
339
+ .taskStatus { color: var(--dsw-alias-label-tertiary); font-size: 11px; white-space: nowrap; }
340
+ .taskRow[data-status='running'] .taskStatus { color: var(--dsw-alias-brand-primary); }
341
+ .taskRow[data-status='failed'] .taskStatus { color: var(--dsw-alias-label-error); }
342
+ .taskPrompt { overflow: hidden; color: var(--dsw-alias-label-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
343
+ .taskRow button { padding: 2px 7px; border: 0; border-radius: 5px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); cursor: pointer; font: inherit; font-size: 11px; }
344
+ .taskRow button:hover { color: var(--dsw-alias-brand-primary); }
345
+
346
+ .configGuide { position: fixed; inset: 0; z-index: 1100; display: grid; place-items: center; padding: 20px; background: rgb(0 0 0 / 35%); }
347
+ .configGuideBody { display: flex; width: min(360px, 100%); flex-direction: column; gap: 12px; padding: 18px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); box-shadow: 0 14px 40px rgb(0 0 0 / 20%); }
348
+ .configGuideBody span { color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.55; }
349
+ .configGuideBody button { align-self: flex-end; min-height: 30px; padding: 0 12px; border: 0; border-radius: 7px; color: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-brand-primary); cursor: pointer; font: inherit; font-size: 12px; }
350
+
322
351
  /* --- history column (far right) ------------------------------------------------ */
323
352
 
324
353
  .history {
@@ -345,6 +374,36 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
345
374
  flex: none;
346
375
  }
347
376
 
377
+ .historyFilters {
378
+ display: grid;
379
+ grid-template-columns: 1fr 1fr;
380
+ gap: 6px;
381
+ padding: 8px 10px;
382
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
383
+ }
384
+
385
+ .historySearch,
386
+ .historyFilters select,
387
+ .gallerySearch {
388
+ min-width: 0;
389
+ min-height: 29px;
390
+ box-sizing: border-box;
391
+ padding: 0 8px;
392
+ border: 1px solid var(--dsw-alias-border-l2);
393
+ border-radius: 7px;
394
+ color: var(--dsw-alias-label-primary);
395
+ background: var(--dsw-alias-bg-layer-2);
396
+ font: inherit;
397
+ font-size: 11px;
398
+ }
399
+
400
+ .historySearch { grid-column: 1 / -1; }
401
+ .gallerySearch { width: 156px; }
402
+ .galleryTagInput { width: 130px; min-height: 29px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: inherit; font-size: 11px; }
403
+ .galleryBulkButton { min-height: 29px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 7px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); cursor: pointer; font: inherit; font-size: 11px; }
404
+ .galleryBulkButton:hover:not(:disabled) { color: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
405
+ .galleryBulkButton:disabled { opacity: .45; cursor: default; }
406
+
348
407
  .historyTitle {
349
408
  font-size: 13px;
350
409
  font-weight: 600;
@@ -663,6 +722,26 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
663
722
  transform: translateY(0);
664
723
  }
665
724
 
725
+ .enhanceButton {
726
+ height: 26px;
727
+ margin-left: auto;
728
+ padding: 0 11px;
729
+ border: 1px solid var(--dsw-alias-border-l2);
730
+ border-radius: 999px;
731
+ color: var(--dsw-alias-label-secondary);
732
+ background: var(--dsw-alias-bg-layer-2);
733
+ font: inherit;
734
+ font-size: 12px;
735
+ cursor: pointer;
736
+ }
737
+
738
+ .enhanceButton:hover:not(:disabled) {
739
+ color: var(--dsw-alias-brand-primary);
740
+ border-color: var(--dsw-alias-brand-primary);
741
+ }
742
+
743
+ .enhanceButton:disabled { opacity: 0.5; cursor: default; }
744
+
666
745
  .promptCount {
667
746
  font-size: 11px;
668
747
  color: var(--dsw-alias-label-tertiary);
@@ -1376,22 +1455,62 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1376
1455
  .galleryRatioList { display: flex; flex-wrap: wrap; gap: 6px; }
1377
1456
  .galleryRatio { padding: 6px 10px; border: 0; border-radius: 999px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); cursor: pointer; font-size: 12px; }
1378
1457
  .galleryRatio[data-active] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 13%, transparent); }
1458
+ .galleryTagFilterList { display: flex; flex-wrap: wrap; gap: 6px; }
1459
+ .galleryTagFilter { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; min-height: 27px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); cursor: pointer; font: inherit; font-size: 11px; }
1460
+ .galleryTagFilter span:first-child { overflow: hidden; max-width: 112px; text-overflow: ellipsis; white-space: nowrap; }
1461
+ .galleryTagFilter span:last-child { color: var(--dsw-alias-label-tertiary); font-size: 10px; }
1462
+ .galleryTagFilter:hover, .galleryTagFilter[data-active] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }
1379
1463
  .galleryFilterNote { margin: 20px 4px 0; color: var(--dsw-alias-label-quaternary); font-size: 11px; line-height: 1.5; }
1380
1464
  .galleryWorkspace { position: absolute; inset: 0; display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; padding: 22px 24px 26px; box-sizing: border-box; }
1381
1465
  .galleryToolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex: none; min-width: 0; margin-bottom: 18px; }
1382
1466
  .galleryHeading { display: inline; margin: 0; color: var(--dsw-alias-label-primary); font-size: 20px; font-weight: 700; }
1383
1467
  .galleryCount { margin-left: 8px; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
1384
1468
  .galleryToolbarActions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
1469
+ .gallerySelectMode, .galleryBulkButton, .gallerySelectionClear { min-height: 30px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); cursor: pointer; font: inherit; font-size: 12px; }
1470
+ .gallerySelectMode:hover, .gallerySelectMode[data-active], .galleryBulkButton:hover:not(:disabled) { color: var(--dsw-alias-brand-primary); border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 38%, var(--dsw-alias-border-l1)); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 11%, transparent); }
1471
+ .galleryBulkButton:disabled { cursor: not-allowed; opacity: .45; }
1472
+ .gallerySelectionBar { display: flex; align-items: center; gap: 10px; flex: none; min-width: 0; margin: -4px 0 16px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 35%, var(--dsw-alias-border-l1)); border-radius: 9px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 7%, var(--dsw-alias-bg-layer-1)); }
1473
+ .gallerySelectionBar strong { flex: none; color: var(--dsw-alias-brand-primary); font-size: 12px; }
1474
+ .gallerySelectionClear { margin-left: auto; }
1475
+ .gallerySelectionClear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
1476
+ .galleryTagInput, .gallerySearch { min-width: 0; height: 30px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); font: inherit; font-size: 12px; }
1477
+ .galleryTagInput { flex: 1 1 190px; }
1478
+ .galleryTagInput:focus, .gallerySearch:focus { border-color: var(--dsw-alias-brand-primary); }
1385
1479
  .galleryViewToggle { display: flex; padding: 3px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); }
1386
1480
  .galleryViewToggle button, .gallerySort, .galleryClear { min-height: 30px; padding: 0 10px; border: 0; border-radius: 7px; color: var(--dsw-alias-label-secondary); background: transparent; cursor: pointer; font: inherit; font-size: 12px; }
1387
1481
  .galleryViewToggle button[data-active], .galleryViewToggle button:hover, .gallerySort:hover, .galleryClear:hover { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 11%, transparent); }
1388
1482
  .gallerySort { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); }
1389
1483
  .galleryClear { border: 1px solid var(--dsw-alias-border-l1); }
1484
+ .compareControl { display: flex; flex-direction: column; gap: 6px; margin: 0 0 10px; }
1485
+ .compareToggle, .compareModelChoices label { display: flex; align-items: center; gap: 6px; color: var(--dsw-alias-label-secondary); font-size: 12px; cursor: pointer; }
1486
+ .compareToggle input, .compareModelChoices input { accent-color: var(--dsw-alias-brand-primary); }
1487
+ .compareModelChoices { display: flex; flex-wrap: wrap; gap: 6px; }
1488
+ .compareModelChoices label { padding: 4px 6px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 5px; font-size: 10px; }
1489
+ .comparisonBoard { position: absolute; z-index: 4; top: 126px; right: 14px; bottom: 14px; left: 14px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); box-shadow: 0 8px 24px rgb(0 0 0 / 12%); }
1490
+ .comparisonBoard > header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
1491
+ .comparisonBoard > header div { display: flex; align-items: baseline; gap: 7px; }
1492
+ .comparisonBoard > header strong { color: var(--dsw-alias-label-primary); font-size: 13px; }
1493
+ .comparisonBoard > header span { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
1494
+ .comparisonBoard > header button { min-height: 28px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 5px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-2); cursor: pointer; font: inherit; font-size: 11px; }
1495
+ .comparisonGrid { display: grid; flex: 1; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; overflow: auto; padding: 12px; }
1496
+ .comparisonGrid article { display: flex; min-width: 0; flex-direction: column; gap: 7px; }
1497
+ .comparisonGrid article > strong { color: var(--dsw-alias-label-primary); font-size: 12px; }
1498
+ .comparisonGrid article > span { display: grid; min-height: 160px; place-items: center; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-bg-layer-2); font-size: 12px; }
1499
+ .comparisonGrid img { display: block; width: 100%; min-height: 160px; max-height: calc(100vh - 260px); object-fit: contain; background: var(--dsw-alias-bg-base); }
1500
+ .comparisonFullscreen { position: fixed; z-index: 1200; inset: 0; overflow: auto; padding: 54px 24px 24px; background: rgb(0 0 0 / 92%); }
1501
+ .comparisonFullscreenGrid { display: grid; min-height: 100%; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); align-items: start; gap: 18px; }
1502
+ .comparisonFullscreen figure { min-width: 0; margin: 0; }
1503
+ .comparisonFullscreen figcaption { margin-bottom: 8px; color: #fff; font-size: 13px; font-weight: 600; }
1504
+ .comparisonFullscreen img { display: block; width: 100%; margin-bottom: 10px; background: #111; }
1390
1505
  .galleryMasonry { display: grid; flex: 1 1 auto; width: 100%; height: 0; max-width: 100%; min-width: 0; min-height: 0; box-sizing: border-box; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: max-content; align-content: start; gap: 16px; overflow-y: scroll; overflow-x: hidden; overscroll-behavior: contain; padding: 2px 8px 16px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2) transparent; }
1391
1506
  .galleryMasonry::-webkit-scrollbar { width: 8px; }
1392
1507
  .galleryMasonry::-webkit-scrollbar-thumb { background: var(--dsw-alias-border-l2); border-radius: 999px; }
1393
- .galleryCard { display: block; width: 100%; margin: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-alias-bg-layer-1); box-shadow: 0 5px 18px rgb(24 32 54 / 7%); }
1508
+ .galleryCard { position: relative; display: block; width: 100%; margin: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-alias-bg-layer-1); box-shadow: 0 5px 18px rgb(24 32 54 / 7%); }
1509
+ .galleryCard[data-selected] { border-color: var(--dsw-alias-brand-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent), 0 5px 18px rgb(24 32 54 / 7%); }
1510
+ .gallerySelect { position: absolute; z-index: 2; top: 9px; right: 9px; display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid rgb(255 255 255 / 75%); border-radius: 7px; background: rgb(0 0 0 / 58%); cursor: pointer; }
1511
+ .gallerySelect input { width: 16px; height: 16px; margin: 0; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; }
1394
1512
  .galleryImageButton { position: relative; display: block; width: 100%; padding: 0; border: 0; background: var(--dsw-alias-bg-base); cursor: zoom-in; }
1513
+ .galleryImageButton[data-selecting] { cursor: pointer; }
1395
1514
  .galleryImage { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
1396
1515
  .galleryMasonry[data-view='masonry'] .galleryCard:nth-child(3n + 1) .galleryImage { aspect-ratio: 4 / 5; }
1397
1516
  .galleryMasonry[data-view='masonry'] .galleryCard:nth-child(3n + 2) .galleryImage { aspect-ratio: 4 / 3; }
@@ -1403,6 +1522,15 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1403
1522
  .galleryCardInfo strong, .galleryCardInfo small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1404
1523
  .galleryCardInfo strong { color: var(--dsw-alias-label-primary); font-size: 12px; }
1405
1524
  .galleryCardInfo small { color: var(--dsw-alias-label-tertiary); font-size: 10px; }
1525
+ .galleryTags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
1526
+ .galleryTags button { overflow: hidden; max-width: 96px; min-height: 19px; padding: 1px 6px; border: 0; border-radius: 4px; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); cursor: pointer; font: inherit; font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
1527
+ .galleryTags button:hover { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 17%, transparent); }
1528
+ .galleryTags .galleryTagEdit { flex: none; color: var(--dsw-alias-label-tertiary); background: transparent; }
1529
+ .galleryTagEditor { display: flex; align-items: center; gap: 6px; padding: 0 12px 10px 48px; }
1530
+ .galleryTagEditor input { min-width: 0; flex: 1; height: 27px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; outline: none; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); font: inherit; font-size: 11px; }
1531
+ .galleryTagEditor input:focus { border-color: var(--dsw-alias-brand-primary); }
1532
+ .galleryTagEditor button { height: 27px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); cursor: pointer; font: inherit; font-size: 11px; }
1533
+ .galleryTagEditor button[type='submit'] { color: var(--dsw-alias-brand-primary); }
1406
1534
  .galleryRemove { flex: none; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; color: var(--dsw-alias-label-tertiary); background: transparent; cursor: pointer; font-size: 18px; }
1407
1535
  .galleryRemove:hover { color: var(--dsw-alias-state-error); background: var(--dsw-alias-bg-layer-2); }
1408
1536
 
@@ -247,6 +247,48 @@
247
247
  color: var(--dsw-alias-label-tertiary);
248
248
  }
249
249
 
250
+ .sectionDivider {
251
+ height: 1px;
252
+ margin: 18px 0 14px;
253
+ background: var(--dsw-alias-border-l2);
254
+ }
255
+
256
+ .sectionTitle {
257
+ margin: 0;
258
+ color: var(--dsw-alias-label-primary);
259
+ font-size: 14px;
260
+ line-height: 1.4;
261
+ }
262
+
263
+ .sectionHint {
264
+ margin: -4px 0 2px;
265
+ color: var(--dsw-alias-label-tertiary);
266
+ font-size: 12px;
267
+ line-height: 1.5;
268
+ }
269
+
270
+ .modelFetchRow {
271
+ display: flex;
272
+ gap: 8px;
273
+ align-items: center;
274
+ }
275
+
276
+ .modelFetch,
277
+ .modelChoices {
278
+ min-height: 32px;
279
+ border: 1px solid var(--dsw-alias-border-l2);
280
+ border-radius: 7px;
281
+ background: var(--dsw-alias-bg-layer-3);
282
+ color: var(--dsw-alias-label-secondary);
283
+ font: inherit;
284
+ font-size: 12px;
285
+ }
286
+
287
+ .modelFetch { padding: 0 10px; cursor: pointer; }
288
+ .modelFetch:hover:not(:disabled) { color: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
289
+ .modelFetch:disabled { opacity: 0.5; cursor: default; }
290
+ .modelChoices { min-width: 0; flex: 1; padding: 0 8px; }
291
+
250
292
  /* --- footer (mirror of the official card footer) ------------------------------ */
251
293
 
252
294
  .footer {
@@ -22,6 +22,9 @@ export interface ImageGenConfig {
22
22
  announceToAgent?: boolean
23
23
  apiUrl?: string
24
24
  apiKey?: string
25
+ promptApiUrl?: string
26
+ promptApiKey?: string
27
+ promptModel?: string
25
28
  }
26
29
 
27
30
  /** Wire shape of one namespace view from the bridge. */
@@ -79,6 +82,8 @@ class BridgeScopeController<T> implements SettingsScope<T> {
79
82
  private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
80
83
  /** Whether the namespace currently holds a stored secret (e.g. apiKey). */
81
84
  private readonly keySet: SnapshotStore<boolean>
85
+ /** Individual secret presence bits, keyed by the settings field name. */
86
+ private readonly secretSets: SnapshotStore<Record<string, boolean>>
82
87
  private tail: Promise<void> = Promise.resolve()
83
88
  private disposed = false
84
89
 
@@ -96,6 +101,7 @@ class BridgeScopeController<T> implements SettingsScope<T> {
96
101
  mode: 'host',
97
102
  })
98
103
  this.keySet = createSnapshotStore(false)
104
+ this.secretSets = createSnapshotStore({})
99
105
  }
100
106
 
101
107
  getSnapshot(): SettingsScopeSnapshot<T> {
@@ -112,6 +118,16 @@ class BridgeScopeController<T> implements SettingsScope<T> {
112
118
  return this.keySet.subscribe(listener)
113
119
  }
114
120
 
121
+ /** Whether a specific secret field currently has a stored value. */
122
+ getSecretSetSnapshot(field: string): boolean {
123
+ return this.secretSets.getSnapshot()[field] === true
124
+ }
125
+
126
+ /** Observe changes to individual secret-field presence bits. */
127
+ subscribeSecretSets(listener: () => void): () => void {
128
+ return this.secretSets.subscribe(listener)
129
+ }
130
+
115
131
  subscribe(listener: () => void): () => void {
116
132
  return this.store.subscribe(listener)
117
133
  }
@@ -164,6 +180,7 @@ class BridgeScopeController<T> implements SettingsScope<T> {
164
180
  draft.writable = writable === true
165
181
  })
166
182
  this.keySet.set(false)
183
+ this.secretSets.set({})
167
184
  return
168
185
  }
169
186
  this.accept(view, writable)
@@ -200,7 +217,9 @@ class BridgeScopeController<T> implements SettingsScope<T> {
200
217
  // card binds without a narrowing decoder.
201
218
  draft.value = view.value as T
202
219
  })
203
- this.keySet.set(Array.isArray(view.secrets) && view.secrets.some(secret => secret.set))
220
+ const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
221
+ this.keySet.set(Object.values(secretSets).some(Boolean))
222
+ this.secretSets.set(secretSets)
204
223
  }
205
224
  }
206
225
 
@@ -210,6 +229,8 @@ export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
210
229
  load(): Promise<void>
211
230
  getKeySetSnapshot(): boolean
212
231
  subscribeKeySet(listener: () => void): () => void
232
+ getSecretSetSnapshot(field: string): boolean
233
+ subscribeSecretSets(listener: () => void): () => void
213
234
  }
214
235
 
215
236
  /**
package/src/engine.ts CHANGED
@@ -212,6 +212,7 @@ async function requestOneImage(
212
212
  upstream: UpstreamConfig,
213
213
  request: GenerateRequest,
214
214
  params: ReturnType<typeof effectiveParams>,
215
+ signal?: AbortSignal,
215
216
  ): Promise<GeneratedImage[]> {
216
217
  const headers: Record<string, string> = {
217
218
  authorization: `Bearer ${upstream.apiKey.trim()}`,
@@ -264,7 +265,7 @@ async function requestOneImage(
264
265
  method: 'POST',
265
266
  headers,
266
267
  body,
267
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
268
+ signal: signal === undefined ? AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)]),
268
269
  })
269
270
  } catch (error) {
270
271
  const message = error instanceof Error ? error.message : String(error)
@@ -312,14 +313,14 @@ async function requestOneImage(
312
313
  * parameter is never sent, because Responses-API-based gateways reject it as
313
314
  * `tools[0].n`), then the results are flattened in order.
314
315
  */
315
- export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest): Promise<GenerateResult> {
316
+ export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
316
317
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
317
318
  if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
318
319
  if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
319
320
  const params = effectiveParams(request)
320
321
  const count = effectiveCount(request)
321
322
  const batches = await Promise.all(
322
- Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params)),
323
+ Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
323
324
  )
324
325
  return { images: batches.flat() }
325
326
  }
@@ -60,6 +60,7 @@ interface StoredEntry {
60
60
  images: StoredImage[]
61
61
  hash?: string
62
62
  refName?: string
63
+ tags?: string[]
63
64
  }
64
65
 
65
66
  /** The index.json shape. */
@@ -171,6 +172,7 @@ function toWire(entry: StoredEntry): HistoryEntry {
171
172
  ...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
172
173
  })),
173
174
  ...entry.refName === undefined ? {} : { refName: entry.refName },
175
+ ...entry.tags === undefined ? {} : { tags: entry.tags },
174
176
  }
175
177
  }
176
178
 
@@ -242,6 +244,18 @@ export async function removeGallery(id: string): Promise<HistoryEntry[]> {
242
244
  })
243
245
  }
244
246
 
247
+ /** Replace the user-managed labels for one gallery entry. */
248
+ export async function updateGalleryTags(id: string, tags: string[]): Promise<HistoryEntry[]> {
249
+ return mutateGallery(async () => {
250
+ const normalized = [...new Set(tags.map(tag => tag.trim()).filter(Boolean))].slice(0, 20)
251
+ const entries = await readIndex()
252
+ const target = entries.find(entry => entry.id === id)
253
+ if (target !== undefined) target.tags = normalized
254
+ await writeIndex(entries)
255
+ return entries.map(toWire)
256
+ })
257
+ }
258
+
245
259
  /** Remove every entry (and all image files). */
246
260
  export async function clearGallery(): Promise<HistoryEntry[]> {
247
261
  return mutateGallery(async () => {
@@ -263,4 +277,4 @@ export async function readGalleryImage(file: string): Promise<{ data: Buffer; mi
263
277
  } catch {
264
278
  return undefined
265
279
  }
266
- }
280
+ }