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 +1 -0
- api/image_inputs.py +2 -1
- api/image_tasks.py +3 -0
- services/image_task_service.py +11 -4
- services/protocol/conversation.py +11 -14
- services/protocol/openai_v1_image_edit.py +2 -0
- services/protocol/openai_v1_image_generations.py +2 -0
- services/protocol/openai_v1_response.py +10 -1
- web/src/app/image/components/image-composer.tsx +202 -56
- web/src/app/image/components/image-results.tsx +10 -12
- web/src/app/image/page.tsx +63 -19
- web/src/lib/api.ts +9 -3
- web/src/store/image-conversations.ts +9 -0
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 |
-
|
|
|
|
| 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 |
-
|
| 133 |
-
|
|
|
|
| 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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 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=
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
availableQuota,
|
| 34 |
activeTaskCount,
|
| 35 |
referenceImages,
|
|
@@ -37,7 +81,12 @@ export function ImageComposer({
|
|
| 37 |
fileInputRef,
|
| 38 |
onPromptChange,
|
| 39 |
onImageCountChange,
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 57 |
-
|
| 58 |
-
|
| 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
|
| 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-
|
| 216 |
onClick={() => {
|
| 217 |
if (!isSizeMenuOpen && sizeMenuBtnRef.current) {
|
| 218 |
const rect = sizeMenuBtnRef.current.getBoundingClientRect();
|
| 219 |
-
const menuWidth = Math.min(
|
| 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-[
|
| 232 |
style={{
|
| 233 |
top: sizeMenuPos.top,
|
| 234 |
left: sizeMenuPos.left,
|
| 235 |
transform: "translateY(-100%)",
|
| 236 |
-
width: "min(
|
| 237 |
}}
|
| 238 |
>
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 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.
|
| 279 |
-
turn.
|
| 280 |
-
turn.
|
| 281 |
-
turn.
|
| 282 |
-
turn.
|
| 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.
|
| 306 |
-
turn.
|
| 307 |
-
turn.
|
| 308 |
-
turn.
|
| 309 |
-
turn.
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 349 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 412 |
const storedCount = typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_COUNT_STORAGE_KEY) : null;
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|