HouYunFei commited on
Commit
ea286e5
·
1 Parent(s): 3931350

feat(image): 添加图像质量和尺寸配置功能

Browse files

- 在 AI 接口和图像输入处理中添加 quality 参数支持
- 更新前端 API 调用函数以传递 quality 参数到后端服务
- 重构图像构建器组件,替换原有的 imageSize 配置为更详细的 ratio、tier、width、height 和 quality 设置
- 在对话存储中添加新的图像参数字段并实现数据迁移
- 更新图像结果组件以根据新的 ratio 字段应用正确的宽高比样式
- 扩展图像任务服务以处理新增的质量和尺寸参数
- 在页面组件中实现完整的图像配置状态管理和本地存储功能

api/ai.py CHANGED
@@ -23,6 +23,7 @@ class ImageGenerationRequest(BaseModel):
23
  model: str = "gpt-image-2"
24
  n: int = Field(default=1, ge=1, le=4)
25
  size: str | None = None
 
26
  response_format: str = "b64_json"
27
  history_disabled: bool = True
28
  stream: bool | None = None
 
23
  model: str = "gpt-image-2"
24
  n: int = Field(default=1, ge=1, le=4)
25
  size: str | None = None
26
+ quality: str = "auto"
27
  response_format: str = "b64_json"
28
  history_disabled: bool = True
29
  stream: bool | None = None
api/image_inputs.py CHANGED
@@ -69,6 +69,7 @@ def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
69
  "model": _clean(fields.get("model"), "gpt-image-2"),
70
  "n": _parse_count(fields.get("n")),
71
  "size": _clean(fields.get("size")) or None,
 
72
  "response_format": _clean(fields.get("response_format"), "b64_json"),
73
  "stream": _parse_bool(fields.get("stream")),
74
  }
@@ -170,7 +171,7 @@ async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], li
170
 
171
  form = await request.form()
172
  fields: dict[str, Any] = {}
173
- for key in ("client_task_id", "prompt", "model", "n", "size", "response_format", "stream"):
174
  value = form.get(key)
175
  if isinstance(value, str):
176
  fields[key] = value
 
69
  "model": _clean(fields.get("model"), "gpt-image-2"),
70
  "n": _parse_count(fields.get("n")),
71
  "size": _clean(fields.get("size")) or None,
72
+ "quality": _clean(fields.get("quality"), "auto"),
73
  "response_format": _clean(fields.get("response_format"), "b64_json"),
74
  "stream": _parse_bool(fields.get("stream")),
75
  }
 
171
 
172
  form = await request.form()
173
  fields: dict[str, Any] = {}
174
+ for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
175
  value = form.get(key)
176
  if isinstance(value, str):
177
  fields[key] = value
api/image_tasks.py CHANGED
@@ -16,6 +16,7 @@ class ImageGenerationTaskRequest(BaseModel):
16
  prompt: str = Field(..., min_length=1)
17
  model: str = "gpt-image-2"
18
  size: str | None = None
 
19
 
20
 
21
  def _parse_task_ids(value: str) -> list[str]:
@@ -57,6 +58,7 @@ def create_router() -> APIRouter:
57
  prompt=body.prompt,
58
  model=body.model,
59
  size=body.size,
 
60
  base_url=resolve_image_base_url(request),
61
  )
62
  except ValueError as exc:
@@ -84,6 +86,7 @@ def create_router() -> APIRouter:
84
  prompt=prompt,
85
  model=model,
86
  size=payload["size"],
 
87
  base_url=resolve_image_base_url(request),
88
  images=images,
89
  )
 
16
  prompt: str = Field(..., min_length=1)
17
  model: str = "gpt-image-2"
18
  size: str | None = None
19
+ quality: str = "auto"
20
 
21
 
22
  def _parse_task_ids(value: str) -> list[str]:
 
58
  prompt=body.prompt,
59
  model=body.model,
60
  size=body.size,
61
+ quality=body.quality,
62
  base_url=resolve_image_base_url(request),
63
  )
64
  except ValueError as exc:
 
86
  prompt=prompt,
87
  model=model,
88
  size=payload["size"],
89
+ quality=payload["quality"],
90
  base_url=resolve_image_base_url(request),
91
  images=images,
92
  )
services/image_task_service.py CHANGED
@@ -68,6 +68,7 @@ def _public_task(task: dict[str, Any]) -> dict[str, Any]:
68
  "mode": task.get("mode"),
69
  "model": task.get("model"),
70
  "size": task.get("size"),
 
71
  "created_at": task.get("created_at"),
72
  "updated_at": task.get("updated_at"),
73
  }
@@ -109,13 +110,15 @@ class ImageTaskService:
109
  prompt: str,
110
  model: str,
111
  size: str | None,
112
- base_url: str,
 
113
  ) -> dict[str, Any]:
114
  payload = {
115
  "prompt": prompt,
116
  "model": model,
117
  "n": 1,
118
  "size": size,
 
119
  "response_format": "url",
120
  "base_url": base_url,
121
  }
@@ -129,15 +132,17 @@ class ImageTaskService:
129
  prompt: str,
130
  model: str,
131
  size: str | None,
132
- base_url: str,
133
- images: list[tuple[bytes, str, str]],
 
134
  ) -> dict[str, Any]:
135
  payload = {
136
  "prompt": prompt,
137
- "images": images,
138
  "model": model,
139
  "n": 1,
140
  "size": size,
 
141
  "response_format": "url",
142
  "base_url": base_url,
143
  }
@@ -196,6 +201,7 @@ class ImageTaskService:
196
  "mode": mode,
197
  "model": _clean(payload.get("model"), "gpt-image-2"),
198
  "size": _clean(payload.get("size")),
 
199
  "created_at": now,
200
  "updated_at": now,
201
  }
@@ -334,6 +340,7 @@ class ImageTaskService:
334
  "mode": "edit" if item.get("mode") == "edit" else "generate",
335
  "model": _clean(item.get("model"), "gpt-image-2"),
336
  "size": _clean(item.get("size")),
 
337
  "created_at": _clean(item.get("created_at"), _now_iso()),
338
  "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
339
  }
 
68
  "mode": task.get("mode"),
69
  "model": task.get("model"),
70
  "size": task.get("size"),
71
+ "quality": task.get("quality"),
72
  "created_at": task.get("created_at"),
73
  "updated_at": task.get("updated_at"),
74
  }
 
110
  prompt: str,
111
  model: str,
112
  size: str | None,
113
+ quality: str = "auto",
114
+ base_url: str = "",
115
  ) -> dict[str, Any]:
116
  payload = {
117
  "prompt": prompt,
118
  "model": model,
119
  "n": 1,
120
  "size": size,
121
+ "quality": quality,
122
  "response_format": "url",
123
  "base_url": base_url,
124
  }
 
132
  prompt: str,
133
  model: str,
134
  size: str | None,
135
+ quality: str = "auto",
136
+ base_url: str = "",
137
+ images: list[tuple[bytes, str, str]] | None = None,
138
  ) -> dict[str, Any]:
139
  payload = {
140
  "prompt": prompt,
141
+ "images": images or [],
142
  "model": model,
143
  "n": 1,
144
  "size": size,
145
+ "quality": quality,
146
  "response_format": "url",
147
  "base_url": base_url,
148
  }
 
201
  "mode": mode,
202
  "model": _clean(payload.get("model"), "gpt-image-2"),
203
  "size": _clean(payload.get("size")),
204
+ "quality": _clean(payload.get("quality"), "auto"),
205
  "created_at": now,
206
  "updated_at": now,
207
  }
 
340
  "mode": "edit" if item.get("mode") == "edit" else "generate",
341
  "model": _clean(item.get("model"), "gpt-image-2"),
342
  "size": _clean(item.get("size")),
343
+ "quality": _clean(item.get("quality"), "auto"),
344
  "created_at": _clean(item.get("created_at"), _now_iso()),
345
  "updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
346
  }
services/protocol/conversation.py CHANGED
@@ -133,19 +133,13 @@ def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
133
  return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
134
 
135
 
136
- def build_image_prompt(prompt: str, size: str | None) -> str:
137
- if not size:
138
- return prompt
139
- if size not in {"1:1", "16:9", "9:16", "4:3", "3:4"}:
140
- return f"{prompt.strip()}\n\n输出图片,宽高比为 {size}。"
141
- hint = {
142
- "1:1": "输出为 1:1 正方形构图,主体居中,适合正方形画幅。",
143
- "16:9": "输出为 16:9 横屏构图,适合宽画幅展示。",
144
- "9:16": "输出为 9:16 竖屏构图,适合竖版画幅展示。",
145
- "4:3": "输出为 4:3 比例,兼顾宽度与高度,适合展示画面细节。",
146
- "3:4": "输出为 3:4 比例,纵向构图,适合人物肖像或竖向场景。",
147
- }[size]
148
- return f"{prompt.strip()}\n\n{hint}"
149
 
150
 
151
  def encoding_for_model(model: str):
@@ -215,6 +209,7 @@ class ConversationRequest:
215
  images: list[str] | None = None
216
  n: int = 1
217
  size: str | None = None
 
218
  response_format: str = "b64_json"
219
  base_url: str | None = None
220
  message_as_error: bool = False
@@ -478,12 +473,13 @@ def conversation_events(
478
  prompt: str = "",
479
  images: list[str] | None = None,
480
  size: str | None = None,
 
481
  ) -> Iterator[dict[str, Any]]:
482
  normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
483
  image_model = str(model or "").strip() in IMAGE_MODELS
484
  history_text = "" if image_model else assistant_history_text(normalized)
485
  history_messages = [] if image_model else assistant_history_messages(normalized)
486
- final_prompt = prompt_with_global_system(build_image_prompt(prompt, size)) if image_model else prompt
487
  payloads = backend.stream_conversation(
488
  messages=normalized,
489
  model=model,
@@ -545,6 +541,7 @@ def stream_image_outputs(
545
  model=request.model,
546
  images=request.images or [],
547
  size=request.size,
 
548
  ):
549
  last = event
550
  if event.get("type") == "conversation.delta":
 
133
  return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
134
 
135
 
136
+ def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
137
+ hints = []
138
+ if size:
139
+ hints.append(f"输出图片尺寸为 {size}。")
140
+ if quality:
141
+ hints.append(f"输出图片质量为 {quality}。")
142
+ return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
 
 
 
 
 
 
143
 
144
 
145
  def encoding_for_model(model: str):
 
209
  images: list[str] | None = None
210
  n: int = 1
211
  size: str | None = None
212
+ quality: str = "auto"
213
  response_format: str = "b64_json"
214
  base_url: str | None = None
215
  message_as_error: bool = False
 
473
  prompt: str = "",
474
  images: list[str] | None = None,
475
  size: str | None = None,
476
+ quality: str = "auto",
477
  ) -> Iterator[dict[str, Any]]:
478
  normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
479
  image_model = str(model or "").strip() in IMAGE_MODELS
480
  history_text = "" if image_model else assistant_history_text(normalized)
481
  history_messages = [] if image_model else assistant_history_messages(normalized)
482
+ final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
483
  payloads = backend.stream_conversation(
484
  messages=normalized,
485
  model=model,
 
541
  model=request.model,
542
  images=request.images or [],
543
  size=request.size,
544
+ quality=request.quality,
545
  ):
546
  last = event
547
  if event.get("type") == "conversation.delta":
services/protocol/openai_v1_image_edit.py CHANGED
@@ -18,6 +18,7 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
18
  model = str(body.get("model") or "gpt-image-2")
19
  n = int(body.get("n") or 1)
20
  size = body.get("size")
 
21
  response_format = str(body.get("response_format") or "b64_json")
22
  base_url = str(body.get("base_url") or "") or None
23
  encoded_images = encode_images(images)
@@ -28,6 +29,7 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
28
  model=model,
29
  n=n,
30
  size=size,
 
31
  response_format=response_format,
32
  base_url=base_url,
33
  images=encoded_images,
 
18
  model = str(body.get("model") or "gpt-image-2")
19
  n = int(body.get("n") or 1)
20
  size = body.get("size")
21
+ quality = str(body.get("quality") or "auto")
22
  response_format = str(body.get("response_format") or "b64_json")
23
  base_url = str(body.get("base_url") or "") or None
24
  encoded_images = encode_images(images)
 
29
  model=model,
30
  n=n,
31
  size=size,
32
+ quality=quality,
33
  response_format=response_format,
34
  base_url=base_url,
35
  images=encoded_images,
services/protocol/openai_v1_image_generations.py CHANGED
@@ -15,6 +15,7 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
15
  model = str(body.get("model") or "gpt-image-2")
16
  n = int(body.get("n") or 1)
17
  size = body.get("size")
 
18
  response_format = str(body.get("response_format") or "b64_json")
19
  base_url = str(body.get("base_url") or "") or None
20
  outputs = stream_image_outputs_with_pool(ConversationRequest(
@@ -22,6 +23,7 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
22
  model=model,
23
  n=n,
24
  size=size,
 
25
  response_format=response_format,
26
  base_url=base_url,
27
  message_as_error=True,
 
15
  model = str(body.get("model") or "gpt-image-2")
16
  n = int(body.get("n") or 1)
17
  size = body.get("size")
18
+ quality = str(body.get("quality") or "auto")
19
  response_format = str(body.get("response_format") or "b64_json")
20
  base_url = str(body.get("base_url") or "") or None
21
  outputs = stream_image_outputs_with_pool(ConversationRequest(
 
23
  model=model,
24
  n=n,
25
  size=size,
26
+ quality=quality,
27
  response_format=response_format,
28
  base_url=base_url,
29
  message_as_error=True,
services/protocol/openai_v1_response.py CHANGED
@@ -22,6 +22,13 @@ def is_text_response_request(body: dict[str, Any]) -> bool:
22
  return not has_response_image_generation_tool(body)
23
 
24
 
 
 
 
 
 
 
 
25
  def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
26
  if isinstance(input_value, dict):
27
  images = extract_image_from_message_content(input_value.get("content"))
@@ -199,10 +206,12 @@ def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
199
  images = encode_images([(image_data, "image.png", mime_type)])
200
  else:
201
  images = None
 
202
  image_outputs = stream_image_outputs_with_pool(ConversationRequest(
203
  prompt=prompt,
204
  model=model,
205
- size=None if images else "1:1",
 
206
  response_format="b64_json",
207
  images=images,
208
  ))
 
22
  return not has_response_image_generation_tool(body)
23
 
24
 
25
+ def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
26
+ for tool in body.get("tools") or []:
27
+ if isinstance(tool, dict) and tool.get("type") == "image_generation":
28
+ return tool
29
+ return {}
30
+
31
+
32
  def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
33
  if isinstance(input_value, dict):
34
  images = extract_image_from_message_content(input_value.get("content"))
 
206
  images = encode_images([(image_data, "image.png", mime_type)])
207
  else:
208
  images = None
209
+ tool = response_image_tool(body)
210
  image_outputs = stream_image_outputs_with_pool(ConversationRequest(
211
  prompt=prompt,
212
  model=model,
213
+ size=tool.get("size"),
214
+ quality=str(tool.get("quality") or "auto"),
215
  response_format="b64_json",
216
  images=images,
217
  ))
web/src/app/image/components/image-composer.tsx CHANGED
@@ -1,17 +1,23 @@
1
  "use client";
2
- import { ArrowUp, Check, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react";
3
  import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type RefObject } from "react";
4
 
5
  import { ImageLightbox } from "@/components/image-lightbox";
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
  import { Textarea } from "@/components/ui/textarea";
 
9
  import { cn } from "@/lib/utils";
10
 
11
  type ImageComposerProps = {
12
  prompt: string;
13
  imageCount: string;
14
- imageSize: string;
 
 
 
 
 
15
  availableQuota: string;
16
  activeTaskCount: number;
17
  referenceImages: Array<{ name: string; dataUrl: string }>;
@@ -19,17 +25,55 @@ type ImageComposerProps = {
19
  fileInputRef: RefObject<HTMLInputElement | null>;
20
  onPromptChange: (value: string) => void;
21
  onImageCountChange: (value: string) => void;
22
- onImageSizeChange: (value: string) => void;
 
 
 
 
 
23
  onSubmit: () => void | Promise<void>;
24
  onPickReferenceImage: () => void;
25
  onReferenceImageChange: (files: File[]) => void | Promise<void>;
26
  onRemoveReferenceImage: (index: number) => void;
27
  };
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  export function ImageComposer({
30
  prompt,
31
  imageCount,
32
- imageSize,
 
 
 
 
 
33
  availableQuota,
34
  activeTaskCount,
35
  referenceImages,
@@ -37,7 +81,12 @@ export function ImageComposer({
37
  fileInputRef,
38
  onPromptChange,
39
  onImageCountChange,
40
- onImageSizeChange,
 
 
 
 
 
41
  onSubmit,
42
  onPickReferenceImage,
43
  onReferenceImageChange,
@@ -53,15 +102,9 @@ export function ImageComposer({
53
  () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
54
  [referenceImages],
55
  );
56
- const imageSizeOptions = [
57
- { value: "", label: "未指定" },
58
- { value: "1:1", label: "1:1 (正方形)" },
59
- { value: "16:9", label: "16:9 (横版)" },
60
- { value: "4:3", label: "4:3 (横版)" },
61
- { value: "3:4", label: "3:4 (竖版)" },
62
- { value: "9:16", label: "9:16 (竖版)" },
63
- ];
64
- const imageSizeLabel = imageSizeOptions.find((option) => option.value === imageSize)?.label || "未指定";
65
 
66
  useEffect(() => {
67
  if (!isSizeMenuOpen) {
@@ -192,31 +235,15 @@ export function ImageComposer({
192
  {activeTaskCount}<span className="hidden sm:inline"> 个任务处理中</span>
193
  </div>
194
  )}
195
- <div className="flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-stone-200 bg-white px-2 py-0.5 sm:h-auto sm:gap-2 sm:px-3 sm:py-1">
196
- <span className="hidden text-[11px] font-medium text-stone-700 sm:inline sm:text-sm">张数</span>
197
- <Input
198
- type="number"
199
- inputMode="numeric"
200
- min="1"
201
- max="100"
202
- step="1"
203
- value={imageCount}
204
- onChange={(event) => onImageCountChange(event.target.value)}
205
- className="h-7 w-[40px] border-0 bg-transparent px-0 text-center text-xs font-medium text-stone-700 shadow-none focus-visible:ring-0 sm:h-8 sm:w-[64px] sm:text-sm"
206
- />
207
- </div>
208
- <div
209
- className="relative flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-stone-200 bg-white px-2 py-0.5 text-[11px] sm:h-auto sm:gap-2 sm:px-3 sm:py-1 sm:text-[13px]"
210
- >
211
- <span className="hidden font-medium text-stone-700 sm:inline sm:text-sm">比例</span>
212
  <button
213
  ref={sizeMenuBtnRef}
214
  type="button"
215
- className="flex h-7 w-[78px] items-center justify-between bg-transparent text-left text-xs font-bold text-stone-700 min-[390px]:w-[96px] sm:h-8 sm:w-[132px]"
216
  onClick={() => {
217
  if (!isSizeMenuOpen && sizeMenuBtnRef.current) {
218
  const rect = sizeMenuBtnRef.current.getBoundingClientRect();
219
- const menuWidth = Math.min(186, window.innerWidth - 32);
220
  setSizeMenuPos({ top: rect.top - 8, left: Math.max(16, Math.min(rect.left, window.innerWidth - menuWidth - 16)) });
221
  }
222
  setIsSizeMenuOpen((open) => !open);
@@ -228,34 +255,154 @@ export function ImageComposer({
228
  {isSizeMenuOpen ? (
229
  <div
230
  ref={sizeMenuRef}
231
- className="fixed z-[80] max-h-[45dvh] overflow-y-auto rounded-3xl border border-white/80 bg-white p-2 shadow-[0_24px_80px_-32px_rgba(15,23,42,0.35)]"
232
  style={{
233
  top: sizeMenuPos.top,
234
  left: sizeMenuPos.left,
235
  transform: "translateY(-100%)",
236
- width: "min(186px, calc(100vw - 2rem))",
237
  }}
238
  >
239
- {imageSizeOptions.map((option) => {
240
- const active = option.value === imageSize;
241
- return (
242
- <button
243
- key={option.label}
244
- type="button"
245
- className={cn(
246
- "flex w-full items-center justify-between rounded-2xl px-3 py-2 text-left text-sm text-stone-700 transition hover:bg-stone-100",
247
- active && "bg-stone-100 font-medium text-stone-950",
248
- )}
249
- onClick={() => {
250
- onImageSizeChange(option.value);
251
- setIsSizeMenuOpen(false);
252
- }}
253
- >
254
- <span>{option.label}</span>
255
- {active ? <Check className="size-4" /> : null}
256
- </button>
257
- );
258
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  </div>
260
  ) : null}
261
  </div>
@@ -279,4 +426,3 @@ export function ImageComposer({
279
  </div>
280
  );
281
  }
282
-
 
1
  "use client";
2
+ import { ArrowUp, ChevronDown, ImagePlus, Info, LoaderCircle, RectangleHorizontal, RectangleVertical, Square, X } from "lucide-react";
3
  import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type RefObject } from "react";
4
 
5
  import { ImageLightbox } from "@/components/image-lightbox";
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
  import { Textarea } from "@/components/ui/textarea";
9
+ import type { ImageModel } from "@/lib/api";
10
  import { cn } from "@/lib/utils";
11
 
12
  type ImageComposerProps = {
13
  prompt: string;
14
  imageCount: string;
15
+ imageRatio: string;
16
+ imageTier: string;
17
+ imageWidth: string;
18
+ imageHeight: string;
19
+ imageQuality: string;
20
+ imageModel: ImageModel;
21
  availableQuota: string;
22
  activeTaskCount: number;
23
  referenceImages: Array<{ name: string; dataUrl: string }>;
 
25
  fileInputRef: RefObject<HTMLInputElement | null>;
26
  onPromptChange: (value: string) => void;
27
  onImageCountChange: (value: string) => void;
28
+ onImageRatioChange: (value: string) => void;
29
+ onImageTierChange: (value: string) => void;
30
+ onImageWidthChange: (value: string) => void;
31
+ onImageHeightChange: (value: string) => void;
32
+ onImageQualityChange: (value: string) => void;
33
+ onImageModelChange: (value: ImageModel) => void;
34
  onSubmit: () => void | Promise<void>;
35
  onPickReferenceImage: () => void;
36
  onReferenceImageChange: (files: File[]) => void | Promise<void>;
37
  onRemoveReferenceImage: (index: number) => void;
38
  };
39
 
40
+ const qualityOptions = [
41
+ { value: "auto", label: "自动" },
42
+ { value: "low", label: "低" },
43
+ { value: "medium", label: "中" },
44
+ { value: "high", label: "高" },
45
+ ];
46
+ const modelOptions: Array<{ value: ImageModel; label: string }> = [
47
+ { value: "gpt-image-2", label: "gpt-image-2" },
48
+ { value: "codex-gpt-image-2", label: "codex-gpt-image-2" },
49
+ ];
50
+
51
+ const aspectOptions = [
52
+ { ratio: "1:1", tier: "1k", width: "1024", height: "1024", label: "1:1", icon: Square },
53
+ { ratio: "2:3", tier: "1k", width: "1024", height: "1536", label: "2:3", icon: RectangleVertical },
54
+ { ratio: "3:2", tier: "1k", width: "1536", height: "1024", label: "3:2", icon: RectangleHorizontal },
55
+ { ratio: "3:4", tier: "1k", width: "1024", height: "1365", label: "3:4", icon: RectangleVertical },
56
+ { ratio: "4:3", tier: "1k", width: "1365", height: "1024", label: "4:3", icon: RectangleHorizontal },
57
+ { ratio: "9:16", tier: "1k", width: "1088", height: "1920", label: "9:16", icon: RectangleVertical },
58
+ { ratio: "16:9", tier: "1k", width: "1920", height: "1088", label: "16:9", icon: RectangleHorizontal },
59
+ { ratio: "1:1", tier: "2k", width: "2048", height: "2048", label: "1:1(2k)", icon: Square },
60
+ { ratio: "16:9", tier: "2k", width: "2560", height: "1440", label: "16:9(2k)", icon: RectangleHorizontal },
61
+ { ratio: "9:16", tier: "2k", width: "1440", height: "2560", label: "9:16(2k)", icon: RectangleVertical },
62
+ { ratio: "16:9", tier: "4k", width: "3840", height: "2160", label: "16:9(4k)", icon: RectangleHorizontal },
63
+ { ratio: "9:16", tier: "4k", width: "2160", height: "3840", label: "9:16(4k)", icon: RectangleVertical },
64
+ { ratio: "auto", tier: "auto", width: "1024", height: "1024", label: "auto", icon: null },
65
+ ];
66
+ const countOptions = Array.from({ length: 10 }, (_, index) => String(index + 1));
67
+
68
  export function ImageComposer({
69
  prompt,
70
  imageCount,
71
+ imageRatio,
72
+ imageTier,
73
+ imageWidth,
74
+ imageHeight,
75
+ imageQuality,
76
+ imageModel,
77
  availableQuota,
78
  activeTaskCount,
79
  referenceImages,
 
81
  fileInputRef,
82
  onPromptChange,
83
  onImageCountChange,
84
+ onImageRatioChange,
85
+ onImageTierChange,
86
+ onImageWidthChange,
87
+ onImageHeightChange,
88
+ onImageQualityChange,
89
+ onImageModelChange,
90
  onSubmit,
91
  onPickReferenceImage,
92
  onReferenceImageChange,
 
102
  () => referenceImages.map((image, index) => ({ id: `${image.name}-${index}`, src: image.dataUrl })),
103
  [referenceImages],
104
  );
105
+ const qualityLabel = qualityOptions.find((option) => option.value === imageQuality)?.label || "自动";
106
+ const ratioLabel = imageRatio === "auto" ? "auto" : `${imageRatio}(${imageTier})`;
107
+ const imageSizeLabel = `${qualityLabel} · ${ratioLabel} · ${imageCount || 1} 张`;
 
 
 
 
 
 
108
 
109
  useEffect(() => {
110
  if (!isSizeMenuOpen) {
 
235
  {activeTaskCount}<span className="hidden sm:inline"> 个任务处理中</span>
236
  </div>
237
  )}
238
+ <div className="relative flex h-9 min-w-0 shrink items-center rounded-full bg-transparent text-[11px] sm:h-auto sm:shrink-0 sm:text-[13px]">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  <button
240
  ref={sizeMenuBtnRef}
241
  type="button"
242
+ className="inline-flex h-9 w-fit max-w-[calc(100vw-12rem)] items-center justify-between gap-2 rounded-full bg-stone-100 px-4 text-left text-xs font-semibold text-stone-900 sm:h-10 sm:max-w-none sm:text-sm"
243
  onClick={() => {
244
  if (!isSizeMenuOpen && sizeMenuBtnRef.current) {
245
  const rect = sizeMenuBtnRef.current.getBoundingClientRect();
246
+ const menuWidth = Math.min(460, window.innerWidth - 32);
247
  setSizeMenuPos({ top: rect.top - 8, left: Math.max(16, Math.min(rect.left, window.innerWidth - menuWidth - 16)) });
248
  }
249
  setIsSizeMenuOpen((open) => !open);
 
255
  {isSizeMenuOpen ? (
256
  <div
257
  ref={sizeMenuRef}
258
+ className="fixed z-[80] max-h-[62dvh] overflow-y-auto rounded-[24px] border border-stone-200/70 bg-white p-4 shadow-[0_30px_90px_-34px_rgba(15,23,42,0.42)] sm:max-h-none sm:overflow-visible"
259
  style={{
260
  top: sizeMenuPos.top,
261
  left: sizeMenuPos.left,
262
  transform: "translateY(-100%)",
263
+ width: "min(460px, calc(100vw - 2rem))",
264
  }}
265
  >
266
+ <h3 className="mb-3 text-base font-semibold text-stone-950">图像设置</h3>
267
+ <div className="mb-3">
268
+ <div className="mb-2 text-sm font-medium text-stone-900">模型</div>
269
+ <div className="grid grid-cols-2 gap-2">
270
+ {modelOptions.map((option) => {
271
+ const active = option.value === imageModel;
272
+ return (
273
+ <button
274
+ key={option.value}
275
+ type="button"
276
+ className={cn(
277
+ "h-9 cursor-pointer rounded-full border border-stone-200 bg-white px-3 text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
278
+ active && "border-stone-950 bg-white font-medium text-stone-950",
279
+ )}
280
+ onClick={() => onImageModelChange(option.value)}
281
+ >
282
+ {option.label}
283
+ </button>
284
+ );
285
+ })}
286
+ </div>
287
+ </div>
288
+ <div className="mb-3">
289
+ <div className="mb-2 text-sm font-medium text-stone-900">质量</div>
290
+ <div className="grid grid-cols-4 gap-2">
291
+ {qualityOptions.map((option) => {
292
+ const active = option.value === imageQuality;
293
+ return (
294
+ <button
295
+ key={option.value}
296
+ type="button"
297
+ className={cn(
298
+ "h-9 cursor-pointer rounded-full border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
299
+ active && "border-stone-950 bg-white font-medium text-stone-950",
300
+ )}
301
+ onClick={() => onImageQualityChange(option.value)}
302
+ >
303
+ {option.label}
304
+ </button>
305
+ );
306
+ })}
307
+ </div>
308
+ </div>
309
+ <div className="mb-3">
310
+ <div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-stone-900">
311
+ 尺寸 <Info className="size-3.5 text-stone-400" />
312
+ </div>
313
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
314
+ <div className="flex items-center rounded-lg bg-stone-100 px-3 py-1.5 text-sm text-stone-700">
315
+ <span className="mr-2 text-stone-500">W</span>
316
+ <Input
317
+ type="number"
318
+ inputMode="numeric"
319
+ min="1"
320
+ value={imageWidth}
321
+ onChange={(event) => onImageWidthChange(event.target.value)}
322
+ className="h-7 border-0 bg-transparent px-0 text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
323
+ />
324
+ </div>
325
+ <span className="text-stone-400">×</span>
326
+ <div className="flex items-center rounded-lg bg-stone-100 px-3 py-1.5 text-sm text-stone-700">
327
+ <span className="mr-2 text-stone-500">H</span>
328
+ <Input
329
+ type="number"
330
+ inputMode="numeric"
331
+ min="1"
332
+ value={imageHeight}
333
+ onChange={(event) => onImageHeightChange(event.target.value)}
334
+ className="h-7 border-0 bg-transparent px-0 text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
335
+ />
336
+ </div>
337
+ </div>
338
+ </div>
339
+ <div className="mb-3">
340
+ <div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-stone-900">
341
+ 宽高比 <Info className="size-3.5 text-stone-400" />
342
+ </div>
343
+ <div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
344
+ {aspectOptions.map((option) => {
345
+ const active = option.ratio === imageRatio && option.tier === imageTier && option.width === imageWidth && option.height === imageHeight;
346
+ const Icon = option.icon;
347
+ return (
348
+ <button
349
+ key={`${option.ratio}-${option.tier}-${option.label}`}
350
+ type="button"
351
+ className={cn(
352
+ "flex h-[64px] cursor-pointer flex-col items-center justify-center gap-1 rounded-2xl border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
353
+ active && "border-stone-950",
354
+ )}
355
+ onClick={() => {
356
+ onImageRatioChange(option.ratio);
357
+ onImageTierChange(option.tier);
358
+ onImageWidthChange(option.width);
359
+ onImageHeightChange(option.height);
360
+ }}
361
+ >
362
+ {Icon ? (
363
+ <>
364
+ <Icon className="size-3.5 stroke-[1.8]" />
365
+ <span>{option.label}</span>
366
+ </>
367
+ ) : (
368
+ <span>{option.label}</span>
369
+ )}
370
+ </button>
371
+ );
372
+ })}
373
+ </div>
374
+ </div>
375
+ <div className="border-t border-stone-100 pt-3">
376
+ <div className="mb-2 text-sm font-medium text-stone-900">生成数量</div>
377
+ <div className="grid grid-cols-4 gap-2 sm:grid-cols-5">
378
+ {countOptions.map((option) => {
379
+ const active = imageCount === option;
380
+ return (
381
+ <button
382
+ key={option}
383
+ type="button"
384
+ className={cn(
385
+ "h-9 cursor-pointer rounded-full border border-stone-200 bg-white text-sm text-stone-800 transition hover:border-stone-300 hover:bg-stone-50",
386
+ active && "border-stone-950 bg-white font-medium text-stone-950",
387
+ )}
388
+ onClick={() => onImageCountChange(option)}
389
+ >
390
+ {option} 张
391
+ </button>
392
+ );
393
+ })}
394
+ <Input
395
+ type="number"
396
+ inputMode="numeric"
397
+ min="1"
398
+ max="100"
399
+ step="1"
400
+ value={imageCount}
401
+ onChange={(event) => onImageCountChange(event.target.value)}
402
+ className="h-9 rounded-full border-stone-200 bg-white px-3 text-center text-sm font-medium text-stone-800 shadow-none focus-visible:ring-0"
403
+ />
404
+ </div>
405
+ </div>
406
  </div>
407
  ) : null}
408
  </div>
 
426
  </div>
427
  );
428
  }
 
web/src/app/image/components/image-results.tsx CHANGED
@@ -275,12 +275,11 @@ export function ImageResults({
275
  className={cn(
276
  "break-inside-avoid overflow-hidden rounded-xl border border-rose-200 bg-rose-50 sm:rounded-none",
277
  "aspect-square",
278
- turn.size === "1:1" && "sm:aspect-square",
279
- turn.size === "16:9" && "sm:aspect-video",
280
- turn.size === "9:16" && "sm:aspect-[9/16]",
281
- turn.size === "4:3" && "sm:aspect-[4/3]",
282
- turn.size === "3:4" && "sm:aspect-[3/4]",
283
- !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "sm:aspect-square",
284
  )}
285
  >
286
  <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">
@@ -302,12 +301,11 @@ export function ImageResults({
302
  key={image.id}
303
  className={cn(
304
  "break-inside-avoid overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 sm:rounded-none",
305
- turn.size === "1:1" && "aspect-square",
306
- turn.size === "16:9" && "aspect-video",
307
- turn.size === "9:16" && "aspect-[9/16]",
308
- turn.size === "4:3" && "aspect-[4/3]",
309
- turn.size === "3:4" && "aspect-[3/4]",
310
- !["1:1", "16:9", "9:16", "4:3", "3:4"].includes(turn.size) && "aspect-square",
311
  )}
312
  >
313
  <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">
 
275
  className={cn(
276
  "break-inside-avoid overflow-hidden rounded-xl border border-rose-200 bg-rose-50 sm:rounded-none",
277
  "aspect-square",
278
+ turn.ratio === "1:1" && "sm:aspect-square",
279
+ turn.ratio === "16:9" && "sm:aspect-video",
280
+ turn.ratio === "9:16" && "sm:aspect-[9/16]",
281
+ turn.ratio === "4:3" && "sm:aspect-[4/3]",
282
+ turn.ratio === "3:4" && "sm:aspect-[3/4]",
 
283
  )}
284
  >
285
  <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">
 
301
  key={image.id}
302
  className={cn(
303
  "break-inside-avoid overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100/80 sm:rounded-none",
304
+ turn.ratio === "1:1" && "aspect-square",
305
+ turn.ratio === "16:9" && "aspect-video",
306
+ turn.ratio === "9:16" && "aspect-[9/16]",
307
+ turn.ratio === "4:3" && "aspect-[4/3]",
308
+ turn.ratio === "3:4" && "aspect-[3/4]",
 
309
  )}
310
  >
311
  <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">
web/src/app/image/page.tsx CHANGED
@@ -23,6 +23,7 @@ import {
23
  fetchAccounts,
24
  fetchImageTasks,
25
  type Account,
 
26
  type ImageTask,
27
  } from "@/lib/api";
28
  import { useAuthGuard } from "@/lib/use-auth-guard";
@@ -43,12 +44,20 @@ import {
43
  } from "@/store/image-conversations";
44
 
45
  const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
46
- const IMAGE_SIZE_STORAGE_KEY = "chatgpt2api:image_last_size";
 
 
 
47
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
48
 
49
  function clampImageCount(value: string) {
50
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
51
  }
 
 
 
 
 
52
  const activeConversationQueueIds = new Set<string>();
53
 
54
  function buildConversationTitle(prompt: string) {
@@ -345,8 +354,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
345
  const fileInputRef = useRef<HTMLInputElement>(null);
346
 
347
  const [imagePrompt, setImagePrompt] = useState("");
348
- const [imageCount, setImageCount] = useState("1");
349
- const [imageSize, setImageSize] = useState("");
 
 
 
 
 
350
  const [isHistoryOpen, setIsHistoryOpen] = useState(false);
351
  const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
352
  const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
@@ -408,9 +422,17 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
408
 
409
  const loadHistory = async () => {
410
  try {
411
- const storedSize = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_SIZE_STORAGE_KEY) : null;
 
 
 
412
  const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
413
- setImageSize(storedSize || "");
 
 
 
 
 
414
  setImageCount(storedCount ? clampImageCount(storedCount) : "1");
415
 
416
  const items = await listImageConversations();
@@ -502,12 +524,11 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
502
  return;
503
  }
504
 
505
- if (imageSize) {
506
- window.localStorage.setItem(IMAGE_SIZE_STORAGE_KEY, imageSize);
507
- return;
508
- }
509
- window.localStorage.removeItem(IMAGE_SIZE_STORAGE_KEY);
510
- }, [imageSize]);
511
 
512
  useEffect(() => {
513
  if (typeof window !== "undefined" && parsedCount > 0) {
@@ -779,7 +800,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
779
  setSelectedConversationId(conversationId);
780
  setImagePrompt(turn.prompt);
781
  setImageCount(String(Math.max(1, turn.count || turn.images.length || 1)));
782
- setImageSize(turn.size);
 
 
 
 
 
 
783
  setReferenceImages(turn.referenceImages);
784
  setReferenceImageFiles(
785
  turn.referenceImages.map((image) => dataUrlToFile(image.dataUrl, image.name, image.type)),
@@ -890,8 +917,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
890
  pendingImages.map((image) => {
891
  const taskId = image.taskId || image.id;
892
  return activeTurn.mode === "edit"
893
- ? createImageEditTask(taskId, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size)
894
- : createImageGenerationTask(taskId, activeTurn.prompt, activeTurn.model, activeTurn.size);
895
  }),
896
  );
897
  await applyTasks(submitted);
@@ -919,8 +946,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
919
  const resubmitted = await Promise.all(
920
  missingImages.map((image) =>
921
  activeTurn.mode === "edit"
922
- ? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size)
923
- : createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size),
924
  ),
925
  );
926
  if (resubmitted.length > 0) {
@@ -991,6 +1018,9 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
991
  referenceImages: sourceTurn.referenceImages,
992
  count,
993
  size: sourceTurn.size,
 
 
 
994
  images: createLoadingImages(nextTurnId, count),
995
  createdAt: now,
996
  status: "queued",
@@ -1085,14 +1115,18 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1085
  const now = new Date().toISOString();
1086
  const conversationId = targetConversation?.id ?? createId();
1087
  const turnId = createId();
 
1088
  const draftTurn: ImageTurn = {
1089
  id: turnId,
1090
  prompt,
1091
- model: "gpt-image-2",
1092
  mode: effectiveImageMode,
1093
  referenceImages: effectiveImageMode === "edit" ? referenceImages : [],
1094
  count: parsedCount,
1095
  size: imageSize,
 
 
 
1096
  images: createLoadingImages(turnId, parsedCount),
1097
  createdAt: now,
1098
  status: "queued",
@@ -1223,7 +1257,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1223
  <ImageComposer
1224
  prompt={imagePrompt}
1225
  imageCount={imageCount}
1226
- imageSize={imageSize}
 
 
 
 
 
1227
  availableQuota={availableQuota}
1228
  activeTaskCount={activeTaskCount}
1229
  referenceImages={referenceImages}
@@ -1231,7 +1270,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1231
  fileInputRef={fileInputRef}
1232
  onPromptChange={setImagePrompt}
1233
  onImageCountChange={(value) => setImageCount(value ? clampImageCount(value) : "")}
1234
- onImageSizeChange={setImageSize}
 
 
 
 
 
1235
  onSubmit={handleSubmit}
1236
  onPickReferenceImage={() => fileInputRef.current?.click()}
1237
  onReferenceImageChange={handleReferenceImageChange}
 
23
  fetchAccounts,
24
  fetchImageTasks,
25
  type Account,
26
+ type ImageModel,
27
  type ImageTask,
28
  } from "@/lib/api";
29
  import { useAuthGuard } from "@/lib/use-auth-guard";
 
44
  } from "@/store/image-conversations";
45
 
46
  const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
47
+ const IMAGE_RATIO_STORAGE_KEY = "chatgpt2api:image_last_ratio";
48
+ const IMAGE_TIER_STORAGE_KEY = "chatgpt2api:image_last_tier";
49
+ const IMAGE_QUALITY_STORAGE_KEY = "chatgpt2api:image_last_quality";
50
+ const IMAGE_MODEL_STORAGE_KEY = "chatgpt2api:image_last_model";
51
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
52
 
53
  function clampImageCount(value: string) {
54
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
55
  }
56
+ function parseImageSize(size: string) {
57
+ const match = size.match(/^(\d+)x(\d+)$/);
58
+ return match ? { width: match[1], height: match[2] } : { width: "1024", height: "1024" };
59
+ }
60
+
61
  const activeConversationQueueIds = new Set<string>();
62
 
63
  function buildConversationTitle(prompt: string) {
 
354
  const fileInputRef = useRef<HTMLInputElement>(null);
355
 
356
  const [imagePrompt, setImagePrompt] = useState("");
357
+ const [imageCount, setImageCount] = useState("3");
358
+ const [imageRatio, setImageRatio] = useState("auto");
359
+ const [imageTier, setImageTier] = useState("1k");
360
+ const [imageWidth, setImageWidth] = useState("1024");
361
+ const [imageHeight, setImageHeight] = useState("1024");
362
+ const [imageQuality, setImageQuality] = useState("auto");
363
+ const [imageModel, setImageModel] = useState<ImageModel>("gpt-image-2");
364
  const [isHistoryOpen, setIsHistoryOpen] = useState(false);
365
  const [referenceImageFiles, setReferenceImageFiles] = useState<File[]>([]);
366
  const [referenceImages, setReferenceImages] = useState<StoredReferenceImage[]>([]);
 
422
 
423
  const loadHistory = async () => {
424
  try {
425
+ const storedRatio = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_RATIO_STORAGE_KEY) : null;
426
+ const storedTier = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_TIER_STORAGE_KEY) : null;
427
+ const storedQuality = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_QUALITY_STORAGE_KEY) : null;
428
+ const storedModel = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_MODEL_STORAGE_KEY) : null;
429
  const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
430
+ setImageRatio(storedRatio || "1:1");
431
+ setImageTier(storedTier || "1k");
432
+ setImageWidth("1024");
433
+ setImageHeight("1024");
434
+ setImageQuality(storedQuality || "auto");
435
+ setImageModel(storedModel === "codex-gpt-image-2" ? "codex-gpt-image-2" : "gpt-image-2");
436
  setImageCount(storedCount ? clampImageCount(storedCount) : "1");
437
 
438
  const items = await listImageConversations();
 
524
  return;
525
  }
526
 
527
+ window.localStorage.setItem(IMAGE_RATIO_STORAGE_KEY, imageRatio);
528
+ window.localStorage.setItem(IMAGE_TIER_STORAGE_KEY, imageTier);
529
+ window.localStorage.setItem(IMAGE_QUALITY_STORAGE_KEY, imageQuality);
530
+ window.localStorage.setItem(IMAGE_MODEL_STORAGE_KEY, imageModel);
531
+ }, [imageRatio, imageTier, imageQuality, imageModel]);
 
532
 
533
  useEffect(() => {
534
  if (typeof window !== "undefined" && parsedCount > 0) {
 
800
  setSelectedConversationId(conversationId);
801
  setImagePrompt(turn.prompt);
802
  setImageCount(String(Math.max(1, turn.count || turn.images.length || 1)));
803
+ setImageRatio(turn.ratio);
804
+ setImageTier(turn.tier);
805
+ const parsedSize = parseImageSize(turn.size);
806
+ setImageWidth(parsedSize.width);
807
+ setImageHeight(parsedSize.height);
808
+ setImageQuality(turn.quality);
809
+ setImageModel(turn.model);
810
  setReferenceImages(turn.referenceImages);
811
  setReferenceImageFiles(
812
  turn.referenceImages.map((image) => dataUrlToFile(image.dataUrl, image.name, image.type)),
 
917
  pendingImages.map((image) => {
918
  const taskId = image.taskId || image.id;
919
  return activeTurn.mode === "edit"
920
+ ? createImageEditTask(taskId, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
921
+ : createImageGenerationTask(taskId, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality);
922
  }),
923
  );
924
  await applyTasks(submitted);
 
946
  const resubmitted = await Promise.all(
947
  missingImages.map((image) =>
948
  activeTurn.mode === "edit"
949
+ ? createImageEditTask(image.taskId || image.id, referenceFiles, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality)
950
+ : createImageGenerationTask(image.taskId || image.id, activeTurn.prompt, activeTurn.model, activeTurn.size, activeTurn.quality),
951
  ),
952
  );
953
  if (resubmitted.length > 0) {
 
1018
  referenceImages: sourceTurn.referenceImages,
1019
  count,
1020
  size: sourceTurn.size,
1021
+ ratio: sourceTurn.ratio,
1022
+ tier: sourceTurn.tier,
1023
+ quality: sourceTurn.quality,
1024
  images: createLoadingImages(nextTurnId, count),
1025
  createdAt: now,
1026
  status: "queued",
 
1115
  const now = new Date().toISOString();
1116
  const conversationId = targetConversation?.id ?? createId();
1117
  const turnId = createId();
1118
+ const imageSize = `${imageWidth || 1024}x${imageHeight || 1024}`;
1119
  const draftTurn: ImageTurn = {
1120
  id: turnId,
1121
  prompt,
1122
+ model: imageModel,
1123
  mode: effectiveImageMode,
1124
  referenceImages: effectiveImageMode === "edit" ? referenceImages : [],
1125
  count: parsedCount,
1126
  size: imageSize,
1127
+ ratio: imageRatio,
1128
+ tier: imageTier,
1129
+ quality: imageQuality,
1130
  images: createLoadingImages(turnId, parsedCount),
1131
  createdAt: now,
1132
  status: "queued",
 
1257
  <ImageComposer
1258
  prompt={imagePrompt}
1259
  imageCount={imageCount}
1260
+ imageRatio={imageRatio}
1261
+ imageTier={imageTier}
1262
+ imageWidth={imageWidth}
1263
+ imageHeight={imageHeight}
1264
+ imageQuality={imageQuality}
1265
+ imageModel={imageModel}
1266
  availableQuota={availableQuota}
1267
  activeTaskCount={activeTaskCount}
1268
  referenceImages={referenceImages}
 
1270
  fileInputRef={fileInputRef}
1271
  onPromptChange={setImagePrompt}
1272
  onImageCountChange={(value) => setImageCount(value ? clampImageCount(value) : "")}
1273
+ onImageRatioChange={setImageRatio}
1274
+ onImageTierChange={setImageTier}
1275
+ onImageWidthChange={setImageWidth}
1276
+ onImageHeightChange={setImageHeight}
1277
+ onImageQualityChange={setImageQuality}
1278
+ onImageModelChange={setImageModel}
1279
  onSubmit={handleSubmit}
1280
  onPickReferenceImage={() => fileInputRef.current?.click()}
1281
  onReferenceImageChange={handleReferenceImageChange}
web/src/lib/api.ts CHANGED
@@ -210,6 +210,7 @@ export type ImageTask = {
210
  mode: "generate" | "edit";
211
  model?: ImageModel;
212
  size?: string;
 
213
  created_at: string;
214
  updated_at: string;
215
  data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
@@ -359,7 +360,7 @@ export async function updateAccount(
359
  });
360
  }
361
 
362
- export async function generateImage(prompt: string, model?: ImageModel, size?: string) {
363
  return httpRequest<ImageResponse>(
364
  "/v1/images/generations",
365
  {
@@ -368,6 +369,7 @@ export async function generateImage(prompt: string, model?: ImageModel, size?: s
368
  prompt,
369
  ...(model ? { model } : {}),
370
  ...(size ? { size } : {}),
 
371
  n: 1,
372
  response_format: "b64_json",
373
  },
@@ -375,7 +377,7 @@ export async function generateImage(prompt: string, model?: ImageModel, size?: s
375
  );
376
  }
377
 
378
- export async function editImage(files: File | File[], prompt: string, model?: ImageModel, size?: string) {
379
  const formData = new FormData();
380
  const uploadFiles = Array.isArray(files) ? files : [files];
381
 
@@ -389,6 +391,7 @@ export async function editImage(files: File | File[], prompt: string, model?: Im
389
  if (size) {
390
  formData.append("size", size);
391
  }
 
392
  formData.append("n", "1");
393
 
394
  return httpRequest<ImageResponse>(
@@ -400,7 +403,7 @@ export async function editImage(files: File | File[], prompt: string, model?: Im
400
  );
401
  }
402
 
403
- export async function createImageGenerationTask(clientTaskId: string, prompt: string, model?: ImageModel, size?: string) {
404
  return httpRequest<ImageTask>("/api/image-tasks/generations", {
405
  method: "POST",
406
  body: {
@@ -408,6 +411,7 @@ export async function createImageGenerationTask(clientTaskId: string, prompt: st
408
  prompt,
409
  ...(model ? { model } : {}),
410
  ...(size ? { size } : {}),
 
411
  },
412
  });
413
  }
@@ -418,6 +422,7 @@ export async function createImageEditTask(
418
  prompt: string,
419
  model?: ImageModel,
420
  size?: string,
 
421
  ) {
422
  const formData = new FormData();
423
  const uploadFiles = Array.isArray(files) ? files : [files];
@@ -433,6 +438,7 @@ export async function createImageEditTask(
433
  if (size) {
434
  formData.append("size", size);
435
  }
 
436
 
437
  return httpRequest<ImageTask>("/api/image-tasks/edits", {
438
  method: "POST",
 
210
  mode: "generate" | "edit";
211
  model?: ImageModel;
212
  size?: string;
213
+ quality?: string;
214
  created_at: string;
215
  updated_at: string;
216
  data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }>;
 
360
  });
361
  }
362
 
363
+ export async function generateImage(prompt: string, model?: ImageModel, size?: string, quality = "auto") {
364
  return httpRequest<ImageResponse>(
365
  "/v1/images/generations",
366
  {
 
369
  prompt,
370
  ...(model ? { model } : {}),
371
  ...(size ? { size } : {}),
372
+ quality,
373
  n: 1,
374
  response_format: "b64_json",
375
  },
 
377
  );
378
  }
379
 
380
+ export async function editImage(files: File | File[], prompt: string, model?: ImageModel, size?: string, quality = "auto") {
381
  const formData = new FormData();
382
  const uploadFiles = Array.isArray(files) ? files : [files];
383
 
 
391
  if (size) {
392
  formData.append("size", size);
393
  }
394
+ formData.append("quality", quality);
395
  formData.append("n", "1");
396
 
397
  return httpRequest<ImageResponse>(
 
403
  );
404
  }
405
 
406
+ export async function createImageGenerationTask(clientTaskId: string, prompt: string, model?: ImageModel, size?: string, quality = "auto") {
407
  return httpRequest<ImageTask>("/api/image-tasks/generations", {
408
  method: "POST",
409
  body: {
 
411
  prompt,
412
  ...(model ? { model } : {}),
413
  ...(size ? { size } : {}),
414
+ quality,
415
  },
416
  });
417
  }
 
422
  prompt: string,
423
  model?: ImageModel,
424
  size?: string,
425
+ quality = "auto",
426
  ) {
427
  const formData = new FormData();
428
  const uploadFiles = Array.isArray(files) ? files : [files];
 
438
  if (size) {
439
  formData.append("size", size);
440
  }
441
+ formData.append("quality", quality);
442
 
443
  return httpRequest<ImageTask>("/api/image-tasks/edits", {
444
  method: "POST",
web/src/store/image-conversations.ts CHANGED
@@ -32,6 +32,9 @@ export type ImageTurn = {
32
  referenceImages: StoredReferenceImage[];
33
  count: number;
34
  size: string;
 
 
 
35
  images: StoredImage[];
36
  createdAt: string;
37
  status: ImageTurnStatus;
@@ -136,6 +139,9 @@ function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
136
  referenceImages: getLegacyReferenceImages(turn),
137
  count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
138
  size: typeof turn.size === "string" ? turn.size : "",
 
 
 
139
  images: normalizedImages,
140
  createdAt: String(turn.createdAt || new Date().toISOString()),
141
  status:
@@ -163,6 +169,9 @@ function normalizeConversation(conversation: ImageConversation & Record<string,
163
  referenceImages: getLegacyReferenceImages(conversation),
164
  count: Number(conversation.count || 1),
165
  size: typeof conversation.size === "string" ? conversation.size : "",
 
 
 
166
  images: Array.isArray(conversation.images) ? (conversation.images as StoredImage[]) : [],
167
  createdAt: String(conversation.createdAt || new Date().toISOString()),
168
  status:
 
32
  referenceImages: StoredReferenceImage[];
33
  count: number;
34
  size: string;
35
+ ratio: string;
36
+ tier: string;
37
+ quality: string;
38
  images: StoredImage[];
39
  createdAt: string;
40
  status: ImageTurnStatus;
 
139
  referenceImages: getLegacyReferenceImages(turn),
140
  count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
141
  size: typeof turn.size === "string" ? turn.size : "",
142
+ ratio: typeof turn.ratio === "string" && turn.ratio ? turn.ratio : "1:1",
143
+ tier: typeof turn.tier === "string" && turn.tier ? turn.tier : "1k",
144
+ quality: typeof turn.quality === "string" && turn.quality ? turn.quality : "auto",
145
  images: normalizedImages,
146
  createdAt: String(turn.createdAt || new Date().toISOString()),
147
  status:
 
169
  referenceImages: getLegacyReferenceImages(conversation),
170
  count: Number(conversation.count || 1),
171
  size: typeof conversation.size === "string" ? conversation.size : "",
172
+ ratio: typeof conversation.ratio === "string" && conversation.ratio ? conversation.ratio : "1:1",
173
+ tier: typeof conversation.tier === "string" && conversation.tier ? conversation.tier : "1k",
174
+ quality: typeof conversation.quality === "string" && conversation.quality ? conversation.quality : "auto",
175
  images: Array.isArray(conversation.images) ? (conversation.images as StoredImage[]) : [],
176
  createdAt: String(conversation.createdAt || new Date().toISOString()),
177
  status: