弘扬 commited on
Commit
dbeb0bd
·
1 Parent(s): 54b474c

feat(image-web): support stateless continue edit history

Browse files
web/src/app/image/components/image-composer.tsx CHANGED
@@ -23,8 +23,9 @@ type ImageComposerProps = {
23
  model: ImageModel;
24
  imageCount: string;
25
  availableQuota: string;
26
- hasAnyGenerating: boolean;
27
- generatingCount: number;
 
28
  referenceImages: Array<{ name: string; dataUrl: string }>;
29
  textareaRef: RefObject<HTMLTextAreaElement | null>;
30
  fileInputRef: RefObject<HTMLInputElement | null>;
@@ -45,8 +46,9 @@ export function ImageComposer({
45
  model,
46
  imageCount,
47
  availableQuota,
48
- hasAnyGenerating,
49
- generatingCount,
 
50
  referenceImages,
51
  textareaRef,
52
  fileInputRef,
@@ -176,10 +178,10 @@ export function ImageComposer({
176
  </Button>
177
  )}
178
  <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">剩余额度 {availableQuota}</div>
179
- {hasAnyGenerating && (
180
  <div className="flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
181
  <LoaderCircle className="size-3 animate-spin" />
182
- {generatingCount} 个任务进行中
183
  </div>
184
  )}
185
  <Select value={model} onValueChange={(value) => onModelChange(value as ImageModel)}>
@@ -214,6 +216,17 @@ export function ImageComposer({
214
  编辑图
215
  </ModeButton>
216
  </div>
 
 
 
 
 
 
 
 
 
 
 
217
  </div>
218
 
219
  <button
 
23
  model: ImageModel;
24
  imageCount: string;
25
  availableQuota: string;
26
+ activeTaskCount: number;
27
+ selectedConversationTitle: string | null;
28
+ selectedConversationStats: { queued: number; running: number } | null;
29
  referenceImages: Array<{ name: string; dataUrl: string }>;
30
  textareaRef: RefObject<HTMLTextAreaElement | null>;
31
  fileInputRef: RefObject<HTMLInputElement | null>;
 
46
  model,
47
  imageCount,
48
  availableQuota,
49
+ activeTaskCount,
50
+ selectedConversationTitle,
51
+ selectedConversationStats,
52
  referenceImages,
53
  textareaRef,
54
  fileInputRef,
 
178
  </Button>
179
  )}
180
  <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">剩余额度 {availableQuota}</div>
181
+ {activeTaskCount > 0 && (
182
  <div className="flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
183
  <LoaderCircle className="size-3 animate-spin" />
184
+ {activeTaskCount} 个任务处理中或排队中
185
  </div>
186
  )}
187
  <Select value={model} onValueChange={(value) => onModelChange(value as ImageModel)}>
 
216
  编辑图
217
  </ModeButton>
218
  </div>
219
+ {selectedConversationTitle ? (
220
+ <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">
221
+ 当前对话:{selectedConversationTitle}
222
+ {selectedConversationStats?.running ? ` · 处理中 ${selectedConversationStats.running}` : ""}
223
+ {selectedConversationStats?.queued ? ` · 排队 ${selectedConversationStats.queued}` : ""}
224
+ </div>
225
+ ) : (
226
+ <div className="rounded-full bg-stone-100 px-3 py-2 text-xs font-medium text-stone-600">
227
+ 当前对话:发送后自动创建
228
+ </div>
229
+ )}
230
  </div>
231
 
232
  <button
web/src/app/image/components/image-results.tsx CHANGED
@@ -1,35 +1,28 @@
1
  "use client";
2
- import { LoaderCircle } from "lucide-react";
3
- import { useMemo, useState } from "react";
4
 
5
- import { ImageLightbox } from "@/components/image-lightbox";
6
- import type { ImageConversation, StoredImage } from "@/store/image-conversations";
 
 
 
 
 
 
 
7
 
8
  type ImageResultsProps = {
9
  selectedConversation: ImageConversation | null;
10
- isSelectedGenerating: boolean;
11
- openLightbox: (imageId: string) => void;
12
  formatConversationTime: (value: string) => string;
13
  };
14
 
15
  export function ImageResults({
16
  selectedConversation,
17
- isSelectedGenerating,
18
- openLightbox,
19
  formatConversationTime,
20
  }: ImageResultsProps) {
21
- const [referenceLightboxOpen, setReferenceLightboxOpen] = useState(false);
22
- const [referenceLightboxIndex, setReferenceLightboxIndex] = useState(0);
23
-
24
- const referenceLightboxImages = useMemo(
25
- () =>
26
- (selectedConversation?.referenceImages ?? []).map((image, index) => ({
27
- id: `${image.name}-${index}`,
28
- src: image.dataUrl,
29
- })),
30
- [selectedConversation?.referenceImages],
31
- );
32
-
33
  if (!selectedConversation) {
34
  return (
35
  <div className="flex h-full min-h-[420px] items-center justify-center text-center">
@@ -48,7 +41,7 @@ export function ImageResults({
48
  fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
49
  }}
50
  >
51
- Describe a scene, a mood, or a character, and let the next image start here.
52
  </p>
53
  </div>
54
  </div>
@@ -56,128 +49,166 @@ export function ImageResults({
56
  }
57
 
58
  return (
59
- <div className="mx-auto flex w-full max-w-[980px] flex-col gap-4">
60
- <ImageLightbox
61
- images={referenceLightboxImages}
62
- currentIndex={referenceLightboxIndex}
63
- open={referenceLightboxOpen}
64
- onOpenChange={setReferenceLightboxOpen}
65
- onIndexChange={setReferenceLightboxIndex}
66
- />
 
 
 
67
 
68
- <div className="flex justify-end">
69
- <div className="w-full max-w-[min(820px,92%)] px-1 pt-1">
70
- <div className="ml-auto flex max-w-full flex-col items-end gap-2.5 text-right">
71
- <div className="w-fit max-w-[min(32rem,100%)] whitespace-pre-wrap break-words text-[15px] leading-6 text-stone-700 sm:leading-7">
72
- {selectedConversation.prompt}
73
- </div>
74
- {selectedConversation.referenceImages?.length ? (
75
- <div
76
- className="grid w-fit auto-rows-fr gap-3"
77
- style={{
78
- gridTemplateColumns: `repeat(${Math.min(selectedConversation.referenceImages.length, 3)}, minmax(0, 1fr))`,
79
- }}
80
- >
81
- {selectedConversation.referenceImages.map((image, index) => (
82
- <button
83
- key={`${image.name}-${index}`}
84
- type="button"
85
- onClick={() => {
86
- setReferenceLightboxIndex(index);
87
- setReferenceLightboxOpen(true);
88
- }}
89
- className="group relative aspect-square min-h-[112px] overflow-hidden rounded-[18px] border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300 sm:min-h-[136px]"
90
- aria-label={`预览参考图 ${image.name || index + 1}`}
91
- >
92
- <img
93
- src={image.dataUrl}
94
- alt={image.name || `参考图 ${index + 1}`}
95
- className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
96
- />
97
- </button>
98
- ))}
99
  </div>
100
- ) : null}
101
- </div>
102
- </div>
103
- </div>
104
 
105
- <div className="flex justify-start">
106
- <div className="w-full p-1">
107
- <div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-stone-500">
108
- <span className="rounded-full bg-stone-100 px-3 py-1">{selectedConversation.mode === "edit" ? "编辑图" : "文生图"}</span>
109
- <span className="rounded-full bg-stone-100 px-3 py-1">{selectedConversation.model}</span>
110
- <span className="rounded-full bg-stone-100 px-3 py-1">{selectedConversation.count} 张</span>
111
- <span className="rounded-full bg-stone-100 px-3 py-1">
112
- {formatConversationTime(selectedConversation.createdAt)}
113
- </span>
114
- {isSelectedGenerating && (
115
- <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">处理中</span>
116
- )}
117
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
- {selectedConversation.status === "error" && selectedConversation.images.length === 0 ? (
120
- <div className="border-l-2 border-rose-300 bg-rose-50/70 px-4 py-4 text-sm leading-6 text-rose-600">
121
- {selectedConversation.error || "生成失败"}
122
- </div>
123
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- {selectedConversation.images.length > 0 ? (
126
- <div className="columns-1 gap-4 space-y-4 sm:columns-2 xl:columns-3">
127
- {selectedConversation.images.map((image, index) => (
128
- <div key={image.id} className="break-inside-avoid overflow-hidden rounded-[22px]">
129
- <ImageResultCard image={image} index={index} onOpen={openLightbox} />
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  </div>
131
- ))}
132
- </div>
133
- ) : null}
134
 
135
- {selectedConversation.status === "error" && selectedConversation.images.length > 0 ? (
136
- <div className="mt-4 border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
137
- {selectedConversation.error}
 
 
 
138
  </div>
139
- ) : null}
140
- </div>
141
- </div>
142
  </div>
143
  );
144
  }
145
 
146
- function ImageResultCard({
147
- image,
148
- index,
149
- onOpen,
150
- }: {
151
- image: StoredImage;
152
- index: number;
153
- onOpen: (imageId: string) => void;
154
- }) {
155
- if (image.status === "success" && image.b64_json) {
156
- return (
157
- <button type="button" onClick={() => onOpen(image.id)} className="group block w-full cursor-zoom-in">
158
- <img
159
- src={`data:image/png;base64,${image.b64_json}`}
160
- alt={`Generated result ${index + 1}`}
161
- className="block h-auto w-full transition duration-200 group-hover:brightness-90"
162
- />
163
- </button>
164
- );
165
  }
166
-
167
- if (image.status === "error") {
168
- return (
169
- <div className="flex min-h-[320px] items-center justify-center bg-rose-50 px-6 py-8 text-center text-sm leading-6 text-rose-600">
170
- {image.error || "生成失败"}
171
- </div>
172
- );
173
  }
174
-
175
- return (
176
- <div className="flex min-h-[320px] flex-col items-center justify-center gap-3 bg-stone-100/80 px-6 py-8 text-center text-stone-500">
177
- <div className="rounded-full bg-white p-3 shadow-sm">
178
- <LoaderCircle className="size-5 animate-spin" />
179
- </div>
180
- <p className="text-sm">正在生成图片...</p>
181
- </div>
182
- );
183
  }
 
1
  "use client";
 
 
2
 
3
+ import { Clock3, LoaderCircle, Sparkles } from "lucide-react";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import type { ImageConversation, ImageTurnStatus, StoredImage } from "@/store/image-conversations";
7
+
8
+ export type ImageLightboxItem = {
9
+ id: string;
10
+ src: string;
11
+ };
12
 
13
  type ImageResultsProps = {
14
  selectedConversation: ImageConversation | null;
15
+ onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
16
+ onContinueEdit: (conversationId: string, image: StoredImage) => void;
17
  formatConversationTime: (value: string) => string;
18
  };
19
 
20
  export function ImageResults({
21
  selectedConversation,
22
+ onOpenLightbox,
23
+ onContinueEdit,
24
  formatConversationTime,
25
  }: ImageResultsProps) {
 
 
 
 
 
 
 
 
 
 
 
 
26
  if (!selectedConversation) {
27
  return (
28
  <div className="flex h-full min-h-[420px] items-center justify-center text-center">
 
41
  fontFamily: '"Palatino Linotype","Book Antiqua","URW Palladio L","Times New Roman",serif',
42
  }}
43
  >
44
+ 在同一窗口里保留本地历史与任务状态,并从已有结果图继续发起新的无状态编辑。
45
  </p>
46
  </div>
47
  </div>
 
49
  }
50
 
51
  return (
52
+ <div className="mx-auto flex w-full max-w-[980px] flex-col gap-8">
53
+ {selectedConversation.turns.map((turn, turnIndex) => {
54
+ const referenceLightboxImages = turn.referenceImages.map((image, index) => ({
55
+ id: `${turn.id}-reference-${index}`,
56
+ src: image.dataUrl,
57
+ }));
58
+ const successfulTurnImages = turn.images.flatMap((image) =>
59
+ image.status === "success" && image.b64_json
60
+ ? [{ id: image.id, src: `data:image/png;base64,${image.b64_json}` }]
61
+ : [],
62
+ );
63
 
64
+ return (
65
+ <div key={turn.id} className="flex flex-col gap-4">
66
+ <div className="flex justify-end">
67
+ <div className="max-w-[82%] rounded-[28px] bg-stone-950 px-5 py-4 text-[15px] leading-7 text-white shadow-sm">
68
+ <div className="mb-2 flex flex-wrap items-center gap-2 text-[11px] text-white/70">
69
+ <span className="rounded-full bg-white/10 px-2.5 py-1">第 {turnIndex + 1} 轮</span>
70
+ <span className="rounded-full bg-white/10 px-2.5 py-1">
71
+ {turn.mode === "edit" ? "编辑图" : "文生图"}
72
+ </span>
73
+ <span className="rounded-full bg-white/10 px-2.5 py-1">{turn.model}</span>
74
+ <span className="rounded-full bg-white/10 px-2.5 py-1">{getTurnStatusLabel(turn.status)}</span>
75
+ <span className="rounded-full bg-white/10 px-2.5 py-1">{formatConversationTime(turn.createdAt)}</span>
76
+ </div>
77
+ <div>{turn.prompt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
+ </div>
 
 
 
80
 
81
+ <div className="flex justify-start">
82
+ <div className="w-full rounded-[28px] border border-stone-200/80 bg-white/85 p-4 shadow-[0_14px_40px_rgba(28,25,23,0.05)]">
83
+ {turn.referenceImages.length > 0 ? (
84
+ <div className="mb-5 rounded-[22px] border border-stone-200/80 bg-stone-50/80 p-3">
85
+ <div className="mb-3 text-xs font-medium text-stone-500">本轮参考图</div>
86
+ <div
87
+ className="grid gap-3"
88
+ style={{
89
+ gridTemplateColumns: `repeat(${Math.min(turn.referenceImages.length, 3)}, minmax(0, 1fr))`,
90
+ }}
91
+ >
92
+ {turn.referenceImages.map((image, index) => (
93
+ <button
94
+ key={`${turn.id}-${image.name}-${index}`}
95
+ type="button"
96
+ onClick={() => onOpenLightbox(referenceLightboxImages, index)}
97
+ className="group relative aspect-square overflow-hidden rounded-[18px] border border-stone-200/80 bg-stone-100/60 text-left transition hover:border-stone-300"
98
+ aria-label={`预览参考图 ${image.name || index + 1}`}
99
+ >
100
+ <img
101
+ src={image.dataUrl}
102
+ alt={image.name || `参考图 ${index + 1}`}
103
+ className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
104
+ />
105
+ </button>
106
+ ))}
107
+ </div>
108
+ </div>
109
+ ) : null}
110
 
111
+ <div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-stone-500">
112
+ <span className="rounded-full bg-stone-100 px-3 py-1">{turn.count} 张</span>
113
+ <span className="rounded-full bg-stone-100 px-3 py-1">{getTurnStatusLabel(turn.status)}</span>
114
+ {turn.status === "queued" ? (
115
+ <span className="rounded-full bg-amber-50 px-3 py-1 text-amber-700">等待当前对话中的前序任务完成</span>
116
+ ) : null}
117
+ </div>
118
+
119
+ <div className="columns-1 gap-4 space-y-4 sm:columns-2 xl:columns-3">
120
+ {turn.images.map((image, index) => {
121
+ if (image.status === "success" && image.b64_json) {
122
+ const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
123
+
124
+ return (
125
+ <div
126
+ key={image.id}
127
+ className="break-inside-avoid overflow-hidden rounded-[22px] border border-stone-200/80 bg-stone-50/60"
128
+ >
129
+ <button
130
+ type="button"
131
+ onClick={() => onOpenLightbox(successfulTurnImages, currentIndex)}
132
+ className="group block w-full cursor-zoom-in"
133
+ >
134
+ <img
135
+ src={`data:image/png;base64,${image.b64_json}`}
136
+ alt={`Generated result ${index + 1}`}
137
+ className="block h-auto w-full transition duration-200 group-hover:brightness-90"
138
+ />
139
+ </button>
140
+ <div className="flex items-center justify-between gap-2 px-3 py-3">
141
+ <div className="text-xs text-stone-500">结果 {index + 1}</div>
142
+ <Button
143
+ variant="outline"
144
+ size="sm"
145
+ className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
146
+ onClick={() => onContinueEdit(selectedConversation.id, image)}
147
+ >
148
+ <Sparkles className="size-4" />
149
+ 继续编辑
150
+ </Button>
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ if (image.status === "error") {
157
+ return (
158
+ <div
159
+ key={image.id}
160
+ className="break-inside-avoid overflow-hidden rounded-[22px] border border-rose-200 bg-rose-50"
161
+ >
162
+ <div className="flex min-h-[320px] items-center justify-center px-6 py-8 text-center text-sm leading-6 text-rose-600">
163
+ {image.error || "生成失败"}
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
 
169
+ return (
170
+ <div
171
+ key={image.id}
172
+ className="break-inside-avoid overflow-hidden rounded-[22px] border border-stone-200/80 bg-stone-100/80"
173
+ >
174
+ <div className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 py-8 text-center text-stone-500">
175
+ <div className="rounded-full bg-white p-3 shadow-sm">
176
+ {turn.status === "queued" ? (
177
+ <Clock3 className="size-5" />
178
+ ) : (
179
+ <LoaderCircle className="size-5 animate-spin" />
180
+ )}
181
+ </div>
182
+ <p className="text-sm">{turn.status === "queued" ? "已加入当前对话队列..." : "正在处理图片..."}</p>
183
+ </div>
184
+ </div>
185
+ );
186
+ })}
187
  </div>
 
 
 
188
 
189
+ {turn.status === "error" && turn.error ? (
190
+ <div className="mt-4 border-l-2 border-amber-300 bg-amber-50/70 px-4 py-3 text-sm leading-6 text-amber-700">
191
+ {turn.error}
192
+ </div>
193
+ ) : null}
194
+ </div>
195
  </div>
196
+ </div>
197
+ );
198
+ })}
199
  </div>
200
  );
201
  }
202
 
203
+ function getTurnStatusLabel(status: ImageTurnStatus) {
204
+ if (status === "queued") {
205
+ return "排队中";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  }
207
+ if (status === "generating") {
208
+ return "处理中";
 
 
 
 
 
209
  }
210
+ if (status === "success") {
211
+ return "已完成";
212
+ }
213
+ return "失败";
 
 
 
 
 
214
  }
web/src/app/image/components/image-sidebar.tsx CHANGED
@@ -9,7 +9,6 @@ import type { ImageConversation } from "@/store/image-conversations";
9
  type ImageSidebarProps = {
10
  conversations: ImageConversation[];
11
  isLoadingHistory: boolean;
12
- generatingIds: Set<string>;
13
  selectedConversationId: string | null;
14
  onCreateDraft: () => void;
15
  onClearHistory: () => void | Promise<void>;
@@ -21,7 +20,6 @@ type ImageSidebarProps = {
21
  export function ImageSidebar({
22
  conversations,
23
  isLoadingHistory,
24
- generatingIds,
25
  selectedConversationId,
26
  onCreateDraft,
27
  onClearHistory,
@@ -58,7 +56,7 @@ export function ImageSidebar({
58
  ) : (
59
  conversations.map((conversation) => {
60
  const active = conversation.id === selectedConversationId;
61
- const generating = generatingIds.has(conversation.id);
62
  return (
63
  <div
64
  key={conversation.id}
@@ -74,13 +72,22 @@ export function ImageSidebar({
74
  onClick={() => onSelectConversation(conversation.id)}
75
  className="block w-full pr-8 text-left"
76
  >
77
- <div className="flex items-center gap-1.5 truncate text-sm font-semibold">
78
- {generating && <LoaderCircle className="size-3.5 shrink-0 animate-spin text-stone-400" />}
79
  <span className="truncate">{conversation.title}</span>
80
  </div>
81
  <div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
82
- {formatConversationTime(conversation.createdAt)}
83
  </div>
 
 
 
 
 
 
 
 
 
 
84
  </button>
85
  <button
86
  type="button"
@@ -99,3 +106,17 @@ export function ImageSidebar({
99
  </aside>
100
  );
101
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  type ImageSidebarProps = {
10
  conversations: ImageConversation[];
11
  isLoadingHistory: boolean;
 
12
  selectedConversationId: string | null;
13
  onCreateDraft: () => void;
14
  onClearHistory: () => void | Promise<void>;
 
20
  export function ImageSidebar({
21
  conversations,
22
  isLoadingHistory,
 
23
  selectedConversationId,
24
  onCreateDraft,
25
  onClearHistory,
 
56
  ) : (
57
  conversations.map((conversation) => {
58
  const active = conversation.id === selectedConversationId;
59
+ const stats = getConversationStats(conversation);
60
  return (
61
  <div
62
  key={conversation.id}
 
72
  onClick={() => onSelectConversation(conversation.id)}
73
  className="block w-full pr-8 text-left"
74
  >
75
+ <div className="truncate text-sm font-semibold">
 
76
  <span className="truncate">{conversation.title}</span>
77
  </div>
78
  <div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
79
+ {conversation.turns.length} 轮 · {formatConversationTime(conversation.updatedAt)}
80
  </div>
81
+ {stats.running > 0 || stats.queued > 0 ? (
82
+ <div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
83
+ {stats.running > 0 ? (
84
+ <span className="rounded-full bg-blue-50 px-2 py-1 text-blue-600">处理中 {stats.running}</span>
85
+ ) : null}
86
+ {stats.queued > 0 ? (
87
+ <span className="rounded-full bg-amber-50 px-2 py-1 text-amber-700">排队 {stats.queued}</span>
88
+ ) : null}
89
+ </div>
90
+ ) : null}
91
  </button>
92
  <button
93
  type="button"
 
106
  </aside>
107
  );
108
  }
109
+
110
+ function getConversationStats(conversation: ImageConversation) {
111
+ return conversation.turns.reduce(
112
+ (acc, turn) => {
113
+ if (turn.status === "queued") {
114
+ acc.queued += 1;
115
+ } else if (turn.status === "generating") {
116
+ acc.running += 1;
117
+ }
118
+ return acc;
119
+ },
120
+ { queued: 0, running: 0 },
121
+ );
122
+ }
web/src/app/image/page.tsx CHANGED
@@ -3,11 +3,11 @@
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
  import { toast } from "sonner";
5
 
6
- import { ImageLightbox } from "@/components/image-lightbox";
7
- import { editImage, fetchAccounts, generateImage, type Account, type ImageModel } from "@/lib/api";
8
  import { ImageComposer } from "@/app/image/components/image-composer";
9
- import { ImageResults } from "@/app/image/components/image-results";
10
  import { ImageSidebar } from "@/app/image/components/image-sidebar";
 
 
11
  import {
12
  clearImageConversations,
13
  deleteImageConversation,
@@ -15,10 +15,15 @@ import {
15
  saveImageConversation,
16
  type ImageConversation,
17
  type ImageConversationMode,
 
 
18
  type StoredImage,
19
  type StoredReferenceImage,
20
  } from "@/store/image-conversations";
21
 
 
 
 
22
  const imageModelOptions: Array<{ label: string; value: ImageModel }> = [
23
  { label: "gpt-image-1", value: "gpt-image-1" },
24
  { label: "gpt-image-2", value: "gpt-image-2" },
@@ -26,10 +31,10 @@ const imageModelOptions: Array<{ label: string; value: ImageModel }> = [
26
 
27
  function buildConversationTitle(prompt: string) {
28
  const trimmed = prompt.trim();
29
- if (trimmed.length <= 5) {
30
  return trimmed;
31
  }
32
- return `${trimmed.slice(0, 5)}...`;
33
  }
34
 
35
  function formatConversationTime(value: string) {
@@ -50,41 +55,11 @@ function formatAvailableQuota(accounts: Account[]) {
50
  return String(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
51
  }
52
 
53
- async function normalizeConversationHistory(items: ImageConversation[]) {
54
- const normalized = items.map((item) =>
55
- item.status === "generating"
56
- ? {
57
- ...item,
58
- status: "error" as const,
59
- error: item.images.some((image) => image.status === "success")
60
- ? item.error || "生成已中断"
61
- : "页面已刷新,生成已中断",
62
- images: item.images.map((image) =>
63
- image.status === "loading"
64
- ? {
65
- ...image,
66
- status: "error" as const,
67
- error: "页面已刷新,生成已中断",
68
- }
69
- : image,
70
- ),
71
- }
72
- : item,
73
- );
74
-
75
- await Promise.all(
76
- normalized
77
- .filter((item, index) => item !== items[index])
78
- .map((item) => saveImageConversation(item)),
79
- );
80
-
81
- return normalized;
82
- }
83
-
84
  function createId() {
85
- return typeof crypto !== "undefined" && "randomUUID" in crypto
86
- ? crypto.randomUUID()
87
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
 
88
  }
89
 
90
  function readFileAsDataUrl(file: File) {
@@ -96,8 +71,118 @@ function readFileAsDataUrl(file: File) {
96
  });
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  export default function ImagePage() {
100
  const didLoadQuotaRef = useRef(false);
 
101
  const resultsViewportRef = useRef<HTMLDivElement>(null);
102
  const textareaRef = useRef<HTMLTextAreaElement>(null);
103
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -111,61 +196,52 @@ export default function ImagePage() {
111
  const [conversations, setConversations] = useState<ImageConversation[]>([]);
112
  const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
113
  const [isLoadingHistory, setIsLoadingHistory] = useState(true);
114
- const [generatingIds, setGeneratingIds] = useState<Set<string>>(new Set());
115
- const [availableQuota, setAvailableQuota] = useState("加载中");
116
  const [lightboxOpen, setLightboxOpen] = useState(false);
117
  const [lightboxIndex, setLightboxIndex] = useState(0);
118
 
 
119
  const selectedConversation = useMemo(
120
  () => conversations.find((item) => item.id === selectedConversationId) ?? null,
121
  [conversations, selectedConversationId],
122
  );
123
- const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
124
- const isSelectedGenerating = selectedConversationId !== null && generatingIds.has(selectedConversationId);
125
- const hasAnyGenerating = generatingIds.size > 0;
126
-
127
- const addGeneratingId = useCallback((id: string) => {
128
- setGeneratingIds((prev) => new Set(prev).add(id));
129
- }, []);
130
-
131
- const removeGeneratingId = useCallback((id: string) => {
132
- setGeneratingIds((prev) => {
133
- const next = new Set(prev);
134
- next.delete(id);
135
- return next;
136
- });
137
- }, []);
138
-
139
- const lightboxImages = useMemo(
140
- () =>
141
- (selectedConversation?.images ?? [])
142
- .filter((img): img is StoredImage & { b64_json: string } => img.status === "success" && !!img.b64_json)
143
- .map((img) => ({ id: img.id, src: `data:image/png;base64,${img.b64_json}` })),
144
  [selectedConversation],
145
  );
146
-
147
- const openLightbox = useCallback(
148
- (imageId: string) => {
149
- const idx = lightboxImages.findIndex((img) => img.id === imageId);
150
- if (idx >= 0) {
151
- setLightboxIndex(idx);
152
- setLightboxOpen(true);
153
- }
154
- },
155
- [lightboxImages],
156
  );
157
 
 
 
 
 
158
  useEffect(() => {
159
  let cancelled = false;
160
 
161
  const loadHistory = async () => {
162
  try {
163
  const items = await listImageConversations();
164
- const normalizedItems = await normalizeConversationHistory(items);
165
  if (cancelled) {
166
  return;
167
  }
 
168
  setConversations(normalizedItems);
 
 
 
 
 
 
 
169
  } catch (error) {
170
  const message = error instanceof Error ? error.message : "读取会话记录失败";
171
  toast.error(message);
@@ -187,7 +263,7 @@ export default function ImagePage() {
187
  const data = await fetchAccounts();
188
  setAvailableQuota(formatAvailableQuota(data.items));
189
  } catch {
190
- setAvailableQuota((prev) => (prev === "加载中" ? "—" : prev));
191
  }
192
  }, []);
193
 
@@ -197,15 +273,11 @@ export default function ImagePage() {
197
  }
198
  didLoadQuotaRef.current = true;
199
 
200
- const syncQuota = async () => {
201
- await loadQuota();
202
- };
203
-
204
  const handleFocus = () => {
205
- void syncQuota();
206
  };
207
 
208
- void syncQuota();
209
  window.addEventListener("focus", handleFocus);
210
  return () => {
211
  window.removeEventListener("focus", handleFocus);
@@ -213,7 +285,7 @@ export default function ImagePage() {
213
  }, [loadQuota]);
214
 
215
  useEffect(() => {
216
- if (!selectedConversation && !isSelectedGenerating) {
217
  return;
218
  }
219
 
@@ -221,35 +293,53 @@ export default function ImagePage() {
221
  top: resultsViewportRef.current.scrollHeight,
222
  behavior: "smooth",
223
  });
224
- }, [selectedConversation, isSelectedGenerating]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  const persistConversation = async (conversation: ImageConversation) => {
227
  setConversations((prev) => {
228
  const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
229
- return next.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
230
  });
231
  await saveImageConversation(conversation);
232
  };
233
 
234
- const updateConversation = async (
235
- conversationId: string,
236
- updater: (current: ImageConversation | null) => ImageConversation,
237
- ) => {
238
- let nextConversation: ImageConversation | null = null;
239
 
240
- setConversations((prev) => {
241
- const current = prev.find((item) => item.id === conversationId) ?? null;
242
- nextConversation = updater(current);
243
- const next = [nextConversation, ...prev.filter((item) => item.id !== conversationId)];
244
- return next.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
245
- });
246
 
247
- if (nextConversation) {
248
- await saveImageConversation(nextConversation);
249
- }
250
- };
 
 
251
 
252
- const resetComposer = useCallback(() => {
253
  setImagePrompt("");
254
  setImageCount("1");
255
  setReferenceImageFiles([]);
@@ -259,6 +349,11 @@ export default function ImagePage() {
259
  }
260
  }, []);
261
 
 
 
 
 
 
262
  const handleCreateDraft = () => {
263
  setSelectedConversationId(null);
264
  resetComposer();
@@ -268,7 +363,10 @@ export default function ImagePage() {
268
  const handleDeleteConversation = async (id: string) => {
269
  const nextConversations = conversations.filter((item) => item.id !== id);
270
  setConversations(nextConversations);
271
- setSelectedConversationId((prev) => (prev === id ? null : prev));
 
 
 
272
 
273
  try {
274
  await deleteImageConversation(id);
@@ -285,6 +383,7 @@ export default function ImagePage() {
285
  await clearImageConversations();
286
  setConversations([]);
287
  setSelectedConversationId(null);
 
288
  toast.success("已清空历史记录");
289
  } catch (error) {
290
  const message = error instanceof Error ? error.message : "清空历史记录失败";
@@ -305,8 +404,10 @@ export default function ImagePage() {
305
  dataUrl: await readFileAsDataUrl(file),
306
  })),
307
  );
 
308
  setReferenceImageFiles((prev) => [...prev, ...files]);
309
  setReferenceImages((prev) => [...prev, ...previews]);
 
310
  if (fileInputRef.current) {
311
  fileInputRef.current.value = "";
312
  }
@@ -316,15 +417,16 @@ export default function ImagePage() {
316
  }
317
  }, []);
318
 
319
- const handleReferenceImageChange = useCallback(async (files: File[]) => {
320
- if (files.length === 0) {
321
- setReferenceImageFiles([]);
322
- setReferenceImages([]);
323
- return;
324
- }
325
 
326
- await appendReferenceImages(files);
327
- }, [appendReferenceImages]);
 
 
328
 
329
  const handleRemoveReferenceImage = useCallback((index: number) => {
330
  setReferenceImageFiles((prev) => {
@@ -337,7 +439,240 @@ export default function ImagePage() {
337
  setReferenceImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
338
  }, []);
339
 
340
- const handleGenerateImage = async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  const prompt = imagePrompt.trim();
342
  if (!prompt) {
343
  toast.error("请输入提示词");
@@ -349,118 +684,54 @@ export default function ImagePage() {
349
  return;
350
  }
351
 
 
 
 
352
  const now = new Date().toISOString();
353
- const conversationId = createId();
354
- const draftReferenceImages = imageMode === "edit" ? referenceImages : [];
355
-
356
- const draftConversation: ImageConversation = {
357
- id: conversationId,
358
- title: buildConversationTitle(prompt),
359
  prompt,
360
  model: imageModel,
361
  mode: imageMode,
362
- referenceImages: draftReferenceImages,
363
  count: parsedCount,
364
  images: Array.from({ length: parsedCount }, (_, index) => ({
365
- id: `${conversationId}-${index}`,
366
- status: "loading",
367
  })),
368
  createdAt: now,
369
- status: "generating",
370
  };
371
 
372
- addGeneratingId(conversationId);
373
- setSelectedConversationId(conversationId);
374
- resetComposer();
375
-
376
- try {
377
- await persistConversation(draftConversation);
378
-
379
- const tasks = Array.from({ length: parsedCount }, async (_, index) => {
380
- try {
381
- const data =
382
- imageMode === "edit" && referenceImageFiles.length > 0
383
- ? await editImage(referenceImageFiles, prompt, imageModel)
384
- : await generateImage(prompt, imageModel);
385
- const first = data.data?.[0];
386
- if (!first?.b64_json) {
387
- throw new Error(`第 ${index + 1} 张没有返回图片数据`);
388
- }
389
-
390
- const nextImage: StoredImage = {
391
- id: `${conversationId}-${index}`,
392
- status: "success",
393
- b64_json: first.b64_json,
394
- };
395
-
396
- await updateConversation(conversationId, (current) => ({
397
- ...(current ?? draftConversation),
398
- images: (current?.images ?? draftConversation.images).map((image) =>
399
- image.id === nextImage.id ? nextImage : image,
400
- ),
401
- }));
402
-
403
- return nextImage;
404
- } catch (error) {
405
- const message = error instanceof Error ? error.message : `第 ${index + 1} 张生成失败`;
406
- const failedImage: StoredImage = {
407
- id: `${conversationId}-${index}`,
408
- status: "error",
409
- error: message,
410
- };
411
-
412
- await updateConversation(conversationId, (current) => ({
413
- ...(current ?? draftConversation),
414
- images: (current?.images ?? draftConversation.images).map((image) =>
415
- image.id === failedImage.id ? failedImage : image,
416
- ),
417
- }));
418
-
419
- throw error;
420
  }
421
- });
422
-
423
- const settled = await Promise.allSettled(tasks);
424
- const successCount = settled.filter((item): item is PromiseFulfilledResult<StoredImage> => item.status === "fulfilled")
425
- .length;
426
- const failedCount = settled.length - successCount;
427
-
428
- if (successCount === 0) {
429
- const firstError = settled.find((item) => item.status === "rejected");
430
- throw new Error(firstError?.status === "rejected" ? String(firstError.reason) : "生成图片失败");
431
- }
432
 
433
- await updateConversation(conversationId, (current) => ({
434
- ...(current ?? draftConversation),
435
- status: failedCount > 0 ? "error" : "success",
436
- error: failedCount > 0 ? `其中 ${failedCount} 张生成失败` : undefined,
437
- }));
438
- await loadQuota();
439
-
440
- if (failedCount > 0) {
441
- toast.error(`已完成 ${successCount} 张,另有 ${failedCount} 张未生成成功`);
442
- } else {
443
- toast.success(imageMode === "edit" ? `已完成 ${successCount} 张图片编辑` : `已生成 ${successCount} 张图片`);
444
- }
445
- } catch (error) {
446
- const message = error instanceof Error ? error.message : imageMode === "edit" ? "编辑图片失败" : "生成图片失败";
447
- await persistConversation({
448
- ...draftConversation,
449
- status: "error",
450
- error: message,
451
- images: draftConversation.images.map((image) =>
452
- image.status === "loading"
453
- ? {
454
- ...image,
455
- status: "error",
456
- error: message,
457
- }
458
- : image,
459
- ),
460
- });
461
- toast.error(message);
462
- } finally {
463
- removeGeneratingId(conversationId);
464
  }
465
  };
466
 
@@ -470,7 +741,6 @@ export default function ImagePage() {
470
  <ImageSidebar
471
  conversations={conversations}
472
  isLoadingHistory={isLoadingHistory}
473
- generatingIds={generatingIds}
474
  selectedConversationId={selectedConversationId}
475
  onCreateDraft={handleCreateDraft}
476
  onClearHistory={handleClearHistory}
@@ -486,8 +756,8 @@ export default function ImagePage() {
486
  >
487
  <ImageResults
488
  selectedConversation={selectedConversation}
489
- isSelectedGenerating={isSelectedGenerating}
490
- openLightbox={openLightbox}
491
  formatConversationTime={formatConversationTime}
492
  />
493
  </div>
@@ -498,8 +768,9 @@ export default function ImagePage() {
498
  model={imageModel}
499
  imageCount={imageCount}
500
  availableQuota={availableQuota}
501
- hasAnyGenerating={hasAnyGenerating}
502
- generatingCount={generatingIds.size}
 
503
  referenceImages={referenceImages}
504
  textareaRef={textareaRef}
505
  fileInputRef={fileInputRef}
@@ -508,7 +779,7 @@ export default function ImagePage() {
508
  onPromptChange={setImagePrompt}
509
  onModelChange={setImageModel}
510
  onImageCountChange={setImageCount}
511
- onSubmit={handleGenerateImage}
512
  onPickReferenceImage={() => fileInputRef.current?.click()}
513
  onReferenceImageChange={handleReferenceImageChange}
514
  onRemoveReferenceImage={handleRemoveReferenceImage}
 
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
  import { toast } from "sonner";
5
 
 
 
6
  import { ImageComposer } from "@/app/image/components/image-composer";
7
+ import { ImageResults, type ImageLightboxItem } from "@/app/image/components/image-results";
8
  import { ImageSidebar } from "@/app/image/components/image-sidebar";
9
+ import { ImageLightbox } from "@/components/image-lightbox";
10
+ import { editImage, fetchAccounts, generateImage, type Account, type ImageModel } from "@/lib/api";
11
  import {
12
  clearImageConversations,
13
  deleteImageConversation,
 
15
  saveImageConversation,
16
  type ImageConversation,
17
  type ImageConversationMode,
18
+ type ImageTurn,
19
+ type ImageTurnStatus,
20
  type StoredImage,
21
  type StoredReferenceImage,
22
  } from "@/store/image-conversations";
23
 
24
+ const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
25
+ const activeConversationQueueIds = new Set<string>();
26
+
27
  const imageModelOptions: Array<{ label: string; value: ImageModel }> = [
28
  { label: "gpt-image-1", value: "gpt-image-1" },
29
  { label: "gpt-image-2", value: "gpt-image-2" },
 
31
 
32
  function buildConversationTitle(prompt: string) {
33
  const trimmed = prompt.trim();
34
+ if (trimmed.length <= 12) {
35
  return trimmed;
36
  }
37
+ return `${trimmed.slice(0, 12)}...`;
38
  }
39
 
40
  function formatConversationTime(value: string) {
 
55
  return String(availableAccounts.reduce((sum, account) => sum + Math.max(0, account.quota), 0));
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  function createId() {
59
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
60
+ return crypto.randomUUID();
61
+ }
62
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
63
  }
64
 
65
  function readFileAsDataUrl(file: File) {
 
71
  });
72
  }
73
 
74
+ function dataUrlToFile(dataUrl: string, fileName: string, mimeType?: string) {
75
+ const [header, content] = dataUrl.split(",", 2);
76
+ const matchedMimeType = header.match(/data:(.*?);base64/)?.[1];
77
+ const binary = atob(content || "");
78
+ const bytes = new Uint8Array(binary.length);
79
+ for (let index = 0; index < binary.length; index += 1) {
80
+ bytes[index] = binary.charCodeAt(index);
81
+ }
82
+ return new File([bytes], fileName, { type: mimeType || matchedMimeType || "image/png" });
83
+ }
84
+
85
+ function buildReferenceImageFromResult(image: StoredImage, fileName: string): StoredReferenceImage | null {
86
+ if (!image.b64_json) {
87
+ return null;
88
+ }
89
+
90
+ return {
91
+ name: fileName,
92
+ type: "image/png",
93
+ dataUrl: `data:image/png;base64,${image.b64_json}`,
94
+ };
95
+ }
96
+
97
+ function getConversationStats(conversation: ImageConversation | null) {
98
+ if (!conversation) {
99
+ return { queued: 0, running: 0 };
100
+ }
101
+
102
+ return conversation.turns.reduce(
103
+ (acc, turn) => {
104
+ if (turn.status === "queued") {
105
+ acc.queued += 1;
106
+ } else if (turn.status === "generating") {
107
+ acc.running += 1;
108
+ }
109
+ return acc;
110
+ },
111
+ { queued: 0, running: 0 },
112
+ );
113
+ }
114
+
115
+ function pickFallbackConversationId(conversations: ImageConversation[]) {
116
+ const activeConversation = conversations.find((conversation) =>
117
+ conversation.turns.some((turn) => turn.status === "queued" || turn.status === "generating"),
118
+ );
119
+ return activeConversation?.id ?? conversations[0]?.id ?? null;
120
+ }
121
+
122
+ async function recoverConversationHistory(items: ImageConversation[]) {
123
+ const normalized = items.map((conversation) => {
124
+ let changed = false;
125
+
126
+ const turns = conversation.turns.map((turn) => {
127
+ if (turn.status !== "queued" && turn.status !== "generating") {
128
+ return turn;
129
+ }
130
+
131
+ const loadingCount = turn.images.filter((image) => image.status === "loading").length;
132
+ if (loadingCount > 0 && turn.status === "generating") {
133
+ changed = true;
134
+ return {
135
+ ...turn,
136
+ status: "queued" as const,
137
+ error: undefined,
138
+ };
139
+ }
140
+
141
+ if (loadingCount > 0) {
142
+ return turn;
143
+ }
144
+
145
+ const failedCount = turn.images.filter((image) => image.status === "error").length;
146
+ const successCount = turn.images.filter((image) => image.status === "success").length;
147
+ const nextStatus: ImageTurnStatus =
148
+ failedCount > 0 ? "error" : successCount > 0 ? "success" : "queued";
149
+ const nextError = failedCount > 0 ? turn.error || `其中 ${failedCount} 张未成功生成` : undefined;
150
+ if (nextStatus === turn.status && nextError === turn.error) {
151
+ return turn;
152
+ }
153
+
154
+ changed = true;
155
+ return {
156
+ ...turn,
157
+ status: nextStatus,
158
+ error: nextError,
159
+ };
160
+ });
161
+
162
+ if (!changed) {
163
+ return conversation;
164
+ }
165
+
166
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
167
+ return {
168
+ ...conversation,
169
+ turns,
170
+ updatedAt: lastTurn?.createdAt || conversation.updatedAt,
171
+ };
172
+ });
173
+
174
+ await Promise.all(
175
+ normalized
176
+ .filter((conversation, index) => conversation !== items[index])
177
+ .map((conversation) => saveImageConversation(conversation)),
178
+ );
179
+
180
+ return normalized;
181
+ }
182
+
183
  export default function ImagePage() {
184
  const didLoadQuotaRef = useRef(false);
185
+ const conversationsRef = useRef<ImageConversation[]>([]);
186
  const resultsViewportRef = useRef<HTMLDivElement>(null);
187
  const textareaRef = useRef<HTMLTextAreaElement>(null);
188
  const fileInputRef = useRef<HTMLInputElement>(null);
 
196
  const [conversations, setConversations] = useState<ImageConversation[]>([]);
197
  const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
198
  const [isLoadingHistory, setIsLoadingHistory] = useState(true);
199
+ const [availableQuota, setAvailableQuota] = useState("加载中...");
200
+ const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
201
  const [lightboxOpen, setLightboxOpen] = useState(false);
202
  const [lightboxIndex, setLightboxIndex] = useState(0);
203
 
204
+ const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
205
  const selectedConversation = useMemo(
206
  () => conversations.find((item) => item.id === selectedConversationId) ?? null,
207
  [conversations, selectedConversationId],
208
  );
209
+ const selectedConversationStats = useMemo(
210
+ () => getConversationStats(selectedConversation),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  [selectedConversation],
212
  );
213
+ const activeTaskCount = useMemo(
214
+ () =>
215
+ conversations.reduce((sum, conversation) => {
216
+ const stats = getConversationStats(conversation);
217
+ return sum + stats.queued + stats.running;
218
+ }, 0),
219
+ [conversations],
 
 
 
220
  );
221
 
222
+ useEffect(() => {
223
+ conversationsRef.current = conversations;
224
+ }, [conversations]);
225
+
226
  useEffect(() => {
227
  let cancelled = false;
228
 
229
  const loadHistory = async () => {
230
  try {
231
  const items = await listImageConversations();
232
+ const normalizedItems = await recoverConversationHistory(items);
233
  if (cancelled) {
234
  return;
235
  }
236
+
237
  setConversations(normalizedItems);
238
+ const storedConversationId =
239
+ typeof window !== "undefined" ? window.localStorage.getItem(ACTIVE_CONVERSATION_STORAGE_KEY) : null;
240
+ const nextSelectedConversationId =
241
+ (storedConversationId && normalizedItems.some((conversation) => conversation.id === storedConversationId)
242
+ ? storedConversationId
243
+ : null) ?? pickFallbackConversationId(normalizedItems);
244
+ setSelectedConversationId(nextSelectedConversationId);
245
  } catch (error) {
246
  const message = error instanceof Error ? error.message : "读取会话记录失败";
247
  toast.error(message);
 
263
  const data = await fetchAccounts();
264
  setAvailableQuota(formatAvailableQuota(data.items));
265
  } catch {
266
+ setAvailableQuota((prev) => (prev === "加载中..." ? "--" : prev));
267
  }
268
  }, []);
269
 
 
273
  }
274
  didLoadQuotaRef.current = true;
275
 
 
 
 
 
276
  const handleFocus = () => {
277
+ void loadQuota();
278
  };
279
 
280
+ void loadQuota();
281
  window.addEventListener("focus", handleFocus);
282
  return () => {
283
  window.removeEventListener("focus", handleFocus);
 
285
  }, [loadQuota]);
286
 
287
  useEffect(() => {
288
+ if (!selectedConversation) {
289
  return;
290
  }
291
 
 
293
  top: resultsViewportRef.current.scrollHeight,
294
  behavior: "smooth",
295
  });
296
+ }, [selectedConversation?.updatedAt, selectedConversation?.turns.length, selectedConversation]);
297
+
298
+ useEffect(() => {
299
+ if (typeof window === "undefined") {
300
+ return;
301
+ }
302
+
303
+ if (selectedConversationId) {
304
+ window.localStorage.setItem(ACTIVE_CONVERSATION_STORAGE_KEY, selectedConversationId);
305
+ } else {
306
+ window.localStorage.removeItem(ACTIVE_CONVERSATION_STORAGE_KEY);
307
+ }
308
+ }, [selectedConversationId]);
309
+
310
+ useEffect(() => {
311
+ if (selectedConversationId && !conversations.some((conversation) => conversation.id === selectedConversationId)) {
312
+ setSelectedConversationId(pickFallbackConversationId(conversations));
313
+ }
314
+ }, [conversations, selectedConversationId]);
315
 
316
  const persistConversation = async (conversation: ImageConversation) => {
317
  setConversations((prev) => {
318
  const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
319
+ return next.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
320
  });
321
  await saveImageConversation(conversation);
322
  };
323
 
324
+ const updateConversation = useCallback(
325
+ async (conversationId: string, updater: (current: ImageConversation | null) => ImageConversation) => {
326
+ let nextConversation: ImageConversation | null = null;
 
 
327
 
328
+ setConversations((prev) => {
329
+ const current = prev.find((item) => item.id === conversationId) ?? null;
330
+ nextConversation = updater(current);
331
+ const next = [nextConversation, ...prev.filter((item) => item.id !== conversationId)];
332
+ return next.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
333
+ });
334
 
335
+ if (nextConversation) {
336
+ await saveImageConversation(nextConversation);
337
+ }
338
+ },
339
+ [],
340
+ );
341
 
342
+ const clearComposerInputs = useCallback(() => {
343
  setImagePrompt("");
344
  setImageCount("1");
345
  setReferenceImageFiles([]);
 
349
  }
350
  }, []);
351
 
352
+ const resetComposer = useCallback(() => {
353
+ setImageMode("generate");
354
+ clearComposerInputs();
355
+ }, [clearComposerInputs]);
356
+
357
  const handleCreateDraft = () => {
358
  setSelectedConversationId(null);
359
  resetComposer();
 
363
  const handleDeleteConversation = async (id: string) => {
364
  const nextConversations = conversations.filter((item) => item.id !== id);
365
  setConversations(nextConversations);
366
+ if (selectedConversationId === id) {
367
+ setSelectedConversationId(pickFallbackConversationId(nextConversations));
368
+ resetComposer();
369
+ }
370
 
371
  try {
372
  await deleteImageConversation(id);
 
383
  await clearImageConversations();
384
  setConversations([]);
385
  setSelectedConversationId(null);
386
+ resetComposer();
387
  toast.success("已清空历史记录");
388
  } catch (error) {
389
  const message = error instanceof Error ? error.message : "清空历史记录失败";
 
404
  dataUrl: await readFileAsDataUrl(file),
405
  })),
406
  );
407
+
408
  setReferenceImageFiles((prev) => [...prev, ...files]);
409
  setReferenceImages((prev) => [...prev, ...previews]);
410
+ setImageMode("edit");
411
  if (fileInputRef.current) {
412
  fileInputRef.current.value = "";
413
  }
 
417
  }
418
  }, []);
419
 
420
+ const handleReferenceImageChange = useCallback(
421
+ async (files: File[]) => {
422
+ if (files.length === 0) {
423
+ return;
424
+ }
 
425
 
426
+ await appendReferenceImages(files);
427
+ },
428
+ [appendReferenceImages],
429
+ );
430
 
431
  const handleRemoveReferenceImage = useCallback((index: number) => {
432
  setReferenceImageFiles((prev) => {
 
439
  setReferenceImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
440
  }, []);
441
 
442
+ const handleContinueEdit = useCallback(
443
+ (conversationId: string, image: StoredImage) => {
444
+ const nextReferenceImage = buildReferenceImageFromResult(
445
+ image,
446
+ `conversation-${conversationId}-${Date.now()}.png`,
447
+ );
448
+ if (!nextReferenceImage) {
449
+ return;
450
+ }
451
+
452
+ setSelectedConversationId(conversationId);
453
+ setImageMode("edit");
454
+ setReferenceImages([nextReferenceImage]);
455
+ setReferenceImageFiles([
456
+ dataUrlToFile(nextReferenceImage.dataUrl, nextReferenceImage.name, nextReferenceImage.type),
457
+ ]);
458
+ setImagePrompt("");
459
+ textareaRef.current?.focus();
460
+ toast.success("已把这张结果图设为当前参考图");
461
+ },
462
+ [],
463
+ );
464
+
465
+ const openLightbox = useCallback((images: ImageLightboxItem[], index: number) => {
466
+ if (images.length === 0) {
467
+ return;
468
+ }
469
+
470
+ setLightboxImages(images);
471
+ setLightboxIndex(Math.max(0, Math.min(index, images.length - 1)));
472
+ setLightboxOpen(true);
473
+ }, []);
474
+
475
+ const runConversationQueue = useCallback(
476
+ async (conversationId: string) => {
477
+ if (activeConversationQueueIds.has(conversationId)) {
478
+ return;
479
+ }
480
+
481
+ const snapshot = conversationsRef.current.find((conversation) => conversation.id === conversationId);
482
+ const queuedTurn = snapshot?.turns.find((turn) => turn.status === "queued");
483
+ if (!snapshot || !queuedTurn) {
484
+ return;
485
+ }
486
+
487
+ activeConversationQueueIds.add(conversationId);
488
+ await updateConversation(conversationId, (current) => {
489
+ const conversation = current ?? snapshot;
490
+ return {
491
+ ...conversation,
492
+ updatedAt: new Date().toISOString(),
493
+ turns: conversation.turns.map((turn) =>
494
+ turn.id === queuedTurn.id
495
+ ? {
496
+ ...turn,
497
+ status: "generating",
498
+ error: undefined,
499
+ }
500
+ : turn,
501
+ ),
502
+ };
503
+ });
504
+
505
+ try {
506
+ const referenceFiles = queuedTurn.referenceImages.map((image, index) =>
507
+ dataUrlToFile(image.dataUrl, image.name || `${queuedTurn.id}-${index + 1}.png`, image.type),
508
+ );
509
+ const pendingImages = queuedTurn.images.filter((image) => image.status === "loading");
510
+
511
+ if (queuedTurn.mode === "edit" && referenceFiles.length === 0) {
512
+ throw new Error("未找到可用于继续编辑的参考图");
513
+ }
514
+
515
+ if (pendingImages.length === 0) {
516
+ const existingFailedCount = queuedTurn.images.filter((image) => image.status === "error").length;
517
+ const existingSuccessCount = queuedTurn.images.filter((image) => image.status === "success").length;
518
+ await updateConversation(conversationId, (current) => {
519
+ const conversation = current ?? snapshot;
520
+ return {
521
+ ...conversation,
522
+ updatedAt: new Date().toISOString(),
523
+ turns: conversation.turns.map((turn) =>
524
+ turn.id === queuedTurn.id
525
+ ? {
526
+ ...turn,
527
+ status: existingFailedCount > 0 ? "error" : existingSuccessCount > 0 ? "success" : "queued",
528
+ error: existingFailedCount > 0 ? `其中 ${existingFailedCount} 张未成功生成` : undefined,
529
+ }
530
+ : turn,
531
+ ),
532
+ };
533
+ });
534
+ return;
535
+ }
536
+
537
+ const tasks = pendingImages.map(async (pendingImage) => {
538
+ try {
539
+ const data =
540
+ queuedTurn.mode === "edit"
541
+ ? await editImage(referenceFiles, queuedTurn.prompt, queuedTurn.model)
542
+ : await generateImage(queuedTurn.prompt, queuedTurn.model);
543
+ const first = data.data?.[0];
544
+ if (!first?.b64_json) {
545
+ throw new Error("未返回图片数据");
546
+ }
547
+
548
+ const nextImage: StoredImage = {
549
+ id: pendingImage.id,
550
+ status: "success",
551
+ b64_json: first.b64_json,
552
+ };
553
+
554
+ await updateConversation(conversationId, (current) => {
555
+ const conversation = current ?? snapshot;
556
+ return {
557
+ ...conversation,
558
+ updatedAt: new Date().toISOString(),
559
+ turns: conversation.turns.map((turn) =>
560
+ turn.id === queuedTurn.id
561
+ ? {
562
+ ...turn,
563
+ images: turn.images.map((image) => (image.id === nextImage.id ? nextImage : image)),
564
+ }
565
+ : turn,
566
+ ),
567
+ };
568
+ });
569
+
570
+ return nextImage;
571
+ } catch (error) {
572
+ const message = error instanceof Error ? error.message : "生成失败";
573
+ const failedImage: StoredImage = {
574
+ id: pendingImage.id,
575
+ status: "error",
576
+ error: message,
577
+ };
578
+
579
+ await updateConversation(conversationId, (current) => {
580
+ const conversation = current ?? snapshot;
581
+ return {
582
+ ...conversation,
583
+ updatedAt: new Date().toISOString(),
584
+ turns: conversation.turns.map((turn) =>
585
+ turn.id === queuedTurn.id
586
+ ? {
587
+ ...turn,
588
+ images: turn.images.map((image) => (image.id === failedImage.id ? failedImage : image)),
589
+ }
590
+ : turn,
591
+ ),
592
+ };
593
+ });
594
+
595
+ throw error;
596
+ }
597
+ });
598
+
599
+ const settled = await Promise.allSettled(tasks);
600
+ const resumedSuccessCount = settled.filter(
601
+ (item): item is PromiseFulfilledResult<StoredImage> => item.status === "fulfilled",
602
+ ).length;
603
+ const resumedFailedCount = settled.length - resumedSuccessCount;
604
+ const existingSuccessCount = queuedTurn.images.filter((image) => image.status === "success").length;
605
+ const existingFailedCount = queuedTurn.images.filter((image) => image.status === "error").length;
606
+ const successCount = existingSuccessCount + resumedSuccessCount;
607
+ const failedCount = existingFailedCount + resumedFailedCount;
608
+
609
+ await updateConversation(conversationId, (current) => {
610
+ const conversation = current ?? snapshot;
611
+ return {
612
+ ...conversation,
613
+ updatedAt: new Date().toISOString(),
614
+ turns: conversation.turns.map((turn) =>
615
+ turn.id === queuedTurn.id
616
+ ? {
617
+ ...turn,
618
+ status: failedCount > 0 ? "error" : "success",
619
+ error: failedCount > 0 ? `其中 ${failedCount} 张未成功生成` : undefined,
620
+ }
621
+ : turn,
622
+ ),
623
+ };
624
+ });
625
+
626
+ await loadQuota();
627
+ } catch (error) {
628
+ const message = error instanceof Error ? error.message : "生成图片失败";
629
+ await updateConversation(conversationId, (current) => {
630
+ const conversation = current ?? snapshot;
631
+ return {
632
+ ...conversation,
633
+ updatedAt: new Date().toISOString(),
634
+ turns: conversation.turns.map((turn) =>
635
+ turn.id === queuedTurn.id
636
+ ? {
637
+ ...turn,
638
+ status: "error",
639
+ error: message,
640
+ images: turn.images.map((image) =>
641
+ image.status === "loading" ? { ...image, status: "error", error: message } : image,
642
+ ),
643
+ }
644
+ : turn,
645
+ ),
646
+ };
647
+ });
648
+ toast.error(message);
649
+ } finally {
650
+ activeConversationQueueIds.delete(conversationId);
651
+ for (const conversation of conversationsRef.current) {
652
+ if (
653
+ !activeConversationQueueIds.has(conversation.id) &&
654
+ conversation.turns.some((turn) => turn.status === "queued")
655
+ ) {
656
+ void runConversationQueue(conversation.id);
657
+ }
658
+ }
659
+ }
660
+ },
661
+ [loadQuota, updateConversation],
662
+ );
663
+
664
+ useEffect(() => {
665
+ for (const conversation of conversations) {
666
+ if (
667
+ !activeConversationQueueIds.has(conversation.id) &&
668
+ conversation.turns.some((turn) => turn.status === "queued")
669
+ ) {
670
+ void runConversationQueue(conversation.id);
671
+ }
672
+ }
673
+ }, [conversations, runConversationQueue]);
674
+
675
+ const handleSubmit = async () => {
676
  const prompt = imagePrompt.trim();
677
  if (!prompt) {
678
  toast.error("请输入提示词");
 
684
  return;
685
  }
686
 
687
+ const targetConversation = selectedConversationId
688
+ ? conversationsRef.current.find((conversation) => conversation.id === selectedConversationId) ?? null
689
+ : null;
690
  const now = new Date().toISOString();
691
+ const conversationId = targetConversation?.id ?? createId();
692
+ const turnId = createId();
693
+ const draftTurn: ImageTurn = {
694
+ id: turnId,
 
 
695
  prompt,
696
  model: imageModel,
697
  mode: imageMode,
698
+ referenceImages: imageMode === "edit" ? referenceImages : [],
699
  count: parsedCount,
700
  images: Array.from({ length: parsedCount }, (_, index) => ({
701
+ id: `${turnId}-${index}`,
702
+ status: "loading" as const,
703
  })),
704
  createdAt: now,
705
+ status: "queued",
706
  };
707
 
708
+ const baseConversation: ImageConversation = targetConversation
709
+ ? {
710
+ ...targetConversation,
711
+ updatedAt: now,
712
+ turns: [...targetConversation.turns, draftTurn],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  }
714
+ : {
715
+ id: conversationId,
716
+ title: buildConversationTitle(prompt),
717
+ createdAt: now,
718
+ updatedAt: now,
719
+ turns: [draftTurn],
720
+ };
 
 
 
 
721
 
722
+ setSelectedConversationId(conversationId);
723
+ clearComposerInputs();
724
+
725
+ await persistConversation(baseConversation);
726
+ void runConversationQueue(conversationId);
727
+
728
+ const targetStats = getConversationStats(baseConversation);
729
+ if (targetStats.running > 0 || targetStats.queued > 1) {
730
+ toast.success("已加入当前对话队列");
731
+ } else if (!targetConversation) {
732
+ toast.success("已创建新对话并开始处理");
733
+ } else {
734
+ toast.success("已发送到当前对话");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  }
736
  };
737
 
 
741
  <ImageSidebar
742
  conversations={conversations}
743
  isLoadingHistory={isLoadingHistory}
 
744
  selectedConversationId={selectedConversationId}
745
  onCreateDraft={handleCreateDraft}
746
  onClearHistory={handleClearHistory}
 
756
  >
757
  <ImageResults
758
  selectedConversation={selectedConversation}
759
+ onOpenLightbox={openLightbox}
760
+ onContinueEdit={handleContinueEdit}
761
  formatConversationTime={formatConversationTime}
762
  />
763
  </div>
 
768
  model={imageModel}
769
  imageCount={imageCount}
770
  availableQuota={availableQuota}
771
+ activeTaskCount={activeTaskCount}
772
+ selectedConversationTitle={selectedConversation?.title ?? null}
773
+ selectedConversationStats={selectedConversation ? selectedConversationStats : null}
774
  referenceImages={referenceImages}
775
  textareaRef={textareaRef}
776
  fileInputRef={fileInputRef}
 
779
  onPromptChange={setImagePrompt}
780
  onModelChange={setImageModel}
781
  onImageCountChange={setImageCount}
782
+ onSubmit={handleSubmit}
783
  onPickReferenceImage={() => fileInputRef.current?.click()}
784
  onReferenceImageChange={handleReferenceImageChange}
785
  onRemoveReferenceImage={handleRemoveReferenceImage}
web/src/store/image-conversations.ts CHANGED
@@ -19,22 +19,29 @@ export type StoredImage = {
19
  error?: string;
20
  };
21
 
22
- export type ImageConversationStatus = "generating" | "success" | "error";
23
 
24
- export type ImageConversation = {
25
  id: string;
26
- title: string;
27
  prompt: string;
28
  model: ImageModel;
29
- mode?: ImageConversationMode;
30
- referenceImages?: StoredReferenceImage[];
31
  count: number;
32
  images: StoredImage[];
33
  createdAt: string;
34
- status: ImageConversationStatus;
35
  error?: string;
36
  };
37
 
 
 
 
 
 
 
 
 
38
  const imageConversationStorage = localforage.createInstance({
39
  name: "chatgpt2api",
40
  storeName: "image_conversations",
@@ -52,23 +59,119 @@ function normalizeStoredImage(image: StoredImage): StoredImage {
52
  };
53
  }
54
 
55
- function normalizeConversation(conversation: ImageConversation): ImageConversation {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return {
57
- ...conversation,
58
- mode: conversation.mode === "edit" ? "edit" : "generate",
59
- images: (conversation.images || []).map(normalizeStoredImage),
 
 
60
  };
61
  }
62
 
63
  export async function listImageConversations(): Promise<ImageConversation[]> {
64
- const items = (await imageConversationStorage.getItem<ImageConversation[]>(IMAGE_CONVERSATIONS_KEY)) || [];
65
- return items.map(normalizeConversation).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
 
 
66
  }
67
 
68
  export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
69
  const items = await listImageConversations();
70
  const nextItems = [normalizeConversation(conversation), ...items.filter((item) => item.id !== conversation.id)];
71
- nextItems.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
72
  await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
73
  }
74
 
 
19
  error?: string;
20
  };
21
 
22
+ export type ImageTurnStatus = "queued" | "generating" | "success" | "error";
23
 
24
+ export type ImageTurn = {
25
  id: string;
 
26
  prompt: string;
27
  model: ImageModel;
28
+ mode: ImageConversationMode;
29
+ referenceImages: StoredReferenceImage[];
30
  count: number;
31
  images: StoredImage[];
32
  createdAt: string;
33
+ status: ImageTurnStatus;
34
  error?: string;
35
  };
36
 
37
+ export type ImageConversation = {
38
+ id: string;
39
+ title: string;
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ turns: ImageTurn[];
43
+ };
44
+
45
  const imageConversationStorage = localforage.createInstance({
46
  name: "chatgpt2api",
47
  storeName: "image_conversations",
 
59
  };
60
  }
61
 
62
+ function normalizeReferenceImage(image: StoredReferenceImage): StoredReferenceImage {
63
+ return {
64
+ name: image.name || "reference.png",
65
+ type: image.type || "image/png",
66
+ dataUrl: image.dataUrl,
67
+ };
68
+ }
69
+
70
+ function dataUrlMimeType(dataUrl: string) {
71
+ const match = dataUrl.match(/^data:(.*?);base64,/);
72
+ return match?.[1] || "image/png";
73
+ }
74
+
75
+ function getLegacyReferenceImages(source: Record<string, unknown>): StoredReferenceImage[] {
76
+ if (Array.isArray(source.referenceImages)) {
77
+ return source.referenceImages
78
+ .filter((image): image is StoredReferenceImage => {
79
+ if (!image || typeof image !== "object") {
80
+ return false;
81
+ }
82
+ const candidate = image as StoredReferenceImage;
83
+ return typeof candidate.dataUrl === "string" && candidate.dataUrl.length > 0;
84
+ })
85
+ .map(normalizeReferenceImage);
86
+ }
87
+
88
+ if (source.sourceImage && typeof source.sourceImage === "object") {
89
+ const image = source.sourceImage as { dataUrl?: unknown; fileName?: unknown };
90
+ if (typeof image.dataUrl === "string" && image.dataUrl) {
91
+ return [
92
+ {
93
+ name: typeof image.fileName === "string" && image.fileName ? image.fileName : "reference.png",
94
+ type: dataUrlMimeType(image.dataUrl),
95
+ dataUrl: image.dataUrl,
96
+ },
97
+ ];
98
+ }
99
+ }
100
+
101
+ return [];
102
+ }
103
+
104
+ function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
105
+ const normalizedImages = Array.isArray(turn.images) ? turn.images.map(normalizeStoredImage) : [];
106
+ const derivedStatus: ImageTurnStatus =
107
+ normalizedImages.some((image) => image.status === "loading")
108
+ ? "generating"
109
+ : normalizedImages.some((image) => image.status === "error")
110
+ ? "error"
111
+ : "success";
112
+
113
+ return {
114
+ id: String(turn.id || `${Date.now()}`),
115
+ prompt: String(turn.prompt || ""),
116
+ model: (turn.model as ImageModel) || "gpt-image-1",
117
+ mode: turn.mode === "edit" ? "edit" : "generate",
118
+ referenceImages: getLegacyReferenceImages(turn),
119
+ count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
120
+ images: normalizedImages,
121
+ createdAt: String(turn.createdAt || new Date().toISOString()),
122
+ status:
123
+ turn.status === "queued" ||
124
+ turn.status === "generating" ||
125
+ turn.status === "success" ||
126
+ turn.status === "error"
127
+ ? turn.status
128
+ : derivedStatus,
129
+ error: typeof turn.error === "string" ? turn.error : undefined,
130
+ };
131
+ }
132
+
133
+ function normalizeConversation(conversation: ImageConversation & Record<string, unknown>): ImageConversation {
134
+ const turns = Array.isArray(conversation.turns)
135
+ ? conversation.turns.map((turn) => normalizeTurn(turn as ImageTurn & Record<string, unknown>))
136
+ : [
137
+ normalizeTurn({
138
+ id: String(conversation.id || `${Date.now()}`),
139
+ prompt: String(conversation.prompt || ""),
140
+ model: (conversation.model as ImageModel) || "gpt-image-1",
141
+ mode: conversation.mode === "edit" ? "edit" : "generate",
142
+ referenceImages: getLegacyReferenceImages(conversation),
143
+ count: Number(conversation.count || 1),
144
+ images: Array.isArray(conversation.images) ? (conversation.images as StoredImage[]) : [],
145
+ createdAt: String(conversation.createdAt || new Date().toISOString()),
146
+ status:
147
+ conversation.status === "generating" || conversation.status === "success" || conversation.status === "error"
148
+ ? conversation.status
149
+ : "success",
150
+ error: typeof conversation.error === "string" ? conversation.error : undefined,
151
+ }),
152
+ ];
153
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
154
+
155
  return {
156
+ id: String(conversation.id || `${Date.now()}`),
157
+ title: String(conversation.title || ""),
158
+ createdAt: String(conversation.createdAt || lastTurn?.createdAt || new Date().toISOString()),
159
+ updatedAt: String(conversation.updatedAt || lastTurn?.createdAt || new Date().toISOString()),
160
+ turns,
161
  };
162
  }
163
 
164
  export async function listImageConversations(): Promise<ImageConversation[]> {
165
+ const items =
166
+ (await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(IMAGE_CONVERSATIONS_KEY)) ||
167
+ [];
168
+ return items.map(normalizeConversation).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
169
  }
170
 
171
  export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
172
  const items = await listImageConversations();
173
  const nextItems = [normalizeConversation(conversation), ...items.filter((item) => item.id !== conversation.id)];
174
+ nextItems.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
175
  await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
176
  }
177