From 506a80e8c2e65ac2c6deb90e06c73f555a1e30c7 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Sun, 19 Jul 2026 13:11:40 +0800 Subject: [PATCH 1/6] Clarify request model usage and harden native drag handling --- README.md | 6 +- docs/features.md | 2 +- docs/manual-verification.md | 2 +- docs/no-prompt-revision/README.md | 6 +- docs/privacy-policy.md | 2 +- docs/troubleshooting.md | 20 +-- docs/usage.md | 7 +- gio-client/internal/ui/layout_controls.go | 30 ++-- gio-client/internal/ui/upstream_models.go | 6 +- .../internal/ui/upstream_models_test.go | 6 +- go-cli/pkg/client/payload.go | 1 - go-cli/pkg/client/payload_test.go | 4 +- image-studio/backend/dialogs.go | 12 ++ image-studio/backend/mask_composite.go | 153 ++++++++++++++++++ image-studio/backend/mask_composite_test.go | 118 ++++++++++++++ image-studio/backend/native_drag_windows.go | 26 ++- image-studio/backend/service.go | 34 +++- image-studio/backend/types.go | 1 + .../src/components/canvas/CanvasStage.tsx | 2 +- .../src/components/panel/FAQModal.tsx | 20 +-- .../components/panel/ResultDetailDrawer.tsx | 2 +- .../panel/UpstreamProfileEditor.tsx | 51 +++--- .../frontend/src/lib/maskComposite.ts | 110 +++++++++++++ .../upstream/AndroidUpstreamProfileForm.tsx | 43 ++--- .../frontend/src/platform/runtime/host.ts | 38 ++++- .../frontend/src/state/studioStore.shared.ts | 38 ++++- .../frontend/src/state/studioStore.ts | 33 ++-- image-studio/frontend/src/styles/_canvas.css | 8 + .../frontend/test/requestModel.test.mjs | 4 +- shared/kernel/requestModel.js | 1 - 30 files changed, 652 insertions(+), 134 deletions(-) create mode 100644 image-studio/backend/mask_composite.go create mode 100644 image-studio/backend/mask_composite_test.go create mode 100644 image-studio/frontend/src/lib/maskComposite.ts diff --git a/README.md b/README.md index 3708d89d0..fa652cdc3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 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、文本模型与图像模型。 +项目不内置任何默认上游。首次启动需要你自己填写 BASE_URL、API Key 与请求模型 ID。 当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 @@ -28,7 +28,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 - 抢先体验当前分支的最新改动:到 [DR-lin-eng/Image-Studio Actions · release.yml](https://github.com/DR-lin-eng/Image-Studio/actions/workflows/release.yml) 下载最近一次成功构建的 artifact。 Windows 上这类 CI `exe` 如果没有签名,可能会被 Win11 Smart App Control / SmartScreen 拦截,因此只建议用于内部测试。 - 各平台安装包区别、命名规则和选择建议见 [docs/packages.md](./docs/packages.md)。 -2. 首次启动后打开「上游配置」,填写 API 形态、BASE_URL、API Key、文本模型 ID、图像模型 ID。 +2. 首次启动后打开「上游配置」,填写 API 形态、BASE_URL、API Key、请求模型 ID。 3. 根据上游能力选择 API 形态 - Responses API:更适合长推理、抗 524/504。 - Images API:更适合只提供标准图像接口的兼容上游。 @@ -43,7 +43,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 提 Issue 前建议先做这几步: -1. 在当前 profile 里点一次「测试连接」,确认 `BASE_URL`、`API Key`、文本模型 ID、图像模型 ID 真实可用。 +1. 在当前 profile 里点一次「测试连接」,确认 `BASE_URL`、`API Key`、请求模型 ID 真实可用。 2. 对照 [docs/troubleshooting.md](./docs/troubleshooting.md) 自查 `524/504`、`401/403`、`model not found`、多参考图/蒙版不生效、Android 保存目录行为等常见非软件问题。 3. 从历史详情或 raw 响应里确认真实 HTTP 状态码和上游报错,不要只看页面 toast。 4. 如果同样的 `BASE_URL + Key + 模型 ID` 在 curl、Postman 或上游自带调试页里也失败,优先联系你的上游服务商,而不是提交本仓库 Issue。 diff --git a/docs/features.md b/docs/features.md index abeecbbe8..8d9c3b36f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -48,7 +48,7 @@ ## 设置 -- 上游配置:API 形态、BASE_URL、API Key、文本模型 ID、图像模型 ID、连接测试。 +- 上游配置:API 形态、BASE_URL、API Key、请求模型 ID、连接测试。Responses API 和 Images API 都只填写一个请求模型 ID。 - API Key: - 桌面端使用系统安全存储(Keychain / Credential Manager / Secret Service)。 - Android 壳层使用应用私有 SharedPreferences。 diff --git a/docs/manual-verification.md b/docs/manual-verification.md index f2f2d4bfa..e33cdcaf7 100644 --- a/docs/manual-verification.md +++ b/docs/manual-verification.md @@ -75,7 +75,7 @@ node scripts/init-manual-verification.mjs custom "my regression check" ### 前置条件 - 至少一条真实可用上游,且能稳定返回图像 -- 已知正常的 `BASE_URL` / `API Key` / 文本模型 ID / 图像模型 ID +- 已知正常的 `BASE_URL` / `API Key` / 请求模型 ID - 如果是 Android: - 已接入真机或模拟器 - 可先执行: diff --git a/docs/no-prompt-revision/README.md b/docs/no-prompt-revision/README.md index afe9eb728..04a35ff02 100644 --- a/docs/no-prompt-revision/README.md +++ b/docs/no-prompt-revision/README.md @@ -1,14 +1,14 @@ # 原始提示词传递说明 -Image Studio 现在默认要求 Responses API 文本模型不要改写用户输入的 prompt。 +Image Studio 现在默认要求 Responses API 请求模型不要改写用户输入的 prompt。 -它用于 Responses API 模式。Image Studio 会在 `/v1/responses` 请求顶层加入一段 `instructions`,要求文本模型把用户 prompt 原样传给 `image_generation`,不要重写、扩写、润色或调整措辞。界面不再提供关闭或开启该行为的按钮;所有 Responses 生成请求都按这个策略发送。 +它用于 Responses API 模式。Image Studio 会在 `/v1/responses` 请求顶层加入一段 `instructions`,要求请求模型把用户 prompt 原样传给 `image_generation`,不要重写、扩写、润色或调整措辞。界面不再提供关闭或开启该行为的按钮;所有 Responses 生成请求都按这个策略发送。 ## 适合什么场景 - 你已经精修过 prompt,希望图像模型尽量逐字执行。 - prompt 里有固定格式、专有名词、镜头参数、构图要求或中英文混排内容。 -- 你想减少 Responses API 文本模型二次发挥导致的风格漂移。 +- 你想减少 Responses API 请求模型二次发挥导致的风格漂移。 ## 功能边界 diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 8fccb04b0..0018fa1f4 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -14,7 +14,7 @@ Image Studio 在本地可能处理以下数据: -- 上游配置与偏好设置:例如 `BASE_URL`、API 形态、文本/图像模型 ID、请求策略、并发限制、代理模式与代理地址、主题、字号、输出格式、预设、自定义比例、输出目录、受信任输出目录、是否保留日志、忽略的版本号、完成提示音配置等。 +- 上游配置与偏好设置:例如 `BASE_URL`、API 形态、请求模型 ID、请求策略、并发限制、代理模式与代理地址、主题、字号、输出格式、预设、自定义比例、输出目录、受信任输出目录、是否保留日志、忽略的版本号、完成提示音配置等。 - API Key: - 桌面版默认使用系统安全存储,不写入普通 JSON 配置。 - Android 版保存在应用私有 `SharedPreferences` 中。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d48a9297d..621d17f24 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,9 +9,9 @@ 先做这组最短排查: -1. 在当前 profile 里点一次「测试连接」,确认 `BASE_URL`、`API Key`、文本模型 ID、图像模型 ID 至少能通过基础校验。 +1. 在当前 profile 里点一次「测试连接」,确认 `BASE_URL`、`API Key`、请求模型 ID 至少能通过基础校验。 2. 确认自己选对了 API 形态: - - `Responses API` 需要文本模型可用,并且上游真正实现了 `/v1/responses` 与 SSE。 + - `Responses API` 需要所填请求模型可用,并且上游真正实现了 `/v1/responses` 与 SSE。 - `Images API` 需要上游真正实现 `/v1/images/generations` 或 `/v1/images/edits`。 3. 打开历史详情或 raw 响应,先看真实 `HTTP status` 和上游错误 message,不要只看页面上的 toast。 4. 如果同样的 `BASE_URL + Key + 模型 ID` 在 curl、Postman 或上游自带调试页里也失败,优先联系上游服务商。 @@ -20,13 +20,13 @@ ### 这些情况通常不是软件 bug - `401 / 403 / model not found`: - 常见原因是 Key 没权限、模型 ID 填错、账号没开通对应模型、IP 白名单未放行,或上游把图像模型和文本模型分了不同权限组。 + 常见原因是 Key 没权限、请求模型 ID 填错、账号没开通对应模型或 IP 白名单未放行。 - `524 / 504 / 5xx`: 常见原因是 Cloudflare / Nginx / relay 网关超时、上游排队过久、服务商限流、上游临时故障,不一定是本地程序崩溃。 - 多参考图、蒙版、`seed`、`negative_prompt` 不生效: 常见原因是 relay 只“接受字段”但没有真正透传,或目标模型本身就不支持这些能力。 - `Responses API` 一直失败: - 常见原因是上游根本没实现 `/v1/responses`、会缓冲 SSE,或者 Key 只有 image-only 权限。 + 常见原因是上游根本没实现 `/v1/responses`、会缓冲 SSE,或者 Key 没有所填请求模型的权限。 - `Responses WebSocket mode` 握手失败: 如果 raw 日志或上游报错里出现 `WebSocket upgrade required (Upgrade: websocket)`,说明当前链路把 WebSocket 请求降成了普通 HTTP,请求没有真正完成 Upgrade。常见原因是中转站 / 反向代理 / 网关没有正确放行 `Upgrade: websocket`,这种情况应直接切回 `HTTP SSE` 或修上游代理配置。 - Android 看不到“目录”或保存位置和桌面端不一样: @@ -54,7 +54,7 @@ - 应用版本: release 版本号或 Actions artifact 对应 commit / 构建日期 - API 形态: `Responses API` 或 `Images API` - `BASE_URL` 类型: 官方 / OpenAI 兼容中转 / 自建网关 -- 文本模型 ID 与图像模型 ID +- 请求模型 ID - 最短复现步骤 - raw 响应中的 `HTTP status`、错误 message 或截图 @@ -96,7 +96,7 @@ log/ 处理顺序: 1. 如果当前是 Images API,优先切到 Responses API。 -2. 确认 key 有文本模型权限,例如默认 `gpt-5.5`。 +2. 确认 key 有所填请求模型的权限。 3. 降低质量或尺寸,缩短单次推理时间。 4. 从历史项查看 raw 响应,确认是 Cloudflare 524/504、上游 JSON 5xx,还是模型权限错误。 5. 如果上游本身不支持 SSE 或会缓冲 SSE,换上游或走 Images API。 @@ -145,13 +145,13 @@ websocket handshake failed: HTTP 400: WebSocket upgrade required (Upgrade: webso Responses API: -- key 没有文本模型权限。 -- 文本模型 ID 或图像模型 ID 在该上游不可用。 -- key 绑到了 image-only 分组,但 Responses API 需要文本模型来调用 `image_generation` 工具。 +- key 没有请求模型权限。 +- 请求模型 ID 在该上游不可用。 +- 上游虽然列出了该模型,但没有允许它通过 Responses API 调用。 Images API: -- 图像模型 ID 不存在。 +- 请求模型 ID 不存在。 - key 没有 image endpoint 权限。 - 上游只实现了 `/v1/chat/completions`,没有实现 `/v1/images/generations` 或 `/v1/images/edits`。 diff --git a/docs/usage.md b/docs/usage.md index 1d2b18f63..52316a4e0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,9 +9,8 @@ 1. API 形态:Responses API 或 Images API。 2. BASE_URL:你自己的 OpenAI 兼容中转站地址。 3. API Key。 -4. 文本模型 ID:Responses API 与 prompt 优化会用到,默认 `gpt-5.5`。 -5. 图像模型 ID:两种 API 形态都会用到,默认 `gpt-image-2`。 -6. 测试连接:保存前建议先点一次,确认当前 profile 能真正走通。 +4. 请求模型 ID:Responses API 直接把它写入请求的 `model` 字段,支持上游允许的文本或图像模型;Images API 也把它作为请求的 `model` 字段。 +5. 测试连接:保存前建议先点一次,确认当前 profile 能真正走通。 本应用不内置任何默认上游,也不会向除你配置的 BASE_URL 以外的生成服务发请求。 @@ -25,7 +24,7 @@ - 图像推理容易超过 100 秒。 - 上游在 Cloudflare / Nginx 后面,常见 524/504。 -- 你的 key 有文本模型权限,例如默认的 `gpt-5.5`。 +- 你的 key 有所填请求模型的权限,例如默认的 `gpt-5.5`,或上游支持的图像模型。 特点: diff --git a/gio-client/internal/ui/layout_controls.go b/gio-client/internal/ui/layout_controls.go index b1166d052..017010474 100644 --- a/gio-client/internal/ui/layout_controls.go +++ b/gio-client/internal/ui/layout_controls.go @@ -1043,10 +1043,10 @@ func (a *App) layoutSettingsHelpModal(gtx layout.Context) layout.Dimensions { return a.helpInfoCard(gtx, "模型 ID 怎么填?", "", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.label(gtx, "Responses API 会同时用到文本模型 ID 与图像模型 ID;Images API 只读取图像模型 ID。", unit.Sp(11), fluent.textMuted, font.Normal) + return a.label(gtx, "Responses API 与 Images API 都只填写一个请求模型 ID;Responses API 可使用上游支持的文本或图像模型。", unit.Sp(11), fluent.textMuted, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.label(gtx, "留空时默认文本模型是 gpt-5.5,默认图像模型是 gpt-image-2。", unit.Sp(11), fluent.textMuted, font.Normal) + return a.label(gtx, "Responses API 留空默认 gpt-5.5;Images API 留空默认 gpt-image-2。", unit.Sp(11), fluent.textMuted, font.Normal) }), ) }) @@ -2866,7 +2866,7 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout }, a.api, a.apiButtons, 2, func(value string) { a.api = value }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - hint := "需要 key 绑定到拥有 gpt-5.5 模型的分组。SSE 保活可防 Cloudflare 524。" + hint := "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" if a.api == string(client.APIModeImages) { hint = "使用标准 Images API,key 走 image-2 / image API 分组,兼容性最广。" } @@ -2976,11 +2976,11 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout })) if a.api == string(client.APIModeResponses) { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.technicalField(gtx, "文本模型 ID", &a.textModelInput, client.TextModel, unit.Dp(40)) + return a.technicalField(gtx, "请求模型 ID", &a.textModelInput, client.TextModel, unit.Dp(40)) })) if len(probeTextModels) > 0 { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.layoutProbeModelSuggestions(gtx, "推荐文本模型", "text", probeTextModels, strings.TrimSpace(a.textModelInput.Text()), func(id string) { + return a.layoutProbeModelSuggestions(gtx, "推荐请求模型", "text", probeTextModels, strings.TrimSpace(a.textModelInput.Text()), func(id string) { a.textModelInput.SetText(strings.TrimSpace(id)) }) })) @@ -2994,17 +2994,17 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.label(gtx, "仅 Responses API 生效,会写入 reasoning.effort。推理越高通常越稳,但延迟也会更长。", unit.Sp(10), fluent.textDim, font.Normal) })) } - rows = append(rows, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.technicalField(gtx, "图像模型 ID", &a.imageModelInput, client.ImageModel, unit.Dp(40)) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.technicalField(gtx, "并发数量限制", &a.concurrencyLimitInput, "留空 = 不限制", unit.Dp(40)) - }), - ) - if len(probeImageModels) > 0 { + if a.api == string(client.APIModeImages) { + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.technicalField(gtx, "请求模型 ID", &a.imageModelInput, client.ImageModel, unit.Dp(40)) + })) + } + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.technicalField(gtx, "并发数量限制", &a.concurrencyLimitInput, "留空 = 不限制", unit.Dp(40)) + })) + if a.api == string(client.APIModeImages) && len(probeImageModels) > 0 { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.layoutProbeModelSuggestions(gtx, "推荐图像模型", "image", probeImageModels, strings.TrimSpace(a.imageModelInput.Text()), func(id string) { + return a.layoutProbeModelSuggestions(gtx, "推荐请求模型", "image", probeImageModels, strings.TrimSpace(a.imageModelInput.Text()), func(id string) { a.imageModelInput.SetText(strings.TrimSpace(id)) }) })) diff --git a/gio-client/internal/ui/upstream_models.go b/gio-client/internal/ui/upstream_models.go index 256965aee..d6b690e39 100644 --- a/gio-client/internal/ui/upstream_models.go +++ b/gio-client/internal/ui/upstream_models.go @@ -61,9 +61,9 @@ func preferredProbeModels(apiMode string, models []kernel.UpstreamModelDescripto } return nil, image } - if len(text) == 0 { - text = all - } + // Responses accepts any supported upstream model as the request model; + // keep the suggestions unclassified so image-capable models are not hidden. + text = all if len(image) == 0 { image = all } diff --git a/gio-client/internal/ui/upstream_models_test.go b/gio-client/internal/ui/upstream_models_test.go index 36c2c043d..7c2313fbb 100644 --- a/gio-client/internal/ui/upstream_models_test.go +++ b/gio-client/internal/ui/upstream_models_test.go @@ -6,14 +6,14 @@ import ( "image-studio/gio-client/internal/kernel" ) -func TestPreferredProbeModelsSplitsTextAndImage(t *testing.T) { +func TestPreferredProbeModelsUsesAllModelsForResponses(t *testing.T) { text, image := preferredProbeModels("responses", []kernel.UpstreamModelDescriptor{ {ID: "gpt-5.5"}, {ID: "gpt-image-2"}, {ID: "gpt-5.5"}, }) - if len(text) != 1 || text[0].ID != "gpt-5.5" { - t.Fatalf("text=%v want [gpt-5.5]", text) + if len(text) != 2 || text[0].ID != "gpt-5.5" || text[1].ID != "gpt-image-2" { + t.Fatalf("text=%v want all request models", text) } if len(image) != 1 || image[0].ID != "gpt-image-2" { t.Fatalf("image=%v want [gpt-image-2]", image) diff --git a/go-cli/pkg/client/payload.go b/go-cli/pkg/client/payload.go index 1704e189b..213414b98 100644 --- a/go-cli/pkg/client/payload.go +++ b/go-cli/pkg/client/payload.go @@ -65,7 +65,6 @@ func BuildPayload(opts Options) ([]byte, error) { } tool := map[string]any{ "type": "image_generation", - "model": imgModel, "action": action, "size": size, "quality": quality, diff --git a/go-cli/pkg/client/payload_test.go b/go-cli/pkg/client/payload_test.go index 3e6433c21..0dec3b244 100644 --- a/go-cli/pkg/client/payload_test.go +++ b/go-cli/pkg/client/payload_test.go @@ -44,8 +44,8 @@ func TestBuildPayloadUsesSizeAndQuality(t *testing.T) { if tool["moderation"] != "low" { t.Errorf("moderation = %v, want low", tool["moderation"]) } - if tool["model"] != "gpt-image-2" { - t.Errorf("model = %v, want gpt-image-2", tool["model"]) + if _, ok := tool["model"]; ok { + t.Errorf("Responses image_generation tool unexpectedly contains a second model: %v", tool["model"]) } if tool["action"] != "generate" { t.Errorf("action = %v, want generate", tool["action"]) diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go index 4b7e35f6e..e2877b899 100644 --- a/image-studio/backend/dialogs.go +++ b/image-studio/backend/dialogs.go @@ -25,6 +25,18 @@ func (s *Service) BeginNativeFileDrag(path string) error { if err != nil { return err } + s.nativeDragMu.Lock() + if s.nativeDragActive { + s.nativeDragMu.Unlock() + return errors.New("已有文件正在拖动,请先松开鼠标或按 Esc 取消") + } + s.nativeDragActive = true + s.nativeDragMu.Unlock() + defer func() { + s.nativeDragMu.Lock() + s.nativeDragActive = false + s.nativeDragMu.Unlock() + }() if s.ctx != nil { runtime.EventsEmit(s.ctx, "native-file-drag", allowed) } diff --git a/image-studio/backend/mask_composite.go b/image-studio/backend/mask_composite.go new file mode 100644 index 000000000..f5817d4bb --- /dev/null +++ b/image-studio/backend/mask_composite.go @@ -0,0 +1,153 @@ +package backend + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "strings" + + xdraw "golang.org/x/image/draw" +) + +// normalizeEditMaskB64 converts imported black/white masks and painted masks +// to OpenAI's alpha convention: transparent pixels are editable and opaque +// pixels protect the source image. The returned PNG always matches the first +// edit source dimensions. +func normalizeEditMaskB64(maskB64, sourcePath string) (string, error) { + maskBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(maskB64)) + if err != nil { + return "", fmt.Errorf("蒙版图片 base64 无效: %w", err) + } + if len(maskBytes) == 0 { + return "", errors.New("蒙版图片为空") + } + mask, _, err := image.Decode(bytes.NewReader(maskBytes)) + if err != nil { + return "", fmt.Errorf("解析蒙版图片失败: %w", err) + } + source, err := loadImage(sourcePath) + if err != nil { + return "", fmt.Errorf("读取蒙版源图失败: %w", err) + } + + dims := source.Bounds().Size() + if dims.X <= 0 || dims.Y <= 0 { + return "", errors.New("蒙版源图尺寸无效") + } + scaled := image.NewNRGBA(image.Rect(0, 0, dims.X, dims.Y)) + copyOrScaleMask(scaled, mask) + + hasTransparency := maskHasTransparency(scaled) + + normalized := image.NewNRGBA(scaled.Bounds()) + for y := 0; y < dims.Y; y++ { + for x := 0; x < dims.X; x++ { + pixel := scaled.NRGBAAt(x, y) + alpha := pixel.A + if !hasTransparency { + // Opaque black/white masks are accepted as a convenience. Match the + // canvas UI: white is the painted/editable selection, black is kept. + luma := (uint32(pixel.R)*299 + uint32(pixel.G)*587 + uint32(pixel.B)*114 + 500) / 1000 + alpha = uint8(0xff - luma) + } + normalized.SetNRGBA(x, y, color.NRGBA{A: alpha}) + } + } + + var encoded bytes.Buffer + if err := png.Encode(&encoded, normalized); err != nil { + return "", fmt.Errorf("编码蒙版 PNG 失败: %w", err) + } + return base64.StdEncoding.EncodeToString(encoded.Bytes()), nil +} + +func maskHasTransparency(mask *image.NRGBA) bool { + for y := mask.Bounds().Min.Y; y < mask.Bounds().Max.Y; y++ { + for x := mask.Bounds().Min.X; x < mask.Bounds().Max.X; x++ { + if mask.NRGBAAt(x, y).A < 0xff { + return true + } + } + } + return false +} + +// compositeMaskedEditB64 enforces the mask locally after generation. Pixels +// protected by the mask come from the original source, so model drift outside +// the editable selection cannot leak into the saved result. +func compositeMaskedEditB64(sourcePath, normalizedMaskB64, generatedB64 string) (string, error) { + source, err := loadImage(sourcePath) + if err != nil { + return "", fmt.Errorf("读取蒙版源图失败: %w", err) + } + mask, err := decodeBase64Image(normalizedMaskB64, "蒙版") + if err != nil { + return "", err + } + generated, err := decodeBase64Image(generatedB64, "生成结果") + if err != nil { + return "", err + } + + bounds := image.Rect(0, 0, source.Bounds().Dx(), source.Bounds().Dy()) + if bounds.Empty() { + return "", errors.New("蒙版源图尺寸无效") + } + sourceRGBA := image.NewNRGBA(bounds) + draw.Draw(sourceRGBA, bounds, source, source.Bounds().Min, draw.Src) + maskRGBA := image.NewNRGBA(bounds) + copyOrScaleMask(maskRGBA, mask) + generatedRGBA := image.NewNRGBA(bounds) + xdraw.CatmullRom.Scale(generatedRGBA, bounds, generated, generated.Bounds(), draw.Src, nil) + + result := image.NewNRGBA(bounds) + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + keep := uint32(maskRGBA.NRGBAAt(x, y).A) + edit := uint32(0xff) - keep + srcPixel := sourceRGBA.NRGBAAt(x, y) + generatedPixel := generatedRGBA.NRGBAAt(x, y) + result.SetNRGBA(x, y, color.NRGBA{ + R: blendMaskChannel(srcPixel.R, generatedPixel.R, keep, edit), + G: blendMaskChannel(srcPixel.G, generatedPixel.G, keep, edit), + B: blendMaskChannel(srcPixel.B, generatedPixel.B, keep, edit), + A: blendMaskChannel(srcPixel.A, generatedPixel.A, keep, edit), + }) + } + } + + var encoded bytes.Buffer + if err := png.Encode(&encoded, result); err != nil { + return "", fmt.Errorf("编码蒙版合成结果失败: %w", err) + } + return base64.StdEncoding.EncodeToString(encoded.Bytes()), nil +} + +func copyOrScaleMask(dst *image.NRGBA, src image.Image) { + if dst.Bounds().Dx() == src.Bounds().Dx() && dst.Bounds().Dy() == src.Bounds().Dy() { + draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) + return + } + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil) +} + +func decodeBase64Image(value, label string) (image.Image, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("%s base64 无效: %w", label, err) + } + decoded, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("解析%s失败: %w", label, err) + } + return decoded, nil +} + +func blendMaskChannel(source, generated uint8, keep, edit uint32) uint8 { + return uint8((uint32(source)*keep + uint32(generated)*edit + 127) / 255) +} diff --git a/image-studio/backend/mask_composite_test.go b/image-studio/backend/mask_composite_test.go new file mode 100644 index 000000000..cd438a978 --- /dev/null +++ b/image-studio/backend/mask_composite_test.go @@ -0,0 +1,118 @@ +package backend + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" +) + +func TestNormalizeEditMaskB64ConvertsWhiteSelectionToTransparentAlpha(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.png") + writeMaskTestPNG(t, sourcePath, image.NewUniform(color.NRGBA{R: 20, G: 30, B: 40, A: 255}), image.Rect(0, 0, 2, 1)) + + mask := image.NewNRGBA(image.Rect(0, 0, 2, 1)) + mask.SetNRGBA(0, 0, color.NRGBA{A: 255}) + mask.SetNRGBA(1, 0, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + maskB64 := encodeMaskTestPNG(t, mask) + + normalizedB64, err := normalizeEditMaskB64(maskB64, sourcePath) + if err != nil { + t.Fatal(err) + } + normalized := decodeMaskTestPNG(t, normalizedB64) + if got := color.NRGBAModel.Convert(normalized.At(0, 0)).(color.NRGBA).A; got != 255 { + t.Fatalf("protected black alpha=%d want 255", got) + } + if got := color.NRGBAModel.Convert(normalized.At(1, 0)).(color.NRGBA).A; got != 0 { + t.Fatalf("editable white alpha=%d want 0", got) + } +} + +func TestCompositeMaskedEditB64KeepsProtectedPixels(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.png") + source := image.NewNRGBA(image.Rect(0, 0, 2, 1)) + source.SetNRGBA(0, 0, color.NRGBA{R: 200, A: 255}) + source.SetNRGBA(1, 0, color.NRGBA{G: 200, A: 255}) + writeMaskTestPNG(t, sourcePath, source, source.Bounds()) + + mask := image.NewNRGBA(source.Bounds()) + mask.SetNRGBA(0, 0, color.NRGBA{A: 255}) + mask.SetNRGBA(1, 0, color.NRGBA{A: 0}) + generated := image.NewNRGBA(source.Bounds()) + generated.SetNRGBA(0, 0, color.NRGBA{B: 220, A: 255}) + generated.SetNRGBA(1, 0, color.NRGBA{B: 220, A: 255}) + + resultB64, err := compositeMaskedEditB64(sourcePath, encodeMaskTestPNG(t, mask), encodeMaskTestPNG(t, generated)) + if err != nil { + t.Fatal(err) + } + result := decodeMaskTestPNG(t, resultB64) + if got := color.NRGBAModel.Convert(result.At(0, 0)).(color.NRGBA); got != source.NRGBAAt(0, 0) { + t.Fatalf("protected pixel=%v want source %v", got, source.NRGBAAt(0, 0)) + } + if got := color.NRGBAModel.Convert(result.At(1, 0)).(color.NRGBA); got != generated.NRGBAAt(1, 0) { + t.Fatalf("editable pixel=%v want generated %v", got, generated.NRGBAAt(1, 0)) + } +} + +func TestNormalizeEditMaskB64PreservesFullyTransparentFullEditMask(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.png") + writeMaskTestPNG(t, sourcePath, image.NewUniform(color.NRGBA{A: 255}), image.Rect(0, 0, 1, 1)) + mask := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + + normalizedB64, err := normalizeEditMaskB64(encodeMaskTestPNG(t, mask), sourcePath) + if err != nil { + t.Fatal(err) + } + normalized := decodeMaskTestPNG(t, normalizedB64) + if got := color.NRGBAModel.Convert(normalized.At(0, 0)).(color.NRGBA).A; got != 0 { + t.Fatalf("full edit alpha=%d want 0", got) + } +} + +func encodeMaskTestPNG(t *testing.T, img image.Image) string { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func decodeMaskTestPNG(t *testing.T, value string) image.Image { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + return img +} + +func writeMaskTestPNG(t *testing.T, path string, src image.Image, bounds image.Rectangle) { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + img.Set(x, y, src.At(bounds.Min.X+x, bounds.Min.Y+y)) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/image-studio/backend/native_drag_windows.go b/image-studio/backend/native_drag_windows.go index cf94a9089..06de40636 100644 --- a/image-studio/backend/native_drag_windows.go +++ b/image-studio/backend/native_drag_windows.go @@ -8,6 +8,7 @@ import ( "strings" "sync/atomic" "syscall" + "time" "unsafe" "golang.org/x/sys/windows" @@ -23,6 +24,8 @@ const ( dropEffectCopy uintptr = 0x00000001 mouseKeyStateLeftButtonDown uintptr = 0x00000001 globalAllocMoveableZeroInit uintptr = 0x00000042 + virtualKeyLeftButton uintptr = 0x01 + nativeDragMaxDuration = 30 * time.Second cfHDrop uint16 = 15 dvAspectContent uint32 = 1 tymedHGlobal uint32 = 1 @@ -32,6 +35,7 @@ var ( ole32 = windows.NewLazySystemDLL("ole32.dll") shell32 = windows.NewLazySystemDLL("shell32.dll") kernel32 = windows.NewLazySystemDLL("kernel32.dll") + user32 = windows.NewLazySystemDLL("user32.dll") procOleInitialize = ole32.NewProc("OleInitialize") procOleUninitialize = ole32.NewProc("OleUninitialize") procDoDragDrop = ole32.NewProc("DoDragDrop") @@ -42,6 +46,7 @@ var ( procGlobalLock = kernel32.NewProc("GlobalLock") procGlobalUnlock = kernel32.NewProc("GlobalUnlock") procGlobalFree = kernel32.NewProc("GlobalFree") + procGetAsyncKeyState = user32.NewProc("GetAsyncKeyState") iidIUnknown = windows.GUID{Data1: 0x00000000, Data2: 0x0000, Data3: 0x0000, Data4: [8]byte{0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} iidIDataObj = windows.GUID{Data1: 0x0000010e, Data2: 0x0000, Data3: 0x0000, Data4: [8]byte{0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46}} @@ -57,8 +62,9 @@ type dropSourceVtbl struct { } type dropSource struct { - lpVtbl *dropSourceVtbl - refs uint32 + lpVtbl *dropSourceVtbl + refs uint32 + startedUnix int64 } var nativeDropSourceVtbl = dropSourceVtbl{ @@ -114,7 +120,7 @@ func beginNativeFileDrag(path string) error { return err } - source := &dropSource{lpVtbl: &nativeDropSourceVtbl, refs: 1} + source := &dropSource{lpVtbl: &nativeDropSourceVtbl, refs: 1, startedUnix: time.Now().UnixNano()} var effect uintptr hr, _, _ = procDoDragDrop.Call( dataObject, @@ -234,13 +240,23 @@ func dropSourceRelease(this uintptr) uintptr { return uintptr(next) } -func dropSourceQueryContinueDrag(_ uintptr, escapePressed uintptr, keyState uintptr) uintptr { +func dropSourceQueryContinueDrag(this uintptr, escapePressed uintptr, keyState uintptr) uintptr { if escapePressed != 0 { return dragDropSCancel } - if keyState&mouseKeyStateLeftButtonDown == 0 { + if this != 0 { + source := (*dropSource)(unsafe.Pointer(this)) + if source.startedUnix > 0 && time.Since(time.Unix(0, source.startedUnix)) >= nativeDragMaxDuration { + return dragDropSCancel + } + } + asyncState, _, _ := procGetAsyncKeyState.Call(virtualKeyLeftButton) + if keyState&mouseKeyStateLeftButtonDown == 0 || uint16(asyncState)&0x8000 == 0 { return dragDropSDrop } + // Some third-party file managers poll IDropSource extremely aggressively. + // Yield briefly so a stalled target cannot pin a CPU core while hovering. + time.Sleep(time.Millisecond) return sOK } diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index 7965c7293..58716a67a 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -35,6 +35,8 @@ type Service struct { ctx context.Context mu sync.Mutex + nativeDragMu sync.Mutex + nativeDragActive bool jobs map[string]*job runningByAPIMode map[string]int outputDir string // 用户自定义输出目录;空时回退到 defaultOutputDir() @@ -431,7 +433,14 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions PartialImages: clientOpts.PartialImages, } } + originalEditSourcePath := "" if mode == client.ModeEdit { + for _, candidate := range opts.collectPaths() { + if strings.TrimSpace(candidate) != "" { + originalEditSourcePath = strings.TrimSpace(candidate) + break + } + } paths, cleanup, prepErr := prepareUploadSourcePaths(opts.collectPaths()) if prepErr != nil { s.emitError(jobID, prepErr) @@ -442,6 +451,17 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions if fallbackClientOpts != nil { fallbackClientOpts.ImagePaths = paths } + if strings.TrimSpace(opts.MaskB64) != "" { + normalizedMask, maskErr := normalizeEditMaskB64(opts.MaskB64, originalEditSourcePath) + if maskErr != nil { + s.emitError(jobID, maskErr) + return + } + clientOpts.MaskB64 = normalizedMask + if fallbackClientOpts != nil { + fallbackClientOpts.MaskB64 = normalizedMask + } + } // Responses API 仍需 data URL(走 input_image 形态); // Images API 直接 multipart 上传文件,跳过 base64 编码节省往返开销。 if apiMode == client.APIModeResponses || (fallbackClientOpts != nil && fallbackClientOpts.APIMode == client.APIModeResponses) { @@ -569,8 +589,19 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions s.emitErrorWithRaw(jobID, err, rawPath) return } + effectiveOutputFormat := opts.OutputFormat + if mode == client.ModeEdit && strings.TrimSpace(clientOpts.MaskB64) != "" && len(clientOpts.ImagePaths) > 0 { + composited, compositeErr := compositeMaskedEditB64(originalEditSourcePath, clientOpts.MaskB64, result.ImageB64) + if compositeErr != nil { + s.emitErrorWithRaw(jobID, fmt.Errorf("保护蒙版外原图失败: %w", compositeErr), rawPath) + return + } + result.ImageB64 = composited + effectiveOutputFormat = "png" + logFn("已在本地合成蒙版结果,蒙版外区域使用原图像素。") + } - imageName := buildImageName(mode, opts.Prompt, timestamp, opts.OutputFormat) + imageName := buildImageName(mode, opts.Prompt, timestamp, effectiveOutputFormat) savedPath := filepath.Join(imagesDir, imageName) absSaved, werr := writeBase64PNG(result.ImageB64, savedPath) if werr != nil { @@ -605,6 +636,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions RawPath: absRaw, Mode: string(mode), Prompt: opts.Prompt, + OutputFormat: effectiveOutputFormat, }) } diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index cec9baa8f..6ebca49a9 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -192,6 +192,7 @@ type ResultPayload struct { RawPath string `json:"rawPath"` // raw SSE dump location Mode string `json:"mode"` Prompt string `json:"prompt"` + OutputFormat string `json:"outputFormat,omitempty"` } // PreviewPayload is emitted as `preview:` while Responses API streams diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx index fc94f9d9f..6b2c66df3 100644 --- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx +++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx @@ -464,7 +464,7 @@ export function CanvasStage() { <> {!currentImage && !showingResultGrid && } diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index 0eb280809..c07f748a7 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -36,19 +36,19 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void image_generation 工具触发生图,支持 HTTP SSE 与 WebSocket mode 两种传输。 能防 Cloudflare 524/504 超时(图像推理常常超过 100 秒)。 - Key 要绑「拥有 gpt-5.5 模型的分组」(中转站后台通常叫「余额分组」或「套餐分组」), - 不要选 image-2 分组。 + Key 需要拥有请求模型的权限(中转站后台通常按「余额分组」或「套餐分组」授权)。 + 请求模型可以是该 Responses 上游支持的文本或图像模型。 Images API · 兼容广:标准 OpenAI /v1/images/generations(文生图)+ /v1/images/edits(图生图,multipart 上传)。一次性 JSON 响应,无 SSE 保活, 长推理上 CF 超时风险更高。几乎所有 OpenAI 兼容中转站都开了这两个端点。 - Key 可使用标准的 image-2 / image API 分组,不需要 gpt-5.5 权限。 + Key 需要拥有请求模型及 Images 端点权限。 - 选哪个?看你 key 绑的是哪个分组。 + 选哪个?看上游实际支持的端点和请求模型。 两边都有的话优先 Responses(SSE 更抗 524)。 @@ -96,23 +96,19 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void - 能换其他文本 / 图像模型吗? + 能换其他请求模型吗? - 可以。在「🔧 上游配置」里填即可,不同 API 形态用到的字段不一样: + 可以。在「🔧 上游配置」里填写请求模型 ID 即可: - Responses API 用两个模型 ID: - - 文本模型 ID(默认 gpt-5.5):承担推理 + 调用 image_generation 工具 - 图像模型 ID(默认 gpt-image-2):工具实际用哪个图像模型出图 - + Responses API 只用一个请求模型 ID,直接写入请求的 model 字段。它可以是文本模型,也可以是上游支持的图像模型;应用不会再要求分别配置两种模型。 Images API 只用一个模型 ID: - 图像模型 ID(默认 gpt-image-2):通常直接传给 /v1/images/generations 的 model 字段。Google 官方主机上的 gemini-3.1-flash-image 会按官方 Interactions API 请求;第三方 Gemini / Imagen 中转仍使用非流式 Images 兼容请求。文本模型 ID 在此模式下不读。 + 请求模型 ID(通常为 gpt-image-2):直接传给 /v1/images/generations 的 model 字段。Google 官方主机上的 gemini-3.1-flash-image 会按官方 Interactions API 请求;第三方 Gemini / Imagen 中转仍使用非流式 Images 兼容请求。 diff --git a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx index e3b30b558..d5d87870b 100644 --- a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx +++ b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx @@ -128,7 +128,7 @@ export function ResultDetailDrawer() { {detail.revisedPrompt && ( 优化后提示词} - hint="Responses API 模式下文本模型可能会重写你的提示词。" + hint="Responses API 模式下请求模型可能会重写你的提示词。" > {detail.revisedPrompt} diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index e77492ad9..7351fb75f 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -75,6 +75,9 @@ export function UpstreamProfileEditor({ 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]; const preferredModels = modelCatalog ? preferredModelsForAPIMode(modelCatalog, draft.apiMode) : null; + const requestModels = modelCatalog + ? (draft.apiMode === "responses" ? modelCatalog.all : preferredModels?.image ?? []) + : []; const fallbackCandidates = profiles.filter((profile) => profile.id !== draft.id && profile.baseURL.trim()); return ( @@ -124,7 +127,7 @@ export function UpstreamProfileEditor({ {draft.apiMode === "responses" - ? "需要 key 绑定到「拥有 gpt-5.5 模型的分组」。SSE 保活可防 Cloudflare 524。" + ? "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" : "使用标准 Images API,key 用 image-2 / image API 分组,兼容性最广。"} @@ -283,7 +286,7 @@ export function UpstreamProfileEditor({ - + - {preferredModels && preferredModels.text.length > 0 ? ( + {requestModels.length > 0 ? ( onPatchDraft({ textModelID: id })} @@ -332,25 +335,27 @@ export function UpstreamProfileEditor({ > ) : null} - - onPatchDraft({ imageModelID: e.target.value })} - spellCheck={false} - className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} - /> - {preferredModels && preferredModels.image.length > 0 ? ( - onPatchDraft({ imageModelID: id })} + {draft.apiMode === "images" ? ( + + onPatchDraft({ imageModelID: e.target.value })} + spellCheck={false} + className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} /> - ) : null} - + {requestModels.length > 0 ? ( + onPatchDraft({ imageModelID: id })} + /> + ) : null} + + ) : null} { + const [source, mask] = await Promise.all([ + loadImage(sourceDataURL), + loadImage(dataURLFromBase64(maskB64)), + ]); + const canvas = makeCanvas(source.naturalWidth, source.naturalHeight); + const context = requireContext(canvas); + context.drawImage(mask, 0, 0, canvas.width, canvas.height); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + let hasTransparency = false; + for (let offset = 3; offset < pixels.data.length; offset += 4) { + const alpha = pixels.data[offset]; + if (alpha < 255) hasTransparency = true; + } + if (!hasTransparency) { + for (let offset = 0; offset < pixels.data.length; offset += 4) { + const luma = Math.round( + pixels.data[offset] * 0.299 + + pixels.data[offset + 1] * 0.587 + + pixels.data[offset + 2] * 0.114, + ); + pixels.data[offset] = 0; + pixels.data[offset + 1] = 0; + pixels.data[offset + 2] = 0; + pixels.data[offset + 3] = 255 - luma; + } + context.putImageData(pixels, 0, 0); + } + return { sourceDataURL, maskB64: stripDataURL(canvas.toDataURL("image/png")) }; +} + +export async function compositeMaskedEdit( + prepared: PreparedMaskComposite, + generatedB64: string, +): Promise { + const [source, generated, mask] = await Promise.all([ + loadImage(prepared.sourceDataURL), + loadImage(dataURLFromBase64(generatedB64)), + loadImage(dataURLFromBase64(prepared.maskB64)), + ]); + const width = source.naturalWidth; + const height = source.naturalHeight; + const maskCanvas = makeCanvas(width, height); + const maskContext = requireContext(maskCanvas); + maskContext.drawImage(mask, 0, 0, width, height); + const maskPixels = maskContext.getImageData(0, 0, width, height).data; + + const sourceCanvas = makeCanvas(width, height); + const sourceContext = requireContext(sourceCanvas); + sourceContext.drawImage(source, 0, 0, width, height); + const sourcePixels = sourceContext.getImageData(0, 0, width, height).data; + + const generatedCanvas = makeCanvas(width, height); + const generatedContext = requireContext(generatedCanvas); + generatedContext.drawImage(generated, 0, 0, width, height); + const generatedPixels = generatedContext.getImageData(0, 0, width, height).data; + + const result = generatedContext.createImageData(width, height); + for (let offset = 0; offset < result.data.length; offset += 4) { + const keep = maskPixels[offset + 3]; + const edit = 255 - keep; + for (let channel = 0; channel < 4; channel++) { + result.data[offset + channel] = Math.round( + (sourcePixels[offset + channel] * keep + generatedPixels[offset + channel] * edit) / 255, + ); + } + } + generatedContext.putImageData(result, 0, 0); + return stripDataURL(generatedCanvas.toDataURL("image/png")); +} + +function makeCanvas(width: number, height: number): HTMLCanvasElement { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error("蒙版源图尺寸无效"); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; +} + +function requireContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("无法创建蒙版合成画布"); + return context; +} + +function loadImage(dataURL: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("无法解码蒙版或源图片")); + image.src = dataURL; + }); +} + +function stripDataURL(value: string): string { + const comma = value.indexOf(","); + return comma >= 0 ? value.slice(comma + 1) : value; +} diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx index a5dd26486..7f5cff942 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx @@ -59,6 +59,9 @@ export function AndroidUpstreamProfileForm({ const isActive = draft.id === activeProfileId; const busy = saving || isTestingKey; const preferredModels = modelCatalog ? preferredModelsForAPIMode(modelCatalog, draft.apiMode) : null; + const requestModels = modelCatalog + ? (draft.apiMode === "responses" ? modelCatalog.all : preferredModels?.image ?? []) + : []; return ( @@ -187,7 +190,7 @@ export function AndroidUpstreamProfileForm({ - + - {preferredModels && preferredModels.text.length > 0 ? ( + {requestModels.length > 0 ? ( onPatchDraft({ textModelID: id })} /> @@ -223,23 +226,25 @@ export function AndroidUpstreamProfileForm({ > ) : null} - - onPatchDraft({ imageModelID: event.target.value })} - placeholder="留空 = 默认 gpt-image-2" - className="focus-ring android-upstream-input font-mono-token" - spellCheck={false} - /> - {preferredModels && preferredModels.image.length > 0 ? ( - onPatchDraft({ imageModelID: id })} + {draft.apiMode === "images" ? ( + + onPatchDraft({ imageModelID: event.target.value })} + placeholder="留空 = 默认 gpt-image-2" + className="focus-ring android-upstream-input font-mono-token" + spellCheck={false} /> - ) : null} - + {requestModels.length > 0 ? ( + onPatchDraft({ imageModelID: id })} + /> + ) : null} + + ) : null} diff --git a/image-studio/frontend/src/platform/runtime/host.ts b/image-studio/frontend/src/platform/runtime/host.ts index 940472c66..9cccad5a8 100644 --- a/image-studio/frontend/src/platform/runtime/host.ts +++ b/image-studio/frontend/src/platform/runtime/host.ts @@ -18,7 +18,10 @@ import { registerVirtualText, releaseVirtualPath, rotateVirtualImage, + sourceToDataURL, } from "../../lib/virtualHostStore.ts"; +import { compositeMaskedEdit, normalizeMaskForSource, type PreparedMaskComposite } from "../../lib/maskComposite.ts"; +import { dataURLFromBase64 } from "../../lib/images.ts"; import { browserStoredAPIKey, fileNameFromPath, @@ -146,12 +149,26 @@ async function startRemoteJob(options: GenerateOptionsLike): Promise { try { + let preparedMask: PreparedMaskComposite | null = null; + let remoteOptions = options; + if (options.mode === "edit" && options.maskB64) { + const firstSource = options.sourceImages?.[0]; + let sourceDataURL = await sourceToDataURL(firstSource); + const sourcePath = firstSource?.path || options.imagePaths?.[0] || options.imagePath; + if (!sourceDataURL && sourcePath) { + const sourceB64 = await ReadImageAsBase64(sourcePath); + sourceDataURL = dataURLFromBase64(sourceB64); + } + if (!sourceDataURL) throw new RemoteKernelError("蒙版任务无法读取第一张源图"); + preparedMask = await normalizeMaskForSource(sourceDataURL, options.maskB64); + remoteOptions = { ...options, maskB64: preparedMask.maskB64 }; + } const result = await runRemoteImageJob({ payload: { - ...options, - requestPolicy: normalizeRequestPolicy(options.requestPolicy), - imagesNewAPICompat: options.imagesNewAPICompat === true, - allowInsecureConnection: options.allowInsecureConnection === true, - }, sourceImages: options.sourceImages }, { + ...remoteOptions, + requestPolicy: normalizeRequestPolicy(remoteOptions.requestPolicy), + imagesNewAPICompat: remoteOptions.imagesNewAPICompat === true, + allowInsecureConnection: remoteOptions.allowInsecureConnection === true, + }, sourceImages: remoteOptions.sourceImages }, { signal: controller.signal, onLog: (line) => emitLocalEvent(`log:${jobId}`, line), onProgress: (stage, elapsed, bytes) => emitLocalEvent(`progress:${jobId}`, { stage, elapsed, bytes }), @@ -164,18 +181,23 @@ async function startRemoteJob(options: GenerateOptionsLike): Promise { @@ -198,6 +209,21 @@ export function imageDims(b64: string): { w: number; h: number } | null { return getImageDimensionsFromBase64(b64); } +export async function loadImageDims(b64: string): Promise<{ w: number; h: number } | null> { + if (!b64) return null; + const fast = detectImageMimeTypeFromBase64(b64) === "image/png" ? imageDims(b64) : null; + if (fast) return fast; + if (typeof Image === "undefined") return null; + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0 && image.naturalHeight > 0 + ? { w: image.naturalWidth, h: image.naturalHeight } + : null); + image.onerror = () => resolve(null); + image.src = tempDataURLFromB64(b64); + }); +} + export function augmentPromptWithAnnotations( prompt: string, annotations: Annotation[], diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index 4ebb10a9b..c9b05a9f9 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -164,6 +164,7 @@ import { genId, imageDims, loadModeConfig, + loadImageDims, loadStoredActiveProfileId, loadStoredAIProfileId, loadStoredProfiles, @@ -1263,6 +1264,26 @@ export const useStudioStore = create((set, get) => ({ jobsCompleted: 0, runningJobs: [], }; + const importedMaskDataURL = s.maskDataURL && s.maskDataURL !== "__PENDING_MASK__" ? s.maskDataURL : null; + let maskTargetDims = s.currentImage?.imageB64 ? await loadImageDims(s.currentImage.imageB64) : null; + if (s.mode === "edit" && s.strokes.length > 0 && !maskTargetDims && editSourcePaths[0]) { + const sourceB64 = await ReadImageAsBase64(editSourcePaths[0]).catch(() => ""); + maskTargetDims = await loadImageDims(sourceB64); + } + if (s.mode === "edit" && s.strokes.length > 0 && !maskTargetDims) { + set({ + errorMessage: "无法读取源图真实尺寸,未提交蒙版任务。请重新导入源图后再试。", + errorCanRetry: false, + errorRawPath: null, + }); + return; + } + const maskDataURL = s.mode === "edit" + ? (s.strokes.length > 0 + ? buildMaskPNGDataURL(s.strokes, maskTargetDims) + : importedMaskDataURL) + : null; + const maskB64 = maskDataURL ? stripDataURLPrefix(maskDataURL) : ""; set({ ...runPatch, batchCount, @@ -1280,14 +1301,6 @@ export const useStudioStore = create((set, get) => ({ resultGridOpen: requestedJobCount > 1, }), }); - - const importedMaskDataURL = s.maskDataURL && s.maskDataURL !== "__PENDING_MASK__" ? s.maskDataURL : null; - const maskDataURL = s.mode === "edit" - ? (s.strokes.length > 0 - ? buildMaskPNGDataURL(s.strokes, s.currentImage?.imageB64 ? imageDims(s.currentImage.imageB64) : null) - : importedMaskDataURL) - : null; - const maskB64 = maskDataURL ? stripDataURLPrefix(maskDataURL) : ""; let augmentedPrompt = augmentPromptWithAnnotations(s.prompt, s.annotations, s.currentImage?.imageB64 ? imageDims(s.currentImage.imageB64) : null); // Append style chip suffix if the user picked one (other than "全部"). const styleSuffix = STYLE_SUFFIXES[s.styleTag]; @@ -2544,7 +2557,9 @@ async function launchOneJob( mode: r.mode as Mode, size: snapshot.size, quality: snapshot.quality, - outputFormat: snapshot.outputFormat, + outputFormat: r.outputFormat === "png" || r.outputFormat === "jpeg" || r.outputFormat === "webp" + ? r.outputFormat + : snapshot.outputFormat, parentId, createdAt: Date.now(), seed: payload.seed || undefined, diff --git a/image-studio/frontend/src/styles/_canvas.css b/image-studio/frontend/src/styles/_canvas.css index 8e9f4a157..f4043eae0 100644 --- a/image-studio/frontend/src/styles/_canvas.css +++ b/image-studio/frontend/src/styles/_canvas.css @@ -292,6 +292,14 @@ html[data-target-platform="macos"] .source-strip { will-change: background-position; } +/* The Konva canvas fully covers the checkerboard once an image is present. + Stop the hidden background animation so an idle/backgrounded WebView does + not keep repainting the whole desktop canvas. */ +.stage-host.has-image { + animation: none; + will-change: auto; +} + .stream-preview-overlay { position: absolute; top: 12px; diff --git a/image-studio/frontend/test/requestModel.test.mjs b/image-studio/frontend/test/requestModel.test.mjs index 5119006fa..097b80b32 100644 --- a/image-studio/frontend/test/requestModel.test.mjs +++ b/image-studio/frontend/test/requestModel.test.mjs @@ -40,9 +40,11 @@ test("Responses payload defaults partial_images to streaming preview count", () quality: "low", outputFormat: "png", imageModelID: "gpt-image-2", - textModelID: "gpt-5.5", + textModelID: "gpt-image-1.5", requestPolicy: "openai", }, []); + assert.equal(payload.model, "gpt-image-1.5"); + assert.equal("model" in payload.tools[0], false); assert.equal(payload.tools[0].partial_images, DEFAULT_PARTIAL_IMAGES); assert.equal(payload.reasoning.effort, DEFAULT_REASONING_EFFORT); }); diff --git a/shared/kernel/requestModel.js b/shared/kernel/requestModel.js index 858227a42..e9ad19121 100644 --- a/shared/kernel/requestModel.js +++ b/shared/kernel/requestModel.js @@ -372,7 +372,6 @@ export function buildResponsesImageTool(payload, sourceDataURLs, options = {}) { const partialImages = payload.disablePreview ? 0 : normalizePartialImages(payload.partialImages); const tool = { type: "image_generation", - model: normalizeImageModel(payload.imageModelID), action: sourceDataURLs.length > 0 ? "edit" : "generate", size, quality, From d27fbd694cf3310c0e5ce3e62a3ea69a69dfb626 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Tue, 21 Jul 2026 12:45:51 +0800 Subject: [PATCH 2/6] Add provider-aware upstream probing and profile persistence --- .../android/AndroidImageStudioBridge.kt | 41 ++++++--- gio-client/internal/compat/state.go | 5 ++ gio-client/internal/kernel/probe.go | 44 ++++++++-- gio-client/internal/kernel/runner.go | 15 +++- gio-client/internal/kernel/runner_test.go | 31 +++++++ gio-client/internal/ui/app.go | 6 ++ gio-client/internal/ui/codex_config.go | 2 + gio-client/internal/ui/layout_controls.go | 37 +++++++- gio-client/internal/ui/run_job.go | 2 + gio-client/internal/ui/ui_state.go | 22 +++++ .../internal/ui/upstream_config_transfer.go | 7 ++ go-cli/pkg/client/client.go | 4 +- go-cli/pkg/client/grok_images.go | 87 +++++++++++++++++++ go-cli/pkg/client/images_api.go | 23 ++++- go-cli/pkg/client/images_api_test.go | 83 ++++++++++++++++++ go-cli/pkg/client/types.go | 14 +++ go-cli/pkg/client/url.go | 51 ++++++++--- go-cli/pkg/client/url_test.go | 16 ++++ image-studio/backend/probe.go | 43 ++++++--- image-studio/backend/probe_test.go | 31 +++++++ image-studio/backend/service.go | 2 + image-studio/backend/types.go | 5 +- .../frontend/src/app/dev/previewData.ts | 1 + .../components/panel/UpstreamConfigModal.tsx | 42 ++++++--- .../panel/UpstreamProfileEditor.tsx | 67 +++++++++++--- .../components/panel/UpstreamProfileList.tsx | 2 +- image-studio/frontend/src/lib/profiles.ts | 18 +++- .../src/lib/upstreamConfigTransfer.ts | 12 ++- .../upstream/AndroidUpstreamEmptyState.tsx | 10 +-- .../upstream/AndroidUpstreamProfileForm.tsx | 37 ++++++-- .../upstream/AndroidUpstreamProfileRail.tsx | 3 +- .../upstream/useAndroidUpstreamConfig.ts | 31 ++++++- .../frontend/src/platform/runtime/host.ts | 2 + .../src/platform/runtime/hostTypes.ts | 5 +- .../platform/runtime/remote-kernel/common.ts | 19 ++-- .../platform/runtime/remote-kernel/images.ts | 6 +- .../platform/runtime/remote-kernel/index.ts | 5 +- .../runtime/remote-kernel/requestPayloads.ts | 52 ++++++++++- .../platform/runtime/remote-kernel/types.ts | 4 +- .../src/state/studioStore.profiles.ts | 14 ++- .../frontend/src/state/studioStore.ts | 13 ++- .../frontend/src/state/studioStore.types.ts | 3 + image-studio/frontend/src/types/domain.ts | 3 + .../frontend/test/remoteKernel.test.mjs | 50 +++++++++++ image-studio/frontend/wailsjs/go/models.ts | 4 + shared/compat-go/state.go | 7 ++ shared/compat-go/state_test.go | 3 +- shared/kernel/requestModel.d.ts | 7 +- shared/kernel/requestModel.js | 29 +++++-- 49 files changed, 892 insertions(+), 128 deletions(-) create mode 100644 go-cli/pkg/client/grok_images.go 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 d3a27436c..bb670c60b 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 @@ -595,18 +595,24 @@ class AndroidImageStudioBridge( val apiKey = payload.optString("apiKey").trim() val proxyMode = payload.optString("proxyMode", "system") val proxyUrl = payload.optString("proxyURL", "") + val provider = payload.optString("provider", "openai").lowercase(Locale.US).let { + if (it == "google" || it == "grok") it else "openai" + } 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 { - val connection = openHttpConnection(openAIEndpoint(baseUrl, "models"), proxyMode, proxyUrl, allowInsecureConnection).apply { + val endpointLabel = if (provider == "google") "/v1beta/models" else "/v1/models" + val modelsEndpoint = if (provider == "google") googleEndpoint(baseUrl, "models") else openAIEndpoint(baseUrl, "models") + val connection = openHttpConnection(modelsEndpoint, proxyMode, proxyUrl, allowInsecureConnection).apply { requestMethod = "GET" instanceFollowRedirects = true connectTimeout = 20_000 readTimeout = 20_000 doInput = true - setRequestProperty("Authorization", "Bearer $apiKey") + if (provider == "google") setRequestProperty("X-Goog-Api-Key", apiKey) + else setRequestProperty("Authorization", "Bearer $apiKey") setRequestProperty("Accept", "application/json") setRequestProperty("User-Agent", "image-studio-android") } @@ -617,30 +623,30 @@ class AndroidImageStudioBridge( } ?: "" connection.disconnect() if (status !in 200..299) { - throw IllegalStateException("上游 /v1/models 返回 $status${summarizeProbeBody(body).let { if (it.isBlank()) "" else ": $it" }}") + throw IllegalStateException("上游 $endpointLabel 返回 $status${summarizeProbeBody(body).let { if (it.isBlank()) "" else ": $it" }}") } val parsed = JSONObject(body) - if (!parsed.has("data") || parsed.isNull("data")) { - throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") - } - val data = parsed.optJSONArray("data") ?: throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") + val data = if (provider == "google") parsed.optJSONArray("models") + ?: throw IllegalStateException("上游 /v1beta/models 响应缺少 models 数组") + else parsed.optJSONArray("data") + ?: throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") val models = mutableListOf>() for (index in 0 until data.length()) { val item = data.optJSONObject(index) ?: continue - val id = item.optString("id").trim() + val id = if (provider == "google") item.optString("name").trim().removePrefix("models/") else item.optString("id").trim() if (id.isBlank()) continue models += mapOf( "id" to id, - "object" to item.optString("object").trim(), - "ownedBy" to item.optString("owned_by").trim(), - "displayName" to item.optString("name").trim(), + "object" to if (provider == "google") "model" else item.optString("object").trim(), + "ownedBy" to if (provider == "google") "google" else item.optString("owned_by").trim(), + "displayName" to if (provider == "google") item.optString("displayName").trim() else item.optString("name").trim(), ) } val result = mutableMapOf( "modelCount" to data.length(), "models" to models, ) - if (apiMode == "responses" && responsesTransport == "websocket") { + if (provider == "openai" && apiMode == "responses" && responsesTransport == "websocket") { result["responsesTransport"] = "websocket" val probePayload = JSONObject() .put("type", "response.create") @@ -994,6 +1000,17 @@ class AndroidImageStudioBridge( return if (basePath.endsWith("/openai")) "$cleaned/$path" else "$cleaned/v1/$path" } + private fun googleEndpoint(baseUrl: String, endpointPath: String): String { + var cleaned = baseUrl.trim().trimEnd('/') + val path = endpointPath.trim().trim('/') + cleaned = when { + cleaned.endsWith("/v1beta/openai", ignoreCase = true) -> cleaned.dropLast("/openai".length) + cleaned.endsWith("/v1beta", ignoreCase = true) -> cleaned + else -> "$cleaned/v1beta" + } + return if (path.isBlank()) cleaned else "$cleaned/$path" + } + private fun isProbeLoopbackHost(host: String): Boolean { val lower = host.lowercase(Locale.US).trim('[', ']') return lower == "localhost" || lower.endsWith(".localhost") || lower == "127.0.0.1" || lower == "::1" || lower == "0:0:0:0:0:0:0:1" diff --git a/gio-client/internal/compat/state.go b/gio-client/internal/compat/state.go index 56e8f8a8f..2e3c5e9d3 100644 --- a/gio-client/internal/compat/state.go +++ b/gio-client/internal/compat/state.go @@ -106,7 +106,11 @@ func ConfigFromState(cfg kernel.Config, state shared.State) kernel.Config { cfg.BaseURL = profile.BaseURL cfg.TextModelID = profile.TextModelID cfg.ImageModelID = profile.ImageModelID + cfg.Provider = client.NormalizeProvider(client.Provider(profile.Provider)) cfg.APIMode = normaliseAPIMode(profile.APIMode) + if cfg.Provider != client.ProviderOpenAI { + cfg.APIMode = client.APIModeImages + } cfg.ResponsesTransport = client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)) cfg.FallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) cfg.RequestPolicy = normalisePolicy(profile.RequestPolicy) @@ -234,6 +238,7 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State { profile := shared.UpstreamProfile{ ID: profileID, Name: nextDefaultProfileName(state.Profiles), + Provider: string(client.NormalizeProvider(cfg.Provider)), APIMode: string(normaliseAPIMode(string(cfg.APIMode))), ResponsesTransport: normalizeProfileResponsesTransport(string(cfg.ResponsesTransport)), RequestPolicy: string(normalisePolicy(string(cfg.RequestPolicy))), diff --git a/gio-client/internal/kernel/probe.go b/gio-client/internal/kernel/probe.go index a913063fd..7afc449b5 100644 --- a/gio-client/internal/kernel/probe.go +++ b/gio-client/internal/kernel/probe.go @@ -42,11 +42,22 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { ctx, cancel := context.WithTimeout(ctx, probeUpstreamTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil) + provider := client.NormalizeProvider(cfg.Provider) + modelsEndpoint := client.OpenAIAPIEndpoint(baseURL, "models") + endpointLabel := "/v1/models" + if provider == client.ProviderGoogle { + modelsEndpoint = client.GoogleAPIEndpoint(baseURL, "models") + endpointLabel = "/v1beta/models" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsEndpoint, nil) if err != nil { return ProbeResult{}, fmt.Errorf("构造测活请求失败: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + if provider == client.ProviderGoogle { + req.Header.Set("X-Goog-Api-Key", apiKey) + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", client.UserAgent()) @@ -72,9 +83,9 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { summary = readErr.Error() } if summary != "" { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回 %d: %s", resp.StatusCode, summary) + return ProbeResult{}, fmt.Errorf("上游 %s 返回 %d: %s", endpointLabel, resp.StatusCode, summary) } - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回 %d", resp.StatusCode) + return ProbeResult{}, fmt.Errorf("上游 %s 返回 %d", endpointLabel, resp.StatusCode) } if readErr != nil { return ProbeResult{}, fmt.Errorf("读取上游响应失败: %w", readErr) @@ -87,12 +98,27 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { OwnedBy string `json:"owned_by"` DisplayName string `json:"name"` } `json:"data"` + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` } if err := json.Unmarshal(body, &parsed); err != nil { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err) - } - if parsed.Data == nil { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组") + return ProbeResult{}, fmt.Errorf("上游 %s 返回的 JSON 无效: %w", endpointLabel, err) + } + if provider == client.ProviderGoogle { + for _, item := range parsed.Models { + parsed.Data = append(parsed.Data, struct { + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + DisplayName string `json:"name"` + }{ + ID: strings.TrimPrefix(strings.TrimSpace(item.Name), "models/"), Object: "model", OwnedBy: "google", DisplayName: item.DisplayName, + }) + } + } else if parsed.Data == nil { + return ProbeResult{}, fmt.Errorf("上游 %s 响应缺少 data 数组", endpointLabel) } models := make([]UpstreamModelDescriptor, 0, len(parsed.Data)) for _, item := range parsed.Data { @@ -111,7 +137,7 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { ModelCount: len(parsed.Data), Models: models, } - if cfg.APIMode == client.APIModeResponses && + if provider == client.ProviderOpenAI && cfg.APIMode == client.APIModeResponses && client.NormalizeProxyTransportValue(strings.TrimSpace(string(cfg.ResponsesTransport))) == string(client.ResponsesTransportWebSocket) { result.ResponsesTransport = string(client.ResponsesTransportWebSocket) if wsErr := client.ProbeResponsesWebSocket(ctx, client.ProbeResponsesWebSocketOptions{ diff --git a/gio-client/internal/kernel/runner.go b/gio-client/internal/kernel/runner.go index b98112a90..67bbaf06b 100644 --- a/gio-client/internal/kernel/runner.go +++ b/gio-client/internal/kernel/runner.go @@ -30,6 +30,7 @@ type Config struct { ImageModelID string Prompt string Mode client.Mode + Provider client.Provider APIMode client.APIMode RequestPolicy client.RequestPolicy ResponsesTransport client.ResponsesTransport @@ -72,6 +73,7 @@ type FallbackProfileConfig struct { BaseURL string TextModelID string ImageModelID string + Provider client.Provider APIMode client.APIMode ResponsesTransport client.ResponsesTransport RequestPolicy client.RequestPolicy @@ -107,6 +109,7 @@ func DefaultConfig() Config { TextModelID: client.TextModel, ImageModelID: client.ImageModel, Mode: client.ModeGenerate, + Provider: client.ProviderOpenAI, APIMode: client.APIModeResponses, RequestPolicy: client.RequestPolicyOpenAI, ResponsesTransport: client.ResponsesTransportSSE, @@ -206,6 +209,7 @@ func (Runner) Run(ctx context.Context, cfg Config, cb Callbacks) (Result, error) fallbackCfg.BaseURL = fallback.BaseURL fallbackCfg.TextModelID = fallback.TextModelID fallbackCfg.ImageModelID = fallback.ImageModelID + fallbackCfg.Provider = fallback.Provider fallbackCfg.APIMode = fallback.APIMode fallbackCfg.ResponsesTransport = fallback.ResponsesTransport fallbackCfg.RequestPolicy = fallback.RequestPolicy @@ -274,7 +278,10 @@ func normalizeConfig(cfg Config) Config { if cfg.Mode != client.ModeEdit { cfg.Mode = client.ModeGenerate } - if cfg.APIMode != client.APIModeImages { + cfg.Provider = client.NormalizeProvider(cfg.Provider) + if cfg.Provider != client.ProviderOpenAI || cfg.APIMode == client.APIModeImages { + cfg.APIMode = client.APIModeImages + } else { cfg.APIMode = client.APIModeResponses } if cfg.RequestPolicy != client.RequestPolicyCompat { @@ -343,8 +350,11 @@ func normalizeFallbackProfileConfig(value *FallbackProfileConfig) *FallbackProfi next.BaseURL = strings.TrimSpace(next.BaseURL) next.TextModelID = strings.TrimSpace(next.TextModelID) next.ImageModelID = strings.TrimSpace(next.ImageModelID) + next.Provider = client.NormalizeProvider(next.Provider) next.RequestPolicy = normalizeRequestPolicy(next.RequestPolicy) - if next.APIMode != client.APIModeImages { + if next.Provider != client.ProviderOpenAI || next.APIMode == client.APIModeImages { + next.APIMode = client.APIModeImages + } else { next.APIMode = client.APIModeResponses } if next.ResponsesTransport != client.ResponsesTransportWebSocket { @@ -385,6 +395,7 @@ func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir st ImageModelID: cfg.ImageModelID, Prompt: cfg.Prompt, Mode: cfg.Mode, + Provider: cfg.Provider, APIMode: cfg.APIMode, ResponsesTransport: cfg.ResponsesTransport, RequestPolicy: cfg.RequestPolicy, diff --git a/gio-client/internal/kernel/runner_test.go b/gio-client/internal/kernel/runner_test.go index 856fc64cd..190aee9f7 100644 --- a/gio-client/internal/kernel/runner_test.go +++ b/gio-client/internal/kernel/runner_test.go @@ -156,6 +156,37 @@ func TestProbeUpstreamReturnsModelCount(t *testing.T) { } } +func TestProbeUpstreamGoogleProviderUsesNativeModelsEndpoint(t *testing.T) { + var gotPath string + var gotGoogleKey string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotGoogleKey = r.Header.Get("X-Goog-Api-Key") + if auth := r.Header.Get("Authorization"); auth != "" { + t.Fatalf("Google probe must not send Authorization: %q", auth) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-3.1-flash-image","displayName":"Nano Banana 2"}]}`)) + })) + defer server.Close() + + result, err := ProbeUpstream(context.Background(), Config{ + APIKey: "google-key", + BaseURL: server.URL, + Provider: client.ProviderGoogle, + APIMode: client.APIModeImages, + }) + if err != nil { + t.Fatalf("Google probe returned error: %v", err) + } + if gotPath != "/v1beta/models" || gotGoogleKey != "google-key" { + t.Fatalf("path=%q X-Goog-Api-Key=%q", gotPath, gotGoogleKey) + } + if result.ModelCount != 1 || len(result.Models) != 1 || result.Models[0].ID != "gemini-3.1-flash-image" || result.Models[0].OwnedBy != "google" { + t.Fatalf("unexpected Google models: %+v", result) + } +} + func TestProbeUpstreamSkipsModelItemsWithoutID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/gio-client/internal/ui/app.go b/gio-client/internal/ui/app.go index e1a79c693..3f46fe202 100644 --- a/gio-client/internal/ui/app.go +++ b/gio-client/internal/ui/app.go @@ -25,6 +25,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "github.com/yuanhua/image-gptcodex/pkg/client" "github.com/yuanhua/image-gptcodex/pkg/promptimport" ) @@ -239,6 +240,7 @@ type App struct { customSizeHeightInput widget.Editor mode string + provider string api string size string quality string @@ -283,6 +285,7 @@ type App struct { batchCount int modeButtons []widget.Clickable + providerButtons []widget.Clickable apiButtons []widget.Clickable sizeButtons []widget.Clickable aspectButtons []widget.Clickable @@ -908,6 +911,7 @@ func New() *App { th: th, runner: kernel.Runner{}, mode: string(cfg.Mode), + provider: string(client.NormalizeProvider(cfg.Provider)), api: string(cfg.APIMode), size: cfg.Size, quality: cfg.Quality, @@ -1007,6 +1011,7 @@ func New() *App { workflowPreviewButtons: make([]widget.Clickable, len(partialPreviewChoices)), pruneGeneralHistoryButtons: make([]widget.Clickable, 2), modeButtons: make([]widget.Clickable, len(modeChoices)), + providerButtons: make([]widget.Clickable, 3), apiButtons: make([]widget.Clickable, len(apiChoices)), sizeButtons: make([]widget.Clickable, len(sizeChoices)), aspectButtons: make([]widget.Clickable, len(aspectChoices)+len(compatState.Settings.CustomAspectRatios)+24), @@ -1220,6 +1225,7 @@ func (a *App) configureEditors(cfg kernel.Config) { a.baseURLInput.SetText(cfg.BaseURL) a.textModelInput.SetText(cfg.TextModelID) a.imageModelInput.SetText(cfg.ImageModelID) + a.provider = string(client.NormalizeProvider(cfg.Provider)) a.outputDirInput.SetText(cfg.OutputDir) a.partialImagesInput.SetText(strconv.Itoa(cfg.PartialImages)) a.outputCompressionInput.SetText(strconv.Itoa(cfg.OutputCompression)) diff --git a/gio-client/internal/ui/codex_config.go b/gio-client/internal/ui/codex_config.go index 7af8f543e..3c77af2e3 100644 --- a/gio-client/internal/ui/codex_config.go +++ b/gio-client/internal/ui/codex_config.go @@ -305,6 +305,7 @@ func (a *App) applyCodexConfigSync(imported codexAPIConfig) error { continue } state.Profiles[i].Name = name + state.Profiles[i].Provider = string(client.ProviderOpenAI) state.Profiles[i].APIMode = string(client.APIModeResponses) state.Profiles[i].RequestPolicy = string(client.RequestPolicyOpenAI) state.Profiles[i].ImagesNewAPICompat = false @@ -319,6 +320,7 @@ func (a *App) applyCodexConfigSync(imported codexAPIConfig) error { state.Profiles = append(state.Profiles, sharedCompat.UpstreamProfile{ ID: profileID, Name: name, + Provider: string(client.ProviderOpenAI), APIMode: string(client.APIModeResponses), RequestPolicy: string(client.RequestPolicyOpenAI), ImagesNewAPICompat: false, diff --git a/gio-client/internal/ui/layout_controls.go b/gio-client/internal/ui/layout_controls.go index 017010474..927125787 100644 --- a/gio-client/internal/ui/layout_controls.go +++ b/gio-client/internal/ui/layout_controls.go @@ -2860,12 +2860,31 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.field(gtx, "名称", &a.profileNameInput, "配置1", unit.Dp(40)) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.layoutSettingsOptionCards(gtx, "服务商请求方式", []settingsOptionChoice{ + {Title: "OpenAI / 兼容", Detail: "Responses 或 Images", Value: string(client.ProviderOpenAI)}, + {Title: "Google Gemini", Detail: "Interactions 原生协议", Value: string(client.ProviderGoogle)}, + {Title: "Grok / xAI", Detail: "Imagine 原生协议", Value: string(client.ProviderGrok)}, + }, a.provider, a.providerButtons, 3, func(value string) { + a.provider = normalizeProfileProvider(value) + if a.provider != string(client.ProviderOpenAI) { + a.api = string(client.APIModeImages) + a.policy = string(client.RequestPolicyOpenAI) + } + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return a.label(gtx, "服务商决定请求路径、认证头与请求体;第三方 OpenAI 兼容中转请选择 OpenAI / 兼容。", unit.Sp(10), fluent.textDim, font.Normal) + } return a.layoutSettingsOptionCards(gtx, "API 形态", []settingsOptionChoice{ {Title: "Responses API", Detail: "SSE 保活,更适合长推理", Value: string(client.APIModeResponses)}, {Title: "Images API", Detail: "标准 generations / edits", Value: string(client.APIModeImages)}, }, a.api, a.apiButtons, 2, func(value string) { a.api = value }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } hint := "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" if a.api == string(client.APIModeImages) { hint = "使用标准 Images API,key 走 image-2 / image API 分组,兼容性最广。" @@ -2873,7 +2892,7 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.label(gtx, hint, unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if a.api != string(client.APIModeResponses) { + if a.provider != string(client.ProviderOpenAI) || a.api != string(client.APIModeResponses) { return layout.Dimensions{} } return a.layoutSettingsOptionCards(gtx, "Responses 传输", responsesTransportChoices, a.responsesTransport, a.responsesTransportButtons, 2, func(value string) { @@ -2881,25 +2900,37 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if a.api != string(client.APIModeResponses) { + if a.provider != string(client.ProviderOpenAI) || a.api != string(client.APIModeResponses) { return layout.Dimensions{} } return a.label(gtx, "WebSocket mode 需要上游 / 代理正确转发 Upgrade: websocket;若握手失败,请求层会自动回退到 HTTP SSE,并把原因写进 Raw response。", unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } return a.layoutSettingsOptionCards(gtx, "参数策略", []settingsOptionChoice{ {Title: "OpenAI 标准", Detail: "只发送官方公开字段", Value: string(client.RequestPolicyOpenAI)}, {Title: "兼容中转扩展", Detail: "附带 seed / negative_prompt", Value: string(client.RequestPolicyCompat)}, }, a.policy, a.policyButtons, 1, func(value string) { a.policy = value }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } return a.label(gtx, "OpenAI 标准更适合直连 OpenAI;兼容中转扩展会额外发送 relay 常见扩展字段。", unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.technicalField(gtx, "上游 BASE_URL", &a.baseURLInput, "https://example.com", unit.Dp(40)) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.label(gtx, "中转站只填根地址;Google 官方 Images 配置填写 https://generativelanguage.googleapis.com/v1beta/openai。", unit.Sp(10), fluent.textDim, font.Normal) + hint := "中转站只填根地址,应用自动拼接 OpenAI API 路径。" + if a.provider == string(client.ProviderGoogle) { + hint = "Google 官方填写 https://generativelanguage.googleapis.com,应用自动拼接 /v1beta/interactions。" + } else if a.provider == string(client.ProviderGrok) { + hint = "xAI 官方填写 https://api.x.ai,应用自动拼接 Imagine API 路径。" + } + return a.label(gtx, hint, unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { active := a.allowInsecureConnection diff --git a/gio-client/internal/ui/run_job.go b/gio-client/internal/ui/run_job.go index bd9ff7c48..5c4f96224 100644 --- a/gio-client/internal/ui/run_job.go +++ b/gio-client/internal/ui/run_job.go @@ -598,6 +598,7 @@ func (a *App) currentConfig() kernel.Config { ImageModelID: a.imageModelInput.Text(), Prompt: prompt, Mode: client.Mode(a.mode), + Provider: client.Provider(a.provider), APIMode: client.APIMode(a.api), RequestPolicy: client.RequestPolicy(a.policy), ResponsesTransport: client.ResponsesTransport(a.responsesTransport), @@ -769,6 +770,7 @@ func resolveFallbackProfileConfig(state sharedCompat.State, fallbackProfileID st BaseURL: strings.TrimSpace(profile.BaseURL), TextModelID: strings.TrimSpace(profile.TextModelID), ImageModelID: strings.TrimSpace(profile.ImageModelID), + Provider: client.Provider(normalizeProfileProvider(profile.Provider)), APIMode: client.APIMode(normalizeProfileAPIMode(profile.APIMode)), ResponsesTransport: client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)), RequestPolicy: client.RequestPolicy(normalizeProfilePolicy(profile.RequestPolicy)), diff --git a/gio-client/internal/ui/ui_state.go b/gio-client/internal/ui/ui_state.go index 28d3f5502..21de4c807 100644 --- a/gio-client/internal/ui/ui_state.go +++ b/gio-client/internal/ui/ui_state.go @@ -1395,6 +1395,17 @@ func normalizeProfileAPIMode(mode string) string { return string(client.APIModeResponses) } +func normalizeProfileProvider(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(client.ProviderGoogle): + return string(client.ProviderGoogle) + case string(client.ProviderGrok): + return string(client.ProviderGrok) + default: + return string(client.ProviderOpenAI) + } +} + func normalizeProfilePolicy(policy string) string { if strings.TrimSpace(policy) == string(client.RequestPolicyCompat) { return string(client.RequestPolicyCompat) @@ -1447,7 +1458,11 @@ func (a *App) settingsDraftReady() bool { func (a *App) applySettingsProfileDraft(state sharedCompat.State, profile sharedCompat.UpstreamProfile) { a.settingsSelectedProfileID = profile.ID a.profileNameInput.SetText(strings.TrimSpace(profile.Name)) + a.provider = normalizeProfileProvider(profile.Provider) a.api = normalizeProfileAPIMode(profile.APIMode) + if a.provider != string(client.ProviderOpenAI) { + a.api = string(client.APIModeImages) + } a.policy = normalizeProfilePolicy(profile.RequestPolicy) a.responsesTransport = normalizeProfileResponsesTransport(profile.ResponsesTransport) a.reasoningEffort = normalizeReasoningEffort(profile.ReasoningEffort) @@ -1584,6 +1599,7 @@ func (a *App) loadSettingsProfileDraft(profileID string) error { a.protectStreamPreview = kernel.DefaultConfig().ProtectStreamPreview } a.imagesNewAPICompat = false + a.provider = string(client.ProviderOpenAI) a.api = string(client.APIModeResponses) a.policy = string(client.RequestPolicyOpenAI) a.apiKeyVisible = false @@ -1678,7 +1694,11 @@ func (a *App) saveSettingsSelection() error { } } state.Profiles[i].Name = name + state.Profiles[i].Provider = normalizeProfileProvider(a.provider) state.Profiles[i].APIMode = normalizeProfileAPIMode(a.api) + if state.Profiles[i].Provider != string(client.ProviderOpenAI) { + state.Profiles[i].APIMode = string(client.APIModeImages) + } state.Profiles[i].RequestPolicy = normalizeProfilePolicy(a.policy) state.Profiles[i].ResponsesTransport = normalizeProfileResponsesTransport(a.responsesTransport) state.Profiles[i].ReasoningEffort = normalizeReasoningEffort(a.reasoningEffort) @@ -1802,6 +1822,7 @@ func (a *App) createSettingsProfile(apiMode string) error { profile = sharedCompat.UpstreamProfile{ ID: nextProfileID(state.Profiles, now), Name: nextProfileName(state.Profiles), + Provider: string(client.ProviderOpenAI), APIMode: normalizeProfileAPIMode(apiMode), ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -2070,6 +2091,7 @@ func (a *App) createBlankProfileWithMode(apiMode string) { profile = sharedCompat.UpstreamProfile{ ID: nextProfileID(state.Profiles, now), Name: nextProfileName(state.Profiles), + Provider: string(client.ProviderOpenAI), APIMode: apiMode, ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), diff --git a/gio-client/internal/ui/upstream_config_transfer.go b/gio-client/internal/ui/upstream_config_transfer.go index d0a1056eb..c8a83d623 100644 --- a/gio-client/internal/ui/upstream_config_transfer.go +++ b/gio-client/internal/ui/upstream_config_transfer.go @@ -19,6 +19,7 @@ import ( type upstreamConfigExportProfile struct { ID string `json:"id"` Name string `json:"name"` + Provider string `json:"provider"` APIMode string `json:"apiMode"` ResponsesTransport string `json:"responsesTransport"` RequestPolicy string `json:"requestPolicy"` @@ -78,6 +79,7 @@ func buildUpstreamConfigExportFile(state sharedCompat.State) (upstreamConfigExpo profiles = append(profiles, upstreamConfigExportProfile{ ID: profile.ID, Name: strings.TrimSpace(profile.Name), + Provider: normalizeProfileProvider(profile.Provider), APIMode: normalizeProfileAPIMode(profile.APIMode), ResponsesTransport: normalizeProfileResponsesTransport(profile.ResponsesTransport), RequestPolicy: normalizeProfilePolicy(profile.RequestPolicy), @@ -181,6 +183,7 @@ func parseExportProfile(raw any) (upstreamConfigExportProfile, bool) { return upstreamConfigExportProfile{ ID: strings.TrimSpace(id), Name: strings.TrimSpace(name), + Provider: normalizeProfileProvider(stringValue(source["provider"])), APIMode: normalizeProfileAPIMode(stringValue(source["apiMode"])), ResponsesTransport: normalizeProfileResponsesTransport(stringValue(source["responsesTransport"])), RequestPolicy: normalizeProfilePolicy(stringValue(source["requestPolicy"])), @@ -213,6 +216,7 @@ func parseNewAPIChannelConnTemplate(input map[string]any) (parsedUpstreamConfigI Profiles: []upstreamConfigExportProfile{{ ID: profileID, Name: buildTemplateProfileName("NewAPI", baseURL, ""), + Provider: string(client.ProviderOpenAI), APIMode: string(client.APIModeResponses), ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -262,6 +266,7 @@ func parseOpenCodeProviderTemplate(input map[string]any) (parsedUpstreamConfigIm profiles = append(profiles, upstreamConfigExportProfile{ ID: nextTemplateProfileID(index), Name: buildTemplateProfileName("OpenCode", baseURL, providerName), + Provider: string(client.ProviderOpenAI), APIMode: apiMode, ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -386,6 +391,7 @@ func (a *App) applyParsedUpstreamConfigImport(parsed parsedUpstreamConfigImport) if existingIndex, ok := existingByName[strings.TrimSpace(incoming.Name)]; ok { profile := state.Profiles[existingIndex] profile.Name = strings.TrimSpace(incoming.Name) + profile.Provider = normalizeProfileProvider(incoming.Provider) profile.APIMode = normalizeProfileAPIMode(incoming.APIMode) profile.ResponsesTransport = normalizeProfileResponsesTransport(incoming.ResponsesTransport) profile.RequestPolicy = normalizeProfilePolicy(incoming.RequestPolicy) @@ -411,6 +417,7 @@ func (a *App) applyParsedUpstreamConfigImport(parsed parsedUpstreamConfigImport) profile := sharedCompat.UpstreamProfile{ ID: actualID, Name: strings.TrimSpace(incoming.Name), + Provider: normalizeProfileProvider(incoming.Provider), APIMode: normalizeProfileAPIMode(incoming.APIMode), ResponsesTransport: normalizeProfileResponsesTransport(incoming.ResponsesTransport), RequestPolicy: normalizeProfilePolicy(incoming.RequestPolicy), diff --git a/go-cli/pkg/client/client.go b/go-cli/pkg/client/client.go index 673500625..9961c5a47 100644 --- a/go-cli/pkg/client/client.go +++ b/go-cli/pkg/client/client.go @@ -146,7 +146,7 @@ func RequestAndExtractWithRetriesAndPartial( onProgress func(stage string, elapsed int, bytes int64), onPartial func(PartialImage), ) (ImageResult, string, error) { - if opts.APIMode == APIModeImages { + if EffectiveAPIMode(opts.Provider, opts.APIMode) == APIModeImages { return imagesAPIWithRetries(ctx, opts, outputDir, timestamp, onLog, onProgress, onPartial) } return responsesAPIWithRetries(ctx, transport, opts, outputDir, timestamp, onLog, onProgress, onPartial) @@ -160,7 +160,7 @@ func RequestAndExtractWithRetriesAndPartialInMemory( onProgress func(stage string, elapsed int, bytes int64), onPartial func(PartialImage), ) (ImageResult, string, error) { - if opts.APIMode == APIModeImages { + if EffectiveAPIMode(opts.Provider, opts.APIMode) == APIModeImages { return imagesAPIWithRetriesInMemory(ctx, opts, onLog, onProgress, onPartial) } return responsesAPIWithRetriesInMemory(ctx, transport, opts, onLog, onProgress, onPartial) diff --git a/go-cli/pkg/client/grok_images.go b/go-cli/pkg/client/grok_images.go new file mode 100644 index 000000000..e66fe96a7 --- /dev/null +++ b/go-cli/pkg/client/grok_images.go @@ -0,0 +1,87 @@ +package client + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +var grokAspectRatios = []string{ + "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "19.5:9", "9:19.5", "20:9", "9:20", +} + +func buildGrokImagePayload(opts Options, paths []string, model, size string) ([]byte, error) { + if strings.TrimSpace(opts.MaskB64) != "" { + return nil, fmt.Errorf("Grok Imagine 不支持 OpenAI mask 参数;请清除蒙版后重试") + } + payload := map[string]any{ + "model": model, + "prompt": opts.Prompt, + "response_format": "b64_json", + } + if ratio, resolution := grokImageDimensions(size); ratio != "" { + payload["aspect_ratio"] = ratio + payload["resolution"] = resolution + } + if opts.Mode == ModeEdit { + if len(paths) == 0 { + return nil, fmt.Errorf("Grok Imagine 图生图需要至少一张源图") + } + images := make([]map[string]string, 0, len(paths)) + for _, path := range paths { + dataURL, err := ImageFileToDataURL(path) + if err != nil { + return nil, err + } + images = append(images, map[string]string{ + "type": "image_url", + "url": dataURL, + }) + } + if len(images) == 1 { + payload["image"] = images[0] + } else { + payload["image"] = images + } + } + return json.Marshal(payload) +} + +func grokImageDimensions(size string) (string, string) { + width, height, ok := parseGoogleInteractionSize(size) + if !ok { + return "", "" + } + maxSide := width + if height > maxSide { + maxSide = height + } + resolution := "1k" + if maxSide >= 1536 { + resolution = "2k" + } + return closestGrokAspectRatio(float64(width) / float64(height)), resolution +} + +func closestGrokAspectRatio(target float64) string { + best := "1:1" + bestDistance := math.Inf(1) + for _, candidate := range grokAspectRatios { + parts := strings.Split(candidate, ":") + left, _ := parseAspectNumber(parts[0]) + right, _ := parseAspectNumber(parts[1]) + distance := math.Abs(math.Log((left / right) / target)) + if distance < bestDistance { + best = candidate + bestDistance = distance + } + } + return best +} + +func parseAspectNumber(value string) (float64, error) { + var out float64 + _, err := fmt.Sscanf(value, "%f", &out) + return out, err +} diff --git a/go-cli/pkg/client/images_api.go b/go-cli/pkg/client/images_api.go index bbb19fd51..daa6e60fe 100644 --- a/go-cli/pkg/client/images_api.go +++ b/go-cli/pkg/client/images_api.go @@ -261,7 +261,10 @@ func RequestImagesAPIWithPartial( } includeExtended := shouldSendExtendedImageParameters(opts.RequestPolicy) useNewAPICompat := shouldUseImagesNonStreamingCompat(model, opts.ImagesNewAPICompat) - useGoogleInteractions := shouldUseGoogleNativeInteractions(baseURL, model) + provider := NormalizeProvider(opts.Provider) + useGoogleInteractions := provider == ProviderGoogle || + (provider == ProviderOpenAI && shouldUseGoogleNativeInteractions(baseURL, model)) + useGrokImagine := provider == ProviderGrok var ( url string @@ -287,6 +290,22 @@ func RequestImagesAPIWithPartial( } body = bytes.NewReader(payload) contentType = "application/json" + } else if useGrokImagine { + paths := []string(nil) + if opts.Mode == ModeEdit { + paths = opts.imageSourcePathsForEdit() + } + payload, err := buildGrokImagePayload(opts, paths, model, size) + if err != nil { + return ImageResult{}, err + } + endpoint := "images/generations" + if opts.Mode == ModeEdit { + endpoint = "images/edits" + } + url = openAIAPIEndpoint(baseURL, endpoint) + body = bytes.NewReader(payload) + contentType = "application/json" } else if opts.Mode == ModeEdit { paths := opts.imageSourcePathsForEdit() if len(paths) == 0 { @@ -372,6 +391,8 @@ func RequestImagesAPIWithPartial( progressStage := "等待 Images API 返回(无 SSE 保活)" if useGoogleInteractions { progressStage = "等待 Google Interactions 返回(无 SSE 保活)" + } else if useGrokImagine { + progressStage = "等待 Grok Imagine 返回(无 SSE 保活)" } // Progress ticker — Images API has no streaming so we just tick elapsed time. stopProgress := make(chan struct{}) diff --git a/go-cli/pkg/client/images_api_test.go b/go-cli/pkg/client/images_api_test.go index f4d9a7f83..b593da6e1 100644 --- a/go-cli/pkg/client/images_api_test.go +++ b/go-cli/pkg/client/images_api_test.go @@ -599,3 +599,86 @@ func TestRequestImagesAPISendsDalle3Style(t *testing.T) { t.Fatalf("request body missing style=natural: %s", requestBody) } } + +func TestRequestImagesAPIExplicitGoogleProviderUsesInteractionsOnCustomBase(t *testing.T) { + finalB64 := base64.StdEncoding.EncodeToString(fakePNG) + var requestPath string + var googleKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + googleKey = r.Header.Get("X-Goog-Api-Key") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"completed","output_image":{"type":"image","data":%q,"mime_type":"image/png"}}`, finalB64) + })) + defer srv.Close() + + result, err := RequestImagesAPIWithPartial(context.Background(), Options{ + APIKey: "google-key", Provider: ProviderGoogle, BaseURL: srv.URL, + Prompt: "cat", ImageModelID: "gemini-image-model", APIMode: APIModeResponses, + }, &bytes.Buffer{}, nil, nil) + if err != nil { + t.Fatal(err) + } + if requestPath != "/v1beta/interactions" || googleKey != "google-key" { + t.Fatalf("path=%q google key=%q", requestPath, googleKey) + } + if result.ImageB64 != finalB64 || result.SourceEvent != "google_interactions" { + t.Fatalf("result=%+v", result) + } +} + +func TestRequestImagesAPIGrokProviderUsesNativeJSONContracts(t *testing.T) { + finalB64 := base64.StdEncoding.EncodeToString([]byte("final")) + dir := t.TempDir() + source := filepath.Join(dir, "source.png") + if err := os.WriteFile(source, fakePNG, 0o600); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + mode Mode + paths []string + wantPath string + }{ + {name: "generation", mode: ModeGenerate, wantPath: "/v1/images/generations"}, + {name: "edit", mode: ModeEdit, paths: []string{source}, wantPath: "/v1/images/edits"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requestBody []byte + var requestPath, contentType, authorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + contentType = r.Header.Get("Content-Type") + authorization = r.Header.Get("Authorization") + requestBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, finalB64) + })) + defer srv.Close() + + _, err := RequestImagesAPIWithPartial(context.Background(), Options{ + APIKey: "xai-key", Provider: ProviderGrok, BaseURL: srv.URL, + Prompt: "cat", ImageModelID: "grok-imagine-image", Mode: tt.mode, + ImagePaths: tt.paths, Size: "2048x1152", APIMode: APIModeResponses, + }, &bytes.Buffer{}, nil, nil) + if err != nil { + t.Fatal(err) + } + if requestPath != tt.wantPath || contentType != "application/json" || authorization != "Bearer xai-key" { + t.Fatalf("path=%q content-type=%q auth=%q", requestPath, contentType, authorization) + } + var payload map[string]any + if err := json.Unmarshal(requestBody, &payload); err != nil { + t.Fatal(err) + } + if payload["aspect_ratio"] != "16:9" || payload["resolution"] != "2k" || payload["response_format"] != "b64_json" { + t.Fatalf("payload=%s", requestBody) + } + _, hasImage := payload["image"] + if hasImage != (tt.mode == ModeEdit) { + t.Fatalf("image presence=%v payload=%s", hasImage, requestBody) + } + }) + } +} diff --git a/go-cli/pkg/client/types.go b/go-cli/pkg/client/types.go index 357206ee3..327e27f8f 100644 --- a/go-cli/pkg/client/types.go +++ b/go-cli/pkg/client/types.go @@ -116,6 +116,16 @@ const ( APIModeImages APIMode = "images" ) +// Provider selects the upstream vendor contract. Empty values retain the +// historical OpenAI-compatible behavior. +type Provider string + +const ( + ProviderOpenAI Provider = "openai" + ProviderGoogle Provider = "google" + ProviderGrok Provider = "grok" +) + type ResponsesTransport string const ( @@ -162,6 +172,10 @@ type Options struct { // Empty string is treated as APIModeResponses for back-compat. APIMode APIMode + // Provider selects request paths, authentication, and payload semantics. + // Google and Grok use their native image APIs and therefore imply Images. + Provider Provider + // ResponsesTransport selects how Responses API requests are transported. // Empty string is treated as ResponsesTransportSSE for back-compat. ResponsesTransport ResponsesTransport diff --git a/go-cli/pkg/client/url.go b/go-cli/pkg/client/url.go index 5f3b5f5b6..cc6d0bea0 100644 --- a/go-cli/pkg/client/url.go +++ b/go-cli/pkg/client/url.go @@ -58,6 +58,44 @@ func OpenAIAPIEndpoint(baseURL, endpointPath string) string { return cleaned + "/v1/" + path } +// NormalizeProvider keeps old configurations on the OpenAI-compatible path. +func NormalizeProvider(provider Provider) Provider { + switch Provider(strings.ToLower(strings.TrimSpace(string(provider)))) { + case ProviderGoogle: + return ProviderGoogle + case ProviderGrok: + return ProviderGrok + default: + return ProviderOpenAI + } +} + +// EffectiveAPIMode returns the only valid request shape for the selected +// provider. Native Google and Grok image APIs do not use OpenAI Responses. +func EffectiveAPIMode(provider Provider, apiMode APIMode) APIMode { + if NormalizeProvider(provider) != ProviderOpenAI || apiMode == APIModeImages { + return APIModeImages + } + return APIModeResponses +} + +// GoogleAPIEndpoint accepts an official root, the legacy /v1beta/openai base, +// or a Google-compatible relay prefix without duplicating version segments. +func GoogleAPIEndpoint(baseURL, endpointPath string) string { + cleaned := strings.TrimRight(strings.TrimSpace(baseURL), "/") + path := strings.Trim(strings.TrimSpace(endpointPath), "/") + lower := strings.ToLower(cleaned) + if strings.HasSuffix(lower, "/v1beta/openai") { + cleaned = cleaned[:len(cleaned)-len("/openai")] + } else if !strings.HasSuffix(lower, "/v1beta") { + cleaned += "/v1beta" + } + if path == "" { + return cleaned + } + return cleaned + "/" + path +} + func openAIAPIEndpoint(baseURL, endpointPath string) string { return OpenAIAPIEndpoint(baseURL, endpointPath) } @@ -88,18 +126,11 @@ func shouldUseGoogleNativeInteractions(baseURL, model string) bool { } func googleInteractionsEndpoint(baseURL string) (string, error) { - if !isOfficialGoogleGeminiBaseURL(baseURL) { - return "", fmt.Errorf("Google Interactions 仅支持官方 generativelanguage.googleapis.com 主机") - } - u, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/")) - if err != nil { + endpoint := GoogleAPIEndpoint(baseURL, "interactions") + if _, err := url.Parse(endpoint); err != nil { return "", err } - u.Path = "/v1beta/interactions" - u.RawPath = "" - u.RawQuery = "" - u.Fragment = "" - return u.String(), nil + return endpoint, nil } func isLoopbackHost(host string) bool { diff --git a/go-cli/pkg/client/url_test.go b/go-cli/pkg/client/url_test.go index 8dc8d1753..dc439d7bf 100644 --- a/go-cli/pkg/client/url_test.go +++ b/go-cli/pkg/client/url_test.go @@ -85,3 +85,19 @@ func TestGoogleInteractionsEndpointIsNarrowToOfficialNanoBanana2(t *testing.T) { t.Fatal("non-Nano-Banana-2 model must stay on the documented OpenAI-compatible path") } } + +func TestProviderRoutingDefaultsAndNativeModes(t *testing.T) { + t.Parallel() + if got := NormalizeProvider(""); got != ProviderOpenAI { + t.Fatalf("provider=%q", got) + } + if got := EffectiveAPIMode(ProviderGoogle, APIModeResponses); got != APIModeImages { + t.Fatalf("google mode=%q", got) + } + if got := EffectiveAPIMode(ProviderGrok, APIModeResponses); got != APIModeImages { + t.Fatalf("grok mode=%q", got) + } + if got := GoogleAPIEndpoint("https://generativelanguage.googleapis.com/v1beta/openai", "models"); got != "https://generativelanguage.googleapis.com/v1beta/models" { + t.Fatalf("google endpoint=%q", got) + } +} diff --git a/image-studio/backend/probe.go b/image-studio/backend/probe.go index b03f4c812..57961561d 100644 --- a/image-studio/backend/probe.go +++ b/image-studio/backend/probe.go @@ -17,7 +17,11 @@ const probeUpstreamTimeout = 20 * time.Second const probeUpstreamMaxBody = 1 << 20 type modelsListProbeResponse struct { - Data []upstreamModelDescriptorJSON `json:"data"` + Data []upstreamModelDescriptorJSON `json:"data"` + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` } type upstreamModelDescriptorJSON struct { @@ -46,11 +50,22 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst ctx, cancel := context.WithTimeout(parent, probeUpstreamTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.OpenAIAPIEndpoint(baseURL, "models"), nil) + provider := client.NormalizeProvider(client.Provider(opts.Provider)) + modelsEndpoint := client.OpenAIAPIEndpoint(baseURL, "models") + endpointLabel := "/v1/models" + if provider == client.ProviderGoogle { + modelsEndpoint = client.GoogleAPIEndpoint(baseURL, "models") + endpointLabel = "/v1beta/models" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsEndpoint, nil) if err != nil { return ProbeUpstreamResult{}, fmt.Errorf("构造测活请求失败: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + if provider == client.ProviderGoogle { + req.Header.Set("X-Goog-Api-Key", apiKey) + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", client.UserAgent()) @@ -72,9 +87,9 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst summary = readErr.Error() } if summary != "" { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回 %d: %s", resp.StatusCode, summary) + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回 %d: %s", endpointLabel, resp.StatusCode, summary) } - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回 %d", resp.StatusCode) + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回 %d", endpointLabel, resp.StatusCode) } if readErr != nil { return ProbeUpstreamResult{}, fmt.Errorf("读取上游响应失败: %w", readErr) @@ -82,10 +97,18 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst var parsed modelsListProbeResponse if err := json.Unmarshal(body, &parsed); err != nil { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err) - } - if parsed.Data == nil { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组") + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回的 JSON 无效: %w", endpointLabel, err) + } + if provider == client.ProviderGoogle { + for _, item := range parsed.Models { + id := strings.TrimPrefix(strings.TrimSpace(item.Name), "models/") + if id == "" { + continue + } + parsed.Data = append(parsed.Data, upstreamModelDescriptorJSON{ID: id, Object: "model", OwnedBy: "google", DisplayName: item.DisplayName}) + } + } else if parsed.Data == nil { + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 响应缺少 data 数组", endpointLabel) } models := make([]UpstreamModelDescriptor, 0, len(parsed.Data)) for _, item := range parsed.Data { @@ -104,7 +127,7 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst ModelCount: len(parsed.Data), Models: models, } - if strings.TrimSpace(opts.APIMode) == string(client.APIModeResponses) && + if provider == client.ProviderOpenAI && 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{ diff --git a/image-studio/backend/probe_test.go b/image-studio/backend/probe_test.go index 0bd0f1e6f..cf5835e0e 100644 --- a/image-studio/backend/probe_test.go +++ b/image-studio/backend/probe_test.go @@ -49,6 +49,37 @@ func TestProbeUpstreamRequestsModelsFromBackend(t *testing.T) { } } +func TestProbeUpstreamGoogleProviderUsesNativeModelsEndpoint(t *testing.T) { + var gotPath string + var gotGoogleKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotGoogleKey = r.Header.Get("X-Goog-Api-Key") + if auth := r.Header.Get("Authorization"); auth != "" { + t.Fatalf("Google probe must not send Authorization: %q", auth) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-3.1-flash-image","displayName":"Nano Banana 2"}]}`)) + })) + defer srv.Close() + + got, err := probeUpstream(context.Background(), ProbeUpstreamOptions{ + APIKey: "google-key", + BaseURL: srv.URL, + Provider: "google", + APIMode: "images", + }) + if err != nil { + t.Fatalf("Google probe returned error: %v", err) + } + if gotPath != "/v1beta/models" || gotGoogleKey != "google-key" { + t.Fatalf("path=%q X-Goog-Api-Key=%q", gotPath, gotGoogleKey) + } + if got.ModelCount != 1 || len(got.Models) != 1 || got.Models[0].ID != "gemini-3.1-flash-image" || got.Models[0].OwnedBy != "google" { + t.Fatalf("unexpected Google models: %+v", got) + } +} + func TestProbeUpstreamSkipsModelItemsWithoutID(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index 58716a67a..562a66e3c 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -380,6 +380,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions TextModelID: opts.TextModelID, ImageModelID: opts.ImageModelID, ReasoningEffort: opts.ReasoningEffort, + Provider: client.Provider(strings.TrimSpace(opts.Provider)), Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: apiMode, ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.ResponsesTransport)), @@ -420,6 +421,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions TextModelID: strings.TrimSpace(opts.FallbackProfile.TextModelID), ImageModelID: strings.TrimSpace(opts.FallbackProfile.ImageModelID), ReasoningEffort: strings.TrimSpace(opts.FallbackProfile.ReasoningEffort), + Provider: client.Provider(strings.TrimSpace(opts.FallbackProfile.Provider)), Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: fallbackAPIMode, ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.FallbackProfile.ResponsesTransport)), diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 6ebca49a9..ffb68e96b 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -41,7 +41,8 @@ type GenerateOptions struct { TextModelID string `json:"textModelID"` // overrides the default text model ImageModelID string `json:"imageModelID"` // overrides the default image model ReasoningEffort string `json:"reasoningEffort"` - APIMode string `json:"apiMode"` // "responses" (default) | "images" + Provider string `json:"provider,omitempty"` // "openai" (default) | "google" | "grok" + APIMode string `json:"apiMode"` // "responses" (default) | "images" ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` // "openai" (default) | "compat" // ImagesNewAPICompat 开启后仅影响 Images API 请求: @@ -77,6 +78,7 @@ type FallbackProfileOptions struct { TextModelID string `json:"textModelID"` ImageModelID string `json:"imageModelID"` ReasoningEffort string `json:"reasoningEffort"` + Provider string `json:"provider,omitempty"` APIMode string `json:"apiMode"` ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` @@ -106,6 +108,7 @@ type ProbeUpstreamOptions struct { BaseURL string `json:"baseURL"` ProxyMode string `json:"proxyMode"` ProxyURL string `json:"proxyURL"` + Provider string `json:"provider,omitempty"` APIMode string `json:"apiMode,omitempty"` ResponsesTransport string `json:"responsesTransport,omitempty"` AllowInsecureConnection bool `json:"allowInsecureConnection,omitempty"` diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index f74ff84ca..a10e5cd1a 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -166,6 +166,7 @@ function buildPreviewProfile(now: number): UpstreamProfile { return { id: "preview-profile", name: "Preview Responses", + provider: "openai", apiMode: "responses", responsesTransport: "sse", requestPolicy: "openai", diff --git a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx index 982e5ec61..57947aa35 100644 --- a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx @@ -12,7 +12,7 @@ import { probeCurrentUpstream, } from "../../platform/runtime/host"; import { genProfileId, keyringUserFor } from "../../lib/profiles"; -import type { APIMode, RequestPolicy, UpstreamProfile } from "../../types/domain"; +import type { APIMode, RequestPolicy, UpstreamProfile, UpstreamProvider } from "../../types/domain"; import { FAQModal } from "./FAQModal"; import { UpstreamProfileEditor } from "./UpstreamProfileEditor"; import { UpstreamProfileList } from "./UpstreamProfileList"; @@ -102,7 +102,13 @@ export function UpstreamConfigModal({ function patchDraft(patch: Partial) { if (!draft) return; - setDraft({ ...draft, ...patch }); + const provider = patch.provider ?? draft.provider ?? "openai"; + setDraft({ + ...draft, + ...patch, + provider, + apiMode: provider === "openai" ? patch.apiMode ?? draft.apiMode : "images", + }); } function codexProfileName(provider: string) { @@ -122,6 +128,7 @@ export function UpstreamConfigModal({ if (existing) { await updateProfile(existing.id, { name, + provider: "openai", apiMode: "responses", requestPolicy: "openai", imagesNewAPICompat: false, @@ -137,6 +144,7 @@ export function UpstreamConfigModal({ } else { syncedId = await createProfile({ name, + provider: "openai", apiMode: "responses", requestPolicy: "openai", baseURL: imported.baseURL, @@ -225,8 +233,9 @@ export function UpstreamConfigModal({ } } - async function handleNew(apiMode: APIMode = "responses") { + async function handleNew(provider: UpstreamProvider = "openai", apiMode: APIMode = provider === "openai" ? "responses" : "images") { const id = await createProfile({ + provider, apiMode, requestPolicy: "openai", setActive: profiles.length === 0, // 第一个自动 active,后续手动切 @@ -254,6 +263,7 @@ export function UpstreamConfigModal({ if (!draft) return; await updateProfile(draft.id, { name: draft.name, + provider: draft.provider, apiMode: draft.apiMode, responsesTransport: draft.responsesTransport ?? "sse", requestPolicy: draft.requestPolicy, @@ -326,6 +336,8 @@ export function UpstreamConfigModal({ draft.apiMode, draft.responsesTransport ?? "sse", draft.allowInsecureConnection === true, + undefined, + draft.provider, ); const catalog = buildUpstreamModelCatalog(result.models ?? []); setModelCatalog(catalog); @@ -405,16 +417,22 @@ export function UpstreamConfigModal({ {([ { - id: "responses" as APIMode, - title: "Responses API", - sub: "首选。支持 SSE 保活,长任务更稳。", - note: "适合 GPT 图像链路和提示词优化。", + id: "openai" as UpstreamProvider, + title: "OpenAI / 兼容中转", + sub: "支持 Responses 与 Images API。", + note: "适合 OpenAI、NewAPI 和兼容中转。", + }, + { + id: "google" as UpstreamProvider, + title: "Google Gemini", + sub: "原生 /v1beta/interactions。", + note: "使用 x-goog-api-key 与 Google payload。", }, { - id: "images" as APIMode, - title: "Images API", - sub: "兼容性更广,接标准 generations / edits。", - note: "适合只想尽快接上常规生图接口。", + id: "grok" as UpstreamProvider, + title: "Grok / xAI", + sub: "原生 Imagine generations / edits。", + note: "图生图使用 JSON,不使用 multipart。", }, ]).map((item) => ( - {item.id === "responses" ? "R" : "I"} + {item.id === "openai" ? "O" : item.id === "google" ? "G" : "X"} {item.title} diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index 7351fb75f..cd281863e 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -5,8 +5,9 @@ import { type RequestPolicy, type ResponsesTransport, type UpstreamProfile, + type UpstreamProvider, } from "../../types/domain"; -import { requestPolicyLabel } from "../../lib/profiles"; +import { requestPolicyLabel, upstreamProviderLabel } from "../../lib/profiles"; import { usePlatform } from "../../platform/context"; import { formatUpstreamModelLabel, @@ -59,6 +60,11 @@ export function UpstreamProfileEditor({ onSaveAndClose: () => void | Promise; }) { const { isAndroidPhone, usesFluentUI } = usePlatform(); + const providerOptions = [ + { id: "openai" as UpstreamProvider, title: "OpenAI / 兼容中转", sub: "Responses 与 Images API" }, + { id: "google" as UpstreamProvider, title: "Google Gemini", sub: "原生 Interactions API" }, + { id: "grok" as UpstreamProvider, title: "Grok / xAI", sub: "原生 Imagine API" }, + ]; const apiModeOptions = [ { id: "responses" as APIMode, title: "Responses API", sub: "SSE 保活(CF 超时推荐)" }, { id: "images" as APIMode, title: "Images API", sub: "标准 generations / edits" }, @@ -102,7 +108,27 @@ export function UpstreamProfileEditor({ /> - + + {providerOptions.map((option) => ( + onPatchDraft({ + provider: option.id, + apiMode: option.id === "openai" ? draft.apiMode : "images", + imageModelID: draft.imageModelID || (option.id === "google" ? "gemini-3.1-flash-image" : option.id === "grok" ? "grok-imagine-image" : ""), + })} + /> + ))} + + 服务商决定请求路径、认证头和请求体;第三方 OpenAI 兼容中转请选择 OpenAI / 兼容中转。 + + + {draft.provider === "openai" ? API 形态 @@ -130,9 +156,15 @@ export function UpstreamProfileEditor({ ? "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" : "使用标准 Images API,key 用 image-2 / image API 分组,兼容性最广。"} - + : ( + + + {draft.provider === "google" ? "Google Interactions API" : "Grok Imagine Images API"} + + + )} - 参数策略 @@ -158,7 +190,7 @@ export function UpstreamProfileEditor({ `OpenAI 标准` 更适合直连 OpenAI 或严格兼容实现。`兼容中转扩展` 会额外发送一些 relay 常见扩展字段。 - + : null} 上游 BASE_URL >}> {baseURLError ? {baseURLError} : null} - - 中转站只填站点根地址,应用会自动拼接当前 API 路径;Google 官方 Images 配置填写 https://generativelanguage.googleapis.com/v1beta/openai。除该官方兼容地址外,不要手动贴入具体接口路径。 + {draft.provider === "google" + ? <>Google 官方填写 https://generativelanguage.googleapis.com,应用自动拼接 /v1beta/interactions。> + : draft.provider === "grok" + ? <>xAI 官方填写 https://api.x.ai,应用自动拼接 Imagine 请求路径。> + : <>中转站只填站点根地址,应用会自动拼接当前 OpenAI API 路径,不要手动贴入具体接口路径。>} @@ -249,12 +284,12 @@ export function UpstreamProfileEditor({ {loadingModels ? "拉取中..." : "拉取并解析上游模型"} - 通过宿主侧请求 /v1/models 获取模型列表,避免浏览器跨域或 WebView 差异影响结果。 + 通过宿主侧请求 {draft.provider === "google" ? /v1beta/models : /v1/models} 获取模型列表,避免浏览器跨域或 WebView 差异影响结果。 {modelCatalogError ? {modelCatalogError} : null} - {draft.apiMode === "responses" ? ( + {draft.provider === "openai" && draft.apiMode === "responses" ? ( <> onPatchDraft({ imageModelID: e.target.value })} spellCheck={false} className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} @@ -379,7 +414,7 @@ export function UpstreamProfileEditor({ 不自动切备用上游 {fallbackCandidates.map((profile) => ( - {profile.name} · {profile.apiMode === "responses" ? "Responses" : "Images"} + {profile.name} · {upstreamProviderLabel(profile.provider ?? "openai")} ))} @@ -388,7 +423,7 @@ export function UpstreamProfileEditor({ - {draft.apiMode === "images" ? ( + {draft.provider === "openai" && draft.apiMode === "images" ? ( - Images API 通常走标准 /v1/images/generations + /v1/images/edits;Google 官方 gemini-3.1-flash-image 例外走 Interactions API。两者都没有 SSE 保活。 + + {draft.provider === "google" + ? <>Google 原生请求走 /v1beta/interactions,认证使用 x-goog-api-key。> + : draft.provider === "grok" + ? <>Grok 文生图走 /v1/images/generations,图生图走 JSON 格式的 /v1/images/edits。> + : <>OpenAI Images API 走 /v1/images/generations 与 multipart /v1/images/edits。>} + ) : null} diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileList.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileList.tsx index 03b940bc0..c3a3e1cd3 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileList.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileList.tsx @@ -75,7 +75,7 @@ export function UpstreamProfileList({ {isActive ? 生图 : null} {isAI ? AI : null} - {p.apiMode === "responses" ? "R" : "I"} + {(p.provider ?? "openai") === "openai" ? (p.apiMode === "responses" ? "R" : "I") : p.provider === "google" ? "G" : "X"} ); diff --git a/image-studio/frontend/src/lib/profiles.ts b/image-studio/frontend/src/lib/profiles.ts index 5cd5e2bd0..376b74299 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, ResponsesTransport, UpstreamProfile } from "../types/domain"; +import type { APIMode, ReasoningEffortValue, RequestPolicy, ResponsesTransport, UpstreamProfile, UpstreamProvider } from "../types/domain"; function normalizeReasoningEffort(value: unknown): ReasoningEffortValue { return value === "low" || value === "medium" || value === "high" || value === "xhigh" @@ -10,6 +10,17 @@ export function normalizeResponsesTransport(value: unknown): ResponsesTransport return value === "websocket" ? "websocket" : "sse"; } +export function normalizeUpstreamProvider(value: unknown): UpstreamProvider { + if (value === "google" || value === "grok") return value; + return "openai"; +} + +export function upstreamProviderLabel(provider: UpstreamProvider): string { + if (provider === "google") return "Google Gemini"; + if (provider === "grok") return "Grok / xAI"; + return "OpenAI / 兼容中转"; +} + // localStorage 键名规范: // gptcodex.profiles —— UpstreamProfile[] JSON(无 apiKey,key 在 keyring) // gptcodex.activeProfileId —— 当前 active profile 的 id @@ -57,7 +68,8 @@ export function tryParseProfile(raw: unknown): UpstreamProfile | null { const o = raw as Record; const id = typeof o.id === "string" ? o.id : ""; const name = typeof o.name === "string" ? o.name : ""; - const apiMode = o.apiMode === "images" ? "images" : "responses"; + const provider = normalizeUpstreamProvider(o.provider); + const apiMode = provider !== "openai" || o.apiMode === "images" ? "images" : "responses"; const responsesTransport = normalizeResponsesTransport(o.responsesTransport); const requestPolicy = o.requestPolicy === "compat" ? "compat" : "openai"; const imagesNewAPICompat = o.imagesNewAPICompat === true; @@ -75,6 +87,7 @@ export function tryParseProfile(raw: unknown): UpstreamProfile | null { return { id, name, + provider, apiMode, responsesTransport, requestPolicy, @@ -145,6 +158,7 @@ export function makeBlankProfile(apiMode: APIMode = "responses", profiles: Upstr return { id: genProfileId(), name: nextDefaultProfileName(profiles), + provider: "openai", apiMode, responsesTransport: "sse", requestPolicy: "openai", diff --git a/image-studio/frontend/src/lib/upstreamConfigTransfer.ts b/image-studio/frontend/src/lib/upstreamConfigTransfer.ts index d020cbcd2..dc85eece4 100644 --- a/image-studio/frontend/src/lib/upstreamConfigTransfer.ts +++ b/image-studio/frontend/src/lib/upstreamConfigTransfer.ts @@ -1,10 +1,12 @@ -import type { APIMode, RequestPolicy, ReasoningEffortValue, ResponsesTransport, UpstreamProfile } from "../types/domain"; +import type { APIMode, RequestPolicy, ReasoningEffortValue, ResponsesTransport, UpstreamProfile, UpstreamProvider } from "../types/domain"; +import { normalizeUpstreamProvider } from "./profiles.ts"; import { buildUpstreamModelCatalog } from "./upstreamModels.ts"; import { cleanBaseURL } from "./security.ts"; export type UpstreamConfigExportProfile = { id: string; name: string; + provider?: UpstreamProvider; apiMode: APIMode; responsesTransport: ResponsesTransport; requestPolicy: RequestPolicy; @@ -39,6 +41,7 @@ export type UpstreamConfigImportActions = { getProfiles: () => UpstreamProfile[]; createProfile: (input: { name?: string; + provider?: UpstreamProvider; apiMode: APIMode; responsesTransport?: ResponsesTransport; baseURL?: string; @@ -71,6 +74,7 @@ function toProfileSnapshot(input: UpstreamConfigExportProfile, actualId: string) return { id: actualId, name: input.name, + provider: input.provider ?? "openai", apiMode: input.apiMode, responsesTransport: input.responsesTransport ?? "sse", requestPolicy: input.requestPolicy, @@ -152,10 +156,12 @@ function parseExportProfile(raw: unknown): UpstreamConfigExportProfile | null { const id = typeof source.id === "string" ? source.id.trim() : ""; const name = typeof source.name === "string" ? source.name.trim() : ""; if (!id || !name) return null; + const provider = normalizeUpstreamProvider(source.provider); return { id, name, - apiMode: normalizeAPIMode(source.apiMode), + ...(source.provider !== undefined ? { provider } : {}), + apiMode: provider === "openai" ? normalizeAPIMode(source.apiMode) : "images", responsesTransport: normalizeResponsesTransport(source.responsesTransport), requestPolicy: normalizeRequestPolicy(source.requestPolicy), imagesNewAPICompat: source.imagesNewAPICompat === true, @@ -328,6 +334,7 @@ function buildProfilePatch( ): Partial> & { apiKey?: string } { return { name: incoming.name, + provider: incoming.provider ?? "openai", apiMode: incoming.apiMode, responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, @@ -366,6 +373,7 @@ export async function applyParsedUpstreamConfigImport( const newId = await actions.createProfile({ name: incoming.name, + provider: incoming.provider ?? "openai", apiMode: incoming.apiMode, responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamEmptyState.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamEmptyState.tsx index 02b96af04..4163e515a 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamEmptyState.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamEmptyState.tsx @@ -1,12 +1,12 @@ import { Boxes, ClipboardPaste, Plus, RadioTower } from "lucide-react"; -import type { APIMode } from "../../../types/domain"; -import { ANDROID_API_MODE_OPTIONS } from "./useAndroidUpstreamConfig"; +import type { UpstreamProvider } from "../../../types/domain"; +import { ANDROID_PROVIDER_OPTIONS } from "./useAndroidUpstreamConfig"; export function AndroidUpstreamEmptyState({ onCreate, onQuickImport, }: { - onCreate: (apiMode: APIMode) => void | Promise; + onCreate: (provider: UpstreamProvider) => void | Promise; onQuickImport: () => void | Promise; }) { return ( @@ -29,10 +29,10 @@ export function AndroidUpstreamEmptyState({ - {ANDROID_API_MODE_OPTIONS.map((option) => ( + {ANDROID_PROVIDER_OPTIONS.map((option) => ( onCreate(option.id)}> - {option.id === "responses" ? : } + {option.id === "openai" ? : } {option.title} diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx index 7f5cff942..03cda7d56 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import type { UpstreamProfile } from "../../../types/domain"; import { ANDROID_API_MODE_OPTIONS, + ANDROID_PROVIDER_OPTIONS, ANDROID_REASONING_EFFORT_OPTIONS, ANDROID_REQUEST_POLICY_OPTIONS, } from "./useAndroidUpstreamConfig"; @@ -80,7 +81,27 @@ export function AndroidUpstreamProfileForm({ /> - + + + {ANDROID_PROVIDER_OPTIONS.map((option) => ( + onPatchDraft({ + provider: option.id, + apiMode: option.id === "openai" ? draft.apiMode : "images", + imageModelID: draft.imageModelID || (option.id === "google" ? "gemini-3.1-flash-image" : option.id === "grok" ? "grok-imagine-image" : ""), + })} + > + {option.title} + {option.meta} + + ))} + + + + {draft.provider === "openai" ? {ANDROID_API_MODE_OPTIONS.map((option) => ( ))} - + : null} - + {draft.provider === "openai" ? {ANDROID_REQUEST_POLICY_OPTIONS.map((option) => ( ))} - + : null} - + {modelCatalogError}
image_generation
HTTP SSE
WebSocket mode
/v1/images/generations
/v1/images/edits
- 选哪个?看你 key 绑的是哪个分组。 + 选哪个?看上游实际支持的端点和请求模型。 两边都有的话优先 Responses(SSE 更抗 524)。
@@ -96,23 +96,19 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void - 能换其他文本 / 图像模型吗? + 能换其他请求模型吗? - 可以。在「🔧 上游配置」里填即可,不同 API 形态用到的字段不一样: + 可以。在「🔧 上游配置」里填写请求模型 ID 即可: - Responses API 用两个模型 ID: - - 文本模型 ID(默认 gpt-5.5):承担推理 + 调用 image_generation 工具 - 图像模型 ID(默认 gpt-image-2):工具实际用哪个图像模型出图 - + Responses API 只用一个请求模型 ID,直接写入请求的 model 字段。它可以是文本模型,也可以是上游支持的图像模型;应用不会再要求分别配置两种模型。 Images API 只用一个模型 ID: - 图像模型 ID(默认 gpt-image-2):通常直接传给 /v1/images/generations 的 model 字段。Google 官方主机上的 gemini-3.1-flash-image 会按官方 Interactions API 请求;第三方 Gemini / Imagen 中转仍使用非流式 Images 兼容请求。文本模型 ID 在此模式下不读。 + 请求模型 ID(通常为 gpt-image-2):直接传给 /v1/images/generations 的 model 字段。Google 官方主机上的 gemini-3.1-flash-image 会按官方 Interactions API 请求;第三方 Gemini / Imagen 中转仍使用非流式 Images 兼容请求。 diff --git a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx index e3b30b558..d5d87870b 100644 --- a/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx +++ b/image-studio/frontend/src/components/panel/ResultDetailDrawer.tsx @@ -128,7 +128,7 @@ export function ResultDetailDrawer() { {detail.revisedPrompt && ( 优化后提示词} - hint="Responses API 模式下文本模型可能会重写你的提示词。" + hint="Responses API 模式下请求模型可能会重写你的提示词。" > {detail.revisedPrompt} diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index e77492ad9..7351fb75f 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -75,6 +75,9 @@ export function UpstreamProfileEditor({ 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]; const preferredModels = modelCatalog ? preferredModelsForAPIMode(modelCatalog, draft.apiMode) : null; + const requestModels = modelCatalog + ? (draft.apiMode === "responses" ? modelCatalog.all : preferredModels?.image ?? []) + : []; const fallbackCandidates = profiles.filter((profile) => profile.id !== draft.id && profile.baseURL.trim()); return ( @@ -124,7 +127,7 @@ export function UpstreamProfileEditor({ {draft.apiMode === "responses" - ? "需要 key 绑定到「拥有 gpt-5.5 模型的分组」。SSE 保活可防 Cloudflare 524。" + ? "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" : "使用标准 Images API,key 用 image-2 / image API 分组,兼容性最广。"} @@ -283,7 +286,7 @@ export function UpstreamProfileEditor({ - + - {preferredModels && preferredModels.text.length > 0 ? ( + {requestModels.length > 0 ? ( onPatchDraft({ textModelID: id })} @@ -332,25 +335,27 @@ export function UpstreamProfileEditor({ > ) : null} - - onPatchDraft({ imageModelID: e.target.value })} - spellCheck={false} - className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} - /> - {preferredModels && preferredModels.image.length > 0 ? ( - onPatchDraft({ imageModelID: id })} + {draft.apiMode === "images" ? ( + + onPatchDraft({ imageModelID: e.target.value })} + spellCheck={false} + className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} /> - ) : null} - + {requestModels.length > 0 ? ( + onPatchDraft({ imageModelID: id })} + /> + ) : null} + + ) : null} { + const [source, mask] = await Promise.all([ + loadImage(sourceDataURL), + loadImage(dataURLFromBase64(maskB64)), + ]); + const canvas = makeCanvas(source.naturalWidth, source.naturalHeight); + const context = requireContext(canvas); + context.drawImage(mask, 0, 0, canvas.width, canvas.height); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + let hasTransparency = false; + for (let offset = 3; offset < pixels.data.length; offset += 4) { + const alpha = pixels.data[offset]; + if (alpha < 255) hasTransparency = true; + } + if (!hasTransparency) { + for (let offset = 0; offset < pixels.data.length; offset += 4) { + const luma = Math.round( + pixels.data[offset] * 0.299 + + pixels.data[offset + 1] * 0.587 + + pixels.data[offset + 2] * 0.114, + ); + pixels.data[offset] = 0; + pixels.data[offset + 1] = 0; + pixels.data[offset + 2] = 0; + pixels.data[offset + 3] = 255 - luma; + } + context.putImageData(pixels, 0, 0); + } + return { sourceDataURL, maskB64: stripDataURL(canvas.toDataURL("image/png")) }; +} + +export async function compositeMaskedEdit( + prepared: PreparedMaskComposite, + generatedB64: string, +): Promise { + const [source, generated, mask] = await Promise.all([ + loadImage(prepared.sourceDataURL), + loadImage(dataURLFromBase64(generatedB64)), + loadImage(dataURLFromBase64(prepared.maskB64)), + ]); + const width = source.naturalWidth; + const height = source.naturalHeight; + const maskCanvas = makeCanvas(width, height); + const maskContext = requireContext(maskCanvas); + maskContext.drawImage(mask, 0, 0, width, height); + const maskPixels = maskContext.getImageData(0, 0, width, height).data; + + const sourceCanvas = makeCanvas(width, height); + const sourceContext = requireContext(sourceCanvas); + sourceContext.drawImage(source, 0, 0, width, height); + const sourcePixels = sourceContext.getImageData(0, 0, width, height).data; + + const generatedCanvas = makeCanvas(width, height); + const generatedContext = requireContext(generatedCanvas); + generatedContext.drawImage(generated, 0, 0, width, height); + const generatedPixels = generatedContext.getImageData(0, 0, width, height).data; + + const result = generatedContext.createImageData(width, height); + for (let offset = 0; offset < result.data.length; offset += 4) { + const keep = maskPixels[offset + 3]; + const edit = 255 - keep; + for (let channel = 0; channel < 4; channel++) { + result.data[offset + channel] = Math.round( + (sourcePixels[offset + channel] * keep + generatedPixels[offset + channel] * edit) / 255, + ); + } + } + generatedContext.putImageData(result, 0, 0); + return stripDataURL(generatedCanvas.toDataURL("image/png")); +} + +function makeCanvas(width: number, height: number): HTMLCanvasElement { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error("蒙版源图尺寸无效"); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; +} + +function requireContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("无法创建蒙版合成画布"); + return context; +} + +function loadImage(dataURL: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("无法解码蒙版或源图片")); + image.src = dataURL; + }); +} + +function stripDataURL(value: string): string { + const comma = value.indexOf(","); + return comma >= 0 ? value.slice(comma + 1) : value; +} diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx index a5dd26486..7f5cff942 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx @@ -59,6 +59,9 @@ export function AndroidUpstreamProfileForm({ const isActive = draft.id === activeProfileId; const busy = saving || isTestingKey; const preferredModels = modelCatalog ? preferredModelsForAPIMode(modelCatalog, draft.apiMode) : null; + const requestModels = modelCatalog + ? (draft.apiMode === "responses" ? modelCatalog.all : preferredModels?.image ?? []) + : []; return ( @@ -187,7 +190,7 @@ export function AndroidUpstreamProfileForm({ - + - {preferredModels && preferredModels.text.length > 0 ? ( + {requestModels.length > 0 ? ( onPatchDraft({ textModelID: id })} /> @@ -223,23 +226,25 @@ export function AndroidUpstreamProfileForm({ > ) : null} - - onPatchDraft({ imageModelID: event.target.value })} - placeholder="留空 = 默认 gpt-image-2" - className="focus-ring android-upstream-input font-mono-token" - spellCheck={false} - /> - {preferredModels && preferredModels.image.length > 0 ? ( - onPatchDraft({ imageModelID: id })} + {draft.apiMode === "images" ? ( + + onPatchDraft({ imageModelID: event.target.value })} + placeholder="留空 = 默认 gpt-image-2" + className="focus-ring android-upstream-input font-mono-token" + spellCheck={false} /> - ) : null} - + {requestModels.length > 0 ? ( + onPatchDraft({ imageModelID: id })} + /> + ) : null} + + ) : null} diff --git a/image-studio/frontend/src/platform/runtime/host.ts b/image-studio/frontend/src/platform/runtime/host.ts index 940472c66..9cccad5a8 100644 --- a/image-studio/frontend/src/platform/runtime/host.ts +++ b/image-studio/frontend/src/platform/runtime/host.ts @@ -18,7 +18,10 @@ import { registerVirtualText, releaseVirtualPath, rotateVirtualImage, + sourceToDataURL, } from "../../lib/virtualHostStore.ts"; +import { compositeMaskedEdit, normalizeMaskForSource, type PreparedMaskComposite } from "../../lib/maskComposite.ts"; +import { dataURLFromBase64 } from "../../lib/images.ts"; import { browserStoredAPIKey, fileNameFromPath, @@ -146,12 +149,26 @@ async function startRemoteJob(options: GenerateOptionsLike): Promise { try { + let preparedMask: PreparedMaskComposite | null = null; + let remoteOptions = options; + if (options.mode === "edit" && options.maskB64) { + const firstSource = options.sourceImages?.[0]; + let sourceDataURL = await sourceToDataURL(firstSource); + const sourcePath = firstSource?.path || options.imagePaths?.[0] || options.imagePath; + if (!sourceDataURL && sourcePath) { + const sourceB64 = await ReadImageAsBase64(sourcePath); + sourceDataURL = dataURLFromBase64(sourceB64); + } + if (!sourceDataURL) throw new RemoteKernelError("蒙版任务无法读取第一张源图"); + preparedMask = await normalizeMaskForSource(sourceDataURL, options.maskB64); + remoteOptions = { ...options, maskB64: preparedMask.maskB64 }; + } const result = await runRemoteImageJob({ payload: { - ...options, - requestPolicy: normalizeRequestPolicy(options.requestPolicy), - imagesNewAPICompat: options.imagesNewAPICompat === true, - allowInsecureConnection: options.allowInsecureConnection === true, - }, sourceImages: options.sourceImages }, { + ...remoteOptions, + requestPolicy: normalizeRequestPolicy(remoteOptions.requestPolicy), + imagesNewAPICompat: remoteOptions.imagesNewAPICompat === true, + allowInsecureConnection: remoteOptions.allowInsecureConnection === true, + }, sourceImages: remoteOptions.sourceImages }, { signal: controller.signal, onLog: (line) => emitLocalEvent(`log:${jobId}`, line), onProgress: (stage, elapsed, bytes) => emitLocalEvent(`progress:${jobId}`, { stage, elapsed, bytes }), @@ -164,18 +181,23 @@ async function startRemoteJob(options: GenerateOptionsLike): Promise { @@ -198,6 +209,21 @@ export function imageDims(b64: string): { w: number; h: number } | null { return getImageDimensionsFromBase64(b64); } +export async function loadImageDims(b64: string): Promise<{ w: number; h: number } | null> { + if (!b64) return null; + const fast = detectImageMimeTypeFromBase64(b64) === "image/png" ? imageDims(b64) : null; + if (fast) return fast; + if (typeof Image === "undefined") return null; + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0 && image.naturalHeight > 0 + ? { w: image.naturalWidth, h: image.naturalHeight } + : null); + image.onerror = () => resolve(null); + image.src = tempDataURLFromB64(b64); + }); +} + export function augmentPromptWithAnnotations( prompt: string, annotations: Annotation[], diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index 4ebb10a9b..c9b05a9f9 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -164,6 +164,7 @@ import { genId, imageDims, loadModeConfig, + loadImageDims, loadStoredActiveProfileId, loadStoredAIProfileId, loadStoredProfiles, @@ -1263,6 +1264,26 @@ export const useStudioStore = create((set, get) => ({ jobsCompleted: 0, runningJobs: [], }; + const importedMaskDataURL = s.maskDataURL && s.maskDataURL !== "__PENDING_MASK__" ? s.maskDataURL : null; + let maskTargetDims = s.currentImage?.imageB64 ? await loadImageDims(s.currentImage.imageB64) : null; + if (s.mode === "edit" && s.strokes.length > 0 && !maskTargetDims && editSourcePaths[0]) { + const sourceB64 = await ReadImageAsBase64(editSourcePaths[0]).catch(() => ""); + maskTargetDims = await loadImageDims(sourceB64); + } + if (s.mode === "edit" && s.strokes.length > 0 && !maskTargetDims) { + set({ + errorMessage: "无法读取源图真实尺寸,未提交蒙版任务。请重新导入源图后再试。", + errorCanRetry: false, + errorRawPath: null, + }); + return; + } + const maskDataURL = s.mode === "edit" + ? (s.strokes.length > 0 + ? buildMaskPNGDataURL(s.strokes, maskTargetDims) + : importedMaskDataURL) + : null; + const maskB64 = maskDataURL ? stripDataURLPrefix(maskDataURL) : ""; set({ ...runPatch, batchCount, @@ -1280,14 +1301,6 @@ export const useStudioStore = create((set, get) => ({ resultGridOpen: requestedJobCount > 1, }), }); - - const importedMaskDataURL = s.maskDataURL && s.maskDataURL !== "__PENDING_MASK__" ? s.maskDataURL : null; - const maskDataURL = s.mode === "edit" - ? (s.strokes.length > 0 - ? buildMaskPNGDataURL(s.strokes, s.currentImage?.imageB64 ? imageDims(s.currentImage.imageB64) : null) - : importedMaskDataURL) - : null; - const maskB64 = maskDataURL ? stripDataURLPrefix(maskDataURL) : ""; let augmentedPrompt = augmentPromptWithAnnotations(s.prompt, s.annotations, s.currentImage?.imageB64 ? imageDims(s.currentImage.imageB64) : null); // Append style chip suffix if the user picked one (other than "全部"). const styleSuffix = STYLE_SUFFIXES[s.styleTag]; @@ -2544,7 +2557,9 @@ async function launchOneJob( mode: r.mode as Mode, size: snapshot.size, quality: snapshot.quality, - outputFormat: snapshot.outputFormat, + outputFormat: r.outputFormat === "png" || r.outputFormat === "jpeg" || r.outputFormat === "webp" + ? r.outputFormat + : snapshot.outputFormat, parentId, createdAt: Date.now(), seed: payload.seed || undefined, diff --git a/image-studio/frontend/src/styles/_canvas.css b/image-studio/frontend/src/styles/_canvas.css index 8e9f4a157..f4043eae0 100644 --- a/image-studio/frontend/src/styles/_canvas.css +++ b/image-studio/frontend/src/styles/_canvas.css @@ -292,6 +292,14 @@ html[data-target-platform="macos"] .source-strip { will-change: background-position; } +/* The Konva canvas fully covers the checkerboard once an image is present. + Stop the hidden background animation so an idle/backgrounded WebView does + not keep repainting the whole desktop canvas. */ +.stage-host.has-image { + animation: none; + will-change: auto; +} + .stream-preview-overlay { position: absolute; top: 12px; diff --git a/image-studio/frontend/test/requestModel.test.mjs b/image-studio/frontend/test/requestModel.test.mjs index 5119006fa..097b80b32 100644 --- a/image-studio/frontend/test/requestModel.test.mjs +++ b/image-studio/frontend/test/requestModel.test.mjs @@ -40,9 +40,11 @@ test("Responses payload defaults partial_images to streaming preview count", () quality: "low", outputFormat: "png", imageModelID: "gpt-image-2", - textModelID: "gpt-5.5", + textModelID: "gpt-image-1.5", requestPolicy: "openai", }, []); + assert.equal(payload.model, "gpt-image-1.5"); + assert.equal("model" in payload.tools[0], false); assert.equal(payload.tools[0].partial_images, DEFAULT_PARTIAL_IMAGES); assert.equal(payload.reasoning.effort, DEFAULT_REASONING_EFFORT); }); diff --git a/shared/kernel/requestModel.js b/shared/kernel/requestModel.js index 858227a42..e9ad19121 100644 --- a/shared/kernel/requestModel.js +++ b/shared/kernel/requestModel.js @@ -372,7 +372,6 @@ export function buildResponsesImageTool(payload, sourceDataURLs, options = {}) { const partialImages = payload.disablePreview ? 0 : normalizePartialImages(payload.partialImages); const tool = { type: "image_generation", - model: normalizeImageModel(payload.imageModelID), action: sourceDataURLs.length > 0 ? "edit" : "generate", size, quality, From d27fbd694cf3310c0e5ce3e62a3ea69a69dfb626 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Tue, 21 Jul 2026 12:45:51 +0800 Subject: [PATCH 2/6] Add provider-aware upstream probing and profile persistence --- .../android/AndroidImageStudioBridge.kt | 41 ++++++--- gio-client/internal/compat/state.go | 5 ++ gio-client/internal/kernel/probe.go | 44 ++++++++-- gio-client/internal/kernel/runner.go | 15 +++- gio-client/internal/kernel/runner_test.go | 31 +++++++ gio-client/internal/ui/app.go | 6 ++ gio-client/internal/ui/codex_config.go | 2 + gio-client/internal/ui/layout_controls.go | 37 +++++++- gio-client/internal/ui/run_job.go | 2 + gio-client/internal/ui/ui_state.go | 22 +++++ .../internal/ui/upstream_config_transfer.go | 7 ++ go-cli/pkg/client/client.go | 4 +- go-cli/pkg/client/grok_images.go | 87 +++++++++++++++++++ go-cli/pkg/client/images_api.go | 23 ++++- go-cli/pkg/client/images_api_test.go | 83 ++++++++++++++++++ go-cli/pkg/client/types.go | 14 +++ go-cli/pkg/client/url.go | 51 ++++++++--- go-cli/pkg/client/url_test.go | 16 ++++ image-studio/backend/probe.go | 43 ++++++--- image-studio/backend/probe_test.go | 31 +++++++ image-studio/backend/service.go | 2 + image-studio/backend/types.go | 5 +- .../frontend/src/app/dev/previewData.ts | 1 + .../components/panel/UpstreamConfigModal.tsx | 42 ++++++--- .../panel/UpstreamProfileEditor.tsx | 67 +++++++++++--- .../components/panel/UpstreamProfileList.tsx | 2 +- image-studio/frontend/src/lib/profiles.ts | 18 +++- .../src/lib/upstreamConfigTransfer.ts | 12 ++- .../upstream/AndroidUpstreamEmptyState.tsx | 10 +-- .../upstream/AndroidUpstreamProfileForm.tsx | 37 ++++++-- .../upstream/AndroidUpstreamProfileRail.tsx | 3 +- .../upstream/useAndroidUpstreamConfig.ts | 31 ++++++- .../frontend/src/platform/runtime/host.ts | 2 + .../src/platform/runtime/hostTypes.ts | 5 +- .../platform/runtime/remote-kernel/common.ts | 19 ++-- .../platform/runtime/remote-kernel/images.ts | 6 +- .../platform/runtime/remote-kernel/index.ts | 5 +- .../runtime/remote-kernel/requestPayloads.ts | 52 ++++++++++- .../platform/runtime/remote-kernel/types.ts | 4 +- .../src/state/studioStore.profiles.ts | 14 ++- .../frontend/src/state/studioStore.ts | 13 ++- .../frontend/src/state/studioStore.types.ts | 3 + image-studio/frontend/src/types/domain.ts | 3 + .../frontend/test/remoteKernel.test.mjs | 50 +++++++++++ image-studio/frontend/wailsjs/go/models.ts | 4 + shared/compat-go/state.go | 7 ++ shared/compat-go/state_test.go | 3 +- shared/kernel/requestModel.d.ts | 7 +- shared/kernel/requestModel.js | 29 +++++-- 49 files changed, 892 insertions(+), 128 deletions(-) create mode 100644 go-cli/pkg/client/grok_images.go 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 d3a27436c..bb670c60b 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 @@ -595,18 +595,24 @@ class AndroidImageStudioBridge( val apiKey = payload.optString("apiKey").trim() val proxyMode = payload.optString("proxyMode", "system") val proxyUrl = payload.optString("proxyURL", "") + val provider = payload.optString("provider", "openai").lowercase(Locale.US).let { + if (it == "google" || it == "grok") it else "openai" + } 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 { - val connection = openHttpConnection(openAIEndpoint(baseUrl, "models"), proxyMode, proxyUrl, allowInsecureConnection).apply { + val endpointLabel = if (provider == "google") "/v1beta/models" else "/v1/models" + val modelsEndpoint = if (provider == "google") googleEndpoint(baseUrl, "models") else openAIEndpoint(baseUrl, "models") + val connection = openHttpConnection(modelsEndpoint, proxyMode, proxyUrl, allowInsecureConnection).apply { requestMethod = "GET" instanceFollowRedirects = true connectTimeout = 20_000 readTimeout = 20_000 doInput = true - setRequestProperty("Authorization", "Bearer $apiKey") + if (provider == "google") setRequestProperty("X-Goog-Api-Key", apiKey) + else setRequestProperty("Authorization", "Bearer $apiKey") setRequestProperty("Accept", "application/json") setRequestProperty("User-Agent", "image-studio-android") } @@ -617,30 +623,30 @@ class AndroidImageStudioBridge( } ?: "" connection.disconnect() if (status !in 200..299) { - throw IllegalStateException("上游 /v1/models 返回 $status${summarizeProbeBody(body).let { if (it.isBlank()) "" else ": $it" }}") + throw IllegalStateException("上游 $endpointLabel 返回 $status${summarizeProbeBody(body).let { if (it.isBlank()) "" else ": $it" }}") } val parsed = JSONObject(body) - if (!parsed.has("data") || parsed.isNull("data")) { - throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") - } - val data = parsed.optJSONArray("data") ?: throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") + val data = if (provider == "google") parsed.optJSONArray("models") + ?: throw IllegalStateException("上游 /v1beta/models 响应缺少 models 数组") + else parsed.optJSONArray("data") + ?: throw IllegalStateException("上游 /v1/models 响应缺少 data 数组") val models = mutableListOf>() for (index in 0 until data.length()) { val item = data.optJSONObject(index) ?: continue - val id = item.optString("id").trim() + val id = if (provider == "google") item.optString("name").trim().removePrefix("models/") else item.optString("id").trim() if (id.isBlank()) continue models += mapOf( "id" to id, - "object" to item.optString("object").trim(), - "ownedBy" to item.optString("owned_by").trim(), - "displayName" to item.optString("name").trim(), + "object" to if (provider == "google") "model" else item.optString("object").trim(), + "ownedBy" to if (provider == "google") "google" else item.optString("owned_by").trim(), + "displayName" to if (provider == "google") item.optString("displayName").trim() else item.optString("name").trim(), ) } val result = mutableMapOf( "modelCount" to data.length(), "models" to models, ) - if (apiMode == "responses" && responsesTransport == "websocket") { + if (provider == "openai" && apiMode == "responses" && responsesTransport == "websocket") { result["responsesTransport"] = "websocket" val probePayload = JSONObject() .put("type", "response.create") @@ -994,6 +1000,17 @@ class AndroidImageStudioBridge( return if (basePath.endsWith("/openai")) "$cleaned/$path" else "$cleaned/v1/$path" } + private fun googleEndpoint(baseUrl: String, endpointPath: String): String { + var cleaned = baseUrl.trim().trimEnd('/') + val path = endpointPath.trim().trim('/') + cleaned = when { + cleaned.endsWith("/v1beta/openai", ignoreCase = true) -> cleaned.dropLast("/openai".length) + cleaned.endsWith("/v1beta", ignoreCase = true) -> cleaned + else -> "$cleaned/v1beta" + } + return if (path.isBlank()) cleaned else "$cleaned/$path" + } + private fun isProbeLoopbackHost(host: String): Boolean { val lower = host.lowercase(Locale.US).trim('[', ']') return lower == "localhost" || lower.endsWith(".localhost") || lower == "127.0.0.1" || lower == "::1" || lower == "0:0:0:0:0:0:0:1" diff --git a/gio-client/internal/compat/state.go b/gio-client/internal/compat/state.go index 56e8f8a8f..2e3c5e9d3 100644 --- a/gio-client/internal/compat/state.go +++ b/gio-client/internal/compat/state.go @@ -106,7 +106,11 @@ func ConfigFromState(cfg kernel.Config, state shared.State) kernel.Config { cfg.BaseURL = profile.BaseURL cfg.TextModelID = profile.TextModelID cfg.ImageModelID = profile.ImageModelID + cfg.Provider = client.NormalizeProvider(client.Provider(profile.Provider)) cfg.APIMode = normaliseAPIMode(profile.APIMode) + if cfg.Provider != client.ProviderOpenAI { + cfg.APIMode = client.APIModeImages + } cfg.ResponsesTransport = client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)) cfg.FallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) cfg.RequestPolicy = normalisePolicy(profile.RequestPolicy) @@ -234,6 +238,7 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State { profile := shared.UpstreamProfile{ ID: profileID, Name: nextDefaultProfileName(state.Profiles), + Provider: string(client.NormalizeProvider(cfg.Provider)), APIMode: string(normaliseAPIMode(string(cfg.APIMode))), ResponsesTransport: normalizeProfileResponsesTransport(string(cfg.ResponsesTransport)), RequestPolicy: string(normalisePolicy(string(cfg.RequestPolicy))), diff --git a/gio-client/internal/kernel/probe.go b/gio-client/internal/kernel/probe.go index a913063fd..7afc449b5 100644 --- a/gio-client/internal/kernel/probe.go +++ b/gio-client/internal/kernel/probe.go @@ -42,11 +42,22 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { ctx, cancel := context.WithTimeout(ctx, probeUpstreamTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil) + provider := client.NormalizeProvider(cfg.Provider) + modelsEndpoint := client.OpenAIAPIEndpoint(baseURL, "models") + endpointLabel := "/v1/models" + if provider == client.ProviderGoogle { + modelsEndpoint = client.GoogleAPIEndpoint(baseURL, "models") + endpointLabel = "/v1beta/models" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsEndpoint, nil) if err != nil { return ProbeResult{}, fmt.Errorf("构造测活请求失败: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + if provider == client.ProviderGoogle { + req.Header.Set("X-Goog-Api-Key", apiKey) + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", client.UserAgent()) @@ -72,9 +83,9 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { summary = readErr.Error() } if summary != "" { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回 %d: %s", resp.StatusCode, summary) + return ProbeResult{}, fmt.Errorf("上游 %s 返回 %d: %s", endpointLabel, resp.StatusCode, summary) } - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回 %d", resp.StatusCode) + return ProbeResult{}, fmt.Errorf("上游 %s 返回 %d", endpointLabel, resp.StatusCode) } if readErr != nil { return ProbeResult{}, fmt.Errorf("读取上游响应失败: %w", readErr) @@ -87,12 +98,27 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { OwnedBy string `json:"owned_by"` DisplayName string `json:"name"` } `json:"data"` + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` } if err := json.Unmarshal(body, &parsed); err != nil { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err) - } - if parsed.Data == nil { - return ProbeResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组") + return ProbeResult{}, fmt.Errorf("上游 %s 返回的 JSON 无效: %w", endpointLabel, err) + } + if provider == client.ProviderGoogle { + for _, item := range parsed.Models { + parsed.Data = append(parsed.Data, struct { + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + DisplayName string `json:"name"` + }{ + ID: strings.TrimPrefix(strings.TrimSpace(item.Name), "models/"), Object: "model", OwnedBy: "google", DisplayName: item.DisplayName, + }) + } + } else if parsed.Data == nil { + return ProbeResult{}, fmt.Errorf("上游 %s 响应缺少 data 数组", endpointLabel) } models := make([]UpstreamModelDescriptor, 0, len(parsed.Data)) for _, item := range parsed.Data { @@ -111,7 +137,7 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) { ModelCount: len(parsed.Data), Models: models, } - if cfg.APIMode == client.APIModeResponses && + if provider == client.ProviderOpenAI && cfg.APIMode == client.APIModeResponses && client.NormalizeProxyTransportValue(strings.TrimSpace(string(cfg.ResponsesTransport))) == string(client.ResponsesTransportWebSocket) { result.ResponsesTransport = string(client.ResponsesTransportWebSocket) if wsErr := client.ProbeResponsesWebSocket(ctx, client.ProbeResponsesWebSocketOptions{ diff --git a/gio-client/internal/kernel/runner.go b/gio-client/internal/kernel/runner.go index b98112a90..67bbaf06b 100644 --- a/gio-client/internal/kernel/runner.go +++ b/gio-client/internal/kernel/runner.go @@ -30,6 +30,7 @@ type Config struct { ImageModelID string Prompt string Mode client.Mode + Provider client.Provider APIMode client.APIMode RequestPolicy client.RequestPolicy ResponsesTransport client.ResponsesTransport @@ -72,6 +73,7 @@ type FallbackProfileConfig struct { BaseURL string TextModelID string ImageModelID string + Provider client.Provider APIMode client.APIMode ResponsesTransport client.ResponsesTransport RequestPolicy client.RequestPolicy @@ -107,6 +109,7 @@ func DefaultConfig() Config { TextModelID: client.TextModel, ImageModelID: client.ImageModel, Mode: client.ModeGenerate, + Provider: client.ProviderOpenAI, APIMode: client.APIModeResponses, RequestPolicy: client.RequestPolicyOpenAI, ResponsesTransport: client.ResponsesTransportSSE, @@ -206,6 +209,7 @@ func (Runner) Run(ctx context.Context, cfg Config, cb Callbacks) (Result, error) fallbackCfg.BaseURL = fallback.BaseURL fallbackCfg.TextModelID = fallback.TextModelID fallbackCfg.ImageModelID = fallback.ImageModelID + fallbackCfg.Provider = fallback.Provider fallbackCfg.APIMode = fallback.APIMode fallbackCfg.ResponsesTransport = fallback.ResponsesTransport fallbackCfg.RequestPolicy = fallback.RequestPolicy @@ -274,7 +278,10 @@ func normalizeConfig(cfg Config) Config { if cfg.Mode != client.ModeEdit { cfg.Mode = client.ModeGenerate } - if cfg.APIMode != client.APIModeImages { + cfg.Provider = client.NormalizeProvider(cfg.Provider) + if cfg.Provider != client.ProviderOpenAI || cfg.APIMode == client.APIModeImages { + cfg.APIMode = client.APIModeImages + } else { cfg.APIMode = client.APIModeResponses } if cfg.RequestPolicy != client.RequestPolicyCompat { @@ -343,8 +350,11 @@ func normalizeFallbackProfileConfig(value *FallbackProfileConfig) *FallbackProfi next.BaseURL = strings.TrimSpace(next.BaseURL) next.TextModelID = strings.TrimSpace(next.TextModelID) next.ImageModelID = strings.TrimSpace(next.ImageModelID) + next.Provider = client.NormalizeProvider(next.Provider) next.RequestPolicy = normalizeRequestPolicy(next.RequestPolicy) - if next.APIMode != client.APIModeImages { + if next.Provider != client.ProviderOpenAI || next.APIMode == client.APIModeImages { + next.APIMode = client.APIModeImages + } else { next.APIMode = client.APIModeResponses } if next.ResponsesTransport != client.ResponsesTransportWebSocket { @@ -385,6 +395,7 @@ func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir st ImageModelID: cfg.ImageModelID, Prompt: cfg.Prompt, Mode: cfg.Mode, + Provider: cfg.Provider, APIMode: cfg.APIMode, ResponsesTransport: cfg.ResponsesTransport, RequestPolicy: cfg.RequestPolicy, diff --git a/gio-client/internal/kernel/runner_test.go b/gio-client/internal/kernel/runner_test.go index 856fc64cd..190aee9f7 100644 --- a/gio-client/internal/kernel/runner_test.go +++ b/gio-client/internal/kernel/runner_test.go @@ -156,6 +156,37 @@ func TestProbeUpstreamReturnsModelCount(t *testing.T) { } } +func TestProbeUpstreamGoogleProviderUsesNativeModelsEndpoint(t *testing.T) { + var gotPath string + var gotGoogleKey string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotGoogleKey = r.Header.Get("X-Goog-Api-Key") + if auth := r.Header.Get("Authorization"); auth != "" { + t.Fatalf("Google probe must not send Authorization: %q", auth) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-3.1-flash-image","displayName":"Nano Banana 2"}]}`)) + })) + defer server.Close() + + result, err := ProbeUpstream(context.Background(), Config{ + APIKey: "google-key", + BaseURL: server.URL, + Provider: client.ProviderGoogle, + APIMode: client.APIModeImages, + }) + if err != nil { + t.Fatalf("Google probe returned error: %v", err) + } + if gotPath != "/v1beta/models" || gotGoogleKey != "google-key" { + t.Fatalf("path=%q X-Goog-Api-Key=%q", gotPath, gotGoogleKey) + } + if result.ModelCount != 1 || len(result.Models) != 1 || result.Models[0].ID != "gemini-3.1-flash-image" || result.Models[0].OwnedBy != "google" { + t.Fatalf("unexpected Google models: %+v", result) + } +} + func TestProbeUpstreamSkipsModelItemsWithoutID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/gio-client/internal/ui/app.go b/gio-client/internal/ui/app.go index e1a79c693..3f46fe202 100644 --- a/gio-client/internal/ui/app.go +++ b/gio-client/internal/ui/app.go @@ -25,6 +25,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "github.com/yuanhua/image-gptcodex/pkg/client" "github.com/yuanhua/image-gptcodex/pkg/promptimport" ) @@ -239,6 +240,7 @@ type App struct { customSizeHeightInput widget.Editor mode string + provider string api string size string quality string @@ -283,6 +285,7 @@ type App struct { batchCount int modeButtons []widget.Clickable + providerButtons []widget.Clickable apiButtons []widget.Clickable sizeButtons []widget.Clickable aspectButtons []widget.Clickable @@ -908,6 +911,7 @@ func New() *App { th: th, runner: kernel.Runner{}, mode: string(cfg.Mode), + provider: string(client.NormalizeProvider(cfg.Provider)), api: string(cfg.APIMode), size: cfg.Size, quality: cfg.Quality, @@ -1007,6 +1011,7 @@ func New() *App { workflowPreviewButtons: make([]widget.Clickable, len(partialPreviewChoices)), pruneGeneralHistoryButtons: make([]widget.Clickable, 2), modeButtons: make([]widget.Clickable, len(modeChoices)), + providerButtons: make([]widget.Clickable, 3), apiButtons: make([]widget.Clickable, len(apiChoices)), sizeButtons: make([]widget.Clickable, len(sizeChoices)), aspectButtons: make([]widget.Clickable, len(aspectChoices)+len(compatState.Settings.CustomAspectRatios)+24), @@ -1220,6 +1225,7 @@ func (a *App) configureEditors(cfg kernel.Config) { a.baseURLInput.SetText(cfg.BaseURL) a.textModelInput.SetText(cfg.TextModelID) a.imageModelInput.SetText(cfg.ImageModelID) + a.provider = string(client.NormalizeProvider(cfg.Provider)) a.outputDirInput.SetText(cfg.OutputDir) a.partialImagesInput.SetText(strconv.Itoa(cfg.PartialImages)) a.outputCompressionInput.SetText(strconv.Itoa(cfg.OutputCompression)) diff --git a/gio-client/internal/ui/codex_config.go b/gio-client/internal/ui/codex_config.go index 7af8f543e..3c77af2e3 100644 --- a/gio-client/internal/ui/codex_config.go +++ b/gio-client/internal/ui/codex_config.go @@ -305,6 +305,7 @@ func (a *App) applyCodexConfigSync(imported codexAPIConfig) error { continue } state.Profiles[i].Name = name + state.Profiles[i].Provider = string(client.ProviderOpenAI) state.Profiles[i].APIMode = string(client.APIModeResponses) state.Profiles[i].RequestPolicy = string(client.RequestPolicyOpenAI) state.Profiles[i].ImagesNewAPICompat = false @@ -319,6 +320,7 @@ func (a *App) applyCodexConfigSync(imported codexAPIConfig) error { state.Profiles = append(state.Profiles, sharedCompat.UpstreamProfile{ ID: profileID, Name: name, + Provider: string(client.ProviderOpenAI), APIMode: string(client.APIModeResponses), RequestPolicy: string(client.RequestPolicyOpenAI), ImagesNewAPICompat: false, diff --git a/gio-client/internal/ui/layout_controls.go b/gio-client/internal/ui/layout_controls.go index 017010474..927125787 100644 --- a/gio-client/internal/ui/layout_controls.go +++ b/gio-client/internal/ui/layout_controls.go @@ -2860,12 +2860,31 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.field(gtx, "名称", &a.profileNameInput, "配置1", unit.Dp(40)) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.layoutSettingsOptionCards(gtx, "服务商请求方式", []settingsOptionChoice{ + {Title: "OpenAI / 兼容", Detail: "Responses 或 Images", Value: string(client.ProviderOpenAI)}, + {Title: "Google Gemini", Detail: "Interactions 原生协议", Value: string(client.ProviderGoogle)}, + {Title: "Grok / xAI", Detail: "Imagine 原生协议", Value: string(client.ProviderGrok)}, + }, a.provider, a.providerButtons, 3, func(value string) { + a.provider = normalizeProfileProvider(value) + if a.provider != string(client.ProviderOpenAI) { + a.api = string(client.APIModeImages) + a.policy = string(client.RequestPolicyOpenAI) + } + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return a.label(gtx, "服务商决定请求路径、认证头与请求体;第三方 OpenAI 兼容中转请选择 OpenAI / 兼容。", unit.Sp(10), fluent.textDim, font.Normal) + } return a.layoutSettingsOptionCards(gtx, "API 形态", []settingsOptionChoice{ {Title: "Responses API", Detail: "SSE 保活,更适合长推理", Value: string(client.APIModeResponses)}, {Title: "Images API", Detail: "标准 generations / edits", Value: string(client.APIModeImages)}, }, a.api, a.apiButtons, 2, func(value string) { a.api = value }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } hint := "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" if a.api == string(client.APIModeImages) { hint = "使用标准 Images API,key 走 image-2 / image API 分组,兼容性最广。" @@ -2873,7 +2892,7 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.label(gtx, hint, unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if a.api != string(client.APIModeResponses) { + if a.provider != string(client.ProviderOpenAI) || a.api != string(client.APIModeResponses) { return layout.Dimensions{} } return a.layoutSettingsOptionCards(gtx, "Responses 传输", responsesTransportChoices, a.responsesTransport, a.responsesTransportButtons, 2, func(value string) { @@ -2881,25 +2900,37 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if a.api != string(client.APIModeResponses) { + if a.provider != string(client.ProviderOpenAI) || a.api != string(client.APIModeResponses) { return layout.Dimensions{} } return a.label(gtx, "WebSocket mode 需要上游 / 代理正确转发 Upgrade: websocket;若握手失败,请求层会自动回退到 HTTP SSE,并把原因写进 Raw response。", unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } return a.layoutSettingsOptionCards(gtx, "参数策略", []settingsOptionChoice{ {Title: "OpenAI 标准", Detail: "只发送官方公开字段", Value: string(client.RequestPolicyOpenAI)}, {Title: "兼容中转扩展", Detail: "附带 seed / negative_prompt", Value: string(client.RequestPolicyCompat)}, }, a.policy, a.policyButtons, 1, func(value string) { a.policy = value }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.provider != string(client.ProviderOpenAI) { + return layout.Dimensions{} + } return a.label(gtx, "OpenAI 标准更适合直连 OpenAI;兼容中转扩展会额外发送 relay 常见扩展字段。", unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.technicalField(gtx, "上游 BASE_URL", &a.baseURLInput, "https://example.com", unit.Dp(40)) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return a.label(gtx, "中转站只填根地址;Google 官方 Images 配置填写 https://generativelanguage.googleapis.com/v1beta/openai。", unit.Sp(10), fluent.textDim, font.Normal) + hint := "中转站只填根地址,应用自动拼接 OpenAI API 路径。" + if a.provider == string(client.ProviderGoogle) { + hint = "Google 官方填写 https://generativelanguage.googleapis.com,应用自动拼接 /v1beta/interactions。" + } else if a.provider == string(client.ProviderGrok) { + hint = "xAI 官方填写 https://api.x.ai,应用自动拼接 Imagine API 路径。" + } + return a.label(gtx, hint, unit.Sp(10), fluent.textDim, font.Normal) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { active := a.allowInsecureConnection diff --git a/gio-client/internal/ui/run_job.go b/gio-client/internal/ui/run_job.go index bd9ff7c48..5c4f96224 100644 --- a/gio-client/internal/ui/run_job.go +++ b/gio-client/internal/ui/run_job.go @@ -598,6 +598,7 @@ func (a *App) currentConfig() kernel.Config { ImageModelID: a.imageModelInput.Text(), Prompt: prompt, Mode: client.Mode(a.mode), + Provider: client.Provider(a.provider), APIMode: client.APIMode(a.api), RequestPolicy: client.RequestPolicy(a.policy), ResponsesTransport: client.ResponsesTransport(a.responsesTransport), @@ -769,6 +770,7 @@ func resolveFallbackProfileConfig(state sharedCompat.State, fallbackProfileID st BaseURL: strings.TrimSpace(profile.BaseURL), TextModelID: strings.TrimSpace(profile.TextModelID), ImageModelID: strings.TrimSpace(profile.ImageModelID), + Provider: client.Provider(normalizeProfileProvider(profile.Provider)), APIMode: client.APIMode(normalizeProfileAPIMode(profile.APIMode)), ResponsesTransport: client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)), RequestPolicy: client.RequestPolicy(normalizeProfilePolicy(profile.RequestPolicy)), diff --git a/gio-client/internal/ui/ui_state.go b/gio-client/internal/ui/ui_state.go index 28d3f5502..21de4c807 100644 --- a/gio-client/internal/ui/ui_state.go +++ b/gio-client/internal/ui/ui_state.go @@ -1395,6 +1395,17 @@ func normalizeProfileAPIMode(mode string) string { return string(client.APIModeResponses) } +func normalizeProfileProvider(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(client.ProviderGoogle): + return string(client.ProviderGoogle) + case string(client.ProviderGrok): + return string(client.ProviderGrok) + default: + return string(client.ProviderOpenAI) + } +} + func normalizeProfilePolicy(policy string) string { if strings.TrimSpace(policy) == string(client.RequestPolicyCompat) { return string(client.RequestPolicyCompat) @@ -1447,7 +1458,11 @@ func (a *App) settingsDraftReady() bool { func (a *App) applySettingsProfileDraft(state sharedCompat.State, profile sharedCompat.UpstreamProfile) { a.settingsSelectedProfileID = profile.ID a.profileNameInput.SetText(strings.TrimSpace(profile.Name)) + a.provider = normalizeProfileProvider(profile.Provider) a.api = normalizeProfileAPIMode(profile.APIMode) + if a.provider != string(client.ProviderOpenAI) { + a.api = string(client.APIModeImages) + } a.policy = normalizeProfilePolicy(profile.RequestPolicy) a.responsesTransport = normalizeProfileResponsesTransport(profile.ResponsesTransport) a.reasoningEffort = normalizeReasoningEffort(profile.ReasoningEffort) @@ -1584,6 +1599,7 @@ func (a *App) loadSettingsProfileDraft(profileID string) error { a.protectStreamPreview = kernel.DefaultConfig().ProtectStreamPreview } a.imagesNewAPICompat = false + a.provider = string(client.ProviderOpenAI) a.api = string(client.APIModeResponses) a.policy = string(client.RequestPolicyOpenAI) a.apiKeyVisible = false @@ -1678,7 +1694,11 @@ func (a *App) saveSettingsSelection() error { } } state.Profiles[i].Name = name + state.Profiles[i].Provider = normalizeProfileProvider(a.provider) state.Profiles[i].APIMode = normalizeProfileAPIMode(a.api) + if state.Profiles[i].Provider != string(client.ProviderOpenAI) { + state.Profiles[i].APIMode = string(client.APIModeImages) + } state.Profiles[i].RequestPolicy = normalizeProfilePolicy(a.policy) state.Profiles[i].ResponsesTransport = normalizeProfileResponsesTransport(a.responsesTransport) state.Profiles[i].ReasoningEffort = normalizeReasoningEffort(a.reasoningEffort) @@ -1802,6 +1822,7 @@ func (a *App) createSettingsProfile(apiMode string) error { profile = sharedCompat.UpstreamProfile{ ID: nextProfileID(state.Profiles, now), Name: nextProfileName(state.Profiles), + Provider: string(client.ProviderOpenAI), APIMode: normalizeProfileAPIMode(apiMode), ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -2070,6 +2091,7 @@ func (a *App) createBlankProfileWithMode(apiMode string) { profile = sharedCompat.UpstreamProfile{ ID: nextProfileID(state.Profiles, now), Name: nextProfileName(state.Profiles), + Provider: string(client.ProviderOpenAI), APIMode: apiMode, ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), diff --git a/gio-client/internal/ui/upstream_config_transfer.go b/gio-client/internal/ui/upstream_config_transfer.go index d0a1056eb..c8a83d623 100644 --- a/gio-client/internal/ui/upstream_config_transfer.go +++ b/gio-client/internal/ui/upstream_config_transfer.go @@ -19,6 +19,7 @@ import ( type upstreamConfigExportProfile struct { ID string `json:"id"` Name string `json:"name"` + Provider string `json:"provider"` APIMode string `json:"apiMode"` ResponsesTransport string `json:"responsesTransport"` RequestPolicy string `json:"requestPolicy"` @@ -78,6 +79,7 @@ func buildUpstreamConfigExportFile(state sharedCompat.State) (upstreamConfigExpo profiles = append(profiles, upstreamConfigExportProfile{ ID: profile.ID, Name: strings.TrimSpace(profile.Name), + Provider: normalizeProfileProvider(profile.Provider), APIMode: normalizeProfileAPIMode(profile.APIMode), ResponsesTransport: normalizeProfileResponsesTransport(profile.ResponsesTransport), RequestPolicy: normalizeProfilePolicy(profile.RequestPolicy), @@ -181,6 +183,7 @@ func parseExportProfile(raw any) (upstreamConfigExportProfile, bool) { return upstreamConfigExportProfile{ ID: strings.TrimSpace(id), Name: strings.TrimSpace(name), + Provider: normalizeProfileProvider(stringValue(source["provider"])), APIMode: normalizeProfileAPIMode(stringValue(source["apiMode"])), ResponsesTransport: normalizeProfileResponsesTransport(stringValue(source["responsesTransport"])), RequestPolicy: normalizeProfilePolicy(stringValue(source["requestPolicy"])), @@ -213,6 +216,7 @@ func parseNewAPIChannelConnTemplate(input map[string]any) (parsedUpstreamConfigI Profiles: []upstreamConfigExportProfile{{ ID: profileID, Name: buildTemplateProfileName("NewAPI", baseURL, ""), + Provider: string(client.ProviderOpenAI), APIMode: string(client.APIModeResponses), ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -262,6 +266,7 @@ func parseOpenCodeProviderTemplate(input map[string]any) (parsedUpstreamConfigIm profiles = append(profiles, upstreamConfigExportProfile{ ID: nextTemplateProfileID(index), Name: buildTemplateProfileName("OpenCode", baseURL, providerName), + Provider: string(client.ProviderOpenAI), APIMode: apiMode, ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), @@ -386,6 +391,7 @@ func (a *App) applyParsedUpstreamConfigImport(parsed parsedUpstreamConfigImport) if existingIndex, ok := existingByName[strings.TrimSpace(incoming.Name)]; ok { profile := state.Profiles[existingIndex] profile.Name = strings.TrimSpace(incoming.Name) + profile.Provider = normalizeProfileProvider(incoming.Provider) profile.APIMode = normalizeProfileAPIMode(incoming.APIMode) profile.ResponsesTransport = normalizeProfileResponsesTransport(incoming.ResponsesTransport) profile.RequestPolicy = normalizeProfilePolicy(incoming.RequestPolicy) @@ -411,6 +417,7 @@ func (a *App) applyParsedUpstreamConfigImport(parsed parsedUpstreamConfigImport) profile := sharedCompat.UpstreamProfile{ ID: actualID, Name: strings.TrimSpace(incoming.Name), + Provider: normalizeProfileProvider(incoming.Provider), APIMode: normalizeProfileAPIMode(incoming.APIMode), ResponsesTransport: normalizeProfileResponsesTransport(incoming.ResponsesTransport), RequestPolicy: normalizeProfilePolicy(incoming.RequestPolicy), diff --git a/go-cli/pkg/client/client.go b/go-cli/pkg/client/client.go index 673500625..9961c5a47 100644 --- a/go-cli/pkg/client/client.go +++ b/go-cli/pkg/client/client.go @@ -146,7 +146,7 @@ func RequestAndExtractWithRetriesAndPartial( onProgress func(stage string, elapsed int, bytes int64), onPartial func(PartialImage), ) (ImageResult, string, error) { - if opts.APIMode == APIModeImages { + if EffectiveAPIMode(opts.Provider, opts.APIMode) == APIModeImages { return imagesAPIWithRetries(ctx, opts, outputDir, timestamp, onLog, onProgress, onPartial) } return responsesAPIWithRetries(ctx, transport, opts, outputDir, timestamp, onLog, onProgress, onPartial) @@ -160,7 +160,7 @@ func RequestAndExtractWithRetriesAndPartialInMemory( onProgress func(stage string, elapsed int, bytes int64), onPartial func(PartialImage), ) (ImageResult, string, error) { - if opts.APIMode == APIModeImages { + if EffectiveAPIMode(opts.Provider, opts.APIMode) == APIModeImages { return imagesAPIWithRetriesInMemory(ctx, opts, onLog, onProgress, onPartial) } return responsesAPIWithRetriesInMemory(ctx, transport, opts, onLog, onProgress, onPartial) diff --git a/go-cli/pkg/client/grok_images.go b/go-cli/pkg/client/grok_images.go new file mode 100644 index 000000000..e66fe96a7 --- /dev/null +++ b/go-cli/pkg/client/grok_images.go @@ -0,0 +1,87 @@ +package client + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +var grokAspectRatios = []string{ + "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "19.5:9", "9:19.5", "20:9", "9:20", +} + +func buildGrokImagePayload(opts Options, paths []string, model, size string) ([]byte, error) { + if strings.TrimSpace(opts.MaskB64) != "" { + return nil, fmt.Errorf("Grok Imagine 不支持 OpenAI mask 参数;请清除蒙版后重试") + } + payload := map[string]any{ + "model": model, + "prompt": opts.Prompt, + "response_format": "b64_json", + } + if ratio, resolution := grokImageDimensions(size); ratio != "" { + payload["aspect_ratio"] = ratio + payload["resolution"] = resolution + } + if opts.Mode == ModeEdit { + if len(paths) == 0 { + return nil, fmt.Errorf("Grok Imagine 图生图需要至少一张源图") + } + images := make([]map[string]string, 0, len(paths)) + for _, path := range paths { + dataURL, err := ImageFileToDataURL(path) + if err != nil { + return nil, err + } + images = append(images, map[string]string{ + "type": "image_url", + "url": dataURL, + }) + } + if len(images) == 1 { + payload["image"] = images[0] + } else { + payload["image"] = images + } + } + return json.Marshal(payload) +} + +func grokImageDimensions(size string) (string, string) { + width, height, ok := parseGoogleInteractionSize(size) + if !ok { + return "", "" + } + maxSide := width + if height > maxSide { + maxSide = height + } + resolution := "1k" + if maxSide >= 1536 { + resolution = "2k" + } + return closestGrokAspectRatio(float64(width) / float64(height)), resolution +} + +func closestGrokAspectRatio(target float64) string { + best := "1:1" + bestDistance := math.Inf(1) + for _, candidate := range grokAspectRatios { + parts := strings.Split(candidate, ":") + left, _ := parseAspectNumber(parts[0]) + right, _ := parseAspectNumber(parts[1]) + distance := math.Abs(math.Log((left / right) / target)) + if distance < bestDistance { + best = candidate + bestDistance = distance + } + } + return best +} + +func parseAspectNumber(value string) (float64, error) { + var out float64 + _, err := fmt.Sscanf(value, "%f", &out) + return out, err +} diff --git a/go-cli/pkg/client/images_api.go b/go-cli/pkg/client/images_api.go index bbb19fd51..daa6e60fe 100644 --- a/go-cli/pkg/client/images_api.go +++ b/go-cli/pkg/client/images_api.go @@ -261,7 +261,10 @@ func RequestImagesAPIWithPartial( } includeExtended := shouldSendExtendedImageParameters(opts.RequestPolicy) useNewAPICompat := shouldUseImagesNonStreamingCompat(model, opts.ImagesNewAPICompat) - useGoogleInteractions := shouldUseGoogleNativeInteractions(baseURL, model) + provider := NormalizeProvider(opts.Provider) + useGoogleInteractions := provider == ProviderGoogle || + (provider == ProviderOpenAI && shouldUseGoogleNativeInteractions(baseURL, model)) + useGrokImagine := provider == ProviderGrok var ( url string @@ -287,6 +290,22 @@ func RequestImagesAPIWithPartial( } body = bytes.NewReader(payload) contentType = "application/json" + } else if useGrokImagine { + paths := []string(nil) + if opts.Mode == ModeEdit { + paths = opts.imageSourcePathsForEdit() + } + payload, err := buildGrokImagePayload(opts, paths, model, size) + if err != nil { + return ImageResult{}, err + } + endpoint := "images/generations" + if opts.Mode == ModeEdit { + endpoint = "images/edits" + } + url = openAIAPIEndpoint(baseURL, endpoint) + body = bytes.NewReader(payload) + contentType = "application/json" } else if opts.Mode == ModeEdit { paths := opts.imageSourcePathsForEdit() if len(paths) == 0 { @@ -372,6 +391,8 @@ func RequestImagesAPIWithPartial( progressStage := "等待 Images API 返回(无 SSE 保活)" if useGoogleInteractions { progressStage = "等待 Google Interactions 返回(无 SSE 保活)" + } else if useGrokImagine { + progressStage = "等待 Grok Imagine 返回(无 SSE 保活)" } // Progress ticker — Images API has no streaming so we just tick elapsed time. stopProgress := make(chan struct{}) diff --git a/go-cli/pkg/client/images_api_test.go b/go-cli/pkg/client/images_api_test.go index f4d9a7f83..b593da6e1 100644 --- a/go-cli/pkg/client/images_api_test.go +++ b/go-cli/pkg/client/images_api_test.go @@ -599,3 +599,86 @@ func TestRequestImagesAPISendsDalle3Style(t *testing.T) { t.Fatalf("request body missing style=natural: %s", requestBody) } } + +func TestRequestImagesAPIExplicitGoogleProviderUsesInteractionsOnCustomBase(t *testing.T) { + finalB64 := base64.StdEncoding.EncodeToString(fakePNG) + var requestPath string + var googleKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + googleKey = r.Header.Get("X-Goog-Api-Key") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"completed","output_image":{"type":"image","data":%q,"mime_type":"image/png"}}`, finalB64) + })) + defer srv.Close() + + result, err := RequestImagesAPIWithPartial(context.Background(), Options{ + APIKey: "google-key", Provider: ProviderGoogle, BaseURL: srv.URL, + Prompt: "cat", ImageModelID: "gemini-image-model", APIMode: APIModeResponses, + }, &bytes.Buffer{}, nil, nil) + if err != nil { + t.Fatal(err) + } + if requestPath != "/v1beta/interactions" || googleKey != "google-key" { + t.Fatalf("path=%q google key=%q", requestPath, googleKey) + } + if result.ImageB64 != finalB64 || result.SourceEvent != "google_interactions" { + t.Fatalf("result=%+v", result) + } +} + +func TestRequestImagesAPIGrokProviderUsesNativeJSONContracts(t *testing.T) { + finalB64 := base64.StdEncoding.EncodeToString([]byte("final")) + dir := t.TempDir() + source := filepath.Join(dir, "source.png") + if err := os.WriteFile(source, fakePNG, 0o600); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + mode Mode + paths []string + wantPath string + }{ + {name: "generation", mode: ModeGenerate, wantPath: "/v1/images/generations"}, + {name: "edit", mode: ModeEdit, paths: []string{source}, wantPath: "/v1/images/edits"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requestBody []byte + var requestPath, contentType, authorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + contentType = r.Header.Get("Content-Type") + authorization = r.Header.Get("Authorization") + requestBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, finalB64) + })) + defer srv.Close() + + _, err := RequestImagesAPIWithPartial(context.Background(), Options{ + APIKey: "xai-key", Provider: ProviderGrok, BaseURL: srv.URL, + Prompt: "cat", ImageModelID: "grok-imagine-image", Mode: tt.mode, + ImagePaths: tt.paths, Size: "2048x1152", APIMode: APIModeResponses, + }, &bytes.Buffer{}, nil, nil) + if err != nil { + t.Fatal(err) + } + if requestPath != tt.wantPath || contentType != "application/json" || authorization != "Bearer xai-key" { + t.Fatalf("path=%q content-type=%q auth=%q", requestPath, contentType, authorization) + } + var payload map[string]any + if err := json.Unmarshal(requestBody, &payload); err != nil { + t.Fatal(err) + } + if payload["aspect_ratio"] != "16:9" || payload["resolution"] != "2k" || payload["response_format"] != "b64_json" { + t.Fatalf("payload=%s", requestBody) + } + _, hasImage := payload["image"] + if hasImage != (tt.mode == ModeEdit) { + t.Fatalf("image presence=%v payload=%s", hasImage, requestBody) + } + }) + } +} diff --git a/go-cli/pkg/client/types.go b/go-cli/pkg/client/types.go index 357206ee3..327e27f8f 100644 --- a/go-cli/pkg/client/types.go +++ b/go-cli/pkg/client/types.go @@ -116,6 +116,16 @@ const ( APIModeImages APIMode = "images" ) +// Provider selects the upstream vendor contract. Empty values retain the +// historical OpenAI-compatible behavior. +type Provider string + +const ( + ProviderOpenAI Provider = "openai" + ProviderGoogle Provider = "google" + ProviderGrok Provider = "grok" +) + type ResponsesTransport string const ( @@ -162,6 +172,10 @@ type Options struct { // Empty string is treated as APIModeResponses for back-compat. APIMode APIMode + // Provider selects request paths, authentication, and payload semantics. + // Google and Grok use their native image APIs and therefore imply Images. + Provider Provider + // ResponsesTransport selects how Responses API requests are transported. // Empty string is treated as ResponsesTransportSSE for back-compat. ResponsesTransport ResponsesTransport diff --git a/go-cli/pkg/client/url.go b/go-cli/pkg/client/url.go index 5f3b5f5b6..cc6d0bea0 100644 --- a/go-cli/pkg/client/url.go +++ b/go-cli/pkg/client/url.go @@ -58,6 +58,44 @@ func OpenAIAPIEndpoint(baseURL, endpointPath string) string { return cleaned + "/v1/" + path } +// NormalizeProvider keeps old configurations on the OpenAI-compatible path. +func NormalizeProvider(provider Provider) Provider { + switch Provider(strings.ToLower(strings.TrimSpace(string(provider)))) { + case ProviderGoogle: + return ProviderGoogle + case ProviderGrok: + return ProviderGrok + default: + return ProviderOpenAI + } +} + +// EffectiveAPIMode returns the only valid request shape for the selected +// provider. Native Google and Grok image APIs do not use OpenAI Responses. +func EffectiveAPIMode(provider Provider, apiMode APIMode) APIMode { + if NormalizeProvider(provider) != ProviderOpenAI || apiMode == APIModeImages { + return APIModeImages + } + return APIModeResponses +} + +// GoogleAPIEndpoint accepts an official root, the legacy /v1beta/openai base, +// or a Google-compatible relay prefix without duplicating version segments. +func GoogleAPIEndpoint(baseURL, endpointPath string) string { + cleaned := strings.TrimRight(strings.TrimSpace(baseURL), "/") + path := strings.Trim(strings.TrimSpace(endpointPath), "/") + lower := strings.ToLower(cleaned) + if strings.HasSuffix(lower, "/v1beta/openai") { + cleaned = cleaned[:len(cleaned)-len("/openai")] + } else if !strings.HasSuffix(lower, "/v1beta") { + cleaned += "/v1beta" + } + if path == "" { + return cleaned + } + return cleaned + "/" + path +} + func openAIAPIEndpoint(baseURL, endpointPath string) string { return OpenAIAPIEndpoint(baseURL, endpointPath) } @@ -88,18 +126,11 @@ func shouldUseGoogleNativeInteractions(baseURL, model string) bool { } func googleInteractionsEndpoint(baseURL string) (string, error) { - if !isOfficialGoogleGeminiBaseURL(baseURL) { - return "", fmt.Errorf("Google Interactions 仅支持官方 generativelanguage.googleapis.com 主机") - } - u, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/")) - if err != nil { + endpoint := GoogleAPIEndpoint(baseURL, "interactions") + if _, err := url.Parse(endpoint); err != nil { return "", err } - u.Path = "/v1beta/interactions" - u.RawPath = "" - u.RawQuery = "" - u.Fragment = "" - return u.String(), nil + return endpoint, nil } func isLoopbackHost(host string) bool { diff --git a/go-cli/pkg/client/url_test.go b/go-cli/pkg/client/url_test.go index 8dc8d1753..dc439d7bf 100644 --- a/go-cli/pkg/client/url_test.go +++ b/go-cli/pkg/client/url_test.go @@ -85,3 +85,19 @@ func TestGoogleInteractionsEndpointIsNarrowToOfficialNanoBanana2(t *testing.T) { t.Fatal("non-Nano-Banana-2 model must stay on the documented OpenAI-compatible path") } } + +func TestProviderRoutingDefaultsAndNativeModes(t *testing.T) { + t.Parallel() + if got := NormalizeProvider(""); got != ProviderOpenAI { + t.Fatalf("provider=%q", got) + } + if got := EffectiveAPIMode(ProviderGoogle, APIModeResponses); got != APIModeImages { + t.Fatalf("google mode=%q", got) + } + if got := EffectiveAPIMode(ProviderGrok, APIModeResponses); got != APIModeImages { + t.Fatalf("grok mode=%q", got) + } + if got := GoogleAPIEndpoint("https://generativelanguage.googleapis.com/v1beta/openai", "models"); got != "https://generativelanguage.googleapis.com/v1beta/models" { + t.Fatalf("google endpoint=%q", got) + } +} diff --git a/image-studio/backend/probe.go b/image-studio/backend/probe.go index b03f4c812..57961561d 100644 --- a/image-studio/backend/probe.go +++ b/image-studio/backend/probe.go @@ -17,7 +17,11 @@ const probeUpstreamTimeout = 20 * time.Second const probeUpstreamMaxBody = 1 << 20 type modelsListProbeResponse struct { - Data []upstreamModelDescriptorJSON `json:"data"` + Data []upstreamModelDescriptorJSON `json:"data"` + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` } type upstreamModelDescriptorJSON struct { @@ -46,11 +50,22 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst ctx, cancel := context.WithTimeout(parent, probeUpstreamTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.OpenAIAPIEndpoint(baseURL, "models"), nil) + provider := client.NormalizeProvider(client.Provider(opts.Provider)) + modelsEndpoint := client.OpenAIAPIEndpoint(baseURL, "models") + endpointLabel := "/v1/models" + if provider == client.ProviderGoogle { + modelsEndpoint = client.GoogleAPIEndpoint(baseURL, "models") + endpointLabel = "/v1beta/models" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsEndpoint, nil) if err != nil { return ProbeUpstreamResult{}, fmt.Errorf("构造测活请求失败: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + if provider == client.ProviderGoogle { + req.Header.Set("X-Goog-Api-Key", apiKey) + } else { + req.Header.Set("Authorization", "Bearer "+apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", client.UserAgent()) @@ -72,9 +87,9 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst summary = readErr.Error() } if summary != "" { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回 %d: %s", resp.StatusCode, summary) + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回 %d: %s", endpointLabel, resp.StatusCode, summary) } - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回 %d", resp.StatusCode) + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回 %d", endpointLabel, resp.StatusCode) } if readErr != nil { return ProbeUpstreamResult{}, fmt.Errorf("读取上游响应失败: %w", readErr) @@ -82,10 +97,18 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst var parsed modelsListProbeResponse if err := json.Unmarshal(body, &parsed); err != nil { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err) - } - if parsed.Data == nil { - return ProbeUpstreamResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组") + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 返回的 JSON 无效: %w", endpointLabel, err) + } + if provider == client.ProviderGoogle { + for _, item := range parsed.Models { + id := strings.TrimPrefix(strings.TrimSpace(item.Name), "models/") + if id == "" { + continue + } + parsed.Data = append(parsed.Data, upstreamModelDescriptorJSON{ID: id, Object: "model", OwnedBy: "google", DisplayName: item.DisplayName}) + } + } else if parsed.Data == nil { + return ProbeUpstreamResult{}, fmt.Errorf("上游 %s 响应缺少 data 数组", endpointLabel) } models := make([]UpstreamModelDescriptor, 0, len(parsed.Data)) for _, item := range parsed.Data { @@ -104,7 +127,7 @@ func probeUpstream(parent context.Context, opts ProbeUpstreamOptions) (ProbeUpst ModelCount: len(parsed.Data), Models: models, } - if strings.TrimSpace(opts.APIMode) == string(client.APIModeResponses) && + if provider == client.ProviderOpenAI && 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{ diff --git a/image-studio/backend/probe_test.go b/image-studio/backend/probe_test.go index 0bd0f1e6f..cf5835e0e 100644 --- a/image-studio/backend/probe_test.go +++ b/image-studio/backend/probe_test.go @@ -49,6 +49,37 @@ func TestProbeUpstreamRequestsModelsFromBackend(t *testing.T) { } } +func TestProbeUpstreamGoogleProviderUsesNativeModelsEndpoint(t *testing.T) { + var gotPath string + var gotGoogleKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotGoogleKey = r.Header.Get("X-Goog-Api-Key") + if auth := r.Header.Get("Authorization"); auth != "" { + t.Fatalf("Google probe must not send Authorization: %q", auth) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-3.1-flash-image","displayName":"Nano Banana 2"}]}`)) + })) + defer srv.Close() + + got, err := probeUpstream(context.Background(), ProbeUpstreamOptions{ + APIKey: "google-key", + BaseURL: srv.URL, + Provider: "google", + APIMode: "images", + }) + if err != nil { + t.Fatalf("Google probe returned error: %v", err) + } + if gotPath != "/v1beta/models" || gotGoogleKey != "google-key" { + t.Fatalf("path=%q X-Goog-Api-Key=%q", gotPath, gotGoogleKey) + } + if got.ModelCount != 1 || len(got.Models) != 1 || got.Models[0].ID != "gemini-3.1-flash-image" || got.Models[0].OwnedBy != "google" { + t.Fatalf("unexpected Google models: %+v", got) + } +} + func TestProbeUpstreamSkipsModelItemsWithoutID(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index 58716a67a..562a66e3c 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -380,6 +380,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions TextModelID: opts.TextModelID, ImageModelID: opts.ImageModelID, ReasoningEffort: opts.ReasoningEffort, + Provider: client.Provider(strings.TrimSpace(opts.Provider)), Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: apiMode, ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.ResponsesTransport)), @@ -420,6 +421,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions TextModelID: strings.TrimSpace(opts.FallbackProfile.TextModelID), ImageModelID: strings.TrimSpace(opts.FallbackProfile.ImageModelID), ReasoningEffort: strings.TrimSpace(opts.FallbackProfile.ReasoningEffort), + Provider: client.Provider(strings.TrimSpace(opts.FallbackProfile.Provider)), Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, APIMode: fallbackAPIMode, ResponsesTransport: client.ResponsesTransport(strings.TrimSpace(opts.FallbackProfile.ResponsesTransport)), diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 6ebca49a9..ffb68e96b 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -41,7 +41,8 @@ type GenerateOptions struct { TextModelID string `json:"textModelID"` // overrides the default text model ImageModelID string `json:"imageModelID"` // overrides the default image model ReasoningEffort string `json:"reasoningEffort"` - APIMode string `json:"apiMode"` // "responses" (default) | "images" + Provider string `json:"provider,omitempty"` // "openai" (default) | "google" | "grok" + APIMode string `json:"apiMode"` // "responses" (default) | "images" ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` // "openai" (default) | "compat" // ImagesNewAPICompat 开启后仅影响 Images API 请求: @@ -77,6 +78,7 @@ type FallbackProfileOptions struct { TextModelID string `json:"textModelID"` ImageModelID string `json:"imageModelID"` ReasoningEffort string `json:"reasoningEffort"` + Provider string `json:"provider,omitempty"` APIMode string `json:"apiMode"` ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` @@ -106,6 +108,7 @@ type ProbeUpstreamOptions struct { BaseURL string `json:"baseURL"` ProxyMode string `json:"proxyMode"` ProxyURL string `json:"proxyURL"` + Provider string `json:"provider,omitempty"` APIMode string `json:"apiMode,omitempty"` ResponsesTransport string `json:"responsesTransport,omitempty"` AllowInsecureConnection bool `json:"allowInsecureConnection,omitempty"` diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index f74ff84ca..a10e5cd1a 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -166,6 +166,7 @@ function buildPreviewProfile(now: number): UpstreamProfile { return { id: "preview-profile", name: "Preview Responses", + provider: "openai", apiMode: "responses", responsesTransport: "sse", requestPolicy: "openai", diff --git a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx index 982e5ec61..57947aa35 100644 --- a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx @@ -12,7 +12,7 @@ import { probeCurrentUpstream, } from "../../platform/runtime/host"; import { genProfileId, keyringUserFor } from "../../lib/profiles"; -import type { APIMode, RequestPolicy, UpstreamProfile } from "../../types/domain"; +import type { APIMode, RequestPolicy, UpstreamProfile, UpstreamProvider } from "../../types/domain"; import { FAQModal } from "./FAQModal"; import { UpstreamProfileEditor } from "./UpstreamProfileEditor"; import { UpstreamProfileList } from "./UpstreamProfileList"; @@ -102,7 +102,13 @@ export function UpstreamConfigModal({ function patchDraft(patch: Partial) { if (!draft) return; - setDraft({ ...draft, ...patch }); + const provider = patch.provider ?? draft.provider ?? "openai"; + setDraft({ + ...draft, + ...patch, + provider, + apiMode: provider === "openai" ? patch.apiMode ?? draft.apiMode : "images", + }); } function codexProfileName(provider: string) { @@ -122,6 +128,7 @@ export function UpstreamConfigModal({ if (existing) { await updateProfile(existing.id, { name, + provider: "openai", apiMode: "responses", requestPolicy: "openai", imagesNewAPICompat: false, @@ -137,6 +144,7 @@ export function UpstreamConfigModal({ } else { syncedId = await createProfile({ name, + provider: "openai", apiMode: "responses", requestPolicy: "openai", baseURL: imported.baseURL, @@ -225,8 +233,9 @@ export function UpstreamConfigModal({ } } - async function handleNew(apiMode: APIMode = "responses") { + async function handleNew(provider: UpstreamProvider = "openai", apiMode: APIMode = provider === "openai" ? "responses" : "images") { const id = await createProfile({ + provider, apiMode, requestPolicy: "openai", setActive: profiles.length === 0, // 第一个自动 active,后续手动切 @@ -254,6 +263,7 @@ export function UpstreamConfigModal({ if (!draft) return; await updateProfile(draft.id, { name: draft.name, + provider: draft.provider, apiMode: draft.apiMode, responsesTransport: draft.responsesTransport ?? "sse", requestPolicy: draft.requestPolicy, @@ -326,6 +336,8 @@ export function UpstreamConfigModal({ draft.apiMode, draft.responsesTransport ?? "sse", draft.allowInsecureConnection === true, + undefined, + draft.provider, ); const catalog = buildUpstreamModelCatalog(result.models ?? []); setModelCatalog(catalog); @@ -405,16 +417,22 @@ export function UpstreamConfigModal({ {([ { - id: "responses" as APIMode, - title: "Responses API", - sub: "首选。支持 SSE 保活,长任务更稳。", - note: "适合 GPT 图像链路和提示词优化。", + id: "openai" as UpstreamProvider, + title: "OpenAI / 兼容中转", + sub: "支持 Responses 与 Images API。", + note: "适合 OpenAI、NewAPI 和兼容中转。", + }, + { + id: "google" as UpstreamProvider, + title: "Google Gemini", + sub: "原生 /v1beta/interactions。", + note: "使用 x-goog-api-key 与 Google payload。", }, { - id: "images" as APIMode, - title: "Images API", - sub: "兼容性更广,接标准 generations / edits。", - note: "适合只想尽快接上常规生图接口。", + id: "grok" as UpstreamProvider, + title: "Grok / xAI", + sub: "原生 Imagine generations / edits。", + note: "图生图使用 JSON,不使用 multipart。", }, ]).map((item) => ( - {item.id === "responses" ? "R" : "I"} + {item.id === "openai" ? "O" : item.id === "google" ? "G" : "X"} {item.title} diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index 7351fb75f..cd281863e 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -5,8 +5,9 @@ import { type RequestPolicy, type ResponsesTransport, type UpstreamProfile, + type UpstreamProvider, } from "../../types/domain"; -import { requestPolicyLabel } from "../../lib/profiles"; +import { requestPolicyLabel, upstreamProviderLabel } from "../../lib/profiles"; import { usePlatform } from "../../platform/context"; import { formatUpstreamModelLabel, @@ -59,6 +60,11 @@ export function UpstreamProfileEditor({ onSaveAndClose: () => void | Promise; }) { const { isAndroidPhone, usesFluentUI } = usePlatform(); + const providerOptions = [ + { id: "openai" as UpstreamProvider, title: "OpenAI / 兼容中转", sub: "Responses 与 Images API" }, + { id: "google" as UpstreamProvider, title: "Google Gemini", sub: "原生 Interactions API" }, + { id: "grok" as UpstreamProvider, title: "Grok / xAI", sub: "原生 Imagine API" }, + ]; const apiModeOptions = [ { id: "responses" as APIMode, title: "Responses API", sub: "SSE 保活(CF 超时推荐)" }, { id: "images" as APIMode, title: "Images API", sub: "标准 generations / edits" }, @@ -102,7 +108,27 @@ export function UpstreamProfileEditor({ /> - + + {providerOptions.map((option) => ( + onPatchDraft({ + provider: option.id, + apiMode: option.id === "openai" ? draft.apiMode : "images", + imageModelID: draft.imageModelID || (option.id === "google" ? "gemini-3.1-flash-image" : option.id === "grok" ? "grok-imagine-image" : ""), + })} + /> + ))} + + 服务商决定请求路径、认证头和请求体;第三方 OpenAI 兼容中转请选择 OpenAI / 兼容中转。 + + + {draft.provider === "openai" ? API 形态 @@ -130,9 +156,15 @@ export function UpstreamProfileEditor({ ? "需要 key 拥有所填请求模型的权限。SSE 保活可防 Cloudflare 524。" : "使用标准 Images API,key 用 image-2 / image API 分组,兼容性最广。"} - + : ( + + + {draft.provider === "google" ? "Google Interactions API" : "Grok Imagine Images API"} + + + )} - 参数策略 @@ -158,7 +190,7 @@ export function UpstreamProfileEditor({ `OpenAI 标准` 更适合直连 OpenAI 或严格兼容实现。`兼容中转扩展` 会额外发送一些 relay 常见扩展字段。 - + : null} 上游 BASE_URL >}> {baseURLError ? {baseURLError} : null} - - 中转站只填站点根地址,应用会自动拼接当前 API 路径;Google 官方 Images 配置填写 https://generativelanguage.googleapis.com/v1beta/openai。除该官方兼容地址外,不要手动贴入具体接口路径。 + {draft.provider === "google" + ? <>Google 官方填写 https://generativelanguage.googleapis.com,应用自动拼接 /v1beta/interactions。> + : draft.provider === "grok" + ? <>xAI 官方填写 https://api.x.ai,应用自动拼接 Imagine 请求路径。> + : <>中转站只填站点根地址,应用会自动拼接当前 OpenAI API 路径,不要手动贴入具体接口路径。>} @@ -249,12 +284,12 @@ export function UpstreamProfileEditor({ {loadingModels ? "拉取中..." : "拉取并解析上游模型"} - 通过宿主侧请求 /v1/models 获取模型列表,避免浏览器跨域或 WebView 差异影响结果。 + 通过宿主侧请求 {draft.provider === "google" ? /v1beta/models : /v1/models} 获取模型列表,避免浏览器跨域或 WebView 差异影响结果。 {modelCatalogError ? {modelCatalogError} : null} - {draft.apiMode === "responses" ? ( + {draft.provider === "openai" && draft.apiMode === "responses" ? ( <> onPatchDraft({ imageModelID: e.target.value })} spellCheck={false} className={`focus-ring w-full min-w-0 border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 font-mono-token ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} @@ -379,7 +414,7 @@ export function UpstreamProfileEditor({ 不自动切备用上游 {fallbackCandidates.map((profile) => ( - {profile.name} · {profile.apiMode === "responses" ? "Responses" : "Images"} + {profile.name} · {upstreamProviderLabel(profile.provider ?? "openai")} ))} @@ -388,7 +423,7 @@ export function UpstreamProfileEditor({ - {draft.apiMode === "images" ? ( + {draft.provider === "openai" && draft.apiMode === "images" ? ( - Images API 通常走标准 /v1/images/generations + /v1/images/edits;Google 官方 gemini-3.1-flash-image 例外走 Interactions API。两者都没有 SSE 保活。 + + {draft.provider === "google" + ? <>Google 原生请求走 /v1beta/interactions,认证使用 x-goog-api-key。> + : draft.provider === "grok" + ? <>Grok 文生图走 /v1/images/generations,图生图走 JSON 格式的 /v1/images/edits。> + : <>OpenAI Images API 走 /v1/images/generations 与 multipart /v1/images/edits。>} + ) : null}
- 可以。在「🔧 上游配置」里填即可,不同 API 形态用到的字段不一样: + 可以。在「🔧 上游配置」里填写请求模型 ID 即可:
gpt-5.5
gpt-image-2
model
gemini-3.1-flash-image
https://generativelanguage.googleapis.com/v1beta/openai
https://generativelanguage.googleapis.com
/v1beta/interactions
https://api.x.ai
/v1/models
/v1beta/models
x-goog-api-key
- 整批生成全部完成后只播放一次。当前 {completionSound.mode === "custom" && completionSound.customName ? `使用 ${completionSound.customName}` : "使用内置默认音"}。 + 整批完成或首次失败时播放;主动取消时不播放。当前 {completionSound.mode === "custom" && completionSound.customName ? `使用 ${completionSound.customName}` : "使用内置默认音"}。