@dickpy/dsh-imagegen 1.0.9 → 1.0.19

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 { GENERATE_API, HISTORY_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
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'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -98,6 +98,43 @@ export class ImageGenApi {
98
98
  return body.entries
99
99
  }
100
100
 
101
+ /** List the host-persisted gallery (newest first). */
102
+ async galleryList(): Promise<HistoryEntry[]> {
103
+ const response = await fetch(GALLERY_API.list, { method: 'POST' })
104
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
105
+ return body.entries
106
+ }
107
+
108
+ /** Append one image to the gallery. The host assigns the id and skips the
109
+ * append when a content-identical image is already in the gallery. */
110
+ async galleryAppend(entry: HistoryEntryInput): Promise<{ entries: HistoryEntry[]; added: boolean }> {
111
+ const response = await fetch(GALLERY_API.append, {
112
+ method: 'POST',
113
+ headers: { 'content-type': 'application/json' },
114
+ body: JSON.stringify({ entry }),
115
+ })
116
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[]; added: boolean }>(response)
117
+ return { entries: body.entries, added: body.added }
118
+ }
119
+
120
+ /** Remove one gallery entry by id. */
121
+ async galleryRemove(id: string): Promise<HistoryEntry[]> {
122
+ const response = await fetch(GALLERY_API.remove, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ id }),
126
+ })
127
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
128
+ return body.entries
129
+ }
130
+
131
+ /** Clear the entire gallery. */
132
+ async galleryClear(): Promise<HistoryEntry[]> {
133
+ const response = await fetch(GALLERY_API.clear, { method: 'POST' })
134
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
135
+ return body.entries
136
+ }
137
+
101
138
  /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
102
139
  async templatesList(): Promise<TemplateListResult> {
103
140
  const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
@@ -4,9 +4,9 @@
4
4
 
5
5
  export const zh = {
6
6
  'entry.label': 'AI 生图',
7
- 'entry.tooltip': 'AI 生图面板(gpt-image-2)',
7
+ 'entry.tooltip': 'AI 生图面板(gpt-image-2 / grok-imagine-image)',
8
8
  'panel.title': 'AI 生图',
9
- 'panel.subtitle': 'gpt-image-2 图像生成',
9
+ 'panel.githubTip': '觉得好用或有建议?欢迎来 GitHub 提 issues、点个 star 支持一下!',
10
10
  // mode
11
11
  'mode.text': '文生图',
12
12
  'mode.edit': '图生图',
@@ -20,16 +20,18 @@ export const zh = {
20
20
  'params.count': '生成数量',
21
21
  'params.detail': '细节',
22
22
  'size.auto': '自动',
23
- 'size.square': '1024×1024',
24
- 'size.landscape': '1536×1024',
25
- 'size.portrait': '1024×1536',
26
- 'size.small': '512×512',
27
- 'size.wide': '1792×1024',
28
- 'size.tall': '1024×1792',
23
+ 'size.square': '1:1 方图',
24
+ 'size.portrait34': '3:4 标准竖图',
25
+ 'size.landscape43': '4:3 标准横图',
26
+ 'size.portrait916': '9:16 竖屏',
27
+ 'size.portrait23': '2:3 竖图',
28
+ 'size.landscape32': '3:2 横图',
29
+ 'size.wide169': '16:9 宽屏',
30
+ 'size.ultrawide21': '21:9 超宽屏',
29
31
  'quality.auto': '自动',
30
- 'quality.low': '',
31
- 'quality.medium': '',
32
- 'quality.high': '',
32
+ 'quality.1k': '1K',
33
+ 'quality.2k': '2K',
34
+ 'quality.4k': '4K',
33
35
  'count.one': '1 张',
34
36
  'count.two': '2 张',
35
37
  'count.three': '3 张',
@@ -65,6 +67,29 @@ export const zh = {
65
67
  'history.delete': '删除',
66
68
  'history.images': '张',
67
69
  'history.viewing': '历史 · {time}',
70
+ // gallery
71
+ 'gallery.title': '画廊',
72
+ 'gallery.categories': '分类',
73
+ 'gallery.all': '全部作品',
74
+ 'gallery.gpt': 'gpt-image-2',
75
+ 'gallery.grok': 'grok-imagine-image',
76
+ 'gallery.ratio': '画面比例',
77
+ 'gallery.filterHint': '按生成模式、模型和比例筛选画廊',
78
+ 'gallery.count': '· 共 {count} 幅',
79
+ 'gallery.viewMode': '视图模式',
80
+ 'gallery.masonry': '瀑布流',
81
+ 'gallery.grid': '整齐网格',
82
+ 'gallery.sort': '排序',
83
+ 'gallery.newest': '最新发布',
84
+ 'gallery.oldest': '最早发布',
85
+ 'gallery.untitled': '未命名作品',
86
+ 'gallery.add': '加入画廊',
87
+ 'gallery.added': '已加入画廊',
88
+ 'gallery.already': '已在画廊中',
89
+ 'gallery.delete': '移出画廊',
90
+ 'gallery.clear': '清空画廊',
91
+ 'gallery.empty': '画廊还是空的,把喜欢的图片加入进来吧',
92
+ 'gallery.viewing': '画廊 · {time}',
68
93
  // preview
69
94
  'preview.title': '图片预览',
70
95
  'preview.open': '点击预览',
@@ -154,9 +179,9 @@ export const zh = {
154
179
 
155
180
  export const en: Record<keyof typeof zh, string> = {
156
181
  'entry.label': 'AI Image',
157
- 'entry.tooltip': 'AI image generation studio (gpt-image-2)',
182
+ 'entry.tooltip': 'AI image generation studio (gpt-image-2 / grok-imagine-image)',
158
183
  'panel.title': 'AI Image',
159
- 'panel.subtitle': 'gpt-image-2 image generation',
184
+ 'panel.githubTip': 'Like it or have suggestions? Head to GitHub to open issues and star us!',
160
185
  'mode.text': 'Text to Image',
161
186
  'mode.edit': 'Image to Image',
162
187
  '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…',
@@ -167,16 +192,18 @@ export const en: Record<keyof typeof zh, string> = {
167
192
  'params.count': 'Count',
168
193
  'params.detail': 'Detail',
169
194
  'size.auto': 'Auto',
170
- 'size.square': '1024×1024',
171
- 'size.landscape': '1536×1024',
172
- 'size.portrait': '1024×1536',
173
- 'size.small': '512×512',
174
- 'size.wide': '1792×1024',
175
- 'size.tall': '1024×1792',
195
+ 'size.square': '1:1 Square',
196
+ 'size.portrait34': '3:4 Portrait',
197
+ 'size.landscape43': '4:3 Landscape',
198
+ 'size.portrait916': '9:16 Vertical',
199
+ 'size.portrait23': '2:3 Portrait',
200
+ 'size.landscape32': '3:2 Landscape',
201
+ 'size.wide169': '16:9 Widescreen',
202
+ 'size.ultrawide21': '21:9 Ultrawide',
176
203
  'quality.auto': 'Auto',
177
- 'quality.low': 'Low',
178
- 'quality.medium': 'Medium',
179
- 'quality.high': 'High',
204
+ 'quality.1k': '1K',
205
+ 'quality.2k': '2K',
206
+ 'quality.4k': '4K',
180
207
  'count.one': '1',
181
208
  'count.two': '2',
182
209
  'count.three': '3',
@@ -208,6 +235,28 @@ export const en: Record<keyof typeof zh, string> = {
208
235
  'history.delete': 'Delete',
209
236
  'history.images': 'images',
210
237
  'history.viewing': 'History · {time}',
238
+ 'gallery.title': 'Gallery',
239
+ 'gallery.categories': 'Categories',
240
+ 'gallery.all': 'All works',
241
+ 'gallery.gpt': 'gpt-image-2',
242
+ 'gallery.grok': 'grok-imagine-image',
243
+ 'gallery.ratio': 'Aspect ratio',
244
+ 'gallery.filterHint': 'Filter by mode, model, and aspect ratio',
245
+ 'gallery.count': '· {count} works',
246
+ 'gallery.viewMode': 'View mode',
247
+ 'gallery.masonry': 'Masonry',
248
+ 'gallery.grid': 'Grid',
249
+ 'gallery.sort': 'Sort',
250
+ 'gallery.newest': 'Newest',
251
+ 'gallery.oldest': 'Oldest',
252
+ 'gallery.untitled': 'Untitled work',
253
+ 'gallery.add': 'Add to gallery',
254
+ 'gallery.added': 'Added to gallery',
255
+ 'gallery.already': 'Already in gallery',
256
+ 'gallery.delete': 'Remove',
257
+ 'gallery.clear': 'Clear gallery',
258
+ 'gallery.empty': 'The gallery is empty — add images you like here',
259
+ 'gallery.viewing': 'Gallery · {time}',
211
260
  'preview.title': 'Image preview',
212
261
  'preview.open': 'Click to preview',
213
262
  'preview.close': 'Close',
@@ -3,11 +3,16 @@
3
3
  *
4
4
  * The `conversation` slot is single-occupant (ui-conversation) and external
5
5
  * plugins cannot declare slots, so the panel takes over the center column at
6
- * the DOM level: a container is appended inside the `[data-pane="conversation"]`
7
- * grid item (an extra trailing child React never manages), and a stylesheet
8
- * rule hides the conversation content while the panel is active. Toggling is
9
- * a data attribute on <html> — no React involvement, so the conversation
10
- * subtree underneath stays mounted and stateful.
6
+ * the DOM level: a container is appended inside the conversation grid item
7
+ * (an extra trailing child React never manages), and a stylesheet rule hides
8
+ * the conversation content while the panel is active. Toggling is a data
9
+ * attribute on <html> — no React involvement, so the conversation subtree
10
+ * underneath stays mounted and stateful.
11
+ *
12
+ * Shell compatibility: the center column is `[data-pane="conversation"]` on
13
+ * legacy shells and `[class*="centerCol"]` on the rc.6+ AppFrame layout (the
14
+ * same dual selector the dsh-ssh / task-board panels use); both are queried
15
+ * and both get the `position: relative` base in panel.module.css.
11
16
  */
12
17
 
13
18
  import { createRoot, type Root } from 'react-dom/client'
@@ -20,7 +25,7 @@ import css from './panel.module.css'
20
25
  /** The injected panel container (kept in the DOM, hidden when inactive). */
21
26
  export const PANEL_VIEW_SELECTOR = '[data-dsh-imagegen-view]'
22
27
 
23
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
28
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
24
29
  const ACTIVE_ATTR = 'data-dsh-imagegen-active'
25
30
  /** Sibling panels' activation attributes, removed when this panel opens. */
26
31
  const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
@@ -10,7 +10,13 @@
10
10
 
11
11
  /* --- center-column takeover (global rules, attribute-scoped) ----------------- */
12
12
 
13
- [data-pane='conversation'] {
13
+ /* The panel container rides inside the conversation grid item as an extra
14
+ trailing child; hidden unless the panel is active. The position rule covers
15
+ both shells: the legacy `[data-pane="conversation"]` and the rc.6+
16
+ AppFrame class-based column ([class*="centerCol"]) — without a positioned
17
+ ancestor the absolutely-positioned view resolves against the frame. */
18
+ [data-pane='conversation'],
19
+ [class*='centerCol'] {
14
20
  position: relative;
15
21
  }
16
22
 
@@ -107,6 +113,7 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
107
113
  }
108
114
 
109
115
  .panel {
116
+ position: relative;
110
117
  display: flex;
111
118
  flex-direction: column;
112
119
  height: 100%;
@@ -143,12 +150,23 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
143
150
  white-space: nowrap;
144
151
  }
145
152
 
146
- .panelSubtitle {
147
- font-size: 12px;
148
- color: var(--dsw-alias-label-tertiary);
149
- white-space: nowrap;
150
- overflow: hidden;
151
- text-overflow: ellipsis;
153
+ /* GitHub icon button next to the title (opens the repo). */
154
+ .githubLink {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ justify-content: center;
158
+ flex: none;
159
+ width: 22px;
160
+ height: 22px;
161
+ border-radius: 6px;
162
+ color: var(--dsw-alias-label-secondary);
163
+ text-decoration: none;
164
+ transition: color 0.12s, background 0.12s;
165
+ }
166
+
167
+ .githubLink:hover {
168
+ color: var(--dsw-alias-label-primary);
169
+ background: var(--dsw-alias-bg-layer-2);
152
170
  }
153
171
 
154
172
  /* --- connection status and update notice -------------------------------------- */
@@ -715,6 +733,10 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
715
733
  .modelSelect {
716
734
  width: 100%;
717
735
  height: 36px;
736
+ display: flex;
737
+ align-items: center;
738
+ justify-content: space-between;
739
+ gap: 8px;
718
740
  padding: 0 12px;
719
741
  font-size: 13px;
720
742
  color: var(--dsw-alias-label-primary);
@@ -724,6 +746,7 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
724
746
  outline: none;
725
747
  font-family: inherit;
726
748
  cursor: pointer;
749
+ text-align: left;
727
750
  }
728
751
 
729
752
  .modelSelect:focus-visible {
@@ -732,6 +755,59 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
732
755
 
733
756
  .modelSelect:disabled {
734
757
  opacity: 0.55;
758
+ cursor: default;
759
+ }
760
+
761
+ /* Model dropdown trigger + upward-opening menu. */
762
+ .modelMenu {
763
+ position: relative;
764
+ display: block;
765
+ min-width: 0;
766
+ }
767
+
768
+ /* The list opens UPWARD (absolute above the trigger) so it never hides
769
+ behind the footer edge below. */
770
+ .modelMenuList {
771
+ position: absolute;
772
+ left: 0;
773
+ right: 0;
774
+ bottom: calc(100% + 6px);
775
+ z-index: 40;
776
+ display: flex;
777
+ flex-direction: column;
778
+ padding: 4px;
779
+ background: var(--dsw-alias-bg-layer-2);
780
+ border: 1px solid var(--dsw-alias-border-l2);
781
+ border-radius: 12px;
782
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.18);
783
+ overflow: hidden;
784
+ }
785
+
786
+ .modelMenuItem {
787
+ display: block;
788
+ width: 100%;
789
+ padding: 7px 10px;
790
+ font: inherit;
791
+ font-size: 13px;
792
+ color: var(--dsw-alias-label-primary);
793
+ background: transparent;
794
+ border: none;
795
+ border-radius: 8px;
796
+ cursor: pointer;
797
+ text-align: left;
798
+ white-space: nowrap;
799
+ overflow: hidden;
800
+ text-overflow: ellipsis;
801
+ }
802
+
803
+ .modelMenuItem:hover {
804
+ background: var(--dsw-alias-bg-hover);
805
+ }
806
+
807
+ .modelMenuItem[data-selected] {
808
+ color: var(--dsw-alias-brand-primary);
809
+ font-weight: 600;
810
+ background: var(--dsw-alias-bg-layer-1);
735
811
  }
736
812
 
737
813
  .generateButton {
@@ -908,6 +984,42 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
908
984
  background: var(--dsw-alias-bg-base);
909
985
  }
910
986
 
987
+ /* Add-to-gallery pill (top-left, mirrors .download; shows on card hover). */
988
+ .galleryAdd {
989
+ position: absolute;
990
+ top: 8px;
991
+ left: 8px;
992
+ display: inline-flex;
993
+ align-items: center;
994
+ gap: 5px;
995
+ padding: 2px 10px;
996
+ font: inherit;
997
+ font-size: 12px;
998
+ line-height: 20px;
999
+ font-weight: 500;
1000
+ color: var(--dsw-alias-label-primary);
1001
+ background: var(--dsw-alias-bg-mask-1);
1002
+ border: 1px solid var(--dsw-alias-border-l2);
1003
+ border-radius: 999px;
1004
+ cursor: pointer;
1005
+ opacity: 0;
1006
+ transition: opacity 0.12s;
1007
+ backdrop-filter: blur(4px);
1008
+ }
1009
+
1010
+ .imageCard:hover .galleryAdd {
1011
+ opacity: 1;
1012
+ }
1013
+
1014
+ .galleryAdd:hover {
1015
+ background: var(--dsw-alias-bg-base);
1016
+ }
1017
+
1018
+ .galleryAdd:disabled {
1019
+ opacity: 0.4;
1020
+ cursor: default;
1021
+ }
1022
+
911
1023
  /* Hover hint for the click-to-zoom behavior. */
912
1024
  .zoomHint {
913
1025
  position: absolute;
@@ -1211,9 +1323,99 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1211
1323
  to { transform: rotate(360deg); }
1212
1324
  }
1213
1325
 
1326
+ /* Transient "added to gallery" toast, centered above the canvas bottom. */
1327
+ .galleryToast {
1328
+ position: absolute;
1329
+ left: 50%;
1330
+ bottom: 24px;
1331
+ transform: translateX(-50%);
1332
+ z-index: 30;
1333
+ display: inline-flex;
1334
+ align-items: center;
1335
+ gap: 7px;
1336
+ padding: 6px 16px;
1337
+ font-size: 13px;
1338
+ font-weight: 500;
1339
+ color: var(--dsw-alias-label-primary);
1340
+ background: var(--dsw-alias-bg-mask-1);
1341
+ border: 1px solid var(--dsw-alias-border-l2);
1342
+ border-radius: 999px;
1343
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
1344
+ backdrop-filter: blur(6px);
1345
+ pointer-events: none;
1346
+ animation: dshImageGenToastIn 160ms ease-out;
1347
+ }
1348
+
1349
+ @keyframes dshImageGenToastIn {
1350
+ from { opacity: 0; transform: translate(-50%, 6px); }
1351
+ to { opacity: 1; transform: translate(-50%, 0); }
1352
+ }
1353
+
1214
1354
  @media (prefers-reduced-motion: reduce) {
1215
1355
  .download, .spinner, .bigSpinner {
1216
1356
  transition: none;
1217
1357
  animation-duration: 1.5s;
1218
1358
  }
1219
1359
  }
1360
+
1361
+ /* --- gallery workspace -------------------------------------------------------- */
1362
+ .config[data-gallery='true'] .configScroll > *:not(:first-child),
1363
+ .config[data-gallery='true'] .footer,
1364
+ .canvas[data-gallery='true'] > .canvasState,
1365
+ .canvas[data-gallery='true'] > .canvasError,
1366
+ .canvas[data-gallery='true'] > .canvasBody,
1367
+ .studio:has(.config[data-gallery='true']) > .history { display: none; }
1368
+ .config[data-gallery='true'] .configScroll { display: flex; flex: none; overflow: visible; order: 1; }
1369
+ .config[data-gallery='true'] .galleryFilters { order: 2; flex: 1; min-height: 0; }
1370
+ .galleryFilters { padding: 18px 14px; overflow-y: auto; overflow-x: hidden; }
1371
+ .galleryFilterHeading { margin: 0 4px 10px; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 600; }
1372
+ .galleryFilter { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 34px; padding: 0 10px; border: 0; border-radius: 9px; color: var(--dsw-alias-label-secondary); background: transparent; cursor: pointer; text-align: left; }
1373
+ .galleryFilter:hover, .galleryFilter[data-active] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }
1374
+ .galleryFilterCount { min-width: 20px; padding: 1px 6px; border-radius: 999px; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-bg-layer-2); font-size: 11px; text-align: center; }
1375
+ .galleryFilterDivider { height: 1px; margin: 18px 4px; background: var(--dsw-alias-border-l1); }
1376
+ .galleryRatioList { display: flex; flex-wrap: wrap; gap: 6px; }
1377
+ .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
+ .galleryRatio[data-active] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 13%, transparent); }
1379
+ .galleryFilterNote { margin: 20px 4px 0; color: var(--dsw-alias-label-quaternary); font-size: 11px; line-height: 1.5; }
1380
+ .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
+ .galleryToolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex: none; min-width: 0; margin-bottom: 18px; }
1382
+ .galleryHeading { display: inline; margin: 0; color: var(--dsw-alias-label-primary); font-size: 20px; font-weight: 700; }
1383
+ .galleryCount { margin-left: 8px; color: var(--dsw-alias-label-tertiary); font-size: 13px; }
1384
+ .galleryToolbarActions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
1385
+ .galleryViewToggle { display: flex; padding: 3px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); }
1386
+ .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
+ .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
+ .gallerySort { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); }
1389
+ .galleryClear { border: 1px solid var(--dsw-alias-border-l1); }
1390
+ .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
+ .galleryMasonry::-webkit-scrollbar { width: 8px; }
1392
+ .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%); }
1394
+ .galleryImageButton { position: relative; display: block; width: 100%; padding: 0; border: 0; background: var(--dsw-alias-bg-base); cursor: zoom-in; }
1395
+ .galleryImage { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
1396
+ .galleryMasonry[data-view='masonry'] .galleryCard:nth-child(3n + 1) .galleryImage { aspect-ratio: 4 / 5; }
1397
+ .galleryMasonry[data-view='masonry'] .galleryCard:nth-child(3n + 2) .galleryImage { aspect-ratio: 4 / 3; }
1398
+ .galleryMasonry[data-view='masonry'] .galleryCard:nth-child(3n) .galleryImage { aspect-ratio: 3 / 4; }
1399
+ .galleryBadge { position: absolute; top: 10px; left: 10px; padding: 4px 9px; border-radius: 999px; color: #fff; background: rgb(18 23 36 / 76%); font-size: 11px; backdrop-filter: blur(4px); }
1400
+ .galleryCardFooter { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 10px 12px; }
1401
+ .galleryAvatar { display: grid; flex: none; width: 27px; height: 27px; place-items: center; border-radius: 50%; color: #fff; background: var(--dsw-alias-brand-primary); font-size: 12px; font-weight: 700; }
1402
+ .galleryCardInfo { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
1403
+ .galleryCardInfo strong, .galleryCardInfo small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1404
+ .galleryCardInfo strong { color: var(--dsw-alias-label-primary); font-size: 12px; }
1405
+ .galleryCardInfo small { color: var(--dsw-alias-label-tertiary); font-size: 10px; }
1406
+ .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
+ .galleryRemove:hover { color: var(--dsw-alias-state-error); background: var(--dsw-alias-bg-layer-2); }
1408
+
1409
+ @media (max-width: 1100px) {
1410
+ .galleryMasonry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1411
+ }
1412
+
1413
+ @media (max-width: 760px) {
1414
+ .studio { flex-direction: column; overflow: auto; }
1415
+ .config { width: auto; max-width: none; height: auto; min-height: 0; }
1416
+ .config[data-gallery='true'] { flex: none; }
1417
+ .canvas { min-height: 560px; }
1418
+ .galleryToolbar { align-items: flex-start; flex-direction: column; }
1419
+ .galleryToolbarActions { width: 100%; flex-wrap: wrap; }
1420
+ .galleryMasonry { grid-template-columns: 1fr; }
1421
+ }
package/src/engine.ts CHANGED
@@ -42,6 +42,33 @@ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
42
42
  /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
43
43
  const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
44
44
 
45
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
46
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
47
+ * and exposes its own aspect-ratio / response-format knobs instead of the
48
+ * OpenAI size/quality/detail passthrough. */
49
+ function isGrokImagine(model: string): boolean {
50
+ return /^grok-imagine(?:-|$)/.test(model)
51
+ }
52
+
53
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
54
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
55
+ const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
56
+ '1:1': '1024x1024',
57
+ '3:4': '1024x1536',
58
+ '4:3': '1536x1024',
59
+ '9:16': '1024x1792',
60
+ '2:3': '1024x1536',
61
+ '3:2': '1536x1024',
62
+ '16:9': '1792x1024',
63
+ '21:9': '1792x1024',
64
+ }
65
+
66
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
67
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
68
+ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
69
+ '21:9': '20:9',
70
+ }
71
+
45
72
  /** Content-type extension hints for URL-fetched images. */
46
73
  function mimeOfExtension(path: string): string | undefined {
47
74
  const match = /\.([a-z0-9]+)$/i.exec(path)
@@ -87,17 +114,44 @@ function effectiveParams(request: GenerateRequest): {
87
114
  size?: string
88
115
  quality?: string
89
116
  detail?: string
117
+ aspect_ratio?: string
118
+ resolution?: string
119
+ response_format?: string
90
120
  } {
91
121
  const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
92
122
  // dall-e-3 has no quality/detail knobs and only produces one image.
93
123
  if (model === 'dall-e-3') {
94
- const size = DALLE3_SIZES.has(request.size) ? request.size : '1024x1024'
124
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size]
125
+ const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
95
126
  return { model, size }
96
127
  }
128
+ // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
129
+ // the documented 20:9), the clarity tiers become the resolution parameter
130
+ // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
131
+ // output keeps the temporary signed result URLs from expiring before the
132
+ // host downloads them.
133
+ if (isGrokImagine(model)) {
134
+ return {
135
+ model,
136
+ ...request.size !== '' && request.size !== 'auto'
137
+ ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
138
+ : {},
139
+ ...request.quality !== '' && request.quality !== 'auto'
140
+ ? { resolution: request.quality === '4k' ? '2k' : request.quality }
141
+ : {},
142
+ response_format: 'b64_json',
143
+ }
144
+ }
145
+ // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
146
+ // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
97
147
  return {
98
148
  model,
99
- ...request.size !== '' && request.size !== 'auto' ? { size: request.size } : {},
100
- ...request.quality !== '' && request.quality !== 'auto' ? { quality: request.quality } : {},
149
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
150
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
151
+ : {},
152
+ ...request.quality === '1k' ? { quality: 'low' } : {},
153
+ ...request.quality === '2k' ? { quality: 'medium' } : {},
154
+ ...request.quality === '4k' ? { quality: 'high' } : {},
101
155
  ...request.detail !== '' ? { detail: request.detail } : {},
102
156
  }
103
157
  }
@@ -178,14 +232,27 @@ async function requestOneImage(
178
232
  if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
179
233
  throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
180
234
  }
181
- const form = new FormData()
182
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
183
- form.append('prompt', request.prompt)
184
- form.append('model', params.model)
185
- if (params.size !== undefined) form.append('size', params.size)
186
- if (params.quality !== undefined) form.append('quality', params.quality)
187
- if (params.detail !== undefined) form.append('detail', params.detail)
188
- body = form
235
+ // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
236
+ // URI is accepted) instead of OpenAI's multipart form-data upload.
237
+ if (isGrokImagine(params.model)) {
238
+ headers['content-type'] = 'application/json'
239
+ body = JSON.stringify({
240
+ model: params.model,
241
+ prompt: request.prompt,
242
+ image: { url: request.image, type: 'image_url' },
243
+ ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
244
+ response_format: 'b64_json',
245
+ })
246
+ } else {
247
+ const form = new FormData()
248
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
249
+ form.append('prompt', request.prompt)
250
+ form.append('model', params.model)
251
+ if (params.size !== undefined) form.append('size', params.size)
252
+ if (params.quality !== undefined) form.append('quality', params.quality)
253
+ if (params.detail !== undefined) form.append('detail', params.detail)
254
+ body = form
255
+ }
189
256
  } else {
190
257
  headers['content-type'] = 'application/json'
191
258
  body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)