diff --git a/README.md b/README.md
index bac1e656b..3708d89d0 100644
--- a/README.md
+++ b/README.md
@@ -19,12 +19,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推
当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。
-桌面端支持从配套的提示词聚合站 [**Image-Prompts** · prompts.sorry.ink](https://prompts.sorry.ink) 一键导入提示词。Image-Prompts 是 Image-Studio 生态里专门做「Prompt 这一侧」的中英双语提示词聚合站,提示词详情页都带「Send to Image-Studio」按钮 —— 不登录也能用,签发一次性 token 后通过自定义协议跳转到桌面端。
-
-- macOS: 由 Wails 桌面版接收 `image-studio://import?...`
-- Windows / Linux: 由 Gio 客户端接收 `image-studio://import?...`
-
-桌面端拿到 token 后向站点回取 prompt payload(24h 过期、一次消费),弹出独立确认组件后写入当前 prompt 编辑器。仓库:[RoseKhlifa/Image-Prompts](https://github.com/RoseKhlifa/Image-Prompts)。
+**配套项目 [Image-Prompts](https://prompts.sorry.ink/) 提供提示词浏览与一键导入能力,支持把网页上的提示词直接送入 Image Studio 桌面端。相关说明见 [docs/prompt-import.md](./docs/prompt-import.md)。**
## 快速上手
@@ -67,6 +62,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推
| 源码构建、验证脚本、CI 产物链路 | [docs/build.md](./docs/build.md) |
| 真机 / 真实上游手工验证矩阵 | [docs/manual-verification.md](./docs/manual-verification.md) |
| 首次配置、API 形态选择、参数策略 | [docs/usage.md](./docs/usage.md) |
+| 配套项目 Image-Prompts 与提示词导入 | [docs/prompt-import.md](./docs/prompt-import.md) |
| 提 Issue 前自查、数据存储位置、524/504、模型权限、字段兼容问题 | [docs/troubleshooting.md](./docs/troubleshooting.md) |
| 仓库结构、前端分层、内核 / Worker / Android 关系 | [docs/project-structure.md](./docs/project-structure.md) |
| 原始提示词传递策略 | [docs/no-prompt-revision/README.md](./docs/no-prompt-revision/README.md) |
@@ -81,12 +77,6 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推
这意味着基于本项目进行修改后再分发,或将修改版作为网络服务提供给他人使用时,都需要按同一许可证公开对应源码。
-## 配套项目
-
-| 项目 | 角色 |
-|---|---|
-| [**Image-Prompts**](https://github.com/RoseKhlifa/Image-Prompts) · [prompts.sorry.ink](https://prompts.sorry.ink) | 中英双语 AI 提示词聚合站,Image-Studio 生态的「灵感源」一侧。社区投稿 + 公开档案融合,可检索 / 收藏 / 一键导入到 Image-Studio。详情页「Send to Image-Studio」会通过 `image-studio://` 深链把 prompt 直接送进本应用编辑器,**游客也可用** |
-
## 致谢
-
[**linux.do**](https://linux.do/) —— 感谢 L 站及其社区为项目开发与交流提供的支持与启发。
diff --git a/docs/prompt-import.md b/docs/prompt-import.md
new file mode 100644
index 000000000..5f48261b3
--- /dev/null
+++ b/docs/prompt-import.md
@@ -0,0 +1,25 @@
+# Image-Prompts 配套项目与导入说明
+
+[Image-Prompts](https://prompts.sorry.ink/) 是 Image Studio 的配套提示词站,支持从网页端一键导入提示词到桌面端。
+
+## 使用方式
+
+1. 在 Image-Prompts 的提示词详情页点击 `Send to Image-Studio`。
+2. 站点会通过 `image-studio://import?...` 拉起桌面端。
+3. 客户端拉取提示词内容后,会先弹出确认界面,再写入当前 prompt 编辑器。
+
+## 平台分流
+
+- macOS: 由 `image-studio/` 下的 Wails 桌面端接收导入请求。
+- Windows / Linux: 由 `gio-client/` 接收导入请求。
+
+## 导入约束
+
+- 当前导入能力仅面向桌面端,不包含 Android。
+- 站点签发的是一次性 token,默认 24 小时内有效,消费后失效。
+- Windows / Linux 下的协议注册、CLI 和实现细节见 [gio-client.md](./gio-client.md)。
+
+## 相关链接
+
+- 站点: [prompts.sorry.ink](https://prompts.sorry.ink/)
+- 仓库: [RoseKhlifa/Image-Prompts](https://github.com/RoseKhlifa/Image-Prompts)
diff --git a/gio-client/cmd/image-studio-gio/main.go b/gio-client/cmd/image-studio-gio/main.go
index b2a88203a..c86ac9509 100644
--- a/gio-client/cmd/image-studio-gio/main.go
+++ b/gio-client/cmd/image-studio-gio/main.go
@@ -22,6 +22,7 @@ func main() {
os.Exit(exitCode)
}
appUI := ui.New()
+ appUI.StartBackgroundAppUpdateCheck()
server, alreadyRunning, err := promptipc.TryStart(func(msg promptipc.Message) {
switch msg.Type {
case promptipc.MessageTypeRaise:
diff --git a/gio-client/internal/compat/state.go b/gio-client/internal/compat/state.go
index 69581d1ee..8753f5c73 100644
--- a/gio-client/internal/compat/state.go
+++ b/gio-client/internal/compat/state.go
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
+ "path/filepath"
"sort"
"strconv"
"strings"
@@ -151,6 +152,25 @@ func SetSavePromptSuppressed(value bool) error {
return SaveState(state)
}
+func RememberTrustedOutputRoot(state shared.State, root string) shared.State {
+ state = shared.Normalize(state)
+ root = strings.TrimSpace(root)
+ if root == "" {
+ return state
+ }
+ if abs, err := filepath.Abs(root); err == nil {
+ root = abs
+ }
+ root = filepath.Clean(root)
+ for _, existing := range state.Settings.TrustedOutputRoots {
+ if filepath.Clean(strings.TrimSpace(existing)) == root {
+ return state
+ }
+ }
+ state.Settings.TrustedOutputRoots = append(state.Settings.TrustedOutputRoots, root)
+ return state
+}
+
func UpsertConfig(state shared.State, cfg kernel.Config) shared.State {
state = shared.Normalize(state)
now := time.Now().UnixMilli()
@@ -208,6 +228,7 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State {
state.Settings.CompletionSound = &completionSound
state.Settings.OutputFormat = strings.TrimSpace(cfg.OutputFormat)
state.Settings.OutputDir = strings.TrimSpace(cfg.OutputDir)
+ state = RememberTrustedOutputRoot(state, state.Settings.OutputDir)
state.Settings.Background = strings.TrimSpace(cfg.Background)
state.Settings.InputFidelity = strings.TrimSpace(cfg.InputFidelity)
state.Settings.ImageStyle = strings.TrimSpace(cfg.ImageStyle)
diff --git a/gio-client/internal/compat/state_test.go b/gio-client/internal/compat/state_test.go
index ba5561549..618161f20 100644
--- a/gio-client/internal/compat/state_test.go
+++ b/gio-client/internal/compat/state_test.go
@@ -1,6 +1,7 @@
package compat
import (
+ "path/filepath"
"testing"
"image-studio/gio-client/internal/kernel"
@@ -103,6 +104,9 @@ func TestUpsertConfigPreservesActiveProfileIdentity(t *testing.T) {
if next.Settings.OutputFormat != "jpeg" || next.Settings.OutputDir != "/tmp/images" || next.Settings.ProxyMode != client.ProxyModeNone {
t.Fatalf("settings not updated: %#v", next.Settings)
}
+ if len(next.Settings.TrustedOutputRoots) != 1 || next.Settings.TrustedOutputRoots[0] != filepath.Clean("/tmp/images") {
+ t.Fatalf("trusted output roots not updated: %#v", next.Settings.TrustedOutputRoots)
+ }
if next.Settings.CompletionSound == nil || !next.Settings.CompletionSound.Enabled || next.Settings.CompletionSound.Mode != "custom" || next.Settings.CompletionSound.CustomName != "done.wav" || next.Settings.CompletionSound.CustomData != "data:audio/wav;base64,BB==" {
t.Fatalf("completion sound not updated: %#v", next.Settings.CompletionSound)
}
@@ -141,3 +145,15 @@ func TestHistoryItemFromRunUsesWebViewCompatibleFields(t *testing.T) {
t.Fatalf("source paths not mapped: %#v", item.SourcePaths)
}
}
+
+func TestRememberTrustedOutputRootDeduplicatesAndNormalizes(t *testing.T) {
+ state := RememberTrustedOutputRoot(shared.State{}, "./tmp/out")
+ if len(state.Settings.TrustedOutputRoots) != 1 {
+ t.Fatalf("trusted output roots len=%d want 1", len(state.Settings.TrustedOutputRoots))
+ }
+ first := state.Settings.TrustedOutputRoots[0]
+ state = RememberTrustedOutputRoot(state, first)
+ if len(state.Settings.TrustedOutputRoots) != 1 {
+ t.Fatalf("trusted output roots duplicated: %#v", state.Settings.TrustedOutputRoots)
+ }
+}
diff --git a/gio-client/internal/kernel/probe.go b/gio-client/internal/kernel/probe.go
index 1bcea499a..015648262 100644
--- a/gio-client/internal/kernel/probe.go
+++ b/gio-client/internal/kernel/probe.go
@@ -16,7 +16,18 @@ const probeUpstreamTimeout = 20 * time.Second
const probeUpstreamMaxBody = 1 << 20
type ProbeResult struct {
- ModelCount int
+ ModelCount int
+ Models []UpstreamModelDescriptor
+ ResponsesTransport string
+ ResponsesTransportOK bool
+ ResponsesTransportError string
+}
+
+type UpstreamModelDescriptor struct {
+ ID string
+ Object string
+ OwnedBy string
+ DisplayName string
}
func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) {
@@ -70,7 +81,12 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) {
}
var parsed struct {
- Data []json.RawMessage `json:"data"`
+ Data []struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ OwnedBy string `json:"owned_by"`
+ DisplayName string `json:"name"`
+ } `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return ProbeResult{}, fmt.Errorf("上游 /v1/models 返回的 JSON 无效: %w", err)
@@ -78,7 +94,39 @@ func ProbeUpstream(ctx context.Context, cfg Config) (ProbeResult, error) {
if parsed.Data == nil {
return ProbeResult{}, fmt.Errorf("上游 /v1/models 响应缺少 data 数组")
}
- return ProbeResult{ModelCount: len(parsed.Data)}, nil
+ models := make([]UpstreamModelDescriptor, 0, len(parsed.Data))
+ for _, item := range parsed.Data {
+ id := strings.TrimSpace(item.ID)
+ if id == "" {
+ continue
+ }
+ models = append(models, UpstreamModelDescriptor{
+ ID: id,
+ Object: strings.TrimSpace(item.Object),
+ OwnedBy: strings.TrimSpace(item.OwnedBy),
+ DisplayName: strings.TrimSpace(item.DisplayName),
+ })
+ }
+ result := ProbeResult{
+ ModelCount: len(parsed.Data),
+ Models: models,
+ }
+ if 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{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ Proxy: proxyConfig,
+ Model: client.NormalizeTextModel(cfg.TextModelID),
+ }); wsErr != nil {
+ result.ResponsesTransportOK = false
+ result.ResponsesTransportError = wsErr.Error()
+ return result, nil
+ }
+ result.ResponsesTransportOK = true
+ }
+ return result, nil
}
func summarizeProbeBody(body []byte) string {
diff --git a/gio-client/internal/kernel/runner.go b/gio-client/internal/kernel/runner.go
index 0d73bbbe4..de1cd4932 100644
--- a/gio-client/internal/kernel/runner.go
+++ b/gio-client/internal/kernel/runner.go
@@ -205,7 +205,7 @@ func (Runner) Run(ctx context.Context, cfg Config, cb Callbacks) (Result, error)
if idx > 0 {
nonNilLog(cb.Log)("主上游自动重试失败,切换到备用上游再试一次...")
}
- result, err := runSingleConfig(ctx, variant, cb, imagesDir, logDir)
+ result, err := runSingleConfig(ctx, variant, cb, imagesDir, logDir, idx)
if err == nil {
return result, nil
}
@@ -351,7 +351,7 @@ func normalizeRequestPolicy(policy client.RequestPolicy) client.RequestPolicy {
return client.RequestPolicyOpenAI
}
-func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir string, logDir string) (Result, error) {
+func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir string, logDir string, variantIndex int) (Result, error) {
proxy, err := client.NormalizeProxyConfig(cfg.ProxyMode, cfg.ProxyURL)
if err != nil {
return Result{}, err
@@ -397,6 +397,9 @@ func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir st
}
timestamp := time.Now().Format("20060102-150405")
+ if variantIndex > 0 {
+ timestamp = fmt.Sprintf("%s-fallback%d", timestamp, variantIndex)
+ }
result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial(
ctx,
transport,
diff --git a/gio-client/internal/kernel/runner_test.go b/gio-client/internal/kernel/runner_test.go
index 3c9fd3230..e541239f0 100644
--- a/gio-client/internal/kernel/runner_test.go
+++ b/gio-client/internal/kernel/runner_test.go
@@ -2,6 +2,7 @@ package kernel
import (
"context"
+ "encoding/base64"
"image"
"image/color"
"image/png"
@@ -9,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/gen2brain/avif"
@@ -148,6 +150,155 @@ func TestProbeUpstreamReturnsModelCount(t *testing.T) {
if result.ModelCount != 2 {
t.Fatalf("ModelCount=%d want 2", result.ModelCount)
}
+ if len(result.Models) != 2 || result.Models[0].ID != "gpt-5.5" || result.Models[1].ID != "gpt-image-2" {
+ t.Fatalf("Models=%v want parsed descriptors", result.Models)
+ }
+}
+
+func TestProbeUpstreamSkipsModelItemsWithoutID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.5"},{"id":""},{"owned_by":"x"}]}`))
+ }))
+ defer server.Close()
+
+ result, err := ProbeUpstream(context.Background(), Config{
+ APIKey: "sk-test",
+ BaseURL: server.URL,
+ })
+ if err != nil {
+ t.Fatalf("ProbeUpstream: %v", err)
+ }
+ if result.ModelCount != 3 {
+ t.Fatalf("ModelCount=%d want 3", result.ModelCount)
+ }
+ if len(result.Models) != 1 || result.Models[0].ID != "gpt-5.5" {
+ t.Fatalf("Models=%v want only non-empty ids", result.Models)
+ }
+}
+
+func TestProbeUpstreamReportsStructuredWebSocketProbeFailure(t *testing.T) {
+ var gotUpgrade bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/v1/models" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.5"}]}`))
+ return
+ }
+ if r.URL.Path == "/v1/responses" {
+ if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
+ gotUpgrade = true
+ }
+ http.Error(w, "ws unsupported", http.StatusBadRequest)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+
+ result, err := ProbeUpstream(context.Background(), Config{
+ APIKey: "sk-test",
+ BaseURL: server.URL,
+ APIMode: client.APIModeResponses,
+ ResponsesTransport: client.ResponsesTransportWebSocket,
+ })
+ if err != nil {
+ t.Fatalf("ProbeUpstream: %v", err)
+ }
+ if result.ModelCount != 1 {
+ t.Fatalf("ModelCount=%d want 1", result.ModelCount)
+ }
+ if result.ResponsesTransport != string(client.ResponsesTransportWebSocket) {
+ t.Fatalf("responsesTransport=%q want websocket", result.ResponsesTransport)
+ }
+ if result.ResponsesTransportOK {
+ t.Fatal("responsesTransportOK=true want false")
+ }
+ if strings.TrimSpace(result.ResponsesTransportError) == "" {
+ t.Fatal("responsesTransportError should not be empty")
+ }
+ if !gotUpgrade {
+ t.Fatal("expected websocket probe upgrade attempt")
+ }
+}
+
+func TestRunnerRunFallsBackToSecondaryProfileAfterPrimaryFailure(t *testing.T) {
+ finalB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfallback"))
+ primaryHits := 0
+ primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ primaryHits++
+ if r.URL.Path != "/v1/images/generations" {
+ t.Fatalf("unexpected primary path %s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(`{"error":{"message":"service unavailable"}}`))
+ }))
+ defer primary.Close()
+
+ fallbackHits := 0
+ fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fallbackHits++
+ if r.URL.Path != "/v1/images/generations" {
+ t.Fatalf("unexpected fallback path %s", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"b64_json":"` + finalB64 + `"}]}`))
+ }))
+ defer fallback.Close()
+
+ originalBackoff := client.RetryBackoffSeconds
+ client.RetryBackoffSeconds = 0
+ defer func() { client.RetryBackoffSeconds = originalBackoff }()
+
+ var logs []string
+ outDir := t.TempDir()
+ result, err := (Runner{}).Run(context.Background(), Config{
+ APIKey: "sk-primary",
+ BaseURL: primary.URL,
+ Prompt: "hello",
+ APIMode: client.APIModeImages,
+ OutputDir: outDir,
+ AutoRetryEnabled: false,
+ FallbackProfile: &FallbackProfileConfig{
+ APIKey: "sk-fallback",
+ BaseURL: fallback.URL,
+ APIMode: client.APIModeImages,
+ },
+ }, Callbacks{
+ Log: func(line string) {
+ logs = append(logs, line)
+ },
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if primaryHits != 1 {
+ t.Fatalf("primaryHits=%d want 1", primaryHits)
+ }
+ if fallbackHits != 1 {
+ t.Fatalf("fallbackHits=%d want 1", fallbackHits)
+ }
+ if result.SavedPath == "" || result.SourceEvent != "images_api" {
+ t.Fatalf("unexpected result: %#v", result)
+ }
+ foundSwitchLog := false
+ for _, line := range logs {
+ if line == "主上游自动重试失败,切换到备用上游再试一次..." {
+ foundSwitchLog = true
+ break
+ }
+ }
+ if !foundSwitchLog {
+ t.Fatalf("fallback switch log missing: %v", logs)
+ }
+ logEntries, err := os.ReadDir(filepath.Join(outDir, "log"))
+ if err != nil {
+ t.Fatalf("ReadDir(log): %v", err)
+ }
+ if len(logEntries) < 2 {
+ t.Fatalf("expected raw logs from primary and fallback attempts, got %d", len(logEntries))
+ }
}
func TestSaveThumbnailCreatesDownscaledPNG(t *testing.T) {
diff --git a/gio-client/internal/ui/app.go b/gio-client/internal/ui/app.go
index 9ff05c95e..a5adb223a 100644
--- a/gio-client/internal/ui/app.go
+++ b/gio-client/internal/ui/app.go
@@ -59,6 +59,7 @@ type snapshot struct {
TestingUpstream bool
SyncingCodexConfig bool
LastProbeSummary string
+ LastProbeModels []kernel.UpstreamModelDescriptor
ActivePromptGroup historyPromptGroup
ActiveResultDetail sharedCompat.HistoryItem
HistoryTimelineOpen bool
@@ -200,6 +201,7 @@ type App struct {
loopAutoSaveDirInput widget.Editor
batchInputDirInput widget.Editor
batchOutputDirInput widget.Editor
+ upstreamQuickImportInput widget.Editor
rawResponseViewerInput widget.Editor
historyQueryInput widget.Editor
historyTimelineQueryInput widget.Editor
@@ -322,8 +324,14 @@ type App struct {
optimizePromptButton widget.Clickable
testUpstreamButton widget.Clickable
settingsTestUpstreamButton widget.Clickable
+ loadUpstreamModelsButton widget.Clickable
settingsImagesCompatButton widget.Clickable
syncCodexConfigButton widget.Clickable
+ exportUpstreamConfigsButton widget.Clickable
+ importUpstreamConfigsButton widget.Clickable
+ openQuickImportUpstreamConfigsButton widget.Clickable
+ confirmQuickImportUpstreamConfigsButton widget.Clickable
+ closeQuickImportUpstreamConfigsButton widget.Clickable
historyTimelineModePickerButton widget.Clickable
historyTimelineDatePickerButton widget.Clickable
toggleAPIKeyMaskButton widget.Clickable
@@ -341,6 +349,8 @@ type App struct {
openGeneralUpstreamButton widget.Clickable
openGeneralOutputButton widget.Clickable
chooseGeneralOutputButton widget.Clickable
+ chooseGeneralLoopAutoSaveDirButton widget.Clickable
+ useGeneralLoopOutputDirButton widget.Clickable
chooseGeneralBatchInputButton widget.Clickable
chooseGeneralBatchFilesButton widget.Clickable
chooseGeneralBatchOutputButton widget.Clickable
@@ -350,6 +360,7 @@ type App struct {
exportGeneralHistoryButton widget.Clickable
importGeneralHistoryButton widget.Clickable
openGeneralAboutButton widget.Clickable
+ openGeneralUpdateButton widget.Clickable
openGeneralDiagnosticsDirButton widget.Clickable
openGeneralLastLowFPSSnapshotButton widget.Clickable
clearGeneralAPIKeyButton widget.Clickable
@@ -357,7 +368,11 @@ type App struct {
pruneGeneralHistoryButtons []widget.Clickable
openGeneralRepoButton widget.Clickable
openGeneralFeedbackButton widget.Clickable
+ dismissAppUpdateButton widget.Clickable
+ ignoreAppUpdateButton widget.Clickable
+ openAppUpdateButton widget.Clickable
closeAboutButton widget.Clickable
+ openAboutUpdateButton widget.Clickable
openAboutRepoButton widget.Clickable
openAboutFeedbackButton widget.Clickable
openAboutLicenseButton widget.Clickable
@@ -376,6 +391,7 @@ type App struct {
generalAutoRetryCountButtons []widget.Clickable
generalLoopButtons []widget.Clickable
generalLoopAutoSaveButtons []widget.Clickable
+ generalLoopPreviewButtons []widget.Clickable
generalBatchButtons []widget.Clickable
generalBatchRetryButtons []widget.Clickable
generalBatchAutoAspectButtons []widget.Clickable
@@ -460,6 +476,7 @@ type App struct {
syncingCodexConfig bool
processingImageTransform bool
lastProbeSummary string
+ lastProbeModels []kernel.UpstreamModelDescriptor
fullscreen bool
activeResultDetail sharedCompat.HistoryItem
result resultState
@@ -597,10 +614,15 @@ type App struct {
generalSettingsOpen bool
generalRuntimePickerOpen bool
aboutModalOpen bool
+ appUpdateChecking bool
+ appUpdateModalOpen bool
settingsModalOpen bool
settingsHelpOpen bool
+ upstreamQuickImportOpen bool
settingsSelectedProfileID string
apiKeyVisible bool
+ appUpdateInfo *appUpdateInfo
+ ignoredReleaseTag string
workspaces []workspaceState
activeWorkspaceID string
workspaceButtons map[string]*widget.Clickable
@@ -679,6 +701,7 @@ func New() *App {
completionNotificationPermission: readSystemNotificationPermission(),
cleanupPreviewCacheOnExit: compatState.Settings.CleanupPreviewCacheOnExit,
windowFocused: true,
+ ignoredReleaseTag: strings.TrimSpace(compatState.Settings.IgnoredReleaseTag),
batchCount: 1,
themeButtons: make([]widget.Clickable, 3),
generalThemeButtons: make([]widget.Clickable, 3),
@@ -695,6 +718,7 @@ func New() *App {
generalAutoRetryCountButtons: make([]widget.Clickable, 5),
generalLoopButtons: make([]widget.Clickable, 2),
generalLoopAutoSaveButtons: make([]widget.Clickable, 2),
+ generalLoopPreviewButtons: make([]widget.Clickable, 2),
generalBatchButtons: make([]widget.Clickable, 2),
generalBatchRetryButtons: make([]widget.Clickable, 2),
generalBatchAutoAspectButtons: make([]widget.Clickable, 2),
@@ -853,6 +877,8 @@ func (a *App) configureEditors(cfg kernel.Config) {
a.partialImagesInput.Filter = "0123456789"
a.outputCompressionInput.Filter = "0123456789"
a.concurrencyLimitInput.Filter = "0123456789"
+ a.loopTotalCountInput.Filter = "0123456789"
+ a.loopConcurrencyInput.Filter = "0123456789"
a.apiKeyInput.SetText(cfg.APIKey)
a.baseURLInput.SetText(cfg.BaseURL)
a.textModelInput.SetText(cfg.TextModelID)
@@ -862,6 +888,7 @@ func (a *App) configureEditors(cfg kernel.Config) {
a.outputCompressionInput.SetText(strconv.Itoa(cfg.OutputCompression))
a.proxyURLInput.SetText(cfg.ProxyURL)
a.userIdentifierInput.SetText(cfg.UserIdentifier)
+ a.syncLoopInputsFromState()
a.promptInput.SetText("")
}
diff --git a/gio-client/internal/ui/app_test.go b/gio-client/internal/ui/app_test.go
index 3f62674e4..18795f877 100644
--- a/gio-client/internal/ui/app_test.go
+++ b/gio-client/internal/ui/app_test.go
@@ -245,6 +245,70 @@ func TestCurrentConfigIncludesResponsesTransportAndReasoning(t *testing.T) {
}
}
+func TestStartPromptOptimizeBlocksWhenRemoteKernelCannotControlProxy(t *testing.T) {
+ app := &App{
+ kernelRuntimeMode: "remote",
+ proxy: client.ProxyModeCustom,
+ }
+ app.apiKeyInput.SetText("sk-test")
+ app.baseURLInput.SetText("https://example.com")
+ app.promptInput.SetText("hello")
+ app.textModelInput.SetText(client.TextModel)
+ app.proxyURLInput.SetText("http://127.0.0.1:7890")
+
+ app.startPromptOptimize()
+
+ if app.optimizingPrompt {
+ t.Fatal("startPromptOptimize should not enter optimizing state when remote kernel cannot control proxy")
+ }
+ if len(app.logs) == 0 || !strings.Contains(app.logs[len(app.logs)-1], "当前远程内核不能控制代理") {
+ t.Fatalf("unexpected logs: %v", app.logs)
+ }
+}
+
+func TestResolveFallbackProfileConfigRequiresKeyAndBaseURL(t *testing.T) {
+ state := sharedCompat.State{
+ Profiles: []sharedCompat.UpstreamProfile{
+ {
+ ID: "fallback-1",
+ Name: "备用",
+ APIMode: string(client.APIModeResponses),
+ ResponsesTransport: string(client.ResponsesTransportWebSocket),
+ RequestPolicy: string(client.RequestPolicyCompat),
+ BaseURL: "https://fallback.example",
+ TextModelID: "gpt-5.5",
+ ImageModelID: "gpt-image-2",
+ ReasoningEffort: "high",
+ },
+ },
+ }
+ got := resolveFallbackProfileConfig(state, "fallback-1", func(profileID string) (string, error) {
+ if profileID != "fallback-1" {
+ t.Fatalf("unexpected profileID %q", profileID)
+ }
+ return "sk-fallback", nil
+ })
+ if got == nil {
+ t.Fatal("expected fallback profile config")
+ }
+ if got.APIKey != "sk-fallback" || got.BaseURL != "https://fallback.example" || got.ResponsesTransport != client.ResponsesTransportWebSocket || got.RequestPolicy != client.RequestPolicyCompat || got.ReasoningEffort != "high" {
+ t.Fatalf("unexpected fallback config: %#v", got)
+ }
+
+ if got := resolveFallbackProfileConfig(state, "fallback-1", func(string) (string, error) {
+ return "", nil
+ }); got != nil {
+ t.Fatalf("expected nil fallback when key missing, got %#v", got)
+ }
+
+ state.Profiles[0].BaseURL = ""
+ if got := resolveFallbackProfileConfig(state, "fallback-1", func(string) (string, error) {
+ return "sk-fallback", nil
+ }); got != nil {
+ t.Fatalf("expected nil fallback when baseURL missing, got %#v", got)
+ }
+}
+
func TestApplyHistoryThumbBackfillUpdatesInMemoryState(t *testing.T) {
app := &App{}
item := sharedCompat.HistoryItem{ID: "hist-1", SavedPath: "/tmp/full.png"}
diff --git a/gio-client/internal/ui/app_update.go b/gio-client/internal/ui/app_update.go
new file mode 100644
index 000000000..373cf4b3f
--- /dev/null
+++ b/gio-client/internal/ui/app_update.go
@@ -0,0 +1,366 @@
+package ui
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ gioCompat "image-studio/gio-client/internal/compat"
+ sharedCompat "image-studio/shared/compat"
+
+ "github.com/yuanhua/image-gptcodex/pkg/client"
+)
+
+const (
+ defaultAppVersion = "0.1.5"
+ releasesPageURL = "https://github.com/RoseKhlifa/Image-Studio/releases"
+ latestReleaseAPIURL = "https://api.github.com/repos/RoseKhlifa/Image-Studio/releases/latest"
+ latestReleaseAPIURLEnv = "IMAGE_STUDIO_LATEST_RELEASE_API_URL"
+ latestReleaseAPIVersion = "2022-11-28"
+ appUpdateRequestTimeout = 8 * time.Second
+)
+
+var appUpdateSemverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
+
+type appUpdateInfo struct {
+ CurrentVersion string
+ LatestVersion string
+ ReleaseTag string
+ ReleaseName string
+ ReleaseURL string
+ PublishedAt string
+ Body string
+ HasUpdate bool
+}
+
+type githubLatestRelease struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ HTMLURL string `json:"html_url"`
+ PublishedAt string `json:"published_at"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+}
+
+func (a *App) StartBackgroundAppUpdateCheck() {
+ a.startAppUpdateCheck()
+}
+
+func (a *App) readAppUpdateState() (*appUpdateInfo, bool, bool, string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ var info *appUpdateInfo
+ if a.appUpdateInfo != nil {
+ copy := *a.appUpdateInfo
+ info = ©
+ }
+ return info, a.appUpdateModalOpen, a.appUpdateChecking, a.ignoredReleaseTag
+}
+
+func (a *App) startAppUpdateCheck() {
+ a.mu.Lock()
+ if a.appUpdateChecking {
+ a.mu.Unlock()
+ return
+ }
+ a.appUpdateChecking = true
+ a.mu.Unlock()
+
+ go func() {
+ info, err := checkForAppUpdate()
+ a.mu.Lock()
+ a.appUpdateChecking = false
+ if err != nil {
+ a.mu.Unlock()
+ return
+ }
+ a.appUpdateInfo = &info
+ a.appUpdateModalOpen = shouldShowAppUpdate(&info, a.ignoredReleaseTag)
+ a.mu.Unlock()
+ a.invalidateNow()
+ }()
+}
+
+func (a *App) dismissAppUpdateModal() {
+ a.mu.Lock()
+ a.appUpdateModalOpen = false
+ a.mu.Unlock()
+ a.invalidateNow()
+}
+
+func (a *App) ignoreAppUpdate(releaseTag string) {
+ releaseTag = strings.TrimSpace(releaseTag)
+ a.mu.Lock()
+ a.ignoredReleaseTag = releaseTag
+ a.appUpdateModalOpen = false
+ a.mu.Unlock()
+ if err := a.persistIgnoredReleaseTag(releaseTag); err != nil {
+ a.appendLog("保存忽略更新版本失败: " + err.Error())
+ return
+ }
+ a.appendLog("后续不再提示版本: " + releaseTag)
+ a.invalidateNow()
+}
+
+func (a *App) openAppUpdateRelease() {
+ info, _, _, _ := a.readAppUpdateState()
+ target := releasesPageURL
+ if info != nil && strings.TrimSpace(info.ReleaseURL) != "" {
+ target = strings.TrimSpace(info.ReleaseURL)
+ }
+ if err := openExternalURL(target); err != nil {
+ a.appendLog("打开更新页失败: " + err.Error())
+ }
+}
+
+func (a *App) persistIgnoredReleaseTag(value string) error {
+ state, _, err := gioCompat.LoadState()
+ if err != nil {
+ return err
+ }
+ state = sharedCompat.Normalize(state)
+ state.Settings.IgnoredReleaseTag = strings.TrimSpace(value)
+ state.UpdatedAt = time.Now().UnixMilli()
+ return gioCompat.SaveState(state)
+}
+
+func checkForAppUpdate() (appUpdateInfo, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), appUpdateRequestTimeout)
+ defer cancel()
+ apiURL := strings.TrimSpace(os.Getenv(latestReleaseAPIURLEnv))
+ if apiURL == "" {
+ apiURL = latestReleaseAPIURL
+ }
+ return checkForAppUpdateWithClient(ctx, &http.Client{Timeout: appUpdateRequestTimeout}, apiURL)
+}
+
+func checkForAppUpdateWithClient(ctx context.Context, httpClient *http.Client, apiURL string) (appUpdateInfo, error) {
+ currentVersion := currentDesktopAppVersion()
+ release, err := fetchLatestGitHubRelease(ctx, httpClient, apiURL)
+ if err != nil {
+ return appUpdateInfo{}, err
+ }
+ if release.Draft {
+ return appUpdateInfo{}, fmt.Errorf("latest release is still a draft")
+ }
+ latestVersion := normalizeReleaseVersion(release.TagName)
+ if latestVersion == "" {
+ return appUpdateInfo{}, fmt.Errorf("无法识别 release 版本: %q", release.TagName)
+ }
+ hasUpdate := compareSemver(latestVersion, currentVersion) > 0
+ if semverCore(latestVersion) != "" && semverCore(latestVersion) == semverCore(currentVersion) {
+ hasUpdate = false
+ }
+ return appUpdateInfo{
+ CurrentVersion: currentVersion,
+ LatestVersion: latestVersion,
+ ReleaseTag: strings.TrimSpace(release.TagName),
+ ReleaseName: strings.TrimSpace(release.Name),
+ ReleaseURL: chooseReleaseURL(strings.TrimSpace(release.HTMLURL)),
+ PublishedAt: strings.TrimSpace(release.PublishedAt),
+ Body: strings.TrimSpace(release.Body),
+ HasUpdate: hasUpdate,
+ }, nil
+}
+
+func shouldShowAppUpdate(info *appUpdateInfo, ignoredReleaseTag string) bool {
+ if info == nil || !info.HasUpdate {
+ return false
+ }
+ return strings.TrimSpace(info.ReleaseTag) != strings.TrimSpace(ignoredReleaseTag)
+}
+
+func fetchLatestGitHubRelease(ctx context.Context, httpClient *http.Client, apiURL string) (githubLatestRelease, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
+ if err != nil {
+ return githubLatestRelease{}, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", latestReleaseAPIVersion)
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return githubLatestRelease{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return githubLatestRelease{}, fmt.Errorf("GitHub releases 请求失败: %s %s", resp.Status, strings.TrimSpace(string(body)))
+ }
+ var release githubLatestRelease
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&release); err != nil {
+ return githubLatestRelease{}, err
+ }
+ return release, nil
+}
+
+func currentDesktopAppVersion() string {
+ if version := normalizeReleaseVersion(client.Version); version != "" {
+ return version
+ }
+ return defaultAppVersion
+}
+
+func normalizeReleaseVersion(input string) string {
+ value := strings.TrimSpace(input)
+ value = strings.TrimPrefix(value, "v")
+ value = strings.TrimPrefix(value, "V")
+ if value == "" || !appUpdateSemverPattern.MatchString(value) {
+ return ""
+ }
+ return value
+}
+
+func chooseReleaseURL(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return releasesPageURL
+ }
+ return raw
+}
+
+type parsedSemver struct {
+ major int
+ minor int
+ patch int
+ prerelease []string
+}
+
+func compareSemver(a string, b string) int {
+ pa, oka := parseSemver(a)
+ pb, okb := parseSemver(b)
+ if !oka && !okb {
+ return strings.Compare(a, b)
+ }
+ if !oka {
+ return -1
+ }
+ if !okb {
+ return 1
+ }
+ if pa.major != pb.major {
+ if pa.major > pb.major {
+ return 1
+ }
+ return -1
+ }
+ if pa.minor != pb.minor {
+ if pa.minor > pb.minor {
+ return 1
+ }
+ return -1
+ }
+ if pa.patch != pb.patch {
+ if pa.patch > pb.patch {
+ return 1
+ }
+ return -1
+ }
+ return compareSemverSuffix(pa.prerelease, pb.prerelease)
+}
+
+func parseSemver(input string) (parsedSemver, bool) {
+ value := normalizeReleaseVersion(input)
+ if value == "" {
+ return parsedSemver{}, false
+ }
+ core := value
+ prerelease := ""
+ if idx := strings.IndexByte(value, '+'); idx >= 0 {
+ core = value[:idx]
+ }
+ if idx := strings.IndexByte(core, '-'); idx >= 0 {
+ prerelease = core[idx+1:]
+ core = core[:idx]
+ }
+ parts := strings.Split(core, ".")
+ if len(parts) != 3 {
+ return parsedSemver{}, false
+ }
+ major, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return parsedSemver{}, false
+ }
+ minor, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return parsedSemver{}, false
+ }
+ patch, err := strconv.Atoi(parts[2])
+ if err != nil {
+ return parsedSemver{}, false
+ }
+ prereleaseParts := []string{}
+ if prerelease != "" {
+ prereleaseParts = strings.Split(prerelease, ".")
+ }
+ return parsedSemver{major: major, minor: minor, patch: patch, prerelease: prereleaseParts}, true
+}
+
+func compareSemverSuffix(a []string, b []string) int {
+ if len(a) == 0 && len(b) == 0 {
+ return 0
+ }
+ if len(a) == 0 {
+ return 1
+ }
+ if len(b) == 0 {
+ return -1
+ }
+ limit := len(a)
+ if len(b) > limit {
+ limit = len(b)
+ }
+ for i := 0; i < limit; i++ {
+ if i >= len(a) {
+ return -1
+ }
+ if i >= len(b) {
+ return 1
+ }
+ av := a[i]
+ bv := b[i]
+ ai, aerr := strconv.Atoi(av)
+ bi, berr := strconv.Atoi(bv)
+ if aerr == nil && berr == nil {
+ if ai > bi {
+ return 1
+ }
+ if ai < bi {
+ return -1
+ }
+ continue
+ }
+ if aerr == nil {
+ return -1
+ }
+ if berr == nil {
+ return 1
+ }
+ if av > bv {
+ return 1
+ }
+ if av < bv {
+ return -1
+ }
+ }
+ return 0
+}
+
+func semverCore(input string) string {
+ value := normalizeReleaseVersion(input)
+ if value == "" {
+ return ""
+ }
+ if idx := strings.IndexByte(value, '+'); idx >= 0 {
+ value = value[:idx]
+ }
+ if idx := strings.IndexByte(value, '-'); idx >= 0 {
+ value = value[:idx]
+ }
+ return value
+}
diff --git a/gio-client/internal/ui/app_update_test.go b/gio-client/internal/ui/app_update_test.go
new file mode 100644
index 000000000..623652d2a
--- /dev/null
+++ b/gio-client/internal/ui/app_update_test.go
@@ -0,0 +1,81 @@
+package ui
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/yuanhua/image-gptcodex/pkg/client"
+)
+
+func TestNormalizeReleaseVersion(t *testing.T) {
+ if got := normalizeReleaseVersion(" v1.1.6 "); got != "1.1.6" {
+ t.Fatalf("normalizeReleaseVersion() = %q, want 1.1.6", got)
+ }
+ if got := normalizeReleaseVersion("1.1.12-ci.37.1+f1b0c7428c17"); got != "1.1.12-ci.37.1+f1b0c7428c17" {
+ t.Fatalf("normalizeReleaseVersion() = %q, want ci version", got)
+ }
+ if got := normalizeReleaseVersion("release-1.1.6"); got != "" {
+ t.Fatalf("normalizeReleaseVersion() = %q, want empty", got)
+ }
+}
+
+func TestSemverCore(t *testing.T) {
+ if got := semverCore("v1.1.13"); got != "1.1.13" {
+ t.Fatalf("semverCore() = %q, want 1.1.13", got)
+ }
+ if got := semverCore("1.1.13-ci.49.1+4c4e3507d6ca"); got != "1.1.13" {
+ t.Fatalf("semverCore() = %q, want 1.1.13", got)
+ }
+}
+
+func TestCheckForAppUpdateDoesNotFlagSameCoreReleaseForCIBuild(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, `{
+ "tag_name":"v1.1.13",
+ "name":"v1.1.13",
+ "html_url":"https://example.com/releases/v1.1.13",
+ "published_at":"2026-06-07T00:00:00Z",
+ "body":"bugfixes",
+ "draft":false
+ }`)
+ }))
+ defer srv.Close()
+
+ original := client.Version
+ client.Version = "1.1.13-ci.49.1+4c4e3507d6ca"
+ t.Cleanup(func() { client.Version = original })
+
+ info, err := checkForAppUpdateWithClient(context.Background(), &http.Client{}, srv.URL)
+ if err != nil {
+ t.Fatalf("checkForAppUpdateWithClient() error = %v", err)
+ }
+ if info.HasUpdate {
+ t.Fatalf("HasUpdate = true, want false for same-core CI build vs release")
+ }
+ if info.CurrentVersion != "1.1.13-ci.49.1+4c4e3507d6ca" {
+ t.Fatalf("CurrentVersion = %q", info.CurrentVersion)
+ }
+ if info.LatestVersion != "1.1.13" {
+ t.Fatalf("LatestVersion = %q", info.LatestVersion)
+ }
+}
+
+func TestShouldShowAppUpdateHonoursIgnoredReleaseTag(t *testing.T) {
+ info := &appUpdateInfo{
+ ReleaseTag: "v1.1.13",
+ HasUpdate: true,
+ }
+ if shouldShowAppUpdate(info, "") != true {
+ t.Fatal("expected update to show when nothing is ignored")
+ }
+ if shouldShowAppUpdate(info, "v1.1.13") {
+ t.Fatal("expected ignored release tag to suppress update modal")
+ }
+ if shouldShowAppUpdate(&appUpdateInfo{HasUpdate: false}, "") {
+ t.Fatal("expected no modal when no update is available")
+ }
+}
diff --git a/gio-client/internal/ui/layout_about.go b/gio-client/internal/ui/layout_about.go
index f46b39ea0..b35518fec 100644
--- a/gio-client/internal/ui/layout_about.go
+++ b/gio-client/internal/ui/layout_about.go
@@ -2,6 +2,7 @@ package ui
import (
"image"
+ "strings"
"gioui.org/font"
"gioui.org/layout"
@@ -13,6 +14,17 @@ func (a *App) layoutAboutModal(gtx layout.Context) layout.Dimensions {
for a.closeAboutButton.Clicked(gtx) {
a.aboutModalOpen = false
}
+ for a.openAboutUpdateButton.Clicked(gtx) {
+ info, _, _, _ := a.readAppUpdateState()
+ if info != nil && info.HasUpdate && strings.TrimSpace(info.ReleaseTag) != strings.TrimSpace(a.ignoredReleaseTag) {
+ a.mu.Lock()
+ a.appUpdateModalOpen = true
+ a.mu.Unlock()
+ a.invalidateNow()
+ continue
+ }
+ a.openAppUpdateRelease()
+ }
for a.openAboutRepoButton.Clicked(gtx) {
if err := openExternalURL(repoURL); err != nil {
a.appendLog("打开 GitHub 失败: " + err.Error())
@@ -28,6 +40,17 @@ func (a *App) layoutAboutModal(gtx layout.Context) layout.Dimensions {
a.appendLog("打开 License 失败: " + err.Error())
}
}
+ info, _, checking, _ := a.readAppUpdateState()
+ updateSummary := "未发现更高版本"
+ if checking {
+ updateSummary = "检查更新中..."
+ } else if info != nil && info.HasUpdate {
+ updateSummary = "可升级到 v" + info.LatestVersion
+ }
+ updateButtonLabel := "查看更新"
+ if info == nil || (!info.HasUpdate && !checking) {
+ updateButtonLabel = "发布页"
+ }
return a.layoutStandardModal(
gtx,
@@ -80,6 +103,11 @@ func (a *App) layoutAboutModal(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return a.label(gtx, "Copyright © 2026 RoseKhlifa · 本程序按 GNU AGPL v3.0 发布,不提供任何担保。", unit.Sp(10), fluent.textDim, font.Normal)
}),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.helpInfoCard(gtx, "更新", "", func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, updateSummary, unit.Sp(11), fluent.text, font.Medium)
+ })
+ }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -112,7 +140,10 @@ func (a *App) layoutAboutModal(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
- return a.primaryIconTextButton(gtx, &a.openAboutRepoButton, uiIconLaunch, "GitHub 仓库", fluent.accent, fluent.white)
+ return a.primaryIconTextButton(gtx, &a.openAboutUpdateButton, uiIconLaunch, updateButtonLabel, fluent.accent, fluent.white)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactIconTextButton(gtx, &a.openAboutRepoButton, uiIconInfo, "GitHub 仓库", false)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return a.compactIconTextButton(gtx, &a.openAboutFeedbackButton, uiIconFeedback, "反馈", false)
diff --git a/gio-client/internal/ui/layout_app_update.go b/gio-client/internal/ui/layout_app_update.go
new file mode 100644
index 000000000..57a4e5022
--- /dev/null
+++ b/gio-client/internal/ui/layout_app_update.go
@@ -0,0 +1,84 @@
+package ui
+
+import (
+ "strings"
+
+ "gioui.org/font"
+ "gioui.org/layout"
+ "gioui.org/unit"
+)
+
+func (a *App) layoutAppUpdateModal(gtx layout.Context) layout.Dimensions {
+ info, open, _, _ := a.readAppUpdateState()
+ if !open || info == nil {
+ return layout.Dimensions{}
+ }
+ for a.dismissAppUpdateButton.Clicked(gtx) {
+ a.dismissAppUpdateModal()
+ }
+ for a.ignoreAppUpdateButton.Clicked(gtx) {
+ a.ignoreAppUpdate(info.ReleaseTag)
+ }
+ for a.openAppUpdateButton.Clicked(gtx) {
+ a.openAppUpdateRelease()
+ }
+ summary := strings.TrimSpace(info.Body)
+ if summary == "" {
+ summary = "GitHub Releases 已发布新版本。"
+ }
+ if len(summary) > 140 {
+ summary = summary[:140]
+ }
+ return a.layoutStandardModal(
+ gtx,
+ unit.Dp(460),
+ 0,
+ "发现新版本",
+ "",
+ &a.dismissAppUpdateButton,
+ func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(12))}.Layout(gtx,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.borderedSurface(gtx, fluent.accentSoft, fluentCardRadius, accentAlpha(0x22), func(gtx layout.Context) layout.Dimensions {
+ return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(4))}.Layout(gtx,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "v"+info.LatestVersion+" 已发布", unit.Sp(13), fluent.text, font.SemiBold)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ current := "当前版本 v" + info.CurrentVersion
+ if strings.TrimSpace(info.ReleaseName) != "" {
+ current += " · " + strings.TrimSpace(info.ReleaseName)
+ }
+ return a.label(gtx, current, unit.Sp(11), fluent.textDim, font.Normal)
+ }),
+ )
+ })
+ })
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.borderedSurface(gtx, fluent.surface2, fluentCardRadius, fluent.border, func(gtx layout.Context) layout.Dimensions {
+ return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, summary, unit.Sp(11), fluent.text, font.Normal)
+ })
+ })
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx,
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.primaryIconTextButton(gtx, &a.openAppUpdateButton, uiIconLaunch, "立即查看更新", fluent.accent, fluent.white)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return fixedWidth(gtx, unit.Dp(96), func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.dismissAppUpdateButton, "稍后再说", false)
+ })
+ }),
+ )
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.textActionButton(gtx, &a.ignoreAppUpdateButton, "不再提示这个版本", true)
+ }),
+ )
+ },
+ )
+}
diff --git a/gio-client/internal/ui/layout_controls.go b/gio-client/internal/ui/layout_controls.go
index 37b6afccd..0aa1045c3 100644
--- a/gio-client/internal/ui/layout_controls.go
+++ b/gio-client/internal/ui/layout_controls.go
@@ -721,26 +721,47 @@ func (a *App) layoutSettingsModal(gtx layout.Context, snap snapshot) layout.Dime
for a.settingsImagesCompatButton.Clicked(gtx) {
a.imagesNewAPICompat = !a.imagesNewAPICompat
}
+ for a.loadUpstreamModelsButton.Clicked(gtx) {
+ if !a.settingsDraftReady() {
+ continue
+ }
+ if err := a.testSettingsSelection(); err != nil {
+ a.appendLog("拉取模型列表失败: " + err.Error())
+ continue
+ }
+ }
for a.settingsTestUpstreamButton.Clicked(gtx) {
if !a.settingsDraftReady() {
continue
}
- if err := a.saveSettingsSelection(); err != nil {
+ if err := a.testSettingsSelection(); err != nil {
a.appendLog("保存配置失败: " + err.Error())
continue
}
- if strings.TrimSpace(a.settingsSelectedProfileID) != "" && a.settingsSelectedProfileID != a.activeProfileID {
- if err := a.activateStoredProfile(a.settingsSelectedProfileID); err != nil {
- a.appendLog("切换上游失败: " + err.Error())
- continue
- }
- }
- a.closeSettingsModal()
- a.startUpstreamProbe()
}
for a.syncCodexConfigButton.Clicked(gtx) {
a.startCodexConfigSync()
}
+ for a.exportUpstreamConfigsButton.Clicked(gtx) {
+ a.exportUpstreamConfigs()
+ }
+ for a.importUpstreamConfigsButton.Clicked(gtx) {
+ a.importUpstreamConfigsFromFile()
+ }
+ for a.openQuickImportUpstreamConfigsButton.Clicked(gtx) {
+ a.upstreamQuickImportOpen = true
+ }
+ for a.closeQuickImportUpstreamConfigsButton.Clicked(gtx) {
+ a.upstreamQuickImportOpen = false
+ }
+ for a.confirmQuickImportUpstreamConfigsButton.Clicked(gtx) {
+ if err := a.importUpstreamConfigsFromRaw(a.upstreamQuickImportInput.Text()); err != nil {
+ a.appendLog("快捷导入失败: " + err.Error())
+ continue
+ }
+ a.upstreamQuickImportInput.SetText("")
+ a.upstreamQuickImportOpen = false
+ }
for a.createProfileButton.Clicked(gtx) {
if err := a.createSettingsProfile(string(client.APIModeResponses)); err != nil {
a.appendLog("创建配置失败: " + err.Error())
@@ -1015,6 +1036,18 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
for a.generalLoopButtons[1].Clicked(gtx) {
a.loopEnabled = false
}
+ for a.generalLoopAutoSaveButtons[0].Clicked(gtx) {
+ a.setLoopAutoSaveEnabled(true)
+ }
+ for a.generalLoopAutoSaveButtons[1].Clicked(gtx) {
+ a.setLoopAutoSaveEnabled(false)
+ }
+ for a.generalLoopPreviewButtons[0].Clicked(gtx) {
+ a.loopLivePreview = true
+ }
+ for a.generalLoopPreviewButtons[1].Clicked(gtx) {
+ a.loopLivePreview = false
+ }
for a.generalBatchButtons[0].Clicked(gtx) {
a.batchMode = true
}
@@ -1077,6 +1110,12 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
for a.resetGeneralOutputButton.Clicked(gtx) {
a.outputDirInput.SetText(kernel.DefaultOutputDir())
}
+ for a.chooseGeneralLoopAutoSaveDirButton.Clicked(gtx) {
+ a.chooseLoopAutoSaveDir("选择循环自动另存为目录失败: ")
+ }
+ for a.useGeneralLoopOutputDirButton.Clicked(gtx) {
+ a.useCurrentOutputDirForLoopAutoSave()
+ }
for a.chooseGeneralBatchInputButton.Clicked(gtx) {
dir, err := chooseDirectory()
if err != nil {
@@ -1110,6 +1149,7 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
a.batchOutputDir = dir
}
}
+ a.syncLoopSettingsFromInputs()
for a.openGeneralHistoryTimelineButton.Clicked(gtx) {
a.closeGeneralSettingsModal()
a.openHistoryTimeline()
@@ -1161,6 +1201,17 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
a.appendLog("打开反馈页失败: " + err.Error())
}
}
+ for a.openGeneralUpdateButton.Clicked(gtx) {
+ info, _, _, _ := a.readAppUpdateState()
+ if info != nil && info.HasUpdate && strings.TrimSpace(info.ReleaseTag) != strings.TrimSpace(a.ignoredReleaseTag) {
+ a.mu.Lock()
+ a.appUpdateModalOpen = true
+ a.mu.Unlock()
+ a.invalidateNow()
+ continue
+ }
+ a.openAppUpdateRelease()
+ }
renderDiagnostics := formatRenderDiagnostics(snap)
partialPreview := strings.TrimSpace(a.partialImagesInput.Text())
if partialPreview == "" {
@@ -1394,6 +1445,22 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
return a.compactButton(gtx, &a.generalLoopButtons[1], "关闭", !a.loopEnabled)
}),
}
+ loopAutoSaveRows := []layout.FlexChild{
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.generalLoopAutoSaveButtons[0], "自动另存为", a.loopAutoSave)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.generalLoopAutoSaveButtons[1], "不自动保存", !a.loopAutoSave)
+ }),
+ }
+ loopPreviewRows := []layout.FlexChild{
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.generalLoopPreviewButtons[0], "实时预览开", a.loopLivePreview)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.generalLoopPreviewButtons[1], "实时预览关", !a.loopLivePreview)
+ }),
+ }
batchRows := []layout.FlexChild{
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return a.compactButton(gtx, &a.generalBatchButtons[0], "开启", a.batchMode)
@@ -1626,6 +1693,15 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
},
func(gtx layout.Context) layout.Dimensions {
return a.generalSettingsCard(gtx, "循环出图", func(gtx layout.Context) layout.Dimensions {
+ currentOutputDir := strings.TrimSpace(a.outputDirInput.Text())
+ loopAutoSaveSummary := "未开启自动另存为"
+ if a.loopAutoSave {
+ if dir := strings.TrimSpace(a.loopAutoSaveDirInput.Text()); dir != "" {
+ loopAutoSaveSummary = "当前复制目录: " + dir
+ } else {
+ loopAutoSaveSummary = "已开启自动另存为,目录为空时会沿用当前输出目录"
+ }
+ }
rows := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, loopRows...)
@@ -1641,7 +1717,33 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
- return a.label(gtx, "开启后提交按钮会按这里的总张数和并发持续补位生成。当前先对齐桌面主路径,不含自动另存为。", unit.Sp(10), fluent.textDim, font.Normal)
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, loopAutoSaveRows...)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.technicalField(gtx, "自动另存为目录", &a.loopAutoSaveDirInput, "留空 = 沿用当前输出目录", unit.Dp(42))
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx,
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactIconTextButton(gtx, &a.chooseGeneralLoopAutoSaveDirButton, uiIconFolder, "选择自动另存为目录", false)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ label := "使用当前输出目录"
+ if currentOutputDir == "" {
+ label = "当前输出目录未设置"
+ }
+ return a.compactIconTextButton(gtx, &a.useGeneralLoopOutputDirButton, uiIconSave, label, false)
+ }),
+ )
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, loopPreviewRows...)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "开启后会按这里的总张数和并发持续补位生成;常规出图张数只在普通模式下生效。"+loopAutoSaveSummary+"。", unit.Sp(10), fluent.textDim, font.Normal)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "关闭实时预览后不会在画布显示过程图,可以明显降低内存占用。", unit.Sp(10), fluent.textDim, font.Normal)
}),
}
return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, rows...)
@@ -1947,6 +2049,13 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
},
func(gtx layout.Context) layout.Dimensions {
return a.generalSettingsCard(gtx, "支持与反馈", func(gtx layout.Context) layout.Dimensions {
+ info, _, checking, _ := a.readAppUpdateState()
+ updateLabel := "更新"
+ if checking {
+ updateLabel = "检查中..."
+ } else if info != nil && info.HasUpdate {
+ updateLabel = "更新 v" + info.LatestVersion
+ }
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, "代码仓库、问题反馈和更新记录都以 GitHub 为准。", unit.Sp(10), fluent.textDim, font.Normal)
@@ -1954,7 +2063,7 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
- return a.compactIconTextButton(gtx, &a.openGeneralRepoButton, uiIconLaunch, "GitHub", false)
+ return a.compactIconTextButton(gtx, &a.openGeneralUpdateButton, uiIconLaunch, updateLabel, false)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return a.compactIconTextButton(gtx, &a.openGeneralFeedbackButton, uiIconFeedback, "反馈", false)
@@ -2458,6 +2567,19 @@ func (a *App) layoutSettingsProfileRail(gtx layout.Context, snap snapshot) layou
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.compactIconTextButton(gtx, &a.syncCodexConfigButton, uiIconRefresh, label, false)
}),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx,
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactIconTextButton(gtx, &a.exportUpstreamConfigsButton, uiIconSave, "导出", false)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.compactIconTextButton(gtx, &a.importUpstreamConfigsButton, uiIconFolder, "导入文件", false)
+ }),
+ )
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.compactIconTextButton(gtx, &a.openQuickImportUpstreamConfigsButton, uiIconEdit, "粘贴 JSON 快捷导入", false)
+ }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -2567,10 +2689,44 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout
runtimeSection := func(gtx layout.Context) layout.Dimensions {
rows := []layout.FlexChild{}
canSave := strings.TrimSpace(a.baseURLInput.Text()) != "" && strings.TrimSpace(a.apiKeyInput.Text()) != ""
+ probeTextModels, probeImageModels := preferredProbeModels(a.api, snap.LastProbeModels)
+ rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ countLabel := "尚未加载"
+ if len(snap.LastProbeModels) > 0 {
+ countLabel = fmt.Sprintf("已识别 %d 个模型", len(snap.LastProbeModels))
+ }
+ if snap.TestingUpstream {
+ countLabel = "拉取中..."
+ }
+ return a.helpInfoCard(gtx, "上游模型列表", "", 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, countLabel, unit.Sp(11), fluent.text, font.Medium)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "通过宿主侧请求 /v1/models 获取模型列表,避免浏览器跨域或 WebView 差异影响结果。", unit.Sp(10), fluent.textDim, font.Normal)
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ label := "拉取并解析上游模型"
+ if snap.TestingUpstream {
+ label = "拉取中..."
+ }
+ return a.compactIconTextButton(gtx, &a.loadUpstreamModelsButton, uiIconRefresh, label, false)
+ }),
+ )
+ })
+ }))
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))
}))
+ 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) {
+ a.textModelInput.SetText(strings.TrimSpace(id))
+ })
+ }))
+ }
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return a.layoutSettingsOptionCards(gtx, "推理强度", reasoningEffortChoices, a.reasoningEffort, a.reasoningEffortButtons, 2, func(value string) {
a.reasoningEffort = normalizeReasoningEffort(value)
@@ -2588,6 +2744,13 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout
return a.technicalField(gtx, "并发数量限制", &a.concurrencyLimitInput, "留空 = 不限制", unit.Dp(40))
}),
)
+ if 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) {
+ a.imageModelInput.SetText(strings.TrimSpace(id))
+ })
+ }))
+ }
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -3549,10 +3712,10 @@ func (a *App) layoutLoopSection(gtx layout.Context) layout.Dimensions {
a.loopEnabled = false
}
for a.composeLoopAutoSaveButtons[0].Clicked(gtx) {
- a.loopAutoSave = true
+ a.setLoopAutoSaveEnabled(true)
}
for a.composeLoopAutoSaveButtons[1].Clicked(gtx) {
- a.loopAutoSave = false
+ a.setLoopAutoSaveEnabled(false)
}
for a.composeLoopPreviewButtons[0].Clicked(gtx) {
a.loopLivePreview = true
@@ -3561,26 +3724,21 @@ func (a *App) layoutLoopSection(gtx layout.Context) layout.Dimensions {
a.loopLivePreview = false
}
for a.chooseLoopAutoSaveDirButton.Clicked(gtx) {
- dir, err := chooseDirectory()
- if err != nil {
- a.appendLog("选择循环自动另存为目录失败: " + err.Error())
- } else if strings.TrimSpace(dir) != "" {
- a.loopAutoSaveDirInput.SetText(dir)
- a.loopAutoSave = true
- }
+ a.chooseLoopAutoSaveDir("选择循环自动另存为目录失败: ")
}
for idx, value := range []int{4, 8, 10, 20, 50} {
value := value
for a.composeLoopCountButtons[idx].Clicked(gtx) {
- a.loopTotalCount = value
+ a.setLoopTotalCount(value)
}
}
for idx, value := range []int{1, 2, 4, 8} {
value := value
for a.composeLoopConcurrencyButtons[idx].Clicked(gtx) {
- a.loopConcurrency = value
+ a.setLoopConcurrency(value)
}
}
+ a.syncLoopSettingsFromInputs()
loopRows := []layout.FlexChild{
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -4172,6 +4330,65 @@ func apiChoiceLabel(value string) string {
return choiceLabel(apiChoices, value)
}
+func (a *App) layoutProbeModelSuggestions(
+ gtx layout.Context,
+ title string,
+ prefix string,
+ models []kernel.UpstreamModelDescriptor,
+ selectedID string,
+ onSelect func(string),
+) layout.Dimensions {
+ if len(models) == 0 {
+ return layout.Dimensions{}
+ }
+ limit := len(models)
+ if limit > 8 {
+ limit = 8
+ }
+ rows := []layout.FlexChild{
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, title, unit.Sp(11), fluent.textMuted, font.SemiBold)
+ }),
+ }
+ for idx := 0; idx < limit; idx += 2 {
+ idx := idx
+ rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ children := make([]layout.FlexChild, 0, 2)
+ for col := 0; col < 2; col++ {
+ modelIndex := idx + col
+ if modelIndex >= limit {
+ children = append(children, layout.Flexed(1, layout.Spacer{}.Layout))
+ continue
+ }
+ model := models[modelIndex]
+ btn := a.settingsProfileButton("probe-model:" + prefix + ":" + model.ID)
+ for btn.Clicked(gtx) {
+ onSelect(model.ID)
+ }
+ active := strings.TrimSpace(selectedID) == strings.TrimSpace(model.ID)
+ children = append(children, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.surfaceButton(
+ gtx,
+ btn,
+ chooseColor(active, fluent.accentSoft, fluent.surface),
+ chooseColor(active, accentAlpha(0x18), fluent.surface2),
+ chooseColor(active, accentAlpha(0x24), fluent.border),
+ unit.Dp(8),
+ layout.Inset{Top: 8, Bottom: 8, Left: 10, Right: 10},
+ func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, probeModelLabel(model), unit.Sp(10), chooseColor(active, fluent.accent, fluent.text), font.Medium)
+ },
+ )
+ }))
+ }
+ return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, children...)
+ })
+ }))
+ }
+ return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, rows...)
+}
+
func policyChoiceLabel(value string) string {
return choiceLabel(policyChoices, value)
}
diff --git a/gio-client/internal/ui/layout_shell.go b/gio-client/internal/ui/layout_shell.go
index 7dda5c7fd..b3c4b5d0d 100644
--- a/gio-client/internal/ui/layout_shell.go
+++ b/gio-client/internal/ui/layout_shell.go
@@ -89,8 +89,14 @@ func (a *App) layout(gtx layout.Context) layout.Dimensions {
if a.aboutModalOpen {
a.layoutAboutModal(gtx)
}
+ if _, open, _, _ := a.readAppUpdateState(); open {
+ a.layoutAppUpdateModal(gtx)
+ }
if a.settingsModalOpen {
a.layoutSettingsModal(gtx, snap)
+ if a.upstreamQuickImportOpen {
+ a.layoutUpstreamQuickImportModal(gtx)
+ }
if a.settingsHelpOpen {
a.layoutSettingsHelpModal(gtx)
}
diff --git a/gio-client/internal/ui/layout_upstream_quick_import.go b/gio-client/internal/ui/layout_upstream_quick_import.go
new file mode 100644
index 000000000..d835c9e64
--- /dev/null
+++ b/gio-client/internal/ui/layout_upstream_quick_import.go
@@ -0,0 +1,62 @@
+package ui
+
+import (
+ "gioui.org/font"
+ "gioui.org/layout"
+ "gioui.org/unit"
+ "gioui.org/widget/material"
+)
+
+func (a *App) layoutUpstreamQuickImportModal(gtx layout.Context) layout.Dimensions {
+ return a.layoutStandardModal(
+ gtx,
+ unit.Dp(620),
+ unit.Dp(520),
+ "快捷导入上游配置",
+ "",
+ &a.closeQuickImportUpstreamConfigsButton,
+ func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(12))}.Layout(gtx,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "直接粘贴对方提供的 JSON 模板。当前支持本应用导出文件、newapi_channel_conn、OpenCode provider 配置。", unit.Sp(11), fluent.textDim, font.Normal)
+ }),
+ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
+ return a.borderedSurface(gtx, fluent.surface, fluentInputRadius, fluent.border, func(gtx layout.Context) layout.Dimensions {
+ return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ style := material.Editor(a.th, &a.upstreamQuickImportInput, "在这里粘贴 JSON...\n例如 {\"_type\":\"newapi_channel_conn\",...}")
+ style.Color = fluent.text
+ style.HintColor = fluent.textDim
+ style.SelectionColor = accentAlpha(0x3d)
+ style.TextSize = a.scaledSp(unit.Sp(12))
+ style.Font.Typeface = uiMonoTypeface
+ return style.Layout(gtx)
+ })
+ })
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return a.borderedSurface(gtx, fluent.accentSoft, fluentCardRadius, accentAlpha(0x22), func(gtx layout.Context) layout.Dimensions {
+ return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ return a.label(gtx, "导入后会直接生成可用 profile,并把 API Key 写入系统凭据存储。若模板里自带 /v1,会自动适配成站点根地址。", unit.Sp(10), fluent.accent, font.Normal)
+ })
+ })
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx,
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return fixedWidth(gtx, unit.Dp(96), func(gtx layout.Context) layout.Dimensions {
+ return a.compactButton(gtx, &a.closeQuickImportUpstreamConfigsButton, "取消", false)
+ })
+ }),
+ layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+ return fixedWidth(gtx, unit.Dp(120), func(gtx layout.Context) layout.Dimensions {
+ return a.primaryButton(gtx, &a.confirmQuickImportUpstreamConfigsButton, "立即导入", fluent.accent, fluent.white)
+ })
+ }),
+ )
+ })
+ }),
+ )
+ },
+ )
+}
diff --git a/gio-client/internal/ui/loop_settings.go b/gio-client/internal/ui/loop_settings.go
new file mode 100644
index 000000000..11497ecb2
--- /dev/null
+++ b/gio-client/internal/ui/loop_settings.go
@@ -0,0 +1,79 @@
+package ui
+
+import (
+ "strconv"
+ "strings"
+)
+
+func (a *App) setLoopTotalCount(value int) {
+ a.loopTotalCount = normalizeLoopGenerationCount(value)
+ a.loopTotalCountInput.SetText(strconv.Itoa(a.loopTotalCount))
+}
+
+func (a *App) setLoopConcurrency(value int) {
+ a.loopConcurrency = normalizeLoopGenerationConcurrency(value)
+ a.loopConcurrencyInput.SetText(strconv.Itoa(a.loopConcurrency))
+}
+
+func (a *App) syncLoopInputsFromState() {
+ a.loopTotalCount = normalizeLoopGenerationCount(a.loopTotalCount)
+ a.loopConcurrency = normalizeLoopGenerationConcurrency(a.loopConcurrency)
+ a.loopTotalCountInput.SetText(strconv.Itoa(a.loopTotalCount))
+ a.loopConcurrencyInput.SetText(strconv.Itoa(a.loopConcurrency))
+}
+
+func (a *App) syncLoopSettingsFromInputs() {
+ if raw := strings.TrimSpace(a.loopTotalCountInput.Text()); raw == "" {
+ a.loopTotalCount = defaultLoopGenerationCount
+ } else if value, err := strconv.Atoi(raw); err == nil {
+ normalized := normalizeLoopGenerationCount(value)
+ a.loopTotalCount = normalized
+ if normalizedText := strconv.Itoa(normalized); normalizedText != raw {
+ a.loopTotalCountInput.SetText(normalizedText)
+ }
+ }
+ if raw := strings.TrimSpace(a.loopConcurrencyInput.Text()); raw == "" {
+ a.loopConcurrency = defaultLoopGenerationConcurrency
+ } else if value, err := strconv.Atoi(raw); err == nil {
+ normalized := normalizeLoopGenerationConcurrency(value)
+ a.loopConcurrency = normalized
+ if normalizedText := strconv.Itoa(normalized); normalizedText != raw {
+ a.loopConcurrencyInput.SetText(normalizedText)
+ }
+ }
+}
+
+func (a *App) setLoopAutoSaveEnabled(enabled bool) {
+ a.loopAutoSave = enabled
+ if !enabled {
+ return
+ }
+ if strings.TrimSpace(a.loopAutoSaveDirInput.Text()) != "" {
+ return
+ }
+ if dir := strings.TrimSpace(a.outputDirInput.Text()); dir != "" {
+ a.loopAutoSaveDirInput.SetText(dir)
+ }
+}
+
+func (a *App) useCurrentOutputDirForLoopAutoSave() {
+ dir := strings.TrimSpace(a.outputDirInput.Text())
+ if dir == "" {
+ return
+ }
+ a.loopAutoSaveDirInput.SetText(dir)
+ a.loopAutoSave = true
+}
+
+func (a *App) chooseLoopAutoSaveDir(logPrefix string) {
+ dir, err := chooseDirectory()
+ if err != nil {
+ a.appendLog(logPrefix + err.Error())
+ return
+ }
+ if strings.TrimSpace(dir) == "" {
+ return
+ }
+ a.loopAutoSaveDirInput.SetText(dir)
+ a.loopAutoSave = true
+}
diff --git a/gio-client/internal/ui/loop_settings_test.go b/gio-client/internal/ui/loop_settings_test.go
new file mode 100644
index 000000000..ffc6fc51f
--- /dev/null
+++ b/gio-client/internal/ui/loop_settings_test.go
@@ -0,0 +1,67 @@
+package ui
+
+import "testing"
+
+func TestSyncLoopSettingsFromInputsParsesAndClamps(t *testing.T) {
+ app := &App{}
+ app.loopTotalCountInput.SetText("120")
+ app.loopConcurrencyInput.SetText("0")
+
+ app.syncLoopSettingsFromInputs()
+
+ if app.loopTotalCount != maxLoopGenerationCount {
+ t.Fatalf("loop total count=%d want %d", app.loopTotalCount, maxLoopGenerationCount)
+ }
+ if app.loopTotalCountInput.Text() != "99" {
+ t.Fatalf("loop total count input=%q want 99", app.loopTotalCountInput.Text())
+ }
+ if app.loopConcurrency != defaultLoopGenerationConcurrency {
+ t.Fatalf("loop concurrency=%d want %d", app.loopConcurrency, defaultLoopGenerationConcurrency)
+ }
+ if app.loopConcurrencyInput.Text() != "2" {
+ t.Fatalf("loop concurrency input=%q want 2", app.loopConcurrencyInput.Text())
+ }
+}
+
+func TestSetLoopAutoSaveEnabledUsesCurrentOutputDir(t *testing.T) {
+ app := &App{}
+ app.outputDirInput.SetText("/tmp/output")
+
+ app.setLoopAutoSaveEnabled(true)
+
+ if !app.loopAutoSave {
+ t.Fatal("loop auto save should be enabled")
+ }
+ if app.loopAutoSaveDirInput.Text() != "/tmp/output" {
+ t.Fatalf("loop auto save dir=%q want /tmp/output", app.loopAutoSaveDirInput.Text())
+ }
+}
+
+func TestApplyWorkspaceSyncsLoopEditors(t *testing.T) {
+ app := &App{}
+ app.applyWorkspace(workspaceState{
+ LoopEnabled: true,
+ LoopTotalCount: 20,
+ LoopConcurrency: 4,
+ LoopAutoSave: true,
+ LoopAutoSaveDir: "/tmp/loop",
+ LoopLivePreview: false,
+ SourcePathsText: "",
+ BatchResultIDs: nil,
+ ResultGridOpen: false,
+ SelectedHistoryID: "",
+ })
+
+ if app.loopTotalCountInput.Text() != "20" {
+ t.Fatalf("loop total count input=%q want 20", app.loopTotalCountInput.Text())
+ }
+ if app.loopConcurrencyInput.Text() != "4" {
+ t.Fatalf("loop concurrency input=%q want 4", app.loopConcurrencyInput.Text())
+ }
+ if app.loopAutoSaveDirInput.Text() != "/tmp/loop" {
+ t.Fatalf("loop auto save dir=%q want /tmp/loop", app.loopAutoSaveDirInput.Text())
+ }
+ if app.loopLivePreview {
+ t.Fatal("loop live preview should be disabled from workspace state")
+ }
+}
diff --git a/gio-client/internal/ui/run_job.go b/gio-client/internal/ui/run_job.go
index 633140684..13463e772 100644
--- a/gio-client/internal/ui/run_job.go
+++ b/gio-client/internal/ui/run_job.go
@@ -25,6 +25,7 @@ import (
)
func (a *App) startRun() {
+ a.syncLoopSettingsFromInputs()
cfg := a.currentConfig()
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.BaseURL) == "" {
return
@@ -53,9 +54,76 @@ func (a *App) startRun() {
}
total = len(batchSources)
}
+ if errMsg := validateKernelRuntimeForRun(a.kernelRuntimeMode, cfg); errMsg != "" {
+ a.appendLog(errMsg)
+ return
+ }
+ requiredConcurrency := requestedRunConcurrency(total, a.loopEnabled, a.loopConcurrency)
+ if limit := parseConcurrencyLimit(strings.TrimSpace(a.concurrencyLimitInput.Text())); limit > 0 && requiredConcurrency > limit {
+ a.appendLog(runConcurrencyLimitError(cfg.APIMode, limit, requiredConcurrency, a.batchMode, a.loopEnabled))
+ return
+ }
a.startRunWithConfig(cfg, total)
}
+func parseConcurrencyLimit(raw string) int {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return 0
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value <= 0 {
+ return 0
+ }
+ return value
+}
+
+func requestedRunConcurrency(total int, loopEnabled bool, loopConcurrency int) int {
+ total = normalizeBatchCount(total)
+ if loopEnabled {
+ concurrency := normalizeLoopGenerationConcurrency(loopConcurrency)
+ if concurrency > total {
+ concurrency = total
+ }
+ if concurrency < 1 {
+ concurrency = 1
+ }
+ return concurrency
+ }
+ if total < 1 {
+ return 1
+ }
+ return total
+}
+
+func validateKernelRuntimeForRun(kernelRuntimeMode string, cfg kernel.Config) string {
+ if normalizeKernelRuntimeMode(kernelRuntimeMode) != "remote" {
+ return ""
+ }
+ if cfg.ProxyMode != client.ProxyModeSystem {
+ return "当前远程内核不能控制代理,请切回本地内核或使用 Android 原生运行"
+ }
+ if cfg.APIMode == client.APIModeResponses && cfg.ResponsesTransport == client.ResponsesTransportWebSocket {
+ return "当前远程内核模式暂不支持 Responses WebSocket mode,请切回本地内核或关闭该开关。"
+ }
+ return ""
+}
+
+func runConcurrencyLimitError(apiMode client.APIMode, limit int, required int, batchMode bool, loopEnabled bool) string {
+ apiLabel := "Responses API"
+ if apiMode == client.APIModeImages {
+ apiLabel = "Images API"
+ }
+ switch {
+ case batchMode:
+ return fmt.Sprintf("%s 并发限制 %d,当前还可提交 %d 个,批处理并发需要 %d 个。", apiLabel, limit, limit, required)
+ case loopEnabled:
+ return fmt.Sprintf("%s 并发限制 %d,当前还可提交 %d 个,循环模式并发需要 %d 个。", apiLabel, limit, limit, required)
+ default:
+ return fmt.Sprintf("%s 并发限制 %d,当前还可提交 %d 个,本次需要 %d 个。", apiLabel, limit, limit, required)
+ }
+}
+
func (a *App) retryLastRun() {
a.mu.Lock()
cfg := a.lastRunConfig
diff --git a/gio-client/internal/ui/run_job_test.go b/gio-client/internal/ui/run_job_test.go
new file mode 100644
index 000000000..f512c0edb
--- /dev/null
+++ b/gio-client/internal/ui/run_job_test.go
@@ -0,0 +1,78 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/yuanhua/image-gptcodex/pkg/client"
+ "image-studio/gio-client/internal/kernel"
+)
+
+func TestRequestedRunConcurrencyUsesLoopSetting(t *testing.T) {
+ if got := requestedRunConcurrency(4, false, 0); got != 4 {
+ t.Fatalf("requestedRunConcurrency(batch)= %d want 4", got)
+ }
+ if got := requestedRunConcurrency(10, true, 3); got != 3 {
+ t.Fatalf("requestedRunConcurrency(loop)= %d want 3", got)
+ }
+ if got := requestedRunConcurrency(2, true, 9); got != 2 {
+ t.Fatalf("requestedRunConcurrency(loop clamped)= %d want 2", got)
+ }
+}
+
+func TestRunConcurrencyLimitErrorMatchesMode(t *testing.T) {
+ if got := runConcurrencyLimitError(client.APIModeResponses, 2, 4, false, false); got != "Responses API 并发限制 2,当前还可提交 2 个,本次需要 4 个。" {
+ t.Fatalf("regular error=%q", got)
+ }
+ if got := runConcurrencyLimitError(client.APIModeImages, 3, 5, true, false); got != "Images API 并发限制 3,当前还可提交 3 个,批处理并发需要 5 个。" {
+ t.Fatalf("batch error=%q", got)
+ }
+ if got := runConcurrencyLimitError(client.APIModeResponses, 1, 2, false, true); got != "Responses API 并发限制 1,当前还可提交 1 个,循环模式并发需要 2 个。" {
+ t.Fatalf("loop error=%q", got)
+ }
+}
+
+func TestValidateKernelRuntimeForRunMatchesRemoteConstraints(t *testing.T) {
+ if got := validateKernelRuntimeForRun("local", kernel.Config{
+ ProxyMode: client.ProxyModeCustom,
+ APIMode: client.APIModeResponses,
+ ResponsesTransport: client.ResponsesTransportWebSocket,
+ }); got != "" {
+ t.Fatalf("local mode should not block, got %q", got)
+ }
+ if got := validateKernelRuntimeForRun("remote", kernel.Config{
+ ProxyMode: client.ProxyModeCustom,
+ }); got != "当前远程内核不能控制代理,请切回本地内核或使用 Android 原生运行" {
+ t.Fatalf("proxy constraint=%q", got)
+ }
+ if got := validateKernelRuntimeForRun("remote", kernel.Config{
+ ProxyMode: client.ProxyModeSystem,
+ APIMode: client.APIModeResponses,
+ ResponsesTransport: client.ResponsesTransportWebSocket,
+ }); got != "当前远程内核模式暂不支持 Responses WebSocket mode,请切回本地内核或关闭该开关。" {
+ t.Fatalf("websocket constraint=%q", got)
+ }
+}
+
+func TestStartRunBlocksWhenConcurrencyLimitTooLow(t *testing.T) {
+ app := &App{
+ api: string(client.APIModeResponses),
+ batchCount: 4,
+ }
+ app.apiKeyInput.SetText("sk-test")
+ app.baseURLInput.SetText("https://example.com")
+ app.promptInput.SetText("hello")
+ app.concurrencyLimitInput.SetText("2")
+
+ app.startRun()
+
+ if app.isRunning() {
+ t.Fatal("startRun should not enter running state when concurrency limit is exceeded")
+ }
+ if len(app.logs) == 0 {
+ t.Fatal("expected concurrency limit warning log")
+ }
+ if !strings.Contains(app.logs[len(app.logs)-1], "并发限制 2") || !strings.Contains(app.logs[len(app.logs)-1], "本次需要 4 个") {
+ t.Fatalf("unexpected log: %q", app.logs[len(app.logs)-1])
+ }
+}
diff --git a/gio-client/internal/ui/state.go b/gio-client/internal/ui/state.go
index 5d0945095..cb0b5fe1a 100644
--- a/gio-client/internal/ui/state.go
+++ b/gio-client/internal/ui/state.go
@@ -8,6 +8,7 @@ import (
"gioui.org/widget"
"github.com/yuanhua/image-gptcodex/pkg/client"
gioCompat "image-studio/gio-client/internal/compat"
+ "image-studio/gio-client/internal/kernel"
sharedCompat "image-studio/shared/compat"
)
@@ -275,6 +276,7 @@ func (a *App) readSnapshot() snapshot {
promptTemplates := a.promptTemplates
presets := a.presets
todayCount := a.todayHistoryCountLocked()
+ lastProbeModels := append([]kernel.UpstreamModelDescriptor(nil), a.lastProbeModels...)
snap := snapshot{
Running: a.running,
ProcessingImageTransform: a.processingImageTransform,
@@ -299,6 +301,7 @@ func (a *App) readSnapshot() snapshot {
TestingUpstream: a.testingUpstream,
SyncingCodexConfig: a.syncingCodexConfig,
LastProbeSummary: a.lastProbeSummary,
+ LastProbeModels: lastProbeModels,
ActivePromptGroup: a.activePromptGroup,
ActiveResultDetail: a.activeResultDetail,
HistoryTimelineOpen: a.historyTimelineOpen,
@@ -430,7 +433,9 @@ func (a *App) persistGeneralSettings() error {
state.Settings.ReducedEffects = a.reducedEffects
state.Settings.ProxyURL = strings.TrimSpace(a.proxyURLInput.Text())
state.Settings.OutputDir = strings.TrimSpace(a.outputDirInput.Text())
+ state = gioCompat.RememberTrustedOutputRoot(state, state.Settings.OutputDir)
state.Settings.KeepLogs = a.keepLogs
+ state.Settings.IgnoredReleaseTag = strings.TrimSpace(a.ignoredReleaseTag)
state.Settings.UserIdentifier = strings.TrimSpace(a.userIdentifierInput.Text())
state.UpdatedAt = time.Now().UnixMilli()
if err := gioCompat.SaveState(state); err != nil {
diff --git a/gio-client/internal/ui/ui_state.go b/gio-client/internal/ui/ui_state.go
index e28c1701f..26d0f5bb7 100644
--- a/gio-client/internal/ui/ui_state.go
+++ b/gio-client/internal/ui/ui_state.go
@@ -73,6 +73,8 @@ type historyMediaBackfillUpdate struct {
PreviewPath string
}
+var probeUpstreamFunc = kernel.ProbeUpstream
+
func isMissingPreview(err error) bool {
return errors.Is(err, errMissingPreview)
}
@@ -1588,6 +1590,7 @@ func (a *App) restoreActiveRuntimeConfig(logErrors bool) error {
a.completionNotificationPermission = readSystemNotificationPermission()
a.keepLogs = state.Settings.KeepLogs
a.cleanupPreviewCacheOnExit = state.Settings.CleanupPreviewCacheOnExit
+ a.ignoredReleaseTag = strings.TrimSpace(state.Settings.IgnoredReleaseTag)
a.mu.Unlock()
a.apiKeyVisible = false
return nil
@@ -1661,6 +1664,7 @@ func (a *App) saveSettingsSelection() error {
}
state.Settings.ProxyURL = strings.TrimSpace(a.proxyURLInput.Text())
state.Settings.OutputDir = strings.TrimSpace(a.outputDirInput.Text())
+ state = gioCompat.RememberTrustedOutputRoot(state, state.Settings.OutputDir)
state.Settings.Background = strings.TrimSpace(a.background)
outputCompression := client.DefaultOutputCompression
if raw := strings.TrimSpace(a.outputCompressionInput.Text()); raw != "" {
@@ -3035,6 +3039,10 @@ func (a *App) startPromptOptimize() {
return
}
cfg := a.currentConfig()
+ if normalizeKernelRuntimeMode(a.kernelRuntimeMode) == "remote" && cfg.ProxyMode != client.ProxyModeSystem {
+ a.appendLog("优化提示词失败: 当前远程内核不能控制代理,请切回本地内核或使用 Android 原生运行")
+ return
+ }
a.mu.Lock()
if a.optimizingPrompt {
a.mu.Unlock()
@@ -3078,12 +3086,13 @@ func (a *App) startUpstreamProbe() {
}
a.testingUpstream = true
a.lastProbeSummary = ""
+ a.lastProbeModels = nil
a.appendLogLocked("开始测试上游连接")
a.mu.Unlock()
a.invalidateNow()
go func() {
- result, err := kernel.ProbeUpstream(context.Background(), cfg)
+ result, err := probeUpstreamFunc(context.Background(), cfg)
a.mu.Lock()
a.testingUpstream = false
if err != nil {
@@ -3092,13 +3101,31 @@ func (a *App) startUpstreamProbe() {
a.appendLog("上游测试失败: " + err.Error())
return
}
- a.lastProbeSummary = fmt.Sprintf("已连接 · %d models", result.ModelCount)
- a.appendLogLocked(fmt.Sprintf("上游测试成功: 发现 %d 个 models", result.ModelCount))
+ switch {
+ case result.ResponsesTransport == string(client.ResponsesTransportWebSocket) && !result.ResponsesTransportOK:
+ a.lastProbeSummary = fmt.Sprintf("已连接 · %d models · WebSocket 不可用", result.ModelCount)
+ a.appendLogLocked(fmt.Sprintf("上游测试成功: 发现 %d 个 models,但 Responses WebSocket 不可用: %s", result.ModelCount, strings.TrimSpace(result.ResponsesTransportError)))
+ case result.ResponsesTransport == string(client.ResponsesTransportWebSocket):
+ a.lastProbeSummary = fmt.Sprintf("已连接 · %d models · WebSocket 可用", result.ModelCount)
+ a.appendLogLocked(fmt.Sprintf("上游测试成功: 发现 %d 个 models,Responses WebSocket 可用", result.ModelCount))
+ default:
+ a.lastProbeSummary = fmt.Sprintf("已连接 · %d models", result.ModelCount)
+ a.appendLogLocked(fmt.Sprintf("上游测试成功: 发现 %d 个 models", result.ModelCount))
+ }
+ a.lastProbeModels = append([]kernel.UpstreamModelDescriptor(nil), result.Models...)
a.mu.Unlock()
a.invalidateNow()
}()
}
+func (a *App) testSettingsSelection() error {
+ if err := a.saveSettingsSelection(); err != nil {
+ return err
+ }
+ a.startUpstreamProbe()
+ return nil
+}
+
func (a *App) replaceCurrentResultWithPath(path string, sourceEvent string) error {
path = strings.TrimSpace(path)
if path == "" {
diff --git a/gio-client/internal/ui/ui_workspaces.go b/gio-client/internal/ui/ui_workspaces.go
index cce632230..5e9463f1c 100644
--- a/gio-client/internal/ui/ui_workspaces.go
+++ b/gio-client/internal/ui/ui_workspaces.go
@@ -154,6 +154,7 @@ func (a *App) applyWorkspace(ws workspaceState) {
a.loopEnabled = ws.LoopEnabled
a.loopTotalCount = normalizeLoopGenerationCount(ws.LoopTotalCount)
a.loopConcurrency = normalizeLoopGenerationConcurrency(ws.LoopConcurrency)
+ a.syncLoopInputsFromState()
a.loopAutoSave = ws.LoopAutoSave
a.loopAutoSaveDirInput.SetText(strings.TrimSpace(ws.LoopAutoSaveDir))
a.loopLivePreview = ws.LoopLivePreview
diff --git a/gio-client/internal/ui/upstream_config_transfer.go b/gio-client/internal/ui/upstream_config_transfer.go
new file mode 100644
index 000000000..bf0dc51da
--- /dev/null
+++ b/gio-client/internal/ui/upstream_config_transfer.go
@@ -0,0 +1,552 @@
+package ui
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ gioCompat "image-studio/gio-client/internal/compat"
+ "image-studio/gio-client/internal/kernel"
+ sharedCompat "image-studio/shared/compat"
+
+ "github.com/yuanhua/image-gptcodex/pkg/client"
+)
+
+type upstreamConfigExportProfile struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ APIMode string `json:"apiMode"`
+ ResponsesTransport string `json:"responsesTransport"`
+ RequestPolicy string `json:"requestPolicy"`
+ ImagesNewAPICompat bool `json:"imagesNewAPICompat"`
+ BaseURL string `json:"baseURL"`
+ TextModelID string `json:"textModelID"`
+ ImageModelID string `json:"imageModelID"`
+ ReasoningEffort string `json:"reasoningEffort"`
+ ConcurrencyLimit int `json:"concurrencyLimit"`
+ FallbackProfileID string `json:"fallbackProfileId,omitempty"`
+ CreatedAt int64 `json:"createdAt"`
+ LastUsedAt int64 `json:"lastUsedAt,omitempty"`
+ APIKey string `json:"apiKey,omitempty"`
+}
+
+type upstreamConfigExportFile struct {
+ Version int `json:"version"`
+ ExportedAt string `json:"exportedAt"`
+ ActiveProfileID string `json:"activeProfileId"`
+ Profiles []upstreamConfigExportProfile `json:"profiles"`
+}
+
+type parsedUpstreamConfigImport struct {
+ ActiveProfileID string
+ Profiles []upstreamConfigExportProfile
+}
+
+func numberFromAny(raw any) (int, bool) {
+ switch value := raw.(type) {
+ case float64:
+ return int(value), true
+ case float32:
+ return int(value), true
+ case int:
+ return value, true
+ case int64:
+ return int(value), true
+ case int32:
+ return int(value), true
+ case json.Number:
+ if parsed, err := value.Int64(); err == nil {
+ return int(parsed), true
+ }
+ }
+ return 0, false
+}
+
+func buildUpstreamConfigExportFile(state sharedCompat.State) (upstreamConfigExportFile, error) {
+ state = sharedCompat.Normalize(state)
+ profiles := make([]upstreamConfigExportProfile, 0, len(state.Profiles))
+ for _, profile := range state.Profiles {
+ apiKey, err := gioCompat.ReadAPIKey(profile.ID)
+ if err != nil {
+ return upstreamConfigExportFile{}, err
+ }
+ profiles = append(profiles, upstreamConfigExportProfile{
+ ID: profile.ID,
+ Name: strings.TrimSpace(profile.Name),
+ APIMode: normalizeProfileAPIMode(profile.APIMode),
+ ResponsesTransport: normalizeProfileResponsesTransport(profile.ResponsesTransport),
+ RequestPolicy: normalizeProfilePolicy(profile.RequestPolicy),
+ ImagesNewAPICompat: profile.ImagesNewAPICompat,
+ BaseURL: strings.TrimSpace(profile.BaseURL),
+ TextModelID: strings.TrimSpace(profile.TextModelID),
+ ImageModelID: strings.TrimSpace(profile.ImageModelID),
+ ReasoningEffort: normalizeReasoningEffort(profile.ReasoningEffort),
+ ConcurrencyLimit: profile.ConcurrencyLimit,
+ FallbackProfileID: strings.TrimSpace(profile.FallbackProfileID),
+ CreatedAt: profile.CreatedAt,
+ LastUsedAt: profile.LastUsedAt,
+ APIKey: strings.TrimSpace(apiKey),
+ })
+ }
+ return upstreamConfigExportFile{
+ Version: 1,
+ ExportedAt: time.Now().UTC().Format(time.RFC3339),
+ ActiveProfileID: strings.TrimSpace(state.ActiveProfile),
+ Profiles: profiles,
+ }, nil
+}
+
+func parseUpstreamConfigImportJSON(raw string) (parsedUpstreamConfigImport, error) {
+ raw = stripWrappedCodeFence(raw)
+ var anyJSON map[string]any
+ if err := json.Unmarshal([]byte(raw), &anyJSON); err != nil {
+ return parsedUpstreamConfigImport{}, err
+ }
+ if parsed, ok, err := parseNewAPIChannelConnTemplate(anyJSON); err != nil {
+ return parsedUpstreamConfigImport{}, err
+ } else if ok {
+ return parsed, nil
+ }
+ if parsed, ok, err := parseOpenCodeProviderTemplate(anyJSON); err != nil {
+ return parsedUpstreamConfigImport{}, err
+ } else if ok {
+ return parsed, nil
+ }
+ parsed, err := parseExportProfiles(anyJSON)
+ if err != nil {
+ return parsedUpstreamConfigImport{}, err
+ }
+ if len(parsed.Profiles) == 0 {
+ return parsedUpstreamConfigImport{}, fmt.Errorf("暂不支持这类 JSON。当前支持:本应用导出文件、newapi_channel_conn、OpenCode provider 配置")
+ }
+ return parsed, nil
+}
+
+func stripWrappedCodeFence(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if strings.HasPrefix(trimmed, "```") && strings.HasSuffix(trimmed, "```") {
+ trimmed = strings.TrimPrefix(trimmed, "```json")
+ trimmed = strings.TrimPrefix(trimmed, "```JSON")
+ trimmed = strings.TrimPrefix(trimmed, "```")
+ trimmed = strings.TrimSuffix(trimmed, "```")
+ }
+ return strings.TrimSpace(trimmed)
+}
+
+func parseExportProfiles(input map[string]any) (parsedUpstreamConfigImport, error) {
+ rawProfiles, _ := input["profiles"].([]any)
+ profiles := make([]upstreamConfigExportProfile, 0, len(rawProfiles))
+ for _, item := range rawProfiles {
+ profile, ok := parseExportProfile(item)
+ if ok {
+ profiles = append(profiles, profile)
+ }
+ }
+ activeProfileID, _ := input["activeProfileId"].(string)
+ return parsedUpstreamConfigImport{
+ ActiveProfileID: strings.TrimSpace(activeProfileID),
+ Profiles: profiles,
+ }, nil
+}
+
+func parseExportProfile(raw any) (upstreamConfigExportProfile, bool) {
+ source, ok := raw.(map[string]any)
+ if !ok {
+ return upstreamConfigExportProfile{}, false
+ }
+ id, _ := source["id"].(string)
+ name, _ := source["name"].(string)
+ if strings.TrimSpace(id) == "" || strings.TrimSpace(name) == "" {
+ return upstreamConfigExportProfile{}, false
+ }
+ createdAt := time.Now().UnixMilli()
+ if value, ok := numberFromAny(source["createdAt"]); ok {
+ createdAt = int64(value)
+ }
+ lastUsedAt := int64(0)
+ if value, ok := numberFromAny(source["lastUsedAt"]); ok {
+ lastUsedAt = int64(value)
+ }
+ concurrencyLimit := 0
+ if value, ok := numberFromAny(source["concurrencyLimit"]); ok && value > 0 {
+ concurrencyLimit = value
+ }
+ apiKey, _ := source["apiKey"].(string)
+ return upstreamConfigExportProfile{
+ ID: strings.TrimSpace(id),
+ Name: strings.TrimSpace(name),
+ APIMode: normalizeProfileAPIMode(stringValue(source["apiMode"])),
+ ResponsesTransport: normalizeProfileResponsesTransport(stringValue(source["responsesTransport"])),
+ RequestPolicy: normalizeProfilePolicy(stringValue(source["requestPolicy"])),
+ ImagesNewAPICompat: boolValue(source["imagesNewAPICompat"]),
+ BaseURL: normalizeImportedBaseURL(stringValue(source["baseURL"])),
+ TextModelID: strings.TrimSpace(stringValue(source["textModelID"])),
+ ImageModelID: strings.TrimSpace(stringValue(source["imageModelID"])),
+ ReasoningEffort: normalizeReasoningEffort(stringValue(source["reasoningEffort"])),
+ ConcurrencyLimit: concurrencyLimit,
+ FallbackProfileID: strings.TrimSpace(stringValue(source["fallbackProfileId"])),
+ CreatedAt: createdAt,
+ LastUsedAt: lastUsedAt,
+ APIKey: strings.TrimSpace(apiKey),
+ }, true
+}
+
+func parseNewAPIChannelConnTemplate(input map[string]any) (parsedUpstreamConfigImport, bool, error) {
+ if strings.TrimSpace(stringValue(input["_type"])) != "newapi_channel_conn" {
+ return parsedUpstreamConfigImport{}, false, nil
+ }
+ apiKey := strings.TrimSpace(stringValue(input["key"]))
+ baseURL := normalizeImportedBaseURL(stringValue(input["url"]))
+ if apiKey == "" || baseURL == "" {
+ return parsedUpstreamConfigImport{}, true, fmt.Errorf("newapi 模板缺少 key 或 url")
+ }
+ profileID := nextTemplateProfileID(1)
+ return parsedUpstreamConfigImport{
+ ActiveProfileID: profileID,
+ Profiles: []upstreamConfigExportProfile{{
+ ID: profileID,
+ Name: buildTemplateProfileName("NewAPI", baseURL, ""),
+ APIMode: string(client.APIModeResponses),
+ ResponsesTransport: string(client.ResponsesTransportSSE),
+ RequestPolicy: string(client.RequestPolicyOpenAI),
+ ImagesNewAPICompat: false,
+ BaseURL: baseURL,
+ TextModelID: "",
+ ImageModelID: "",
+ ReasoningEffort: client.DefaultReasoningEffort,
+ CreatedAt: time.Now().UnixMilli(),
+ APIKey: apiKey,
+ }},
+ }, true, nil
+}
+
+func parseOpenCodeProviderTemplate(input map[string]any) (parsedUpstreamConfigImport, bool, error) {
+ providerRoot, ok := input["provider"].(map[string]any)
+ if !ok {
+ return parsedUpstreamConfigImport{}, false, nil
+ }
+ profiles := make([]upstreamConfigExportProfile, 0, len(providerRoot))
+ index := 1
+ for providerName, rawProvider := range providerRoot {
+ provider, ok := rawProvider.(map[string]any)
+ if !ok {
+ continue
+ }
+ options, _ := provider["options"].(map[string]any)
+ apiKey := strings.TrimSpace(stringValue(options["apiKey"]))
+ baseURL := normalizeImportedBaseURL(firstString(options["baseURL"], options["baseUrl"]))
+ if apiKey == "" || baseURL == "" {
+ continue
+ }
+ modelCatalog := buildOpenCodeModelCatalog(provider["models"])
+ textModels, imageModels := preferredProbeModels(string(client.APIModeResponses), modelCatalog)
+ apiMode := string(client.APIModeResponses)
+ textModelID := ""
+ imageModelID := ""
+ if len(textModels) > 0 {
+ textModelID = textModels[0].ID
+ }
+ if len(imageModels) > 0 {
+ imageModelID = imageModels[0].ID
+ }
+ if len(textModels) == 0 && len(imageModels) > 0 {
+ apiMode = string(client.APIModeImages)
+ }
+ profiles = append(profiles, upstreamConfigExportProfile{
+ ID: nextTemplateProfileID(index),
+ Name: buildTemplateProfileName("OpenCode", baseURL, providerName),
+ APIMode: apiMode,
+ ResponsesTransport: string(client.ResponsesTransportSSE),
+ RequestPolicy: string(client.RequestPolicyOpenAI),
+ ImagesNewAPICompat: false,
+ BaseURL: baseURL,
+ TextModelID: textModelID,
+ ImageModelID: imageModelID,
+ ReasoningEffort: inferReasoningEffortFromOpenCodeModels(provider["models"], textModelID),
+ CreatedAt: time.Now().UnixMilli(),
+ APIKey: apiKey,
+ })
+ index++
+ }
+ if len(profiles) == 0 {
+ return parsedUpstreamConfigImport{}, true, fmt.Errorf("OpenCode 模板里没有可用的 provider 配置")
+ }
+ return parsedUpstreamConfigImport{
+ ActiveProfileID: profiles[0].ID,
+ Profiles: profiles,
+ }, true, nil
+}
+
+func buildOpenCodeModelCatalog(raw any) []kernel.UpstreamModelDescriptor {
+ models, _ := raw.(map[string]any)
+ out := make([]kernel.UpstreamModelDescriptor, 0, len(models))
+ for id, rawModel := range models {
+ model, _ := rawModel.(map[string]any)
+ out = append(out, kernel.UpstreamModelDescriptor{
+ ID: strings.TrimSpace(id),
+ DisplayName: strings.TrimSpace(stringValue(model["name"])),
+ })
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ return out[i].ID < out[j].ID
+ })
+ return out
+}
+
+func inferReasoningEffortFromOpenCodeModels(raw any, textModelID string) string {
+ models, _ := raw.(map[string]any)
+ model, _ := models[textModelID].(map[string]any)
+ variants, _ := model["variants"].(map[string]any)
+ if len(variants) == 0 {
+ return client.DefaultReasoningEffort
+ }
+ for _, candidate := range []string{"xhigh", "high", "medium", "low"} {
+ if _, ok := variants[candidate]; ok {
+ return candidate
+ }
+ }
+ return client.DefaultReasoningEffort
+}
+
+func nextTemplateProfileID(index int) string {
+ return fmt.Sprintf("template-%d", index)
+}
+
+func buildTemplateProfileName(prefix string, baseURL string, providerName string) string {
+ host := baseURL
+ host = strings.TrimPrefix(host, "https://")
+ host = strings.TrimPrefix(host, "http://")
+ parts := []string{strings.TrimSpace(prefix), strings.TrimSpace(providerName), strings.TrimSpace(host)}
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ if len(out) == 0 {
+ return "快捷导入"
+ }
+ return strings.Join(out, " · ")
+}
+
+func normalizeImportedBaseURL(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ trimmed = strings.TrimRight(trimmed, "/")
+ if trimmed == "" {
+ return ""
+ }
+ return strings.TrimSuffix(trimmed, "/v1")
+}
+
+func stringValue(raw any) string {
+ value, _ := raw.(string)
+ return value
+}
+
+func firstString(values ...any) string {
+ for _, raw := range values {
+ if value := strings.TrimSpace(stringValue(raw)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func boolValue(raw any) bool {
+ value, _ := raw.(bool)
+ return value
+}
+
+func (a *App) applyParsedUpstreamConfigImport(parsed parsedUpstreamConfigImport) (int, string, error) {
+ state, _, err := gioCompat.LoadState()
+ if err != nil {
+ return 0, "", err
+ }
+ state = sharedCompat.Normalize(state)
+ existingByName := map[string]int{}
+ for i, profile := range state.Profiles {
+ existingByName[strings.TrimSpace(profile.Name)] = i
+ }
+ originalToActualID := map[string]string{}
+ type fallbackLink struct {
+ actualID string
+ originalFallbackID string
+ }
+ links := make([]fallbackLink, 0, len(parsed.Profiles))
+ importedIDs := make([]string, 0, len(parsed.Profiles))
+ now := time.Now().UnixMilli()
+
+ for idx, incoming := range parsed.Profiles {
+ if existingIndex, ok := existingByName[strings.TrimSpace(incoming.Name)]; ok {
+ profile := state.Profiles[existingIndex]
+ profile.Name = strings.TrimSpace(incoming.Name)
+ profile.APIMode = normalizeProfileAPIMode(incoming.APIMode)
+ profile.ResponsesTransport = normalizeProfileResponsesTransport(incoming.ResponsesTransport)
+ profile.RequestPolicy = normalizeProfilePolicy(incoming.RequestPolicy)
+ profile.ImagesNewAPICompat = incoming.ImagesNewAPICompat
+ profile.BaseURL = normalizeImportedBaseURL(incoming.BaseURL)
+ profile.TextModelID = strings.TrimSpace(incoming.TextModelID)
+ profile.ImageModelID = strings.TrimSpace(incoming.ImageModelID)
+ profile.ReasoningEffort = normalizeReasoningEffort(incoming.ReasoningEffort)
+ profile.ConcurrencyLimit = incoming.ConcurrencyLimit
+ profile.LastUsedAt = incoming.LastUsedAt
+ state.Profiles[existingIndex] = profile
+ if strings.TrimSpace(incoming.APIKey) != "" {
+ _ = gioCompat.WriteAPIKey(profile.ID, strings.TrimSpace(incoming.APIKey))
+ }
+ originalToActualID[incoming.ID] = profile.ID
+ importedIDs = append(importedIDs, profile.ID)
+ links = append(links, fallbackLink{actualID: profile.ID, originalFallbackID: strings.TrimSpace(incoming.FallbackProfileID)})
+ continue
+ }
+
+ actualID := fmt.Sprintf("gio-import-%d-%d", now, idx+1)
+ profile := sharedCompat.UpstreamProfile{
+ ID: actualID,
+ Name: strings.TrimSpace(incoming.Name),
+ APIMode: normalizeProfileAPIMode(incoming.APIMode),
+ ResponsesTransport: normalizeProfileResponsesTransport(incoming.ResponsesTransport),
+ RequestPolicy: normalizeProfilePolicy(incoming.RequestPolicy),
+ ImagesNewAPICompat: incoming.ImagesNewAPICompat,
+ BaseURL: normalizeImportedBaseURL(incoming.BaseURL),
+ TextModelID: strings.TrimSpace(incoming.TextModelID),
+ ImageModelID: strings.TrimSpace(incoming.ImageModelID),
+ ReasoningEffort: normalizeReasoningEffort(incoming.ReasoningEffort),
+ ConcurrencyLimit: incoming.ConcurrencyLimit,
+ CreatedAt: now,
+ LastUsedAt: incoming.LastUsedAt,
+ }
+ state.Profiles = append(state.Profiles, profile)
+ if strings.TrimSpace(incoming.APIKey) != "" {
+ _ = gioCompat.WriteAPIKey(actualID, strings.TrimSpace(incoming.APIKey))
+ }
+ existingByName[profile.Name] = len(state.Profiles) - 1
+ originalToActualID[incoming.ID] = actualID
+ importedIDs = append(importedIDs, actualID)
+ links = append(links, fallbackLink{actualID: actualID, originalFallbackID: strings.TrimSpace(incoming.FallbackProfileID)})
+ }
+
+ for _, link := range links {
+ if strings.TrimSpace(link.originalFallbackID) == "" {
+ continue
+ }
+ resolved := originalToActualID[strings.TrimSpace(link.originalFallbackID)]
+ if resolved == "" {
+ for _, profile := range state.Profiles {
+ if profile.ID == strings.TrimSpace(link.originalFallbackID) {
+ resolved = profile.ID
+ break
+ }
+ }
+ }
+ if resolved == "" {
+ continue
+ }
+ for i := range state.Profiles {
+ if state.Profiles[i].ID == link.actualID {
+ state.Profiles[i].FallbackProfileID = resolved
+ break
+ }
+ }
+ }
+
+ activeProfileID := ""
+ if strings.TrimSpace(parsed.ActiveProfileID) != "" {
+ activeProfileID = originalToActualID[strings.TrimSpace(parsed.ActiveProfileID)]
+ }
+ if activeProfileID != "" {
+ state.ActiveProfile = activeProfileID
+ }
+ state.UpdatedAt = now
+ if err := gioCompat.SaveState(state); err != nil {
+ return 0, "", err
+ }
+ if activeProfileID != "" {
+ if err := a.restoreActiveRuntimeConfig(false); err != nil {
+ return 0, "", err
+ }
+ if err := a.loadSettingsProfileDraft(activeProfileID); err != nil {
+ return 0, "", err
+ }
+ } else if len(importedIDs) > 0 {
+ if err := a.loadSettingsProfileDraft(importedIDs[0]); err != nil {
+ return 0, "", err
+ }
+ }
+ a.mu.Lock()
+ a.setProfilesLocked(state.Profiles)
+ a.settingsSelectedProfileID = normalizeSettingsSelectedProfileID(state, activeProfileID)
+ a.mu.Unlock()
+ return len(parsed.Profiles), activeProfileID, nil
+}
+
+func (a *App) exportUpstreamConfigs() {
+ state, _, err := gioCompat.LoadState()
+ if err != nil {
+ a.appendLog("导出上游配置失败: " + err.Error())
+ return
+ }
+ state = sharedCompat.Normalize(state)
+ payload, err := buildUpstreamConfigExportFile(state)
+ if err != nil {
+ a.appendLog("导出上游配置失败: " + err.Error())
+ return
+ }
+ filename := fmt.Sprintf("image-studio-upstream-config-%s.json", time.Now().Format("20060102-150405"))
+ dst, err := chooseSaveJSONFile(filename)
+ if err != nil {
+ a.appendLog("选择导出文件失败: " + err.Error())
+ return
+ }
+ if strings.TrimSpace(dst) == "" {
+ return
+ }
+ data, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ a.appendLog("导出上游配置失败: " + err.Error())
+ return
+ }
+ if err := os.WriteFile(dst, data, 0o600); err != nil {
+ a.appendLog("写入上游配置导出文件失败: " + err.Error())
+ return
+ }
+ a.appendLog("已导出上游配置: " + filepath.Base(dst))
+}
+
+func (a *App) importUpstreamConfigsFromFile() {
+ src, err := chooseJSONFile()
+ if err != nil {
+ a.appendLog("选择上游配置文件失败: " + err.Error())
+ return
+ }
+ if strings.TrimSpace(src) == "" {
+ return
+ }
+ data, err := os.ReadFile(src)
+ if err != nil {
+ a.appendLog("读取上游配置文件失败: " + err.Error())
+ return
+ }
+ if err := a.importUpstreamConfigsFromRaw(string(data)); err != nil {
+ a.appendLog("导入上游配置失败: " + err.Error())
+ return
+ }
+ a.appendLog("已导入上游配置: " + filepath.Base(src))
+}
+
+func (a *App) importUpstreamConfigsFromRaw(raw string) error {
+ parsed, err := parseUpstreamConfigImportJSON(raw)
+ if err != nil {
+ return err
+ }
+ count, _, err := a.applyParsedUpstreamConfigImport(parsed)
+ if err != nil {
+ return err
+ }
+ a.appendLog(fmt.Sprintf("已导入 %d 个上游配置", count))
+ return nil
+}
diff --git a/gio-client/internal/ui/upstream_config_transfer_test.go b/gio-client/internal/ui/upstream_config_transfer_test.go
new file mode 100644
index 000000000..f8abcd31b
--- /dev/null
+++ b/gio-client/internal/ui/upstream_config_transfer_test.go
@@ -0,0 +1,63 @@
+package ui
+
+import "testing"
+
+func TestParseUpstreamConfigImportJSONParsesExportFile(t *testing.T) {
+ raw := `{
+ "version": 1,
+ "activeProfileId": "p1",
+ "profiles": [
+ {
+ "id": "p1",
+ "name": "Responses · 默认",
+ "apiMode": "responses",
+ "responsesTransport": "websocket",
+ "requestPolicy": "compat",
+ "imagesNewAPICompat": false,
+ "baseURL": "https://example.com/v1",
+ "textModelID": "gpt-5.5",
+ "imageModelID": "gpt-image-2",
+ "reasoningEffort": "high",
+ "concurrencyLimit": 3,
+ "fallbackProfileId": "p2",
+ "createdAt": 1,
+ "apiKey": "sk-test"
+ }
+ ]
+}`
+ parsed, err := parseUpstreamConfigImportJSON(raw)
+ if err != nil {
+ t.Fatalf("parseUpstreamConfigImportJSON: %v", err)
+ }
+ if parsed.ActiveProfileID != "p1" {
+ t.Fatalf("activeProfileID=%q want p1", parsed.ActiveProfileID)
+ }
+ if len(parsed.Profiles) != 1 {
+ t.Fatalf("profiles len=%d want 1", len(parsed.Profiles))
+ }
+ profile := parsed.Profiles[0]
+ if profile.BaseURL != "https://example.com" || profile.ResponsesTransport != "websocket" || profile.RequestPolicy != "compat" || profile.ConcurrencyLimit != 3 || profile.FallbackProfileID != "p2" || profile.APIKey != "sk-test" {
+ t.Fatalf("unexpected parsed profile: %#v", profile)
+ }
+}
+
+func TestParseUpstreamConfigImportJSONParsesNewAPIChannelConn(t *testing.T) {
+ raw := `{"_type":"newapi_channel_conn","url":"https://relay.example.com/v1","key":"sk-newapi"}`
+ parsed, err := parseUpstreamConfigImportJSON(raw)
+ if err != nil {
+ t.Fatalf("parseUpstreamConfigImportJSON: %v", err)
+ }
+ if len(parsed.Profiles) != 1 {
+ t.Fatalf("profiles len=%d want 1", len(parsed.Profiles))
+ }
+ profile := parsed.Profiles[0]
+ if profile.Name != "NewAPI · relay.example.com" {
+ t.Fatalf("name=%q want NewAPI · relay.example.com", profile.Name)
+ }
+ if profile.BaseURL != "https://relay.example.com" {
+ t.Fatalf("baseURL=%q want https://relay.example.com", profile.BaseURL)
+ }
+ if profile.APIKey != "sk-newapi" {
+ t.Fatalf("apiKey=%q want sk-newapi", profile.APIKey)
+ }
+}
diff --git a/gio-client/internal/ui/upstream_models.go b/gio-client/internal/ui/upstream_models.go
new file mode 100644
index 000000000..256965aee
--- /dev/null
+++ b/gio-client/internal/ui/upstream_models.go
@@ -0,0 +1,71 @@
+package ui
+
+import (
+ "strings"
+
+ "image-studio/gio-client/internal/kernel"
+)
+
+func uniqueProbeModels(input []kernel.UpstreamModelDescriptor) []kernel.UpstreamModelDescriptor {
+ seen := map[string]struct{}{}
+ out := make([]kernel.UpstreamModelDescriptor, 0, len(input))
+ for _, item := range input {
+ id := strings.TrimSpace(item.ID)
+ if id == "" {
+ continue
+ }
+ key := strings.ToLower(id)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, item)
+ }
+ return out
+}
+
+func probeModelLooksLikeImage(model kernel.UpstreamModelDescriptor) bool {
+ haystack := strings.ToLower(strings.TrimSpace(model.ID + " " + model.DisplayName + " " + model.Object + " " + model.OwnedBy))
+ return strings.Contains(haystack, "gpt-image") ||
+ strings.Contains(haystack, "image-") ||
+ strings.Contains(haystack, "images") ||
+ strings.Contains(haystack, "dall-e") ||
+ strings.Contains(haystack, "vision-image")
+}
+
+func probeModelLooksLikeText(model kernel.UpstreamModelDescriptor) bool {
+ return !probeModelLooksLikeImage(model)
+}
+
+func probeModelLabel(model kernel.UpstreamModelDescriptor) string {
+ display := strings.TrimSpace(model.DisplayName)
+ id := strings.TrimSpace(model.ID)
+ if display != "" && display != id {
+ return display + " (" + id + ")"
+ }
+ return id
+}
+
+func preferredProbeModels(apiMode string, models []kernel.UpstreamModelDescriptor) (text []kernel.UpstreamModelDescriptor, image []kernel.UpstreamModelDescriptor) {
+ all := uniqueProbeModels(models)
+ for _, model := range all {
+ if probeModelLooksLikeImage(model) {
+ image = append(image, model)
+ } else {
+ text = append(text, model)
+ }
+ }
+ if apiMode == "images" {
+ if len(image) == 0 {
+ image = all
+ }
+ return nil, image
+ }
+ if len(text) == 0 {
+ text = all
+ }
+ if len(image) == 0 {
+ image = all
+ }
+ return text, image
+}
diff --git a/gio-client/internal/ui/upstream_models_test.go b/gio-client/internal/ui/upstream_models_test.go
new file mode 100644
index 000000000..36c2c043d
--- /dev/null
+++ b/gio-client/internal/ui/upstream_models_test.go
@@ -0,0 +1,33 @@
+package ui
+
+import (
+ "testing"
+
+ "image-studio/gio-client/internal/kernel"
+)
+
+func TestPreferredProbeModelsSplitsTextAndImage(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(image) != 1 || image[0].ID != "gpt-image-2" {
+ t.Fatalf("image=%v want [gpt-image-2]", image)
+ }
+}
+
+func TestPreferredProbeModelsFallsBackToAllForImagesMode(t *testing.T) {
+ text, image := preferredProbeModels("images", []kernel.UpstreamModelDescriptor{
+ {ID: "custom-model"},
+ })
+ if text != nil {
+ t.Fatalf("text=%v want nil for images mode", text)
+ }
+ if len(image) != 1 || image[0].ID != "custom-model" {
+ t.Fatalf("image=%v want [custom-model]", image)
+ }
+}
diff --git a/image-studio/build/windows/installer/project.nsi b/image-studio/build/windows/installer/project.nsi
index 8c93da758..5a4152032 100644
--- a/image-studio/build/windows/installer/project.nsi
+++ b/image-studio/build/windows/installer/project.nsi
@@ -92,21 +92,7 @@ Section
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
-
- # Custom protocol registration -- intentionally NOT using
- # `wails.associateCustomProtocols` because Wails' upstream macro writes
- # the registry command as `$INSTDIR\.exe "%1"` without quoting the
- # exe path. Our default install dir contains spaces
- # (`C:\Program Files\YuanHua\Image Studio\`), so Windows ShellExecute
- # cannot locate the binary when a browser opens `image-studio://...`,
- # and the Image-Prompts page falls back to "Image-Studio not detected".
- # The CUSTOM_PROTOCOL_ASSOCIATE macro itself is fine -- we just pass it
- # a properly quoted COMMAND string.
- #
- # Protocol values are hardcoded because Go-template `{{range}}` syntax is
- # NOT processed in this file (see header note line 4 above) -- only
- # `wails_tools.nsh` gets templated by `wails build`. If we ever add more
- # schemes to wails.json, add another !insertmacro line here.
+ # Quote the exe path explicitly so protocol launch still works under Program Files.
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "image-studio" "Image-Prompts import" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$\"$INSTDIR\${PRODUCT_EXECUTABLE}$\" $\"%1$\""
!insertmacro wails.writeUninstaller