From 586268a82936e808b0e533334aab26916508c980 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Wed, 10 Jun 2026 14:25:16 +0800 Subject: [PATCH 1/4] Add Image-Prompts import docs and fix protocol quoting --- README.md | 8 +- docs/prompt-import.md | 25 + gio-client/cmd/image-studio-gio/main.go | 1 + gio-client/internal/compat/state.go | 21 + gio-client/internal/compat/state_test.go | 16 + gio-client/internal/kernel/probe.go | 54 +- gio-client/internal/kernel/runner.go | 7 +- gio-client/internal/kernel/runner_test.go | 151 +++++ gio-client/internal/ui/app.go | 27 + gio-client/internal/ui/app_test.go | 64 ++ gio-client/internal/ui/app_update.go | 366 ++++++++++++ gio-client/internal/ui/app_update_test.go | 81 +++ gio-client/internal/ui/layout_about.go | 33 +- gio-client/internal/ui/layout_app_update.go | 84 +++ gio-client/internal/ui/layout_controls.go | 261 ++++++++- gio-client/internal/ui/layout_shell.go | 6 + .../ui/layout_upstream_quick_import.go | 62 ++ gio-client/internal/ui/loop_settings.go | 79 +++ gio-client/internal/ui/loop_settings_test.go | 67 +++ gio-client/internal/ui/run_job.go | 68 +++ gio-client/internal/ui/run_job_test.go | 78 +++ gio-client/internal/ui/state.go | 5 + gio-client/internal/ui/ui_state.go | 33 +- gio-client/internal/ui/ui_workspaces.go | 1 + .../internal/ui/upstream_config_transfer.go | 552 ++++++++++++++++++ .../ui/upstream_config_transfer_test.go | 63 ++ gio-client/internal/ui/upstream_models.go | 71 +++ .../internal/ui/upstream_models_test.go | 33 ++ .../build/windows/installer/project.nsi | 3 +- 29 files changed, 2282 insertions(+), 38 deletions(-) create mode 100644 docs/prompt-import.md create mode 100644 gio-client/internal/ui/app_update.go create mode 100644 gio-client/internal/ui/app_update_test.go create mode 100644 gio-client/internal/ui/layout_app_update.go create mode 100644 gio-client/internal/ui/layout_upstream_quick_import.go create mode 100644 gio-client/internal/ui/loop_settings.go create mode 100644 gio-client/internal/ui/loop_settings_test.go create mode 100644 gio-client/internal/ui/run_job_test.go create mode 100644 gio-client/internal/ui/upstream_config_transfer.go create mode 100644 gio-client/internal/ui/upstream_config_transfer_test.go create mode 100644 gio-client/internal/ui/upstream_models.go create mode 100644 gio-client/internal/ui/upstream_models_test.go diff --git a/README.md b/README.md index 04daa27bd..48d178a76 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 -桌面端现在支持提示词网站深链导入: - -- macOS: 由 Wails 桌面版接收 `image-studio://import?...` -- Windows / Linux: 由 Gio 客户端接收 `image-studio://import?...` - -对应站点上的 `Send to Image-Studio` 会把一次性 token 交给桌面端,再由客户端拉取提示词并弹出独立确认组件。 +桌面端支持从配套提示词聚合站 [**Image-Prompts**](https://prompts.sorry.ink/) 一键导入提示词。相关导入说明见 [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) | diff --git a/docs/prompt-import.md b/docs/prompt-import.md new file mode 100644 index 000000000..e07ad927d --- /dev/null +++ b/docs/prompt-import.md @@ -0,0 +1,25 @@ +# Image-Prompts 导入说明 + +Image Studio 桌面端支持从配套提示词聚合站 [Image-Prompts](https://prompts.sorry.ink/) 一键导入提示词。 + +## 使用方式 + +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 654ae2e49..c44710aed 100644 --- a/image-studio/build/windows/installer/project.nsi +++ b/image-studio/build/windows/installer/project.nsi @@ -92,7 +92,8 @@ Section CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" !insertmacro wails.associateFiles - !insertmacro wails.associateCustomProtocols + # Wails writes an unquoted exe path here; Program Files installs break the protocol launch. + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "image-studio" "Image-Prompts import" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$\"$INSTDIR\${PRODUCT_EXECUTABLE}$\" $\"%1$\"" !insertmacro wails.writeUninstaller SectionEnd From 56b3cd9c81d6f9461583d34e395ff1d615ba9e0c Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Wed, 10 Jun 2026 14:36:16 +0800 Subject: [PATCH 2/4] docs: refine Image-Prompts companion wording --- README.md | 4 ++-- docs/prompt-import.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 48d178a76..b94935bb9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 -桌面端支持从配套提示词聚合站 [**Image-Prompts**](https://prompts.sorry.ink/) 一键导入提示词。相关导入说明见 [docs/prompt-import.md](./docs/prompt-import.md)。 +配套项目 [**Image-Prompts**](https://prompts.sorry.ink/) 提供提示词浏览与一键导入能力,支持把网页上的提示词直接送入 Image Studio 桌面端。相关说明见 [docs/prompt-import.md](./docs/prompt-import.md)。 ## 快速上手 @@ -62,7 +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) | +| 配套项目 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) | diff --git a/docs/prompt-import.md b/docs/prompt-import.md index e07ad927d..5f48261b3 100644 --- a/docs/prompt-import.md +++ b/docs/prompt-import.md @@ -1,6 +1,6 @@ -# Image-Prompts 导入说明 +# Image-Prompts 配套项目与导入说明 -Image Studio 桌面端支持从配套提示词聚合站 [Image-Prompts](https://prompts.sorry.ink/) 一键导入提示词。 +[Image-Prompts](https://prompts.sorry.ink/) 是 Image Studio 的配套提示词站,支持从网页端一键导入提示词到桌面端。 ## 使用方式 From 615595c41661aa871184858523eba2633cf1712a Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Wed, 10 Jun 2026 14:39:49 +0800 Subject: [PATCH 3/4] chore: trim protocol installer comment --- image-studio/build/windows/installer/project.nsi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/image-studio/build/windows/installer/project.nsi b/image-studio/build/windows/installer/project.nsi index c44710aed..5a4152032 100644 --- a/image-studio/build/windows/installer/project.nsi +++ b/image-studio/build/windows/installer/project.nsi @@ -92,7 +92,7 @@ Section CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" !insertmacro wails.associateFiles - # Wails writes an unquoted exe path here; Program Files installs break the protocol launch. + # 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 From 27fcb24cacb3457bac207621046ffc06df5e44ad Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Wed, 10 Jun 2026 14:47:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b94935bb9..3708d89d0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 -配套项目 [**Image-Prompts**](https://prompts.sorry.ink/) 提供提示词浏览与一键导入能力,支持把网页上的提示词直接送入 Image Studio 桌面端。相关说明见 [docs/prompt-import.md](./docs/prompt-import.md)。 +**配套项目 [Image-Prompts](https://prompts.sorry.ink/) 提供提示词浏览与一键导入能力,支持把网页上的提示词直接送入 Image Studio 桌面端。相关说明见 [docs/prompt-import.md](./docs/prompt-import.md)。** ## 快速上手