dsh-plugin-image-workbench 1.1.5 → 1.1.7

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.
Files changed (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,554 +1,554 @@
1
- "use client";
2
-
3
- import { memo, useEffect, useRef, useState } from "react";
4
- import { Clock3, Download, EyeOff, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
5
-
6
- import { Button } from "@/components/ui/button";
7
- import { cn } from "@/lib/utils";
8
- import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
9
-
10
- export type ImageLightboxItem = {
11
- id: string;
12
- src: string;
13
- sizeLabel?: string;
14
- dimensions?: string;
15
- };
16
-
17
- type ImageResultsProps = {
18
- selectedConversation: ImageConversation | null;
19
- onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
20
- onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
21
- onDeletePrompt: (conversationId: string, turnId: string) => void;
22
- onDeleteResults: (conversationId: string, turnId: string) => void;
23
- onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
24
- onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
25
- onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
26
- onTimeoutRetryContinue: (taskId: string) => void | Promise<void>;
27
- onDismissErrors: (conversationId: string, turnId: string) => void | Promise<void>;
28
- formatConversationTime: (value: string) => string;
29
- };
30
-
31
- // Blob URL 缓存:避免 base64 超长字符串在 DOM 中,改用短小的 blob: URL
32
- const b64BlobUrlCache = new Map<string, string>();
33
-
34
- function getStoredImageSrc(image: StoredImage) {
35
- if (image.b64_json) {
36
- let url = b64BlobUrlCache.get(image.b64_json);
37
- if (!url) {
38
- const binary = atob(image.b64_json);
39
- const bytes = new Uint8Array(binary.length);
40
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
41
- const blob = new Blob([bytes], { type: "image/png" });
42
- url = URL.createObjectURL(blob);
43
- b64BlobUrlCache.set(image.b64_json, url);
44
- }
45
- return url;
46
- }
47
- return image.url || "";
48
- }
49
-
50
- async function downloadStoredImage(image: StoredImage, index: number) {
51
- let blob: Blob | null = null;
52
- try {
53
- if (image.b64_json) {
54
- const binary = atob(image.b64_json);
55
- const bytes = new Uint8Array(binary.length);
56
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
57
- blob = new Blob([bytes], { type: "image/png" });
58
- } else if (image.url) {
59
- // 确保 URL 是绝对路径
60
- const url = image.url.startsWith("http") ? image.url : `${window.location.origin}${image.url}`;
61
- const res = await fetch(url);
62
- if (!res.ok) {
63
- throw new Error(`HTTP ${res.status}: ${res.statusText}`);
64
- }
65
- blob = await res.blob();
66
- } else {
67
- return;
68
- }
69
- } catch (err) {
70
- console.error("Failed to download image:", err);
71
- // 如果 fetch 失败,尝试直接在新窗口打开
72
- if (image.url) {
73
- window.open(image.url, "_blank");
74
- }
75
- return;
76
- }
77
- const url = URL.createObjectURL(blob);
78
- const a = document.createElement("a");
79
- a.href = url;
80
- a.download = `image-${index + 1}.png`;
81
- document.body.appendChild(a);
82
- a.click();
83
- document.body.removeChild(a);
84
- URL.revokeObjectURL(url);
85
- }
86
-
87
- export function ImageResults({
88
- selectedConversation,
89
- onOpenLightbox,
90
- onContinueEdit,
91
- onDeletePrompt,
92
- onDeleteResults,
93
- onReuseTurnConfig,
94
- onRegenerateTurn,
95
- onRetryImage,
96
- onTimeoutRetryContinue,
97
- onDismissErrors,
98
- formatConversationTime,
99
- }: ImageResultsProps) {
100
- const imageDimensionsRef = useRef<Record<string, string>>({});
101
- const [currentTime, setCurrentTime] = useState(Date.now());
102
-
103
- // 仅在存在 loading 图片时启动定时器,避免空闲时无谓重渲染
104
- const hasLoadingImages = selectedConversation?.turns.some(
105
- (turn) => !turn.resultsDeleted && turn.images.some((image) => image.status === "loading"),
106
- );
107
- useEffect(() => {
108
- if (!hasLoadingImages) return;
109
- const timer = setInterval(() => {
110
- setCurrentTime(Date.now());
111
- }, 500);
112
- return () => clearInterval(timer);
113
- }, [hasLoadingImages]);
114
-
115
- const updateImageDimensions = (id: string, width: number, height: number) => {
116
- const dimensions = formatImageDimensions(width, height);
117
- // 使用 ref 存储,不触发 React 重渲染,消除级联重渲染
118
- if (imageDimensionsRef.current[id] !== dimensions) {
119
- imageDimensionsRef.current[id] = dimensions;
120
- }
121
- };
122
-
123
- if (!selectedConversation) {
124
- return (
125
- <div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
126
- <div className="w-full max-w-4xl">
127
- <h1
128
- className="text-2xl font-semibold tracking-tight text-stone-950 sm:text-3xl md:text-5xl"
129
- style={{
130
- fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
131
- }}
132
- >
133
- Turn ideas into images
134
- </h1>
135
- <p
136
- className="mx-auto mt-3 max-w-[280px] text-sm italic tracking-[0.01em] text-stone-500 sm:mt-4 sm:max-w-none sm:text-[15px]"
137
- style={{
138
- fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
139
- }}
140
- >
141
- 在同一窗口里保留本地历史与任务状态,并从已有结果图继续发起新的无状态编辑。
142
- </p>
143
- </div>
144
- </div>
145
- );
146
- }
147
-
148
- return (
149
- <div className="mx-auto flex w-full max-w-[980px] flex-col gap-5 sm:gap-8">
150
- {selectedConversation.turns.map((turn, turnIndex) => {
151
- const referenceLightboxImages = turn.referenceImages.map((image, index) => ({
152
- id: `${turn.id}-reference-${index}`,
153
- src: image.dataUrl,
154
- }));
155
- const successfulTurnImages = turn.images.flatMap((image) => {
156
- const src = image.status === "success" ? getStoredImageSrc(image) : "";
157
- return src
158
- ? [
159
- {
160
- id: image.id,
161
- src,
162
- sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
163
- dimensions: imageDimensionsRef.current[image.id],
164
- },
165
- ]
166
- : [];
167
- });
168
-
169
- return (
170
- <div key={turn.id} className="flex flex-col gap-3 sm:gap-4">
171
- {!turn.promptDeleted ? (
172
- <div className="flex justify-end">
173
- <div className="max-w-[90%] px-1 py-1 text-[14px] leading-6 text-stone-900 sm:max-w-[82%] sm:text-[15px] sm:leading-7">
174
- <div className="mb-1.5 flex flex-wrap justify-end gap-2 text-[11px] text-stone-400 sm:mb-2">
175
- <span>第 {turnIndex + 1} 轮</span>
176
- <span>
177
- {turn.mode === "edit" ? "编辑图" : "文生图"}
178
- </span>
179
- <span>{getTurnStatusLabel(turn.status)}</span>
180
- <span>{formatConversationTime(turn.createdAt)}</span>
181
- </div>
182
- <div className="text-right">{turn.prompt}</div>
183
- <div className="mt-2 flex flex-wrap justify-end gap-1.5">
184
- <button
185
- type="button"
186
- onClick={() => void onReuseTurnConfig(selectedConversation.id, turn.id)}
187
- className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:bg-stone-200 hover:text-stone-900"
188
- >
189
- 复用配置
190
- </button>
191
- <button
192
- type="button"
193
- onClick={() => onDeletePrompt(selectedConversation.id, turn.id)}
194
- className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
195
- aria-label="删除提示词记录"
196
- >
197
- <Trash2 className="size-3" />
198
- </button>
199
- </div>
200
- </div>
201
- </div>
202
- ) : null}
203
-
204
- {!turn.resultsDeleted ? (
205
- <div className="flex justify-start">
206
- <div className="w-full p-1">
207
- {turn.referenceImages.length > 0 ? (
208
- <div className="mb-4 flex flex-col items-end">
209
- <div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
210
- <div className="flex flex-wrap justify-end gap-3">
211
- {turn.referenceImages.map((image, index) => (
212
- <div key={`${turn.id}-${image.name}-${index}`} className="flex flex-col items-end gap-2">
213
- <button
214
- type="button"
215
- onClick={() => onOpenLightbox(referenceLightboxImages, index)}
216
- className="group relative h-24 w-24 overflow-hidden border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
217
- aria-label={`预览参考图 ${image.name || index + 1}`}
218
- >
219
- <img
220
- src={image.dataUrl}
221
- alt={image.name || `参考图 ${index + 1}`}
222
- className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
223
- />
224
- </button>
225
- <Button
226
- variant="outline"
227
- size="sm"
228
- className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
229
- onClick={() => onContinueEdit(selectedConversation.id, image)}
230
- >
231
- <Sparkles className="size-4" />
232
- 加入编辑
233
- </Button>
234
- </div>
235
- ))}
236
- </div>
237
- </div>
238
- ) : null}
239
-
240
- <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 sm:mb-4 sm:gap-2 sm:text-xs">
241
- <span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
242
- <span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
243
- {turn.status === "queued" ? (
244
- <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
245
- ) : null}
246
- </div>
247
-
248
- <div className="grid grid-cols-3 gap-2 sm:block sm:columns-2 sm:gap-4 sm:space-y-4 xl:columns-3">
249
- {turn.images.map((image, index) => {
250
- const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
251
- if (image.status === "success" && imageSrc) {
252
- const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
253
- const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
254
- const dimensions = imageDimensionsRef.current[image.id];
255
- const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
256
-
257
- return (
258
- <div
259
- key={image.id}
260
- className="break-inside-avoid"
261
- >
262
- <LazyImage
263
- src={imageSrc}
264
- alt={`Generated result ${index + 1}`}
265
- className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
266
- onLoad={(event) => {
267
- updateImageDimensions(
268
- image.id,
269
- event.currentTarget.naturalWidth,
270
- event.currentTarget.naturalHeight,
271
- );
272
- }}
273
- onOpen={() => onOpenLightbox(successfulTurnImages, currentIndex)}
274
- />
275
- <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
276
- <div className="min-w-0 text-stone-500">
277
- <span>结果 {index + 1}</span>
278
- {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
279
- {imageMeta ? <span className="block text-stone-400">{imageMeta}</span> : null}
280
- </div>
281
- <div className="flex items-center gap-1.5">
282
- <Button
283
- variant="outline"
284
- size="sm"
285
- className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
286
- onClick={() => onContinueEdit(selectedConversation.id, image)}
287
- aria-label="加入编辑"
288
- >
289
- <Sparkles className="size-3 sm:size-4" />
290
- <span className="hidden sm:inline">加入编辑</span>
291
- </Button>
292
- <Button
293
- variant="outline"
294
- size="sm"
295
- className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
296
- onClick={() => void downloadStoredImage(image, index)}
297
- aria-label="下载"
298
- >
299
- <Download className="size-3 sm:size-4" />
300
- <span className="hidden sm:inline">下载</span>
301
- </Button>
302
- </div>
303
- </div>
304
- </div>
305
- );
306
- }
307
-
308
- if (image.status === "error") {
309
- const isTimeoutError = image.error?.includes("超时") && image.taskId;
310
- return (
311
- <div key={image.id} className="break-inside-avoid">
312
- <div
313
- className={cn(
314
- "overflow-hidden rounded-xl border border-rose-200 bg-rose-50",
315
- "aspect-square",
316
- turn.ratio === "1:1" && "sm:aspect-square",
317
- turn.ratio === "16:9" && "sm:aspect-video",
318
- turn.ratio === "9:16" && "sm:aspect-[9/16]",
319
- turn.ratio === "4:3" && "sm:aspect-[4/3]",
320
- turn.ratio === "3:4" && "sm:aspect-[3/4]",
321
- )}
322
- >
323
- <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
324
- <p className="font-medium">图片 {index + 1}/{turn.images.length}</p>
325
- <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
326
- <div className="flex items-center gap-2">
327
- {isTimeoutError && (
328
- <button
329
- type="button"
330
- onClick={() => void onTimeoutRetryContinue(image.taskId!)}
331
- className="rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-medium text-emerald-600 shadow-sm transition hover:bg-emerald-200 sm:px-3 sm:text-xs"
332
- >
333
- 继续等待
334
- </button>
335
- )}
336
- <button
337
- type="button"
338
- onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
339
- className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
340
- >
341
- 重新生成这一张
342
- </button>
343
- </div>
344
- </div>
345
- </div>
346
- <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
347
- <div className="min-w-0 text-stone-500">
348
- <span>结果 {index + 1}</span>
349
- {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
350
- <span className="block text-transparent">-</span>
351
- </div>
352
- </div>
353
- </div>
354
- );
355
- }
356
-
357
- const imageTaskStatus = image.taskStatus || (turn.status === "queued" ? "queued" : "running");
358
- const imageStatusLabel = imageTaskStatus === "queued" ? "排队中" : getProgressLabel(image.progress);
359
- const showElapsed = imageTaskStatus === "running" && image.elapsedSecs != null;
360
- const elapsedDisplay = showElapsed
361
- ? formatElapsed(
362
- image.elapsedUpdatedAt != null
363
- ? image.elapsedSecs! + (currentTime - image.elapsedUpdatedAt!) / 1000
364
- : image.elapsedSecs!,
365
- )
366
- : null;
367
- return (
368
- <div key={image.id} className="break-inside-avoid">
369
- <div
370
- className={cn(
371
- "overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 relative",
372
- turn.ratio === "1:1" && "aspect-square",
373
- turn.ratio === "16:9" && "aspect-video",
374
- turn.ratio === "9:16" && "aspect-[9/16]",
375
- turn.ratio === "4:3" && "aspect-[4/3]",
376
- turn.ratio === "3:4" && "aspect-[3/4]",
377
- )}
378
- >
379
- <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
380
- <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
381
- {imageTaskStatus === "queued" ? (
382
- <Clock3 className="size-4 sm:size-5" />
383
- ) : (
384
- <LoaderCircle className="size-4 animate-spin sm:size-5" />
385
- )}
386
- </div>
387
- <p className="text-[11px] font-medium leading-4 sm:text-sm">
388
- 图片 {index + 1}/{turn.images.length}
389
- </p>
390
- <p className="text-[10px] leading-4 text-stone-400 sm:text-xs">
391
- {imageStatusLabel}
392
- </p>
393
- </div>
394
- </div>
395
- {elapsedDisplay != null && (
396
- <div className="px-0.5 py-1 text-[10px] text-stone-400 sm:px-3 sm:py-3 sm:text-xs">{elapsedDisplay}</div>
397
- )}
398
- </div>
399
- );
400
- })}
401
- </div>
402
-
403
- {turn.status === "error" && turn.error ? (
404
- <div className="mt-4 flex items-center justify-between border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
405
- <span>{turn.error}</span>
406
- <button
407
- type="button"
408
- onClick={() => void onDismissErrors(selectedConversation.id, turn.id)}
409
- className="ml-3 inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:bg-amber-200 hover:text-amber-900"
410
- >
411
- <EyeOff className="size-3" />
412
- 忽略错误
413
- </button>
414
- </div>
415
- ) : null}
416
-
417
- <div className="mt-3 flex items-center gap-1.5 text-[11px] sm:mt-4">
418
- <button
419
- type="button"
420
- onClick={() => void onRegenerateTurn(selectedConversation.id, turn.id)}
421
- className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-medium text-stone-500 transition hover:bg-stone-200 hover:text-stone-900"
422
- >
423
- <RotateCcw className="size-3" />
424
- 全部重新生成
425
- </button>
426
- <button
427
- type="button"
428
- onClick={() => onDeleteResults(selectedConversation.id, turn.id)}
429
- className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
430
- aria-label="删除生成结果"
431
- >
432
- <Trash2 className="size-3" />
433
- </button>
434
- </div>
435
- </div>
436
- </div>
437
- ) : null}
438
- </div>
439
- );
440
- })}
441
- </div>
442
- );
443
- }
444
-
445
- function getTurnStatusLabel(status: ImageTurnStatus) {
446
- if (status === "queued") {
447
- return "排队中";
448
- }
449
- if (status === "generating") {
450
- return "处理中";
451
- }
452
- if (status === "success") {
453
- return "已完成";
454
- }
455
- return "失败";
456
- }
457
-
458
- const PROGRESS_LABELS: Record<string, string> = {
459
- getting_account: "确认可用账号",
460
- uploading: "上传图片",
461
- bootstrapping: "预热首页",
462
- getting_token: "获取 token",
463
- preparing_conversation: "准备会话",
464
- starting_generation: "启动生成",
465
- generating: "生成中",
466
- receiving_image: "接收图片中",
467
- };
468
-
469
- function getProgressLabel(progress?: string) {
470
- if (!progress) {
471
- return "生成中";
472
- }
473
- return PROGRESS_LABELS[progress] || "生成中";
474
- }
475
-
476
- function formatElapsed(seconds: number): string {
477
- return `${seconds.toFixed(1)}s`;
478
- }
479
-
480
- function formatDuration(ms: number): string {
481
- return `${(ms / 1000).toFixed(1)}s`;
482
- }
483
-
484
- const base64SizeCache = new Map<string, string>();
485
- function formatBase64ImageSize(base64: string) {
486
- let cached = base64SizeCache.get(base64);
487
- if (cached !== undefined) return cached;
488
- const normalized = base64.replace(/\s/g, "");
489
- const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
490
- const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
491
-
492
- if (bytes >= 1024 * 1024) {
493
- cached = `${(bytes / 1024 / 1024).toFixed(2)} MB`;
494
- } else if (bytes >= 1024) {
495
- cached = `${(bytes / 1024).toFixed(1)} KB`;
496
- } else {
497
- cached = `${bytes} B`;
498
- }
499
- base64SizeCache.set(base64, cached);
500
- return cached;
501
- }
502
-
503
- function formatImageDimensions(width: number, height: number) {
504
- return `${width} x ${height}`;
505
- }
506
-
507
- const LazyImage = memo(function LazyImage({ src, alt, className, onLoad, onOpen }: {
508
- src: string;
509
- alt: string;
510
- className: string;
511
- onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
512
- onOpen?: () => void;
513
- }) {
514
- const [isVisible, setIsVisible] = useState(false);
515
- const imgRef = useRef<HTMLDivElement>(null);
516
-
517
- useEffect(() => {
518
- const element = imgRef.current;
519
- if (!element) return;
520
-
521
- const observer = new IntersectionObserver(
522
- ([entry]) => {
523
- if (entry.isIntersecting) {
524
- setIsVisible(true);
525
- observer.disconnect();
526
- }
527
- },
528
- { rootMargin: "400px" },
529
- );
530
- observer.observe(element);
531
- return () => observer.disconnect();
532
- }, []);
533
-
534
- return (
535
- <div ref={imgRef} className="relative">
536
- {isVisible ? (
537
- <button
538
- type="button"
539
- onClick={onOpen}
540
- className={className}
541
- >
542
- <img
543
- src={src}
544
- alt={alt}
545
- className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
546
- onLoad={onLoad}
547
- />
548
- </button>
549
- ) : (
550
- <div className={`animate-pulse rounded-xl bg-stone-100 min-h-[200px] sm:min-h-[280px] ${className}`} />
551
- )}
552
- </div>
553
- );
554
- });
1
+ "use client";
2
+
3
+ import { memo, useEffect, useRef, useState } from "react";
4
+ import { Clock3, Download, EyeOff, LoaderCircle, RotateCcw, Sparkles, Trash2 } from "lucide-react";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+ import { cn } from "@/lib/utils";
8
+ import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
9
+
10
+ export type ImageLightboxItem = {
11
+ id: string;
12
+ src: string;
13
+ sizeLabel?: string;
14
+ dimensions?: string;
15
+ };
16
+
17
+ type ImageResultsProps = {
18
+ selectedConversation: ImageConversation | null;
19
+ onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
20
+ onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
21
+ onDeletePrompt: (conversationId: string, turnId: string) => void;
22
+ onDeleteResults: (conversationId: string, turnId: string) => void;
23
+ onReuseTurnConfig: (conversationId: string, turnId: string) => void | Promise<void>;
24
+ onRegenerateTurn: (conversationId: string, turnId: string) => void | Promise<void>;
25
+ onRetryImage: (conversationId: string, turnId: string, imageId: string) => void | Promise<void>;
26
+ onTimeoutRetryContinue: (taskId: string) => void | Promise<void>;
27
+ onDismissErrors: (conversationId: string, turnId: string) => void | Promise<void>;
28
+ formatConversationTime: (value: string) => string;
29
+ };
30
+
31
+ // Blob URL 缓存:避免 base64 超长字符串在 DOM 中,改用短小的 blob: URL
32
+ const b64BlobUrlCache = new Map<string, string>();
33
+
34
+ function getStoredImageSrc(image: StoredImage) {
35
+ if (image.b64_json) {
36
+ let url = b64BlobUrlCache.get(image.b64_json);
37
+ if (!url) {
38
+ const binary = atob(image.b64_json);
39
+ const bytes = new Uint8Array(binary.length);
40
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
41
+ const blob = new Blob([bytes], { type: "image/png" });
42
+ url = URL.createObjectURL(blob);
43
+ b64BlobUrlCache.set(image.b64_json, url);
44
+ }
45
+ return url;
46
+ }
47
+ return image.url || "";
48
+ }
49
+
50
+ async function downloadStoredImage(image: StoredImage, index: number) {
51
+ let blob: Blob | null = null;
52
+ try {
53
+ if (image.b64_json) {
54
+ const binary = atob(image.b64_json);
55
+ const bytes = new Uint8Array(binary.length);
56
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
57
+ blob = new Blob([bytes], { type: "image/png" });
58
+ } else if (image.url) {
59
+ // 确保 URL 是绝对路径
60
+ const url = image.url.startsWith("http") ? image.url : `${window.location.origin}${image.url}`;
61
+ const res = await fetch(url);
62
+ if (!res.ok) {
63
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
64
+ }
65
+ blob = await res.blob();
66
+ } else {
67
+ return;
68
+ }
69
+ } catch (err) {
70
+ console.error("Failed to download image:", err);
71
+ // 如果 fetch 失败,尝试直接在新窗口打开
72
+ if (image.url) {
73
+ window.open(image.url, "_blank");
74
+ }
75
+ return;
76
+ }
77
+ const url = URL.createObjectURL(blob);
78
+ const a = document.createElement("a");
79
+ a.href = url;
80
+ a.download = `image-${index + 1}.png`;
81
+ document.body.appendChild(a);
82
+ a.click();
83
+ document.body.removeChild(a);
84
+ URL.revokeObjectURL(url);
85
+ }
86
+
87
+ export function ImageResults({
88
+ selectedConversation,
89
+ onOpenLightbox,
90
+ onContinueEdit,
91
+ onDeletePrompt,
92
+ onDeleteResults,
93
+ onReuseTurnConfig,
94
+ onRegenerateTurn,
95
+ onRetryImage,
96
+ onTimeoutRetryContinue,
97
+ onDismissErrors,
98
+ formatConversationTime,
99
+ }: ImageResultsProps) {
100
+ const imageDimensionsRef = useRef<Record<string, string>>({});
101
+ const [currentTime, setCurrentTime] = useState(Date.now());
102
+
103
+ // 仅在存在 loading 图片时启动定时器,避免空闲时无谓重渲染
104
+ const hasLoadingImages = selectedConversation?.turns.some(
105
+ (turn) => !turn.resultsDeleted && turn.images.some((image) => image.status === "loading"),
106
+ );
107
+ useEffect(() => {
108
+ if (!hasLoadingImages) return;
109
+ const timer = setInterval(() => {
110
+ setCurrentTime(Date.now());
111
+ }, 500);
112
+ return () => clearInterval(timer);
113
+ }, [hasLoadingImages]);
114
+
115
+ const updateImageDimensions = (id: string, width: number, height: number) => {
116
+ const dimensions = formatImageDimensions(width, height);
117
+ // 使用 ref 存储,不触发 React 重渲染,消除级联重渲染
118
+ if (imageDimensionsRef.current[id] !== dimensions) {
119
+ imageDimensionsRef.current[id] = dimensions;
120
+ }
121
+ };
122
+
123
+ if (!selectedConversation) {
124
+ return (
125
+ <div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
126
+ <div className="w-full max-w-4xl">
127
+ <h1
128
+ className="text-2xl font-semibold tracking-tight text-stone-950 sm:text-3xl md:text-5xl"
129
+ style={{
130
+ fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
131
+ }}
132
+ >
133
+ Turn ideas into images
134
+ </h1>
135
+ <p
136
+ className="mx-auto mt-3 max-w-[280px] text-sm italic tracking-[0.01em] text-stone-500 sm:mt-4 sm:max-w-none sm:text-[15px]"
137
+ style={{
138
+ fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
139
+ }}
140
+ >
141
+ 在同一窗口里保留本地历史与任务状态,并从已有结果图继续发起新的无状态编辑。
142
+ </p>
143
+ </div>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ return (
149
+ <div className="mx-auto flex w-full max-w-[980px] flex-col gap-5 sm:gap-8">
150
+ {selectedConversation.turns.map((turn, turnIndex) => {
151
+ const referenceLightboxImages = turn.referenceImages.map((image, index) => ({
152
+ id: `${turn.id}-reference-${index}`,
153
+ src: image.dataUrl,
154
+ }));
155
+ const successfulTurnImages = turn.images.flatMap((image) => {
156
+ const src = image.status === "success" ? getStoredImageSrc(image) : "";
157
+ return src
158
+ ? [
159
+ {
160
+ id: image.id,
161
+ src,
162
+ sizeLabel: image.b64_json ? formatBase64ImageSize(image.b64_json) : undefined,
163
+ dimensions: imageDimensionsRef.current[image.id],
164
+ },
165
+ ]
166
+ : [];
167
+ });
168
+
169
+ return (
170
+ <div key={turn.id} className="flex flex-col gap-3 sm:gap-4">
171
+ {!turn.promptDeleted ? (
172
+ <div className="flex justify-end">
173
+ <div className="max-w-[90%] px-1 py-1 text-[14px] leading-6 text-stone-900 sm:max-w-[82%] sm:text-[15px] sm:leading-7">
174
+ <div className="mb-1.5 flex flex-wrap justify-end gap-2 text-[11px] text-stone-400 sm:mb-2">
175
+ <span>第 {turnIndex + 1} 轮</span>
176
+ <span>
177
+ {turn.mode === "edit" ? "编辑图" : "文生图"}
178
+ </span>
179
+ <span>{getTurnStatusLabel(turn.status)}</span>
180
+ <span>{formatConversationTime(turn.createdAt)}</span>
181
+ </div>
182
+ <div className="text-right">{turn.prompt}</div>
183
+ <div className="mt-2 flex flex-wrap justify-end gap-1.5">
184
+ <button
185
+ type="button"
186
+ onClick={() => void onReuseTurnConfig(selectedConversation.id, turn.id)}
187
+ className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 text-[11px] font-medium text-stone-600 transition hover:bg-stone-200 hover:text-stone-900"
188
+ >
189
+ 复用配置
190
+ </button>
191
+ <button
192
+ type="button"
193
+ onClick={() => onDeletePrompt(selectedConversation.id, turn.id)}
194
+ className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
195
+ aria-label="删除提示词记录"
196
+ >
197
+ <Trash2 className="size-3" />
198
+ </button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ ) : null}
203
+
204
+ {!turn.resultsDeleted ? (
205
+ <div className="flex justify-start">
206
+ <div className="w-full p-1">
207
+ {turn.referenceImages.length > 0 ? (
208
+ <div className="mb-4 flex flex-col items-end">
209
+ <div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
210
+ <div className="flex flex-wrap justify-end gap-3">
211
+ {turn.referenceImages.map((image, index) => (
212
+ <div key={`${turn.id}-${image.name}-${index}`} className="flex flex-col items-end gap-2">
213
+ <button
214
+ type="button"
215
+ onClick={() => onOpenLightbox(referenceLightboxImages, index)}
216
+ className="group relative h-24 w-24 overflow-hidden border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
217
+ aria-label={`预览参考图 ${image.name || index + 1}`}
218
+ >
219
+ <img
220
+ src={image.dataUrl}
221
+ alt={image.name || `参考图 ${index + 1}`}
222
+ className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
223
+ />
224
+ </button>
225
+ <Button
226
+ variant="outline"
227
+ size="sm"
228
+ className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
229
+ onClick={() => onContinueEdit(selectedConversation.id, image)}
230
+ >
231
+ <Sparkles className="size-4" />
232
+ 加入编辑
233
+ </Button>
234
+ </div>
235
+ ))}
236
+ </div>
237
+ </div>
238
+ ) : null}
239
+
240
+ <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 sm:mb-4 sm:gap-2 sm:text-xs">
241
+ <span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
242
+ <span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
243
+ {turn.status === "queued" ? (
244
+ <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
245
+ ) : null}
246
+ </div>
247
+
248
+ <div className="grid grid-cols-3 gap-2 sm:block sm:columns-2 sm:gap-4 sm:space-y-4 xl:columns-3">
249
+ {turn.images.map((image, index) => {
250
+ const imageSrc = image.status === "success" ? getStoredImageSrc(image) : "";
251
+ if (image.status === "success" && imageSrc) {
252
+ const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
253
+ const sizeLabel = image.b64_json ? formatBase64ImageSize(image.b64_json) : "";
254
+ const dimensions = imageDimensionsRef.current[image.id];
255
+ const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
256
+
257
+ return (
258
+ <div
259
+ key={image.id}
260
+ className="break-inside-avoid"
261
+ >
262
+ <LazyImage
263
+ src={imageSrc}
264
+ alt={`Generated result ${index + 1}`}
265
+ className="group block aspect-square w-full cursor-zoom-in overflow-hidden rounded-xl sm:aspect-auto"
266
+ onLoad={(event) => {
267
+ updateImageDimensions(
268
+ image.id,
269
+ event.currentTarget.naturalWidth,
270
+ event.currentTarget.naturalHeight,
271
+ );
272
+ }}
273
+ onOpen={() => onOpenLightbox(successfulTurnImages, currentIndex)}
274
+ />
275
+ <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
276
+ <div className="min-w-0 text-stone-500">
277
+ <span>结果 {index + 1}</span>
278
+ {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
279
+ {imageMeta ? <span className="block text-stone-400">{imageMeta}</span> : null}
280
+ </div>
281
+ <div className="flex items-center gap-1.5">
282
+ <Button
283
+ variant="outline"
284
+ size="sm"
285
+ className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
286
+ onClick={() => onContinueEdit(selectedConversation.id, image)}
287
+ aria-label="加入编辑"
288
+ >
289
+ <Sparkles className="size-3 sm:size-4" />
290
+ <span className="hidden sm:inline">加入编辑</span>
291
+ </Button>
292
+ <Button
293
+ variant="outline"
294
+ size="sm"
295
+ className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
296
+ onClick={() => void downloadStoredImage(image, index)}
297
+ aria-label="下载"
298
+ >
299
+ <Download className="size-3 sm:size-4" />
300
+ <span className="hidden sm:inline">下载</span>
301
+ </Button>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ if (image.status === "error") {
309
+ const isTimeoutError = image.error?.includes("超时") && image.taskId;
310
+ return (
311
+ <div key={image.id} className="break-inside-avoid">
312
+ <div
313
+ className={cn(
314
+ "overflow-hidden rounded-xl border border-rose-200 bg-rose-50",
315
+ "aspect-square",
316
+ turn.ratio === "1:1" && "sm:aspect-square",
317
+ turn.ratio === "16:9" && "sm:aspect-video",
318
+ turn.ratio === "9:16" && "sm:aspect-[9/16]",
319
+ turn.ratio === "4:3" && "sm:aspect-[4/3]",
320
+ turn.ratio === "3:4" && "sm:aspect-[3/4]",
321
+ )}
322
+ >
323
+ <div className="flex h-full min-h-16 flex-col items-center justify-center gap-1.5 px-2 py-2 text-center text-[11px] leading-4 text-rose-600 sm:gap-3 sm:px-6 sm:py-8 sm:text-sm sm:leading-6">
324
+ <p className="font-medium">图片 {index + 1}/{turn.images.length}</p>
325
+ <span className="line-clamp-2 sm:line-clamp-none">{image.error || "生成失败"}</span>
326
+ <div className="flex items-center gap-2">
327
+ {isTimeoutError && (
328
+ <button
329
+ type="button"
330
+ onClick={() => void onTimeoutRetryContinue(image.taskId!)}
331
+ className="rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-medium text-emerald-600 shadow-sm transition hover:bg-emerald-200 sm:px-3 sm:text-xs"
332
+ >
333
+ 继续等待
334
+ </button>
335
+ )}
336
+ <button
337
+ type="button"
338
+ onClick={() => void onRetryImage(selectedConversation.id, turn.id, image.id)}
339
+ className="rounded-full bg-white px-2 py-1 text-[10px] font-medium text-rose-600 shadow-sm transition hover:bg-rose-100 sm:px-3 sm:text-xs"
340
+ >
341
+ 重新生成这一张
342
+ </button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ <div className="flex flex-col gap-1 px-0.5 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-3 sm:py-3 sm:text-xs">
347
+ <div className="min-w-0 text-stone-500">
348
+ <span>结果 {index + 1}</span>
349
+ {image.durationMs != null ? <span className="text-stone-400 sm:ml-2">{formatDuration(image.durationMs)}</span> : null}
350
+ <span className="block text-transparent">-</span>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ );
355
+ }
356
+
357
+ const imageTaskStatus = image.taskStatus || (turn.status === "queued" ? "queued" : "running");
358
+ const imageStatusLabel = imageTaskStatus === "queued" ? "排队中" : getProgressLabel(image.progress);
359
+ const showElapsed = imageTaskStatus === "running" && image.elapsedSecs != null;
360
+ const elapsedDisplay = showElapsed
361
+ ? formatElapsed(
362
+ image.elapsedUpdatedAt != null
363
+ ? image.elapsedSecs! + (currentTime - image.elapsedUpdatedAt!) / 1000
364
+ : image.elapsedSecs!,
365
+ )
366
+ : null;
367
+ return (
368
+ <div key={image.id} className="break-inside-avoid">
369
+ <div
370
+ className={cn(
371
+ "overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 relative",
372
+ turn.ratio === "1:1" && "aspect-square",
373
+ turn.ratio === "16:9" && "aspect-video",
374
+ turn.ratio === "9:16" && "aspect-[9/16]",
375
+ turn.ratio === "4:3" && "aspect-[4/3]",
376
+ turn.ratio === "3:4" && "aspect-[3/4]",
377
+ )}
378
+ >
379
+ <div className="flex h-full flex-col items-center justify-center gap-1.5 px-2 py-3 text-center text-stone-500 sm:gap-3 sm:px-6 sm:py-8">
380
+ <div className="rounded-full bg-white p-2 shadow-sm sm:p-3">
381
+ {imageTaskStatus === "queued" ? (
382
+ <Clock3 className="size-4 sm:size-5" />
383
+ ) : (
384
+ <LoaderCircle className="size-4 animate-spin sm:size-5" />
385
+ )}
386
+ </div>
387
+ <p className="text-[11px] font-medium leading-4 sm:text-sm">
388
+ 图片 {index + 1}/{turn.images.length}
389
+ </p>
390
+ <p className="text-[10px] leading-4 text-stone-400 sm:text-xs">
391
+ {imageStatusLabel}
392
+ </p>
393
+ </div>
394
+ </div>
395
+ {elapsedDisplay != null && (
396
+ <div className="px-0.5 py-1 text-[10px] text-stone-400 sm:px-3 sm:py-3 sm:text-xs">{elapsedDisplay}</div>
397
+ )}
398
+ </div>
399
+ );
400
+ })}
401
+ </div>
402
+
403
+ {turn.status === "error" && turn.error ? (
404
+ <div className="mt-4 flex items-center justify-between border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
405
+ <span>{turn.error}</span>
406
+ <button
407
+ type="button"
408
+ onClick={() => void onDismissErrors(selectedConversation.id, turn.id)}
409
+ className="ml-3 inline-flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:bg-amber-200 hover:text-amber-900"
410
+ >
411
+ <EyeOff className="size-3" />
412
+ 忽略错误
413
+ </button>
414
+ </div>
415
+ ) : null}
416
+
417
+ <div className="mt-3 flex items-center gap-1.5 text-[11px] sm:mt-4">
418
+ <button
419
+ type="button"
420
+ onClick={() => void onRegenerateTurn(selectedConversation.id, turn.id)}
421
+ className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-medium text-stone-500 transition hover:bg-stone-200 hover:text-stone-900"
422
+ >
423
+ <RotateCcw className="size-3" />
424
+ 全部重新生成
425
+ </button>
426
+ <button
427
+ type="button"
428
+ onClick={() => onDeleteResults(selectedConversation.id, turn.id)}
429
+ className="inline-flex size-6 items-center justify-center rounded-full text-stone-300 transition hover:bg-rose-50 hover:text-rose-500"
430
+ aria-label="删除生成结果"
431
+ >
432
+ <Trash2 className="size-3" />
433
+ </button>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ ) : null}
438
+ </div>
439
+ );
440
+ })}
441
+ </div>
442
+ );
443
+ }
444
+
445
+ function getTurnStatusLabel(status: ImageTurnStatus) {
446
+ if (status === "queued") {
447
+ return "排队中";
448
+ }
449
+ if (status === "generating") {
450
+ return "处理中";
451
+ }
452
+ if (status === "success") {
453
+ return "已完成";
454
+ }
455
+ return "失败";
456
+ }
457
+
458
+ const PROGRESS_LABELS: Record<string, string> = {
459
+ getting_account: "确认可用账号",
460
+ uploading: "上传图片",
461
+ bootstrapping: "预热首页",
462
+ getting_token: "获取 token",
463
+ preparing_conversation: "准备会话",
464
+ starting_generation: "启动生成",
465
+ generating: "生成中",
466
+ receiving_image: "接收图片中",
467
+ };
468
+
469
+ function getProgressLabel(progress?: string) {
470
+ if (!progress) {
471
+ return "生成中";
472
+ }
473
+ return PROGRESS_LABELS[progress] || "生成中";
474
+ }
475
+
476
+ function formatElapsed(seconds: number): string {
477
+ return `${seconds.toFixed(1)}s`;
478
+ }
479
+
480
+ function formatDuration(ms: number): string {
481
+ return `${(ms / 1000).toFixed(1)}s`;
482
+ }
483
+
484
+ const base64SizeCache = new Map<string, string>();
485
+ function formatBase64ImageSize(base64: string) {
486
+ let cached = base64SizeCache.get(base64);
487
+ if (cached !== undefined) return cached;
488
+ const normalized = base64.replace(/\s/g, "");
489
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
490
+ const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
491
+
492
+ if (bytes >= 1024 * 1024) {
493
+ cached = `${(bytes / 1024 / 1024).toFixed(2)} MB`;
494
+ } else if (bytes >= 1024) {
495
+ cached = `${(bytes / 1024).toFixed(1)} KB`;
496
+ } else {
497
+ cached = `${bytes} B`;
498
+ }
499
+ base64SizeCache.set(base64, cached);
500
+ return cached;
501
+ }
502
+
503
+ function formatImageDimensions(width: number, height: number) {
504
+ return `${width} x ${height}`;
505
+ }
506
+
507
+ const LazyImage = memo(function LazyImage({ src, alt, className, onLoad, onOpen }: {
508
+ src: string;
509
+ alt: string;
510
+ className: string;
511
+ onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
512
+ onOpen?: () => void;
513
+ }) {
514
+ const [isVisible, setIsVisible] = useState(false);
515
+ const imgRef = useRef<HTMLDivElement>(null);
516
+
517
+ useEffect(() => {
518
+ const element = imgRef.current;
519
+ if (!element) return;
520
+
521
+ const observer = new IntersectionObserver(
522
+ ([entry]) => {
523
+ if (entry.isIntersecting) {
524
+ setIsVisible(true);
525
+ observer.disconnect();
526
+ }
527
+ },
528
+ { rootMargin: "400px" },
529
+ );
530
+ observer.observe(element);
531
+ return () => observer.disconnect();
532
+ }, []);
533
+
534
+ return (
535
+ <div ref={imgRef} className="relative">
536
+ {isVisible ? (
537
+ <button
538
+ type="button"
539
+ onClick={onOpen}
540
+ className={className}
541
+ >
542
+ <img
543
+ src={src}
544
+ alt={alt}
545
+ className="block h-full w-full object-cover transition duration-200 group-hover:brightness-90 sm:h-auto sm:object-contain"
546
+ onLoad={onLoad}
547
+ />
548
+ </button>
549
+ ) : (
550
+ <div className={`animate-pulse rounded-xl bg-stone-100 min-h-[200px] sm:min-h-[280px] ${className}`} />
551
+ )}
552
+ </div>
553
+ );
554
+ });