diff --git a/README.md b/README.md index 933a810ed..e8db30c53 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Image Studio > 开源图像生成 / 编辑客户端 · Wails(Go + React/TS) 桌面端 + Android WebView 壳层 · -> 支持 Responses API SSE 保活与标准 Images API +> 支持 Responses API SSE / WebSocket mode 与标准 Images API ![license](https://img.shields.io/badge/license-AGPLv3-b22222) ![go](https://img.shields.io/badge/go-%3E%3D1.25-00ADD8) @@ -13,7 +13,7 @@ ![wails](https://img.shields.io/badge/wails-v2.12-DF0000) ![platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20android-lightgrey) -Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推理在 Cloudflare / Nginx 后面容易遇到的 524/504 断连问题。Responses API 模式通过 SSE 持续接收事件来保持连接活跃;Images API 模式则兼容标准 `/v1/images/generations` 与 `/v1/images/edits`。 +Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推理在 Cloudflare / Nginx 后面容易遇到的 524/504 断连问题。Responses API 模式支持 `HTTP SSE` 与 `WebSocket mode` 两种传输;Images API 模式则兼容标准 `/v1/images/generations` 与 `/v1/images/edits`。 项目不内置任何默认上游。首次启动需要你自己填写 BASE_URL、API Key、文本模型与图像模型。 diff --git a/android-shell/app/build.gradle.kts b/android-shell/app/build.gradle.kts index 2d0e13a88..d47b87c5d 100644 --- a/android-shell/app/build.gradle.kts +++ b/android-shell/app/build.gradle.kts @@ -211,6 +211,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") implementation("androidx.webkit:webkit:1.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250517") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt index 14f968159..8af100d51 100644 --- a/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt +++ b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt @@ -35,12 +35,20 @@ import java.net.URL import java.text.SimpleDateFormat import java.util.Date import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import java.util.Locale import kotlin.concurrent.thread import kotlin.math.max import kotlin.math.roundToInt import android.graphics.Bitmap import android.graphics.BitmapFactory +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.Response class AndroidImageStudioBridge( private val context: Context, @@ -53,6 +61,7 @@ class AndroidImageStudioBridge( private var pendingOpenImageRequestId: String? = null private var pendingImportHistoryRequestId: String? = null private val httpRequests = ConcurrentHashMap() + private val webSocketRequests = ConcurrentHashMap() @Volatile private var fullscreen = false companion object { @@ -124,6 +133,10 @@ class AndroidImageStudioBridge( val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少 HTTP 请求参数") runHttpRequestText(requestId, payload) } + "ResponsesWebSocketRequest" -> { + val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少 WebSocket 请求参数") + runResponsesWebSocketRequest(requestId, payload) + } "ProbeUpstream" -> { val payload = args.optJSONObject(0) ?: throw IllegalArgumentException("缺少测活参数") runProbeUpstream(requestId, payload) @@ -132,6 +145,10 @@ class AndroidImageStudioBridge( cancelHttpRequest(args.optString(0)) null } + "CancelWebSocketRequest" -> { + cancelWebSocketRequest(args.optString(0)) + null + } "Vibrate" -> { vibrate(args.optLong(0, 50L)) null @@ -514,6 +531,8 @@ class AndroidImageStudioBridge( val apiKey = payload.optString("apiKey").trim() val proxyMode = payload.optString("proxyMode", "system") val proxyUrl = payload.optString("proxyURL", "") + val apiMode = payload.optString("apiMode", "responses") + val responsesTransport = payload.optString("responsesTransport", "sse") if (apiKey.isBlank()) throw IllegalArgumentException("API Key 不能为空") thread(name = "image-studio-probe-${requestId.take(12)}") { try { @@ -553,7 +572,36 @@ class AndroidImageStudioBridge( "displayName" to item.optString("name").trim(), ) } - resolve(requestId, mapOf("modelCount" to data.length(), "models" to models)) + val result = mutableMapOf( + "modelCount" to data.length(), + "models" to models, + ) + if (apiMode == "responses" && responsesTransport == "websocket") { + result["responsesTransport"] = "websocket" + val probePayload = JSONObject() + .put("type", "response.create") + .put("model", "gpt-5.5") + .put("store", false) + .put("generate", false) + .put("input", JSONArray().put( + JSONObject() + .put("role", "user") + .put("content", JSONArray().put( + JSONObject() + .put("type", "input_text") + .put("text", "health check") + )) + )) + .put("tools", JSONArray()) + try { + probeResponsesWebSocket(baseUrl, apiKey, probePayload.toString(), proxyMode, proxyUrl) + result["responsesTransportOK"] = true + } catch (wsError: Exception) { + result["responsesTransportOK"] = false + result["responsesTransportError"] = wsError.message ?: wsError.javaClass.simpleName + } + } + resolve(requestId, result) } catch (error: Exception) { reject(requestId, error.message ?: error.javaClass.simpleName) } @@ -607,6 +655,192 @@ class AndroidImageStudioBridge( httpRequests.remove(requestKey)?.disconnect() } + private fun cancelWebSocketRequest(requestKey: String) { + webSocketRequests.remove(requestKey)?.cancel() + } + + private fun runResponsesWebSocketRequest(requestId: String, payload: JSONObject): Nothing { + val requestKey = payload.optString("requestKey").ifBlank { requestId } + val baseUrl = validateProbeBaseUrl(payload.optString("baseURL")) + val apiKey = payload.optString("apiKey").trim() + val proxyMode = payload.optString("proxyMode", "system") + val proxyUrl = payload.optString("proxyURL", "") + val payloadText = payload.optString("payload") + if (apiKey.isBlank()) throw IllegalArgumentException("API Key 不能为空") + if (payloadText.isBlank()) throw IllegalArgumentException("WebSocket payload 不能为空") + val wsUrl = responsesWebSocketUrl(baseUrl) + val client = okHttpClientForWebSocket(proxyMode, proxyUrl) + val rawLines = mutableListOf() + val streamResult = arrayOfNulls(1) + val settled = java.util.concurrent.atomic.AtomicBoolean(false) + val socket = client.newWebSocket( + Request.Builder() + .url(wsUrl) + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("User-Agent", "image-studio-android") + .build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocketRequests[requestKey] = webSocket + webSocket.send(payloadText) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val line = text.trim() + if (line.isBlank()) return + rawLines += "data: $line" + if (streamResult[0] == null) { + streamResult[0] = extractNativeHttpStreamResult(line) + } + buildNativeHttpStreamProgressPayload(line)?.let { progressPayload -> + emitNativeProgress(requestKey, progressPayload) + } + try { + val parsed = JSONObject(line) + when (parsed.optString("type")) { + "response.completed" -> { + if (settled.compareAndSet(false, true)) { + val rawBody = rawLines.joinToString("\n") + val rawPath = if (rawBody.isNotBlank()) writeNativeHttpRawBody(rawBody, "text/plain", requestKey) else "" + resolve(requestId, mapOf( + "status" to 200, + "body" to "", + "contentType" to "text/event-stream", + "rawPath" to rawPath, + "resultImageB64" to (streamResult[0]?.imageB64 ?: ""), + "revisedPrompt" to (streamResult[0]?.revisedPrompt ?: ""), + "sourceEvent" to (streamResult[0]?.sourceEvent ?: "final"), + )) + webSocket.close(1000, "completed") + webSocketRequests.remove(requestKey) + } + } + "error" -> { + if (settled.compareAndSet(false, true)) { + reject(requestId, line) + webSocket.cancel() + webSocketRequests.remove(requestKey) + } + } + } + } catch (_: Exception) { + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + webSocketRequests.remove(requestKey) + if (settled.compareAndSet(false, true)) { + reject(requestId, t.message ?: "websocket failure") + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + webSocketRequests.remove(requestKey) + if (settled.compareAndSet(false, true)) { + if (streamResult[0]?.imageB64?.isNotBlank() == true) { + val rawBody = rawLines.joinToString("\n") + val rawPath = if (rawBody.isNotBlank()) writeNativeHttpRawBody(rawBody, "text/plain", requestKey) else "" + resolve(requestId, mapOf( + "status" to 200, + "body" to "", + "contentType" to "text/event-stream", + "rawPath" to rawPath, + "resultImageB64" to (streamResult[0]?.imageB64 ?: ""), + "revisedPrompt" to (streamResult[0]?.revisedPrompt ?: ""), + "sourceEvent" to (streamResult[0]?.sourceEvent ?: "final"), + )) + } else { + reject(requestId, if (reason.isNotBlank()) reason else "websocket closed before final image") + } + } + } + } + ) + webSocketRequests[requestKey] = socket + throw EarlyResolve() + } + + private fun responsesWebSocketUrl(baseUrl: String): String { + val uri = URI(baseUrl) + val scheme = when (uri.scheme?.lowercase(Locale.US)) { + "https" -> "wss" + "http" -> "ws" + else -> throw IllegalArgumentException("BASE_URL 仅支持 http:// 或 https://") + } + val path = (uri.path ?: "").trimEnd('/') + val normalizedPath = if (path.isBlank()) "/v1/responses" else "$path/v1/responses" + return URI(scheme, uri.userInfo, uri.host, uri.port, normalizedPath, null, null).toString() + } + + private fun okHttpClientForWebSocket(proxyMode: String, proxyUrl: String): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(generationConnectTimeoutMs.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS) + .readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) + .pingInterval(25, java.util.concurrent.TimeUnit.SECONDS) + when (normalizeProxyMode(proxyMode)) { + "none" -> builder.proxy(Proxy.NO_PROXY) + "custom" -> builder.proxy(parseCustomProxy(proxyUrl)) + } + return builder.build() + } + + private fun probeResponsesWebSocket( + baseUrl: String, + apiKey: String, + payload: String, + proxyMode: String, + proxyUrl: String, + ) { + val wsUrl = responsesWebSocketUrl(baseUrl) + val client = okHttpClientForWebSocket(proxyMode, proxyUrl) + val done = CountDownLatch(1) + val errorRef = AtomicReference(null) + val socket = client.newWebSocket( + Request.Builder() + .url(wsUrl) + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("User-Agent", "image-studio-android") + .build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(payload) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val parsed = JSONObject(text) + when (parsed.optString("type")) { + "response.created", "response.completed" -> { + webSocket.close(1000, "probe completed") + done.countDown() + } + "error" -> { + errorRef.set(text) + webSocket.cancel() + done.countDown() + } + } + } catch (_: Exception) { + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + errorRef.set(t.message ?: "websocket failure") + done.countDown() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + done.countDown() + } + } + ) + if (!done.await(20, TimeUnit.SECONDS)) { + socket.cancel() + throw IllegalStateException("Responses WebSocket 探测超时") + } + errorRef.get()?.let { throw IllegalStateException(it) } + } + private fun validateProbeBaseUrl(raw: String): String { val cleaned = raw.trim().trimEnd('/') if (cleaned.isBlank()) throw IllegalArgumentException("未配置上游 BASE_URL") diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md index b7d49dbce..10bb44519 100644 --- a/docs/batch-img2img/README.md +++ b/docs/batch-img2img/README.md @@ -28,20 +28,22 @@ ## 使用流程 -1. 选择输入文件夹。 -2. 应用扫描当前目录中的图片文件。 -3. 确认扫描数量和示例文件名。 -4. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。 -5. 选择输出位置。 +1. 选择输入文件夹并扫描当前目录中的图片文件。 + 或者直接选择多张图片,把它们加入当前批处理队列。 +2. 确认当前批处理队列中的图片数量和示例文件名。 +3. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。 +4. 选择输出位置。 - 默认保存回原目录:每张结果回到对应源图所在目录。 - 独立输出路径:所有结果统一保存到你选定的目录。 -6. 设置并发数。 -7. 点击 `编辑` 开始处理。 +5. 设置并发数。 +6. 点击 `编辑` 开始处理。 ## 输入规则 当前版本只处理你选择的这一层目录,不递归子目录。 +如果你不想走目录模式,也可以直接选择多张图片加入批处理队列。 + 支持的输入图片格式: - `png` @@ -89,6 +91,41 @@ - 实际可并发数量仍受当前上游 profile 的并发限制约束。 - 如果并发开得太高,应用可能会自动关闭流式预览,优先保证最终图完整。 +## 不同比例源图的处理 + +如果一个目录里的源图宽高比不一致,但你仍然希望: + +- 所有图片使用相同提示词 +- 所有图片使用同一档分辨率 +- 每张图仍然尽量保持各自源图的比例 + +可以在批处理面板里开启: + +`按源图比例自动适配` + +然后再选择统一的分辨率档位,例如 `1K`、`2K`、`4K`。 + +这时应用会对每一张源图分别计算: + +- 比例:取该源图自身宽高比 +- 分辨率:取你在批处理面板里指定的统一档位,并沿用应用当前对 `1K / 2K / 4K` 的尺寸换算规则 + +例如: + +- 横图会得到横向尺寸 +- 竖图会得到纵向尺寸 +- 它们都仍然共享同一个提示词和同一个分辨率档位 + +这里的 `1K / 2K / 4K` 仍然遵循应用当前的尺寸语义: + +- 内置比例会落到内置尺寸矩阵 +- 非内置比例会按该档位的像素面积参考值推导具体宽高 + +注意: + +- 这是批处理模式下的额外尺寸策略。 +- 它不会改变普通图生图里 `Auto` 比例与 `size=auto` 的原有语义。 + ## 参数继承 批处理会复用你当前图生图控制面板里的参数,包括: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e0eb6abf4..d48a9297d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,6 +27,8 @@ 常见原因是 relay 只“接受字段”但没有真正透传,或目标模型本身就不支持这些能力。 - `Responses API` 一直失败: 常见原因是上游根本没实现 `/v1/responses`、会缓冲 SSE,或者 Key 只有 image-only 权限。 +- `Responses WebSocket mode` 握手失败: + 如果 raw 日志或上游报错里出现 `WebSocket upgrade required (Upgrade: websocket)`,说明当前链路把 WebSocket 请求降成了普通 HTTP,请求没有真正完成 Upgrade。常见原因是中转站 / 反向代理 / 网关没有正确放行 `Upgrade: websocket`,这种情况应直接切回 `HTTP SSE` 或修上游代理配置。 - Android 看不到“目录”或保存位置和桌面端不一样: 常见原因是 Android 走 `MediaStore` / 系统相册,不会像桌面文件管理器那样直接暴露同一个物理目录。 - 浏览器预览里看到 `memory://...`: @@ -99,6 +101,46 @@ log/ 4. 从历史项查看 raw 响应,确认是 Cloudflare 524/504、上游 JSON 5xx,还是模型权限错误。 5. 如果上游本身不支持 SSE 或会缓冲 SSE,换上游或走 Images API。 +## Responses WebSocket 握手失败 / `Upgrade: websocket` + +如果你在 raw 日志里看到类似: + +```text +websocket handshake failed: HTTP 400: WebSocket upgrade required (Upgrade: websocket) +``` + +或上游直接返回: + +```json +{ + "error": { + "message": "WebSocket upgrade required (Upgrade: websocket)", + "type": "invalid_request_error" + } +} +``` + +这代表问题发生在 **握手阶段**,不是生图中途断流: + +1. 客户端已经尝试走 `Responses WebSocket mode` +2. 但链路上的某一层把请求当成了普通 HTTP +3. 上游没有收到正确的 `Upgrade: websocket` + +常见原因: + +- Nginx / 网关把 `Connection` 头清空了 +- `Upgrade` 头没有透传 +- 某些 HTTP/2 / H2C 转发链路不支持或没有正确桥接 WebSocket Upgrade +- 上游只实现了普通 `/v1/responses`,没有真正实现 WS `/v1/responses` + +处理建议: + +1. 先把当前 profile 的传输切回 `HTTP SSE` +2. 如果同一个 `BASE_URL + Key` 在 Codex 自己的 WS 能正常,而本应用不行,优先检查: + - 你在本应用里填的是不是站点根地址,而不是已经带 `/v1` 的地址 + - 真实链路上是否存在额外的代理层 +3. 如果上游明说支持 WS,但仍返回这个错误,优先让服务商检查 `Upgrade: websocket` 是否真的到达了后端 + ## `model not found` / 401 / 403 Responses API: diff --git a/go-cli/go.mod b/go-cli/go.mod index f03478587..658827041 100644 --- a/go-cli/go.mod +++ b/go-cli/go.mod @@ -3,3 +3,5 @@ module github.com/yuanhua/image-gptcodex go 1.25.5 toolchain go1.26.3 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go-cli/pkg/client/client.go b/go-cli/pkg/client/client.go index 0f9c06a35..47bcd7e0f 100644 --- a/go-cli/pkg/client/client.go +++ b/go-cli/pkg/client/client.go @@ -173,7 +173,11 @@ func responsesAPIWithRetries( } for attempt := 1; attempt <= MaxAttempts; attempt++ { - rawPath := filepath.Join(outputDir, fmt.Sprintf("sse-response-%s-attempt%d.txt", timestamp, attempt)) + rawPrefix := "sse-response" + if normalizeResponsesTransport(opts.ResponsesTransport) == ResponsesTransportWebSocket { + rawPrefix = "ws-response" + } + rawPath := filepath.Join(outputDir, fmt.Sprintf("%s-%s-attempt%d.txt", rawPrefix, timestamp, attempt)) lastPath = rawPath onLog(fmt.Sprintf("第 %d/%d 次请求...", attempt, MaxAttempts)) @@ -182,7 +186,15 @@ func responsesAPIWithRetries( return ImageResult{}, lastPath, fmt.Errorf("create raw response file: %w", err) } - result, reqErr := RequestAndExtractWithPartial(ctx, transport, opts, f, onProgress, onPartial) + var ( + result ImageResult + reqErr error + ) + if normalizeResponsesTransport(opts.ResponsesTransport) == ResponsesTransportWebSocket { + result, reqErr = requestResponsesWithWebSocketReplay(ctx, opts, f, onProgress, onPartial, attempt, onLog) + } else { + result, reqErr = RequestAndExtractWithPartial(ctx, transport, opts, f, onProgress, onPartial) + } f.Close() if reqErr == nil { diff --git a/go-cli/pkg/client/client_test.go b/go-cli/pkg/client/client_test.go index a26d66692..209998421 100644 --- a/go-cli/pkg/client/client_test.go +++ b/go-cli/pkg/client/client_test.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -13,6 +14,8 @@ import ( "strings" "testing" "time" + + "github.com/gorilla/websocket" ) // startSSEServer returns an httptest.Server that streams the given lines as SSE. @@ -396,3 +399,125 @@ func (i *injectingTransport) Stream(ctx context.Context, req Request, rawSink io req.URL = i.url + "/v1/responses" return i.inner.Stream(ctx, req, rawSink, progress) } + +func TestBuildResponsesWebSocketCreatePayloadStripsHTTPOnlyFields(t *testing.T) { + raw, err := BuildPayload(Options{ + APIKey: "sk-test", + Prompt: "hello", + BaseURL: "https://relay.example.com", + TextModelID: "gpt-5.5", + ImageModelID: "gpt-image-2", + }) + if err != nil { + t.Fatalf("BuildPayload: %v", err) + } + wsPayload, err := buildResponsesWebSocketCreatePayload(raw) + if err != nil { + t.Fatalf("buildResponsesWebSocketCreatePayload: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(wsPayload, &decoded); err != nil { + t.Fatalf("json decode: %v", err) + } + if decoded["type"] != "response.create" { + t.Fatalf("type = %v, want response.create", decoded["type"]) + } + if _, ok := decoded["stream"]; ok { + t.Fatalf("websocket payload must not include stream") + } +} + +func TestRequestResponsesOverWebSocketSucceedsWhenFinalArrivesBeforeDisconnect(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + t.Fatalf("expected websocket upgrade") + } + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + defer conn.Close() + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(msg, &payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload["type"] != "response.create" { + t.Fatalf("type = %v, want response.create", payload["type"]) + } + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"resp_123"}}`)) + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.output_item.done","item":{"type":"image_generation_call","result":"`+pngB64+`"}}`)) + _ = conn.Close() + })) + defer srv.Close() + + result, err := requestResponsesWithWebSocketReplay( + context.Background(), + Options{ + APIKey: "sk-test", + Prompt: "hello", + BaseURL: strings.Replace(srv.URL, "http://", "http://", 1), + ResponsesTransport: ResponsesTransportWebSocket, + }, + io.Discard, + nil, + nil, + 1, + nil, + ) + if err != nil { + t.Fatalf("requestResponsesWithWebSocketReplay: %v", err) + } + if result.ImageB64 != pngB64 { + t.Fatalf("result image mismatch") + } +} + +func TestRequestResponsesWithWebSocketReplayFallsBackToSSEOnHandshakeFailure(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfallback")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/responses" && strings.EqualFold(r.Header.Get("Upgrade"), "websocket"): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUpgradeRequired) + _, _ = w.Write([]byte(`{"error":{"message":"WebSocket upgrade required (Upgrade: websocket)"}}`)) + case r.URL.Path == "/v1/responses": + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(w, `data: {"type":"response.created"}`) + fmt.Fprintln(w, `data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"`+pngB64+`"}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var raw bytes.Buffer + result, err := requestResponsesWithWebSocketReplay( + context.Background(), + Options{ + APIKey: "sk-test", + Prompt: "hello", + BaseURL: srv.URL, + ResponsesTransport: ResponsesTransportWebSocket, + }, + &raw, + nil, + nil, + 1, + nil, + ) + if err != nil { + t.Fatalf("requestResponsesWithWebSocketReplay: %v", err) + } + if result.ImageB64 != pngB64 { + t.Fatalf("result image mismatch") + } + if !strings.Contains(raw.String(), "websocket-error-1") { + t.Fatalf("expected raw log to record websocket handshake failure, got %q", raw.String()) + } +} diff --git a/go-cli/pkg/client/responses_transport.go b/go-cli/pkg/client/responses_transport.go new file mode 100644 index 000000000..a9570a037 --- /dev/null +++ b/go-cli/pkg/client/responses_transport.go @@ -0,0 +1,12 @@ +package client + +import "strings" + +func normalizeResponsesTransport(value ResponsesTransport) ResponsesTransport { + switch strings.ToLower(strings.TrimSpace(string(value))) { + case string(ResponsesTransportWebSocket): + return ResponsesTransportWebSocket + default: + return ResponsesTransportSSE + } +} diff --git a/go-cli/pkg/client/responses_websocket.go b/go-cli/pkg/client/responses_websocket.go new file mode 100644 index 000000000..3fb2dd165 --- /dev/null +++ b/go-cli/pkg/client/responses_websocket.go @@ -0,0 +1,506 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +const websocketQuickReconnectAttempts = 1 + +type responsesWebSocketFallbackError struct { + err error +} + +func (e *responsesWebSocketFallbackError) Error() string { + if e == nil || e.err == nil { + return "responses websocket fallback" + } + return e.err.Error() +} + +func (e *responsesWebSocketFallbackError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +type ResponsesWSRunStateSnapshot struct { + AttemptIndex int + SocketEpoch int + CreatedAt time.Time + LastActivityAt time.Time + RequestPayload []byte + ResponseID string + LatestEventType string + PartialPreviewCount int + HasFinalImage bool + Cancelled bool + Completed bool +} + +type ProbeResponsesWebSocketOptions struct { + BaseURL string + APIKey string + Proxy ProxyConfig + Model string +} + +func requestResponsesWithWebSocketReplay( + ctx context.Context, + opts Options, + rawSink io.Writer, + onProgress func(stage string, elapsedSeconds int, bytesReceived int64), + onPartial func(PartialImage), + attempt int, + onLog func(string), +) (ImageResult, error) { + httpPayload, err := BuildPayload(opts) + if err != nil { + return ImageResult{}, err + } + payload, err := buildResponsesWebSocketCreatePayload(httpPayload) + if err != nil { + return ImageResult{}, err + } + baseURL := strings.TrimSpace(opts.BaseURL) + if baseURL == "" { + baseURL = strings.TrimSpace(BaseURL) + } + if baseURL == "" { + return ImageResult{}, errors.New("未配置上游 BASE_URL,请在「设置 → 上游 BASE_URL」中填入兼容 Responses API 的中转站地址") + } + snapshot := &ResponsesWSRunStateSnapshot{ + AttemptIndex: attempt, + CreatedAt: time.Now(), + LastActivityAt: time.Now(), + RequestPayload: payload, + } + startedAt := time.Now() + progressDone := make(chan struct{}) + defer close(progressDone) + go func() { + if onProgress == nil { + return + } + ticker := time.NewTicker(time.Duration(StatusIntervalSecond) * time.Second) + defer ticker.Stop() + lastBytes := int64(0) + lastStage := "等待接口响应" + for { + select { + case <-ctx.Done(): + return + case <-progressDone: + return + case <-ticker.C: + if snapshot.LatestEventType != "" { + lastStage = SummarizeSSELine(`data: {"type":"` + snapshot.LatestEventType + `"}`) + if lastStage == "" { + lastStage = "模型处理中" + } + } + onProgress(lastStage, int(time.Since(startedAt).Seconds()), lastBytes) + } + } + }() + if onLog != nil { + onLog("使用 Responses WebSocket mode 发起请求...") + } + result, err := requestResponsesOverWebSocket(ctx, baseURL, opts.APIKey, opts.Proxy, payload, rawSink, onPartial, snapshot) + var fallbackErr *responsesWebSocketFallbackError + if errors.As(err, &fallbackErr) { + if onLog != nil { + onLog("Responses WebSocket 握手失败,当前上游不兼容该 WS 路径,自动切回 HTTP SSE...") + } + transport, terr := PickTransportWithProxy(opts.Proxy) + if terr != nil { + return ImageResult{}, terr + } + return RequestAndExtractWithPartial(ctx, transport, opts, rawSink, onProgress, onPartial) + } + if err != nil && !snapshot.HasFinalImage && onLog != nil { + onLog("WebSocket 连接中断,正在重新连接并重放本次生成...") + } + return result, err +} + +func NormalizeTextModel(modelID string) string { + trimmed := strings.TrimSpace(modelID) + if trimmed == "" { + return TextModel + } + return trimmed +} + +func NormalizeProxyTransportValue(value string) string { + return string(normalizeResponsesTransport(ResponsesTransport(value))) +} + +func ProbeResponsesWebSocket(ctx context.Context, opts ProbeResponsesWebSocketOptions) error { + model := NormalizeTextModel(opts.Model) + payload, err := json.Marshal(map[string]any{ + "type": "response.create", + "model": model, + "store": false, + "input": []map[string]any{ + { + "role": "user", + "content": []map[string]any{ + {"type": "input_text", "text": "health check"}, + }, + }, + }, + "tools": []map[string]any{}, + "generate": false, + }) + if err != nil { + return err + } + return probeResponsesWebSocketOnce(ctx, opts.BaseURL, opts.APIKey, opts.Proxy, payload) +} + +func requestResponsesOverWebSocket( + ctx context.Context, + baseURL string, + apiKey string, + proxy ProxyConfig, + payload []byte, + rawSink io.Writer, + onPartial func(PartialImage), + snapshot *ResponsesWSRunStateSnapshot, +) (ImageResult, error) { + if snapshot == nil { + snapshot = &ResponsesWSRunStateSnapshot{} + } + snapshot.CreatedAt = time.Now() + snapshot.LastActivityAt = snapshot.CreatedAt + snapshot.RequestPayload = append(snapshot.RequestPayload[:0], payload...) + + var lastErr error + for reconnect := 0; reconnect <= websocketQuickReconnectAttempts; reconnect++ { + snapshot.SocketEpoch = reconnect + 1 + if reconnect > 0 { + if rawSink != nil { + _, _ = io.WriteString(rawSink, fmt.Sprintf("--- websocket-reconnect-%d ---\n", reconnect)) + } + } + result, err := requestResponsesOverWebSocketOnce(ctx, baseURL, apiKey, proxy, payload, rawSink, onPartial, snapshot) + if err == nil { + return result, nil + } + if rawSink != nil { + _, _ = io.WriteString(rawSink, fmt.Sprintf("--- websocket-error-%d: %v ---\n", snapshot.SocketEpoch, err)) + } + if isResponsesWebSocketFallbackError(err) { + return ImageResult{}, &responsesWebSocketFallbackError{err: err} + } + lastErr = err + if snapshot.HasFinalImage { + return result, nil + } + if reconnect < websocketQuickReconnectAttempts { + continue + } + } + return ImageResult{}, lastErr +} + +func requestResponsesOverWebSocketOnce( + ctx context.Context, + baseURL string, + apiKey string, + proxy ProxyConfig, + payload []byte, + rawSink io.Writer, + onPartial func(PartialImage), + snapshot *ResponsesWSRunStateSnapshot, +) (ImageResult, error) { + wsURL, err := responsesWebSocketURL(baseURL) + if err != nil { + return ImageResult{}, err + } + dialer, err := newResponsesWebSocketDialer(proxy) + if err != nil { + return ImageResult{}, err + } + headers := http.Header{} + headers.Set("Authorization", "Bearer "+apiKey) + headers.Set("User-Agent", UserAgent()) + headers.Set("Accept", "application/json") + + conn, resp, err := dialer.DialContext(ctx, wsURL, headers) + if err != nil { + return ImageResult{}, describeWebSocketDialError(err, resp) + } + defer conn.Close() + + conn.SetPingHandler(func(appData string) error { + snapshot.LastActivityAt = time.Now() + return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second)) + }) + conn.SetPongHandler(func(string) error { + snapshot.LastActivityAt = time.Now() + return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + }) + _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + + done := make(chan struct{}) + defer close(done) + go responsesWebSocketKeepalive(ctx, conn, done) + + if rawSink != nil { + _, _ = io.WriteString(rawSink, fmt.Sprintf("--- websocket-session-%d ---\n", snapshot.SocketEpoch)) + } + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return ImageResult{}, fmt.Errorf("websocket write: %w", err) + } + + collector := newResponseCollectorWithPartial(rawSink, onPartial) + for { + if ctx.Err() != nil { + snapshot.Cancelled = true + return ImageResult{}, ctx.Err() + } + msgType, data, err := conn.ReadMessage() + if err != nil { + if res, rerr := collector.result(); rerr == nil && res.ImageB64 != "" { + snapshot.HasFinalImage = true + snapshot.Completed = true + return res, nil + } + return ImageResult{}, fmt.Errorf("websocket read: %w", err) + } + if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage { + continue + } + snapshot.LastActivityAt = time.Now() + line := bytes.TrimSpace(data) + if len(line) == 0 { + continue + } + _, _ = collector.Write(append([]byte("data: "), append(line, '\n')...)) + var ev Event + if err := decodeEvent(string(line), &ev); err == nil { + if evType, _ := ev["type"].(string); evType != "" { + snapshot.LatestEventType = evType + switch evType { + case "response.created": + if responseAny, ok := ev["response"].(map[string]any); ok { + if id, _ := responseAny["id"].(string); id != "" { + snapshot.ResponseID = id + } + } + if snapshot.ResponseID == "" { + if id, _ := ev["response_id"].(string); id != "" { + snapshot.ResponseID = id + } + } + case "response.image_generation_call.partial_image": + snapshot.PartialPreviewCount++ + case "response.output_item.done": + itemAny, _ := ev["item"] + item, _ := itemAny.(map[string]any) + if item != nil { + if itemType, _ := item["type"].(string); itemType == "image_generation_call" { + if result, _ := item["result"].(string); result != "" { + snapshot.HasFinalImage = true + } + } + } + case "response.completed": + snapshot.Completed = true + return collector.result() + case "error": + return ImageResult{}, fmt.Errorf("%s", DescribeProblem(string(line))) + } + } + } + if snapshot.HasFinalImage { + if res, rerr := collector.result(); rerr == nil && res.ImageB64 != "" { + snapshot.Completed = true + return res, nil + } + } + } +} + +func responsesWebSocketKeepalive(ctx context.Context, conn *websocket.Conn, done <-chan struct{}) { + ticker := time.NewTicker(25 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case <-ticker.C: + _ = conn.WriteControl(websocket.PingMessage, []byte("image-studio"), time.Now().Add(5*time.Second)) + } + } +} + +func responsesWebSocketURL(baseURL string) (string, error) { + normalized, err := ValidateBaseURL(baseURL) + if err != nil { + return "", err + } + parsed, err := url.Parse(normalized) + if err != nil { + return "", err + } + switch parsed.Scheme { + case "https": + parsed.Scheme = "wss" + case "http": + parsed.Scheme = "ws" + default: + return "", fmt.Errorf("BASE_URL 仅支持 http:// 或 https://") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/responses" + return parsed.String(), nil +} + +func newResponsesWebSocketDialer(proxy ProxyConfig) (*websocket.Dialer, error) { + proxyFn, err := proxyFunc(proxy) + if err != nil { + return nil, err + } + return &websocket.Dialer{ + Proxy: proxyFn, + HandshakeTimeout: 30 * time.Second, + }, nil +} + +func buildResponsesWebSocketCreatePayload(httpPayload []byte) ([]byte, error) { + var body map[string]any + if err := json.Unmarshal(httpPayload, &body); err != nil { + return nil, fmt.Errorf("decode responses payload: %w", err) + } + delete(body, "stream") + delete(body, "background") + body["type"] = "response.create" + out, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode websocket payload: %w", err) + } + return out, nil +} + +func probeResponsesWebSocketOnce( + ctx context.Context, + baseURL string, + apiKey string, + proxy ProxyConfig, + payload []byte, +) error { + wsURL, err := responsesWebSocketURL(baseURL) + if err != nil { + return err + } + dialer, err := newResponsesWebSocketDialer(proxy) + if err != nil { + return err + } + headers := http.Header{} + headers.Set("Authorization", "Bearer "+apiKey) + headers.Set("User-Agent", UserAgent()) + headers.Set("Accept", "application/json") + conn, resp, err := dialer.DialContext(ctx, wsURL, headers) + if err != nil { + return describeWebSocketDialError(err, resp) + } + defer conn.Close() + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return fmt.Errorf("websocket write: %w", err) + } + for { + if ctx.Err() != nil { + return ctx.Err() + } + _, data, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("websocket read: %w", err) + } + var ev Event + if err := decodeEvent(string(bytes.TrimSpace(data)), &ev); err != nil { + continue + } + switch evType, _ := ev["type"].(string); evType { + case "response.created", "response.completed": + return nil + case "error": + return fmt.Errorf("%s", DescribeProblem(string(data))) + } + } +} + +func describeWebSocketDialError(err error, resp *http.Response) error { + if err == nil { + return nil + } + if resp != nil { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + summary := summarizeWebSocketHandshakeBody(body) + if summary != "" { + return fmt.Errorf("websocket handshake failed: HTTP %d: %s", resp.StatusCode, summary) + } + return fmt.Errorf("websocket handshake failed: HTTP %d", resp.StatusCode) + } + var handshakeErr websocket.HandshakeError + if errors.As(err, &handshakeErr) { + return fmt.Errorf("websocket handshake failed: %w", err) + } + text := err.Error() + if strings.Contains(strings.ToLower(text), "bad handshake") { + return fmt.Errorf("websocket handshake failed: %s", text) + } + return fmt.Errorf("websocket dial: %w", err) +} + +func summarizeWebSocketHandshakeBody(body []byte) string { + text := strings.TrimSpace(string(body)) + if text == "" { + return "" + } + var parsed struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &parsed); err == nil { + if msg := strings.TrimSpace(parsed.Error.Message); msg != "" { + text = msg + } else if msg := strings.TrimSpace(parsed.Message); msg != "" { + text = msg + } + } + lower := strings.ToLower(text) + if strings.Contains(lower, "websocket upgrade required") || strings.Contains(lower, "upgrade: websocket") { + return "上游要求 WebSocket Upgrade,但当前链路没有正确转发 Upgrade: websocket。通常是中转站 / 反向代理 / 网关不支持或没放行 Responses WebSocket,建议切回 HTTP SSE。" + } + if len(text) > 160 { + return text[:160] + } + return text +} + +func isResponsesWebSocketFallbackError(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(err.Error()) + return strings.Contains(text, "websocket handshake failed") +} diff --git a/go-cli/pkg/client/types.go b/go-cli/pkg/client/types.go index 6657daa03..03bebf1a2 100644 --- a/go-cli/pkg/client/types.go +++ b/go-cli/pkg/client/types.go @@ -114,6 +114,13 @@ const ( APIModeImages APIMode = "images" ) +type ResponsesTransport string + +const ( + ResponsesTransportSSE ResponsesTransport = "sse" + ResponsesTransportWebSocket ResponsesTransport = "websocket" +) + type RequestPolicy string const ( @@ -153,6 +160,10 @@ type Options struct { // Empty string is treated as APIModeResponses for back-compat. APIMode APIMode + // ResponsesTransport selects how Responses API requests are transported. + // Empty string is treated as ResponsesTransportSSE for back-compat. + ResponsesTransport ResponsesTransport + // RequestPolicy selects whether to send only OpenAI-documented fields // ("openai", default) or also include relay-oriented extension fields // such as seed / negative_prompt ("compat"). diff --git a/go-cli/pkg/client/url.go b/go-cli/pkg/client/url.go index 2ac94b9df..b379818aa 100644 --- a/go-cli/pkg/client/url.go +++ b/go-cli/pkg/client/url.go @@ -15,6 +15,11 @@ func ValidateBaseURL(raw string) (string, error) { if cleaned == "" { return "", fmt.Errorf("未配置上游 BASE_URL") } + cleaned = strings.TrimSuffix(cleaned, "/v1") + cleaned = strings.TrimRight(cleaned, "/") + if cleaned == "" { + return "", fmt.Errorf("未配置上游 BASE_URL") + } u, err := url.Parse(cleaned) if err != nil { return "", fmt.Errorf("BASE_URL 无效: %w", err) diff --git a/go-cli/pkg/client/url_test.go b/go-cli/pkg/client/url_test.go index d6faf7e50..70a5368ee 100644 --- a/go-cli/pkg/client/url_test.go +++ b/go-cli/pkg/client/url_test.go @@ -12,6 +12,8 @@ func TestValidateBaseURL(t *testing.T) { wantErr bool }{ {name: "https ok", raw: " https://relay.example.com/ ", want: "https://relay.example.com"}, + {name: "https trailing v1 trimmed", raw: "https://relay.example.com/v1", want: "https://relay.example.com"}, + {name: "https nested path trailing v1 trimmed", raw: "https://relay.example.com/proxy/v1/", want: "https://relay.example.com/proxy"}, {name: "http localhost ok", raw: "http://localhost:8787/api", want: "http://localhost:8787/api"}, {name: "http loopback ip ok", raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080"}, {name: "http remote rejected", raw: "http://relay.example.com", wantErr: true}, diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go index a49c926a7..d858f2303 100644 --- a/image-studio/backend/dialogs.go +++ b/image-studio/backend/dialogs.go @@ -66,6 +66,50 @@ func (s *Service) OpenImageDialog() (SelectFileResponse, error) { return resp, nil } +func (s *Service) OpenImagesDialog() (SelectFilesResponse, error) { + paths, err := runtime.OpenMultipleFilesDialog(s.ctx, runtime.OpenDialogOptions{ + Title: "选择批处理源图片", + Filters: []runtime.FileFilter{ + {DisplayName: "支持的图片 (*.png;*.jpg;*.jpeg;*.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp"}, + {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil { + return SelectFilesResponse{}, err + } + if len(paths) == 0 { + return SelectFilesResponse{}, nil + } + files := make([]BatchInputImage, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + info, statErr := os.Stat(path) + if statErr != nil || info.IsDir() { + continue + } + item := BatchInputImage{ + Path: path, + Name: filepath.Base(path), + Size: info.Size(), + } + if cfg, cfgErr := imageConfig(path); cfgErr == nil { + item.Width = cfg.Width + item.Height = cfg.Height + } + if info.Size() > 0 && info.Size() <= maxDialogReadBytes { + if preview, previewErr := s.registerImportedPreview(path); previewErr == nil { + item.PreviewURL = preview.PreviewURL + item.PreviewWidth = preview.PreviewWidth + item.PreviewHeight = preview.PreviewHeight + } + } + files = append(files, item) + } + return SelectFilesResponse{Files: files}, nil +} + func (s *Service) ChooseBatchInputDir() (BatchInputDirectory, error) { if s.ctx == nil { return BatchInputDirectory{}, errors.New("服务未启动") @@ -121,6 +165,10 @@ func (s *Service) ListBatchInputImages(directory string) (BatchInputDirectory, e Name: entry.Name(), Size: info.Size(), } + if cfg, cfgErr := imageConfig(path); cfgErr == nil { + item.Width = cfg.Width + item.Height = cfg.Height + } if info.Size() > 0 && info.Size() <= maxDialogReadBytes { if preview, previewErr := s.registerImportedPreview(path); previewErr == nil { item.PreviewURL = preview.PreviewURL diff --git a/image-studio/backend/probe.go b/image-studio/backend/probe.go index 6ff263935..978ddf3e9 100644 --- a/image-studio/backend/probe.go +++ b/image-studio/backend/probe.go @@ -100,7 +100,26 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst DisplayName: strings.TrimSpace(item.DisplayName), }) } - return ProbeUpstreamResult{ModelCount: len(parsed.Data), Models: models}, nil + result := ProbeUpstreamResult{ + ModelCount: len(parsed.Data), + Models: models, + } + if strings.TrimSpace(opts.APIMode) == string(client.APIModeResponses) && + client.NormalizeProxyTransportValue(strings.TrimSpace(opts.ResponsesTransport)) == string(client.ResponsesTransportWebSocket) { + result.ResponsesTransport = string(client.ResponsesTransportWebSocket) + if wsErr := client.ProbeResponsesWebSocket(ctx, client.ProbeResponsesWebSocketOptions{ + BaseURL: baseURL, + APIKey: apiKey, + Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, + Model: client.TextModel, + }); wsErr != nil { + result.ResponsesTransportOK = false + result.ResponsesTransportError = wsErr.Error() + return result, nil + } + result.ResponsesTransportOK = true + } + return result, nil } func summarizeProbeBody(body []byte) string { diff --git a/image-studio/backend/probe_test.go b/image-studio/backend/probe_test.go index 902d0a2c8..073c5d751 100644 --- a/image-studio/backend/probe_test.go +++ b/image-studio/backend/probe_test.go @@ -129,6 +129,51 @@ func TestProbeUpstreamUsesCustomProxy(t *testing.T) { } } +func TestProbeUpstreamReportsStructuredWebSocketProbeFailure(t *testing.T) { + var gotUpgrade bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/models" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.5"}]}`)) + return + } + if r.URL.Path == "/v1/responses" { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + gotUpgrade = true + } + http.Error(w, "ws unsupported", http.StatusBadRequest) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + got, err := probeUpstream(context.Background(), ProbeUpstreamOptions{ + APIKey: "sk-test", + BaseURL: strings.Replace(srv.URL, "http://", "http://", 1), + APIMode: "responses", + ResponsesTransport: "websocket", + }) + if err != nil { + t.Fatalf("ProbeUpstream returned error: %v", err) + } + if got.ModelCount != 1 { + t.Fatalf("model count = %d, want 1", got.ModelCount) + } + if got.ResponsesTransport != "websocket" { + t.Fatalf("responsesTransport = %q, want websocket", got.ResponsesTransport) + } + if got.ResponsesTransportOK { + t.Fatalf("responsesTransportOK = true, want false") + } + if strings.TrimSpace(got.ResponsesTransportError) == "" { + t.Fatalf("responsesTransportError should not be empty") + } + if !gotUpgrade { + t.Fatalf("expected websocket probe upgrade attempt") + } +} + func TestServiceProbeUpstreamRequiresStartup(t *testing.T) { svc := NewService() _, err := svc.ProbeUpstream(ProbeUpstreamOptions{APIKey: "sk-test", BaseURL: "http://127.0.0.1:1"}) diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index ddade9eb4..4a3de662c 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -371,6 +371,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions ReasoningEffort: opts.ReasoningEffort, Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: apiMode, + ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.ResponsesTransport)), RequestPolicy: client.RequestPolicy(strings.TrimSpace(opts.RequestPolicy)), ImagesNewAPICompat: opts.ImagesNewAPICompat, NoPromptRevision: opts.NoPromptRevision, @@ -408,6 +409,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions ReasoningEffort: strings.TrimSpace(opts.FallbackProfile.ReasoningEffort), Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: fallbackAPIMode, + ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.FallbackProfile.ResponsesTransport)), RequestPolicy: client.RequestPolicy(strings.TrimSpace(opts.FallbackProfile.RequestPolicy)), ImagesNewAPICompat: opts.FallbackProfile.ImagesNewAPICompat, NoPromptRevision: opts.NoPromptRevision, diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 9c25c7f57..3fe3fe0c0 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -42,6 +42,7 @@ type GenerateOptions struct { ImageModelID string `json:"imageModelID"` // overrides the default image model ReasoningEffort string `json:"reasoningEffort"` APIMode string `json:"apiMode"` // "responses" (default) | "images" + ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` // "openai" (default) | "compat" // ImagesNewAPICompat 开启后仅影响 Images API 请求: // 强制使用 b64_json,并关闭 stream/partial_images,用于兼容部分 NewAPI 中转。 @@ -73,6 +74,7 @@ type FallbackProfileOptions struct { ImageModelID string `json:"imageModelID"` ReasoningEffort string `json:"reasoningEffort"` APIMode string `json:"apiMode"` + ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` ImagesNewAPICompat bool `json:"imagesNewAPICompat,omitempty"` } @@ -94,15 +96,20 @@ type PromptOptimizeOptions struct { // and the actual /v1/models request are intentionally host-side so browser and // WebView quirks do not decide whether a channel is alive. type ProbeUpstreamOptions struct { - APIKey string `json:"apiKey"` - BaseURL string `json:"baseURL"` - ProxyMode string `json:"proxyMode"` - ProxyURL string `json:"proxyURL"` + APIKey string `json:"apiKey"` + BaseURL string `json:"baseURL"` + ProxyMode string `json:"proxyMode"` + ProxyURL string `json:"proxyURL"` + APIMode string `json:"apiMode,omitempty"` + ResponsesTransport string `json:"responsesTransport,omitempty"` } type ProbeUpstreamResult struct { - ModelCount int `json:"modelCount"` - Models []UpstreamModelDescriptor `json:"models,omitempty"` + ModelCount int `json:"modelCount"` + Models []UpstreamModelDescriptor `json:"models,omitempty"` + ResponsesTransport string `json:"responsesTransport,omitempty"` + ResponsesTransportOK bool `json:"responsesTransportOK,omitempty"` + ResponsesTransportError string `json:"responsesTransportError,omitempty"` } type UpstreamModelDescriptor struct { @@ -203,6 +210,8 @@ type BatchInputImage struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` PreviewWidth int `json:"previewWidth,omitempty"` PreviewHeight int `json:"previewHeight,omitempty"` @@ -213,6 +222,10 @@ type BatchInputDirectory struct { Images []BatchInputImage `json:"images"` } +type SelectFilesResponse struct { + Files []BatchInputImage `json:"files"` +} + // ImportedImage describes a freshly imported (drag-dropped or pasted) image. type ImportedImage struct { Path string `json:"path"` diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index 27600aa03..20d4220ef 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -28,6 +28,7 @@ export function readPreviewScenario(): PreviewScenario | null { } } + function buildHistory(now: number): HistoryItem[] { const batchPrompt = "赛博雨夜角色海报,湿地街道反光,红青霓虹边缘光,35mm,电影感,超细节"; const batchDefs = Array.from({ length: 9 }, (_, index) => ({ @@ -152,6 +153,7 @@ function buildPreviewProfile(now: number): UpstreamProfile { id: "preview-profile", name: "Preview Responses", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", baseURL: "https://code1.linzefeng.top", textModelID: "gpt-4.1-mini", @@ -182,7 +184,9 @@ function buildWorkspace( outputMode: "source_dir", outputDir: "", concurrency: 2, + retryOnFailure: false, fileNamePrefix: "processed-", + autoAspectResolution: "", discoveredSources: [], }; return { diff --git a/image-studio/frontend/src/app/gates/SavePromptGate.tsx b/image-studio/frontend/src/app/gates/SavePromptGate.tsx index c267cab74..7212394ba 100644 --- a/image-studio/frontend/src/app/gates/SavePromptGate.tsx +++ b/image-studio/frontend/src/app/gates/SavePromptGate.tsx @@ -4,8 +4,8 @@ import { useStudioStore } from "../../state/studioStore"; const SavePromptModal = lazy(() => import("../../components/common/SavePromptModal").then((module) => ({ default: module.SavePromptModal }))); export function SavePromptGate() { - const item = useStudioStore((s) => s.savePromptItem); - if (!item) return null; + const request = useStudioStore((s) => s.savePromptRequest); + if (!request) return null; return ( diff --git a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx index b626b97f7..512d5d4e0 100644 --- a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx +++ b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx @@ -15,6 +15,10 @@ export function BatchResultGrid({ onClose, showClose = true, title, + selectedIds, + onToggleSelect, + selectionMode = false, + livePreview = false, }: { items: HistoryItem[]; slots?: BatchGridSlot[]; @@ -23,12 +27,17 @@ export function BatchResultGrid({ onClose: () => void; showClose?: boolean; title?: string; + selectedIds?: Set; + onToggleSelect?: (item: HistoryItem) => void; + selectionMode?: boolean; + livePreview?: boolean; }) { const gridSlots = slots ?? items.map((item) => ({ type: "result", item }) satisfies BatchGridSlot); - const columns = gridSlots.length <= 2 ? 2 : gridSlots.length <= 4 ? 2 : 3; + const singleLivePreview = livePreview && gridSlots.length === 1; + const columns = singleLivePreview ? 1 : gridSlots.length <= 4 ? 2 : 3; return ( -
-
+
+
{title ?? `本批结果 · ${items.length} 张`} {showClose ? (
{gridSlots.map((slot, index) => { if (slot.type === "pending") { - return ; + return ; } return ( ); })} @@ -66,36 +79,56 @@ function BatchGridTile({ active, preview, onSelect, + selected, + onToggleSelect, + selectionMode, + singleLayout, }: { item: HistoryItem; index: number; active: boolean; preview: boolean; onSelect: (item: HistoryItem) => void | Promise; + selected: boolean; + onToggleSelect?: (item: HistoryItem) => void; + selectionMode: boolean; + singleLayout: boolean; }) { const previewURL = useBlobURL(item.imageBlob ?? item.previewBlob ?? null, item.imageB64 ?? null); const src = historyPreviewSrc(item, previewURL); return (
@@ -104,12 +137,13 @@ function BatchGridTile({ ); } -function PendingGridTile({ index }: { index: number }) { +function PendingGridTile({ index, singleLayout }: { index: number; singleLayout: boolean }) { return (
{index + 1} 等待预览 + {singleLayout ? 第一张流式预览出现后,会在这里实时刷新。 : null}
); } diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx index e0276f5e7..0039122b1 100644 --- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx +++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx @@ -14,7 +14,7 @@ import { AnnotationShape } from "./AnnotationShape"; import { useCanvasShortcuts } from "./useCanvasShortcuts"; import { StreamPreviewBadge } from "./StreamPreviewBadge"; import { streamPreviewItemsFromPreviews } from "../../state/studioStore.streamPreview"; -import { historyFullSrc } from "../../lib/images"; +import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../lib/images"; import { DragExportHandle } from "./DragExportHandle"; export function CanvasStage() { @@ -34,6 +34,7 @@ export function CanvasStage() { jobsTotal, jobsCompleted, toggleFullscreen, + history, batchResults, resultGridOpen, selectBatchResult, closeResultGrid, canvasViewResetTick, stepBatchResult, @@ -48,22 +49,30 @@ export function CanvasStage() { outputFormat: useStudioStore.getState().outputFormat, currentImage, }); - const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); + const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); + const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); + const liveBatchSlotCount = runningJobs.length; const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); - for (const item of batchResults) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length) liveBatchSlots[index] = { type: "result", item }; + for (const item of [...orderedBatchResults].reverse()) { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + liveBatchSlots[index] = { type: "result", item }; + } } for (const item of streamPreviewItems) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length) { liveBatchSlots[index] = { type: "preview", item }; } } - const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1; + const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0; const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1); - const currentBatchIndex = currentImage ? batchResults.findIndex((item) => item.id === currentImage.id) : -1; - const canNavigateBatchResults = currentBatchIndex >= 0 && batchResults.length > 1; + const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; + const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; // Hold-space-for-pan: while space is held, override tool to "pan". const [spacePan, setSpacePan] = useState(false); @@ -465,13 +474,14 @@ export function CanvasStage() { ) : null} {showingResultGrid && ( )} {!showingResultGrid && currentImage && compareB && ( diff --git a/image-studio/frontend/src/components/common/SavePromptModal.tsx b/image-studio/frontend/src/components/common/SavePromptModal.tsx index d088b8752..6ac018904 100644 --- a/image-studio/frontend/src/components/common/SavePromptModal.tsx +++ b/image-studio/frontend/src/components/common/SavePromptModal.tsx @@ -1,30 +1,54 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FolderOpen, Save } from "lucide-react"; import { useStudioStore } from "../../state/studioStore"; +import { BatchResultGrid } from "../canvas/BatchResultGrid"; import { historyPreviewSrc, useBlobURL } from "../../lib/images"; -import { saveHistoryItemAs } from "../../lib/saveResultImage"; +import { + buildHistoryItemDragExport, + writeImageFileDragData, + writeInternalHistoryItemDragData, +} from "../../lib/dragExport.ts"; +import { saveHistoryItemAs, saveHistoryItemsToDirectory } from "../../lib/saveResultImage"; import { androidSaveHint, androidTarget } from "../../platform/android/bridge"; +import { BeginNativeFileDrag, getHostCapabilities, ChooseDirectory } from "../../platform/runtime/host"; import { usePlatform } from "../../platform/context"; import { Modal } from "./Modal"; export function SavePromptModal() { - const item = useStudioStore((s) => s.savePromptItem); + const request = useStudioStore((s) => s.savePromptRequest); const closeSavePrompt = useStudioStore((s) => s.closeSavePrompt); const suppressed = useStudioStore((s) => s.savePromptSuppressed); const setSuppressed = useStudioStore((s) => s.setSavePromptSuppressed); const pushToast = useStudioStore((s) => s.pushToast); - const { usesFluentUI, isAndroidPhone } = usePlatform(); + const { usesFluentUI, isAndroidPhone, isAndroid, isMac } = usePlatform(); const [saving, setSaving] = useState(false); - const previewURL = useBlobURL(item?.previewBlob ?? item?.imageBlob ?? null, item?.imageB64 ?? null); + const [selectedIds, setSelectedIds] = useState>(new Set()); - if (!item) return null; - const imageSrc = historyPreviewSrc(item, previewURL); + useEffect(() => { + if (request?.kind === "batch") { + setSelectedIds(new Set(request.items.map((item) => item.id))); + return; + } + setSelectedIds(new Set()); + }, [request]); + + const batchItems = request?.kind === "batch" ? request.items : []; + const selectedBatchItems = useMemo( + () => batchItems.filter((item) => selectedIds.has(item.id)), + [batchItems, selectedIds], + ); - async function saveAs() { - if (saving || !item) return; + if (!request) return null; + const isBatch = request.kind === "batch"; + const singleItem = request.kind === "single" ? request.item : null; + const batchRequest = request.kind === "batch" ? request : null; + const singlePreviewURL = useBlobURL(singleItem?.previewBlob ?? singleItem?.imageBlob ?? null, singleItem?.imageB64 ?? null); + + async function saveSingleAs() { + if (saving || !singleItem) return; setSaving(true); try { - const saved = await saveHistoryItemAs(item); + const saved = await saveHistoryItemAs(singleItem); if (saved) pushToast(`已保存:${saved.split(/[\\/]/).pop()}`, "success"); closeSavePrompt(); } catch (error: any) { @@ -34,39 +58,198 @@ export function SavePromptModal() { } } - return ( - -
-
-
- {imageSrc ? ( - 生成结果预览 - ) : ( - - )} + async function saveBatchAs() { + if (saving || !batchRequest) return; + if (selectedBatchItems.length === 0) { + pushToast("请先勾选要另存的图片", "warn"); + return; + } + if (!(hostCapabilities.nativeOutputDirectoryPicker && !isAndroid)) { + pushToast("当前平台暂不支持批量选择目录另存,请改用单图另存为。", "warn", 5000); + return; + } + setSaving(true); + try { + const directory = await ChooseDirectory("选择批量另存为目录"); + if (!directory) { + setSaving(false); + return; + } + const saved = await saveHistoryItemsToDirectory(selectedBatchItems, directory); + pushToast(`已另存 ${saved.length} 张到 ${directory.split(/[\\/]/).pop() || directory}`, "success", 6000); + closeSavePrompt(); + } catch (error: any) { + pushToast(`批量保存失败:${error?.message ?? error}`, "error", 6000); + } finally { + setSaving(false); + } + } + + if (!isBatch) { + const imageSrc = historyPreviewSrc(singleItem, singlePreviewURL); + const dragSpec = singleItem ? buildHistoryItemDragExport(singleItem) : null; + + function handleSinglePreviewDragStart(event: React.DragEvent) { + if (!singleItem || !dragSpec) { + event.preventDefault(); + return; + } + event.stopPropagation(); + if (isMac && singleItem.savedPath) { + event.preventDefault(); + void BeginNativeFileDrag(singleItem.savedPath).catch((error) => { + console.error("[drag-export] native-file-drag failed", error); + }); + return; + } + event.dataTransfer.effectAllowed = "copy"; + writeInternalHistoryItemDragData(event.dataTransfer, singleItem); + writeImageFileDragData(event.dataTransfer, dragSpec); + } + + return ( + +
+
+
+ {imageSrc ? ( + 生成结果预览 + ) : ( + + )} +
+
+

+ 图片已生成并保存在默认输出目录。 +

+

+ 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。 +

+ {singleItem?.savedPath ? ( +

+ {singleItem.savedPath} +

+ ) : null} +
-
+ + {androidTarget.isAndroid ? ( +

{androidSaveHint()}

+ ) : null} + + + +
+ + +
+
+ + ); + } + + const hostCapabilities = getHostCapabilities(); + const canBatchSaveToDirectory = hostCapabilities.nativeOutputDirectoryPicker && !isAndroid; + + return ( + +
+
+

- 图片已生成并保存在默认输出目录。 + 单次任务只弹这一次。勾选需要的图片后,再统一另存为到目标目录。

- 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。 + 当前已选 {selectedBatchItems.length} / {batchItems.length} 张。

- {item.savedPath ? ( -

- {item.savedPath} -

- ) : null}
+
+ + +
+
+ +
+ undefined} + onClose={closeSavePrompt} + showClose={false} + title={`勾选要另存的结果 · ${batchItems.length} 张`} + selectionMode + selectedIds={selectedIds} + onToggleSelect={(item) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }} + />
{androidTarget.isAndroid ? ( -

{androidSaveHint()}

+

+ {androidSaveHint()} 当前平台批量勾选后统一目录另存尚不可用,请改用单图另存为。 +

+ ) : !canBatchSaveToDirectory ? ( +

+ 当前环境没有可用的目录选择器,暂时无法一次性批量另存。 +

) : null}
diff --git a/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx b/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx index 7b5f30cd0..ac4e34a62 100644 --- a/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx +++ b/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx @@ -172,7 +172,7 @@ function HistoryPromptModalThumbnail({ onContextMenu={openMenu} onDragStart={handleDragStart} > - {item.prompt} + {item.prompt} #{displayIndex} {isCompare ? B : null} diff --git a/image-studio/frontend/src/components/history/HistoryPromptThumbnailStack.tsx b/image-studio/frontend/src/components/history/HistoryPromptThumbnailStack.tsx index c3bc17f17..a43fd15f3 100644 --- a/image-studio/frontend/src/components/history/HistoryPromptThumbnailStack.tsx +++ b/image-studio/frontend/src/components/history/HistoryPromptThumbnailStack.tsx @@ -29,7 +29,7 @@ function HistoryPromptThumbnailStackLayer({ item, index }: { item: HistoryItem; return ( {loadState === "ready" ? ( - + ) : ( )} diff --git a/image-studio/frontend/src/components/history/HistoryTile.tsx b/image-studio/frontend/src/components/history/HistoryTile.tsx index 4bf66c54a..e2e4bdf26 100644 --- a/image-studio/frontend/src/components/history/HistoryTile.tsx +++ b/image-studio/frontend/src/components/history/HistoryTile.tsx @@ -87,6 +87,7 @@ export function HistoryTile({ alt={item.prompt} loading="eager" decoding="async" + draggable={false} className={imageClassName} /> ) : ( diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx index 42a24824b..3eac2aa3d 100644 --- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -1,4 +1,4 @@ -import { FolderOpen, RefreshCw } from "lucide-react"; +import { FolderOpen, Images, RefreshCw } from "lucide-react"; import type { BatchProcessConfig, EditSourceMode } from "../../types/domain"; import { Section, Seg, SegItem } from "./panelChrome"; @@ -9,6 +9,15 @@ function sampleNames(config: BatchProcessConfig): string { return `${names.join("、")} 等 ${config.discoveredSources.length} 张`; } +function batchModeSummary(config: BatchProcessConfig): string { + const output = config.outputMode === "custom_dir" ? "独立输出目录" : "回原图目录"; + const sizing = config.autoAspectResolution + ? `按源图比例 + ${config.autoAspectResolution.toUpperCase()}` + : "沿用当前比例/分辨率"; + const retry = config.retryOnFailure ? "失败自动重试" : "失败直接跳过"; + return `${config.discoveredSources.length} 张 · 并发 ${config.concurrency} · ${output} · ${sizing} · ${retry}`; +} + export function BatchProcessSection({ currentImageSavedPath, editSourceMode, @@ -16,6 +25,7 @@ export function BatchProcessSection({ setEditSourceMode, setBatchProcess, onChooseInputDir, + onChooseInputFiles, onChooseOutputDir, onRefreshInputDir, usesFluentUI = false, @@ -26,121 +36,266 @@ export function BatchProcessSection({ setEditSourceMode: (mode: EditSourceMode) => void; setBatchProcess: (next: BatchProcessConfig) => void; onChooseInputDir: () => void; + onChooseInputFiles?: () => void; onChooseOutputDir?: () => void; onRefreshInputDir: () => void; usesFluentUI?: boolean; }) { const batchMode = editSourceMode === "batch"; + const surfaceClass = `border border-black/[0.06] bg-[var(--surface)] dark:border-white/[0.04]`; + const roundedClass = usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"; return ( -
+
+ {batchModeSummary(batchProcess)} + + ) : undefined} + >
- setEditSourceMode("manual")}> + { + setEditSourceMode("manual"); + setBatchProcess({ ...batchProcess, enabled: false }); + }} + > 普通图生图 - setEditSourceMode("batch")}> + { + setEditSourceMode("batch"); + setBatchProcess({ ...batchProcess, enabled: true }); + }} + > 批处理 {!batchMode ? ( -
+
{currentImageSavedPath ? "当前画板图会作为隐式源图参与本次编辑,也可以继续手动添加参考图。" : "手动添加参考图,或从历史里挑一张结果继续编辑。"}
) : (
-
- 选择一个图片目录后,会按当前 prompt 和参数逐张提交图生图任务。默认保存回原图目录,也可以单独指定输出路径。 -
- - - -
- - +
+ 批处理属于图生图模式。这里加入的每一张图片都会作为独立源图,复用同一套 prompt 和参数逐张处理。默认保存回原图目录,也可以单独指定输出路径。
-
- 已扫描 {batchProcess.discoveredSources.length} 张 -
{sampleNames(batchProcess)}
-
+
+
+
+
批处理队列
+
+ 支持选目录扫描,也支持直接选择多张图片加入队列。 +
+
+ + {batchProcess.discoveredSources.length} 张 + +
-
- 输出位置 - - setBatchProcess({ ...batchProcess, outputMode: "source_dir" })} - > - 默认保存回原目录 - - setBatchProcess({ ...batchProcess, outputMode: "custom_dir" })} - > - 独立输出路径 - - -
+
+ - {batchProcess.outputMode === "custom_dir" ? ( -
- 独立输出路径 - setBatchProcess({ ...batchProcess, outputDir: event.target.value })} - placeholder="请选择独立输出路径" - className={`focus-ring w-full border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-[13px] text-zinc-900 dark:border-white/[0.08] dark:text-zinc-100 ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} - /> - {onChooseOutputDir ? ( +
+ {onChooseInputFiles ? ( + + ) : null} + +
+
+ +
+
样例: {sampleNames(batchProcess)}
+
目录扫描仅处理当前目录,不递归子目录。
+
+
+ +
+
输出与执行
+
+ 这里决定保存路径、并发数量和失败后的处理策略。 +
+ +
+
+ 输出位置 + + setBatchProcess({ ...batchProcess, outputMode: "source_dir" })} + > + 默认保存回原目录 + + setBatchProcess({ ...batchProcess, outputMode: "custom_dir" })} + > + 独立输出路径 + + +
+ + {batchProcess.outputMode === "custom_dir" ? ( +
+ 独立输出路径 + setBatchProcess({ ...batchProcess, outputDir: event.target.value })} + placeholder="请选择独立输出路径" + className={`focus-ring w-full border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-[13px] text-zinc-900 dark:border-white/[0.08] dark:text-zinc-100 ${roundedClass}`} + /> + {onChooseOutputDir ? ( + + ) : null} +
) : null} + +
+ + +
+ 失败处理 + + setBatchProcess({ ...batchProcess, retryOnFailure: false })} + > + 失败直接跳过 + + setBatchProcess({ ...batchProcess, retryOnFailure: true })} + > + 自动重试 + + +
+ 批处理默认关闭自动重试,避免单张失败时整批任务长时间卡在 15 秒回退等待。 +
+
+
+
+
+ +
+
批处理尺寸策略
+
+ 批处理开启按源图比例自动适配后,会接管本批任务的比例与分辨率计算。
- ) : null} - - - -
+ +
+ 不同源图比例处理 + + setBatchProcess({ ...batchProcess, autoAspectResolution: "" })} + > + 沿用当前比例 + + setBatchProcess({ ...batchProcess, autoAspectResolution: batchProcess.autoAspectResolution || "1k" })} + > + 按源图比例自动适配 + + +
+ + {batchProcess.autoAspectResolution !== "" ? ( + <> +
+
+
+
统一分辨率档位
+
+ 每张图按自己的比例自动适配,但都会使用这里选定的分辨率档位。 +
+
+ + 当前 {batchProcess.autoAspectResolution.toUpperCase()} + +
+
+ {(["256", "512", "1k", "2k", "4k"] as const).map((value) => ( + + ))} +
+
+
+ 开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。 +
+ + ) : ( +
+ 关闭时,批处理直接沿用当前控制面板里的比例和分辨率设置。 +
+ )} +
+ +
结果文件名前缀固定为 processed-,遇到同名会自动追加 -2、-3。
diff --git a/image-studio/frontend/src/components/panel/ControlPanel.tsx b/image-studio/frontend/src/components/panel/ControlPanel.tsx index c1259c9ae..e7d81d90b 100644 --- a/image-studio/frontend/src/components/panel/ControlPanel.tsx +++ b/image-studio/frontend/src/components/panel/ControlPanel.tsx @@ -46,7 +46,7 @@ export function ControlPanel({ apiMode, requestPolicy, baseURL, profiles, imageModelID, customAspectRatios, setField, clearError, pushToast, - selectSourceImage, chooseBatchInputDir, refreshBatchInputDir, removeSource, clearSources, viewSourceOnCanvas, + selectSourceImage, chooseBatchInputDir, chooseBatchInputFiles, refreshBatchInputDir, removeSource, clearSources, viewSourceOnCanvas, compareSourceOnCanvas, openCustomAspectRatioModal, openCustomSizeModal, @@ -247,6 +247,7 @@ export function ControlPanel({ batchCount={batchCount} batchProcess={batchProcess} chooseBatchInputDir={chooseBatchInputDir} + chooseBatchInputFiles={chooseBatchInputFiles} chooseBatchOutputDir={chooseBatchOutputDir} clearSources={clearSources} currentImageSavedPath={currentImage?.savedPath ?? null} @@ -291,6 +292,7 @@ export function ControlPanel({ batchCount={batchCount} batchProcess={batchProcess} chooseBatchInputDir={chooseBatchInputDir} + chooseBatchInputFiles={chooseBatchInputFiles} chooseBatchOutputDir={chooseBatchOutputDir} clearSources={clearSources} currentImageSavedPath={currentImage?.savedPath ?? null} @@ -334,6 +336,7 @@ export function ControlPanel({ batchCount={batchCount} batchProcess={batchProcess} chooseBatchInputDir={chooseBatchInputDir} + chooseBatchInputFiles={chooseBatchInputFiles} chooseBatchOutputDir={chooseBatchOutputDir} mode={mode} sources={sources} diff --git a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx index 888ee79e9..dd4c6e56e 100644 --- a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx +++ b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx @@ -30,6 +30,7 @@ export function DesktopComposeSections({ batchCount, batchProcess, chooseBatchInputDir, + chooseBatchInputFiles, chooseBatchOutputDir, clearSources, currentImageSavedPath, @@ -64,6 +65,7 @@ export function DesktopComposeSections({ batchCount: number; batchProcess: BatchProcessConfig; chooseBatchInputDir: () => void; + chooseBatchInputFiles: () => void; chooseBatchOutputDir: () => void; clearSources: () => void; currentImageSavedPath?: string | null; @@ -88,6 +90,9 @@ export function DesktopComposeSections({ styleTag: string; availableResolutions: ResolutionPreset[]; }) { + const batchMode = mode === "edit" && editSourceMode === "batch"; + const batchAutoAspectActive = batchMode && batchProcess.autoAspectResolution !== ""; + return ( <>
@@ -117,79 +122,89 @@ export function DesktopComposeSections({
-
- 自定义比例 - - ) : undefined} - > -
- {aspectOptions.map((aspect) => { - const active = activeAspect === aspect.value; - return ( + {!batchAutoAspectActive ? ( + <> +
handleAspectSelect(aspect.value)} - title={aspect.auto ? "让上游决定尺寸 / 比例" : aspect.label} - className={`flex min-h-[56px] flex-col items-center justify-center gap-1 ring-1 transition-colors ${ - active - ? "bg-[var(--accent-soft)] ring-[color:var(--accent)]/35" - : "ring-black/[0.08] dark:ring-white/[0.08] hover:ring-[color:var(--accent)]/30" - } py-2 ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} + type="button" + onClick={onOpenCustomAspectRatioModal} + className="text-[11px] text-[var(--accent)] transition-opacity hover:opacity-80" > - - {aspect.label} + 自定义比例 - ); - })} -
-
+ ) : undefined} + > +
+ {aspectOptions.map((aspect) => { + const active = activeAspect === aspect.value; + return ( + + ); + })} +
+
-
- {allowPreciseSizeControl ? ( -
- +
+ {allowPreciseSizeControl ? ( +
+ +
+ ) : null} + + {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( + handleResolutionSelect(item.value)} + > + {item.label} + + ))} + + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} + {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( +

+ {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} +

+ ) : null} +
+ + ) : ( +
+
+ 当前批处理已开启“按源图比例自动适配”,本批任务的比例与分辨率由“批处理图生图”区统一控制。这里的普通比例/分辨率预设已暂时隐藏,避免出现两套尺寸入口。
- ) : null} - - {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( - handleResolutionSelect(item.value)} - > - {item.label} - - ))} - - {exactSizeLabel ? ( -

- 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 -

- ) : null} - {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( -

- {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} -

- ) : null} -
+
+ )}
@@ -245,6 +260,7 @@ export function DesktopComposeSections({ setEditSourceMode={(next) => setField("editSourceMode" as any, next)} setBatchProcess={(next) => setField("batchProcess" as any, next)} onChooseInputDir={chooseBatchInputDir} + onChooseInputFiles={chooseBatchInputFiles} onChooseOutputDir={chooseBatchOutputDir} onRefreshInputDir={onRefreshBatchInputDir} usesFluentUI={usesFluentUI} diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index c1f8ad224..289cf8604 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -33,7 +33,7 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void
  • Responses API · CF 超时推荐:POST /v1/responses,通过模型内置的 - image_generation 工具触发生图,SSE 流式接收。 + image_generation 工具触发生图,支持 HTTP SSE 与 WebSocket mode 两种传输。 能防 Cloudflare 524/504 超时(图像推理常常超过 100 秒)。
    Key 要绑「拥有 gpt-5.5 模型的分组」(中转站后台通常叫「余额分组」或「套餐分组」), @@ -51,6 +51,12 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void 选哪个?看你 key 绑的是哪个分组。 两边都有的话优先 Responses(SSE 更抗 524)。

    +

    + 如果在 Responses API 配置里把传输切到 WebSocket mode,应用会在连接中断时快速重连一次;若仍然失败,通常说明上游或代理不兼容,建议切回 HTTP SSE 再试。 +

    +

    + 如果错误里直接出现 WebSocket upgrade required (Upgrade: websocket),说明失败发生在握手阶段:当前链路没有把 WebSocket Upgrade 正确转发到上游,这种情况不是“生成到一半断了”,而是上游 / 代理当前不支持这个 WS 路径。 +

diff --git a/image-studio/frontend/src/components/panel/MacComposePanel.tsx b/image-studio/frontend/src/components/panel/MacComposePanel.tsx index fcadf843e..87f6dbc1b 100644 --- a/image-studio/frontend/src/components/panel/MacComposePanel.tsx +++ b/image-studio/frontend/src/components/panel/MacComposePanel.tsx @@ -25,6 +25,7 @@ export function MacComposePanel({ batchCount, batchProcess, chooseBatchInputDir, + chooseBatchInputFiles, chooseBatchOutputDir, mode, sources, @@ -65,6 +66,7 @@ export function MacComposePanel({ batchCount: number; batchProcess: BatchProcessConfig; chooseBatchInputDir: () => void; + chooseBatchInputFiles: () => void; chooseBatchOutputDir: () => void; mode: string; sources: SourceImage[]; @@ -109,6 +111,7 @@ export function MacComposePanel({ activeAspect={activeAspect} aspectOptions={aspectOptions} activeResolution={activeResolution} + batchAutoAspectActive={mode === "edit" && editSourceMode === "batch" && batchProcess.autoAspectResolution !== ""} exactSizeLabel={exactSizeLabel} allowCustomAspectRatios={allowCustomAspectRatios} allowPreciseSizeControl={allowPreciseSizeControl} @@ -133,6 +136,7 @@ export function MacComposePanel({ void; + chooseBatchInputFiles: () => void; chooseBatchOutputDir: () => void; compareSourceOnCanvas: (index: number) => void; clearSources: () => void; @@ -39,6 +41,7 @@ export function MacComposeSources({ setEditSourceMode={(next) => setField("editSourceMode", next)} setBatchProcess={(next) => setField("batchProcess", next)} onChooseInputDir={chooseBatchInputDir} + onChooseInputFiles={chooseBatchInputFiles} onChooseOutputDir={chooseBatchOutputDir} onRefreshInputDir={refreshBatchInputDir} /> diff --git a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx index a1ae401db..f3c416bc1 100644 --- a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx +++ b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx @@ -13,6 +13,7 @@ export function MacComposeStyleAndSize({ activeAspect, aspectOptions, activeResolution, + batchAutoAspectActive = false, exactSizeLabel, allowCustomAspectRatios, allowPreciseSizeControl, @@ -35,6 +36,7 @@ export function MacComposeStyleAndSize({ activeAspect: AspectPreset | null; aspectOptions: AspectPresetOption[]; activeResolution: ResolutionPreset | null; + batchAutoAspectActive?: boolean; exactSizeLabel?: string | null; allowCustomAspectRatios: boolean; allowPreciseSizeControl: boolean; @@ -81,81 +83,89 @@ export function MacComposeStyleAndSize({
-
-
- 比例 - {allowCustomAspectRatios ? ( - - ) : null} -
-
- {aspectOptions.map((aspect) => { - const active = activeAspect === aspect.value; - return ( - - ); - })} -
-
+ {!batchAutoAspectActive ? ( + <> +
+
+ 比例 + {allowCustomAspectRatios ? ( + + ) : null} +
+
+ {aspectOptions.map((aspect) => { + const active = activeAspect === aspect.value; + return ( + + ); + })} +
+
-
-
- 分辨率 - {allowPreciseSizeControl ? ( - - ) : null} +
+
+ 分辨率 + {allowPreciseSizeControl ? ( + + ) : null} +
+ + {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( + handleResolutionSelect(item.value)} + > + {item.label} + + ))} + + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} + {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( +

+ {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} +

+ ) : null} +
+ + ) : ( +
+ 当前批处理已开启“按源图比例自动适配”,本批任务的比例与分辨率由“批处理图生图”区统一控制。这里的普通比例/分辨率预设已暂时隐藏,避免出现两套尺寸入口。
- - {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( - handleResolutionSelect(item.value)} - > - {item.label} - - ))} - - {exactSizeLabel ? ( -

- 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 -

- ) : null} - {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( -

- {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} -

- ) : null} -
+ )}
质量
diff --git a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx index 67fb19537..791718c92 100644 --- a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx +++ b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx @@ -1,8 +1,13 @@ import { ClipboardCopy, Folder, RotateCw, Save, Sparkles } from "lucide-react"; import { useStudioStore } from "../../state/studioStore"; -import { OpenOutputDir } from "../../platform/runtime/host"; +import { BeginNativeFileDrag, OpenOutputDir } from "../../platform/runtime/host"; import { submitShortcutLabel } from "../../platform"; import { historyPreviewSrc, useBlobURL } from "../../lib/images"; +import { + buildHistoryItemDragExport, + writeImageFileDragData, + writeInternalHistoryItemDragData, +} from "../../lib/dragExport.ts"; import { androidSaveHint, androidTarget, openOutputLocationForPlatform } from "../../platform/android/bridge"; import { saveHistoryItemAs } from "../../lib/saveResultImage"; import { Modal } from "../common/Modal"; @@ -14,7 +19,7 @@ export function ResultDetailDrawer() { const close = useStudioStore((s) => s.closeResultDetail); const setField = useStudioStore((s) => s.setField); const pushToast = useStudioStore((s) => s.pushToast); - const { usesFluentUI } = usePlatform(); + const { usesFluentUI, isMac } = usePlatform(); if (!item) return null; const detail = item; @@ -22,6 +27,25 @@ export function ResultDetailDrawer() { const created = new Date(detail.createdAt).toLocaleString(); const previewURL = useBlobURL(detail.previewBlob ?? detail.imageBlob ?? null, detail.imageB64 ?? null); const imageSrc = historyPreviewSrc(detail, previewURL); + const dragSpec = buildHistoryItemDragExport(detail); + + function handlePreviewDragStart(event: React.DragEvent) { + if (!dragSpec) { + event.preventDefault(); + return; + } + event.stopPropagation(); + if (isMac && detail.savedPath) { + event.preventDefault(); + void BeginNativeFileDrag(detail.savedPath).catch((error) => { + console.error("[drag-export] native-file-drag failed", error); + }); + return; + } + event.dataTransfer.effectAllowed = "copy"; + writeInternalHistoryItemDragData(event.dataTransfer, detail); + writeImageFileDragData(event.dataTransfer, dragSpec); + } function copy(text: string, label: string) { navigator.clipboard.writeText(text).then( @@ -51,11 +75,17 @@ export function ResultDetailDrawer() {
-
+
生成结果
diff --git a/image-studio/frontend/src/components/panel/SettingsPanel.tsx b/image-studio/frontend/src/components/panel/SettingsPanel.tsx index 453973055..7fd76f7fa 100644 --- a/image-studio/frontend/src/components/panel/SettingsPanel.tsx +++ b/image-studio/frontend/src/components/panel/SettingsPanel.tsx @@ -7,7 +7,7 @@ import { useStudioStore } from "../../state/studioStore"; import { GetOutputDir, OpenOutputDir, OpenExternalURL, ChooseOutputDir, SetOutputDir, } from "../../platform/runtime/host"; -import type { KernelRuntimeMode, ProxyMode } from "../../types/domain"; +import type { KernelRuntimeMode, ProxyMode, SystemNotificationPermissionState } from "../../types/domain"; import { Modal } from "../common/Modal"; import { rememberTrustedOutputRoot } from "../../lib/storage"; import { scheduleCompatibilityExport } from "../../lib/compatState"; @@ -45,11 +45,15 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => keepLogs, setKeepLogs, cleanupPreviewCacheOnExit, setCleanupPreviewCacheOnExit, completionSound, + completionNotification, + completionNotificationPermission, setCompletionSoundEnabled, setCompletionSoundMode, setCompletionSoundCustom, resetCompletionSoundCustom, previewCompletionSound, + setCompletionNotificationEnabled, + requestCompletionNotificationPermission, } = useStudioStore(); const [outputDir, setOutputDir] = useState(""); @@ -138,6 +142,47 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => input.click(); } + function systemNotificationPermissionLabel(permission: SystemNotificationPermissionState): string { + if (permission === "granted") return "已允许"; + if (permission === "denied") return "已拒绝"; + if (permission === "unsupported") return "当前平台不支持"; + return "尚未授权"; + } + + async function updateCompletionNotificationEnabled(value: boolean) { + const permission = await setCompletionNotificationEnabled(value); + if (!value) { + pushToast("已关闭系统通知", "success"); + return; + } + if (permission === "granted") { + pushToast("已开启系统通知", "success"); + return; + } + if (permission === "unsupported") { + pushToast("当前平台不支持系统通知", "warn", 5000); + return; + } + if (permission === "denied") { + pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000); + return; + } + pushToast("请先允许系统通知权限", "warn"); + } + + async function askCompletionNotificationPermission() { + const permission = await requestCompletionNotificationPermission(); + if (permission === "granted") { + pushToast("系统通知权限已授权", "success"); + } else if (permission === "denied") { + pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000); + } else if (permission === "unsupported") { + pushToast("当前平台不支持系统通知", "warn", 5000); + } else { + pushToast("系统通知权限仍未授权", "warn"); + } + } + function closeSettings() { setAboutOpen(false); onClose(); @@ -475,6 +520,28 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>

+ +
+ void updateCompletionNotificationEnabled(true)}> + 开启 + + void updateCompletionNotificationEnabled(false)}> + 关闭 + +
+
+ +
+

+ 整批任务全部完成后,仅在窗口不在前台时发送系统通知。当前权限:{systemNotificationPermissionLabel(completionNotificationPermission)}。 +

+
+
setTheme("system")}> diff --git a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx index 785b61551..bc1c64ac1 100644 --- a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx @@ -187,6 +187,7 @@ export function UpstreamConfigModal({ await updateProfile(match.id, { name: incoming.name, apiMode: incoming.apiMode, + responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, imagesNewAPICompat: incoming.imagesNewAPICompat, baseURL: incoming.baseURL, @@ -206,6 +207,7 @@ export function UpstreamConfigModal({ const newId = await createProfile({ name: incoming.name, apiMode: incoming.apiMode, + responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, imagesNewAPICompat: incoming.imagesNewAPICompat, baseURL: incoming.baseURL, @@ -276,6 +278,7 @@ export function UpstreamConfigModal({ await updateProfile(draft.id, { name: draft.name, apiMode: draft.apiMode, + responsesTransport: draft.responsesTransport ?? "sse", requestPolicy: draft.requestPolicy, imagesNewAPICompat: draft.imagesNewAPICompat === true, baseURL: draft.baseURL, @@ -320,15 +323,31 @@ export function UpstreamConfigModal({ setLoadingModels(true); setModelCatalogError(null); try { - const result = await probeCurrentUpstream(baseURL, apiKey); + const runtimeState = useStudioStore.getState(); + const result = await probeCurrentUpstream( + baseURL, + apiKey, + runtimeState.proxyMode, + runtimeState.proxyURL, + draft.apiMode, + draft.responsesTransport ?? "sse", + ); const catalog = buildUpstreamModelCatalog(result.models ?? []); setModelCatalog(catalog); - pushToast( - catalog.all.length > 0 - ? `已加载 ${catalog.all.length} 个模型` - : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, - catalog.all.length > 0 ? "success" : "warn", - ); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + pushToast( + `已拉取模型,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, + "warn", + 7000, + ); + } else { + const message = result.responsesTransport === "websocket" + ? `已加载 ${catalog.all.length} 个模型,Responses WebSocket 可用` + : catalog.all.length > 0 + ? `已加载 ${catalog.all.length} 个模型` + : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`; + pushToast(message, catalog.all.length > 0 ? "success" : "warn"); + } } catch (error: any) { const message = `加载模型失败:${error?.message ?? error}`; setModelCatalogError(message); diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index 0ed6a0083..ff793bf6f 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -3,6 +3,7 @@ import { REASONING_EFFORT_OPTIONS, type APIMode, type RequestPolicy, + type ResponsesTransport, type UpstreamProfile, } from "../../types/domain"; import { requestPolicyLabel } from "../../lib/profiles"; @@ -66,6 +67,10 @@ export function UpstreamProfileEditor({ { id: "openai" as RequestPolicy, title: requestPolicyLabel("openai"), sub: "默认。只发送 OpenAI 官方公开字段。" }, { id: "compat" as RequestPolicy, title: requestPolicyLabel("compat"), sub: "兼容部分 relay 扩展字段,例如 seed / negative_prompt。" }, ]; + const responsesTransportOptions = [ + { id: "sse" as ResponsesTransport, title: "HTTP SSE", sub: "默认,兼容性更稳" }, + { id: "websocket" as ResponsesTransport, title: "WebSocket mode", sub: "需要上游支持" }, + ]; const selectedAPIMode = apiModeOptions.find((option) => option.id === draft.apiMode) ?? apiModeOptions[0]; const selectedRequestPolicy = requestPolicyOptions.find((option) => option.id === draft.requestPolicy) ?? requestPolicyOptions[0]; const selectedReasoningEffort = REASONING_EFFORT_OPTIONS.find((option) => option.value === draft.reasoningEffort) ?? REASONING_EFFORT_OPTIONS[0]; @@ -217,6 +222,36 @@ export function UpstreamProfileEditor({ {draft.apiMode === "responses" ? ( <> + + Responses 传输 + + 已选 {(draft.responsesTransport ?? "sse") === "websocket" ? "WebSocket mode" : "HTTP SSE"} + + + )} + > +
+ {responsesTransportOptions.map((option) => { + const active = (draft.responsesTransport ?? "sse") === option.id; + return ( + onPatchDraft({ responsesTransport: option.id })} + /> + ); + })} +
+ + 这是 Responses API 的传输方式,不是 Realtime API。当前仅桌面本地内核与 Android 壳层支持 WebSocket mode。 + +
+ void; + chooseBatchInputFiles: () => void; chooseBatchOutputDir: () => void; clearSources: () => void; currentImageSavedPath?: string | null; @@ -129,6 +131,7 @@ export function WindowsComposePanel({ batchCount={batchCount} batchProcess={batchProcess} chooseBatchInputDir={chooseBatchInputDir} + chooseBatchInputFiles={chooseBatchInputFiles} chooseBatchOutputDir={chooseBatchOutputDir} clearSources={clearSources} currentImageSavedPath={currentImageSavedPath} diff --git a/image-studio/frontend/src/components/panel/sizeCapabilities.ts b/image-studio/frontend/src/components/panel/sizeCapabilities.ts index dcd82f699..7677582ad 100644 --- a/image-studio/frontend/src/components/panel/sizeCapabilities.ts +++ b/image-studio/frontend/src/components/panel/sizeCapabilities.ts @@ -408,6 +408,32 @@ export function buildResolutionSizeSelection( ); } +export function buildReferenceResolutionSizeSelection( + resolution: Exclude, + reference: { width: number; height: number } | null, + input: SizeCapabilityInput, + customRatios: CustomAspectRatio[] = [], +): SizeValue { + if (!reference || !Number.isFinite(reference.width) || !Number.isFinite(reference.height) || reference.width <= 0 || reference.height <= 0) { + return buildResolutionSizeSelection("auto", resolution, input, customRatios); + } + if (supportsCustomAspectRatios(input)) { + const customReference = buildReferenceAspectRatio(reference.width, reference.height, customRatios); + if (customReference) { + const ratios = customRatios.some((item) => item.id === customReference.id) + ? customRatios + : [...customRatios, customReference]; + return buildSizeSelection(buildCustomAspectValue(customReference.id), resolution, input, ratios); + } + } + return buildSizeSelection( + nearestBuiltinAspect(reference.width, reference.height).value, + resolution, + input, + customRatios, + ); +} + export function normalizeResolutionSelection(resolution: ResolutionPreset, input: SizeCapabilityInput): ResolutionPreset { const allowed = availableResolutionPresets(input); if (allowed.includes(resolution)) return resolution; diff --git a/image-studio/frontend/src/lib/compatState.ts b/image-studio/frontend/src/lib/compatState.ts index 6bbed1dcf..92346fa48 100644 --- a/image-studio/frontend/src/lib/compatState.ts +++ b/image-studio/frontend/src/lib/compatState.ts @@ -2,6 +2,7 @@ import { LoadCompatibilityState, SaveCompatibilityState } from "../platform/runt import type { BatchProcessConfig, BackgroundValue, + CompletionNotificationConfig, CustomAspectRatio, EditSourceMode, CompletionSoundConfig, @@ -33,6 +34,10 @@ import { normalizeCompletionSoundConfig, persistCompletionSoundConfig, } from "./completionSound"; +import { + normalizeCompletionNotificationConfig, + persistCompletionNotificationConfig, +} from "./completionNotification"; import { normalizePromptTemplates } from "./promptTemplates"; const SCHEMA_VERSION = 1; @@ -69,6 +74,7 @@ export type CompatibilityState = { cleanupPreviewCacheOnExit?: boolean; ignoredReleaseTag?: string; completionSound?: CompletionSoundConfig; + completionNotification?: CompletionNotificationConfig; }; profiles: UpstreamProfile[]; activeProfileId: string; @@ -103,6 +109,7 @@ export type CompatibilityExportInput = { cleanupPreviewCacheOnExit: boolean; ignoredReleaseTag: string; completionSound: CompletionSoundConfig; + completionNotification: CompletionNotificationConfig; }; let exportTimer: ReturnType | null = null; @@ -162,6 +169,7 @@ export function compatibilityExportFingerprint(input: CompatibilityExportInput): cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit, ignoredReleaseTag: input.ignoredReleaseTag, completionSound: input.completionSound, + completionNotification: input.completionNotification, outputDir: readLocalStorageString("gptcodex.outputDir"), trustedOutputRoots: loadTrustedOutputRoots(), savePromptSuppressed: readLocalStorageString("gptcodex.savePromptSuppressed") === "1", @@ -202,6 +210,7 @@ function buildCompatibilityState(input: CompatibilityExportInput): Compatibility cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit === true, ignoredReleaseTag: readLocalStorageString("gptcodex.ignoredReleaseTag"), completionSound: normalizeCompletionSoundConfig(input.completionSound), + completionNotification: normalizeCompletionNotificationConfig(input.completionNotification), }, profiles: normalizeProfiles(input.profiles), activeProfileId: input.activeProfileId || "", @@ -254,6 +263,7 @@ function applyCompatibilityLocalStorage(state: CompatibilityState): void { removeLocalStorage("gptcodex.ignoredReleaseTag"); } persistCompletionSoundConfig(normalizeCompletionSoundConfig(settings.completionSound)); + persistCompletionNotificationConfig(normalizeCompletionNotificationConfig(settings.completionNotification)); } async function persistCompatibilityHistory(state: CompatibilityState): Promise { @@ -320,6 +330,7 @@ function normalizeSettings(raw: unknown): CompatibilityState["settings"] { cleanupPreviewCacheOnExit: source.cleanupPreviewCacheOnExit === true, ignoredReleaseTag: typeof source.ignoredReleaseTag === "string" ? source.ignoredReleaseTag.trim() : "", completionSound: normalizeCompletionSoundConfig(source.completionSound), + completionNotification: normalizeCompletionNotificationConfig(source.completionNotification), }; } @@ -363,6 +374,7 @@ function toSerializableHistoryItem(raw: unknown): HistoryItem | null { moderation: moderationOrUndefined(item.moderation), styleTag: stringOrUndefined(item.styleTag), batchIndex: numberOrUndefined(item.batchIndex), + previewSlotIndex: numberOrUndefined(item.previewSlotIndex), elapsedSec: numberOrUndefined(item.elapsedSec), savedPath: stringOrUndefined(item.savedPath), rawPath: stringOrUndefined(item.rawPath), @@ -392,6 +404,7 @@ function historyFingerprint(item: HistoryItem) { moderation: item.moderation, styleTag: item.styleTag, batchIndex: item.batchIndex, + previewSlotIndex: item.previewSlotIndex, elapsedSec: item.elapsedSec, imageB64: item.imageB64, }; @@ -425,6 +438,7 @@ function cloneExportInput(input: CompatibilityExportInput): CompatibilityExportI cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit, ignoredReleaseTag: input.ignoredReleaseTag, completionSound: normalizeCompletionSoundConfig(input.completionSound), + completionNotification: normalizeCompletionNotificationConfig(input.completionNotification), }; } @@ -562,7 +576,11 @@ export function normalizeBatchProcess(value: unknown): BatchProcessConfig { outputMode: source.outputMode === "custom_dir" ? "custom_dir" : "source_dir", outputDir: typeof source.outputDir === "string" ? source.outputDir.trim() : "", concurrency: Number.isFinite(Number(source.concurrency)) ? Math.max(1, Math.min(9, Math.floor(Number(source.concurrency)))) : 2, + retryOnFailure: source.retryOnFailure === true, fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-", + autoAspectResolution: source.autoAspectResolution === "256" || source.autoAspectResolution === "512" || source.autoAspectResolution === "1k" || source.autoAspectResolution === "2k" || source.autoAspectResolution === "4k" + ? source.autoAspectResolution + : "", discoveredSources, }; } diff --git a/image-studio/frontend/src/lib/completionNotification.ts b/image-studio/frontend/src/lib/completionNotification.ts new file mode 100644 index 000000000..ee9c7d88c --- /dev/null +++ b/image-studio/frontend/src/lib/completionNotification.ts @@ -0,0 +1,86 @@ +import type { + CompletionNotificationConfig, + SystemNotificationPermissionState, +} from "../types/domain"; + +const COMPLETION_NOTIFICATION_ENABLED_KEY = "gptcodex.completionNotification.enabled"; + +export function normalizeCompletionNotificationConfig(raw: unknown): CompletionNotificationConfig { + const source = raw && typeof raw === "object" ? raw as Record : {}; + return { + enabled: source.enabled !== false, + }; +} + +export function readCompletionNotificationConfig(): CompletionNotificationConfig { + try { + return normalizeCompletionNotificationConfig({ + enabled: localStorage.getItem(COMPLETION_NOTIFICATION_ENABLED_KEY) !== "0", + }); + } catch { + return normalizeCompletionNotificationConfig(null); + } +} + +export function persistCompletionNotificationConfig(value: CompletionNotificationConfig): void { + const next = normalizeCompletionNotificationConfig(value); + try { + localStorage.setItem(COMPLETION_NOTIFICATION_ENABLED_KEY, next.enabled ? "1" : "0"); + } catch { + // localStorage can be unavailable in tests/previews. + } +} + +export function readSystemNotificationPermission(): SystemNotificationPermissionState { + try { + if (typeof Notification === "undefined") return "unsupported"; + if (Notification.permission === "granted") return "granted"; + if (Notification.permission === "denied") return "denied"; + return "default"; + } catch { + return "unsupported"; + } +} + +export async function requestSystemNotificationPermission(): Promise { + const current = readSystemNotificationPermission(); + if (current === "unsupported" || current === "granted") return current; + try { + if (typeof Notification === "undefined" || typeof Notification.requestPermission !== "function") { + return current; + } + const next = await Notification.requestPermission(); + if (next === "granted" || next === "denied" || next === "default") return next; + } catch {} + return readSystemNotificationPermission(); +} + +export function shouldSendCompletionNotification(input: { + config: CompletionNotificationConfig; + completedNow: number; + totalNow: number; + windowHidden: boolean; +}): boolean { + const next = normalizeCompletionNotificationConfig(input.config); + return next.enabled + && input.windowHidden + && input.totalNow > 0 + && input.completedNow === input.totalNow; +} + +export function showSystemNotification(title: string, body: string, onClick?: () => void): boolean { + if (readSystemNotificationPermission() !== "granted") return false; + try { + const notification = new Notification(title, { body }); + if (onClick) { + notification.onclick = () => { + try { window.focus(); } catch {} + onClick(); + notification.close(); + }; + } + return true; + } catch { + return false; + } +} diff --git a/image-studio/frontend/src/lib/images.ts b/image-studio/frontend/src/lib/images.ts index 0023f4ad1..23bdb6112 100644 --- a/image-studio/frontend/src/lib/images.ts +++ b/image-studio/frontend/src/lib/images.ts @@ -181,6 +181,24 @@ export function mediaFullUrlFromImageId(imageId?: string | null): string { return imageId ? `/media/full/${imageId}` : ""; } +export function sortHistoryItemsByCreatedAtAsc(items: T[]): T[] { + return [...items].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); +} + +export function orderedNavigationItemsForCurrent( + currentId: string | null | undefined, + historyItems: T[], + batchItems: T[], +): T[] { + const orderedBatchItems = sortHistoryItemsByCreatedAtAsc(batchItems); + if (currentId && orderedBatchItems.some((item) => item.id === currentId)) return orderedBatchItems; + + const orderedHistoryItems = sortHistoryItemsByCreatedAtAsc(historyItems); + if (currentId && orderedHistoryItems.some((item) => item.id === currentId)) return orderedHistoryItems; + + return orderedBatchItems.length > 1 ? orderedBatchItems : orderedHistoryItems; +} + export function isTransientPreviewItem( item: { id?: string | null; diff --git a/image-studio/frontend/src/lib/profiles.ts b/image-studio/frontend/src/lib/profiles.ts index 1617c08a1..25a3a3114 100644 --- a/image-studio/frontend/src/lib/profiles.ts +++ b/image-studio/frontend/src/lib/profiles.ts @@ -1,4 +1,4 @@ -import type { APIMode, ReasoningEffortValue, RequestPolicy, UpstreamProfile } from "../types/domain"; +import type { APIMode, ReasoningEffortValue, RequestPolicy, ResponsesTransport, UpstreamProfile } from "../types/domain"; function normalizeReasoningEffort(value: unknown): ReasoningEffortValue { return value === "low" || value === "medium" || value === "high" || value === "xhigh" @@ -6,6 +6,10 @@ function normalizeReasoningEffort(value: unknown): ReasoningEffortValue { : "xhigh"; } +export function normalizeResponsesTransport(value: unknown): ResponsesTransport { + return value === "websocket" ? "websocket" : "sse"; +} + // localStorage 键名规范: // gptcodex.profiles —— UpstreamProfile[] JSON(无 apiKey,key 在 keyring) // gptcodex.activeProfileId —— 当前 active profile 的 id @@ -52,6 +56,7 @@ export function tryParseProfile(raw: unknown): UpstreamProfile | null { const id = typeof o.id === "string" ? o.id : ""; const name = typeof o.name === "string" ? o.name : ""; const apiMode = o.apiMode === "images" ? "images" : "responses"; + const responsesTransport = normalizeResponsesTransport(o.responsesTransport); const requestPolicy = o.requestPolicy === "compat" ? "compat" : "openai"; const imagesNewAPICompat = o.imagesNewAPICompat === true; const baseURL = typeof o.baseURL === "string" ? o.baseURL : ""; @@ -68,6 +73,7 @@ export function tryParseProfile(raw: unknown): UpstreamProfile | null { id, name, apiMode, + responsesTransport, requestPolicy, imagesNewAPICompat, baseURL, @@ -113,6 +119,7 @@ export function makeBlankProfile(apiMode: APIMode = "responses", profiles: Upstr id: genProfileId(), name: nextDefaultProfileName(profiles), apiMode, + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, baseURL: "", diff --git a/image-studio/frontend/src/lib/savePromptState.ts b/image-studio/frontend/src/lib/savePromptState.ts new file mode 100644 index 000000000..ed5cde231 --- /dev/null +++ b/image-studio/frontend/src/lib/savePromptState.ts @@ -0,0 +1,13 @@ +import type { HistoryItem } from "../types/domain"; + +export type SavePromptRequest = + | { kind: "single"; item: HistoryItem } + | { kind: "batch"; items: HistoryItem[]; workspaceId: string }; + +export function normalizeSavePromptRequest(request: SavePromptRequest): SavePromptRequest | null { + if (request.kind === "single") { + return request.item ? request : null; + } + if (request.items.length === 0) return null; + return request; +} diff --git a/image-studio/frontend/src/lib/saveResultImage.ts b/image-studio/frontend/src/lib/saveResultImage.ts index c976cafaa..59288e34f 100644 --- a/image-studio/frontend/src/lib/saveResultImage.ts +++ b/image-studio/frontend/src/lib/saveResultImage.ts @@ -22,3 +22,11 @@ export async function saveHistoryItemToDirectoryAs(item: HistoryItem, directory: if (item.imageB64) return SaveImageToDir(item.imageB64, directory, suggestedName); throw new Error("当前图片没有可保存内容"); } + +export async function saveHistoryItemsToDirectory(items: HistoryItem[], directory: string): Promise { + const saved: string[] = []; + for (const item of items) { + saved.push(await saveHistoryItemToDirectory(item, directory)); + } + return saved; +} diff --git a/image-studio/frontend/src/lib/upstreamConfigTransfer.ts b/image-studio/frontend/src/lib/upstreamConfigTransfer.ts index 73c38f9b3..7c01a3d9d 100644 --- a/image-studio/frontend/src/lib/upstreamConfigTransfer.ts +++ b/image-studio/frontend/src/lib/upstreamConfigTransfer.ts @@ -1,9 +1,10 @@ -import type { APIMode, RequestPolicy, ReasoningEffortValue, UpstreamProfile } from "../types/domain"; +import type { APIMode, RequestPolicy, ReasoningEffortValue, ResponsesTransport, UpstreamProfile } from "../types/domain"; export type UpstreamConfigExportProfile = { id: string; name: string; apiMode: APIMode; + responsesTransport: ResponsesTransport; requestPolicy: RequestPolicy; imagesNewAPICompat: boolean; baseURL: string; @@ -37,6 +38,10 @@ function normalizeRequestPolicy(value: unknown): RequestPolicy { return value === "compat" ? "compat" : "openai"; } +function normalizeResponsesTransport(value: unknown): ResponsesTransport { + return value === "websocket" ? "websocket" : "sse"; +} + function normalizeReasoningEffort(value: unknown): ReasoningEffortValue { return value === "low" || value === "medium" || value === "high" || value === "xhigh" ? value @@ -58,6 +63,7 @@ function parseExportProfile(raw: unknown): UpstreamConfigExportProfile | null { id, name, apiMode: normalizeAPIMode(source.apiMode), + responsesTransport: normalizeResponsesTransport(source.responsesTransport), requestPolicy: normalizeRequestPolicy(source.requestPolicy), imagesNewAPICompat: source.imagesNewAPICompat === true, baseURL: typeof source.baseURL === "string" ? source.baseURL.trim() : "", @@ -83,6 +89,7 @@ export function buildUpstreamConfigExportFile( activeProfileId, profiles: profiles.map((profile) => ({ ...profile, + responsesTransport: profile.responsesTransport === "websocket" ? "websocket" : "sse", imagesNewAPICompat: profile.imagesNewAPICompat === true, apiKey: (apiKeysById[profile.id] ?? "").trim(), })), diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx index b6ddacefd..530b315d7 100644 --- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx +++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx @@ -10,7 +10,7 @@ import { CompareOverlay } from "../../../components/canvas/CompareOverlay"; import { copyImageB64ToClipboard, copyImageURLToClipboard, useImageFromSource } from "../../../components/canvas/canvasImage"; import { StreamPreviewBadge } from "../../../components/canvas/StreamPreviewBadge"; import { useCanvasShortcuts } from "../../../components/canvas/useCanvasShortcuts"; -import { historyFullSrc } from "../../../lib/images"; +import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../../lib/images"; import { streamPreviewItemsFromPreviews } from "../../../state/studioStore.streamPreview"; import { vibrateForPlatform } from "../bridge"; @@ -43,6 +43,7 @@ export function AndroidCanvasStage() { jobsCompleted, activeWorkspaceId, toggleFullscreen, + history, batchResults, resultGridOpen, selectBatchResult, closeResultGrid, canvasViewResetTick, stepBatchResult, @@ -56,22 +57,30 @@ export function AndroidCanvasStage() { outputFormat: useStudioStore.getState().outputFormat, currentImage, }); - const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); + const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); + const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); + const liveBatchSlotCount = runningJobs.length; const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); - for (const item of batchResults) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length) liveBatchSlots[index] = { type: "result", item }; + for (const item of [...orderedBatchResults].reverse()) { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + liveBatchSlots[index] = { type: "result", item }; + } } for (const item of streamPreviewItems) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length) { liveBatchSlots[index] = { type: "preview", item }; } } - const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1; + const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0; const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1); - const currentBatchIndex = currentImage ? batchResults.findIndex((item) => item.id === currentImage.id) : -1; - const canNavigateBatchResults = currentBatchIndex >= 0 && batchResults.length > 1; + const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; + const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; const stageRef = useRef(null); const roRef = useRef(null); @@ -463,13 +472,14 @@ export function AndroidCanvasStage() { > {showingResultGrid ? ( ) : null} {!showingResultGrid && currentImage && compareB ? ( diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx index 72056a3ae..a7e8339d0 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx @@ -152,6 +152,25 @@ export function AndroidUpstreamProfileForm({ {draft.apiMode === "responses" ? ( <> + +
+ {[ + { id: "sse", title: "HTTP SSE", meta: "默认,兼容性更稳" }, + { id: "websocket", title: "WebSocket", meta: "需要上游支持" }, + ].map((option) => ( + + ))} +
+
+ 0 - ? `已加载 ${catalog.all.length} 个模型` - : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, - catalog.all.length > 0 ? "success" : "warn", - ); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + pushToast( + `已拉取模型,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, + "warn", + 7000, + ); + } else { + pushToast( + result.responsesTransport === "websocket" + ? `已加载 ${catalog.all.length} 个模型,Responses WebSocket 可用` + : catalog.all.length > 0 + ? `已加载 ${catalog.all.length} 个模型` + : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, + catalog.all.length > 0 ? "success" : "warn", + ); + } } catch (error: any) { const message = `加载模型失败:${error?.message ?? error}`; setModelCatalogError(message); diff --git a/image-studio/frontend/src/platform/runtime/host.ts b/image-studio/frontend/src/platform/runtime/host.ts index 22fd2a085..d56ec03bd 100644 --- a/image-studio/frontend/src/platform/runtime/host.ts +++ b/image-studio/frontend/src/platform/runtime/host.ts @@ -57,6 +57,7 @@ import type { ProbeUpstreamResultLike, PromptOptimizeOptionsLike, SelectFileResponseLike, + SelectFilesResponseLike, } from "./hostTypes.ts"; const remoteJobControllers = new Map(); @@ -356,6 +357,13 @@ export function OpenImageDialog(): Promise { return openImageDialogFallback(); } +export function OpenImagesDialog(): Promise { + if (hasServiceMethod("OpenImagesDialog")) { + return invokeService(unsupportedMessage, "OpenImagesDialog"); + } + return Promise.reject(new Error(unsupportedMessage("OpenImagesDialog"))); +} + export function ChooseBatchInputDir(): Promise { if (hasServiceMethod("ChooseBatchInputDir")) { return invokeService(unsupportedMessage, "ChooseBatchInputDir"); @@ -718,10 +726,19 @@ export async function probeCurrentUpstream( apiKey: string, proxyMode = "system", proxyURL = "", + apiMode = "responses", + responsesTransport = "sse", signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - const options: ProbeUpstreamOptionsLike = { baseURL, apiKey, proxyMode, proxyURL }; + const options: ProbeUpstreamOptionsLike = { + baseURL, + apiKey, + proxyMode, + proxyURL, + apiMode, + responsesTransport, + }; if (hasServiceMethod("ProbeUpstream")) { return invokeService(unsupportedMessage, "ProbeUpstream", options); } diff --git a/image-studio/frontend/src/platform/runtime/hostTypes.ts b/image-studio/frontend/src/platform/runtime/hostTypes.ts index 2da022f42..2577fd3fc 100644 --- a/image-studio/frontend/src/platform/runtime/hostTypes.ts +++ b/image-studio/frontend/src/platform/runtime/hostTypes.ts @@ -25,6 +25,7 @@ export type GenerateOptionsLike = { proxyMode?: string; proxyURL?: string; apiMode: string; + responsesTransport?: string; requestPolicy: string; imagesNewAPICompat?: boolean; noPromptRevision: boolean; @@ -37,6 +38,7 @@ export type GenerateOptionsLike = { imageModelID: string; reasoningEffort?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; }; @@ -69,11 +71,16 @@ export type ProbeUpstreamOptionsLike = { baseURL: string; proxyMode?: string; proxyURL?: string; + apiMode?: string; + responsesTransport?: string; }; export type ProbeUpstreamResultLike = { modelCount: number; models?: UpstreamModelDescriptorLike[]; + responsesTransport?: string; + responsesTransportOK?: boolean; + responsesTransportError?: string; }; export type UpstreamModelDescriptorLike = { @@ -103,6 +110,8 @@ export type BatchInputImageLike = { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; @@ -111,6 +120,9 @@ export type BatchInputDirectoryLike = { directory: string; images: BatchInputImageLike[]; }; +export type SelectFilesResponseLike = { + files: BatchInputImageLike[]; +}; export type ImageTransformResultLike = { path: string; acceleration?: string }; export type SelectFileResponseLike = { path: string; diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts new file mode 100644 index 000000000..ec3a64887 --- /dev/null +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts @@ -0,0 +1,62 @@ +import { invokeAndroidNative } from "../../android/nativeInvoke.ts"; +import type { NativeTextResponse } from "./types.ts"; + +type NativeProgressWindow = Window & { + __imageStudioNativeProgress?: (requestId: string, payload: unknown) => void; +}; + +const nativeWSProgressHandlers = new Map void>(); +let wsProgressHookInstalled = false; +let wsProgressHookWindow: NativeProgressWindow | null = null; + +function ensureAndroidWSProgressHook() { + if (typeof window === "undefined") return; + const browserWindow = window as NativeProgressWindow; + if (wsProgressHookInstalled && wsProgressHookWindow === browserWindow) return; + const previous = browserWindow.__imageStudioNativeProgress; + browserWindow.__imageStudioNativeProgress = (requestId, payload) => { + const handler = nativeWSProgressHandlers.get(requestId); + if (handler) { + handler(payload); + return; + } + previous?.(requestId, payload); + }; + wsProgressHookInstalled = true; + wsProgressHookWindow = browserWindow; +} + +export async function nativeWebSocketResponsesRequest( + requestKey: string, + baseURL: string, + apiKey: string, + payload: string, + signal?: AbortSignal, + onStreamPayload?: (payload: unknown) => void, + proxyConfig?: { proxyMode?: string; proxyURL?: string }, +): Promise { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + ensureAndroidWSProgressHook(); + if (onStreamPayload) nativeWSProgressHandlers.set(requestKey, onStreamPayload); + let aborted = false; + const onAbort = () => { + aborted = true; + void invokeAndroidNative("CancelWebSocketRequest", requestKey).catch(() => undefined); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const response = await invokeAndroidNative("ResponsesWebSocketRequest", { + requestKey, + baseURL, + apiKey, + payload, + proxyMode: proxyConfig?.proxyMode || "system", + proxyURL: proxyConfig?.proxyURL || "", + }); + if (aborted) throw new DOMException("Aborted", "AbortError"); + return response; + } finally { + nativeWSProgressHandlers.delete(requestKey); + signal?.removeEventListener("abort", onAbort); + } +} diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts index 62a479cbc..52d74a2c9 100644 --- a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts @@ -7,6 +7,7 @@ import { shouldUseAndroidNativeHTTP, } from "./common.ts"; import { nativeHttpRequestText } from "./nativeHttp.ts"; +import { nativeWebSocketResponsesRequest } from "./nativeWebSocket.ts"; import { buildResponsesPayload } from "./requestPayloads.ts"; import { MAX_ATTEMPTS, @@ -18,6 +19,19 @@ import { type RemoteJobResult, } from "./types.ts"; +type ResponsesWSRunStateSnapshot = { + attemptIndex: number; + socketEpoch: number; + createdAt: number; + lastActivityAt: number; + requestPayload: string; + latestEventType: string; + partialPreviewCount: number; + hasFinalImage: boolean; + completed: boolean; + cancelled: boolean; +}; + function summarizeSSELine(line: string): string { const stripped = line.trim(); if (!stripped) return ""; @@ -120,6 +134,21 @@ function emitPartialPreview(event: any, callbacks: RemoteJobCallbacks) { }); } +function updateRunStateFromEvent(state: ResponsesWSRunStateSnapshot, event: any) { + if (!event?.type) return; + state.lastActivityAt = Date.now(); + state.latestEventType = event.type; + if (event.type === "response.image_generation_call.partial_image") { + state.partialPreviewCount += 1; + } + if (event.type === "response.output_item.done" && event?.item?.type === "image_generation_call" && event?.item?.result) { + state.hasFinalImage = true; + } + if (event.type === "response.completed") { + state.completed = true; + } +} + function walkForImageCall(value: any): any | null { if (!value) return null; if (Array.isArray(value)) { @@ -181,6 +210,22 @@ function extractImageResult(raw: string): ExtractedImageResult | null { return null; } +function buildWebSocketCreatePayload(body: string): string { + const parsed = JSON.parse(body) as Record; + delete parsed.stream; + delete parsed.background; + parsed.type = "response.create"; + return JSON.stringify(parsed); +} + +function isWebSocketHandshakeFailure(error: unknown): boolean { + const message = String((error as any)?.message || error || "").toLowerCase(); + return message.includes("bad handshake") + || message.includes("upgrade: websocket") + || message.includes("websocket upgrade required") + || message.includes("websocket handshake failed"); +} + export async function requestResponsesOnce( request: RemoteJobRequest, attempt: number, @@ -200,14 +245,28 @@ export async function requestResponsesOnce( }, STATUS_INTERVAL_MS); try { const proxyMode = request.payload.proxyMode === "none" || request.payload.proxyMode === "custom" ? request.payload.proxyMode : "system"; + const responsesTransport = request.payload.responsesTransport === "websocket" ? "websocket" : "sse"; if (shouldUseAndroidNativeHTTP()) { let receivedNativeStreamPayload = false; + const runState: ResponsesWSRunStateSnapshot = { + attemptIndex: attempt, + socketEpoch: 1, + createdAt: Date.now(), + lastActivityAt: Date.now(), + requestPayload: body, + latestEventType: "", + partialPreviewCount: 0, + hasFinalImage: false, + completed: false, + cancelled: false, + }; const consumeNativePayload = (payload: unknown) => { receivedNativeStreamPayload = true; const parsed = parseNativeProgressPayload(payload); if (parsed.line) { bytesReceived += parsed.line.length + 1; } + updateRunStateFromEvent(runState, parsed.event); emitPartialPreview(parsed.event, callbacks); const summary = parsed.event ? summarizeSSEEvent(parsed.event) : summarizeSSELine(parsed.line); if (summary) { @@ -216,19 +275,63 @@ export async function requestResponsesOnce( callbacks.onProgress?.(lastStage, nowSeconds(startedAt), bytesReceived); } }; - const response = await nativeHttpRequestText( - url, - "POST", - { - Authorization: `Bearer ${request.payload.apiKey}`, - "Content-Type": "application/json", - Accept: "text/event-stream, application/json", - }, - body, - callbacks.signal, - consumeNativePayload, - { proxyMode, proxyURL: request.payload.proxyURL || "" }, - ); + const requestOnce = async (socketEpoch: number) => { + runState.socketEpoch = socketEpoch; + return responsesTransport === "websocket" + ? await nativeWebSocketResponsesRequest( + `native-ws-${attempt}-${socketEpoch}-${Math.random().toString(36).slice(2, 10)}`, + normalizeBaseURL(request.payload.baseURL), + request.payload.apiKey, + buildWebSocketCreatePayload(body), + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ) + : await nativeHttpRequestText( + url, + "POST", + { + Authorization: `Bearer ${request.payload.apiKey}`, + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + }, + body, + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ); + }; + let response: Awaited>; + try { + response = await requestOnce(1); + } catch (error) { + if (responsesTransport === "websocket" && isWebSocketHandshakeFailure(error)) { + callbacks.onLog?.("Responses WebSocket 握手失败,当前上游不兼容该 WS 路径,自动切回 HTTP SSE..."); + response = await nativeHttpRequestText( + url, + "POST", + { + Authorization: `Bearer ${request.payload.apiKey}`, + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + }, + body, + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ); + } else if (responsesTransport === "websocket" && !runState.hasFinalImage) { + callbacks.onLog?.("WebSocket 连接中断,正在重新连接并重放本次生成..."); + try { + response = await requestOnce(2); + } catch (retryError) { + callbacks.onLog?.(`WebSocket 重连失败: ${String((retryError as any)?.message || retryError)}`); + throw retryError; + } + } else { + throw error; + } + } raw = response.body || ""; if (response.resultImageB64) { return { diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts index d7efb32d2..ba2be8f54 100644 --- a/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts @@ -33,6 +33,7 @@ export type RemoteGeneratePayload = { proxyMode?: string; proxyURL?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; noPromptRevision: boolean; @@ -46,6 +47,7 @@ export type RemoteGeneratePayload = { imageModelID: string; reasoningEffort?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; }; diff --git a/image-studio/frontend/src/state/studioStore.images.ts b/image-studio/frontend/src/state/studioStore.images.ts index dd7c2cf89..accb15246 100644 --- a/image-studio/frontend/src/state/studioStore.images.ts +++ b/image-studio/frontend/src/state/studioStore.images.ts @@ -1,6 +1,7 @@ import { ChooseBatchInputDir, ListBatchInputImages, + OpenImagesDialog, OpenImageDialog, ImportImageFromB64, RegisterImportedImageAsset, @@ -68,6 +69,8 @@ export function createImageActions(store: StateAdapter) { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; @@ -76,6 +79,8 @@ export function createImageActions(store: StateAdapter) { path: source.path, name: source.name, size: source.size, + width: source.width, + height: source.height, previewUrl: source.previewUrl, previewWidth: source.previewWidth, previewHeight: source.previewHeight, @@ -336,6 +341,33 @@ export function createImageActions(store: StateAdapter) { } }, + async chooseBatchInputFiles() { + try { + const result = await OpenImagesDialog(); + if (!result?.files?.length) return; + store.setState((state) => { + const existing = new Map(state.batchProcess.discoveredSources.map((item) => [item.path, item])); + for (const file of result.files) { + existing.set(file.path, mapBatchSource(file)); + } + return { + mode: "edit", + editSourceMode: "batch", + batchProcess: { + ...state.batchProcess, + enabled: true, + discoveredSources: Array.from(existing.values()), + }, + errorMessage: null, + errorCanRetry: false, + errorRawPath: null, + }; + }); + } catch (error: any) { + store.setState({ errorMessage: `选择批处理图片失败:${error?.message ?? error}`, errorCanRetry: false, errorRawPath: null }); + } + }, + async refreshBatchInputDir() { const { batchProcess } = store.getState(); if (!batchProcess.inputDir.trim()) return; diff --git a/image-studio/frontend/src/state/studioStore.media.ts b/image-studio/frontend/src/state/studioStore.media.ts index b63eb635d..41a05593e 100644 --- a/image-studio/frontend/src/state/studioStore.media.ts +++ b/image-studio/frontend/src/state/studioStore.media.ts @@ -18,7 +18,7 @@ import { buildPresetPatch, pickPresetStateSnapshot, } from "../lib/presets"; -import { base64ToBlob } from "../lib/images"; +import { base64ToBlob, orderedNavigationItemsForCurrent } from "../lib/images"; import { persistHistoryItems } from "../lib/storage"; import type { HistoryItem, Preset, Toast } from "../types/domain"; import type { StudioState } from "./studioStore.types"; @@ -104,11 +104,17 @@ export function createMediaActions(store: StateAdapter) { async stepBatchResult(delta: -1 | 1) { const state = store.getState(); - if (state.batchResults.length <= 1 || !state.currentImage) return; - const currentIndex = state.batchResults.findIndex((item) => item.id === state.currentImage?.id); + if (!state.currentImage) return; + const navigationItems = orderedNavigationItemsForCurrent( + state.currentImage.id, + state.history, + state.batchResults, + ); + if (navigationItems.length <= 1) return; + const currentIndex = navigationItems.findIndex((item) => item.id === state.currentImage?.id); if (currentIndex < 0) return; - const nextIndex = (currentIndex + delta + state.batchResults.length) % state.batchResults.length; - const nextItem = state.batchResults[nextIndex]; + const nextIndex = (currentIndex + delta + navigationItems.length) % navigationItems.length; + const nextItem = navigationItems[nextIndex]; if (!nextItem) return; await store.getState().selectBatchResult(nextItem); }, diff --git a/image-studio/frontend/src/state/studioStore.profiles.ts b/image-studio/frontend/src/state/studioStore.profiles.ts index e5560656e..3e96cb083 100644 --- a/image-studio/frontend/src/state/studioStore.profiles.ts +++ b/image-studio/frontend/src/state/studioStore.profiles.ts @@ -10,6 +10,7 @@ import { genProfileId, keyringUserFor, nextDefaultProfileName, + normalizeResponsesTransport, pickActiveProfile, } from "../lib/profiles"; import { cleanBaseURL } from "../lib/security"; @@ -26,6 +27,7 @@ export function createProfileActions(store: StateAdapter) { async createProfile(input: { name?: string; apiMode: APIMode; + responsesTransport?: UpstreamProfile["responsesTransport"]; baseURL?: string; requestPolicy?: RequestPolicy; imagesNewAPICompat?: boolean; @@ -42,6 +44,7 @@ export function createProfileActions(store: StateAdapter) { id, name: input.name?.trim() || nextDefaultProfileName(list), apiMode: input.apiMode, + responsesTransport: normalizeResponsesTransport(input.apiMode === "responses" ? input.responsesTransport : "sse"), requestPolicy: input.requestPolicy ?? "openai", imagesNewAPICompat: input.imagesNewAPICompat === true, baseURL: cleanBaseURL(input.baseURL ?? ""), @@ -76,6 +79,9 @@ export function createProfileActions(store: StateAdapter) { ...current, name: patch.name !== undefined ? patch.name.trim() : current.name, apiMode: patch.apiMode ?? current.apiMode, + responsesTransport: patch.responsesTransport !== undefined + ? normalizeResponsesTransport(patch.responsesTransport) + : normalizeResponsesTransport(current.responsesTransport), requestPolicy: patch.requestPolicy ?? current.requestPolicy, imagesNewAPICompat: patch.imagesNewAPICompat ?? current.imagesNewAPICompat ?? false, baseURL: patch.baseURL !== undefined ? cleanBaseURL(patch.baseURL) : current.baseURL, @@ -100,6 +106,7 @@ export function createProfileActions(store: StateAdapter) { const apiKey = patch.apiKey !== undefined ? patch.apiKey.trim() : store.getState().apiKey; store.setState({ apiMode: next.apiMode, + responsesTransport: next.responsesTransport ?? "sse", requestPolicy: next.requestPolicy, imagesNewAPICompat: next.imagesNewAPICompat ?? false, baseURL: next.baseURL, @@ -138,6 +145,7 @@ export function createProfileActions(store: StateAdapter) { imageModelID: "", reasoningEffort: "xhigh", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, upstreamModalOpen: false, @@ -176,6 +184,7 @@ export function createProfileActions(store: StateAdapter) { profiles: nextProfiles, activeProfileId: id, apiMode: profile.apiMode, + responsesTransport: profile.responsesTransport ?? "sse", requestPolicy: profile.requestPolicy, imagesNewAPICompat: profile.imagesNewAPICompat ?? false, baseURL: profile.baseURL, diff --git a/image-studio/frontend/src/state/studioStore.streamPreview.ts b/image-studio/frontend/src/state/studioStore.streamPreview.ts index 5852819b9..9f4ddc662 100644 --- a/image-studio/frontend/src/state/studioStore.streamPreview.ts +++ b/image-studio/frontend/src/state/studioStore.streamPreview.ts @@ -23,6 +23,7 @@ export type StreamPreviewPayload = { previewHeight?: number; revisedPrompt?: string; partialImageIndex?: number; + previewSlotIndex?: number; mode?: string; prompt?: string; }; @@ -36,6 +37,7 @@ export type StreamPreviewSnapshot = { outputFormat: OutputFormatValue; currentImage: HistoryItem | null; batchIndex?: number; + previewSlotIndex?: number; }; export function latestStreamPreview(previews: StreamPreviewMap | null | undefined): StreamPreview | null { @@ -75,6 +77,7 @@ export function streamPreviewItemFromPayload( parentId: mode === "edit" ? snapshot.currentImage?.savedPath : undefined, createdAt: Date.now(), batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, previewOnly: true, }; } @@ -101,6 +104,7 @@ export function streamPreviewStatePatch( revisedPrompt: item.revisedPrompt, partialImageIndex: payload.partialImageIndex, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, updatedAt: Date.now(), }; const streamPreviews = { ...previousPreviews, [jobId]: nextPreview }; @@ -185,6 +189,7 @@ export function streamPreviewItemFromWorkspace( outputFormat: workspace.outputFormat, currentImage, batchIndex: preview.batchIndex, + previewSlotIndex: preview.previewSlotIndex, }); } diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index f2585843a..20509edd7 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -69,6 +69,7 @@ import { } from "../lib/compatState"; import { normalizeAppUpdateInfo } from "../lib/appUpdate.ts"; import { appVersion } from "../lib/version.ts"; +import { normalizeSavePromptRequest, type SavePromptRequest } from "../lib/savePromptState"; import { readSavePromptSuppressed, writeSavePromptSuppressed, @@ -80,6 +81,15 @@ import { readCompletionSoundConfig, shouldPlayCompletionSound, } from "../lib/completionSound"; +import { + normalizeCompletionNotificationConfig, + persistCompletionNotificationConfig, + readCompletionNotificationConfig, + readSystemNotificationPermission, + requestSystemNotificationPermission, + shouldSendCompletionNotification, + showSystemNotification, +} from "../lib/completionNotification"; import { persistPromptTemplates, readStoredPromptTemplates, @@ -125,6 +135,7 @@ import { getStreamPreviewDisableReason } from "./streamPreviewPolicy"; import { buildAspectSizeSelection, buildExactSizeValue, + buildReferenceResolutionSizeSelection, buildCustomAspectValue, deriveAspectPreset, deriveResolutionPreset, @@ -158,9 +169,9 @@ import type { ModeConfig, PromptOptimizeRequest, Stroke, StudioState, UndoEntry import { cryptoIDFallback, ensureFullHistoryItem as ensureFullHistoryItemRuntime, + historyItemsByIds, materializeHistoryItem as materializeHistoryItemRuntime, STYLE_SUFFIXES, - tryNotify, withMediaAssetRef, } from "./studioStore.runtime"; import { createMediaActions } from "./studioStore.media"; @@ -185,6 +196,7 @@ type JobSnapshot = { workspaceId: string; apiMode: APIModeValue; batchIndex: number; + previewSlotIndex?: number; size: SizeValue; quality: QualityValue; outputFormat: OutputFormatValue; @@ -205,12 +217,20 @@ type LoopRunController = { totalJobs: number; maxConcurrent: number; launchedJobs: number; + nextPreviewSlotIndex: number; mode: Mode; payload: RuntimeGenerateOptions; snapshotBase: Omit; batchSources?: BatchProcessSourceImage[]; batchOutputDir?: string; batchOutputPrefix?: string; + batchAutoAspectResolution?: Exclude | ""; + batchCapabilityInput?: { + apiMode: APIMode; + requestPolicy: RequestPolicy; + imageModelID?: string; + }; + batchCustomAspectRatios?: import("../types/domain").CustomAspectRatio[]; stopped: boolean; }; @@ -238,7 +258,8 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { return; } const runtime = workspaceRuntimeFromState(state, controller.workspaceId); - if (runtime.jobsTotal === 0) { + const batchQueueMode = Array.isArray(controller.batchSources) && controller.batchSources.length > 0; + if (!batchQueueMode && runtime.jobsTotal === 0) { stopLoopRun(controller.workspaceId); return; } @@ -246,20 +267,35 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { while (!controller.stopped && controller.launchedJobs < controller.totalJobs) { const latestState = useStudioStore.getState(); const latestRuntime = workspaceRuntimeFromState(latestState, controller.workspaceId); - if (latestRuntime.jobsTotal === 0 || latestRuntime.runningJobs.length >= controller.maxConcurrent) break; + if ((!batchQueueMode && latestRuntime.jobsTotal === 0) || latestRuntime.runningJobs.length >= controller.maxConcurrent) break; const batchIndex = controller.launchedJobs; controller.launchedJobs += 1; + const previewSlotIndex = controller.nextPreviewSlotIndex; + controller.nextPreviewSlotIndex = (controller.nextPreviewSlotIndex + 1) % Math.max(1, controller.maxConcurrent); const payloadSeed = controller.payload.seed ? controller.payload.seed + batchIndex : 0; const batchSource = controller.batchSources?.[batchIndex]; + const batchPayloadSize = batchSource + && controller.snapshotBase.editSourceMode === "batch" + && controller.batchAutoAspectResolution + && controller.batchCapabilityInput + ? buildReferenceResolutionSizeSelection( + controller.batchAutoAspectResolution, + batchSource.width && batchSource.height ? { width: batchSource.width, height: batchSource.height } : null, + controller.batchCapabilityInput, + controller.batchCustomAspectRatios ?? [], + ) + : controller.payload.size; const nextPayload: RuntimeGenerateOptions = { ...controller.payload, seed: payloadSeed, + size: batchPayloadSize, imagePaths: batchSource ? [batchSource.path] : controller.payload.imagePaths, sourceImages: batchSource ? [batchSource] : controller.payload.sourceImages, }; void launchOneJob(controller.mode, nextPayload, { ...controller.snapshotBase, batchIndex, + previewSlotIndex, sources: batchSource ? [batchSource as SourceImage] : controller.snapshotBase.sources, batchProcessLink: batchSource ? { @@ -272,13 +308,18 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { onSettled: (status) => { const current = loopRunControllers.get(controller.workspaceId); if (!current || current !== controller) return; - if (status === "error") { + if (status === "error" && !batchQueueMode) { stopLoopRun(controller.workspaceId); return; } const currentState = useStudioStore.getState(); const currentRuntime = workspaceRuntimeFromState(currentState, controller.workspaceId); - if (currentRuntime.jobsTotal === 0) { + if (batchQueueMode) { + if (current.launchedJobs >= current.totalJobs && currentRuntime.runningJobs.length === 0) { + stopLoopRun(controller.workspaceId); + return; + } + } else if (currentRuntime.jobsTotal === 0) { stopLoopRun(controller.workspaceId); return; } @@ -440,6 +481,36 @@ function mergeHistoryMediaRef(current: HistoryItem | null, nextById: Map entry.id === workspaceId); + const items = historyItemsByIds(state.history, workspace?.batchResultIds ?? []).sort((a, b) => { + const aIndex = typeof a.batchIndex === "number" ? a.batchIndex : Number.MAX_SAFE_INTEGER; + const bIndex = typeof b.batchIndex === "number" ? b.batchIndex : Number.MAX_SAFE_INTEGER; + if (aIndex !== bIndex) return aIndex - bIndex; + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a.id.localeCompare(b.id); + }); + if (items.length === 0) return null; + return { + kind: "batch", + items, + workspaceId, + }; +} + +function maybeEnqueueBatchSavePrompt( + store: typeof useStudioStore, + workspaceId: string, + completedNow: number, + totalNow: number, +): void { + if (totalNow <= 1 || completedNow !== totalNow) return; + const batchRequest = batchSavePromptRequestFromState(store.getState(), workspaceId); + if (batchRequest) { + store.getState().enqueueSavePrompt(batchRequest); + } +} + async function hydrateHistoryPreviewRefs(items: HistoryItem[]): Promise { return mapWithConcurrency(items, HISTORY_MEDIA_HYDRATE_CONCURRENCY, async (item) => { if (!needsHistoryPreviewHydration(item)) return item; @@ -542,6 +613,7 @@ export const useStudioStore = create((set, get) => ({ proxyMode: "system", proxyURL: "", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, noPromptRevision: true, @@ -595,12 +667,14 @@ export const useStudioStore = create((set, get) => ({ fullscreen: false, starPromptOpen: false, starPromptSource: "auto", - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs: readKeepLogs(), cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(), completionSound: readCompletionSoundConfig(), + completionNotification: readCompletionNotificationConfig(), + completionNotificationPermission: readSystemNotificationPermission(), ignoredReleaseTag: readIgnoredReleaseTag(), appUpdate: null, appUpdateModalOpen: false, @@ -715,25 +789,34 @@ export const useStudioStore = create((set, get) => ({ set({ starPromptOpen: false }); try { localStorage.setItem("gptcodex.starPrompted", "1"); } catch {} }, - enqueueSavePrompt: (item) => { + enqueueSavePrompt: (request) => { if (get().savePromptSuppressed) return; + const normalized = normalizeSavePromptRequest(request); + if (!normalized) return; + const hydrated = normalized.kind === "batch" + ? { + ...normalized, + items: historyItemsByIds(normalized.items, normalized.items.map((item) => item.id)), + } + : normalized; + if (hydrated.kind === "batch" && hydrated.items.length === 0) return; set((state) => { - if (!state.savePromptItem) return { savePromptItem: item }; - return { savePromptQueue: [...state.savePromptQueue, item].slice(-12) }; + if (!state.savePromptRequest) return { savePromptRequest: hydrated }; + return { savePromptQueue: [...state.savePromptQueue, hydrated].slice(-12) }; }); }, closeSavePrompt: () => { set((state) => { const [next, ...rest] = state.savePromptQueue; return { - savePromptItem: next ?? null, + savePromptRequest: next ?? null, savePromptQueue: rest, }; }); }, setSavePromptSuppressed: (value) => { writeSavePromptSuppressed(value); - set(value ? { savePromptSuppressed: true, savePromptQueue: [] } : { savePromptSuppressed: false }); + set(value ? { savePromptSuppressed: true, savePromptRequest: null, savePromptQueue: [] } : { savePromptSuppressed: false }); }, setKeepLogs: async (value) => { writeKeepLogs(value); @@ -790,6 +873,30 @@ export const useStudioStore = create((set, get) => ({ previewCompletionSound: async () => { await playCompletionSound(get().completionSound, { force: true }); }, + setCompletionNotificationEnabled: async (value) => { + if (!value) { + const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: false }); + persistCompletionNotificationConfig(next); + const permission = readSystemNotificationPermission(); + set({ + completionNotification: next, + completionNotificationPermission: permission, + }); + return permission; + } + const permission = await requestSystemNotificationPermission(); + set({ completionNotificationPermission: permission }); + if (permission !== "granted") return permission; + const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: true }); + persistCompletionNotificationConfig(next); + set({ completionNotification: next }); + return permission; + }, + requestCompletionNotificationPermission: async () => { + const permission = await requestSystemNotificationPermission(); + set({ completionNotificationPermission: permission }); + return permission; + }, workspaces: [], activeWorkspaceId: "", styleTag: "", @@ -966,6 +1073,7 @@ export const useStudioStore = create((set, get) => ({ selectSourceImage: async () => imageActions.selectSourceImage(), chooseBatchInputDir: async () => imageActions.chooseBatchInputDir(), + chooseBatchInputFiles: async () => imageActions.chooseBatchInputFiles(), refreshBatchInputDir: async () => imageActions.refreshBatchInputDir(), removeSource: (index) => imageActions.removeSource(index), clearSources: () => imageActions.clearSources(), @@ -990,7 +1098,8 @@ export const useStudioStore = create((set, get) => ({ const batchCount = normalizeBatchCount(s.batchCount); const batchProcess = normalizeBatchProcessConfig(s.batchProcess); const loopGeneration = normalizeLoopGenerationConfig(s.loopGeneration); - const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcess.enabled; + const batchProcessActive = batchProcess.enabled || !!batchProcess.inputDir.trim() || batchProcess.discoveredSources.length > 0; + const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcessActive; const loopEnabled = !batchProcessEnabled && loopGeneration.enabled; const requestedJobCount = loopEnabled ? normalizeLoopGenerationCount(loopGeneration.totalCount) @@ -1012,12 +1121,8 @@ export const useStudioStore = create((set, get) => ({ set({ errorMessage: "批处理模式暂仅支持桌面端", errorCanRetry: false, errorRawPath: null }); return; } - if (!batchProcess.inputDir.trim()) { - set({ errorMessage: "请先选择批处理输入目录", errorCanRetry: false, errorRawPath: null }); - return; - } if (batchProcess.discoveredSources.length === 0) { - set({ errorMessage: "批处理目录中没有可处理的图片", errorCanRetry: false, errorRawPath: null }); + set({ errorMessage: "请先选择批处理输入目录,或直接选择多张图片加入队列", errorCanRetry: false, errorRawPath: null }); return; } if (batchProcess.outputMode === "custom_dir" && !batchProcess.outputDir.trim()) { @@ -1026,6 +1131,15 @@ export const useStudioStore = create((set, get) => ({ } } const activeProfile = s.profiles.find((p) => p.id === s.activeProfileId); + const responsesTransport = activeProfile?.responsesTransport ?? s.responsesTransport; + if (s.apiMode === "responses" && responsesTransport === "websocket" && s.kernelRuntimeMode === "remote" && !runtimePlatform.isAndroid) { + set({ + errorMessage: "当前远程内核模式暂不支持 Responses WebSocket mode,请切回本地内核或关闭该开关。", + errorCanRetry: false, + errorRawPath: null, + }); + return; + } const concurrencyLimit = normalizeConcurrencyLimit(activeProfile?.concurrencyLimit ?? 0); const fallbackProfile = activeProfile?.fallbackProfileId ? s.profiles.find((profile) => profile.id === activeProfile.fallbackProfileId) ?? null @@ -1156,6 +1270,7 @@ export const useStudioStore = create((set, get) => ({ reasoningEffort: s.reasoningEffort, proxyMode: s.proxyMode, proxyURL: s.proxyURL, + responsesTransport: s.responsesTransport, requestPolicy: s.requestPolicy, imagesNewAPICompat: s.imagesNewAPICompat, apiMode: s.apiMode, @@ -1170,11 +1285,12 @@ export const useStudioStore = create((set, get) => ({ imageModelID: fallbackProfile.imageModelID, reasoningEffort: fallbackProfile.reasoningEffort, apiMode: fallbackProfile.apiMode, + responsesTransport: fallbackProfile.responsesTransport ?? "sse", requestPolicy: fallbackProfile.requestPolicy, imagesNewAPICompat: fallbackProfile.imagesNewAPICompat === true, } : undefined, - autoRetryEnabled: s.autoRetryEnabled, + autoRetryEnabled: batchProcessEnabled ? batchProcess.retryOnFailure : s.autoRetryEnabled, disablePreview: s.partialImages === 0 || (loopEnabled && !loopGeneration.livePreview) || forceDisableStreamPreview, }; const remotePayload: RuntimeGenerateOptions = { @@ -1223,12 +1339,20 @@ export const useStudioStore = create((set, get) => ({ totalJobs: batchProcess.discoveredSources.length, maxConcurrent: requestedConcurrency, launchedJobs: 0, + nextPreviewSlotIndex: 0, mode: s.mode, payload: remotePayload, snapshotBase, batchSources: batchProcess.discoveredSources, batchOutputDir: batchProcess.outputMode === "custom_dir" ? batchProcess.outputDir : "", batchOutputPrefix: batchProcess.fileNamePrefix, + batchAutoAspectResolution: batchProcess.autoAspectResolution, + batchCapabilityInput: { + apiMode: s.apiMode, + requestPolicy: s.requestPolicy, + imageModelID: s.imageModelID, + }, + batchCustomAspectRatios: s.customAspectRatios, stopped: false, }; loopRunControllers.set(workspaceId, controller); @@ -1242,6 +1366,7 @@ export const useStudioStore = create((set, get) => ({ totalJobs: requestedJobCount, maxConcurrent: requestedConcurrency, launchedJobs: 0, + nextPreviewSlotIndex: 0, mode: s.mode, payload: remotePayload, snapshotBase, @@ -1258,6 +1383,7 @@ export const useStudioStore = create((set, get) => ({ void launchOneJob(s.mode, p, { ...snapshotBase, batchIndex: i, + previewSlotIndex: i, }); } }, @@ -1332,6 +1458,7 @@ export const useStudioStore = create((set, get) => ({ proxyMode: "system", proxyURL: "", apiMode: preview.profile.apiMode, + responsesTransport: preview.profile.responsesTransport ?? "sse", requestPolicy: preview.profile.requestPolicy, noPromptRevision: true, profiles: [preview.profile], @@ -1400,12 +1527,14 @@ export const useStudioStore = create((set, get) => ({ starPromptOpen: false, starPromptSource: "auto", autoRetryEnabled: readAutoRetryEnabled(), - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs: readKeepLogs(), cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(), completionSound: readCompletionSoundConfig(), + completionNotification: readCompletionNotificationConfig(), + completionNotificationPermission: readSystemNotificationPermission(), ignoredReleaseTag: readIgnoredReleaseTag(), appUpdate: null, appUpdateModalOpen: false, @@ -1452,6 +1581,8 @@ export const useStudioStore = create((set, get) => ({ const keepLogs = readKeepLogs(); const cleanupPreviewCacheOnExit = readCleanupPreviewCacheOnExit(); const completionSound = readCompletionSoundConfig(); + const completionNotification = readCompletionNotificationConfig(); + const completionNotificationPermission = readSystemNotificationPermission(); const ignoredReleaseTag = readIgnoredReleaseTag(); const updateInfo = normalizeAppUpdateInfo(await CheckForAppUpdate().catch(() => null)); const shouldShowUpdate = !!updateInfo?.hasUpdate && updateInfo.releaseTag !== ignoredReleaseTag; @@ -1534,6 +1665,7 @@ export const useStudioStore = create((set, get) => ({ id, name: "Responses · 默认", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, baseURL: legacyResponses.baseURL, @@ -1554,6 +1686,7 @@ export const useStudioStore = create((set, get) => ({ id, name: "Images · 默认", apiMode: "images", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, baseURL: legacyImages.baseURL, @@ -1590,6 +1723,7 @@ export const useStudioStore = create((set, get) => ({ persistActiveProfileId(activeProfileId); } const apiMode: APIMode = activeProfile?.apiMode ?? "responses"; + const responsesTransport = activeProfile?.responsesTransport ?? "sse"; const requestPolicy: RequestPolicy = activeProfile?.requestPolicy ?? "openai"; const imagesNewAPICompat = activeProfile?.imagesNewAPICompat === true; const baseURL = activeProfile?.baseURL ?? ""; @@ -1665,7 +1799,7 @@ export const useStudioStore = create((set, get) => ({ historyHasMore, historyLoading: false, historyCursorBeforeDayStart: initialHistoryPage.nextCursor?.beforeDayStart ?? null, - apiMode, requestPolicy, imagesNewAPICompat, baseURL, textModelID, imageModelID, reasoningEffort, kernelRuntimeMode, noPromptRevision, + apiMode, responsesTransport, requestPolicy, imagesNewAPICompat, baseURL, textModelID, imageModelID, reasoningEffort, kernelRuntimeMode, noPromptRevision, proxyMode: proxyConfig.mode, proxyURL: proxyConfig.url, outputFormat, @@ -1691,12 +1825,14 @@ export const useStudioStore = create((set, get) => ({ customSizeModalOpen: false, upstreamModalOpen: false, upstreamReturnTarget: shouldAutoOpenSettings ? "settings" : "app", - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs, cleanupPreviewCacheOnExit, completionSound, + completionNotification, + completionNotificationPermission, ignoredReleaseTag, appUpdate: shouldShowUpdate ? updateInfo : null, appUpdateModalOpen: shouldShowUpdate, @@ -1916,9 +2052,22 @@ export const useStudioStore = create((set, get) => ({ set({ isTestingKey: true }); s.pushToast("正在测试连接...", "info", 8000); try { - await probeCurrentUpstream(cleanedBaseURL, s.apiKey.trim(), s.proxyMode, s.proxyURL); + const result = await probeCurrentUpstream( + cleanedBaseURL, + s.apiKey.trim(), + s.proxyMode, + s.proxyURL, + s.apiMode, + s.responsesTransport, + ); set({ isTestingKey: false }); - s.pushToast("连接 OK · 上游 models 列表可访问", "success"); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + s.pushToast(`基础连接 OK,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, "warn", 7000); + } else if (result.responsesTransport === "websocket") { + s.pushToast("连接 OK · /v1/models 可访问,Responses WebSocket 可用", "success"); + } else { + s.pushToast("连接 OK · 上游 models 列表可访问", "success"); + } } catch (e: any) { set({ isTestingKey: false }); s.pushToast(`连接失败:${e?.message ?? e}`, "error", 6000); @@ -2149,6 +2298,7 @@ async function launchOneJob( outputFormat: snapshot.outputFormat, currentImage: snapshot.currentImage, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, }) ?? {} )); }); @@ -2193,6 +2343,7 @@ async function launchOneJob( moderation: payload.moderation === "auto" ? "auto" : "low", styleTag: snapshot.styleTag || undefined, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, elapsedSec: Number(elapsedSec.toFixed(1)), savedPath: r.savedPath, rawPath: r.rawPath, @@ -2253,12 +2404,19 @@ async function launchOneJob( completedNow, totalNow, }); + const shouldNotifyCompletion = shouldSendCompletionNotification({ + config: store.getState().completionNotification, + completedNow, + totalNow, + windowHidden: willNotify, + }); if (shouldPlaySound) { void playCompletionSound(store.getState().completionSound); } - // 桌面通知 —— 点击拉前台 + 直达详情抽屉 - if (willNotify && (!loopMode || isFinalLoopResult)) { - tryNotify("Image Studio · 已完成", r.prompt ?? "", () => { + if (shouldNotifyCompletion && (!loopMode || isFinalLoopResult)) { + const notificationBody = (r.revisedPrompt ?? r.prompt ?? payload.prompt ?? "").trim() + || (historyItem.mode === "edit" ? "图片编辑任务已完成" : "图片生成任务已完成"); + showSystemNotification("Image Studio · 已完成", notificationBody, () => { store.getState().openResultDetail(historyItem); }); } @@ -2271,7 +2429,10 @@ async function launchOneJob( 6000, { label: "查看详情", onClick: () => store.getState().openResultDetail(historyItem) }, ); - store.getState().enqueueSavePrompt(historyItem); + if (totalNow <= 1) { + store.getState().enqueueSavePrompt({ kind: "single", item: historyItem }); + } + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); } else if (isFinalLoopResult) { store.getState().pushToast( `循环出图完成 · ${completedNow} 张`, @@ -2329,7 +2490,8 @@ async function launchOneJob( workspaces: patchWorkspaceRuntime(state.workspaces, snapshot.workspaceId, patch), ...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(patch) : {}), } as Partial)); - removeFromRunning(); + const { completed: completedNow, total: totalNow } = removeFromRunning(); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); } })(); @@ -2364,7 +2526,8 @@ async function launchOneJob( : {}), } as Partial; }); - removeFromRunning(); + const { completed: completedNow, total: totalNow } = removeFromRunning(); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); }); const started = mode === "edit" @@ -2381,8 +2544,12 @@ async function launchOneJob( errorCanRetry: true, errorRawPath: null, }; + let completedNow = 0; + let totalNow = 0; store.setState((state) => { const runtime = workspaceRuntimeFromState(state, snapshot.workspaceId); + completedNow = runtime.jobsCompleted + 1; + totalNow = runtime.jobsTotal; const nextMeta = { ...state.runningJobMeta }; delete nextMeta[jobId]; const remaining = runtime.runningJobs.filter((id) => id !== jobId); @@ -2403,6 +2570,7 @@ async function launchOneJob( ...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(nextPatch) : {}), } as Partial; }); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); } } diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts index 185616cf2..fb68019f7 100644 --- a/image-studio/frontend/src/state/studioStore.types.ts +++ b/image-studio/frontend/src/state/studioStore.types.ts @@ -1,4 +1,5 @@ import type { GenerateOptionsLike } from "../platform/runtime/hostTypes"; +import type { SavePromptRequest } from "../lib/savePromptState"; import type { Annotation, AppUpdateInfo, @@ -17,6 +18,7 @@ import type { OutputFormatValue, CustomAspectRatio, CompletionSoundConfig, + CompletionNotificationConfig, Preset, ProgressInfo, PromptTemplate, @@ -27,6 +29,7 @@ import type { SourceImage, StreamPreview, StreamPreviewMap, + SystemNotificationPermissionState, ThemeMode, Toast, UpstreamProfile, @@ -92,6 +95,7 @@ export interface StudioState { imageModelID: string; reasoningEffort: import("../types/domain").ReasoningEffortValue; apiMode: APIMode; + responsesTransport: import("../types/domain").ResponsesTransport; requestPolicy: RequestPolicy; imagesNewAPICompat: boolean; noPromptRevision: boolean; @@ -159,6 +163,7 @@ export interface StudioState { createProfile: (input: { name?: string; apiMode: APIMode; + responsesTransport?: import("../types/domain").ResponsesTransport; baseURL?: string; requestPolicy?: RequestPolicy; imagesNewAPICompat?: boolean; @@ -175,6 +180,7 @@ export interface StudioState { setActiveProfile: (id: string) => Promise; selectSourceImage: () => Promise; chooseBatchInputDir: () => Promise; + chooseBatchInputFiles: () => Promise; refreshBatchInputDir: () => Promise; viewSourceOnCanvas: (index: number) => Promise; compareSourceOnCanvas: (index: number) => Promise; @@ -210,16 +216,18 @@ export interface StudioState { resultDetail: HistoryItem | null; openResultDetail: (item: HistoryItem) => Promise; closeResultDetail: () => void; - savePromptItem: HistoryItem | null; - savePromptQueue: HistoryItem[]; + savePromptRequest: SavePromptRequest | null; + savePromptQueue: SavePromptRequest[]; savePromptSuppressed: boolean; keepLogs: boolean; cleanupPreviewCacheOnExit: boolean; completionSound: CompletionSoundConfig; + completionNotification: CompletionNotificationConfig; + completionNotificationPermission: SystemNotificationPermissionState; ignoredReleaseTag: string; appUpdate: AppUpdateInfo | null; appUpdateModalOpen: boolean; - enqueueSavePrompt: (item: HistoryItem) => void; + enqueueSavePrompt: (request: SavePromptRequest) => void; closeSavePrompt: () => void; setSavePromptSuppressed: (value: boolean) => void; setKeepLogs: (value: boolean) => Promise; @@ -231,6 +239,8 @@ export interface StudioState { setCompletionSoundCustom: (input: { name: string; dataURL: string }) => void; resetCompletionSoundCustom: () => void; previewCompletionSound: () => Promise; + setCompletionNotificationEnabled: (value: boolean) => Promise; + requestCompletionNotificationPermission: () => Promise; materializeCurrentImage: (item: HistoryItem) => Promise; retryLast: () => Promise; setHistoryRailCollapsed: (collapsed: boolean) => void; diff --git a/image-studio/frontend/src/state/workspaceRuntime.ts b/image-studio/frontend/src/state/workspaceRuntime.ts index 6d8027937..a86272c01 100644 --- a/image-studio/frontend/src/state/workspaceRuntime.ts +++ b/image-studio/frontend/src/state/workspaceRuntime.ts @@ -1,4 +1,5 @@ import type { + BatchProcessAutoAspectResolution, BatchProcessSourceImage, BatchProcessConfig, LoopGenerationConfig, @@ -96,11 +97,19 @@ export function defaultBatchProcessConfig(): BatchProcessConfig { outputMode: "source_dir", outputDir: "", concurrency: DEFAULT_BATCH_PROCESS_CONCURRENCY, + retryOnFailure: false, fileNamePrefix: "processed-", + autoAspectResolution: "", discoveredSources: [], }; } +export function normalizeBatchProcessAutoAspectResolution(value: unknown): BatchProcessAutoAspectResolution { + return value === "256" || value === "512" || value === "1k" || value === "2k" || value === "4k" + ? value + : ""; +} + export function normalizeBatchProcessConcurrency(value: unknown): number { const n = Number(value); if (!Number.isFinite(n)) return DEFAULT_BATCH_PROCESS_CONCURRENCY; @@ -119,6 +128,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig path?: unknown; name?: unknown; size?: unknown; + width?: unknown; + height?: unknown; previewUrl?: unknown; previewWidth?: unknown; previewHeight?: unknown; @@ -130,6 +141,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig path, name, size: Number.isFinite(Number(candidate.size)) ? Math.max(0, Math.floor(Number(candidate.size))) : 0, + width: Number.isFinite(Number(candidate.width)) ? Math.floor(Number(candidate.width)) : undefined, + height: Number.isFinite(Number(candidate.height)) ? Math.floor(Number(candidate.height)) : undefined, previewUrl: typeof candidate.previewUrl === "string" ? candidate.previewUrl : undefined, previewWidth: Number.isFinite(Number(candidate.previewWidth)) ? Math.floor(Number(candidate.previewWidth)) : undefined, previewHeight: Number.isFinite(Number(candidate.previewHeight)) ? Math.floor(Number(candidate.previewHeight)) : undefined, @@ -142,9 +155,11 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig outputMode: source.outputMode === "custom_dir" ? "custom_dir" : "source_dir", outputDir: typeof source.outputDir === "string" ? source.outputDir.trim() : "", concurrency: normalizeBatchProcessConcurrency(source.concurrency), + retryOnFailure: source.retryOnFailure === true, fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-", + autoAspectResolution: normalizeBatchProcessAutoAspectResolution(source.autoAspectResolution), discoveredSources, }; } diff --git a/image-studio/frontend/src/styles/_canvas.css b/image-studio/frontend/src/styles/_canvas.css index d13004ed7..d83147b6a 100644 --- a/image-studio/frontend/src/styles/_canvas.css +++ b/image-studio/frontend/src/styles/_canvas.css @@ -449,6 +449,15 @@ html.dark .stream-preview-badge { background: var(--canvas-bg); z-index: 4; } +.batch-grid-overlay.live-preview-grid { + background: + radial-gradient(circle at top, color-mix(in srgb, var(--accent) 10%, transparent), transparent 42%), + linear-gradient(180deg, color-mix(in srgb, var(--canvas-bg) 90%, var(--surface)), var(--canvas-bg)); +} +.batch-grid-overlay.single-slot { + padding: 22px 24px 24px; + gap: 14px; +} .batch-grid-head { display: flex; align-items: center; @@ -456,6 +465,9 @@ html.dark .stream-preview-badge { gap: 12px; flex: 0 0 auto; } +.batch-grid-head.single-slot { + align-items: flex-start; +} .batch-grid-title { color: var(--text); font-size: 12px; @@ -481,6 +493,9 @@ html.dark .stream-preview-badge { display: grid; gap: 10px; } +.batch-grid.single-slot { + place-items: center; +} .batch-grid-tile { position: relative; min-width: 0; @@ -504,6 +519,14 @@ html.dark .stream-preview-badge { color: inherit; cursor: inherit; } +.batch-grid-tile-button.single-layout { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 16px; + padding: clamp(14px, 2vw, 24px); +} .batch-grid-tile:hover { border-color: color-mix(in srgb, var(--accent) 45%, transparent); transform: translateY(-1px); @@ -512,9 +535,16 @@ html.dark .stream-preview-badge { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 12px 34px rgb(0 0 0 / 0.16); } +.batch-grid-tile.selected { + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent), 0 12px 34px rgb(0 0 0 / 0.16); +} .batch-grid-tile:disabled { cursor: default; } +.batch-grid-tile.selection-mode .batch-grid-drag-export { + display: none; +} .batch-grid-tile.previewing { cursor: default; border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); @@ -533,6 +563,37 @@ html.dark .stream-preview-badge { background: linear-gradient(135deg, color-mix(in srgb, var(--surface) 92%, var(--accent)), var(--surface)); } +.batch-grid-overlay.single-slot .batch-grid-tile { + width: min(100%, 820px); + height: min(100%, 1000px); + max-height: 100%; + border-radius: 20px; + border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 82%, white), color-mix(in srgb, var(--surface) 95%, var(--canvas-bg))); + box-shadow: + 0 18px 48px rgb(15 23 42 / 0.12), + inset 0 1px 0 rgb(255 255 255 / 0.42); +} +.batch-grid-overlay.single-slot .batch-grid-tile.previewing::after { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent), + inset 0 0 0 999px rgb(255 255 255 / 0.02); +} +.batch-grid-media { + position: relative; + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + width: 100%; + border-radius: inherit; + overflow: hidden; + background: + radial-gradient(circle at top, color-mix(in srgb, var(--accent) 8%, transparent), transparent 38%), + var(--canvas-bg); +} .batch-grid-tile img { width: 100%; height: 100%; @@ -540,8 +601,20 @@ html.dark .stream-preview-badge { display: block; background: var(--canvas-bg); } +.batch-grid-overlay.single-slot .batch-grid-media { + min-height: 280px; + border-radius: 16px; + border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.18); +} +.batch-grid-overlay.single-slot .batch-grid-media img { + max-width: 100%; + max-height: 100%; + padding: clamp(10px, 1.6vw, 16px); +} .batch-grid-index, -.batch-grid-meta { +.batch-grid-meta, +.batch-grid-check { position: absolute; top: 8px; background: rgb(0 0 0 / 0.62); @@ -554,6 +627,38 @@ html.dark .stream-preview-badge { } .batch-grid-index { left: 8px; } .batch-grid-meta { right: 8px; font-family: var(--mono-font); } +.batch-grid-overlay.single-slot .batch-grid-index, +.batch-grid-overlay.single-slot .batch-grid-meta { + top: 14px; +} +.batch-grid-overlay.single-slot .batch-grid-index { + left: 14px; +} +.batch-grid-overlay.single-slot .batch-grid-meta { + right: 14px; +} +.batch-grid-check { + top: auto; + right: 8px; + bottom: 8px; + background: color-mix(in srgb, var(--accent) 82%, black 18%); +} +.batch-grid-single-note { + flex: 0 0 auto; + align-self: center; + max-width: min(100%, 560px); + padding: 0 6px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.6; + text-align: center; +} +.batch-grid-single-note.pending-note { + position: absolute; + left: 50%; + bottom: 44px; + transform: translateX(-50%); +} .batch-grid-drag-export { right: 8px; bottom: 8px; @@ -579,6 +684,30 @@ html.dark .stream-preview-badge { white-space: nowrap; } +html.dark .batch-grid-overlay.live-preview-grid { + background: + radial-gradient(circle at top, color-mix(in srgb, var(--accent) 14%, transparent), transparent 42%), + linear-gradient(180deg, color-mix(in srgb, var(--canvas-bg) 92%, rgb(24 24 27)), var(--canvas-bg)); +} + +html.dark .batch-grid-overlay.single-slot .batch-grid-tile { + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 90%, rgb(24 24 27)), color-mix(in srgb, var(--surface) 96%, var(--canvas-bg))); + box-shadow: + 0 22px 56px rgb(0 0 0 / 0.28), + inset 0 1px 0 rgb(255 255 255 / 0.06); +} + +@media (max-width: 1100px) { + .batch-grid-overlay.single-slot { + padding: 18px; + } + + .batch-grid-overlay.single-slot .batch-grid-tile { + width: 100%; + } +} + /* Status bar pinned to bottom of canvas-shell. */ .statusbar { display: flex; align-items: center; gap: 14px; diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts index deeca8cf5..4f15db409 100644 --- a/image-studio/frontend/src/types/domain.ts +++ b/image-studio/frontend/src/types/domain.ts @@ -8,6 +8,7 @@ export type Mode = "generate" | "edit"; export type APIMode = "responses" | "images"; export type RequestPolicy = "openai" | "compat"; export type ReasoningEffortValue = "low" | "medium" | "high" | "xhigh"; +export type ResponsesTransport = "sse" | "websocket"; // UpstreamProfile 是一组完整可用于生成的上游配置。用户可以保存多个,例如 // 「gptcodex 主号 / gptcodex 备号 / OpenAI 直连」,在 UI 里下拉切换 active。 @@ -19,6 +20,7 @@ export interface UpstreamProfile { id: string; name: string; apiMode: APIMode; + responsesTransport?: ResponsesTransport; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; baseURL: string; @@ -57,6 +59,8 @@ export type ThemeMode = "system" | "light" | "dark"; export type CompletionSoundMode = "default" | "custom"; export type EditSourceMode = "manual" | "batch"; export type BatchProcessOutputMode = "source_dir" | "custom_dir"; +export type BatchProcessAutoAspectResolution = "" | "256" | "512" | "1k" | "2k" | "4k"; +export type SystemNotificationPermissionState = "default" | "granted" | "denied" | "unsupported"; export interface CompletionSoundConfig { enabled: boolean; @@ -65,6 +69,10 @@ export interface CompletionSoundConfig { customDataURL: string; } +export interface CompletionNotificationConfig { + enabled: boolean; +} + export interface SizeOption { value: SizeValue; label: string; } export interface QualityOption { value: QualityValue; label: string; } export interface OutputFormatOption { value: OutputFormatValue; label: string; } @@ -160,6 +168,8 @@ export interface BatchProcessSourceImage { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; @@ -171,7 +181,9 @@ export interface BatchProcessConfig { outputMode: BatchProcessOutputMode; outputDir: string; concurrency: number; + retryOnFailure: boolean; fileNamePrefix: string; + autoAspectResolution: BatchProcessAutoAspectResolution; discoveredSources: BatchProcessSourceImage[]; } @@ -209,6 +221,7 @@ export interface HistoryItem { moderation?: ModerationValue; styleTag?: string; batchIndex?: number; + previewSlotIndex?: number; elapsedSec?: number; // generation duration in seconds savedPath?: string; @@ -231,6 +244,7 @@ export interface StreamPreview { revisedPrompt?: string; partialImageIndex?: number; batchIndex?: number; + previewSlotIndex?: number; updatedAt: number; } diff --git a/image-studio/frontend/test/completionNotification.test.mjs b/image-studio/frontend/test/completionNotification.test.mjs new file mode 100644 index 000000000..712f8c164 --- /dev/null +++ b/image-studio/frontend/test/completionNotification.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const realLocalStorage = globalThis.localStorage; +const realNotification = globalThis.Notification; +const realWindow = globalThis.window; + +function installStorage() { + const store = new Map(); + globalThis.localStorage = { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(key, String(value)); + }, + removeItem(key) { + store.delete(key); + }, + }; + return store; +} + +test.afterEach(() => { + globalThis.localStorage = realLocalStorage; + globalThis.Notification = realNotification; + globalThis.window = realWindow; +}); + +test("completion notification persists enabled state", async () => { + const store = installStorage(); + const completionNotification = await import("../src/lib/completionNotification.ts"); + + completionNotification.persistCompletionNotificationConfig({ enabled: false }); + assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: false }); + assert.equal(store.get("gptcodex.completionNotification.enabled"), "0"); + + completionNotification.persistCompletionNotificationConfig({ enabled: true }); + assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: true }); + assert.equal(store.get("gptcodex.completionNotification.enabled"), "1"); +}); + +test("completion notification only fires for the final hidden task", async () => { + const completionNotification = await import("../src/lib/completionNotification.ts"); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 1, + totalNow: 3, + windowHidden: true, + }), false); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 3, + totalNow: 3, + windowHidden: false, + }), false); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 3, + totalNow: 3, + windowHidden: true, + }), true); +}); + +test("system notification only shows after permission is granted", async () => { + const seen = []; + globalThis.window = { focus() {} }; + globalThis.Notification = class MockNotification { + static permission = "granted"; + static async requestPermission() { + return this.permission; + } + constructor(title, options) { + seen.push({ title, body: options.body }); + this.onclick = null; + } + close() {} + }; + + const completionNotification = await import("../src/lib/completionNotification.ts"); + assert.equal(completionNotification.showSystemNotification("done", "body"), true); + assert.deepEqual(seen, [{ title: "done", body: "body" }]); + + globalThis.Notification.permission = "denied"; + assert.equal(completionNotification.showSystemNotification("blocked", "body"), false); + assert.equal(seen.length, 1); +}); diff --git a/image-studio/frontend/test/dragExport.test.mjs b/image-studio/frontend/test/dragExport.test.mjs index f860be4e8..2c1e14514 100644 --- a/image-studio/frontend/test/dragExport.test.mjs +++ b/image-studio/frontend/test/dragExport.test.mjs @@ -113,6 +113,31 @@ test("buildHistoryItemDragExport rewrites wails asset URLs for drag export", asy } }); +test("buildHistoryItemDragExport still uses the full asset for persisted preview-only history items", async () => { + installWindow("http://wails.localhost/app/"); + try { + const dragExport = await import(`../src/lib/dragExport.ts?drag-export-test=${Date.now()}-${Math.random().toString(36).slice(2)}`); + const spec = dragExport.buildHistoryItemDragExport({ + id: "history-persisted-1", + mode: "generate", + outputFormat: "webp", + savedPath: "/tmp/image-generate-history.webp", + fullUrl: "/media/full/history-full-1", + imageId: "history-full-1", + imageB64: "", + previewOnly: true, + }); + assert.deepEqual(spec, { + href: "http://wails.localhost/media/full/history-full-1", + fileName: "image-generate-history.webp", + mimeType: "image/webp", + downloadURL: "image/webp:image-generate-history.webp:http://wails.localhost/media/full/history-full-1", + }); + } finally { + restoreWindow(); + } +}); + test("writeImageFileDragData writes the expected drag payload formats", async () => { const dragExport = await import(`../src/lib/dragExport.ts?drag-export-test=${Date.now()}-${Math.random().toString(36).slice(2)}`); const writes = []; diff --git a/image-studio/frontend/test/remoteKernel.test.mjs b/image-studio/frontend/test/remoteKernel.test.mjs index 046fb0806..a0b418bf7 100644 --- a/image-studio/frontend/test/remoteKernel.test.mjs +++ b/image-studio/frontend/test/remoteKernel.test.mjs @@ -944,3 +944,130 @@ test("Android shell remote kernel can use native HTTP bridge to bypass browser f assert.ok(progressEvents.some(([stage]) => stage === "已收到图片数据片段")); }); }); + +test("Android shell remote kernel wraps Responses websocket requests as response.create payloads", async () => { + let capturedInvoke = null; + await withPatchedGlobals(async () => { + globalThis.window.AndroidImageStudio = { + invoke(requestId, method, payloadJson) { + const args = JSON.parse(payloadJson); + if (method === "ResponsesWebSocketRequest") { + capturedInvoke = { method, args }; + queueMicrotask(() => { + window.__imageStudioNativeResolve?.(requestId, { + status: 200, + body: "", + contentType: "text/event-stream", + resultImageB64: "ZmluYWw=", + revisedPrompt: "rev", + sourceEvent: "final", + }); + }); + return; + } + window.__imageStudioNativeReject?.(requestId, `unsupported ${method}`); + }, + }; + globalThis.fetch = async () => { + throw new Error("websocket branch should not use browser fetch"); + }; + }, async () => { + const kernel = await loadRemoteKernel(); + const result = await kernel.runRemoteImageJob( + { + payload: { + apiKey: "key", + mode: "generate", + prompt: "cat", + size: "1024x1024", + quality: "low", + outputFormat: "png", + imagePaths: [], + imagePath: "", + maskB64: "", + seed: 0, + negativePrompt: "", + baseURL: "https://upstream.example", + textModelID: "gpt-5.5", + imageModelID: "gpt-image-2", + apiMode: "responses", + responsesTransport: "websocket", + requestPolicy: "openai", + noPromptRevision: false, + }, + }, + { signal: new AbortController().signal }, + ); + assert.equal(result.imageB64, "ZmluYWw="); + assert.equal(capturedInvoke?.method, "ResponsesWebSocketRequest"); + const payload = JSON.parse(capturedInvoke.args[0].payload); + assert.equal(payload.type, "response.create"); + assert.equal(payload.model, "gpt-5.5"); + assert.equal(payload.store, false); + assert.equal(payload.stream, undefined); + }); +}); + +test("Android shell remote kernel falls back to native HTTP when Responses websocket handshake fails", async () => { + const calls = []; + await withPatchedGlobals(async () => { + globalThis.window.AndroidImageStudio = { + invoke(requestId, method, payloadJson) { + const args = JSON.parse(payloadJson); + calls.push(method); + queueMicrotask(() => { + if (method === "ResponsesWebSocketRequest") { + window.__imageStudioNativeReject?.(requestId, "websocket handshake failed: HTTP 426: WebSocket upgrade required (Upgrade: websocket)"); + return; + } + if (method === "HttpRequestText") { + const payload = args[0]; + assert.equal(payload.url, "https://upstream.example/v1/responses"); + window.__imageStudioNativeResolve?.(requestId, { + status: 200, + body: "", + contentType: "text/event-stream", + resultImageB64: "ZmFsbGJhY2s=", + revisedPrompt: "fallback", + sourceEvent: "final", + }); + return; + } + window.__imageStudioNativeReject?.(requestId, `unsupported ${method}`); + }); + }, + }; + globalThis.fetch = async () => { + throw new Error("fallback path should stay on Android native transport"); + }; + }, async () => { + const kernel = await loadRemoteKernel(); + const result = await kernel.runRemoteImageJob( + { + payload: { + apiKey: "key", + mode: "generate", + prompt: "cat", + size: "1024x1024", + quality: "low", + outputFormat: "png", + imagePaths: [], + imagePath: "", + maskB64: "", + seed: 0, + negativePrompt: "", + baseURL: "https://upstream.example", + textModelID: "gpt-5.5", + imageModelID: "gpt-image-2", + apiMode: "responses", + responsesTransport: "websocket", + requestPolicy: "openai", + noPromptRevision: false, + }, + }, + { signal: new AbortController().signal }, + ); + assert.equal(result.imageB64, "ZmFsbGJhY2s="); + assert.deepEqual(calls.slice(0, 2), ["ResponsesWebSocketRequest", "HttpRequestText"]); + }); +}); diff --git a/image-studio/frontend/test/runtimeHost.test.mjs b/image-studio/frontend/test/runtimeHost.test.mjs index 624d7b732..6ce522ea4 100644 --- a/image-studio/frontend/test/runtimeHost.test.mjs +++ b/image-studio/frontend/test/runtimeHost.test.mjs @@ -652,7 +652,14 @@ test("runtimeHost probes upstream through Wails backend", async () => { const runtimeHost = await loadRuntimeHost(); await runtimeHost.probeCurrentUpstream("https://relay.example.com", "sk-test"); assert.deepEqual(globalThis.__probeCalls, [ - { baseURL: "https://relay.example.com", apiKey: "sk-test", proxyMode: "system", proxyURL: "" }, + { + baseURL: "https://relay.example.com", + apiKey: "sk-test", + proxyMode: "system", + proxyURL: "", + apiMode: "responses", + responsesTransport: "sse", + }, ]); }); }); @@ -682,7 +689,14 @@ test("runtimeHost probes upstream through Android backend", async () => { assert.deepEqual(globalThis.__probeCalls, [ { method: "ProbeUpstream", - args: [{ baseURL: "https://relay.example.com", apiKey: "sk-android", proxyMode: "system", proxyURL: "" }], + args: [{ + baseURL: "https://relay.example.com", + apiKey: "sk-android", + proxyMode: "system", + proxyURL: "", + apiMode: "responses", + responsesTransport: "sse", + }], }, ]); }); diff --git a/image-studio/frontend/test/savePromptState.test.mjs b/image-studio/frontend/test/savePromptState.test.mjs new file mode 100644 index 000000000..205064f4b --- /dev/null +++ b/image-studio/frontend/test/savePromptState.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const savePromptState = await import("../src/lib/savePromptState.ts"); + +function makeItem(id, createdAt) { + return { + id, + prompt: `prompt-${id}`, + mode: "generate", + size: "1024x1024", + quality: "medium", + createdAt, + }; +} + +test("normalizeSavePromptRequestForTest keeps batch items in request order", () => { + const one = makeItem("one", 1); + const two = makeItem("two", 2); + const request = savePromptState.normalizeSavePromptRequest({ + kind: "batch", + items: [two, one], + workspaceId: "ws-1", + }); + + assert.equal(request?.kind, "batch"); + assert.deepEqual(request?.items.map((item) => item.id), ["two", "one"]); +}); + +test("normalizeSavePromptRequest keeps single requests intact", () => { + const one = makeItem("one", 1); + const request = savePromptState.normalizeSavePromptRequest({ + kind: "single", + item: one, + }); + + assert.equal(request?.kind, "single"); + assert.equal(request?.item.id, "one"); +}); + +test("normalizeSavePromptRequest rejects empty batch requests", () => { + const request = savePromptState.normalizeSavePromptRequest({ + kind: "batch", + items: [], + workspaceId: "ws-1", + }); + + assert.equal(request, null); +}); diff --git a/image-studio/frontend/test/sizeCapabilities.test.mjs b/image-studio/frontend/test/sizeCapabilities.test.mjs index 23c158d3e..1cf69627b 100644 --- a/image-studio/frontend/test/sizeCapabilities.test.mjs +++ b/image-studio/frontend/test/sizeCapabilities.test.mjs @@ -295,3 +295,19 @@ test("auto aspect can resolve 2K/4K against a reference image ratio", () => { "3840x1920", ); }); + +test("reference resolution selection can use source image ratio directly", () => { + const input = { + apiMode: "responses", + requestPolicy: "openai", + imageModelID: "gpt-image-2", + }; + assert.equal( + caps.buildReferenceResolutionSizeSelection("2k", { width: 1200, height: 1600 }, input, []), + "1448x1928", + ); + assert.equal( + caps.buildReferenceResolutionSizeSelection("4k", { width: 1600, height: 1200 }, input, []), + "3312x2496", + ); +}); diff --git a/image-studio/frontend/test/upstreamConfigTransfer.test.mjs b/image-studio/frontend/test/upstreamConfigTransfer.test.mjs index d85863459..1c8192d5e 100644 --- a/image-studio/frontend/test/upstreamConfigTransfer.test.mjs +++ b/image-studio/frontend/test/upstreamConfigTransfer.test.mjs @@ -35,6 +35,7 @@ test("parseUpstreamConfigImportFile normalizes profiles and preserves embedded a id: "p-2", name: "备用", apiMode: "images", + responsesTransport: "sse", requestPolicy: "compat", imagesNewAPICompat: true, baseURL: " https://img.example.com/ ", @@ -57,6 +58,7 @@ test("parseUpstreamConfigImportFile normalizes profiles and preserves embedded a id: "p-2", name: "备用", apiMode: "images", + responsesTransport: "sse", requestPolicy: "compat", imagesNewAPICompat: true, baseURL: "https://img.example.com/", diff --git a/image-studio/frontend/test/workspaceRuntime.test.mjs b/image-studio/frontend/test/workspaceRuntime.test.mjs index 960c13f92..eff06da25 100644 --- a/image-studio/frontend/test/workspaceRuntime.test.mjs +++ b/image-studio/frontend/test/workspaceRuntime.test.mjs @@ -42,6 +42,34 @@ test("normalizes api mode and concurrency values", () => { assert.equal(runtime.normalizeBatchCount(3.8), 3); assert.equal(runtime.normalizeBatchCount(0), 1); assert.equal(runtime.normalizeBatchCount(99), 9); + assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("2k"), "2k"); + assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("oops"), ""); +}); + +test("normalizes batch process config and keeps scanned image dimensions", () => { + const value = runtime.normalizeBatchProcessConfig({ + enabled: true, + inputDir: " /tmp/src ", + outputMode: "custom_dir", + outputDir: " /tmp/out ", + concurrency: 3.8, + retryOnFailure: true, + fileNamePrefix: "processed-", + autoAspectResolution: "4k", + discoveredSources: [ + { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800 }, + { path: " ", name: "bad", size: 0 }, + ], + }); + assert.equal(value.enabled, true); + assert.equal(value.inputDir, "/tmp/src"); + assert.equal(value.outputDir, "/tmp/out"); + assert.equal(value.concurrency, 3); + assert.equal(value.retryOnFailure, true); + assert.equal(value.autoAspectResolution, "4k"); + assert.deepEqual(value.discoveredSources, [ + { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800, previewUrl: undefined, previewWidth: undefined, previewHeight: undefined }, + ]); }); test("patches only the target workspace runtime", () => { diff --git a/image-studio/frontend/wailsjs/go/models.ts b/image-studio/frontend/wailsjs/go/models.ts index 7c662bda6..48297bc7f 100755 --- a/image-studio/frontend/wailsjs/go/models.ts +++ b/image-studio/frontend/wailsjs/go/models.ts @@ -26,6 +26,7 @@ export namespace backend { proxyMode: string; proxyURL: string; apiMode: string; + responsesTransport: string; requestPolicy: string; imagesNewAPICompat: boolean; noPromptRevision: boolean; @@ -66,6 +67,7 @@ export namespace backend { this.proxyMode = source["proxyMode"]; this.proxyURL = source["proxyURL"]; this.apiMode = source["apiMode"]; + this.responsesTransport = source["responsesTransport"]; this.requestPolicy = source["requestPolicy"]; this.imagesNewAPICompat = source["imagesNewAPICompat"]; this.noPromptRevision = source["noPromptRevision"]; @@ -209,6 +211,8 @@ export namespace backend { baseURL: string; proxyMode: string; proxyURL: string; + apiMode: string; + responsesTransport: string; static createFrom(source: any = {}) { return new ProbeUpstreamOptions(source); @@ -220,11 +224,16 @@ export namespace backend { this.baseURL = source["baseURL"]; this.proxyMode = source["proxyMode"]; this.proxyURL = source["proxyURL"]; + this.apiMode = source["apiMode"]; + this.responsesTransport = source["responsesTransport"]; } } export class ProbeUpstreamResult { modelCount: number; models?: UpstreamModelDescriptor[]; + responsesTransport?: string; + responsesTransportOK?: boolean; + responsesTransportError?: string; static createFrom(source: any = {}) { return new ProbeUpstreamResult(source); @@ -234,6 +243,9 @@ export namespace backend { if ('string' === typeof source) source = JSON.parse(source); this.modelCount = source["modelCount"]; this.models = this.convertValues(source["models"], UpstreamModelDescriptor); + this.responsesTransport = source["responsesTransport"]; + this.responsesTransportOK = source["responsesTransportOK"]; + this.responsesTransportError = source["responsesTransportError"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/scripts/local-websocket-smoke-check.mjs b/scripts/local-websocket-smoke-check.mjs new file mode 100644 index 000000000..53daf2d0e --- /dev/null +++ b/scripts/local-websocket-smoke-check.mjs @@ -0,0 +1,125 @@ +import path from "node:path"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; + +function run(cmd, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd ?? process.cwd(), + env: { ...process.env, ...(options.env ?? {}) }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code ?? 1}\n${stderr || stdout}`)); + }); + }); +} + +const tmpDir = path.join(process.cwd(), ".tmp"); +await mkdir(tmpDir, { recursive: true }); +const sourcePath = path.join(tmpDir, "local-websocket-smoke-main.go"); + +const source = `package main +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + client "github.com/yuanhua/image-gptcodex/pkg/client" + "github.com/gorilla/websocket" +) +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(\`{"data":[{"id":"gpt-5.5"},{"id":"gpt-image-2"}]}\`)) + }) + upgrader := websocket.Upgrader{} + mux.HandleFunc("/v1/responses", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + panic(err) + } + defer conn.Close() + _, raw, err := conn.ReadMessage() + if err != nil { + panic(err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + panic(err) + } + if payload["type"] != "response.create" { + panic("expected response.create payload") + } + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.created","response":{"id":"resp_local_smoke"}}\`)) + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.output_item.done","item":{"type":"image_generation_call","result":"c21va2Utd3MtaW1hZ2U=","revised_prompt":"smoke ws revised prompt"}}\`)) + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.completed"}\`)) + }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { panic(err) } + server := &http.Server{Handler: mux} + defer server.Shutdown(context.Background()) + go server.Serve(listener) + origin := "http://" + listener.Addr().String() + outDir, err := os.MkdirTemp("", "image-studio-ws-smoke-*") + if err != nil { panic(err) } + defer os.RemoveAll(outDir) + result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial( + context.Background(), + &client.NativeTransport{}, + client.Options{ + APIKey: "smoke-key", + Prompt: "cat", + BaseURL: origin, + APIMode: client.APIModeResponses, + ResponsesTransport: client.ResponsesTransportWebSocket, + }, + outDir, + "smoke", + nil, + nil, + nil, + ) + if err != nil { panic(err) } + abs, _ := filepath.Abs(rawPath) + fmt.Println(result.ImageB64) + fmt.Println(result.RevisedPrompt) + fmt.Println(strings.TrimSpace(abs)) +} +`; + +await writeFile(sourcePath, source, "utf8"); + +try { + const { stdout } = await run("go", ["run", sourcePath], { + cwd: process.cwd(), + env: { + GOPATH: path.join(process.cwd(), ".gopath"), + GOMODCACHE: path.join(process.cwd(), ".gomodcache"), + GOCACHE: path.join(process.cwd(), ".gocache"), + }, + }); + const lines = stdout.trim().split(/\r?\n/); + const imageB64 = lines[0] ?? ""; + const revisedPrompt = lines[1] ?? ""; + const rawPath = lines.slice(2).join("\n"); + console.log(JSON.stringify({ + status: "passed", + imageB64, + revisedPrompt, + rawPath, + }, null, 2)); +} finally { + await rm(sourcePath, { force: true }); +} diff --git a/shared/compat-go/state.go b/shared/compat-go/state.go index 3ff83c328..a2377d16b 100644 --- a/shared/compat-go/state.go +++ b/shared/compat-go/state.go @@ -61,6 +61,7 @@ type UpstreamProfile struct { ID string `json:"id"` Name string `json:"name"` APIMode string `json:"apiMode"` + ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` ImagesNewAPICompat bool `json:"imagesNewAPICompat,omitempty"` BaseURL string `json:"baseURL"` @@ -216,6 +217,9 @@ func Normalize(state State) State { if state.Profiles[i].ReasoningEffort == "" { state.Profiles[i].ReasoningEffort = "xhigh" } + if state.Profiles[i].ResponsesTransport == "" { + state.Profiles[i].ResponsesTransport = "sse" + } } if state.History == nil { state.History = []HistoryItem{} diff --git a/shared/kernel/requestModel.js b/shared/kernel/requestModel.js index b18d1db8b..5b9816d6b 100644 --- a/shared/kernel/requestModel.js +++ b/shared/kernel/requestModel.js @@ -18,7 +18,8 @@ export const STATUS_INTERVAL_MS = 10_000; const NO_PROMPT_REVISION_INSTRUCTIONS = "You are a tool runner. Pass the user prompt to image_generation VERBATIM. DO NOT rewrite, expand, polish, or revise it in any way. Use the exact text the user gave."; export function normalizeBaseURL(raw) { - return String(raw || "").trim().replace(/\/+$/, ""); + const trimmed = String(raw || "").trim().replace(/\/+$/, ""); + return trimmed.replace(/\/v1$/i, ""); } export function normalizeAPIMode(apiMode) {