diff --git a/.github/workflows/windows-portable-fixed-webview.yml b/.github/workflows/windows-portable-fixed-webview.yml new file mode 100644 index 000000000..8044653ec --- /dev/null +++ b/.github/workflows/windows-portable-fixed-webview.yml @@ -0,0 +1,128 @@ +name: Windows Portable Fixed WebView2 + +on: + workflow_dispatch: + inputs: + base_version: + description: "基础版本号,例如 0.1.6。手动构建前必须填写。" + required: true + type: string + +permissions: + contents: read + +jobs: + prepare-version: + name: Prepare version metadata + runs-on: ubuntu-24.04 + outputs: + product_version: ${{ steps.version.outputs.product_version }} + app_version: ${{ steps.version.outputs.app_version }} + frontend_version: ${{ steps.version.outputs.frontend_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute version + id: version + shell: bash + env: + DISPATCH_BASE_VERSION: ${{ github.event.inputs.base_version }} + run: | + if [ -z "${DISPATCH_BASE_VERSION// }" ]; then + echo "base_version is required for manual portable builds" >&2 + exit 1 + fi + eval "$(./scripts/compute-version.sh "$DISPATCH_BASE_VERSION")" + { + echo "product_version=${PRODUCT_VERSION}" + echo "app_version=${APP_VERSION}" + echo "frontend_version=${FRONTEND_VERSION}" + } >> "$GITHUB_OUTPUT" + + build-portable: + name: Build ${{ matrix.id }} + runs-on: ${{ matrix.runner }} + needs: prepare-version + strategy: + fail-fast: false + matrix: + include: + - id: windows-amd64-portable-fixed-webview + runner: windows-latest + wails_args: -platform windows/amd64 -clean + portable_arch: x64 + artifact_suffix: windows-amd64-portable-fixed-webview.zip + - id: windows-arm64-portable-fixed-webview + runner: windows-11-arm + wails_args: -platform windows/arm64 -clean + portable_arch: arm64 + artifact_suffix: windows-arm64-portable-fixed-webview.zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + cache-dependency-path: image-studio/go.sum + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: image-studio/frontend/package-lock.json + + - name: Sync version metadata + shell: bash + env: + IMAGE_STUDIO_PRODUCT_VERSION: ${{ needs.prepare-version.outputs.product_version }} + IMAGE_STUDIO_FRONTEND_VERSION: ${{ needs.prepare-version.outputs.frontend_version }} + run: node ./scripts/sync-version-metadata.mjs + + - name: Install frontend dependencies + working-directory: image-studio/frontend + shell: bash + run: npm ci + + - name: Install Wails CLI + shell: bash + run: | + go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Build portable exe + working-directory: image-studio + shell: bash + env: + VITE_APP_VERSION: ${{ needs.prepare-version.outputs.app_version }} + run: | + wails build ${{ matrix.wails_args }} \ + -ldflags "-X github.com/yuanhua/image-gptcodex/pkg/client.Version=${{ needs.prepare-version.outputs.app_version }}" + + - name: Build portable fixed-webview zip + id: package + shell: pwsh + run: | + $artifactName = "image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.artifact_suffix }}" + $artifactPath = "dist/$artifactName" + ./scripts/build-windows-portable-fixed-webview.ps1 ` + -Architecture "${{ matrix.portable_arch }}" ` + -Version "${{ needs.prepare-version.outputs.app_version }}" ` + -BinaryPath "image-studio/build/bin/image-studio.exe" ` + -OutputZipPath $artifactPath + "artifact_name=$artifactName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_path=$artifactPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Upload portable artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.artifact_name }} + path: ${{ steps.package.outputs.artifact_path }} + retention-days: 14 diff --git a/.gitignore b/.gitignore index 8130e1404..fca72849e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /image-studio/build/darwin/build/ /image-studio/build/windows/build/ /image-studio/frontend/dist/ +/image-studio/frontend/dist-file-preview/ /image-studio/frontend/node_modules/ /android-shell/.gradle/ /android-shell/.gradle-wrapper/ diff --git a/README.md b/README.md index e8db30c53..04daa27bd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 +桌面端现在支持提示词网站深链导入: + +- macOS: 由 Wails 桌面版接收 `image-studio://import?...` +- Windows / Linux: 由 Gio 客户端接收 `image-studio://import?...` + +对应站点上的 `Send to Image-Studio` 会把一次性 token 交给桌面端,再由客户端拉取提示词并弹出独立确认组件。 + ## 快速上手 1. 安装应用 diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 15bd77365..65b2f8c21 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -1,9 +1,10 @@ import { + DEFAULT_AUTO_RETRY_COUNT, buildPromptOptimizePayload, buildResponsesPayload, describeProblem, isRetryableRaw, - MAX_ATTEMPTS, + normalizeAutoRetryCount, normalizeAPIMode, normalizeBaseURL, RETRY_BACKOFF_MS, @@ -61,13 +62,14 @@ async function forwardRawWithRetry({ method, headers, bodyBuffer, + maxAttempts, shouldRetry, }) { let lastRaw = ""; let lastStatus = 502; let lastContentType = "application/json; charset=utf-8"; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { const response = await fetch(upstreamURL, { method, headers, @@ -84,7 +86,7 @@ async function forwardRawWithRetry({ }, }); } - if (attempt < MAX_ATTEMPTS && shouldRetry(lastRaw, response.status)) { + if (attempt < maxAttempts && shouldRetry(lastRaw, response.status)) { await sleep(RETRY_BACKOFF_MS); continue; } @@ -100,6 +102,10 @@ async function forwardRawWithRetry({ }, { status: lastStatus || 502 }); } +function resolveMaxAttempts(autoRetryCount) { + return normalizeAutoRetryCount(autoRetryCount ?? DEFAULT_AUTO_RETRY_COUNT) + 1; +} + function sanitizePayload(input) { return { apiKey: String(input?.apiKey || ""), @@ -121,6 +127,7 @@ function sanitizePayload(input) { requestPolicy: input?.requestPolicy === "compat" ? "compat" : "openai", noPromptRevision: !!input?.noPromptRevision, partialImages: Number(input?.partialImages || 0), + autoRetryCount: Number(input?.autoRetryCount || 0), }; } @@ -145,8 +152,9 @@ async function forwardResponses(env, payload, apiKey) { const requestBody = buildResponsesPayload(payload, sourceDataURLs); let lastRaw = ""; let lastStatus = 502; + const maxAttempts = resolveMaxAttempts(payload.autoRetryCount); - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { const response = await fetch(`${upstreamBaseURL}/v1/responses`, { method: "POST", headers: { @@ -166,7 +174,7 @@ async function forwardResponses(env, payload, apiKey) { }, }); } - if (attempt < MAX_ATTEMPTS && isRetryableRaw(lastRaw)) { + if (attempt < maxAttempts && (isRetryableRaw(lastRaw) || [403, 502, 503, 504, 524].includes(response.status))) { await sleep(RETRY_BACKOFF_MS); continue; } @@ -195,12 +203,21 @@ async function forwardOpenAIPath(env, request, apiKey) { const bodyBuffer = request.method === "GET" || request.method === "HEAD" ? null : await request.arrayBuffer(); + let parsedBody = null; + if (bodyBuffer) { + try { + parsedBody = JSON.parse(new TextDecoder().decode(bodyBuffer)); + } catch { + parsedBody = null; + } + } return forwardRawWithRetry({ upstreamURL, method: request.method, headers: makeUpstreamHeaders(request, apiKey), bodyBuffer, - shouldRetry: (raw, status) => isRetryableRaw(raw) || [502, 503, 504, 524].includes(status), + maxAttempts: resolveMaxAttempts(parsedBody?.autoRetryCount), + shouldRetry: (raw, status) => isRetryableRaw(raw) || [403, 502, 503, 504, 524].includes(status), }); } diff --git a/docs/build.md b/docs/build.md index 0ce330361..7ffd1972f 100644 --- a/docs/build.md +++ b/docs/build.md @@ -85,6 +85,14 @@ cd image-studio wails build -platform windows/amd64 -clean ``` +如果你要生成“用户直接解压双击 `exe` 也能运行”的 Windows 便携包,不要复用普通 release 的裸 `exe`。仓库提供了独立 workflow: + +- GitHub Actions 工作流:`Windows Portable Fixed WebView2` +- 触发方式:手动 `workflow_dispatch` +- 产物:`image-studio--windows-amd64-portable-fixed-webview.zip` 与 `image-studio--windows-arm64-portable-fixed-webview.zip` + +这两个 zip 会把 `image-studio.exe` 和微软 Fixed Version WebView2 Runtime 一起打包,并在应用启动时优先使用同目录下的 `WebView2FixedRuntime/`。 + Linux Ubuntu 24.04 / Debian 新版本: ```bash diff --git a/docs/gio-client.md b/docs/gio-client.md index 2a68c875c..b8f444d24 100644 --- a/docs/gio-client.md +++ b/docs/gio-client.md @@ -1,6 +1,6 @@ # Gio 高性能测试客户端 -Gio 客户端是 Windows / Linux 的独立测试版本,目录为 `gio-client/`。它的目标是验证不经过 WebView2/WebKitGTK 的原生 Gio 渲染路径,同时保持当前请求内核不变。 +Gio 客户端是 Windows / Linux 的独立桌面版本,目录为 `gio-client/`。它继续承担原生 Gio 渲染路径验证,但现在也负责 Windows / Linux 下的 `image-studio://import?...` 网页导入协议入口。 ## 边界 @@ -9,6 +9,45 @@ Gio 客户端是 Windows / Linux 的独立测试版本,目录为 `gio-client/` - 复用 `go-cli/pkg/client` 的 Responses API、Images API、SSE、retry、proxy、模型默认值和请求字段策略。 - Gio 前端为新的 immediate-mode 架构,UI 结构沿用桌面端的控制面板、画布、历史记录和运行日志布局。 - GUI 入口仅面向 Windows / Linux;其他平台只编译 unsupported stub,避免误判为 macOS 支持。 +- 网页深链协议按平台分流: + - macOS 由 `image-studio/` Wails 客户端处理 + - Windows / Linux 由 `gio-client/` 处理 + +## 网页提示词导入协议 + +Image-Prompts 站点通过以下深链拉起桌面端: + +```text +image-studio://import?token=XXXXXXXX +``` + +Gio 端实现了: + +- 启动参数解析与运行中实例投递 +- `promptimport` 共享拉取逻辑 +- 独立导入确认弹层 +- 首启显式协议注册提示 + +CLI: + +```bash +go run ./cmd/image-studio-gio protocol register +go run ./cmd/image-studio-gio protocol unregister +go run ./cmd/image-studio-gio protocol status +go run ./cmd/image-studio-gio import-token TESTTEST +``` + +Linux 也可直接用仓库脚本写入 `.desktop` 文件并调用 `xdg-mime`: + +```bash +bash ../scripts/register-gio-linux-scheme.sh /absolute/path/to/image-studio-gio +``` + +模板文件位于: + +```text +gio-client/assets/image-studio-gio.desktop.in +``` ## WebView2 兼容状态 diff --git a/docs/packages.md b/docs/packages.md index 8ce8c739c..0461a0cb7 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -24,6 +24,8 @@ Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 |---|---|---| | Windows x64 | `image-studio--windows-amd64.exe` | 裸 Wails 可执行文件,适合内部测试,不是安装器。 | | Windows ARM64 | `image-studio--windows-arm64.exe` | 裸 Wails 可执行文件,适合内部测试,不是安装器。 | +| Windows Portable Fixed WebView2 x64 | `image-studio--windows-amd64-portable-fixed-webview.zip` | 便携压缩包,内置 Fixed Version WebView2 Runtime,适合用户直接解压后双击 `exe`。 | +| Windows Portable Fixed WebView2 ARM64 | `image-studio--windows-arm64-portable-fixed-webview.zip` | ARM64 便携压缩包,内置 Fixed Version WebView2 Runtime。 | | Windows Installer | `image-studio--windows-installer.exe` | 单个 NSIS 安装器,内含 amd64 与 arm64 两套二进制,安装时按本机架构自动选择。 | | Windows MSIX x64 | `image-studio--windows-x64.msix` | 面向 Microsoft Store / 企业分发的 x64 MSIX 包。 | | Windows MSIX ARM64 | `image-studio--windows-arm64.msix` | 面向 Microsoft Store / 企业分发的 ARM64 MSIX 包。 | @@ -32,16 +34,17 @@ Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 | Linux x64 | `image-studio--linux-amd64.tar.gz` | 标准 Wails 桌面版。 | | Linux ARM64 | `image-studio--linux-arm64.tar.gz` | 面向 ARM64 Linux 桌面环境。 | | Android | `image-studio--android-release.apk` | 单 APK,运行时自适应 phone / pad 布局。 | -| Gio Windows | `image-studio-gio--windows-*.exe` | Gio 原生 GUI 测试版,不依赖 WebView2。 | -| Gio Linux | `image-studio-gio--linux-*.tar.gz` | Gio 原生 GUI 测试版,不依赖 WebKitGTK。 | +| Gio Windows | `image-studio-gio--windows-*.exe` | Gio 原生 GUI 版,不依赖 WebView2;Windows 下可作为 `image-studio://` 网页导入默认处理器。 | +| Gio Linux | `image-studio-gio--linux-*.tar.gz` | Gio 原生 GUI 版,不依赖 WebKitGTK;Linux 下可通过 CLI / `.desktop` 注册网页导入协议。 | ## 各平台选择建议 | 场景 | 建议下载 | |---|---| | 普通桌面用户 | 对应平台的 `image-studio--...` Wails 版。 | -| Windows 上没有稳定 WebView2 环境,或想验证 Gio 原生界面 | `image-studio-gio--windows-...`。 | -| Linux 上只想试 Gio 原生渲染路径 | `image-studio-gio--linux-...`。 | +| Windows 用户会直接解压后双击 `exe`,且机器上可能没有可用 WebView2 | `image-studio--windows-*-portable-fixed-webview.zip`。 | +| Windows 上没有稳定 WebView2 环境,或需要接收提示词网站的 `Send to Image-Studio` 深链 | `image-studio-gio--windows-...`。 | +| Linux 上想用 Gio 原生渲染路径,或需要接收 `image-studio://import?...` 深链 | `image-studio-gio--linux-...`。 | | 手机与平板统一安装 | `image-studio--android-release.apk`。 | ## 平台注意事项 @@ -49,8 +52,10 @@ Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 ### Windows - Wails 裸 `exe` 依赖 WebView2 Runtime;安装器版本会在安装阶段检查并静默拉起 WebView2 Runtime 安装。 +- `windows-*-portable-fixed-webview.zip` 会把 Fixed Version WebView2 Runtime 一起打包,适合直接解压运行;请保持 `image-studio.exe` 与 `WebView2FixedRuntime/` 在同一目录层级。 - ARM64 设备优先下载 `windows-arm64`,避免 x64 仿真带来的额外开销。 - 对外普通分发时请优先使用 `image-studio--windows-installer.exe`,不要直接使用裸 `exe`。 +- 便携 Fixed WebView2 包是单独 workflow 产物,不替代正式安装器或 MSIX。 - 提交 Microsoft Store 时请优先使用 `image-studio--windows.msixbundle`,其次再按需要使用分架构 `.msix`。 - 对外分发请优先使用带有效 Authenticode 签名的正式 release;未签名的 CI `exe` 或安装器在 Windows 11 上可能被智能应用控制直接阻止运行。 @@ -69,6 +74,7 @@ xattr -dr com.apple.quarantine "Image Studio.app" - 预编译包主要面向带 GTK / WebKitGTK 依赖的桌面环境。 - 如果你更想避免 WebKitGTK 依赖,可以优先试 Gio 测试版。 +- Gio Linux 版支持通过 `go run ./cmd/image-studio-gio protocol register` 或 `bash scripts/register-gio-linux-scheme.sh /path/to/image-studio-gio` 注册 `image-studio://` 网页导入协议。 ### Android diff --git a/gio-client/README.md b/gio-client/README.md index e623fb52d..47e9c1ace 100644 --- a/gio-client/README.md +++ b/gio-client/README.md @@ -1,6 +1,6 @@ # Image Studio Gio Client -`gio-client/` is an independent high-performance desktop test client for Windows and Linux. It uses Gio for the native GUI and reuses the existing Go request kernel from `go-cli/pkg/client`. +`gio-client/` is an independent high-performance desktop client for Windows and Linux. It uses Gio for the native GUI, reuses the existing Go request kernel from `go-cli/pkg/client`, and can act as the default `image-studio://import?...` handler on Windows / Linux. It does not embed the React frontend, Wails, WebView2, or WebKitGTK. The current Wails desktop app remains in `image-studio/` and continues to build through the existing WebView2/WebKit path. @@ -18,6 +18,35 @@ The UI keeps the current desktop control-panel / canvas / log-rail structure, bu The GUI entrypoint is built only for Windows and Linux. Other platforms compile a small unsupported stub so accidental local launches do not imply macOS support for this test client. +## Prompt Import Protocol + +The prompt website can open the desktop client through: + +```text +image-studio://import?token=XXXXXXXX +``` + +Platform ownership is split intentionally: + +- macOS: handled by the Wails desktop app in `image-studio/` +- Windows / Linux: handled by `gio-client/` + +The Gio client exposes protocol helpers through its CLI: + +```bash +go run ./cmd/image-studio-gio protocol register +go run ./cmd/image-studio-gio protocol unregister +go run ./cmd/image-studio-gio protocol status +``` + +For manual Linux registration you can also use: + +```bash +bash ../scripts/register-gio-linux-scheme.sh /absolute/path/to/image-studio-gio +``` + +When the client is not yet the default `image-studio://` handler, the first launch shows an explicit registration prompt instead of silently taking over the scheme. + ## WebView2 Compatibility Gio and the Wails/WebView2 client share a compatibility state file: diff --git a/gio-client/assets/image-studio-gio.desktop.in b/gio-client/assets/image-studio-gio.desktop.in new file mode 100644 index 000000000..b8409a134 --- /dev/null +++ b/gio-client/assets/image-studio-gio.desktop.in @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Image Studio Gio +Exec="__EXECUTABLE__" %u +Type=Application +NoDisplay=true +Terminal=false +MimeType=x-scheme-handler/image-studio; +Categories=Graphics; diff --git a/gio-client/cmd/image-studio-gio/cli.go b/gio-client/cmd/image-studio-gio/cli.go index c6d1f15bf..8841b6592 100644 --- a/gio-client/cmd/image-studio-gio/cli.go +++ b/gio-client/cmd/image-studio-gio/cli.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "flag" "fmt" @@ -8,9 +9,12 @@ import ( "strings" "time" + "github.com/yuanhua/image-gptcodex/pkg/promptimport" gioCompat "image-studio/gio-client/internal/compat" "image-studio/gio-client/internal/diagnostics" "image-studio/gio-client/internal/historymedia" + "image-studio/gio-client/internal/promptipc" + "image-studio/gio-client/internal/promptscheme" "image-studio/gio-client/internal/ui" ) @@ -25,11 +29,99 @@ func runCLICommand(args []string, stdout io.Writer, stderr io.Writer) (bool, int case "perf": code, err := runPerfCommand(args[1:], stdout, stderr) return true, code, err + case "protocol": + code, err := runProtocolCommand(args[1:], stdout, stderr) + return true, code, err + case "import-token": + code, err := runImportTokenCommand(args[1:], stdout, stderr) + return true, code, err default: return false, 0, nil } } +func runProtocolCommand(args []string, stdout io.Writer, stderr io.Writer) (int, error) { + if len(args) == 0 { + return 2, fmt.Errorf("usage: image-studio-gio protocol ") + } + switch args[0] { + case "register": + if err := promptscheme.RegisterCurrentExecutable(); err != nil { + return 1, err + } + _, err := io.WriteString(stdout, "protocol registered\n") + return 0, err + case "unregister": + if err := promptscheme.UnregisterCurrentExecutable(); err != nil { + return 1, err + } + _, err := io.WriteString(stdout, "protocol unregistered\n") + return 0, err + case "status": + status, err := promptscheme.StatusForCurrentExecutable() + if err != nil { + return 1, err + } + return 0, writeJSON(stdout, status) + default: + return 2, fmt.Errorf("unknown protocol command: %s", args[0]) + } +} + +func runImportTokenCommand(args []string, stdout io.Writer, stderr io.Writer) (int, error) { + if len(args) == 0 { + return 2, fmt.Errorf("usage: image-studio-gio import-token ") + } + token, err := normalizePromptImportTokenArg(args[0]) + if err != nil { + return 2, err + } + if err := promptipc.SendToken(token); err == nil { + _, writeErr := io.WriteString(stdout, "token sent to running instance\n") + return 0, writeErr + } + payload, fetchErr := promptimport.Fetch(context.Background(), token, promptimport.FetchOptions{}) + if fetchErr != nil { + return 1, fetchErr + } + return 0, writeJSON(stdout, payload) +} + +func promptImportMessageFromArgs(args []string) promptipc.Message { + for _, arg := range args { + trimmed := strings.TrimSpace(arg) + if trimmed == "" { + continue + } + if !strings.Contains(trimmed, "image-studio://") { + continue + } + token, err := promptimport.ParseTokenFromURL(trimmed) + if err != nil { + if promptimport.ErrorCode(err) == promptimport.TokenInvalid { + return promptipc.Message{Type: promptipc.MessageTypeInvalid} + } + continue + } + return promptipc.Message{Type: promptipc.MessageTypeToken, Token: token} + } + return promptipc.Message{} +} + +func normalizePromptImportTokenArg(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("token is empty") + } + if strings.Contains(trimmed, "image-studio://") { + return promptimport.ParseTokenFromURL(trimmed) + } + if !promptimport.IsValidToken(trimmed) { + return "", fmt.Errorf("invalid token") + } + return trimmed, nil +} + func runHistoryMediaCommand(args []string, stdout io.Writer, stderr io.Writer) (int, error) { if len(args) == 0 { return 2, fmt.Errorf("usage: image-studio-gio history-media [flags]") diff --git a/gio-client/cmd/image-studio-gio/main.go b/gio-client/cmd/image-studio-gio/main.go index bf2b3a982..b2a88203a 100644 --- a/gio-client/cmd/image-studio-gio/main.go +++ b/gio-client/cmd/image-studio-gio/main.go @@ -7,6 +7,7 @@ import ( "log" "os" + "image-studio/gio-client/internal/promptipc" "image-studio/gio-client/internal/ui" "gioui.org/app" @@ -20,6 +21,36 @@ func main() { } os.Exit(exitCode) } + appUI := ui.New() + server, alreadyRunning, err := promptipc.TryStart(func(msg promptipc.Message) { + switch msg.Type { + case promptipc.MessageTypeRaise: + appUI.RaiseWindow() + case promptipc.MessageTypeToken: + appUI.HandlePromptImportToken(msg.Token) + case promptipc.MessageTypeInvalid: + appUI.HandlePromptImportInvalid() + } + }) + if err != nil { + log.Fatal(err) + } + defer server.Close() + initialMessage := promptImportMessageFromArgs(os.Args[1:]) + if alreadyRunning { + if initialMessage.Type == "" { + _ = promptipc.SendRaise() + } else { + _ = promptipc.Send(initialMessage) + } + os.Exit(0) + } + switch initialMessage.Type { + case promptipc.MessageTypeToken: + appUI.HandlePromptImportToken(initialMessage.Token) + case promptipc.MessageTypeInvalid: + appUI.HandlePromptImportInvalid() + } go func() { w := new(app.Window) w.Option( @@ -27,7 +58,7 @@ func main() { app.Size(unit.Dp(1440), unit.Dp(980)), app.MinSize(unit.Dp(1040), unit.Dp(720)), ) - if err := ui.New().Run(w); err != nil { + if err := appUI.Run(w); err != nil { log.Fatal(err) } os.Exit(0) diff --git a/gio-client/internal/compat/completion_notification.go b/gio-client/internal/compat/completion_notification.go new file mode 100644 index 000000000..96bdc0487 --- /dev/null +++ b/gio-client/internal/compat/completion_notification.go @@ -0,0 +1,10 @@ +package compat + +import shared "image-studio/shared/compat" + +func NormaliseCompletionNotificationSettings(value *shared.CompletionNotificationSettings) shared.CompletionNotificationSettings { + if value == nil { + return shared.CompletionNotificationSettings{Enabled: true} + } + return *value +} diff --git a/gio-client/internal/compat/completion_sound.go b/gio-client/internal/compat/completion_sound.go new file mode 100644 index 000000000..aeb9016a4 --- /dev/null +++ b/gio-client/internal/compat/completion_sound.go @@ -0,0 +1,26 @@ +package compat + +import ( + "strings" + + shared "image-studio/shared/compat" +) + +const defaultCompletionSoundMode = "default" + +func NormaliseCompletionSoundSettings(value *shared.CompletionSoundSettings) shared.CompletionSoundSettings { + if value == nil { + return shared.CompletionSoundSettings{ + Enabled: true, + Mode: defaultCompletionSoundMode, + } + } + next := *value + if strings.TrimSpace(next.Mode) != "custom" || strings.TrimSpace(next.CustomData) == "" { + next.Mode = defaultCompletionSoundMode + } + if strings.TrimSpace(next.Mode) == "" { + next.Mode = defaultCompletionSoundMode + } + return next +} diff --git a/gio-client/internal/compat/state.go b/gio-client/internal/compat/state.go index 27fa90f15..69581d1ee 100644 --- a/gio-client/internal/compat/state.go +++ b/gio-client/internal/compat/state.go @@ -52,6 +52,15 @@ func ConfigFromState(cfg kernel.Config, state shared.State) kernel.Config { cfg.ProxyMode = state.Settings.ProxyMode } cfg.ProxyURL = state.Settings.ProxyURL + if state.Settings.ProtectStreamPreview != nil { + cfg.ProtectStreamPreview = *state.Settings.ProtectStreamPreview + } + if state.Settings.AutoRetryEnabled != nil { + cfg.AutoRetryEnabled = *state.Settings.AutoRetryEnabled + } + if state.Settings.AutoRetryCount != nil { + cfg.AutoRetryCount = *state.Settings.AutoRetryCount + } profile, ok := ActiveProfile(state) if !ok { @@ -61,8 +70,11 @@ func ConfigFromState(cfg kernel.Config, state shared.State) kernel.Config { cfg.TextModelID = profile.TextModelID cfg.ImageModelID = profile.ImageModelID cfg.APIMode = normaliseAPIMode(profile.APIMode) + cfg.ResponsesTransport = client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)) + cfg.FallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) cfg.RequestPolicy = normalisePolicy(profile.RequestPolicy) cfg.ImagesNewAPICompat = profile.ImagesNewAPICompat + cfg.ReasoningEffort = normalizeReasoningEffort(profile.ReasoningEffort) if strings.TrimSpace(state.Settings.Background) != "" { cfg.Background = state.Settings.Background } @@ -79,6 +91,7 @@ func ConfigFromState(cfg kernel.Config, state shared.State) kernel.Config { cfg.Moderation = state.Settings.Moderation } cfg.UserIdentifier = strings.TrimSpace(state.Settings.UserIdentifier) + cfg.CompletionSound = NormaliseCompletionSoundSettings(state.Settings.CompletionSound) if state.Settings.PartialImages != nil { cfg.PartialImages = *state.Settings.PartialImages } @@ -162,12 +175,13 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State { ID: profileID, Name: nextDefaultProfileName(state.Profiles), APIMode: string(normaliseAPIMode(string(cfg.APIMode))), + ResponsesTransport: normalizeProfileResponsesTransport(string(cfg.ResponsesTransport)), RequestPolicy: string(normalisePolicy(string(cfg.RequestPolicy))), ImagesNewAPICompat: cfg.ImagesNewAPICompat, BaseURL: strings.TrimSpace(cfg.BaseURL), TextModelID: strings.TrimSpace(cfg.TextModelID), ImageModelID: strings.TrimSpace(cfg.ImageModelID), - ReasoningEffort: "xhigh", + ReasoningEffort: normalizeReasoningEffort(cfg.ReasoningEffort), ConcurrencyLimit: 0, CreatedAt: now, LastUsedAt: now, @@ -176,6 +190,7 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State { profile.Name = state.Profiles[profileIndex].Name profile.CreatedAt = state.Profiles[profileIndex].CreatedAt profile.ConcurrencyLimit = state.Profiles[profileIndex].ConcurrencyLimit + profile.FallbackProfileID = state.Profiles[profileIndex].FallbackProfileID state.Profiles[profileIndex] = profile } else { state.Profiles = append(state.Profiles, profile) @@ -183,6 +198,14 @@ func UpsertConfig(state shared.State, cfg kernel.Config) shared.State { state.ActiveProfile = profile.ID state.Settings.ProxyMode = cfg.ProxyMode state.Settings.ProxyURL = strings.TrimSpace(cfg.ProxyURL) + protectStreamPreview := cfg.ProtectStreamPreview + state.Settings.ProtectStreamPreview = &protectStreamPreview + autoRetryEnabled := cfg.AutoRetryEnabled + state.Settings.AutoRetryEnabled = &autoRetryEnabled + autoRetryCount := cfg.AutoRetryCount + state.Settings.AutoRetryCount = &autoRetryCount + completionSound := cfg.CompletionSound + state.Settings.CompletionSound = &completionSound state.Settings.OutputFormat = strings.TrimSpace(cfg.OutputFormat) state.Settings.OutputDir = strings.TrimSpace(cfg.OutputDir) state.Settings.Background = strings.TrimSpace(cfg.Background) @@ -297,6 +320,26 @@ func normalisePolicy(policy string) client.RequestPolicy { return client.RequestPolicyOpenAI } +func normalizeProfileResponsesTransport(value string) string { + if strings.TrimSpace(value) == string(client.ResponsesTransportWebSocket) { + return string(client.ResponsesTransportWebSocket) + } + return string(client.ResponsesTransportSSE) +} + +func normalizeReasoningEffort(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + default: + return client.DefaultReasoningEffort + } +} + func nextDefaultProfileName(profiles []shared.UpstreamProfile) string { used := map[int]struct{}{} for _, profile := range profiles { diff --git a/gio-client/internal/compat/state_test.go b/gio-client/internal/compat/state_test.go index 78ffafb15..ba5561549 100644 --- a/gio-client/internal/compat/state_test.go +++ b/gio-client/internal/compat/state_test.go @@ -16,10 +16,16 @@ func TestConfigFromStateUsesActiveProfileAndSettings(t *testing.T) { OutputFormat: "webp", ProxyMode: client.ProxyModeCustom, ProxyURL: "http://127.0.0.1:7890", + CompletionSound: &shared.CompletionSoundSettings{ + Enabled: false, + Mode: "custom", + CustomName: "ding.wav", + CustomData: "data:audio/wav;base64,AA==", + }, }, Profiles: []shared.UpstreamProfile{ {ID: "p1", Name: "配置1", BaseURL: "https://old.example", TextModelID: "old-text", ImageModelID: "old-image", APIMode: string(client.APIModeImages)}, - {ID: "p2", Name: "配置2", BaseURL: "https://new.example", TextModelID: "new-text", ImageModelID: "new-image", APIMode: string(client.APIModeResponses), RequestPolicy: string(client.RequestPolicyCompat)}, + {ID: "p2", Name: "配置2", BaseURL: "https://new.example", TextModelID: "new-text", ImageModelID: "new-image", APIMode: string(client.APIModeResponses), ResponsesTransport: string(client.ResponsesTransportWebSocket), RequestPolicy: string(client.RequestPolicyCompat), ReasoningEffort: "high"}, }, ActiveProfile: "p2", } @@ -33,35 +39,52 @@ func TestConfigFromStateUsesActiveProfileAndSettings(t *testing.T) { if cfg.APIMode != client.APIModeResponses || cfg.RequestPolicy != client.RequestPolicyCompat { t.Fatalf("api settings not applied: %#v", cfg) } + if cfg.ResponsesTransport != client.ResponsesTransportWebSocket || cfg.ReasoningEffort != "high" { + t.Fatalf("responses config not applied: transport=%q reasoning=%q", cfg.ResponsesTransport, cfg.ReasoningEffort) + } if cfg.ProxyMode != client.ProxyModeCustom || cfg.ProxyURL != "http://127.0.0.1:7890" { t.Fatalf("proxy not applied: %#v", cfg) } + if cfg.CompletionSound.Mode != "custom" || cfg.CompletionSound.CustomName != "ding.wav" || cfg.CompletionSound.CustomData != "data:audio/wav;base64,AA==" || cfg.CompletionSound.Enabled { + t.Fatalf("completion sound not applied: %#v", cfg.CompletionSound) + } } func TestUpsertConfigPreservesActiveProfileIdentity(t *testing.T) { state := shared.State{ Profiles: []shared.UpstreamProfile{{ - ID: "p1", - Name: "主配置", - APIMode: string(client.APIModeImages), - RequestPolicy: string(client.RequestPolicyOpenAI), - BaseURL: "https://old.example", - TextModelID: "old-text", - ImageModelID: "old-image", - ConcurrencyLimit: 3, - CreatedAt: 100, + ID: "p1", + Name: "主配置", + APIMode: string(client.APIModeImages), + RequestPolicy: string(client.RequestPolicyOpenAI), + BaseURL: "https://old.example", + TextModelID: "old-text", + ImageModelID: "old-image", + ResponsesTransport: string(client.ResponsesTransportSSE), + ReasoningEffort: "xhigh", + ConcurrencyLimit: 3, + FallbackProfileID: "backup-1", + CreatedAt: 100, }}, ActiveProfile: "p1", } cfg := kernel.Config{ - BaseURL: "https://new.example", - TextModelID: "new-text", - ImageModelID: "new-image", - APIMode: client.APIModeResponses, - RequestPolicy: client.RequestPolicyCompat, - OutputFormat: "jpeg", - OutputDir: "/tmp/images", - ProxyMode: client.ProxyModeNone, + BaseURL: "https://new.example", + TextModelID: "new-text", + ImageModelID: "new-image", + APIMode: client.APIModeResponses, + ResponsesTransport: client.ResponsesTransportWebSocket, + RequestPolicy: client.RequestPolicyCompat, + ReasoningEffort: "medium", + OutputFormat: "jpeg", + OutputDir: "/tmp/images", + ProxyMode: client.ProxyModeNone, + CompletionSound: shared.CompletionSoundSettings{ + Enabled: true, + Mode: "custom", + CustomName: "done.wav", + CustomData: "data:audio/wav;base64,BB==", + }, } next := UpsertConfig(state, cfg) if next.ActiveProfile != "p1" || len(next.Profiles) != 1 { @@ -74,9 +97,15 @@ func TestUpsertConfigPreservesActiveProfileIdentity(t *testing.T) { if profile.BaseURL != "https://new.example" || profile.APIMode != string(client.APIModeResponses) || profile.RequestPolicy != string(client.RequestPolicyCompat) { t.Fatalf("profile config not updated: %#v", profile) } + if profile.ResponsesTransport != string(client.ResponsesTransportWebSocket) || profile.ReasoningEffort != "medium" || profile.FallbackProfileID != "backup-1" { + t.Fatalf("extended profile config not updated: %#v", profile) + } if next.Settings.OutputFormat != "jpeg" || next.Settings.OutputDir != "/tmp/images" || next.Settings.ProxyMode != client.ProxyModeNone { t.Fatalf("settings not updated: %#v", next.Settings) } + 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) + } if next.Settings.Theme != "system" || next.Settings.FontScale != 1 { t.Fatalf("default visual settings not set: %#v", next.Settings) } diff --git a/gio-client/internal/kernel/runner.go b/gio-client/internal/kernel/runner.go index 00bd4b551..0d73bbbe4 100644 --- a/gio-client/internal/kernel/runner.go +++ b/gio-client/internal/kernel/runner.go @@ -3,6 +3,7 @@ package kernel import ( "context" "encoding/base64" + "errors" "fmt" "image" "image/draw" @@ -14,6 +15,8 @@ import ( "strings" "time" + sharedCompat "image-studio/shared/compat" + _ "github.com/gen2brain/avif" "github.com/yuanhua/image-gptcodex/pkg/client" xdraw "golang.org/x/image/draw" @@ -21,33 +24,53 @@ import ( ) type Config struct { + APIKey string + BaseURL string + TextModelID string + ImageModelID string + Prompt string + Mode client.Mode + APIMode client.APIMode + RequestPolicy client.RequestPolicy + ResponsesTransport client.ResponsesTransport + ImagesNewAPICompat bool + Size string + Quality string + OutputFormat string + Background string + OutputCompression int + InputFidelity string + ImageStyle string + Moderation string + UserIdentifier string + ProxyMode string + ProxyURL string + ReasoningEffort string + ProtectStreamPreview bool + AutoRetryEnabled bool + AutoRetryCount int + CompletionSound sharedCompat.CompletionSoundSettings + SourcePaths []string + OutputDir string + Seed int64 + NegativePrompt string + PartialImages int + StyleTag string + BatchIndex int + FallbackProfileID string + FallbackProfile *FallbackProfileConfig +} + +type FallbackProfileConfig struct { APIKey string BaseURL string TextModelID string ImageModelID string - Prompt string - Mode client.Mode APIMode client.APIMode + ResponsesTransport client.ResponsesTransport RequestPolicy client.RequestPolicy ImagesNewAPICompat bool - Size string - Quality string - OutputFormat string - Background string - OutputCompression int - InputFidelity string - ImageStyle string - Moderation string - UserIdentifier string - ProxyMode string - ProxyURL string - SourcePaths []string - OutputDir string - Seed int64 - NegativePrompt string - PartialImages int - StyleTag string - BatchIndex int + ReasoningEffort string } type Callbacks struct { @@ -73,22 +96,31 @@ type Runner struct{} func DefaultConfig() Config { return Config{ - TextModelID: client.TextModel, - ImageModelID: client.ImageModel, - Mode: client.ModeGenerate, - APIMode: client.APIModeResponses, - RequestPolicy: client.RequestPolicyOpenAI, - Size: client.DefaultSize, - Quality: client.DefaultQuality, - OutputFormat: client.OutputFormat, - Background: client.DefaultBackground, - OutputCompression: client.DefaultOutputCompression, - InputFidelity: client.DefaultInputFidelity, - ImageStyle: client.DefaultImageStyle, - Moderation: client.DefaultModeration, - ProxyMode: client.ProxyModeSystem, - OutputDir: DefaultOutputDir(), - PartialImages: 0, + TextModelID: client.TextModel, + ImageModelID: client.ImageModel, + Mode: client.ModeGenerate, + APIMode: client.APIModeResponses, + RequestPolicy: client.RequestPolicyOpenAI, + ResponsesTransport: client.ResponsesTransportSSE, + Size: client.DefaultSize, + Quality: client.DefaultQuality, + OutputFormat: client.OutputFormat, + Background: client.DefaultBackground, + OutputCompression: client.DefaultOutputCompression, + InputFidelity: client.DefaultInputFidelity, + ImageStyle: client.DefaultImageStyle, + Moderation: client.DefaultModeration, + ProxyMode: client.ProxyModeSystem, + ReasoningEffort: client.DefaultReasoningEffort, + ProtectStreamPreview: true, + AutoRetryEnabled: true, + AutoRetryCount: client.DefaultAutoRetryCount, + CompletionSound: sharedCompat.CompletionSoundSettings{ + Enabled: true, + Mode: "default", + }, + OutputDir: DefaultOutputDir(), + PartialImages: 0, } } @@ -152,89 +184,40 @@ func (Runner) Run(ctx context.Context, cfg Config, cb Callbacks) (Result, error) return Result{}, fmt.Errorf("创建日志目录失败:%w", err) } - proxy, err := client.NormalizeProxyConfig(cfg.ProxyMode, cfg.ProxyURL) - if err != nil { - return Result{}, err - } - transport, err := client.PickTransportWithProxy(proxy) - if err != nil { - return Result{}, err - } - - opts := client.Options{ - APIKey: cfg.APIKey, - BaseURL: cfg.BaseURL, - TextModelID: cfg.TextModelID, - ImageModelID: cfg.ImageModelID, - Prompt: cfg.Prompt, - Mode: cfg.Mode, - APIMode: cfg.APIMode, - RequestPolicy: cfg.RequestPolicy, - ImagesNewAPICompat: cfg.ImagesNewAPICompat, - Size: cfg.Size, - Quality: cfg.Quality, - OutputFormat: cfg.OutputFormat, - Background: cfg.Background, - OutputCompression: cfg.OutputCompression, - InputFidelity: cfg.InputFidelity, - ImageStyle: cfg.ImageStyle, - Moderation: cfg.Moderation, - UserIdentifier: cfg.UserIdentifier, - Proxy: proxy, - Seed: cfg.Seed, - NegativePrompt: cfg.NegativePrompt, - PartialImages: cfg.PartialImages, - DisablePreview: cfg.PartialImages == 0, - } - if cfg.Mode == client.ModeEdit { - if err := attachSourceImages(&opts, cfg.SourcePaths); err != nil { - return Result{}, err + variants := []Config{cfg} + if fallback := normalizeFallbackProfileConfig(cfg.FallbackProfile); fallback != nil { + fallbackCfg := cfg + fallbackCfg.APIKey = fallback.APIKey + fallbackCfg.BaseURL = fallback.BaseURL + fallbackCfg.TextModelID = fallback.TextModelID + fallbackCfg.ImageModelID = fallback.ImageModelID + fallbackCfg.APIMode = fallback.APIMode + fallbackCfg.ResponsesTransport = fallback.ResponsesTransport + fallbackCfg.RequestPolicy = fallback.RequestPolicy + fallbackCfg.ImagesNewAPICompat = fallback.ImagesNewAPICompat + fallbackCfg.ReasoningEffort = fallback.ReasoningEffort + fallbackCfg.FallbackProfile = nil + variants = append(variants, fallbackCfg) + } + + var lastErr error + for idx, variant := range variants { + if idx > 0 { + nonNilLog(cb.Log)("主上游自动重试失败,切换到备用上游再试一次...") } - } - - timestamp := time.Now().Format("20060102-150405") - result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial( - ctx, - transport, - opts, - logDir, - timestamp, - nonNilLog(cb.Log), - cb.Progress, - cb.Partial, - ) - if err != nil { - return Result{RawPath: absPathOrRaw(rawPath)}, err - } - rawPath = absPathOrRaw(rawPath) - - imageName := buildImageName(cfg.Mode, cfg.Prompt, timestamp, cfg.OutputFormat) - savedPath, err := saveImage(result.ImageB64, filepath.Join(imagesDir, imageName)) - if err != nil { - return Result{RawPath: rawPath, ImageB64: result.ImageB64}, err - } - previewPath := "" - thumbPath := "" - if nextPreview, nextThumb, mediaErr := EnsurePreviewAndThumbForPath(savedPath); mediaErr == nil { - previewPath = nextPreview - thumbPath = nextThumb - } else { - if nextPreview != "" { - previewPath = nextPreview + result, err := runSingleConfig(ctx, variant, cb, imagesDir, logDir) + if err == nil { + return result, nil } - if nextThumb != "" { - thumbPath = nextThumb + lastErr = err + if errors.Is(err, context.Canceled) { + return Result{}, err } - nonNilLog(cb.Log)("生成历史预览/缩略图失败: " + mediaErr.Error()) } - return Result{ - SavedPath: savedPath, - PreviewPath: previewPath, - ThumbPath: thumbPath, - RawPath: rawPath, - RevisedPrompt: result.RevisedPrompt, - SourceEvent: result.SourceEvent, - }, nil + if lastErr != nil { + return Result{}, lastErr + } + return Result{}, fmt.Errorf("多次请求后仍未成功") } func absPathOrRaw(path string) string { @@ -281,6 +264,9 @@ func normalizeConfig(cfg Config) Config { if cfg.RequestPolicy != client.RequestPolicyCompat { cfg.RequestPolicy = client.RequestPolicyOpenAI } + if cfg.ResponsesTransport != client.ResponsesTransportWebSocket { + cfg.ResponsesTransport = client.ResponsesTransportSSE + } if cfg.Size == "" { cfg.Size = client.DefaultSize } @@ -308,16 +294,153 @@ func normalizeConfig(cfg Config) Config { if cfg.ProxyMode == "" { cfg.ProxyMode = client.ProxyModeSystem } + switch strings.ToLower(strings.TrimSpace(cfg.ReasoningEffort)) { + case "low", "medium", "high", "xhigh": + default: + cfg.ReasoningEffort = client.DefaultReasoningEffort + } + if cfg.AutoRetryCount <= 0 { + cfg.AutoRetryCount = client.DefaultAutoRetryCount + } + if cfg.AutoRetryCount > client.MaxAutoRetryCount { + cfg.AutoRetryCount = client.MaxAutoRetryCount + } if cfg.OutputDir == "" { cfg.OutputDir = DefaultOutputDir() } if cfg.PartialImages < 0 { cfg.PartialImages = client.DefaultPartialImages } + cfg.FallbackProfileID = strings.TrimSpace(cfg.FallbackProfileID) + cfg.FallbackProfile = normalizeFallbackProfileConfig(cfg.FallbackProfile) cfg.SourcePaths = normalizeSourcePaths(cfg.SourcePaths) return cfg } +func normalizeFallbackProfileConfig(value *FallbackProfileConfig) *FallbackProfileConfig { + if value == nil { + return nil + } + next := *value + next.APIKey = strings.TrimSpace(next.APIKey) + next.BaseURL = strings.TrimSpace(next.BaseURL) + next.TextModelID = strings.TrimSpace(next.TextModelID) + next.ImageModelID = strings.TrimSpace(next.ImageModelID) + next.RequestPolicy = normalizeRequestPolicy(next.RequestPolicy) + if next.APIMode != client.APIModeImages { + next.APIMode = client.APIModeResponses + } + if next.ResponsesTransport != client.ResponsesTransportWebSocket { + next.ResponsesTransport = client.ResponsesTransportSSE + } + switch strings.ToLower(strings.TrimSpace(next.ReasoningEffort)) { + case "low", "medium", "high", "xhigh": + default: + next.ReasoningEffort = client.DefaultReasoningEffort + } + if next.APIKey == "" || next.BaseURL == "" { + return nil + } + return &next +} + +func normalizeRequestPolicy(policy client.RequestPolicy) client.RequestPolicy { + if policy == client.RequestPolicyCompat { + return client.RequestPolicyCompat + } + return client.RequestPolicyOpenAI +} + +func runSingleConfig(ctx context.Context, cfg Config, cb Callbacks, imagesDir string, logDir string) (Result, error) { + proxy, err := client.NormalizeProxyConfig(cfg.ProxyMode, cfg.ProxyURL) + if err != nil { + return Result{}, err + } + transport, err := client.PickTransportWithProxy(proxy) + if err != nil { + return Result{}, err + } + + opts := client.Options{ + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + TextModelID: cfg.TextModelID, + ImageModelID: cfg.ImageModelID, + Prompt: cfg.Prompt, + Mode: cfg.Mode, + APIMode: cfg.APIMode, + ResponsesTransport: cfg.ResponsesTransport, + RequestPolicy: cfg.RequestPolicy, + ImagesNewAPICompat: cfg.ImagesNewAPICompat, + Size: cfg.Size, + Quality: cfg.Quality, + OutputFormat: cfg.OutputFormat, + Background: cfg.Background, + OutputCompression: cfg.OutputCompression, + InputFidelity: cfg.InputFidelity, + ImageStyle: cfg.ImageStyle, + Moderation: cfg.Moderation, + UserIdentifier: cfg.UserIdentifier, + Proxy: proxy, + ReasoningEffort: cfg.ReasoningEffort, + AutoRetryEnabled: func() *bool { v := cfg.AutoRetryEnabled; return &v }(), + AutoRetryCount: cfg.AutoRetryCount, + Seed: cfg.Seed, + NegativePrompt: cfg.NegativePrompt, + PartialImages: cfg.PartialImages, + DisablePreview: cfg.PartialImages == 0, + } + if cfg.Mode == client.ModeEdit { + if err := attachSourceImages(&opts, cfg.SourcePaths); err != nil { + return Result{}, err + } + } + + timestamp := time.Now().Format("20060102-150405") + result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial( + ctx, + transport, + opts, + logDir, + timestamp, + nonNilLog(cb.Log), + cb.Progress, + cb.Partial, + ) + if err != nil { + return Result{RawPath: absPathOrRaw(rawPath)}, err + } + rawPath = absPathOrRaw(rawPath) + + imageName := buildImageName(cfg.Mode, cfg.Prompt, timestamp, cfg.OutputFormat) + savedPath, err := saveImage(result.ImageB64, filepath.Join(imagesDir, imageName)) + if err != nil { + return Result{RawPath: rawPath, ImageB64: result.ImageB64}, err + } + previewPath := "" + thumbPath := "" + if nextPreview, nextThumb, mediaErr := EnsurePreviewAndThumbForPath(savedPath); mediaErr == nil { + previewPath = nextPreview + thumbPath = nextThumb + } else { + if nextPreview != "" { + previewPath = nextPreview + } + if nextThumb != "" { + thumbPath = nextThumb + } + nonNilLog(cb.Log)("生成历史预览/缩略图失败: " + mediaErr.Error()) + } + return Result{ + SavedPath: savedPath, + PreviewPath: previewPath, + ThumbPath: thumbPath, + RawPath: rawPath, + RevisedPrompt: result.RevisedPrompt, + SourceEvent: result.SourceEvent, + }, nil +} + func normalizeSourcePaths(paths []string) []string { out := make([]string, 0, len(paths)) seen := map[string]struct{}{} diff --git a/gio-client/internal/kernel/runner_test.go b/gio-client/internal/kernel/runner_test.go index 7344ef3e0..3c9fd3230 100644 --- a/gio-client/internal/kernel/runner_test.go +++ b/gio-client/internal/kernel/runner_test.go @@ -70,6 +70,53 @@ func TestNormalizeConfigPreservesZeroPartialImages(t *testing.T) { } } +func TestNormalizeConfigNormalizesAutoRetryCount(t *testing.T) { + cfg := normalizeConfig(Config{ + Prompt: "hello", + OutputDir: filepath.Join("tmp", "out"), + AutoRetryCount: 0, + }) + if cfg.AutoRetryCount != client.DefaultAutoRetryCount { + t.Fatalf("auto retry count=%d want default %d", cfg.AutoRetryCount, client.DefaultAutoRetryCount) + } + cfg = normalizeConfig(Config{ + Prompt: "hello", + OutputDir: filepath.Join("tmp", "out"), + AutoRetryCount: client.MaxAutoRetryCount + 5, + }) + if cfg.AutoRetryCount != client.MaxAutoRetryCount { + t.Fatalf("auto retry count=%d want max %d", cfg.AutoRetryCount, client.MaxAutoRetryCount) + } +} + +func TestNormalizeConfigNormalizesResponsesTransportAndReasoning(t *testing.T) { + cfg := normalizeConfig(Config{ + Prompt: "hello", + OutputDir: filepath.Join("tmp", "out"), + ResponsesTransport: client.ResponsesTransport("invalid"), + ReasoningEffort: "invalid", + }) + if cfg.ResponsesTransport != client.ResponsesTransportSSE { + t.Fatalf("responses transport=%q want %q", cfg.ResponsesTransport, client.ResponsesTransportSSE) + } + if cfg.ReasoningEffort != client.DefaultReasoningEffort { + t.Fatalf("reasoning effort=%q want %q", cfg.ReasoningEffort, client.DefaultReasoningEffort) + } + + cfg = normalizeConfig(Config{ + Prompt: "hello", + OutputDir: filepath.Join("tmp", "out"), + ResponsesTransport: client.ResponsesTransportWebSocket, + ReasoningEffort: "high", + }) + if cfg.ResponsesTransport != client.ResponsesTransportWebSocket { + t.Fatalf("responses transport=%q want websocket", cfg.ResponsesTransport) + } + if cfg.ReasoningEffort != "high" { + t.Fatalf("reasoning effort=%q want high", cfg.ReasoningEffort) + } +} + func TestBuildImageNameMapsJPEGExtension(t *testing.T) { got := buildImageName(client.ModeEdit, "A cat wearing sunglasses", "20260531-120000", "jpeg") want := "image-edit-a-cat-wearing-sunglasses-20260531-120000.jpg" diff --git a/gio-client/internal/promptipc/common.go b/gio-client/internal/promptipc/common.go new file mode 100644 index 000000000..b9e966338 --- /dev/null +++ b/gio-client/internal/promptipc/common.go @@ -0,0 +1,127 @@ +package promptipc + +import ( + "bufio" + "encoding/json" + "errors" + "net" + "os" + "strings" + "sync" + "time" +) + +type Message struct { + Type string `json:"type"` + Token string `json:"token,omitempty"` +} + +const ( + MessageTypePing = "ping" + MessageTypeRaise = "raise" + MessageTypeToken = "token" + MessageTypeInvalid = "invalid" +) + +type Server struct { + listener net.Listener + network string + address string + once sync.Once +} + +func TryStart(handler func(Message)) (*Server, bool, error) { + network, address, err := endpoint() + if err != nil { + return nil, false, err + } + if network == "unix" { + if _, statErr := os.Stat(address); statErr == nil { + if err := Send(Message{Type: MessageTypePing}); err == nil { + return nil, true, nil + } + _ = os.Remove(address) + } + } else if err := Send(Message{Type: MessageTypePing}); err == nil { + return nil, true, nil + } + listener, err := net.Listen(network, address) + if err != nil { + if sendErr := Send(Message{Type: MessageTypePing}); sendErr == nil { + return nil, true, nil + } + return nil, false, err + } + server := &Server{ + listener: listener, + network: network, + address: address, + } + go server.serve(handler) + return server, false, nil +} + +func (s *Server) Close() error { + if s == nil { + return nil + } + var closeErr error + s.once.Do(func() { + closeErr = s.listener.Close() + if s.network == "unix" { + _ = os.Remove(s.address) + } + }) + return closeErr +} + +func Send(msg Message) error { + network, address, err := endpoint() + if err != nil { + return err + } + conn, err := net.DialTimeout(network, address, 500*time.Millisecond) + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetWriteDeadline(time.Now().Add(500 * time.Millisecond)) + return json.NewEncoder(conn).Encode(msg) +} + +func SendRaise() error { + return Send(Message{Type: MessageTypeRaise}) +} + +func SendToken(token string) error { + token = strings.TrimSpace(token) + if token == "" { + return errors.New("token is empty") + } + return Send(Message{Type: MessageTypeToken, Token: token}) +} + +func SendInvalid() error { + return Send(Message{Type: MessageTypeInvalid}) +} + +func (s *Server) serve(handler func(Message)) { + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + reader := bufio.NewReader(conn) + var msg Message + if err := json.NewDecoder(reader).Decode(&msg); err != nil { + return + } + if handler != nil { + handler(msg) + } + }(conn) + } +} diff --git a/gio-client/internal/promptipc/endpoint_linux.go b/gio-client/internal/promptipc/endpoint_linux.go new file mode 100644 index 000000000..ed62a701f --- /dev/null +++ b/gio-client/internal/promptipc/endpoint_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package promptipc + +import ( + "os" + "path/filepath" + + gioCompat "image-studio/gio-client/internal/compat" +) + +func endpoint() (string, string, error) { + root, err := gioCompat.StableDataRoot() + if err != nil { + return "", "", err + } + controlDir := filepath.Join(root, "control") + if err := os.MkdirAll(controlDir, 0o700); err != nil { + return "", "", err + } + return "unix", filepath.Join(controlDir, "prompt-import.sock"), nil +} diff --git a/gio-client/internal/promptipc/endpoint_unsupported.go b/gio-client/internal/promptipc/endpoint_unsupported.go new file mode 100644 index 000000000..9378f26bc --- /dev/null +++ b/gio-client/internal/promptipc/endpoint_unsupported.go @@ -0,0 +1,9 @@ +//go:build !windows && !linux + +package promptipc + +import "fmt" + +func endpoint() (string, string, error) { + return "", "", fmt.Errorf("prompt import control is not supported on this platform") +} diff --git a/gio-client/internal/promptipc/endpoint_windows.go b/gio-client/internal/promptipc/endpoint_windows.go new file mode 100644 index 000000000..d1abf030e --- /dev/null +++ b/gio-client/internal/promptipc/endpoint_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package promptipc + +import ( + "fmt" + "hash/fnv" + + gioCompat "image-studio/gio-client/internal/compat" +) + +func endpoint() (string, string, error) { + root, err := gioCompat.StableDataRoot() + if err != nil { + return "", "", err + } + hash := fnv.New32a() + _, _ = hash.Write([]byte(root)) + port := 42000 + int(hash.Sum32()%6000) + return "tcp", fmt.Sprintf("127.0.0.1:%d", port), nil +} diff --git a/gio-client/internal/promptscheme/common.go b/gio-client/internal/promptscheme/common.go new file mode 100644 index 000000000..fed4eae82 --- /dev/null +++ b/gio-client/internal/promptscheme/common.go @@ -0,0 +1,50 @@ +package promptscheme + +import ( + "os" + "path/filepath" + "strings" +) + +const Scheme = "image-studio" + +type Status struct { + Registered bool + Handler string + Detail string +} + +func RegisterCurrentExecutable() error { + executable, err := currentExecutable() + if err != nil { + return err + } + return RegisterExecutable(executable) +} + +func UnregisterCurrentExecutable() error { + executable, err := currentExecutable() + if err != nil { + return err + } + return UnregisterExecutable(executable) +} + +func StatusForCurrentExecutable() (Status, error) { + executable, err := currentExecutable() + if err != nil { + return Status{}, err + } + return StatusForExecutable(executable) +} + +func currentExecutable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", err + } + if abs, absErr := filepath.Abs(executable); absErr == nil { + executable = abs + } + return strings.TrimSpace(executable), nil +} diff --git a/gio-client/internal/promptscheme/linux.go b/gio-client/internal/promptscheme/linux.go new file mode 100644 index 000000000..3923752fd --- /dev/null +++ b/gio-client/internal/promptscheme/linux.go @@ -0,0 +1,91 @@ +//go:build linux + +package promptscheme + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const linuxDesktopFileName = "image-studio-gio.desktop" + +func RegisterExecutable(executable string) error { + executable = strings.TrimSpace(executable) + if executable == "" { + return fmt.Errorf("executable path is empty") + } + desktopPath, err := desktopFilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil { + return err + } + content := fmt.Sprintf(`[Desktop Entry] +Name=Image Studio Gio +Exec="%s" %%u +Type=Application +NoDisplay=true +Terminal=false +MimeType=x-scheme-handler/%s; +Categories=Graphics; +`, executable, Scheme) + if err := os.WriteFile(desktopPath, []byte(content), 0o644); err != nil { + return err + } + if path, lookErr := exec.LookPath("xdg-mime"); lookErr == nil { + if output, runErr := exec.Command(path, "default", linuxDesktopFileName, "x-scheme-handler/"+Scheme).CombinedOutput(); runErr != nil { + return fmt.Errorf("xdg-mime default failed: %s", strings.TrimSpace(string(output))) + } + } + return nil +} + +func UnregisterExecutable(_ string) error { + desktopPath, err := desktopFilePath() + if err != nil { + return err + } + if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func StatusForExecutable(executable string) (Status, error) { + desktopPath, err := desktopFilePath() + if err != nil { + return Status{}, err + } + data, readErr := os.ReadFile(desktopPath) + if readErr != nil { + return Status{}, nil + } + handler := strings.TrimSpace(string(data)) + registered := strings.Contains(handler, strings.TrimSpace(executable)) + if path, lookErr := exec.LookPath("xdg-mime"); lookErr == nil { + if output, runErr := exec.Command(path, "query", "default", "x-scheme-handler/"+Scheme).Output(); runErr == nil { + desktopName := strings.TrimSpace(string(output)) + if desktopName != "" { + registered = registered && desktopName == linuxDesktopFileName + handler = desktopName + } + } + } + return Status{ + Registered: registered, + Handler: handler, + Detail: desktopPath, + }, nil +} + +func desktopFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "applications", linuxDesktopFileName), nil +} diff --git a/gio-client/internal/promptscheme/unsupported.go b/gio-client/internal/promptscheme/unsupported.go new file mode 100644 index 000000000..ddb0a897b --- /dev/null +++ b/gio-client/internal/promptscheme/unsupported.go @@ -0,0 +1,17 @@ +//go:build !windows && !linux + +package promptscheme + +import "fmt" + +func RegisterExecutable(_ string) error { + return fmt.Errorf("protocol registration is not supported on this platform") +} + +func UnregisterExecutable(_ string) error { + return fmt.Errorf("protocol registration is not supported on this platform") +} + +func StatusForExecutable(_ string) (Status, error) { + return Status{}, fmt.Errorf("protocol registration is not supported on this platform") +} diff --git a/gio-client/internal/promptscheme/windows.go b/gio-client/internal/promptscheme/windows.go new file mode 100644 index 000000000..3b4ea8127 --- /dev/null +++ b/gio-client/internal/promptscheme/windows.go @@ -0,0 +1,83 @@ +//go:build windows + +package promptscheme + +import ( + "fmt" + "strings" + + "golang.org/x/sys/windows/registry" +) + +const windowsProtocolRegistryPath = `Software\Classes\image-studio` + +func RegisterExecutable(executable string) error { + executable = strings.TrimSpace(executable) + if executable == "" { + return fmt.Errorf("executable path is empty") + } + key, _, err := registry.CreateKey(registry.CURRENT_USER, windowsProtocolRegistryPath, registry.SET_VALUE|registry.QUERY_VALUE) + if err != nil { + return err + } + defer key.Close() + if err := key.SetStringValue("", "URL:Image-Studio Prompt Import"); err != nil { + return err + } + if err := key.SetStringValue("URL Protocol", ""); err != nil { + return err + } + iconKey, _, err := registry.CreateKey(registry.CURRENT_USER, windowsProtocolRegistryPath+`\DefaultIcon`, registry.SET_VALUE) + if err == nil { + _ = iconKey.SetStringValue("", executable+",0") + iconKey.Close() + } + commandKey, _, err := registry.CreateKey(registry.CURRENT_USER, windowsProtocolRegistryPath+`\shell\open\command`, registry.SET_VALUE) + if err != nil { + return err + } + defer commandKey.Close() + return commandKey.SetStringValue("", fmt.Sprintf(`"%s" "%%1"`, executable)) +} + +func UnregisterExecutable(executable string) error { + status, err := StatusForExecutable(executable) + if err != nil { + return err + } + if !status.Registered { + return nil + } + paths := []string{ + windowsProtocolRegistryPath + `\shell\open\command`, + windowsProtocolRegistryPath + `\shell\open`, + windowsProtocolRegistryPath + `\shell`, + windowsProtocolRegistryPath + `\DefaultIcon`, + windowsProtocolRegistryPath, + } + for _, path := range paths { + if err := registry.DeleteKey(registry.CURRENT_USER, path); err != nil && err != registry.ErrNotExist { + return err + } + } + return nil +} + +func StatusForExecutable(executable string) (Status, error) { + commandKey, err := registry.OpenKey(registry.CURRENT_USER, windowsProtocolRegistryPath+`\shell\open\command`, registry.QUERY_VALUE) + if err != nil { + return Status{}, nil + } + defer commandKey.Close() + command, _, err := commandKey.GetStringValue("") + if err != nil { + return Status{}, nil + } + normalizedCommand := strings.ToLower(strings.TrimSpace(command)) + normalizedExecutable := strings.ToLower(strings.TrimSpace(executable)) + return Status{ + Registered: normalizedExecutable != "" && strings.Contains(normalizedCommand, normalizedExecutable), + Handler: command, + Detail: windowsProtocolRegistryPath, + }, nil +} diff --git a/gio-client/internal/ui/app.go b/gio-client/internal/ui/app.go index af6a68b73..9ff05c95e 100644 --- a/gio-client/internal/ui/app.go +++ b/gio-client/internal/ui/app.go @@ -21,6 +21,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "github.com/yuanhua/image-gptcodex/pkg/promptimport" ) type resultState struct { @@ -52,6 +53,7 @@ type snapshot struct { SettingsSelectedProfileID string SelectedHistoryID string PromptHistory []string + PromptTemplates []sharedCompat.PromptTemplate Presets []sharedCompat.Preset OptimizingPrompt bool TestingUpstream bool @@ -72,6 +74,14 @@ type snapshot struct { CompareSplit float32 Result resultState SavePromptVisible bool + PromptImportVisible bool + PromptImportLoading bool + PromptImportToken string + PromptImportPayload *promptimport.ImportPayload + PromptImportResolvedSize string + PromptImportRegisterOpen bool + PromptImportRegisterBusy bool + PromptImportRegisterNote string } type cachedImage struct { @@ -127,6 +137,17 @@ type workspaceState struct { StyleTag string SeedText string BatchCount int + LoopEnabled bool + LoopTotalCount int + LoopConcurrency int + LoopAutoSave bool + LoopAutoSaveDir string + LoopLivePreview bool + BatchMode bool + BatchInputDir string + BatchOutputDir string + BatchRetryOnFail bool + BatchAutoAspect string SourcePathsText string ResultSavedPath string ResultRawPath string @@ -171,28 +192,53 @@ type App struct { proxyURLInput widget.Editor userIdentifierInput widget.Editor savePromptPathInput widget.Editor + promptTemplateLabelInput widget.Editor + promptTemplateTextInput widget.Editor + presetNameInput widget.Editor + loopTotalCountInput widget.Editor + loopConcurrencyInput widget.Editor + loopAutoSaveDirInput widget.Editor + batchInputDirInput widget.Editor + batchOutputDirInput widget.Editor rawResponseViewerInput widget.Editor historyQueryInput widget.Editor historyTimelineQueryInput widget.Editor workspaceNameInput widget.Editor - mode string - api string - size string - quality string - format string - policy string - proxy string - background string - inputFidelity string - imageStyle string - moderation string - styleTag string - themeMode string - fontScale float64 - reducedEffects bool - imagesNewAPICompat bool - batchCount int + mode string + api string + size string + quality string + format string + policy string + responsesTransport string + reasoningEffort string + fallbackProfileID string + proxy string + background string + inputFidelity string + imageStyle string + moderation string + styleTag string + protectStreamPreview bool + autoRetryEnabled bool + autoRetryCount int + loopEnabled bool + loopTotalCount int + loopConcurrency int + loopAutoSave bool + loopAutoSaveDir string + loopLivePreview bool + batchMode bool + batchInputDir string + batchOutputDir string + batchRetryOnFail bool + batchAutoAspect string + themeMode string + fontScale float64 + reducedEffects bool + imagesNewAPICompat bool + batchCount int modeButtons []widget.Clickable apiButtons []widget.Clickable @@ -207,12 +253,16 @@ type App struct { qualityButtons []widget.Clickable formatButtons []widget.Clickable policyButtons []widget.Clickable + responsesTransportButtons []widget.Clickable + reasoningEffortButtons []widget.Clickable proxyButtons []widget.Clickable backgroundButtons []widget.Clickable inputFidelityButtons []widget.Clickable imageStyleButtons []widget.Clickable moderationButtons []widget.Clickable + composeSourceModeButtons []widget.Clickable partialPreviewButtons []widget.Clickable + protectStreamPreviewButtons []widget.Clickable historyModeButtons []widget.Clickable historyDateButtons []widget.Clickable historyTimelineModeButtons []widget.Clickable @@ -239,10 +289,35 @@ type App struct { clearSourcesButton widget.Clickable addSourceFilesButton widget.Clickable addSourceStripButton widget.Clickable + chooseBatchInputDirButton widget.Clickable + chooseBatchFilesButton widget.Clickable + chooseBatchOutputDirButton widget.Clickable + toggleLoopButton widget.Clickable + chooseLoopAutoSaveDirButton widget.Clickable emptyStateImportButton widget.Clickable promptHelperButton widget.Clickable promptHelperTemplatesButton widget.Clickable + promptHelperPresetsButton widget.Clickable promptHelperHistoryButton widget.Clickable + openPromptTemplateManagerButton widget.Clickable + openPresetManagerFromPromptButton widget.Clickable + newPromptTemplateButton widget.Clickable + savePromptTemplateButton widget.Clickable + deletePromptTemplateButton widget.Clickable + promptTemplateListButtons map[string]*widget.Clickable + openPresetManagerButton widget.Clickable + saveCurrentPresetButton widget.Clickable + closePresetManagerButton widget.Clickable + newPresetButton widget.Clickable + savePresetButton widget.Clickable + overwritePresetButton widget.Clickable + applyPresetButton widget.Clickable + deletePresetButton widget.Clickable + presetListButtons map[string]*widget.Clickable + openCustomAspectRatioManagerButton widget.Clickable + addCustomAspectRatioButton widget.Clickable + deleteCustomAspectRatioButton widget.Clickable + customAspectRatioListButtons map[string]*widget.Clickable closePromptHelperButton widget.Clickable optimizePromptButton widget.Clickable testUpstreamButton widget.Clickable @@ -258,10 +333,17 @@ type App struct { saveSettingsButton widget.Clickable closeGeneralSettingsButton widget.Clickable copyGeneralPerformanceDiagnosticsButton widget.Clickable + previewCompletionSoundButton widget.Clickable + chooseCompletionSoundButton widget.Clickable + resetCompletionSoundButton widget.Clickable + requestCompletionNotificationButton widget.Clickable generalRuntimePickerButton widget.Clickable openGeneralUpstreamButton widget.Clickable openGeneralOutputButton widget.Clickable chooseGeneralOutputButton widget.Clickable + chooseGeneralBatchInputButton widget.Clickable + chooseGeneralBatchFilesButton widget.Clickable + chooseGeneralBatchOutputButton widget.Clickable resetGeneralOutputButton widget.Clickable triggerGeneralHistoryMediaBackfillButton widget.Clickable openGeneralHistoryTimelineButton widget.Clickable @@ -285,6 +367,27 @@ type App struct { generalFontScaleButtons []widget.Clickable generalPerformanceButtons []widget.Clickable generalSavePromptButtons []widget.Clickable + generalCompletionSoundButtons []widget.Clickable + generalCompletionSoundModeButtons []widget.Clickable + generalCompletionNotificationButtons []widget.Clickable + generalCleanupPreviewCacheButtons []widget.Clickable + generalProtectStreamPreviewButtons []widget.Clickable + generalAutoRetryButtons []widget.Clickable + generalAutoRetryCountButtons []widget.Clickable + generalLoopButtons []widget.Clickable + generalLoopAutoSaveButtons []widget.Clickable + generalBatchButtons []widget.Clickable + generalBatchRetryButtons []widget.Clickable + generalBatchAutoAspectButtons []widget.Clickable + generalBatchAutoAspectResolutionButtons []widget.Clickable + composeBatchRetryButtons []widget.Clickable + composeBatchAutoAspectButtons []widget.Clickable + composeBatchAutoAspectResolutionButtons []widget.Clickable + composeLoopButtons []widget.Clickable + composeLoopCountButtons []widget.Clickable + composeLoopConcurrencyButtons []widget.Clickable + composeLoopAutoSaveButtons []widget.Clickable + composeLoopPreviewButtons []widget.Clickable generalProxyButtons []widget.Clickable generalKeepLogsButtons []widget.Clickable headerAddWorkspaceButton widget.Clickable @@ -329,6 +432,10 @@ type App struct { savePromptSaveButton widget.Clickable savePromptSkipButton widget.Clickable savePromptNeverAsk widget.Bool + promptImportConfirmButton widget.Clickable + promptImportCloseButton widget.Clickable + promptImportRegisterNowButton widget.Clickable + promptImportRegisterLaterButton widget.Clickable mu sync.Mutex running bool @@ -341,8 +448,10 @@ type App struct { history []sharedCompat.HistoryItem profiles []sharedCompat.UpstreamProfile promptHistory []string + promptTemplates []sharedCompat.PromptTemplate promptHistoryRev int presets []sharedCompat.Preset + customAspectRatios []sharedCompat.CustomAspectRatio historyThumbBackfillInFlight map[string]struct{} activeProfileID string selectedHistoryID string @@ -437,47 +546,69 @@ type App struct { compareSplitSlider widget.Float compareSplitDrag gesture.Drag - savePromptVisible bool - savePromptSuppressed bool - keepLogs bool - kernelRuntimeMode string - savePromptSourcePath string - composeOpen bool - advancedOpen bool - profilePickerOpen bool - historyRailCollapsed bool - historyModeFilter string - historyDateFilter string - historyTimelineOpen bool - historyTimelineModeFilter string - historyTimelineDateFilter string - historyTimelineModePickerOpen bool - historyTimelineDatePickerOpen bool - profileButtons map[string]*widget.Clickable - settingsProfileButtons map[string]*widget.Clickable - historyButtons map[string]*widget.Clickable - promptButtons map[string]*widget.Clickable - sourceButtons map[string]*widget.Clickable - historyActionButtons map[string]*widget.Clickable - expandedPromptGroups map[string]bool - promptHelperOpen bool - promptHelperTab string - activePromptGroup historyPromptGroup - generalSettingsOpen bool - generalRuntimePickerOpen bool - aboutModalOpen bool - settingsModalOpen bool - settingsHelpOpen bool - settingsSelectedProfileID string - apiKeyVisible bool - workspaces []workspaceState - activeWorkspaceID string - workspaceButtons map[string]*widget.Clickable - closeWorkspaceButtons map[string]*widget.Clickable - workspaceRenameID string - workspaceLastClickID string - workspaceLastClickAt time.Time - headerQuoteIndex int + savePromptVisible bool + savePromptSuppressed bool + completionSound sharedCompat.CompletionSoundSettings + completionNotification sharedCompat.CompletionNotificationSettings + completionNotificationPermission systemNotificationPermissionState + keepLogs bool + cleanupPreviewCacheOnExit bool + windowFocused bool + kernelRuntimeMode string + savePromptSourcePath string + promptImportOpen bool + promptImportLoading bool + promptImportToken string + promptImportPayload *promptimport.ImportPayload + promptImportResolvedSize string + promptImportQueue []string + promptImportRegisterOpen bool + promptImportRegisterBusy bool + promptImportRegisterNote string + composeOpen bool + advancedOpen bool + profilePickerOpen bool + historyRailCollapsed bool + historyModeFilter string + historyDateFilter string + historyTimelineOpen bool + historyTimelineModeFilter string + historyTimelineDateFilter string + historyTimelineModePickerOpen bool + historyTimelineDatePickerOpen bool + profileButtons map[string]*widget.Clickable + settingsProfileButtons map[string]*widget.Clickable + historyButtons map[string]*widget.Clickable + promptButtons map[string]*widget.Clickable + sourceButtons map[string]*widget.Clickable + historyActionButtons map[string]*widget.Clickable + expandedPromptGroups map[string]bool + promptHelperOpen bool + promptHelperTab string + promptTemplateManagerOpen bool + selectedPromptTemplateID string + presetManagerOpen bool + selectedPresetID string + customAspectRatioManagerOpen bool + customAspectWidthInput widget.Editor + customAspectHeightInput widget.Editor + selectedCustomAspectRatioID string + activePromptGroup historyPromptGroup + generalSettingsOpen bool + generalRuntimePickerOpen bool + aboutModalOpen bool + settingsModalOpen bool + settingsHelpOpen bool + settingsSelectedProfileID string + apiKeyVisible bool + workspaces []workspaceState + activeWorkspaceID string + workspaceButtons map[string]*widget.Clickable + closeWorkspaceButtons map[string]*widget.Clickable + workspaceRenameID string + workspaceLastClickID string + workspaceLastClickAt time.Time + headerQuoteIndex int invalidate func() window *app.Window @@ -508,92 +639,145 @@ func New() *App { } th.TextSize = unit.Sp(float32(14) * float32(fontScale)) a := &App{ - th: th, - runner: kernel.Runner{}, - mode: string(cfg.Mode), - api: string(cfg.APIMode), - size: cfg.Size, - quality: cfg.Quality, - format: cfg.OutputFormat, - policy: string(cfg.RequestPolicy), - proxy: cfg.ProxyMode, - background: cfg.Background, - inputFidelity: cfg.InputFidelity, - imageStyle: cfg.ImageStyle, - moderation: cfg.Moderation, - styleTag: "", - themeMode: themeMode, - fontScale: fontScale, - reducedEffects: compatState.Settings.ReducedEffects, - imagesNewAPICompat: cfg.ImagesNewAPICompat, - kernelRuntimeMode: normalizeKernelRuntimeMode(compatState.Settings.KernelRuntimeMode), - batchCount: 1, - themeButtons: make([]widget.Clickable, 3), - generalThemeButtons: make([]widget.Clickable, 3), - generalRuntimeButtons: make([]widget.Clickable, 3), - generalFontScaleButtons: make([]widget.Clickable, 3), - generalPerformanceButtons: make([]widget.Clickable, 2), - generalSavePromptButtons: make([]widget.Clickable, 2), - generalProxyButtons: make([]widget.Clickable, len(proxyChoices)), - generalKeepLogsButtons: make([]widget.Clickable, 2), - pruneGeneralHistoryButtons: make([]widget.Clickable, 2), - modeButtons: make([]widget.Clickable, len(modeChoices)), - apiButtons: make([]widget.Clickable, len(apiChoices)), - sizeButtons: make([]widget.Clickable, len(sizeChoices)), - aspectButtons: make([]widget.Clickable, len(aspectChoices)), - styleButtons: make([]widget.Clickable, len(styleChoices)), - batchCountButtons: make([]widget.Clickable, len(batchCountChoices)), - resolutionButtons: make([]widget.Clickable, len(resolutionChoices)), - qualityButtons: make([]widget.Clickable, len(qualityChoices)), - formatButtons: make([]widget.Clickable, len(formatChoices)), - policyButtons: make([]widget.Clickable, len(policyChoices)), - proxyButtons: make([]widget.Clickable, len(proxyChoices)), - backgroundButtons: make([]widget.Clickable, len(backgroundChoices)), - inputFidelityButtons: make([]widget.Clickable, len(inputFidelityChoices)), - imageStyleButtons: make([]widget.Clickable, len(imageStyleChoices)), - moderationButtons: make([]widget.Clickable, len(moderationChoices)), - partialPreviewButtons: make([]widget.Clickable, len(partialPreviewChoices)), - historyModeButtons: make([]widget.Clickable, 3), - historyDateButtons: make([]widget.Clickable, 3), - historyTimelineModeButtons: make([]widget.Clickable, 3), - historyTimelineDateButtons: make([]widget.Clickable, 3), - status: "Gio 原生客户端就绪", - logs: []string{"独立 Gio 高性能测试客户端已启动。"}, - logsRev: 1, - logsSnapshotRev: -1, - history: append([]sharedCompat.HistoryItem(nil), compatState.History...), - profiles: append([]sharedCompat.UpstreamProfile(nil), compatState.Profiles...), - promptHistory: append([]string(nil), compatState.Settings.PromptHistory...), - promptHistoryRev: 1, - presets: append([]sharedCompat.Preset(nil), compatState.Settings.Presets...), - savePromptSuppressed: gioCompat.SavePromptSuppressed(compatState), - keepLogs: compatState.Settings.KeepLogs, - imageCache: map[string]cachedImage{}, - historyRev: 1, - composeOpen: false, - advancedOpen: false, - profilePickerOpen: false, - historyRailCollapsed: false, - historyModeFilter: "all", - historyDateFilter: "all", - historyTimelineModeFilter: "all", - historyTimelineDateFilter: "all", - profileButtons: map[string]*widget.Clickable{}, - settingsProfileButtons: map[string]*widget.Clickable{}, - historyButtons: map[string]*widget.Clickable{}, - promptButtons: map[string]*widget.Clickable{}, - sourceButtons: map[string]*widget.Clickable{}, - historyActionButtons: map[string]*widget.Clickable{}, - workspaceButtons: map[string]*widget.Clickable{}, - closeWorkspaceButtons: map[string]*widget.Clickable{}, - expandedPromptGroups: map[string]bool{}, - promptHelperOpen: false, - promptHelperTab: "templates", - headerQuoteIndex: initialHeaderQuoteIndex(time.Now()), + th: th, + runner: kernel.Runner{}, + mode: string(cfg.Mode), + api: string(cfg.APIMode), + size: cfg.Size, + quality: cfg.Quality, + format: cfg.OutputFormat, + policy: string(cfg.RequestPolicy), + responsesTransport: normalizeProfileResponsesTransport(string(cfg.ResponsesTransport)), + reasoningEffort: normalizeReasoningEffort(cfg.ReasoningEffort), + proxy: cfg.ProxyMode, + background: cfg.Background, + inputFidelity: cfg.InputFidelity, + imageStyle: cfg.ImageStyle, + moderation: cfg.Moderation, + styleTag: "", + protectStreamPreview: cfg.ProtectStreamPreview, + autoRetryEnabled: cfg.AutoRetryEnabled, + autoRetryCount: normalizeAutoRetryCount(cfg.AutoRetryCount), + loopEnabled: false, + loopTotalCount: 10, + loopConcurrency: 2, + loopAutoSave: false, + loopAutoSaveDir: "", + loopLivePreview: true, + batchMode: false, + batchInputDir: "", + batchOutputDir: "", + batchRetryOnFail: false, + batchAutoAspect: "", + themeMode: themeMode, + fontScale: fontScale, + reducedEffects: compatState.Settings.ReducedEffects, + imagesNewAPICompat: cfg.ImagesNewAPICompat, + kernelRuntimeMode: normalizeKernelRuntimeMode(compatState.Settings.KernelRuntimeMode), + completionSound: normaliseCompletionSoundSettings(compatState.Settings.CompletionSound), + completionNotification: normaliseCompletionNotificationSettings(compatState.Settings.CompletionNotification), + completionNotificationPermission: readSystemNotificationPermission(), + cleanupPreviewCacheOnExit: compatState.Settings.CleanupPreviewCacheOnExit, + windowFocused: true, + batchCount: 1, + themeButtons: make([]widget.Clickable, 3), + generalThemeButtons: make([]widget.Clickable, 3), + generalRuntimeButtons: make([]widget.Clickable, 3), + generalFontScaleButtons: make([]widget.Clickable, 3), + generalPerformanceButtons: make([]widget.Clickable, 2), + generalSavePromptButtons: make([]widget.Clickable, 2), + generalCompletionSoundButtons: make([]widget.Clickable, 2), + generalCompletionSoundModeButtons: make([]widget.Clickable, 2), + generalCompletionNotificationButtons: make([]widget.Clickable, 2), + generalCleanupPreviewCacheButtons: make([]widget.Clickable, 2), + generalProtectStreamPreviewButtons: make([]widget.Clickable, 2), + generalAutoRetryButtons: make([]widget.Clickable, 2), + generalAutoRetryCountButtons: make([]widget.Clickable, 5), + generalLoopButtons: make([]widget.Clickable, 2), + generalLoopAutoSaveButtons: make([]widget.Clickable, 2), + generalBatchButtons: make([]widget.Clickable, 2), + generalBatchRetryButtons: make([]widget.Clickable, 2), + generalBatchAutoAspectButtons: make([]widget.Clickable, 2), + generalBatchAutoAspectResolutionButtons: make([]widget.Clickable, 5), + composeBatchRetryButtons: make([]widget.Clickable, 2), + composeBatchAutoAspectButtons: make([]widget.Clickable, 2), + composeBatchAutoAspectResolutionButtons: make([]widget.Clickable, 5), + composeLoopButtons: make([]widget.Clickable, 2), + composeLoopCountButtons: make([]widget.Clickable, 5), + composeLoopConcurrencyButtons: make([]widget.Clickable, 4), + composeLoopAutoSaveButtons: make([]widget.Clickable, 2), + composeLoopPreviewButtons: make([]widget.Clickable, 2), + generalProxyButtons: make([]widget.Clickable, len(proxyChoices)), + generalKeepLogsButtons: make([]widget.Clickable, 2), + pruneGeneralHistoryButtons: make([]widget.Clickable, 2), + modeButtons: make([]widget.Clickable, len(modeChoices)), + apiButtons: make([]widget.Clickable, len(apiChoices)), + sizeButtons: make([]widget.Clickable, len(sizeChoices)), + aspectButtons: make([]widget.Clickable, len(aspectChoices)+len(compatState.Settings.CustomAspectRatios)+24), + styleButtons: make([]widget.Clickable, len(styleChoices)), + batchCountButtons: make([]widget.Clickable, len(batchCountChoices)), + resolutionButtons: make([]widget.Clickable, len(resolutionChoices)), + qualityButtons: make([]widget.Clickable, len(qualityChoices)), + formatButtons: make([]widget.Clickable, len(formatChoices)), + policyButtons: make([]widget.Clickable, len(policyChoices)), + responsesTransportButtons: make([]widget.Clickable, len(responsesTransportChoices)), + reasoningEffortButtons: make([]widget.Clickable, len(reasoningEffortChoices)), + proxyButtons: make([]widget.Clickable, len(proxyChoices)), + backgroundButtons: make([]widget.Clickable, len(backgroundChoices)), + inputFidelityButtons: make([]widget.Clickable, len(inputFidelityChoices)), + imageStyleButtons: make([]widget.Clickable, len(imageStyleChoices)), + moderationButtons: make([]widget.Clickable, len(moderationChoices)), + composeSourceModeButtons: make([]widget.Clickable, 2), + partialPreviewButtons: make([]widget.Clickable, len(partialPreviewChoices)), + protectStreamPreviewButtons: make([]widget.Clickable, 2), + historyModeButtons: make([]widget.Clickable, 3), + historyDateButtons: make([]widget.Clickable, 3), + historyTimelineModeButtons: make([]widget.Clickable, 3), + historyTimelineDateButtons: make([]widget.Clickable, 3), + status: "Gio 原生客户端就绪", + logs: []string{"独立 Gio 高性能测试客户端已启动。"}, + logsRev: 1, + logsSnapshotRev: -1, + history: append([]sharedCompat.HistoryItem(nil), compatState.History...), + profiles: append([]sharedCompat.UpstreamProfile(nil), compatState.Profiles...), + promptHistory: append([]string(nil), compatState.Settings.PromptHistory...), + promptTemplates: append([]sharedCompat.PromptTemplate(nil), compatState.Settings.PromptTemplates...), + promptHistoryRev: 1, + presets: append([]sharedCompat.Preset(nil), compatState.Settings.Presets...), + customAspectRatios: append([]sharedCompat.CustomAspectRatio(nil), compatState.Settings.CustomAspectRatios...), + savePromptSuppressed: gioCompat.SavePromptSuppressed(compatState), + keepLogs: compatState.Settings.KeepLogs, + promptImportResolvedSize: "auto", + imageCache: map[string]cachedImage{}, + historyRev: 1, + composeOpen: false, + advancedOpen: false, + profilePickerOpen: false, + historyRailCollapsed: false, + historyModeFilter: "all", + historyDateFilter: "all", + historyTimelineModeFilter: "all", + historyTimelineDateFilter: "all", + profileButtons: map[string]*widget.Clickable{}, + settingsProfileButtons: map[string]*widget.Clickable{}, + historyButtons: map[string]*widget.Clickable{}, + promptButtons: map[string]*widget.Clickable{}, + promptTemplateListButtons: map[string]*widget.Clickable{}, + presetListButtons: map[string]*widget.Clickable{}, + customAspectRatioListButtons: map[string]*widget.Clickable{}, + sourceButtons: map[string]*widget.Clickable{}, + historyActionButtons: map[string]*widget.Clickable{}, + workspaceButtons: map[string]*widget.Clickable{}, + closeWorkspaceButtons: map[string]*widget.Clickable{}, + expandedPromptGroups: map[string]bool{}, + promptHelperOpen: false, + promptHelperTab: "templates", + headerQuoteIndex: initialHeaderQuoteIndex(time.Now()), } if profile, ok := gioCompat.ActiveProfile(compatState); ok { a.activeProfileID = profile.ID a.profileNameInput.SetText(strings.TrimSpace(profile.Name)) + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) if profile.ConcurrencyLimit > 0 { a.concurrencyLimitInput.SetText(strconv.Itoa(profile.ConcurrencyLimit)) } @@ -629,6 +813,7 @@ func New() *App { a.initWorkspaces() a.scheduleHistoryThumbPrewarm(historyThumbPrewarmDelay) a.scheduleHistoryThumbBackfill(historyBackfillStartupDelay) + a.schedulePromptImportRegistrationCheck() return a } @@ -647,6 +832,13 @@ func (a *App) configureEditors(cfg kernel.Config) { &a.proxyURLInput, &a.userIdentifierInput, &a.savePromptPathInput, + &a.promptTemplateLabelInput, + &a.presetNameInput, + &a.loopTotalCountInput, + &a.loopConcurrencyInput, + &a.loopAutoSaveDirInput, + &a.batchInputDirInput, + &a.batchOutputDirInput, &a.historyQueryInput, &a.historyTimelineQueryInput, &a.workspaceNameInput, @@ -685,6 +877,13 @@ func (a *App) applyRuntimeConfig(cfg kernel.Config) { a.inputFidelity = cfg.InputFidelity a.imageStyle = cfg.ImageStyle a.moderation = cfg.Moderation + a.responsesTransport = normalizeProfileResponsesTransport(string(cfg.ResponsesTransport)) + a.reasoningEffort = normalizeReasoningEffort(cfg.ReasoningEffort) + a.fallbackProfileID = strings.TrimSpace(cfg.FallbackProfileID) + a.completionSound = normaliseCompletionSoundSettings(&cfg.CompletionSound) + a.protectStreamPreview = cfg.ProtectStreamPreview + a.autoRetryEnabled = cfg.AutoRetryEnabled + a.autoRetryCount = normalizeAutoRetryCount(cfg.AutoRetryCount) a.proxyURLInput.SetText(cfg.ProxyURL) a.outputDirInput.SetText(cfg.OutputDir) a.partialImagesInput.SetText(strconv.Itoa(cfg.PartialImages)) @@ -708,9 +907,14 @@ func (a *App) Run(w *app.Window) error { var ops op.Ops for { switch e := w.Event().(type) { + case app.ConfigEvent: + a.mu.Lock() + a.windowFocused = e.Config.Focused + a.mu.Unlock() case app.DestroyEvent: a.saveCurrentConfig() a.cancelRun() + a.cleanupRuntimeArtifactsOnExit() return e.Err case app.FrameEvent: a.recordRenderFrame(e.Now, e.Size) diff --git a/gio-client/internal/ui/app_test.go b/gio-client/internal/ui/app_test.go index 997cc6d25..3f62674e4 100644 --- a/gio-client/internal/ui/app_test.go +++ b/gio-client/internal/ui/app_test.go @@ -1,6 +1,7 @@ package ui import ( + "encoding/base64" "fmt" "image" "image/color" @@ -11,6 +12,7 @@ import ( "testing" "time" + "github.com/yuanhua/image-gptcodex/pkg/client" sharedCompat "image-studio/shared/compat" ) @@ -226,6 +228,23 @@ func TestPromptGroupKeyForEntriesReturnsVisibleGroupKey(t *testing.T) { } } +func TestCurrentConfigIncludesResponsesTransportAndReasoning(t *testing.T) { + app := &App{ + mode: string(client.ModeGenerate), + api: string(client.APIModeResponses), + policy: string(client.RequestPolicyOpenAI), + responsesTransport: string(client.ResponsesTransportWebSocket), + reasoningEffort: "high", + } + cfg := app.currentConfig() + if cfg.ResponsesTransport != client.ResponsesTransportWebSocket { + t.Fatalf("responses transport=%q want websocket", cfg.ResponsesTransport) + } + if cfg.ReasoningEffort != "high" { + t.Fatalf("reasoning effort=%q want high", cfg.ReasoningEffort) + } +} + func TestApplyHistoryThumbBackfillUpdatesInMemoryState(t *testing.T) { app := &App{} item := sharedCompat.HistoryItem{ID: "hist-1", SavedPath: "/tmp/full.png"} @@ -965,6 +984,9 @@ func TestPresetLabelsCachedRefreshesAfterPresetChanges(t *testing.T) { if len(first) != 1 || first[0].Title != "A" { t.Fatalf("first=%v want preset A", first) } + if first[0].Kind != "preset" { + t.Fatalf("first kind=%q want preset", first[0].Kind) + } second := app.presetLabelsCached([]sharedCompat.Preset{{ID: "b", Name: "B", Size: "1536x1024", Quality: "medium", OutputFormat: "webp", BatchCount: 2}}) if len(second) != 1 || second[0].Title != "B" { @@ -989,6 +1011,92 @@ func TestPromptHelperApplyTextFallsBackToTitle(t *testing.T) { } } +func TestPromptTemplateItemsIncludesSharedAndBuiltInTemplates(t *testing.T) { + app := &App{ + promptTemplates: []sharedCompat.PromptTemplate{{ + ID: "custom-1", + Label: "我的模板", + Text: "custom prompt", + }}, + } + items := app.promptTemplateItems() + if len(items) != len(builtInPromptTemplates)+1 { + t.Fatalf("len(promptTemplateItems)=%d want %d", len(items), len(builtInPromptTemplates)+1) + } + if items[0].ID != "custom-1" || items[0].Kind != "template" || items[0].Detail != "custom prompt" { + t.Fatalf("unexpected custom template item: %#v", items[0]) + } +} + +func TestApplyPresetUsesExtendedSharedFields(t *testing.T) { + app := &App{} + compression := 87 + app.outputCompressionInput.SetText("100") + app.batchCount = 1 + app.kernelRuntimeMode = "auto" + + app.applyPreset(sharedCompat.Preset{ + ID: "preset-1", + Name: "配置1", + Size: "1536x1024", + Quality: "high", + OutputFormat: "webp", + NegativePrompt: "no watermark", + Background: "transparent", + OutputCompression: &compression, + InputFidelity: "high", + ImageStyle: "vivid", + Moderation: "auto", + StyleTag: "anime", + KernelRuntimeMode: "remote", + BatchCount: 4, + }) + + if app.size != "1536x1024" || app.quality != "high" || app.format != "webp" { + t.Fatalf("basic preset fields not applied: size=%q quality=%q format=%q", app.size, app.quality, app.format) + } + if app.negativePromptInput.Text() != "no watermark" { + t.Fatalf("negativePrompt=%q want no watermark", app.negativePromptInput.Text()) + } + if app.background != "transparent" || app.outputCompressionInput.Text() != "87" || app.inputFidelity != "high" || app.imageStyle != "vivid" || app.moderation != "auto" || app.styleTag != "anime" || app.kernelRuntimeMode != "remote" || app.batchCount != 4 { + t.Fatalf("extended preset fields not applied: background=%q compression=%q fidelity=%q imageStyle=%q moderation=%q style=%q runtime=%q batch=%d", app.background, app.outputCompressionInput.Text(), app.inputFidelity, app.imageStyle, app.moderation, app.styleTag, app.kernelRuntimeMode, app.batchCount) + } +} + +func TestApplyPartialPreviewReplacesCanvasImageWhileRunning(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 2, 2)) + fill := color.NRGBA{R: 0x12, G: 0x34, B: 0x56, A: 0xff} + for y := 0; y < 2; y++ { + for x := 0; x < 2; x++ { + img.SetNRGBA(x, y, fill) + } + } + tmp := filepath.Join(t.TempDir(), "partial.png") + writeImagePNG(t, tmp, img) + data, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("read partial fixture: %v", err) + } + + app := &App{running: true} + app.applyPartialPreview(client.PartialImage{ + ImageB64: base64.StdEncoding.EncodeToString(data), + RevisedPrompt: "partial rev", + PartialImageIndex: 0, + }) + + if app.result.Image == nil { + t.Fatal("result image not populated") + } + if app.result.SourceEvent != "partial" { + t.Fatalf("sourceEvent=%q want partial", app.result.SourceEvent) + } + if app.result.RevisedPrompt != "partial rev" { + t.Fatalf("revisedPrompt=%q want partial rev", app.result.RevisedPrompt) + } + assertImagePixelColor(t, app.result.Image, fill) +} + func TestPromptInputMetricsRefreshesAfterTextChanges(t *testing.T) { app := &App{} app.promptInput.SetText(" hello world ") diff --git a/gio-client/internal/ui/choices.go b/gio-client/internal/ui/choices.go index a64a59f48..41b48dc54 100644 --- a/gio-client/internal/ui/choices.go +++ b/gio-client/internal/ui/choices.go @@ -1,9 +1,11 @@ package ui import ( + "fmt" "strings" "github.com/yuanhua/image-gptcodex/pkg/client" + sharedCompat "image-studio/shared/compat" ) type choice struct { @@ -12,11 +14,12 @@ type choice struct { } type aspectChoice struct { - Value string - Label string - W int - H int - Auto bool + Value string + Label string + W int + H int + Auto bool + Custom bool } var ( @@ -75,6 +78,16 @@ var ( {"OpenAI 标准", string(client.RequestPolicyOpenAI)}, {"兼容中转扩展", string(client.RequestPolicyCompat)}, } + responsesTransportChoices = []settingsOptionChoice{ + {Title: "HTTP SSE", Detail: "默认,兼容性更稳", Value: string(client.ResponsesTransportSSE)}, + {Title: "WebSocket mode", Detail: "需要上游支持 Upgrade: websocket", Value: string(client.ResponsesTransportWebSocket)}, + } + reasoningEffortChoices = []settingsOptionChoice{ + {Title: "low", Detail: "更快返回", Value: "low"}, + {Title: "medium", Detail: "平衡速度与质量", Value: "medium"}, + {Title: "high", Detail: "更认真推理", Value: "high"}, + {Title: "xhigh", Detail: "默认,长推理更稳", Value: "xhigh"}, + } proxyChoices = []choice{ {"系统配置", client.ProxyModeSystem}, {"不使用", client.ProxyModeNone}, @@ -209,6 +222,9 @@ func aspectChoiceLabel(value string) string { return item.Label } } + if strings.HasPrefix(value, "custom:") { + return strings.TrimPrefix(value, "custom:") + } return value } @@ -220,7 +236,7 @@ func sizeDisplayLabel(value string) string { if value == "auto" { return "自动" } - aspect := deriveAspectPreset(value) + aspect := deriveAspectPreset(value, nil) resolution := deriveResolutionPreset(value) if aspect == "" || resolution == "" { return value @@ -268,7 +284,14 @@ func chooseStyleSummary(value string) string { return styleChoiceLabel(value) } -func deriveAspectPreset(size string) string { +func deriveAspectPreset(size string, customRatios []sharedCompat.CustomAspectRatio) string { + for _, ratio := range customRatios { + for _, resolution := range []string{"1k", "2k", "4k"} { + if buildCustomSizeSelection(ratio, resolution) == size { + return "custom:" + ratio.ID + } + } + } if value, ok := sizeToAspect[size]; ok { return value } @@ -308,6 +331,9 @@ func buildSizeSelection(aspect string, resolution string) string { if aspect == "auto" || resolution == "auto" { return "auto" } + if strings.HasPrefix(aspect, "custom:") { + return "auto" + } if sizes, ok := sizeMatrix[aspect]; ok { if value, ok := sizes[resolution]; ok { return value @@ -316,6 +342,80 @@ func buildSizeSelection(aspect string, resolution string) string { return "1024x1024" } +func buildCustomSizeSelection(ratio sharedCompat.CustomAspectRatio, resolution string) string { + switch resolution { + case "2k": + if ratio.Width >= ratio.Height { + return buildCustomResolutionSize(ratio.Width, ratio.Height, 2048) + } + return buildCustomResolutionSize(ratio.Width, ratio.Height, 2048) + case "4k": + if ratio.Width >= ratio.Height { + return buildCustomResolutionSize(ratio.Width, ratio.Height, 3840) + } + return buildCustomResolutionSize(ratio.Width, ratio.Height, 3840) + default: + if ratio.Width >= ratio.Height { + return buildCustomResolutionSize(ratio.Width, ratio.Height, 1536) + } + return buildCustomResolutionSize(ratio.Width, ratio.Height, 1536) + } +} + +func buildCustomResolutionSize(width int, height int, longSide int) string { + if width <= 0 || height <= 0 || longSide <= 0 { + return "1024x1024" + } + if width >= height { + shortSide := max(64, int(float64(longSide)*float64(height)/float64(width)+0.5)) + return fmt.Sprintf("%dx%d", longSide, shortSide) + } + shortSide := max(64, int(float64(longSide)*float64(width)/float64(height)+0.5)) + return fmt.Sprintf("%dx%d", shortSide, longSide) +} + +func aspectChoicesWithCustom(customRatios []sharedCompat.CustomAspectRatio) []aspectChoice { + if len(customRatios) == 0 { + return aspectChoices + } + items := make([]aspectChoice, 0, len(aspectChoices)+len(customRatios)) + items = append(items, aspectChoices...) + for _, ratio := range customRatios { + if strings.TrimSpace(ratio.ID) == "" || ratio.Width <= 0 || ratio.Height <= 0 { + continue + } + w, h := aspectShapeFromRatio(ratio.Width, ratio.Height) + items = append(items, aspectChoice{ + Value: "custom:" + ratio.ID, + Label: ratio.Label, + W: w, + H: h, + Custom: true, + }) + } + return items +} + +func aspectShapeFromRatio(width int, height int) (int, int) { + if width <= 0 || height <= 0 { + return 18, 18 + } + const maxW = 24 + const maxH = 22 + scaleW := float64(maxW) / float64(width) + scaleH := float64(maxH) / float64(height) + scale := scaleW + if scaleH < scale { + scale = scaleH + } + if scale <= 0 { + scale = 1 + } + w := max(10, int(float64(width)*scale+0.5)) + h := max(10, int(float64(height)*scale+0.5)) + return w, h +} + func visibleResolutionChoices(apiMode string, requestPolicy string, imageModelID string) []choice { if supportsExplicitLargeSizes(apiMode, requestPolicy, imageModelID) { return resolutionChoices diff --git a/gio-client/internal/ui/completion_notification.go b/gio-client/internal/ui/completion_notification.go new file mode 100644 index 000000000..bea8ddefc --- /dev/null +++ b/gio-client/internal/ui/completion_notification.go @@ -0,0 +1,190 @@ +package ui + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + sharedCompat "image-studio/shared/compat" +) + +type systemNotificationPermissionState string + +const ( + systemNotificationPermissionDefault systemNotificationPermissionState = "default" + systemNotificationPermissionGranted systemNotificationPermissionState = "granted" + systemNotificationPermissionDenied systemNotificationPermissionState = "denied" + systemNotificationPermissionUnsupported systemNotificationPermissionState = "unsupported" +) + +var readSystemNotificationPermissionFunc = readSystemNotificationPermission +var requestSystemNotificationPermissionFunc = requestSystemNotificationPermission +var showSystemNotificationFunc = showSystemNotification + +func systemNotificationPermissionLabel(permission systemNotificationPermissionState) string { + switch permission { + case systemNotificationPermissionGranted: + return "已允许" + case systemNotificationPermissionDenied: + return "已拒绝" + case systemNotificationPermissionUnsupported: + return "当前平台不支持" + default: + return "尚未授权" + } +} + +func completionNotificationPermissionActionLabel(permission systemNotificationPermissionState) string { + switch permission { + case systemNotificationPermissionGranted: + return "重新检查权限" + case systemNotificationPermissionUnsupported: + return "检查支持" + default: + return "申请权限" + } +} + +func readSystemNotificationPermission() systemNotificationPermissionState { + switch runtime.GOOS { + case "darwin": + if _, err := exec.LookPath("osascript"); err == nil { + return systemNotificationPermissionGranted + } + case "windows": + if _, err := exec.LookPath("powershell"); err == nil { + return systemNotificationPermissionGranted + } + default: + if _, err := exec.LookPath("notify-send"); err == nil { + return systemNotificationPermissionGranted + } + if _, err := exec.LookPath("zenity"); err == nil { + return systemNotificationPermissionGranted + } + if _, err := exec.LookPath("kdialog"); err == nil { + return systemNotificationPermissionGranted + } + } + return systemNotificationPermissionUnsupported +} + +func requestSystemNotificationPermission() systemNotificationPermissionState { + return readSystemNotificationPermission() +} + +func shouldSendCompletionNotification(config sharedCompat.CompletionNotificationSettings, completedNow int, totalNow int, windowFocused bool) bool { + next := normaliseCompletionNotificationSettings(&config) + return next.Enabled && !windowFocused && totalNow > 0 && completedNow == totalNow +} + +func completionNotificationBody(item sharedCompat.HistoryItem) string { + if text := strings.TrimSpace(item.RevisedPrompt); text != "" { + return text + } + if text := strings.TrimSpace(item.Prompt); text != "" { + return text + } + if strings.TrimSpace(item.Mode) == "edit" { + return "图片编辑任务已完成" + } + return "图片生成任务已完成" +} + +func (a *App) setCompletionNotificationEnabled(value bool) systemNotificationPermissionState { + if !value { + a.completionNotification.Enabled = false + a.completionNotificationPermission = readSystemNotificationPermissionFunc() + a.appendLog("已关闭系统通知") + return a.completionNotificationPermission + } + permission := requestSystemNotificationPermissionFunc() + a.completionNotificationPermission = permission + if permission == systemNotificationPermissionGranted { + a.completionNotification.Enabled = true + a.appendLog("已开启系统通知") + return permission + } + switch permission { + case systemNotificationPermissionUnsupported: + a.appendLog("当前平台不支持系统通知") + case systemNotificationPermissionDenied: + a.appendLog("系统通知权限已被拒绝,请在系统设置中允许后重试") + default: + a.appendLog("请先允许系统通知权限") + } + return permission +} + +func (a *App) refreshCompletionNotificationPermission() systemNotificationPermissionState { + permission := requestSystemNotificationPermissionFunc() + a.completionNotificationPermission = permission + switch permission { + case systemNotificationPermissionGranted: + a.appendLog("系统通知权限已授权") + case systemNotificationPermissionDenied: + a.appendLog("系统通知权限已被拒绝,请在系统设置中允许后重试") + case systemNotificationPermissionUnsupported: + a.appendLog("当前平台不支持系统通知") + default: + a.appendLog("系统通知尚未授权") + } + return permission +} + +func (a *App) maybeSendCompletionNotification(item sharedCompat.HistoryItem, completedNow int, totalNow int) { + a.mu.Lock() + config := a.completionNotification + permission := a.completionNotificationPermission + windowFocused := a.windowFocused + a.mu.Unlock() + if permission != systemNotificationPermissionGranted { + return + } + if !shouldSendCompletionNotification(config, completedNow, totalNow, windowFocused) { + return + } + title := "Image Studio · 已完成" + body := completionNotificationBody(item) + go func() { + if err := showSystemNotificationFunc(title, body); err != nil { + a.appendLog("发送系统通知失败: " + err.Error()) + } + }() +} + +func showSystemNotification(title string, body string) error { + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + if title == "" { + title = "Image Studio" + } + if body == "" { + body = "图片任务已完成" + } + switch runtime.GOOS { + case "darwin": + script := fmt.Sprintf("display notification %s with title %s", appleScriptQuoted(body), appleScriptQuoted(title)) + return exec.Command("osascript", "-e", script).Run() + case "windows": + script := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.BalloonTipTitle = $args[0]; $n.BalloonTipText = $args[1]; $n.Visible = $true; $n.ShowBalloonTip(5000); Start-Sleep -Milliseconds 5500; $n.Dispose()` + return exec.Command("powershell", "-NoProfile", "-Command", script, title, body).Run() + default: + if path, err := exec.LookPath("notify-send"); err == nil { + return exec.Command(path, title, body).Run() + } + if path, err := exec.LookPath("zenity"); err == nil { + return exec.Command(path, "--notification", "--text="+title+"\n"+body).Run() + } + if path, err := exec.LookPath("kdialog"); err == nil { + return exec.Command(path, "--title", title, "--passivepopup", body, "5").Run() + } + return fmt.Errorf("当前平台没有可用的系统通知器") + } +} + +func appleScriptQuoted(value string) string { + replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + return `"` + replacer.Replace(value) + `"` +} diff --git a/gio-client/internal/ui/completion_notification_test.go b/gio-client/internal/ui/completion_notification_test.go new file mode 100644 index 000000000..1cb7facea --- /dev/null +++ b/gio-client/internal/ui/completion_notification_test.go @@ -0,0 +1,89 @@ +package ui + +import ( + "testing" + "time" + + sharedCompat "image-studio/shared/compat" +) + +func TestShouldSendCompletionNotificationOnlyOnFinalBackgroundResult(t *testing.T) { + config := sharedCompat.CompletionNotificationSettings{Enabled: true} + if shouldSendCompletionNotification(config, 1, 3, false) { + t.Fatal("should not notify before final result") + } + if shouldSendCompletionNotification(config, 3, 3, true) { + t.Fatal("should not notify while window is focused") + } + if !shouldSendCompletionNotification(config, 3, 3, false) { + t.Fatal("should notify on final background result") + } +} + +func TestSetCompletionNotificationEnabledHonoursPermission(t *testing.T) { + app := &App{ + completionNotification: sharedCompat.CompletionNotificationSettings{Enabled: false}, + } + orig := requestSystemNotificationPermissionFunc + requestSystemNotificationPermissionFunc = func() systemNotificationPermissionState { + return systemNotificationPermissionUnsupported + } + defer func() { requestSystemNotificationPermissionFunc = orig }() + + permission := app.setCompletionNotificationEnabled(true) + if permission != systemNotificationPermissionUnsupported { + t.Fatalf("permission=%q want unsupported", permission) + } + if app.completionNotification.Enabled { + t.Fatal("notification should remain disabled when permission is unavailable") + } + + requestSystemNotificationPermissionFunc = func() systemNotificationPermissionState { + return systemNotificationPermissionGranted + } + permission = app.setCompletionNotificationEnabled(true) + if permission != systemNotificationPermissionGranted { + t.Fatalf("permission=%q want granted", permission) + } + if !app.completionNotification.Enabled { + t.Fatal("notification should enable after permission is granted") + } +} + +func TestMaybeSendCompletionNotificationRequiresBackgroundAndPermission(t *testing.T) { + app := &App{ + completionNotification: sharedCompat.CompletionNotificationSettings{Enabled: true}, + completionNotificationPermission: systemNotificationPermissionGranted, + windowFocused: true, + } + calls := make(chan string, 1) + orig := showSystemNotificationFunc + showSystemNotificationFunc = func(title string, body string) error { + calls <- title + "\n" + body + return nil + } + defer func() { showSystemNotificationFunc = orig }() + + item := sharedCompat.HistoryItem{ + Prompt: "生成一张雪山海报", + RevisedPrompt: "cinematic snow mountain poster", + Mode: "generate", + } + app.maybeSendCompletionNotification(item, 1, 1) + select { + case <-calls: + t.Fatal("should not notify while window is focused") + case <-time.After(200 * time.Millisecond): + } + + app.windowFocused = false + app.maybeSendCompletionNotification(item, 1, 1) + select { + case got := <-calls: + if got != "Image Studio · 已完成\ncinematic snow mountain poster" { + t.Fatalf("notification=%q want title/body pair", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for completion notification") + } +} diff --git a/gio-client/internal/ui/completion_sound.go b/gio-client/internal/ui/completion_sound.go new file mode 100644 index 000000000..b6b1f9859 --- /dev/null +++ b/gio-client/internal/ui/completion_sound.go @@ -0,0 +1,201 @@ +package ui + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + sharedCompat "image-studio/shared/compat" +) + +const maxCompletionSoundBytes = 256 * 1024 + +var completionSoundAudioMIMETypes = map[string]string{ + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".webm": "audio/webm", +} + +var playCompletionSoundFunc = playCompletionSound + +func shouldPlayCompletionSound(config sharedCompat.CompletionSoundSettings, completedNow int, totalNow int) bool { + next := normaliseCompletionSoundSettings(&config) + return next.Enabled && totalNow > 0 && completedNow == totalNow +} + +func (a *App) maybePlayCompletionSound(completedNow int, totalNow int) { + a.mu.Lock() + config := a.completionSound + a.mu.Unlock() + if !shouldPlayCompletionSound(config, completedNow, totalNow) { + return + } + go func() { + if err := playCompletionSoundFunc(config, false); err != nil { + a.appendLog("播放完成提示音失败: " + err.Error()) + } + }() +} + +func (a *App) previewCompletionSound() { + a.mu.Lock() + config := a.completionSound + a.mu.Unlock() + go func() { + if err := playCompletionSoundFunc(config, true); err != nil { + a.appendLog("试听完成提示音失败: " + err.Error()) + } + }() +} + +func (a *App) importCompletionSound() { + path, err := chooseAudioFile() + if err != nil { + a.appendLog("选择提示音失败: " + err.Error()) + return + } + if strings.TrimSpace(path) == "" { + return + } + name, dataURL, err := importCompletionSoundFile(path) + if err != nil { + a.appendLog("导入提示音失败: " + err.Error()) + return + } + a.completionSound.Enabled = true + a.completionSound.Mode = "custom" + a.completionSound.CustomName = name + a.completionSound.CustomData = dataURL + a.appendLog("已导入完成提示音: " + name) +} + +func (a *App) resetCompletionSoundCustom() { + a.completionSound.Mode = "default" + a.completionSound.CustomName = "" + a.completionSound.CustomData = "" + a.appendLog("已恢复默认完成提示音") +} + +func importCompletionSoundFile(path string) (string, string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", "", fmt.Errorf("音频文件为空") + } + data, err := os.ReadFile(path) + if err != nil { + return "", "", err + } + if len(data) == 0 { + return "", "", fmt.Errorf("音频文件为空") + } + if len(data) > maxCompletionSoundBytes { + return "", "", fmt.Errorf("音频文件不能超过 %d KB", maxCompletionSoundBytes/1024) + } + mimeType, ok := completionSoundAudioMIMETypes[strings.ToLower(filepath.Ext(path))] + if !ok { + return "", "", fmt.Errorf("仅支持 mp3、wav、ogg、m4a、aac、webm 音频") + } + return filepath.Base(path), "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +func playCompletionSound(config sharedCompat.CompletionSoundSettings, force bool) error { + next := normaliseCompletionSoundSettings(&config) + if !force && !next.Enabled { + return nil + } + if next.Mode == "custom" && strings.TrimSpace(next.CustomData) != "" { + return playCustomCompletionSound(next.CustomData) + } + return playDefaultCompletionSound() +} + +func playDefaultCompletionSound() error { + switch runtime.GOOS { + case "darwin": + if _, err := os.Stat("/System/Library/Sounds/Glass.aiff"); err == nil { + return exec.Command("afplay", "/System/Library/Sounds/Glass.aiff").Run() + } + return exec.Command("osascript", "-e", "beep").Run() + case "windows": + return exec.Command("powershell", "-NoProfile", "-Command", "[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 500").Run() + default: + if _, err := exec.LookPath("canberra-gtk-play"); err == nil { + return exec.Command("canberra-gtk-play", "-i", "complete").Run() + } + if _, err := exec.LookPath("paplay"); err == nil { + candidate := "/usr/share/sounds/freedesktop/stereo/complete.oga" + if _, statErr := os.Stat(candidate); statErr == nil { + return exec.Command("paplay", candidate).Run() + } + } + return fmt.Errorf("当前系统没有可用的默认提示音播放器") + } +} + +func playCustomCompletionSound(dataURL string) error { + path, err := writeCompletionSoundDataURLToTemp(dataURL) + if err != nil { + return err + } + defer os.Remove(path) + switch runtime.GOOS { + case "darwin": + return exec.Command("afplay", path).Run() + case "windows": + script := `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([Uri]::new($args[0])); $p.Play(); while($p.NaturalDuration.HasTimeSpan -eq $false){Start-Sleep -Milliseconds 100}; Start-Sleep -Milliseconds ([Math]::Max(150, [int]$p.NaturalDuration.TimeSpan.TotalMilliseconds)); $p.Close()` + return exec.Command("powershell", "-NoProfile", "-Command", script, path).Run() + default: + if _, err := exec.LookPath("ffplay"); err == nil { + return exec.Command("ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", path).Run() + } + if _, err := exec.LookPath("paplay"); err == nil { + return exec.Command("paplay", path).Run() + } + if _, err := exec.LookPath("aplay"); err == nil { + return exec.Command("aplay", path).Run() + } + return fmt.Errorf("当前系统没有可用的自定义提示音播放器") + } +} + +func writeCompletionSoundDataURLToTemp(dataURL string) (string, error) { + idx := strings.Index(dataURL, ",") + if !strings.HasPrefix(dataURL, "data:") || idx < 0 { + return "", fmt.Errorf("不是有效的 data URL") + } + header := dataURL[5:idx] + payload := dataURL[idx+1:] + if !strings.Contains(header, "base64") { + return "", fmt.Errorf("data URL 不是 base64") + } + data, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", err + } + ext := ".wav" + for candidateExt, mimeType := range completionSoundAudioMIMETypes { + if strings.HasPrefix(header, mimeType) { + ext = candidateExt + break + } + } + file, err := os.CreateTemp("", "image-studio-completion-*"+ext) + if err != nil { + return "", err + } + if _, err := file.Write(data); err != nil { + file.Close() + return "", err + } + if err := file.Close(); err != nil { + return "", err + } + return file.Name(), nil +} diff --git a/gio-client/internal/ui/completion_sound_test.go b/gio-client/internal/ui/completion_sound_test.go new file mode 100644 index 000000000..073df34b3 --- /dev/null +++ b/gio-client/internal/ui/completion_sound_test.go @@ -0,0 +1,108 @@ +package ui + +import ( + "os" + "path/filepath" + "testing" + "time" + + sharedCompat "image-studio/shared/compat" +) + +func TestShouldPlayCompletionSoundOnlyOnFinalResult(t *testing.T) { + config := sharedCompat.CompletionSoundSettings{Enabled: true, Mode: "default"} + if shouldPlayCompletionSound(config, 1, 3) { + t.Fatal("should not play before final result") + } + if !shouldPlayCompletionSound(config, 3, 3) { + t.Fatal("should play on final result") + } +} + +func TestShouldPlayCompletionSoundHonoursDisabledState(t *testing.T) { + config := sharedCompat.CompletionSoundSettings{Enabled: false, Mode: "default"} + if shouldPlayCompletionSound(config, 1, 1) { + t.Fatal("disabled completion sound should not play") + } +} + +func TestImportCompletionSoundFileRejectsOversizedInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "too-large.wav") + if err := os.WriteFile(path, make([]byte, maxCompletionSoundBytes+1), 0o600); err != nil { + t.Fatalf("write oversized audio: %v", err) + } + if _, _, err := importCompletionSoundFile(path); err == nil { + t.Fatal("expected oversized audio to be rejected") + } +} + +func TestImportCompletionSoundFileReturnsDataURL(t *testing.T) { + path := filepath.Join(t.TempDir(), "ding.wav") + if err := os.WriteFile(path, []byte{1, 2, 3, 4}, 0o600); err != nil { + t.Fatalf("write audio: %v", err) + } + name, dataURL, err := importCompletionSoundFile(path) + if err != nil { + t.Fatalf("importCompletionSoundFile: %v", err) + } + if name != "ding.wav" { + t.Fatalf("name=%q want ding.wav", name) + } + if got, want := dataURL[:22], "data:audio/wav;base64,"; len(dataURL) < len(want) || got != want { + t.Fatalf("dataURL=%q want prefix %q", dataURL, want) + } +} + +func TestPreviewCompletionSoundForcesPlayback(t *testing.T) { + app := &App{ + completionSound: sharedCompat.CompletionSoundSettings{Enabled: false, Mode: "default"}, + } + called := make(chan bool, 1) + orig := playCompletionSoundFunc + playCompletionSoundFunc = func(config sharedCompat.CompletionSoundSettings, force bool) error { + called <- force + return nil + } + defer func() { playCompletionSoundFunc = orig }() + + app.previewCompletionSound() + + select { + case force := <-called: + if !force { + t.Fatal("preview should force playback") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for preview playback") + } +} + +func TestMaybePlayCompletionSoundOnlyTriggersOnFinalResult(t *testing.T) { + app := &App{ + completionSound: sharedCompat.CompletionSoundSettings{Enabled: true, Mode: "default"}, + } + calls := make(chan bool, 2) + orig := playCompletionSoundFunc + playCompletionSoundFunc = func(config sharedCompat.CompletionSoundSettings, force bool) error { + calls <- force + return nil + } + defer func() { playCompletionSoundFunc = orig }() + + app.maybePlayCompletionSound(1, 2) + select { + case <-calls: + t.Fatal("should not play before final result") + case <-time.After(200 * time.Millisecond): + } + + app.maybePlayCompletionSound(2, 2) + select { + case force := <-calls: + if force { + t.Fatal("final settlement playback should not be forced") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for final completion playback") + } +} diff --git a/gio-client/internal/ui/custom_aspect_ratio_manager.go b/gio-client/internal/ui/custom_aspect_ratio_manager.go new file mode 100644 index 000000000..f023c17c6 --- /dev/null +++ b/gio-client/internal/ui/custom_aspect_ratio_manager.go @@ -0,0 +1,283 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "time" + + gioCompat "image-studio/gio-client/internal/compat" + sharedCompat "image-studio/shared/compat" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" +) + +func reduceCustomAspectRatio(width int, height int) (int, int) { + if width <= 0 || height <= 0 { + return 0, 0 + } + a, b := width, height + for b != 0 { + a, b = b, a%b + } + if a <= 0 { + return 0, 0 + } + return width / a, height / a +} + +func buildCustomAspectRatioID(width int, height int) string { + w, h := reduceCustomAspectRatio(width, height) + if w <= 0 || h <= 0 { + return "" + } + return fmt.Sprintf("%d:%d", w, h) +} + +func isBuiltInAspectRatioID(id string) bool { + switch strings.TrimSpace(id) { + case "1:1", "3:2", "2:3", "16:9", "9:16": + return true + default: + return false + } +} + +func (a *App) customAspectRatioListButton(id string) *widget.Clickable { + if a.customAspectRatioListButtons == nil { + a.customAspectRatioListButtons = map[string]*widget.Clickable{} + } + if btn, ok := a.customAspectRatioListButtons[id]; ok { + return btn + } + btn := new(widget.Clickable) + a.customAspectRatioListButtons[id] = btn + return btn +} + +func (a *App) openCustomAspectRatioManager() { + a.mu.Lock() + if a.selectedCustomAspectRatioID == "" && len(a.customAspectRatios) > 0 { + a.selectedCustomAspectRatioID = strings.TrimSpace(a.customAspectRatios[0].ID) + } + a.customAspectRatioManagerOpen = true + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) closeCustomAspectRatioManager() { + a.mu.Lock() + a.customAspectRatioManagerOpen = false + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) addCustomAspectRatio() { + width, _ := strconv.Atoi(strings.TrimSpace(a.customAspectWidthInput.Text())) + height, _ := strconv.Atoi(strings.TrimSpace(a.customAspectHeightInput.Text())) + if width <= 0 || height <= 0 { + a.appendLog("请输入有效的自定义比例宽高") + return + } + id := buildCustomAspectRatioID(width, height) + if id == "" { + a.appendLog("无法生成自定义比例") + return + } + if isBuiltInAspectRatioID(id) { + a.appendLog("这个比例已经内置了") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("保存自定义比例失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + for _, ratio := range state.Settings.CustomAspectRatios { + if strings.TrimSpace(ratio.ID) == id { + a.appendLog("比例已存在: " + id) + return + } + } + w, h := reduceCustomAspectRatio(width, height) + ratio := sharedCompat.CustomAspectRatio{ + ID: id, + Label: id, + Width: w, + Height: h, + CreatedAt: time.Now().UnixMilli(), + } + state.Settings.CustomAspectRatios = append(state.Settings.CustomAspectRatios, ratio) + state.UpdatedAt = time.Now().UnixMilli() + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("保存自定义比例失败: " + err.Error()) + return + } + a.mu.Lock() + a.customAspectRatios = append([]sharedCompat.CustomAspectRatio(nil), state.Settings.CustomAspectRatios...) + a.selectedCustomAspectRatioID = ratio.ID + a.customAspectWidthInput.SetText("") + a.customAspectHeightInput.SetText("") + a.mu.Unlock() + a.appendLog("已添加自定义比例: " + id) + a.invalidateNow() +} + +func (a *App) deleteSelectedCustomAspectRatio() { + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedCustomAspectRatioID) + a.mu.Unlock() + if targetID == "" { + a.appendLog("当前没有可删除的自定义比例") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("删除自定义比例失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + next := make([]sharedCompat.CustomAspectRatio, 0, len(state.Settings.CustomAspectRatios)) + removed := false + for _, ratio := range state.Settings.CustomAspectRatios { + if strings.TrimSpace(ratio.ID) == targetID { + removed = true + continue + } + next = append(next, ratio) + } + if !removed { + a.appendLog("当前自定义比例不存在") + return + } + state.Settings.CustomAspectRatios = next + state.UpdatedAt = time.Now().UnixMilli() + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("删除自定义比例失败: " + err.Error()) + return + } + a.mu.Lock() + a.customAspectRatios = append([]sharedCompat.CustomAspectRatio(nil), next...) + if len(next) > 0 { + a.selectedCustomAspectRatioID = strings.TrimSpace(next[0].ID) + } else { + a.selectedCustomAspectRatioID = "" + } + a.mu.Unlock() + a.appendLog("已删除自定义比例: " + targetID) + a.invalidateNow() +} + +func (a *App) layoutCustomAspectRatioManagerModal(gtx layout.Context) layout.Dimensions { + for a.closePromptHelperButton.Clicked(gtx) { + a.closeCustomAspectRatioManager() + } + for a.addCustomAspectRatioButton.Clicked(gtx) { + a.addCustomAspectRatio() + } + for a.deleteCustomAspectRatioButton.Clicked(gtx) { + a.deleteSelectedCustomAspectRatio() + } + for _, ratio := range a.customAspectRatios { + btn := a.customAspectRatioListButton("custom-aspect:" + ratio.ID) + for btn.Clicked(gtx) { + a.mu.Lock() + a.selectedCustomAspectRatioID = ratio.ID + a.mu.Unlock() + a.invalidateNow() + } + } + + return a.layoutStandardModal( + gtx, + unit.Dp(760), + unit.Dp(560), + "自定义比例", + "", + &a.closePromptHelperButton, + 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, "新增后会直接出现在 Gio 的比例按钮区,并和 WebView 共用这份状态。", unit.Sp(11), fluent.textDim, 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.field(gtx, "宽", &a.customAspectWidthInput, "21", unit.Dp(42)) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.field(gtx, "高", &a.customAspectHeightInput, "9", unit.Dp(42)) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(110), func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.addCustomAspectRatioButton, uiIconAdd, "添加", true) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(a.customAspectRatios) == 0 { + return a.label(gtx, "当前还没有自定义比例。", unit.Sp(11), fluent.textDim, font.Normal) + } + rows := make([]layout.FlexChild, 0, len(a.customAspectRatios)) + for _, ratio := range a.customAspectRatios { + ratio := ratio + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(ratio.ID) == strings.TrimSpace(a.selectedCustomAspectRatioID) + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.surfaceButton( + gtx, + a.customAspectRatioListButton("custom-aspect:"+ratio.ID), + chooseColor(selected, fluent.accentSoft, rgba(0xffffff, 0x00)), + chooseColor(selected, accentAlpha(0x18), fluent.surface2), + chooseColor(selected, accentAlpha(0x38), fluent.border), + fluentCardRadius, + layout.Inset{Top: 9, Bottom: 9, Left: 10, Right: 10}, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(3))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, ratio.Label, unit.Sp(11), chooseColor(selected, fluent.accent, fluent.text), font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, "归一化比例 "+ratio.ID, unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + w, h := aspectShapeFromRatio(ratio.Width, ratio.Height) + return a.surface(gtx, fluent.accentSoft, unit.Dp(6), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, unit.Dp(3), fluent.accent, func(gtx layout.Context) layout.Dimensions { + return fixedPixelWidth(gtx, w, func(gtx layout.Context) layout.Dimensions { + return fixedPixelHeight(gtx, h, func(gtx layout.Context) layout.Dimensions { + return layout.Dimensions{Size: gtx.Constraints.Min} + }) + }) + }) + }) + }) + }), + ) + }, + ) + }) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(128), func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.deleteCustomAspectRatioButton, uiIconDelete, "删除所选", false) + }) + }), + ) + }, + ) +} diff --git a/gio-client/internal/ui/general_settings_support.go b/gio-client/internal/ui/general_settings_support.go index 74e4e0057..c606b060a 100644 --- a/gio-client/internal/ui/general_settings_support.go +++ b/gio-client/internal/ui/general_settings_support.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/yuanhua/image-gptcodex/pkg/client" gioCompat "image-studio/gio-client/internal/compat" sharedCompat "image-studio/shared/compat" ) @@ -20,6 +21,15 @@ var generalKernelRuntimeChoices = []settingsOptionChoice{ {Title: "remote(共享远程内核)", Detail: "共享远程内核", Value: "remote"}, } +var generalAutoRetryCountChoices = []int{1, 3, 5, 8, 10} + +const ( + defaultLoopGenerationCount = 10 + defaultLoopGenerationConcurrency = 2 + maxLoopGenerationCount = 99 + maxLoopGenerationConcurrency = 9 +) + type historyExportPayload struct { Version int `json:"version"` ExportedAt string `json:"exportedAt"` @@ -46,6 +56,44 @@ func kernelRuntimeModeLabel(mode string) string { return generalKernelRuntimeChoices[0].Title } +func normalizeAutoRetryCount(value int) int { + if value <= 0 { + return client.DefaultAutoRetryCount + } + if value > client.MaxAutoRetryCount { + return client.MaxAutoRetryCount + } + return value +} + +func normalizeLoopGenerationCount(value int) int { + if value <= 0 { + return defaultLoopGenerationCount + } + if value > maxLoopGenerationCount { + return maxLoopGenerationCount + } + return value +} + +func normalizeLoopGenerationConcurrency(value int) int { + if value <= 0 { + return defaultLoopGenerationConcurrency + } + if value > maxLoopGenerationConcurrency { + return maxLoopGenerationConcurrency + } + return value +} + +func normaliseCompletionSoundSettings(value *sharedCompat.CompletionSoundSettings) sharedCompat.CompletionSoundSettings { + return gioCompat.NormaliseCompletionSoundSettings(value) +} + +func normaliseCompletionNotificationSettings(value *sharedCompat.CompletionNotificationSettings) sharedCompat.CompletionNotificationSettings { + return gioCompat.NormaliseCompletionNotificationSettings(value) +} + func (a *App) exportHistoryJSON() { state, _, err := gioCompat.LoadState() if err != nil { diff --git a/gio-client/internal/ui/layout_canvas.go b/gio-client/internal/ui/layout_canvas.go index f42400ab5..644b79363 100644 --- a/gio-client/internal/ui/layout_canvas.go +++ b/gio-client/internal/ui/layout_canvas.go @@ -633,7 +633,21 @@ func (a *App) layoutBatchResultGrid(gtx layout.Context, snap snapshot) layout.Di if livePreview { title = fmt.Sprintf("本批预览 · %d/%d", len(items), totalSlots) } - return a.label(gtx, title, unit.Sp(12), fluent.text, font.SemiBold) + subtitle := "" + if snap.Running && snap.BatchTotal > 1 { + subtitle = fmt.Sprintf("进行中 · 已完成 %d/%d", len(items), snap.BatchTotal) + } + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(2))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, title, unit.Sp(12), fluent.text, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if subtitle == "" { + return layout.Dimensions{} + } + return a.singleLineLabel(gtx, subtitle, unit.Sp(10), fluent.textDim, font.Normal) + }), + ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if livePreview { @@ -951,6 +965,15 @@ func (a *App) canvasStatusBar(gtx layout.Context, snap snapshot) layout.Dimensio return a.singleLineLabel(gtx, lastLog, unit.Sp(11), fluent.textDim, font.Normal) }) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if snap.BatchTotal <= 1 { + return layout.Dimensions{} + } + completed := len(snap.BatchResults) + return layout.Inset{Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, fmt.Sprintf("已完成 %d/%d", completed, snap.BatchTotal), unit.Sp(11), fluent.textDim, font.Normal) + }) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !hasRenderDiagnostics { return layout.Dimensions{} diff --git a/gio-client/internal/ui/layout_controls.go b/gio-client/internal/ui/layout_controls.go index 3f9ee3682..37b6afccd 100644 --- a/gio-client/internal/ui/layout_controls.go +++ b/gio-client/internal/ui/layout_controls.go @@ -26,9 +26,10 @@ type promptHelperItem struct { ID string Title string Detail string + Kind string } -var promptTemplates = []promptHelperItem{ +var builtInPromptTemplates = []promptHelperItem{ {ID: "photoreal", Title: "写实摄影", Detail: "photorealistic, professional photography, 35mm, natural lighting, sharp focus, high detail"}, {ID: "cinematic", Title: "电影感", Detail: "cinematic, dramatic lighting, shallow depth of field, film grain, anamorphic, 2.39:1"}, {ID: "anime", Title: "二次元", Detail: "anime style, vibrant colors, cel shading, detailed illustration"}, @@ -310,6 +311,12 @@ func (a *App) layoutPromptCard(gtx layout.Context, snap snapshot, promptLen int) } a.promptHelperOpen = !a.promptHelperOpen } + for a.openPromptTemplateManagerButton.Clicked(gtx) { + a.openPromptTemplateManager() + } + for a.openPresetManagerFromPromptButton.Clicked(gtx) { + a.openPresetManager() + } for a.optimizePromptButton.Clicked(gtx) { a.startPromptOptimize() } @@ -355,6 +362,18 @@ func (a *App) layoutPromptCard(gtx layout.Context, snap snapshot, promptLen int) }) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedHeight(gtx, unit.Dp(32), func(gtx layout.Context) layout.Dimensions { + return a.ghostIconTextButton(gtx, &a.openPromptTemplateManagerButton, uiIconEdit, "管理模板", a.promptTemplateManagerOpen) + }) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedHeight(gtx, unit.Dp(32), func(gtx layout.Context) layout.Dimensions { + return a.ghostIconTextButton(gtx, &a.openPresetManagerFromPromptButton, uiIconSave, "参数预设", a.presetManagerOpen) + }) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "AI 优化" if snap.OptimizingPrompt { @@ -405,13 +424,17 @@ func (a *App) layoutPromptCard(gtx layout.Context, snap snapshot, promptLen int) } func (a *App) layoutPromptHelperPanel(gtx layout.Context, suggestions []string) layout.Dimensions { - items := promptTemplates + items := a.promptTemplateItems() prefix := "prompt-template:" emptyText := "还没有提交过 prompt" if a.promptHelperTab == "history" { items = a.promptLabelsCached(suggestions) prefix = "prompt-history:" emptyText = "还没有提交过提示词。" + } else if a.promptHelperTab == "presets" { + items = a.presetLabelsCached(a.readSnapshot().Presets) + prefix = "prompt-preset:" + emptyText = "还没有保存参数预设。" } if len(items) == 0 { return a.borderedSurface(gtx, fluent.surface, fluentCardRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { @@ -439,6 +462,9 @@ func (a *App) layoutPromptHelperInline(gtx layout.Context, suggestions []string) for a.promptHelperTemplatesButton.Clicked(gtx) { a.promptHelperTab = "templates" } + for a.promptHelperPresetsButton.Clicked(gtx) { + a.promptHelperTab = "presets" + } for a.promptHelperHistoryButton.Clicked(gtx) { a.promptHelperTab = "history" } @@ -449,7 +475,7 @@ func (a *App) layoutPromptHelperInline(gtx layout.Context, suggestions []string) return layout.Inset{Top: 6, Bottom: 6, Left: 8, Right: 8}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return a.layoutPromptHelperTabs(gtx, len(promptTemplates), len(suggestions)) + return a.layoutPromptHelperTabs(gtx, len(a.promptTemplateItems()), len(a.readSnapshot().Presets), len(suggestions)) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -480,6 +506,9 @@ func (a *App) layoutPromptHelperModal(gtx layout.Context) layout.Dimensions { for a.promptHelperTemplatesButton.Clicked(gtx) { a.promptHelperTab = "templates" } + for a.promptHelperPresetsButton.Clicked(gtx) { + a.promptHelperTab = "presets" + } for a.promptHelperHistoryButton.Clicked(gtx) { a.promptHelperTab = "history" } @@ -495,7 +524,7 @@ func (a *App) layoutPromptHelperModal(gtx layout.Context) layout.Dimensions { 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.layoutPromptHelperTabs(gtx, len(promptTemplates), len(suggestions)) + return a.layoutPromptHelperTabs(gtx, len(a.promptTemplateItems()), len(snap.Presets), len(suggestions)) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.layoutPromptHelperPanel(gtx, suggestions) @@ -542,20 +571,36 @@ func chooseFontWeight(active bool) font.Weight { return font.Medium } -func (a *App) layoutPromptHelperTabs(gtx layout.Context, templateCount int, historyCount int) layout.Dimensions { +func (a *App) layoutPromptHelperTabs(gtx layout.Context, templateCount int, presetCount int, historyCount int) 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.surfaceButton( gtx, &a.promptHelperTemplatesButton, - chooseColor(a.promptHelperTab != "history", fluent.accentSoft, rgba(0xffffff, 0x00)), + chooseColor(a.promptHelperTab == "templates", fluent.accentSoft, rgba(0xffffff, 0x00)), + fluent.surface2, + rgba(0xffffff, 0x00), + fluentControlRadius, + layout.Inset{Top: 8, Bottom: 8, Left: 10, Right: 10}, + func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, fmt.Sprintf("模板 (%d)", templateCount), unit.Sp(11), chooseColor(a.promptHelperTab == "templates", fluent.accent, fluent.textMuted), chooseFontWeight(a.promptHelperTab == "templates")) + }) + }, + ) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.surfaceButton( + gtx, + &a.promptHelperPresetsButton, + chooseColor(a.promptHelperTab == "presets", fluent.accentSoft, rgba(0xffffff, 0x00)), fluent.surface2, rgba(0xffffff, 0x00), fluentControlRadius, layout.Inset{Top: 8, Bottom: 8, Left: 10, Right: 10}, func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return a.label(gtx, "模板", unit.Sp(11), chooseColor(a.promptHelperTab != "history", fluent.accent, fluent.textMuted), chooseFontWeight(a.promptHelperTab != "history")) + return a.label(gtx, fmt.Sprintf("预设 (%d)", presetCount), unit.Sp(11), chooseColor(a.promptHelperTab == "presets", fluent.accent, fluent.textMuted), chooseFontWeight(a.promptHelperTab == "presets")) }) }, ) @@ -583,7 +628,7 @@ func (a *App) layoutPromptHelperTabs(gtx layout.Context, templateCount int, hist func (a *App) layoutPromptHelperItem(gtx layout.Context, buttonID string, item promptHelperItem) layout.Dimensions { btn := a.promptButton(buttonID) for btn.Clicked(gtx) { - a.applyPromptSuggestion(promptHelperApplyText(item)) + a.applyPromptHelperItem(item) } return a.surfaceButton( gtx, @@ -616,6 +661,40 @@ func promptHelperApplyText(item promptHelperItem) string { return item.Title } +func (a *App) promptTemplateItems() []promptHelperItem { + a.mu.Lock() + defer a.mu.Unlock() + items := make([]promptHelperItem, 0, len(a.promptTemplates)+len(builtInPromptTemplates)) + for _, item := range a.promptTemplates { + title := strings.TrimSpace(item.Label) + if title == "" { + title = shortPrompt(item.Text) + } + items = append(items, promptHelperItem{ + ID: strings.TrimSpace(item.ID), + Title: title, + Detail: strings.TrimSpace(item.Text), + Kind: "template", + }) + } + for _, item := range builtInPromptTemplates { + item.Kind = "template" + items = append(items, item) + } + return items +} + +func (a *App) applyPromptHelperItem(item promptHelperItem) { + switch strings.TrimSpace(item.Kind) { + case "preset": + if a.applyPresetByID(item.ID) { + return + } + default: + a.applyPromptSuggestion(promptHelperApplyText(item)) + } +} + func (a *App) layoutSettingsModal(gtx layout.Context, snap snapshot) layout.Dimensions { for a.closeSettingsButton.Clicked(gtx) { a.closeSettingsModal() @@ -878,6 +957,96 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo for a.generalPerformanceButtons[1].Clicked(gtx) { a.applyReducedEffects(true) } + for a.generalCompletionSoundButtons[0].Clicked(gtx) { + a.completionSound.Enabled = true + } + for a.generalCompletionSoundButtons[1].Clicked(gtx) { + a.completionSound.Enabled = false + } + for a.generalCompletionSoundModeButtons[0].Clicked(gtx) { + a.completionSound.Mode = "default" + } + for a.generalCompletionSoundModeButtons[1].Clicked(gtx) { + if strings.TrimSpace(a.completionSound.CustomData) == "" { + a.importCompletionSound() + } else { + a.completionSound.Mode = "custom" + } + } + for a.previewCompletionSoundButton.Clicked(gtx) { + a.previewCompletionSound() + } + for a.chooseCompletionSoundButton.Clicked(gtx) { + a.importCompletionSound() + } + for a.resetCompletionSoundButton.Clicked(gtx) { + a.resetCompletionSoundCustom() + } + for a.generalCompletionNotificationButtons[0].Clicked(gtx) { + a.setCompletionNotificationEnabled(true) + } + for a.generalCompletionNotificationButtons[1].Clicked(gtx) { + a.setCompletionNotificationEnabled(false) + } + for a.requestCompletionNotificationButton.Clicked(gtx) { + a.refreshCompletionNotificationPermission() + } + for a.generalCleanupPreviewCacheButtons[0].Clicked(gtx) { + a.cleanupPreviewCacheOnExit = false + } + for a.generalCleanupPreviewCacheButtons[1].Clicked(gtx) { + a.cleanupPreviewCacheOnExit = true + } + for a.generalProtectStreamPreviewButtons[0].Clicked(gtx) { + a.protectStreamPreview = true + } + for a.generalProtectStreamPreviewButtons[1].Clicked(gtx) { + a.protectStreamPreview = false + } + for a.generalAutoRetryButtons[0].Clicked(gtx) { + a.autoRetryEnabled = true + } + for a.generalAutoRetryButtons[1].Clicked(gtx) { + a.autoRetryEnabled = false + } + for a.generalLoopButtons[0].Clicked(gtx) { + a.loopEnabled = true + } + for a.generalLoopButtons[1].Clicked(gtx) { + a.loopEnabled = false + } + for a.generalBatchButtons[0].Clicked(gtx) { + a.batchMode = true + } + for a.generalBatchButtons[1].Clicked(gtx) { + a.batchMode = false + } + for a.generalBatchRetryButtons[0].Clicked(gtx) { + a.batchRetryOnFail = true + } + for a.generalBatchRetryButtons[1].Clicked(gtx) { + a.batchRetryOnFail = false + } + for a.generalBatchAutoAspectButtons[0].Clicked(gtx) { + a.batchAutoAspect = "" + } + for a.generalBatchAutoAspectButtons[1].Clicked(gtx) { + if strings.TrimSpace(a.batchAutoAspect) == "" { + a.batchAutoAspect = "1k" + } + } + for idx, value := range []string{"256", "512", "1k", "2k", "4k"} { + value := value + for a.generalBatchAutoAspectResolutionButtons[idx].Clicked(gtx) { + a.batchAutoAspect = value + } + } + for idx, count := range generalAutoRetryCountChoices { + count := count + for a.generalAutoRetryCountButtons[idx].Clicked(gtx) { + a.autoRetryCount = count + } + } for a.generalSavePromptButtons[0].Clicked(gtx) { a.setSavePromptSuppressed(false) } @@ -908,6 +1077,39 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo for a.resetGeneralOutputButton.Clicked(gtx) { a.outputDirInput.SetText(kernel.DefaultOutputDir()) } + for a.chooseGeneralBatchInputButton.Clicked(gtx) { + dir, err := chooseDirectory() + if err != nil { + a.appendLog("选择批处理输入目录失败: " + err.Error()) + continue + } + if strings.TrimSpace(dir) != "" { + a.batchInputDirInput.SetText(dir) + } + } + for a.chooseGeneralBatchFilesButton.Clicked(gtx) { + paths, err := chooseImageFiles() + if err != nil { + a.appendLog("选择批处理图片失败: " + err.Error()) + continue + } + if len(paths) > 0 { + a.batchInputDirInput.SetText("") + a.sourcePathsInput.SetText(strings.Join(paths, "\n")) + a.sourceButtons = map[string]*widget.Clickable{} + } + } + for a.chooseGeneralBatchOutputButton.Clicked(gtx) { + dir, err := chooseDirectory() + if err != nil { + a.appendLog("选择批处理输出目录失败: " + err.Error()) + continue + } + if strings.TrimSpace(dir) != "" { + a.batchOutputDirInput.SetText(dir) + a.batchOutputDir = dir + } + } for a.openGeneralHistoryTimelineButton.Clicked(gtx) { a.closeGeneralSettingsModal() a.openHistoryTimeline() @@ -927,6 +1129,12 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo for a.openGeneralAboutButton.Clicked(gtx) { a.aboutModalOpen = true } + for a.openPresetManagerButton.Clicked(gtx) { + a.openPresetManager() + } + for a.openCustomAspectRatioManagerButton.Clicked(gtx) { + a.openCustomAspectRatioManager() + } for idx, days := range []int{3, 7} { days := days for a.pruneGeneralHistoryButtons[idx].Clicked(gtx) { @@ -1138,6 +1346,78 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo return a.compactButton(gtx, &a.generalSavePromptButtons[1], "不提示", a.savePromptSuppressed) }), } + completionSoundRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionSoundButtons[0], "开启", a.completionSound.Enabled) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionSoundButtons[1], "关闭", !a.completionSound.Enabled) + }), + } + completionSoundModeRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionSoundModeButtons[0], "默认音", a.completionSound.Mode != "custom") + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionSoundModeButtons[1], "自定义", a.completionSound.Mode == "custom" && strings.TrimSpace(a.completionSound.CustomData) != "") + }), + } + completionNotificationRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionNotificationButtons[0], "开启", a.completionNotification.Enabled) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCompletionNotificationButtons[1], "关闭", !a.completionNotification.Enabled) + }), + } + protectStreamPreviewRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalProtectStreamPreviewButtons[0], "开启", a.protectStreamPreview) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalProtectStreamPreviewButtons[1], "关闭", !a.protectStreamPreview) + }), + } + autoRetryRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalAutoRetryButtons[0], "开启", a.autoRetryEnabled) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalAutoRetryButtons[1], "关闭", !a.autoRetryEnabled) + }), + } + loopRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalLoopButtons[0], "开启", a.loopEnabled) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalLoopButtons[1], "关闭", !a.loopEnabled) + }), + } + batchRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchButtons[0], "开启", a.batchMode) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchButtons[1], "关闭", !a.batchMode) + }), + } + batchRetryRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchRetryButtons[0], "自动重试", a.batchRetryOnFail) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchRetryButtons[1], "失败跳过", !a.batchRetryOnFail) + }), + } + batchAutoAspectRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchAutoAspectButtons[0], "沿用当前比例", strings.TrimSpace(a.batchAutoAspect) == "") + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchAutoAspectButtons[1], "按源图比例", strings.TrimSpace(a.batchAutoAspect) != "") + }), + } keepLogsRows := []layout.FlexChild{ layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return a.compactButton(gtx, &a.generalKeepLogsButtons[0], "关闭", !a.keepLogs) @@ -1146,6 +1426,14 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo return a.compactButton(gtx, &a.generalKeepLogsButtons[1], "开启", a.keepLogs) }), } + cleanupPreviewCacheRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCleanupPreviewCacheButtons[0], "关闭", !a.cleanupPreviewCacheOnExit) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalCleanupPreviewCacheButtons[1], "开启", a.cleanupPreviewCacheOnExit) + }), + } presetItems := a.presetLabelsCached(a.presets) sections := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { @@ -1251,6 +1539,162 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo ) }) }, + func(gtx layout.Context) layout.Dimensions { + currentSound := "使用内置默认音" + if a.completionSound.Mode == "custom" && strings.TrimSpace(a.completionSound.CustomName) != "" && strings.TrimSpace(a.completionSound.CustomData) != "" { + currentSound = "当前使用 " + strings.TrimSpace(a.completionSound.CustomName) + } + return a.generalSettingsCard(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 layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, completionSoundRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, completionSoundModeRows...) + }), + 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.previewCompletionSoundButton, uiIconPlay, "试听", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseCompletionSoundButton, uiIconFolder, "导入音频", false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(78), func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.resetCompletionSoundButton, "默认", false) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "整批任务全部完成后只播放一次。"+currentSound+"。", unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(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 layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, completionNotificationRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.requestCompletionNotificationButton, uiIconInfo, completionNotificationPermissionActionLabel(a.completionNotificationPermission), false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "整批任务全部完成后,仅在窗口不在前台时发送系统通知。当前权限:"+systemNotificationPermissionLabel(a.completionNotificationPermission)+"。", unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(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 layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, protectStreamPreviewRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "默认开启。桌面端高并发任务时会自动关闭流式预览,优先保证最终图完整;关闭后严格按你设置的预览帧数请求。", unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(gtx, "失败自动重试", func(gtx layout.Context) layout.Dimensions { + 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, autoRetryRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + countRows := make([]layout.FlexChild, 0, len(generalAutoRetryCountChoices)) + for idx, count := range generalAutoRetryCountChoices { + idx := idx + count := count + countRows = append(countRows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalAutoRetryCountButtons[idx], fmt.Sprintf("%d 次", count), normalizeAutoRetryCount(a.autoRetryCount) == count) + })) + } + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, countRows...) + }), + 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...) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(gtx, "循环出图", func(gtx layout.Context) layout.Dimensions { + 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...) + }), + 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.field(gtx, "总张数", &a.loopTotalCountInput, "10", unit.Dp(42)) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.field(gtx, "并发数", &a.loopConcurrencyInput, "2", unit.Dp(42)) + }), + ) + }), + 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...) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(gtx, "批处理图生图", func(gtx layout.Context) layout.Dimensions { + 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, batchRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.technicalField(gtx, "输入目录", &a.batchInputDirInput, "选择一个目录后扫描当前目录图片", unit.Dp(42)) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseGeneralBatchInputButton, uiIconFolder, "选择批处理输入目录", false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseGeneralBatchFilesButton, uiIconAdd, "直接加入多张图片", false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.technicalField(gtx, "输出目录", &a.batchOutputDirInput, "留空 = 回原图目录", unit.Dp(42)) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseGeneralBatchOutputButton, uiIconFolder, "选择批处理输出目录", false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, batchRetryRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, batchAutoAspectRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(a.batchAutoAspect) == "" { + return layout.Dimensions{} + } + rows := make([]layout.FlexChild, 0, 5) + for idx, value := range []string{"256", "512", "1k", "2k", "4k"} { + idx := idx + value := value + rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.generalBatchAutoAspectResolutionButtons[idx], strings.ToUpper(value), a.batchAutoAspect == value) + })) + } + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, rows...) + }), + 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...) + }) + }, func(gtx layout.Context) layout.Dimensions { return a.generalSettingsCard(gtx, "日志保留", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, @@ -1263,6 +1707,18 @@ 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 { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, cleanupPreviewCacheRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "默认关闭。开启后,退出应用时会删除可重建的预览图和缩略图缓存;源图、结果图和历史记录不会删除。", unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }, func(gtx layout.Context) layout.Dimensions { return a.generalSettingsCard(gtx, "主题", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, themeRows...) @@ -1327,6 +1783,25 @@ func (a *App) layoutGeneralSettingsModal(gtx layout.Context, snap snapshot) layo })) } } + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.openPresetManagerButton, uiIconSettings, "打开预设管理", false) + })) + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, rows...) + }) + }, + func(gtx layout.Context) layout.Dimensions { + return a.generalSettingsCard(gtx, "自定义比例", func(gtx layout.Context) layout.Dimensions { + rows := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(a.customAspectRatios) == 0 { + return a.label(gtx, "当前还没有自定义比例。已保存的共享比例可以在这里维护。", unit.Sp(10), fluent.textDim, font.Normal) + } + return a.label(gtx, fmt.Sprintf("已保存 %d 个自定义比例,会直接出现在 Gio 的比例按钮区。", len(a.customAspectRatios)), unit.Sp(10), fluent.textDim, font.Normal) + }), + } + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.openCustomAspectRatioManagerButton, uiIconSettings, "打开比例管理", false) + })) return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, rows...) }) }, @@ -2026,6 +2501,16 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout if activeMode == "" { activeMode = a.api } + fallbackCandidates := make([]sharedCompat.UpstreamProfile, 0, len(snap.Profiles)) + for _, profile := range snap.Profiles { + if profile.ID == selectedID { + continue + } + if strings.TrimSpace(profile.BaseURL) == "" { + continue + } + fallbackCandidates = append(fallbackCandidates, profile) + } connectionSection := func(gtx layout.Context) layout.Dimensions { rows := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -2044,6 +2529,20 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout } return a.label(gtx, hint, unit.Sp(10), fluent.textDim, font.Normal) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.api != string(client.APIModeResponses) { + return layout.Dimensions{} + } + return a.layoutSettingsOptionCards(gtx, "Responses 传输", responsesTransportChoices, a.responsesTransport, a.responsesTransportButtons, 2, func(value string) { + a.responsesTransport = normalizeProfileResponsesTransport(value) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if a.api != string(client.APIModeResponses) { + return layout.Dimensions{} + } + return a.label(gtx, "WebSocket mode 需要上游 / 代理正确转发 Upgrade: websocket;若握手失败,请求层会自动回退到 HTTP SSE,并把原因写进 Raw response。", unit.Sp(10), fluent.textDim, font.Normal) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.layoutSettingsOptionCards(gtx, "参数策略", []settingsOptionChoice{ {Title: "OpenAI 标准", Detail: "只发送官方公开字段", Value: string(client.RequestPolicyOpenAI)}, @@ -2072,6 +2571,14 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.technicalField(gtx, "文本模型 ID", &a.textModelInput, client.TextModel, unit.Dp(40)) })) + 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) + }) + })) + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "仅 Responses API 生效,会写入 reasoning.effort。推理越高通常越稳,但延迟也会更长。", unit.Sp(10), fluent.textDim, font.Normal) + })) } rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -2081,6 +2588,76 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout return a.technicalField(gtx, "并发数量限制", &a.concurrencyLimitInput, "留空 = 不限制", unit.Dp(40)) }), ) + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "失败重试路由到", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := a.settingsProfileButton("fallback:none") + for btn.Clicked(gtx) { + a.fallbackProfileID = "" + } + active := strings.TrimSpace(a.fallbackProfileID) == "" + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, 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 layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(3))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "不自动切备用上游", unit.Sp(12), chooseColor(active, fluent.accent, fluent.text), font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "主上游自动重试仍失败后,不再额外切备用配置。", unit.Sp(10), chooseColor(active, withAlpha(fluent.accent, 0xd0), fluent.textDim), font.Normal) + }), + ) + }, + ) + }) + }), + } + for _, profile := range fallbackCandidates { + profile := profile + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := a.settingsProfileButton("fallback:" + profile.ID) + for btn.Clicked(gtx) { + a.fallbackProfileID = profile.ID + } + active := strings.TrimSpace(a.fallbackProfileID) == profile.ID + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, 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 layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(3))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, strings.TrimSpace(profile.Name)+" · "+apiChoiceLabel(strings.TrimSpace(profile.APIMode)), unit.Sp(12), chooseColor(active, fluent.accent, fluent.text), font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, strings.TrimSpace(profile.BaseURL), unit.Sp(10), chooseColor(active, withAlpha(fluent.accent, 0xd0), fluent.textDim), font.Normal) + }), + ) + }, + ) + }) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + })) + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "当前上游自动重试仍失败后,可额外切到这里选定的备用 profile 再尝试一次。默认关闭。", unit.Sp(10), fluent.textDim, font.Normal) + })) if a.api == string(client.APIModeImages) { rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { active := a.imagesNewAPICompat @@ -2276,7 +2853,7 @@ func (a *App) layoutSettingsEditorPane(gtx layout.Context, snap snapshot) layout } func (a *App) composeSummary(snap snapshot) string { - activeAspect := deriveAspectPreset(a.size) + activeAspect := deriveAspectPreset(a.size, a.customAspectRatios) activeResolution := normalizeResolutionChoice(deriveResolutionPreset(a.size), a.api, a.policy, a.imageModelInput.Text()) currentSaved := strings.TrimSpace(snap.Result.SavedPath) sourcePaths := a.sourcePaths() @@ -2291,6 +2868,13 @@ func (a *App) composeSummary(snap snapshot) string { sourceLabel = "未添加源图" } } + runModeLabel := "" + if a.batchMode { + batchSources := a.batchSourcePaths() + runModeLabel = fmt.Sprintf("批处理 %d 张", len(batchSources)) + } else if a.loopEnabled { + runModeLabel = fmt.Sprintf("循环 %d 张 / 并发 %d", normalizeLoopGenerationCount(a.loopTotalCount), normalizeLoopGenerationConcurrency(a.loopConcurrency)) + } key := strings.Join([]string{ a.styleTag, activeAspect, @@ -2298,6 +2882,7 @@ func (a *App) composeSummary(snap snapshot) string { a.quality, strconv.Itoa(normalizeBatchCount(a.batchCount)), a.mode, + runModeLabel, strconv.Itoa(len(sourcePaths)), currentSaved, }, "\x00") @@ -2315,6 +2900,7 @@ func (a *App) composeSummary(snap snapshot) string { choiceLabel(resolutionChoices, activeResolution), qualityChoiceLabel(a.quality), fmt.Sprintf("%d 张", normalizeBatchCount(a.batchCount)), + runModeLabel, sourceLabel, }), " · ") a.mu.Lock() @@ -2326,7 +2912,7 @@ func (a *App) composeSummary(snap snapshot) string { func (a *App) layoutComposeCard(gtx layout.Context, snap snapshot) layout.Dimensions { defer a.recordLayoutTiming(layoutTimingComposeCard, time.Now()) - activeAspect := deriveAspectPreset(a.size) + activeAspect := deriveAspectPreset(a.size, a.customAspectRatios) activeResolution := normalizeResolutionChoice(deriveResolutionPreset(a.size), a.api, a.policy, a.imageModelInput.Text()) currentSaved := strings.TrimSpace(snap.Result.SavedPath) sourcePaths := a.sourcePaths() @@ -2374,6 +2960,10 @@ func (a *App) layoutComposeCard(gtx layout.Context, snap snapshot) layout.Dimens layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.composeSectionCard(gtx, a.layoutBatchCountSection) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.composeSectionCard(gtx, a.layoutLoopSection) + }), ) if a.mode == string(client.ModeEdit) { children = append(children, @@ -2404,7 +2994,8 @@ func (a *App) layoutAspectSection(gtx layout.Context, activeAspect string, curre })) children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) - rows := 2 + choices := aspectChoicesWithCustom(a.customAspectRatios) + rows := (len(choices) + 2) / 3 cols := 3 for row := 0; row < rows; row++ { row := row @@ -2412,13 +3003,22 @@ func (a *App) layoutAspectSection(gtx layout.Context, activeAspect string, curre cells := make([]layout.FlexChild, 0, cols) for col := 0; col < cols; col++ { idx := row*cols + col - if idx >= len(aspectChoices) { + if idx >= len(choices) { cells = append(cells, layout.Flexed(1, layout.Spacer{}.Layout)) continue } - choice := aspectChoices[idx] + choice := choices[idx] for a.aspectButtons[idx].Clicked(gtx) { - a.size = buildAspectSizeSelection(choice.Value, currentResolution, a.api, a.policy, a.imageModelInput.Text()) + next := buildAspectSizeSelection(choice.Value, currentResolution, a.api, a.policy, a.imageModelInput.Text()) + if strings.HasPrefix(choice.Value, "custom:") { + for _, ratio := range a.customAspectRatios { + if "custom:"+ratio.ID == choice.Value { + next = buildCustomSizeSelection(ratio, currentResolution) + break + } + } + } + a.size = next } cells = append(cells, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { return a.layoutAspectButton(gtx, &a.aspectButtons[idx], choice, activeAspect == choice.Value) @@ -2498,6 +3098,30 @@ func (a *App) layoutStyleSection(gtx layout.Context) layout.Dimensions { } func (a *App) layoutSourceInputSection(gtx layout.Context, sourcePaths []string, currentSaved string) layout.Dimensions { + for a.composeSourceModeButtons[0].Clicked(gtx) { + a.batchMode = false + } + for a.composeSourceModeButtons[1].Clicked(gtx) { + a.batchMode = true + } + modeRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeSourceModeButtons[0], "普通图生图", !a.batchMode) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeSourceModeButtons[1], "批处理", a.batchMode) + }), + } + if a.batchMode { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(8))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, modeRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.layoutBatchSourceQueueSection(gtx, sourcePaths) + }), + ) + } for a.addSourceFilesButton.Clicked(gtx) { paths, err := chooseImageFiles() if err != nil { @@ -2520,6 +3144,10 @@ func (a *App) layoutSourceInputSection(gtx layout.Context, sourcePaths []string, } children := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, modeRows...) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { @@ -2594,6 +3222,262 @@ func (a *App) layoutSourceInputSection(gtx layout.Context, sourcePaths []string, return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } +func (a *App) layoutBatchSourceQueueSection(gtx layout.Context, sourcePaths []string) layout.Dimensions { + scannedPaths, scanErr := a.batchSourcePathsForRun() + displayPaths := sourcePaths + if len(displayPaths) == 0 && strings.TrimSpace(a.batchInputDirInput.Text()) != "" { + displayPaths = scannedPaths + } + for a.chooseBatchInputDirButton.Clicked(gtx) { + dir, err := chooseDirectory() + if err != nil { + a.appendLog("选择批处理输入目录失败: " + err.Error()) + } else if strings.TrimSpace(dir) != "" { + a.batchInputDirInput.SetText(dir) + a.sourcePathsInput.SetText("") + a.sourceButtons = map[string]*widget.Clickable{} + } + } + for a.chooseBatchFilesButton.Clicked(gtx) { + paths, err := chooseImageFiles() + if err != nil { + a.appendLog("选择批处理图片失败: " + err.Error()) + } else if len(paths) > 0 { + a.batchInputDirInput.SetText("") + a.sourcePathsInput.SetText(strings.Join(paths, "\n")) + a.sourceButtons = map[string]*widget.Clickable{} + } + } + for a.chooseBatchOutputDirButton.Clicked(gtx) { + dir, err := chooseDirectory() + if err != nil { + a.appendLog("选择批处理输出目录失败: " + err.Error()) + } else if strings.TrimSpace(dir) != "" { + a.batchOutputDirInput.SetText(dir) + a.batchOutputDir = dir + } + } + for a.addSourceFilesButton.Clicked(gtx) { + paths, err := chooseImageFiles() + if err != nil { + a.appendLog("选择批处理图片失败: " + err.Error()) + } else { + for _, path := range paths { + a.appendSourcePath(path) + } + } + } + for a.clearSourcesButton.Clicked(gtx) { + a.setSourcePaths(nil) + } + for _, path := range sourcePaths { + path := path + btn := a.sourceButton("batch-remove:" + path) + for btn.Clicked(gtx) { + a.removeSourcePath(path) + } + } + + queueLabel := fmt.Sprintf("当前队列 %d 张", len(displayPaths)) + if strings.TrimSpace(a.batchInputDirInput.Text()) != "" && len(sourcePaths) == 0 { + queueLabel = fmt.Sprintf("%s · 目录模式", queueLabel) + } + if len(sourcePaths) > 0 { + queueLabel = fmt.Sprintf("%s · 手动队列", fmt.Sprintf("当前队列 %d 张", len(sourcePaths))) + } + children := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "批处理队列", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.metaBadge(gtx, queueLabel, true) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + 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.compactIconTextButton(gtx, &a.chooseBatchInputDirButton, uiIconFolder, "选目录", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseBatchFilesButton, uiIconAdd, "选多图", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.chooseBatchOutputDirButton, uiIconFolder, "输出目录", false) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + } + sourceModeLabel := "直接多图" + if strings.TrimSpace(a.batchInputDirInput.Text()) != "" && len(sourcePaths) == 0 { + sourceModeLabel = "目录扫描" + } + if len(displayPaths) > 0 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "当前来源: "+sourceModeLabel, unit.Sp(10), fluent.textDim, font.Normal) + })) + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + + if scanErr != nil { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, dangerAlpha(0x12), fluentControlRadius, dangerAlpha(0x2a), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "批处理目录读取失败: "+scanErr.Error(), unit.Sp(10), fluent.danger, font.Normal) + }) + }) + })) + } else if len(displayPaths) == 0 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface2, fluentControlRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "可直接加入多张图片,或在设置里选择输入目录后按目录扫描。", unit.Sp(10), fluent.textDim, font.Normal) + }) + }) + })) + } else { + limit := len(displayPaths) + if limit > 6 { + limit = 6 + } + for idx := 0; idx < limit; idx++ { + path := displayPaths[idx] + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := a.sourceButton("batch-remove:" + path) + removeEnabled := len(sourcePaths) > 0 + return a.borderedSurface(gtx, fluent.surface, fluentControlRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: 8, Bottom: 8, Left: 10, Right: 10}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Right: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, strconv.Itoa(idx+1)+".", unit.Sp(10), fluent.textDim, font.Medium) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, filepath.Base(path), unit.Sp(11), fluent.text, font.Medium) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !removeEnabled { + return layout.Dimensions{} + } + return a.ghostIconButton(gtx, btn, uiIconClose, false) + }), + ) + }) + }) + })) + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout)) + } + if len(displayPaths) > limit { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, fmt.Sprintf("还有 %d 张未展开显示。", len(displayPaths)-limit), unit.Sp(10), fluent.textDim, font.Normal) + })) + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout)) + } + } + + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + row := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.addSourceFilesButton, uiIconAdd, "继续加入图片", false) + }), + } + if len(sourcePaths) > 0 { + row = append(row, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Left: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.compactIconButton(gtx, &a.clearSourcesButton, uiIconDelete, false) + }) + })) + } + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, row...) + })) + + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout)) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + retryRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeBatchRetryButtons[0], "自动重试", a.batchRetryOnFail) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeBatchRetryButtons[1], "失败跳过", !a.batchRetryOnFail) + }), + } + for a.composeBatchRetryButtons[0].Clicked(gtx) { + a.batchRetryOnFail = true + } + for a.composeBatchRetryButtons[1].Clicked(gtx) { + a.batchRetryOnFail = false + } + return a.borderedSurface(gtx, fluent.surface2, fluentControlRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "失败处理", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, retryRows...) + }), + ) + }) + }) + })) + + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout)) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + autoAspectRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeBatchAutoAspectButtons[0], "沿用当前比例", strings.TrimSpace(a.batchAutoAspect) == "") + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeBatchAutoAspectButtons[1], "按源图比例", strings.TrimSpace(a.batchAutoAspect) != "") + }), + } + for a.composeBatchAutoAspectButtons[0].Clicked(gtx) { + a.batchAutoAspect = "" + } + for a.composeBatchAutoAspectButtons[1].Clicked(gtx) { + if strings.TrimSpace(a.batchAutoAspect) == "" { + a.batchAutoAspect = "1k" + } + } + cardChildren := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "尺寸策略", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, autoAspectRows...) + }), + } + if strings.TrimSpace(a.batchAutoAspect) != "" { + cardChildren = append(cardChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + rows := make([]layout.FlexChild, 0, 5) + for idx, value := range []string{"256", "512", "1k", "2k", "4k"} { + idx := idx + value := value + for a.composeBatchAutoAspectResolutionButtons[idx].Clicked(gtx) { + a.batchAutoAspect = value + } + rows = append(rows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeBatchAutoAspectResolutionButtons[idx], strings.ToUpper(value), a.batchAutoAspect == value) + })) + } + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, rows...) + })) + } + return a.borderedSurface(gtx, fluent.surface2, fluentControlRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, cardChildren...) + }) + }) + })) + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + func indexOfSourcePath(path string, sourcePaths []string) int { for idx, value := range sourcePaths { if value == path { @@ -2657,6 +3541,129 @@ func (a *App) layoutBatchCountSection(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } +func (a *App) layoutLoopSection(gtx layout.Context) layout.Dimensions { + for a.composeLoopButtons[0].Clicked(gtx) { + a.loopEnabled = true + } + for a.composeLoopButtons[1].Clicked(gtx) { + a.loopEnabled = false + } + for a.composeLoopAutoSaveButtons[0].Clicked(gtx) { + a.loopAutoSave = true + } + for a.composeLoopAutoSaveButtons[1].Clicked(gtx) { + a.loopAutoSave = false + } + for a.composeLoopPreviewButtons[0].Clicked(gtx) { + a.loopLivePreview = true + } + for a.composeLoopPreviewButtons[1].Clicked(gtx) { + 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 + } + } + for idx, value := range []int{4, 8, 10, 20, 50} { + value := value + for a.composeLoopCountButtons[idx].Clicked(gtx) { + a.loopTotalCount = value + } + } + for idx, value := range []int{1, 2, 4, 8} { + value := value + for a.composeLoopConcurrencyButtons[idx].Clicked(gtx) { + a.loopConcurrency = value + } + } + + loopRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopButtons[0], "开启", a.loopEnabled) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopButtons[1], "关闭", !a.loopEnabled) + }), + } + autoSaveRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopAutoSaveButtons[0], "自动另存为", a.loopAutoSave) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopAutoSaveButtons[1], "不自动保存", !a.loopAutoSave) + }), + } + previewRows := []layout.FlexChild{ + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopPreviewButtons[0], "实时预览开", a.loopLivePreview) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopPreviewButtons[1], "实时预览关", !a.loopLivePreview) + }), + } + countRows := make([]layout.FlexChild, 0, 5) + for idx, value := range []int{4, 8, 10, 20, 50} { + idx := idx + value := value + countRows = append(countRows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopCountButtons[idx], fmt.Sprintf("%d 张", value), normalizeLoopGenerationCount(a.loopTotalCount) == value) + })) + } + concurrencyRows := make([]layout.FlexChild, 0, 4) + for idx, value := range []int{1, 2, 4, 8} { + idx := idx + value := value + concurrencyRows = append(concurrencyRows, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.composeLoopConcurrencyButtons[idx], fmt.Sprintf("%d 并发", value), normalizeLoopGenerationConcurrency(a.loopConcurrency) == value) + })) + } + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "循环出图", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + stateLabel := "关闭" + if a.loopEnabled { + stateLabel = fmt.Sprintf("%d 张 / %d 并发", normalizeLoopGenerationCount(a.loopTotalCount), normalizeLoopGenerationConcurrency(a.loopConcurrency)) + } + return a.metaBadge(gtx, stateLabel, true) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, loopRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, countRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, concurrencyRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, autoSaveRows...) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, previewRows...) + }), + 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 a.compactIconTextButton(gtx, &a.chooseLoopAutoSaveDirButton, uiIconFolder, "选择自动另存为目录", false) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "开启后提交按钮会按这里的总张数和并发持续补位生成。自动另存为与实时预览开关都已接到执行路径。", unit.Sp(10), fluent.textDim, font.Normal) + }), + ) +} + func (a *App) advancedSummary() string { partialPreview := strings.TrimSpace(a.partialImagesInput.Text()) if partialPreview == "" { @@ -2675,6 +3682,7 @@ func (a *App) advancedSummary() string { a.imageStyle, a.moderation, partialPreview, + strconv.FormatBool(a.protectStreamPreview), a.userIdentifierInput.Text(), a.seedInput.Text(), }, "\x00") @@ -2695,6 +3703,7 @@ func (a *App) advancedSummary() string { chooseOptionalImageStyleSummary(a.imageStyle), "审核 " + moderationChoiceLabel(a.moderation), "预览 " + partialPreviewSummary, + protectStreamPreviewSummary(a.protectStreamPreview), chooseOptionalUserIdentifierSummary(a.userIdentifierInput.Text()), seedSummary(a.seedInput.Text()), }), " · ") @@ -2711,6 +3720,12 @@ func (a *App) layoutAdvancedCard(gtx layout.Context) layout.Dimensions { copyResultDetailText(gtx, a.buildPerformanceDiagnosticsReport()) a.appendLog("已复制性能诊断") } + for a.protectStreamPreviewButtons[0].Clicked(gtx) { + a.protectStreamPreview = true + } + for a.protectStreamPreviewButtons[1].Clicked(gtx) { + a.protectStreamPreview = false + } summary := a.advancedSummary() return a.elevatedBorderedSurface(gtx, fluent.surfaceElevated, fluentCardRadius, fluent.border, image.Pt(0, 1), func(gtx layout.Context) layout.Dimensions { @@ -2815,6 +3830,19 @@ func (a *App) layoutAdvancedCard(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.label(gtx, "Gio 默认仅请求最终图;打开预览帧会增加上游响应体积和界面刷新开销。", unit.Sp(10), fluent.textDim, font.Normal) }), + 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.compactButton(gtx, &a.protectStreamPreviewButtons[0], "保护开启", a.protectStreamPreview) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.protectStreamPreviewButtons[1], "保护关闭", !a.protectStreamPreview) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "默认开启。桌面端高并发任务时会自动关闭流式预览;关闭后严格按上面的预览帧数请求。", unit.Sp(10), fluent.textDim, font.Normal) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return a.compactIconTextButton(gtx, &a.copyPerformanceDiagnosticsButton, uiIconCopy, "复制性能诊断", false) }), @@ -3191,6 +4219,13 @@ func chooseOptionalUserIdentifierSummary(value string) string { return "用户标识 已填" } +func protectStreamPreviewSummary(enabled bool) string { + if enabled { + return "预览保护 开" + } + return "预览保护 关" +} + func seedSummary(value string) string { value = strings.TrimSpace(value) if value == "" || value == "0" { @@ -3202,16 +4237,21 @@ func seedSummary(value string) string { func presetLabels(presets []sharedCompat.Preset) []promptHelperItem { items := make([]promptHelperItem, 0, len(presets)) for _, preset := range presets { - detail := strings.Join(compactNonEmpty([]string{ + detailItems := compactNonEmpty([]string{ preset.Size, preset.Quality, strings.ToUpper(strings.TrimSpace(preset.OutputFormat)), fmt.Sprintf("%d 张", normalizeBatchCount(preset.BatchCount)), - }), " · ") + }) + if styleTag := strings.TrimSpace(preset.StyleTag); styleTag != "" { + detailItems = append(detailItems, "#"+styleChoiceLabel(styleTag)) + } + detail := strings.Join(detailItems, " · ") items = append(items, promptHelperItem{ ID: preset.ID, Title: strings.TrimSpace(preset.Name), Detail: detail, + Kind: "preset", }) } return items @@ -3224,6 +4264,7 @@ func promptLabels(values []string) []promptHelperItem { ID: fmt.Sprintf("%d", idx), Title: shortPrompt(value), Detail: value, + Kind: "history", }) } return items @@ -3242,6 +4283,13 @@ func presetLabelCacheKey(presets []sharedCompat.Preset) string { strings.TrimSpace(preset.Size), strings.TrimSpace(preset.Quality), strings.TrimSpace(preset.OutputFormat), + strings.TrimSpace(preset.NegativePrompt), + strings.TrimSpace(preset.Background), + strings.TrimSpace(preset.InputFidelity), + strings.TrimSpace(preset.ImageStyle), + strings.TrimSpace(preset.Moderation), + strings.TrimSpace(preset.StyleTag), + strings.TrimSpace(preset.KernelRuntimeMode), strconv.Itoa(normalizeBatchCount(preset.BatchCount)), ) } diff --git a/gio-client/internal/ui/layout_shell.go b/gio-client/internal/ui/layout_shell.go index a1f4f9832..7dda5c7fd 100644 --- a/gio-client/internal/ui/layout_shell.go +++ b/gio-client/internal/ui/layout_shell.go @@ -77,6 +77,12 @@ func (a *App) layout(gtx layout.Context) layout.Dimensions { if snap.SavePromptVisible { a.layoutSavePrompt(gtx, snap) } + if snap.PromptImportRegisterOpen { + a.layoutPromptImportRegistrationPrompt(gtx, snap) + } + if snap.PromptImportVisible { + a.layoutPromptImportModal(gtx, snap) + } if a.generalSettingsOpen { a.layoutGeneralSettingsModal(gtx, snap) } @@ -89,6 +95,15 @@ func (a *App) layout(gtx layout.Context) layout.Dimensions { a.layoutSettingsHelpModal(gtx) } } + if a.promptTemplateManagerOpen { + a.layoutPromptTemplateManagerModal(gtx, snap) + } + if a.presetManagerOpen { + a.layoutPresetManagerModal(gtx, snap) + } + if a.customAspectRatioManagerOpen { + a.layoutCustomAspectRatioManagerModal(gtx) + } if snap.ActiveResultDetail.ID != "" || snap.ActiveResultDetail.SavedPath != "" { a.layoutResultDetailModal(gtx, snap) } @@ -468,6 +483,18 @@ func (a *App) layoutWorkspaceTab(gtx layout.Context, ws workspaceState, active b return a.singleLineLabel(gtx, a.displayedWorkspaceName(ws), unit.Sp(12), chooseColor(active, fluent.text, fluent.textMuted), weight) }) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + modeLabel := "" + if ws.BatchMode { + modeLabel = "批" + } else if ws.LoopEnabled { + modeLabel = "循" + } + if modeLabel == "" { + return layout.Dimensions{} + } + return a.metaBadge(gtx, modeLabel, true) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !running { return layout.Dimensions{} diff --git a/gio-client/internal/ui/preset_manager.go b/gio-client/internal/ui/preset_manager.go new file mode 100644 index 000000000..05690afdd --- /dev/null +++ b/gio-client/internal/ui/preset_manager.go @@ -0,0 +1,435 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "time" + + gioCompat "image-studio/gio-client/internal/compat" + sharedCompat "image-studio/shared/compat" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" +) + +func nextPresetName(items []sharedCompat.Preset) string { + used := map[int]struct{}{} + for _, item := range items { + name := strings.TrimSpace(item.Name) + if !strings.HasPrefix(name, "配置") { + continue + } + raw := strings.TrimSpace(strings.TrimPrefix(name, "配置")) + value, err := strconv.Atoi(raw) + if err == nil && value > 0 { + used[value] = struct{}{} + } + } + for i := 1; ; i++ { + if _, ok := used[i]; !ok { + return "配置" + strconv.Itoa(i) + } + } +} + +func (a *App) presetListButton(id string) *widget.Clickable { + if a.presetListButtons == nil { + a.presetListButtons = map[string]*widget.Clickable{} + } + if btn, ok := a.presetListButtons[id]; ok { + return btn + } + btn := new(widget.Clickable) + a.presetListButtons[id] = btn + return btn +} + +func (a *App) currentPresetSnapshot() sharedCompat.Preset { + compression := 100 + if raw := strings.TrimSpace(a.outputCompressionInput.Text()); raw != "" { + if value, err := strconv.Atoi(raw); err == nil { + compression = value + } + } + return sharedCompat.Preset{ + Name: strings.TrimSpace(a.presetNameInput.Text()), + Size: strings.TrimSpace(a.size), + Quality: strings.TrimSpace(a.quality), + OutputFormat: strings.TrimSpace(a.format), + NegativePrompt: strings.TrimSpace(a.negativePromptInput.Text()), + Background: strings.TrimSpace(a.background), + OutputCompression: &compression, + InputFidelity: strings.TrimSpace(a.inputFidelity), + ImageStyle: strings.TrimSpace(a.imageStyle), + Moderation: strings.TrimSpace(a.moderation), + StyleTag: strings.TrimSpace(a.styleTag), + KernelRuntimeMode: normalizeKernelRuntimeMode(a.kernelRuntimeMode), + BatchCount: normalizeBatchCount(a.batchCount), + } +} + +func (a *App) openPresetManager() { + a.mu.Lock() + if a.selectedPresetID == "" && len(a.presets) > 0 { + a.selectedPresetID = strings.TrimSpace(a.presets[0].ID) + } + if a.selectedPresetID != "" { + a.loadPresetDraftLocked(a.selectedPresetID) + } else { + a.presetNameInput.SetText(nextPresetName(a.presets)) + } + a.presetManagerOpen = true + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) closePresetManager() { + a.mu.Lock() + a.presetManagerOpen = false + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) loadPresetDraftLocked(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for _, item := range a.presets { + if strings.TrimSpace(item.ID) != id { + continue + } + a.selectedPresetID = id + a.presetNameInput.SetText(strings.TrimSpace(item.Name)) + return true + } + return false +} + +func (a *App) startNewPresetDraft() { + a.mu.Lock() + a.selectedPresetID = "" + a.presetNameInput.SetText(nextPresetName(a.presets)) + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) savePresetAsNew() { + name := strings.TrimSpace(a.presetNameInput.Text()) + if name == "" { + a.appendLog("预设名称不能为空") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("保存预设失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + now := time.Now().UnixMilli() + preset := a.currentPresetSnapshot() + preset.ID = fmt.Sprintf("preset-%d", now) + preset.Name = name + state.Settings.Presets = append([]sharedCompat.Preset{preset}, state.Settings.Presets...) + state.UpdatedAt = now + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("保存预设失败: " + err.Error()) + return + } + a.mu.Lock() + a.setPresetsLocked(state.Settings.Presets) + a.selectedPresetID = preset.ID + a.presetNameInput.SetText(preset.Name) + a.mu.Unlock() + a.appendLog("已保存预设: " + preset.Name) + a.invalidateNow() +} + +func (a *App) overwriteSelectedPreset() { + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedPresetID) + a.mu.Unlock() + if targetID == "" { + a.appendLog("当前没有可覆盖的预设") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("更新预设失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + snapshot := a.currentPresetSnapshot() + updated := false + name := "" + for i := range state.Settings.Presets { + if strings.TrimSpace(state.Settings.Presets[i].ID) != targetID { + continue + } + name = strings.TrimSpace(state.Settings.Presets[i].Name) + snapshot.ID = state.Settings.Presets[i].ID + snapshot.Name = name + state.Settings.Presets[i] = snapshot + updated = true + break + } + if !updated { + a.appendLog("当前预设不存在") + return + } + state.UpdatedAt = time.Now().UnixMilli() + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("更新预设失败: " + err.Error()) + return + } + a.mu.Lock() + a.setPresetsLocked(state.Settings.Presets) + a.selectedPresetID = targetID + a.presetNameInput.SetText(name) + a.mu.Unlock() + a.appendLog("已更新预设: " + name) + a.invalidateNow() +} + +func (a *App) deleteSelectedPreset() { + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedPresetID) + a.mu.Unlock() + if targetID == "" { + a.appendLog("当前没有可删除的预设") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("删除预设失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + next := make([]sharedCompat.Preset, 0, len(state.Settings.Presets)) + removedName := "" + for _, item := range state.Settings.Presets { + if strings.TrimSpace(item.ID) == targetID { + removedName = strings.TrimSpace(item.Name) + continue + } + next = append(next, item) + } + if len(next) == len(state.Settings.Presets) { + a.appendLog("当前预设不存在") + return + } + state.Settings.Presets = next + state.UpdatedAt = time.Now().UnixMilli() + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("删除预设失败: " + err.Error()) + return + } + a.mu.Lock() + a.setPresetsLocked(state.Settings.Presets) + if len(state.Settings.Presets) > 0 { + a.selectedPresetID = strings.TrimSpace(state.Settings.Presets[0].ID) + a.presetNameInput.SetText(strings.TrimSpace(state.Settings.Presets[0].Name)) + } else { + a.selectedPresetID = "" + a.presetNameInput.SetText(nextPresetName(nil)) + } + a.mu.Unlock() + if removedName == "" { + removedName = targetID + } + a.appendLog("已删除预设: " + removedName) + a.invalidateNow() +} + +func (a *App) applySelectedPreset() { + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedPresetID) + a.mu.Unlock() + if !a.applyPresetByID(targetID) { + a.appendLog("当前预设不存在") + } +} + +func (a *App) layoutPresetManagerModal(gtx layout.Context, snap snapshot) layout.Dimensions { + for a.closePresetManagerButton.Clicked(gtx) { + a.closePresetManager() + } + for a.newPresetButton.Clicked(gtx) { + a.startNewPresetDraft() + } + for a.savePresetButton.Clicked(gtx) { + a.savePresetAsNew() + } + for a.overwritePresetButton.Clicked(gtx) { + a.overwriteSelectedPreset() + } + for a.applyPresetButton.Clicked(gtx) { + a.applySelectedPreset() + } + for a.deletePresetButton.Clicked(gtx) { + a.deleteSelectedPreset() + } + for _, item := range snap.Presets { + btn := a.presetListButton("preset-list:" + item.ID) + for btn.Clicked(gtx) { + a.mu.Lock() + a.loadPresetDraftLocked(item.ID) + a.mu.Unlock() + a.invalidateNow() + } + } + + return a.layoutStandardModal( + gtx, + unit.Dp(900), + unit.Dp(620), + "参数预设", + "", + &a.closePresetManagerButton, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(12))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(280), func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, fluentCardRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + 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.compactIconTextButton(gtx, &a.newPresetButton, uiIconAdd, "新建", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.applyPresetButton, uiIconCheck, "应用", true) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + } + if len(snap.Presets) == 0 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "还没有保存参数预设。先调好当前参数,再保存一条。", unit.Sp(11), fluent.textDim, font.Normal) + })) + } else { + items := a.presetLabelsCached(snap.Presets) + for idx, item := range snap.Presets { + item := item + summary := items[idx] + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(item.ID) == strings.TrimSpace(a.selectedPresetID) + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.surfaceButton( + gtx, + a.presetListButton("preset-list:"+item.ID), + chooseColor(selected, fluent.accentSoft, rgba(0xffffff, 0x00)), + chooseColor(selected, accentAlpha(0x18), fluent.surface2), + chooseColor(selected, accentAlpha(0x38), fluent.border), + fluentCardRadius, + layout.Inset{Top: 9, Bottom: 9, Left: 10, Right: 10}, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(3))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, strings.TrimSpace(summary.Title), unit.Sp(11), chooseColor(selected, fluent.accent, fluent.text), font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, strings.TrimSpace(summary.Detail), unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }, + ) + }) + })) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, fluentCardRadius, fluent.border, 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(10))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + title := "保存当前参数" + if strings.TrimSpace(a.selectedPresetID) != "" { + title = "当前选中预设" + } + return a.label(gtx, title, unit.Sp(12), fluent.text, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.field(gtx, "预设名称", &a.presetNameInput, "配置1", unit.Dp(42)) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + summary := a.currentPresetSnapshot() + lines := compactNonEmpty([]string{ + sizeDisplayLabel(summary.Size), + qualityDisplayLabel(summary.Quality), + strings.ToUpper(strings.TrimSpace(summary.OutputFormat)), + "负向: " + chooseValueOrFallback(summary.NegativePrompt, "空"), + "背景: " + chooseValueOrFallback(summary.Background, "auto"), + "保真: " + chooseValueOrFallback(summary.InputFidelity, "auto"), + "图风: " + chooseValueOrFallback(summary.ImageStyle, "default"), + "审核: " + chooseValueOrFallback(summary.Moderation, "low"), + "风格: " + chooseValueOrFallback(summary.StyleTag, "默认"), + fmt.Sprintf("张数: %d", summary.BatchCount), + }) + rows := make([]layout.FlexChild, 0, len(lines)) + for _, line := range lines { + line := line + rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, line, unit.Sp(10), fluent.textDim, font.Normal) + })) + } + return a.borderedSurface(gtx, fluent.surface2, fluentCardRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(4))}.Layout(gtx, rows...) + }) + }) + }), + 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.compactIconTextButton(gtx, &a.savePresetButton, uiIconSave, "另存新预设", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.overwritePresetButton, uiIconRefresh, "覆盖当前预设", true) + }), + ) + }), + 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.compactIconTextButton(gtx, &a.applyPresetButton, uiIconCheck, "应用到当前参数", true) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(124), func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.deletePresetButton, uiIconDelete, "删除", false) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, fmt.Sprintf("当前已保存 %d 条共享预设,WebView 与 Gio 会共用这份状态。", len(snap.Presets)), unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }) + }), + ) + }, + ) +} + +func chooseValueOrFallback(value string, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/gio-client/internal/ui/prompt_import.go b/gio-client/internal/ui/prompt_import.go new file mode 100644 index 000000000..958f38e92 --- /dev/null +++ b/gio-client/internal/ui/prompt_import.go @@ -0,0 +1,385 @@ +package ui + +import ( + "context" + "strings" + + "gioui.org/font" + "gioui.org/io/system" + "gioui.org/layout" + "gioui.org/unit" + "github.com/yuanhua/image-gptcodex/pkg/promptimport" + "image-studio/gio-client/internal/promptscheme" +) + +func normalizeImportedPromptSize(size string) string { + size = strings.TrimSpace(size) + if size == "" || size == "auto" { + return "auto" + } + for _, choice := range sizeChoices { + if choice.Value == size { + return size + } + } + return "auto" +} + +func promptImportErrorMessage(err error) string { + switch promptimport.ErrorCode(err) { + case promptimport.TokenUsed: + return "这个提示词已经导入过了" + case promptimport.TokenExpired: + return "导入链接已过期,请回网页重新发送" + case promptimport.TokenNotFound, promptimport.TokenInvalid: + return "提示词链接无效或已被清理,请回网页重新发送" + default: + return "导入服务暂时不可用" + } +} + +func promptImportPreferredText(text *promptimport.BilingualText) string { + return promptimport.PreferChinese(text) +} + +func (a *App) HandlePromptImportInvalid() { + a.RaiseWindow() + a.mu.Lock() + a.status = "导入链接无效" + a.lastErrorMessage = "提示词链接无效或已被清理,请回网页重新发送" + a.appendLogLocked("外部导入失败: " + a.lastErrorMessage) + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) HandlePromptImportToken(token string) { + token = strings.TrimSpace(token) + if !promptimport.IsValidToken(token) { + a.HandlePromptImportInvalid() + return + } + a.RaiseWindow() + a.mu.Lock() + a.promptImportQueue = append(a.promptImportQueue, token) + shouldStart := !a.promptImportLoading && !a.promptImportOpen && len(a.promptImportQueue) == 1 + a.mu.Unlock() + a.invalidateNow() + if shouldStart { + go a.runNextPromptImport() + } +} + +func (a *App) runNextPromptImport() { + a.mu.Lock() + if a.promptImportLoading || a.promptImportOpen || len(a.promptImportQueue) == 0 { + a.mu.Unlock() + return + } + token := a.promptImportQueue[0] + a.promptImportLoading = true + a.promptImportToken = token + a.promptImportPayload = nil + a.promptImportResolvedSize = "auto" + a.mu.Unlock() + a.invalidateNow() + + payload, err := promptimport.Fetch(context.Background(), token, promptimport.FetchOptions{}) + if err != nil { + message := promptImportErrorMessage(err) + a.mu.Lock() + if len(a.promptImportQueue) > 0 && a.promptImportQueue[0] == token { + a.promptImportQueue = a.promptImportQueue[1:] + } + a.promptImportLoading = false + a.promptImportToken = "" + a.status = "导入失败" + a.lastErrorMessage = message + a.appendLogLocked("外部导入失败: " + message) + a.mu.Unlock() + a.invalidateNow() + go a.runNextPromptImport() + return + } + + a.mu.Lock() + if len(a.promptImportQueue) > 0 && a.promptImportQueue[0] == token { + a.promptImportQueue = a.promptImportQueue[1:] + } + a.promptImportLoading = false + a.promptImportOpen = true + a.promptImportToken = token + a.promptImportPayload = payload + a.promptImportResolvedSize = normalizeImportedPromptSize(payload.ResolvedSize) + a.lastErrorMessage = "" + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) confirmPromptImport() { + a.mu.Lock() + payload := a.promptImportPayload + resolvedSize := a.promptImportResolvedSize + a.mu.Unlock() + if payload == nil { + a.closePromptImportModal() + return + } + a.promptInput.SetText(promptImportPreferredText(&payload.Prompt)) + if payload.NegativePrompt != nil { + a.negativePromptInput.SetText(promptImportPreferredText(payload.NegativePrompt)) + } else { + a.negativePromptInput.SetText("") + } + a.size = normalizeImportedPromptSize(resolvedSize) + a.mu.Lock() + a.promptImportOpen = false + a.promptImportPayload = nil + a.promptImportToken = "" + a.status = "已从 Image-Prompts 导入提示词" + a.saveActiveWorkspaceSnapshot() + a.appendLogLocked("已从 Image-Prompts 导入提示词") + a.mu.Unlock() + a.invalidateNow() + go a.runNextPromptImport() +} + +func (a *App) closePromptImportModal() { + a.mu.Lock() + a.promptImportOpen = false + a.promptImportPayload = nil + a.promptImportToken = "" + a.mu.Unlock() + a.invalidateNow() + go a.runNextPromptImport() +} + +func (a *App) schedulePromptImportRegistrationCheck() { + go func() { + status, err := promptscheme.StatusForCurrentExecutable() + if err != nil { + a.appendLog("检测网页导入协议失败: " + err.Error()) + return + } + a.mu.Lock() + a.promptImportRegisterOpen = !status.Registered + if strings.TrimSpace(status.Handler) != "" { + a.promptImportRegisterNote = status.Handler + } else if strings.TrimSpace(status.Detail) != "" { + a.promptImportRegisterNote = status.Detail + } + a.mu.Unlock() + a.invalidateNow() + }() +} + +func (a *App) registerPromptImportProtocol() { + a.mu.Lock() + if a.promptImportRegisterBusy { + a.mu.Unlock() + return + } + a.promptImportRegisterBusy = true + a.promptImportRegisterNote = "正在注册 image-studio:// 协议..." + a.mu.Unlock() + a.invalidateNow() + go func() { + err := promptscheme.RegisterCurrentExecutable() + if err != nil { + a.mu.Lock() + a.promptImportRegisterBusy = false + a.promptImportRegisterNote = "注册失败: " + err.Error() + a.lastErrorMessage = "网页导入协议注册失败" + a.appendLogLocked("网页导入协议注册失败: " + err.Error()) + a.mu.Unlock() + a.invalidateNow() + return + } + a.mu.Lock() + a.promptImportRegisterBusy = false + a.promptImportRegisterOpen = false + a.promptImportRegisterNote = "已注册 image-studio:// 到当前 Gio 客户端" + a.appendLogLocked("已注册网页导入协议到当前 Gio 客户端") + a.mu.Unlock() + a.invalidateNow() + }() +} + +func (a *App) dismissPromptImportRegistrationPrompt() { + a.mu.Lock() + a.promptImportRegisterOpen = false + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) RaiseWindow() { + if a.window != nil { + a.window.Perform(system.ActionRaise) + a.window.Invalidate() + } +} + +func (a *App) layoutPromptImportModal(gtx layout.Context, snap snapshot) layout.Dimensions { + for a.promptImportCloseButton.Clicked(gtx) { + a.closePromptImportModal() + } + for a.promptImportConfirmButton.Clicked(gtx) { + a.confirmPromptImport() + } + payload := snap.PromptImportPayload + if payload == nil { + return layout.Dimensions{} + } + aspectRatio := strings.TrimSpace(payload.AspectRatio) + if aspectRatio == "" { + aspectRatio = "auto" + } + renderTextBlock := func(gtx layout.Context, title string, text *promptimport.BilingualText) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface2, unit.Dp(14), fluent.border, 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(8))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, title, unit.Sp(12), fluent.text, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "中文", unit.Sp(10), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, strings.TrimSpace(text.Zh), unit.Sp(11), fluent.text, font.Normal) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "English", unit.Sp(10), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, strings.TrimSpace(text.En), unit.Sp(11), fluent.text, font.Normal) + }), + ) + }) + }) + } + return a.layoutStandardModal( + gtx, + unit.Dp(640), + 0, + "从 Image-Prompts 导入提示词", + "来源站点: prompts.sorry.ink", + &a.promptImportCloseButton, + func(gtx layout.Context) layout.Dimensions { + negative := payload.NegativePrompt + if negative == nil { + negative = &promptimport.BilingualText{} + } + 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, "确认后将覆盖当前表单中的提示词、反向提示词与尺寸,不会改动参考图、模式、批处理或上游配置。", unit.Sp(11), fluent.textMuted, font.Normal) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return renderTextBlock(gtx, "正向提示词", &payload.Prompt) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return renderTextBlock(gtx, "反向提示词", negative) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(10))}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface2, unit.Dp(12), fluent.border, 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, "站点比例", unit.Sp(10), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, aspectRatio, unit.Sp(12), fluent.text, font.SemiBold) + }), + ) + }) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface2, unit.Dp(12), fluent.border, 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, "应用后尺寸", unit.Sp(10), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, snap.PromptImportResolvedSize, unit.Sp(12), fluent.text, font.SemiBold) + }), + ) + }) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(10))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(110), func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.promptImportCloseButton, "取消", false) + }) + }), + layout.Flexed(1, layout.Spacer{}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(148), func(gtx layout.Context) layout.Dimensions { + return a.primaryButton(gtx, &a.promptImportConfirmButton, "导入到表单", fluent.accent, fluent.white) + }) + }), + ) + }), + ) + }, + ) +} + +func (a *App) layoutPromptImportRegistrationPrompt(gtx layout.Context, snap snapshot) layout.Dimensions { + for a.promptImportRegisterNowButton.Clicked(gtx) { + a.registerPromptImportProtocol() + } + for a.promptImportRegisterLaterButton.Clicked(gtx) { + a.dismissPromptImportRegistrationPrompt() + } + subtitle := "当前 Windows / Linux 桌面端默认由 Gio 客户端接收 image-studio://import?... 深链。" + return a.layoutStandardModal( + gtx, + unit.Dp(560), + 0, + "注册网页导入协议", + subtitle, + nil, + 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, "若你希望从 prompts.sorry.ink 点击 “Send to Image-Studio” 后直接打开当前 Gio 客户端,请先把 image-studio:// 协议注册到当前程序。", unit.Sp(11), fluent.textMuted, font.Normal) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(snap.PromptImportRegisterNote) == "" { + return layout.Dimensions{} + } + return a.borderedSurface(gtx, fluent.surface2, unit.Dp(12), 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, snap.PromptImportRegisterNote, unit.Sp(10), fluent.textDim, font.Normal) + }) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(10))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(110), func(gtx layout.Context) layout.Dimensions { + return a.compactButton(gtx, &a.promptImportRegisterLaterButton, "稍后", false) + }) + }), + layout.Flexed(1, layout.Spacer{}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := "注册为默认处理器" + if snap.PromptImportRegisterBusy { + label = "注册中..." + } + return fixedWidth(gtx, unit.Dp(172), func(gtx layout.Context) layout.Dimensions { + return a.primaryButton(gtx, &a.promptImportRegisterNowButton, label, fluent.accent, fluent.white) + }) + }), + ) + }), + ) + }, + ) +} diff --git a/gio-client/internal/ui/prompt_template_manager.go b/gio-client/internal/ui/prompt_template_manager.go new file mode 100644 index 000000000..6b7e188f8 --- /dev/null +++ b/gio-client/internal/ui/prompt_template_manager.go @@ -0,0 +1,348 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "time" + + gioCompat "image-studio/gio-client/internal/compat" + sharedCompat "image-studio/shared/compat" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" +) + +func nextPromptTemplateLabel(items []sharedCompat.PromptTemplate) string { + used := map[int]struct{}{} + for _, item := range items { + label := strings.TrimSpace(item.Label) + if !strings.HasPrefix(label, "模板") { + continue + } + raw := strings.TrimSpace(strings.TrimPrefix(label, "模板")) + value, err := strconv.Atoi(raw) + if err == nil && value > 0 { + used[value] = struct{}{} + } + } + for i := 1; ; i++ { + if _, ok := used[i]; !ok { + return "模板" + strconv.Itoa(i) + } + } +} + +func (a *App) promptTemplateListButton(id string) *widget.Clickable { + if a.promptTemplateListButtons == nil { + a.promptTemplateListButtons = map[string]*widget.Clickable{} + } + if btn, ok := a.promptTemplateListButtons[id]; ok { + return btn + } + btn := new(widget.Clickable) + a.promptTemplateListButtons[id] = btn + return btn +} + +func (a *App) openPromptTemplateManager() { + a.mu.Lock() + if a.selectedPromptTemplateID == "" && len(a.promptTemplates) > 0 { + a.selectedPromptTemplateID = strings.TrimSpace(a.promptTemplates[0].ID) + } + if a.selectedPromptTemplateID != "" { + a.loadPromptTemplateDraftLocked(a.selectedPromptTemplateID) + } else { + a.promptTemplateLabelInput.SetText(nextPromptTemplateLabel(a.promptTemplates)) + a.promptTemplateTextInput.SetText(strings.TrimSpace(a.promptInput.Text())) + } + a.promptTemplateManagerOpen = true + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) closePromptTemplateManager() { + a.mu.Lock() + a.promptTemplateManagerOpen = false + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) loadPromptTemplateDraftLocked(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for _, item := range a.promptTemplates { + if strings.TrimSpace(item.ID) != id { + continue + } + a.selectedPromptTemplateID = id + a.promptTemplateLabelInput.SetText(strings.TrimSpace(item.Label)) + a.promptTemplateTextInput.SetText(strings.TrimSpace(item.Text)) + return true + } + return false +} + +func (a *App) startNewPromptTemplateDraft() { + a.mu.Lock() + a.selectedPromptTemplateID = "" + a.promptTemplateLabelInput.SetText(nextPromptTemplateLabel(a.promptTemplates)) + a.promptTemplateTextInput.SetText(strings.TrimSpace(a.promptInput.Text())) + a.mu.Unlock() + a.invalidateNow() +} + +func (a *App) savePromptTemplateDraft() { + label := strings.TrimSpace(a.promptTemplateLabelInput.Text()) + text := strings.TrimSpace(a.promptTemplateTextInput.Text()) + if label == "" || text == "" { + a.appendLog("模板标题和内容不能为空") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("保存模板失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + now := time.Now().UnixMilli() + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedPromptTemplateID) + a.mu.Unlock() + updated := false + for i := range state.Settings.PromptTemplates { + if strings.TrimSpace(state.Settings.PromptTemplates[i].ID) != targetID || targetID == "" { + continue + } + state.Settings.PromptTemplates[i].Label = label + state.Settings.PromptTemplates[i].Text = text + state.Settings.PromptTemplates[i].UpdatedAt = now + updated = true + break + } + if !updated { + targetID = fmt.Sprintf("tpl-%d", now) + state.Settings.PromptTemplates = append([]sharedCompat.PromptTemplate{{ + ID: targetID, + Label: label, + Text: text, + CreatedAt: now, + UpdatedAt: now, + }}, state.Settings.PromptTemplates...) + } + state.UpdatedAt = now + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("保存模板失败: " + err.Error()) + return + } + a.mu.Lock() + a.setPromptTemplatesLocked(state.Settings.PromptTemplates) + a.selectedPromptTemplateID = targetID + a.promptTemplateManagerOpen = true + a.loadPromptTemplateDraftLocked(targetID) + a.mu.Unlock() + if updated { + a.appendLog("已更新模板: " + label) + } else { + a.appendLog("已保存模板: " + label) + } + a.invalidateNow() +} + +func (a *App) deleteSelectedPromptTemplate() { + targetID := "" + a.mu.Lock() + targetID = strings.TrimSpace(a.selectedPromptTemplateID) + a.mu.Unlock() + if targetID == "" { + a.appendLog("当前没有可删除的模板") + return + } + state, _, err := gioCompat.LoadState() + if err != nil { + a.appendLog("删除模板失败: " + err.Error()) + return + } + state = sharedCompat.Normalize(state) + next := make([]sharedCompat.PromptTemplate, 0, len(state.Settings.PromptTemplates)) + removedLabel := "" + for _, item := range state.Settings.PromptTemplates { + if strings.TrimSpace(item.ID) == targetID { + removedLabel = strings.TrimSpace(item.Label) + continue + } + next = append(next, item) + } + if len(next) == len(state.Settings.PromptTemplates) { + a.appendLog("当前模板不存在") + return + } + state.Settings.PromptTemplates = next + state.UpdatedAt = time.Now().UnixMilli() + if err := gioCompat.SaveState(state); err != nil { + a.appendLog("删除模板失败: " + err.Error()) + return + } + a.mu.Lock() + a.setPromptTemplatesLocked(state.Settings.PromptTemplates) + if len(state.Settings.PromptTemplates) > 0 { + a.selectedPromptTemplateID = strings.TrimSpace(state.Settings.PromptTemplates[0].ID) + a.loadPromptTemplateDraftLocked(a.selectedPromptTemplateID) + } else { + a.selectedPromptTemplateID = "" + a.promptTemplateLabelInput.SetText(nextPromptTemplateLabel(nil)) + a.promptTemplateTextInput.SetText("") + } + a.mu.Unlock() + if removedLabel == "" { + removedLabel = targetID + } + a.appendLog("已删除模板: " + removedLabel) + a.invalidateNow() +} + +func (a *App) layoutPromptTemplateManagerModal(gtx layout.Context, snap snapshot) layout.Dimensions { + for a.closePromptHelperButton.Clicked(gtx) { + a.closePromptTemplateManager() + } + for a.newPromptTemplateButton.Clicked(gtx) { + a.startNewPromptTemplateDraft() + } + for a.savePromptTemplateButton.Clicked(gtx) { + a.savePromptTemplateDraft() + } + for a.deletePromptTemplateButton.Clicked(gtx) { + a.deleteSelectedPromptTemplate() + } + for _, item := range snap.PromptTemplates { + btn := a.promptTemplateListButton("prompt-template-list:" + item.ID) + for btn.Clicked(gtx) { + a.mu.Lock() + a.loadPromptTemplateDraftLocked(item.ID) + a.mu.Unlock() + a.invalidateNow() + } + } + + return a.layoutStandardModal( + gtx, + unit.Dp(860), + unit.Dp(620), + "自定义提示词模板", + "", + &a.closePromptHelperButton, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Gap: gtx.Dp(unit.Dp(12))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(260), func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, fluentCardRadius, fluent.border, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + 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.compactIconTextButton(gtx, &a.newPromptTemplateButton, uiIconAdd, "新建", false) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.savePromptTemplateButton, uiIconSave, "保存", true) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + } + if len(snap.PromptTemplates) == 0 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "还没有自定义模板。可从当前 prompt 新建一条。", unit.Sp(11), fluent.textDim, font.Normal) + })) + } else { + for _, item := range snap.PromptTemplates { + item := item + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selected := strings.TrimSpace(item.ID) == strings.TrimSpace(a.selectedPromptTemplateID) + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.surfaceButton( + gtx, + a.promptTemplateListButton("prompt-template-list:"+item.ID), + chooseColor(selected, fluent.accentSoft, rgba(0xffffff, 0x00)), + chooseColor(selected, accentAlpha(0x18), fluent.surface2), + chooseColor(selected, accentAlpha(0x38), fluent.border), + fluentCardRadius, + layout.Inset{Top: 9, Bottom: 9, Left: 10, Right: 10}, + func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(3))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.singleLineLabel(gtx, strings.TrimSpace(item.Label), unit.Sp(11), chooseColor(selected, fluent.accent, fluent.text), font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.clampedLabel(gtx, strings.TrimSpace(item.Text), unit.Sp(10), fluent.textDim, font.Normal, 2) + }), + ) + }, + ) + }) + })) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) + }) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, fluentCardRadius, fluent.border, 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(10))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + title := "新建模板" + if strings.TrimSpace(a.selectedPromptTemplateID) != "" { + title = "编辑模板" + } + return a.label(gtx, title, unit.Sp(12), fluent.text, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.field(gtx, "模板标题", &a.promptTemplateLabelInput, "模板1", unit.Dp(42)) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical, Gap: gtx.Dp(unit.Dp(6))}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, "模板内容", unit.Sp(11), fluent.textMuted, font.SemiBold) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedHeight(gtx, unit.Dp(320), func(gtx layout.Context) layout.Dimensions { + return a.borderedSurface(gtx, fluent.surface, fluentInputRadius, fluent.border2, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: 10, Bottom: 10, Left: 12, Right: 12}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return a.editorText(gtx, &a.promptTemplateTextInput, "输入模板内容", unit.Sp(13)) + }) + }) + }) + }), + ) + }), + 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.compactIconTextButton(gtx, &a.savePromptTemplateButton, uiIconSave, "保存模板", true) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return fixedWidth(gtx, unit.Dp(112), func(gtx layout.Context) layout.Dimensions { + return a.compactIconTextButton(gtx, &a.deletePromptTemplateButton, uiIconDelete, "删除", false) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return a.label(gtx, fmt.Sprintf("当前已保存 %d 条自定义模板;内置模板仍会保留在「模板 / 历史」里。", len(snap.PromptTemplates)), unit.Sp(10), fluent.textDim, font.Normal) + }), + ) + }) + }) + }), + ) + }, + ) +} diff --git a/gio-client/internal/ui/run_job.go b/gio-client/internal/ui/run_job.go index 07279607a..633140684 100644 --- a/gio-client/internal/ui/run_job.go +++ b/gio-client/internal/ui/run_job.go @@ -4,15 +4,24 @@ import ( "context" "errors" "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" "strconv" "strings" + "sync" + "sync/atomic" "time" gioCompat "image-studio/gio-client/internal/compat" "image-studio/gio-client/internal/kernel" sharedCompat "image-studio/shared/compat" + _ "github.com/gen2brain/avif" "github.com/yuanhua/image-gptcodex/pkg/client" + _ "golang.org/x/image/webp" ) func (a *App) startRun() { @@ -24,7 +33,27 @@ func (a *App) startRun() { a.appendLog("请先填写提示词,再开始生成。") return } - a.startRunWithConfig(cfg, normalizeBatchCount(a.batchCount)) + if a.batchMode && strings.TrimSpace(a.batchInputDirInput.Text()) == "" && len(a.sourcePaths()) == 0 { + a.appendLog("请先为批处理选择输入目录或多张源图。") + return + } + total := normalizeBatchCount(a.batchCount) + if a.loopEnabled { + total = normalizeLoopGenerationCount(a.loopTotalCount) + } + if a.batchMode { + batchSources, err := a.batchSourcePathsForRun() + if err != nil { + a.appendLog("读取批处理目录失败: " + err.Error()) + return + } + if len(batchSources) == 0 { + a.appendLog("批处理目录里没有可用图片。") + return + } + total = len(batchSources) + } + a.startRunWithConfig(cfg, total) } func (a *App) retryLastRun() { @@ -66,111 +95,188 @@ func (a *App) startRunWithConfig(cfg kernel.Config, total int) { go func() { batchStarted := time.Now() - for i := 0; i < total; i++ { - if err := ctx.Err(); err != nil { - a.finishCancelled() - return - } - jobCfg := cfg - jobCfg.BatchIndex = i - jobCfg.Prompt = augmentPromptWithStyle(cfg.Prompt, cfg.StyleTag) - if cfg.Seed != 0 { - jobCfg.Seed = cfg.Seed + int64(i) + batchSources := a.batchSourcePaths() + var once sync.Once + var firstErr atomic.Pointer[error] + var jobsDone atomic.Int32 + concurrency := total + if a.loopEnabled { + concurrency = normalizeLoopGenerationConcurrency(a.loopConcurrency) + if concurrency > total { + concurrency = total } - jobLabel := fmt.Sprintf("%d/%d", i+1, total) - jobStarted := time.Now() - res, err := a.runner.Run(ctx, jobCfg, kernel.Callbacks{ - Log: func(line string) { - a.appendLog("[" + jobLabel + "] " + line) - }, - Progress: func(stage string, elapsed int, bytes int64) { - a.setStatus(fmt.Sprintf("%s · %s · %ds · %s", jobLabel, stage, elapsed, client.FormatBytes(bytes))) - }, - Partial: func(partial client.PartialImage) { - a.setStatus(fmt.Sprintf("%s · 收到流式预览 #%d", jobLabel, partial.PartialImageIndex)) - }, + } + if concurrency < 1 { + concurrency = 1 + } + effectivePartialImages, streamPreviewDisableReason := a.effectivePartialImagesForRun(cfg, concurrency) + if streamPreviewDisableReason == streamPreviewDisableReasonDesktopConcurrency { + a.appendLog(fmt.Sprintf("高并发(%d)已自动关闭流式预览,优先保证最终图完整。", concurrency)) + } + jobSem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + cancelAll := func() { + once.Do(func() { + cancel() }) - if err != nil { - if errors.Is(err, context.Canceled) { - a.finishCancelled() + } + for i := 0; i < total; i++ { + if ctx.Err() != nil { + break + } + jobSem <- struct{}{} + wg.Add(1) + go func(i int) { + defer wg.Done() + defer func() { <-jobSem }() + if err := ctx.Err(); err != nil { return } - a.finishWithError(err, res.RawPath) - return - } - elapsedSec := time.Since(jobStarted).Seconds() - if err := gioCompat.SaveConfigAndHistory(jobCfg, res, elapsedSec); err != nil { - a.appendLog("兼容历史保存失败: " + err.Error()) - } - compatState, _, _ := gioCompat.LoadState() - compatState = sharedCompat.Normalize(compatState) - selectedItem, hasSelected := historyItemBySavedPath(compatState.History, res.SavedPath) - if !hasSelected { - selectedItem, hasSelected = newestHistoryItem(compatState.History) - } - activeProfileID := "" - if profile, ok := gioCompat.ActiveProfile(compatState); ok { - activeProfileID = profile.ID - } - displayItem := selectedItem - if !hasSelected { - displayItem = sharedCompat.HistoryItem{} - } - if strings.TrimSpace(displayItem.SavedPath) == "" { - displayItem.SavedPath = res.SavedPath - } - if strings.TrimSpace(displayItem.RawPath) == "" { - displayItem.RawPath = res.RawPath - } - if strings.TrimSpace(displayItem.RevisedPrompt) == "" { - displayItem.RevisedPrompt = res.RevisedPrompt - } - if strings.TrimSpace(displayItem.PreviewPath) == "" { - displayItem.PreviewPath = res.PreviewPath - } - if strings.TrimSpace(displayItem.ThumbPath) == "" { - displayItem.ThumbPath = res.ThumbPath - } - nextResult := resultState{ - SavedPath: res.SavedPath, - RawPath: res.RawPath, - RevisedPrompt: res.RevisedPrompt, - SourceEvent: res.SourceEvent, - Item: displayItem, - HasItem: hasSelected || strings.TrimSpace(displayItem.SavedPath) != "", - } - nextResult.Image = a.loadCanvasImmediatePreviewForState(res.SavedPath, nextResult) - a.mu.Lock() - a.status = fmt.Sprintf("完成 %s · %.1fs", jobLabel, time.Since(batchStarted).Seconds()) - a.lastErrorMessage = "" - nextResult.Rev = a.result.Rev + 1 - a.result = nextResult - rev := a.result.Rev - a.setHistoryLocked(compatState.History) - a.setProfilesLocked(compatState.Profiles) - a.activeProfileID = activeProfileID - if hasSelected { - a.selectedHistoryID = selectedItem.ID - } - if total > 1 { + jobCfg := cfg + jobCfg.BatchIndex = i + jobCfg.Prompt = augmentPromptWithStyle(cfg.Prompt, cfg.StyleTag) + jobCfg.PartialImages = effectivePartialImages + if a.batchMode && i < len(batchSources) { + jobCfg.SourcePaths = []string{batchSources[i]} + jobCfg.AutoRetryEnabled = a.batchRetryOnFail + if strings.TrimSpace(a.batchAutoAspect) != "" { + if width, height, err := imageDimensionsFromFile(batchSources[i]); err == nil && width > 0 && height > 0 { + jobCfg.Size = buildBatchAutoAspectSize(width, height, a.batchAutoAspect) + } + } + } + if cfg.Seed != 0 { + jobCfg.Seed = cfg.Seed + int64(i) + } + jobLabel := fmt.Sprintf("%d/%d", i+1, total) + jobStarted := time.Now() + res, err := a.runner.Run(ctx, jobCfg, kernel.Callbacks{ + Log: func(line string) { + a.appendLog("[" + jobLabel + "] " + line) + }, + Progress: func(stage string, elapsed int, bytes int64) { + a.setStatus(fmt.Sprintf("%s · %s · %ds · %s", jobLabel, stage, elapsed, client.FormatBytes(bytes))) + }, + Partial: func(partial client.PartialImage) { + a.setStatus(fmt.Sprintf("%s · 收到流式预览 #%d", jobLabel, partial.PartialImageIndex)) + a.applyPartialPreview(partial) + }, + }) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + if a.batchMode { + a.appendLog(fmt.Sprintf("[%s] 失败并跳过: %v", jobLabel, err)) + completed := int(jobsDone.Add(1)) + a.mu.Lock() + if completed == total { + a.running = false + a.cancel = nil + a.status = fmt.Sprintf("完成 - %.1fs", time.Since(batchStarted).Seconds()) + } + a.mu.Unlock() + a.invalidateNow() + return + } + if firstErr.Load() == nil { + errCopy := err + firstErr.Store(&errCopy) + a.finishWithError(err, res.RawPath) + } + cancelAll() + return + } + elapsedSec := time.Since(jobStarted).Seconds() + if err := gioCompat.SaveConfigAndHistory(jobCfg, res, elapsedSec); err != nil { + a.appendLog("兼容历史保存失败: " + err.Error()) + } + compatState, _, _ := gioCompat.LoadState() + compatState = sharedCompat.Normalize(compatState) + selectedItem, hasSelected := historyItemBySavedPath(compatState.History, res.SavedPath) + if !hasSelected { + selectedItem, hasSelected = newestHistoryItem(compatState.History) + } + activeProfileID := "" + if profile, ok := gioCompat.ActiveProfile(compatState); ok { + activeProfileID = profile.ID + } + displayItem := selectedItem + if !hasSelected { + displayItem = sharedCompat.HistoryItem{} + } + if strings.TrimSpace(displayItem.SavedPath) == "" { + displayItem.SavedPath = res.SavedPath + } + if strings.TrimSpace(displayItem.RawPath) == "" { + displayItem.RawPath = res.RawPath + } + if strings.TrimSpace(displayItem.RevisedPrompt) == "" { + displayItem.RevisedPrompt = res.RevisedPrompt + } + if strings.TrimSpace(displayItem.PreviewPath) == "" { + displayItem.PreviewPath = res.PreviewPath + } + if strings.TrimSpace(displayItem.ThumbPath) == "" { + displayItem.ThumbPath = res.ThumbPath + } + nextResult := resultState{ + SavedPath: res.SavedPath, + RawPath: res.RawPath, + RevisedPrompt: res.RevisedPrompt, + SourceEvent: res.SourceEvent, + Item: displayItem, + HasItem: hasSelected || strings.TrimSpace(displayItem.SavedPath) != "", + } + nextResult.Image = a.loadCanvasImmediatePreviewForState(res.SavedPath, nextResult) + completed := int(jobsDone.Add(1)) + a.mu.Lock() + a.status = fmt.Sprintf("完成 %s · %.1fs", jobLabel, time.Since(batchStarted).Seconds()) + a.lastErrorMessage = "" + nextResult.Rev = a.result.Rev + 1 + a.result = nextResult + rev := a.result.Rev + a.setHistoryLocked(compatState.History) + a.setProfilesLocked(compatState.Profiles) + a.activeProfileID = activeProfileID if hasSelected { + a.selectedHistoryID = selectedItem.ID + } + if total > 1 && hasSelected { a.batchResultIDs = append(a.batchResultIDs, selectedItem.ID) } - } - if !a.savePromptSuppressed && res.SavedPath != "" && total == 1 { - a.savePromptVisible = true - a.savePromptSourcePath = res.SavedPath - a.savePromptPathInput.SetText(res.SavedPath) - } - a.appendLogLocked(fmt.Sprintf("生成完成 %s: %s", jobLabel, res.SavedPath)) - if i == total-1 { - a.running = false - a.cancel = nil - a.status = fmt.Sprintf("完成 - %.1fs", time.Since(batchStarted).Seconds()) - } - a.mu.Unlock() - a.invalidateNow() - a.startAsyncCurrentResultImageLoad(res.SavedPath, displayItem, res.SourceEvent, rev) + if !a.savePromptSuppressed && res.SavedPath != "" && total == 1 { + a.savePromptVisible = true + a.savePromptSourcePath = res.SavedPath + a.savePromptPathInput.SetText(res.SavedPath) + } + a.appendLogLocked(fmt.Sprintf("生成完成 %s: %s", jobLabel, res.SavedPath)) + if a.batchMode && i < len(batchSources) { + if saved, err := a.copyBatchResultIfNeeded(res.SavedPath, batchSources[i]); err == nil && strings.TrimSpace(saved) != "" { + a.appendLogLocked(fmt.Sprintf("批处理已落盘 %s -> %s", filepath.Base(batchSources[i]), filepath.Base(saved))) + } + } else if a.loopEnabled && a.loopAutoSave && strings.TrimSpace(a.loopAutoSaveDirInput.Text()) != "" { + if saved, err := copyImageFile(res.SavedPath, a.loopAutoSaveDirInput.Text()); err == nil && strings.TrimSpace(saved) != "" { + a.appendLogLocked(fmt.Sprintf("循环结果已自动另存为 -> %s", filepath.Base(saved))) + } + } + if completed == total { + a.running = false + a.cancel = nil + a.status = fmt.Sprintf("完成 - %.1fs", time.Since(batchStarted).Seconds()) + } + a.mu.Unlock() + if completed == total { + a.maybePlayCompletionSound(completed, total) + a.maybeSendCompletionNotification(displayItem, completed, total) + } + a.invalidateNow() + a.startAsyncCurrentResultImageLoad(res.SavedPath, displayItem, res.SourceEvent, rev) + }(i) + } + wg.Wait() + if ctx.Err() != nil && firstErr.Load() == nil { + a.finishCancelled() } }() } @@ -185,32 +291,173 @@ func (a *App) currentConfig() kernel.Config { sourcePaths = []string{current} } } + var fallback *kernel.FallbackProfileConfig + fallbackID := strings.TrimSpace(a.fallbackProfileID) + if fallbackID != "" { + state, _, err := gioCompat.LoadState() + if err == nil { + state = sharedCompat.Normalize(state) + fallback = resolveFallbackProfileConfig(state, fallbackID, gioCompat.ReadAPIKey) + } + } return kernel.Config{ - APIKey: a.apiKeyInput.Text(), - BaseURL: a.baseURLInput.Text(), - TextModelID: a.textModelInput.Text(), - ImageModelID: a.imageModelInput.Text(), - Prompt: a.promptInput.Text(), - Mode: client.Mode(a.mode), - APIMode: client.APIMode(a.api), - RequestPolicy: client.RequestPolicy(a.policy), - ImagesNewAPICompat: a.imagesNewAPICompat, - Size: a.size, - Quality: a.quality, - OutputFormat: a.format, - Background: a.background, - OutputCompression: outputCompression, - InputFidelity: a.inputFidelity, - ImageStyle: a.imageStyle, - Moderation: a.moderation, - UserIdentifier: a.userIdentifierInput.Text(), - ProxyMode: a.proxy, - ProxyURL: a.proxyURLInput.Text(), - SourcePaths: sourcePaths, - OutputDir: a.outputDirInput.Text(), - Seed: seed, - NegativePrompt: a.negativePromptInput.Text(), - PartialImages: partial, - StyleTag: a.styleTag, + APIKey: a.apiKeyInput.Text(), + BaseURL: a.baseURLInput.Text(), + TextModelID: a.textModelInput.Text(), + ImageModelID: a.imageModelInput.Text(), + Prompt: a.promptInput.Text(), + Mode: client.Mode(a.mode), + APIMode: client.APIMode(a.api), + RequestPolicy: client.RequestPolicy(a.policy), + ResponsesTransport: client.ResponsesTransport(a.responsesTransport), + ImagesNewAPICompat: a.imagesNewAPICompat, + Size: a.size, + Quality: a.quality, + OutputFormat: a.format, + Background: a.background, + OutputCompression: outputCompression, + InputFidelity: a.inputFidelity, + ImageStyle: a.imageStyle, + Moderation: a.moderation, + UserIdentifier: a.userIdentifierInput.Text(), + ProxyMode: a.proxy, + ProxyURL: a.proxyURLInput.Text(), + ReasoningEffort: a.reasoningEffort, + ProtectStreamPreview: a.protectStreamPreview, + AutoRetryEnabled: a.autoRetryEnabled, + AutoRetryCount: a.autoRetryCount, + CompletionSound: a.completionSound, + SourcePaths: sourcePaths, + OutputDir: a.outputDirInput.Text(), + Seed: seed, + NegativePrompt: a.negativePromptInput.Text(), + PartialImages: partial, + StyleTag: a.styleTag, + FallbackProfileID: fallbackID, + FallbackProfile: fallback, + } +} + +func resolveFallbackProfileConfig(state sharedCompat.State, fallbackProfileID string, readAPIKey func(string) (string, error)) *kernel.FallbackProfileConfig { + fallbackProfileID = strings.TrimSpace(fallbackProfileID) + if fallbackProfileID == "" { + return nil + } + profile, ok := profileByID(state.Profiles, fallbackProfileID) + if !ok { + return nil + } + apiKey, _ := readAPIKey(profile.ID) + if strings.TrimSpace(apiKey) == "" || strings.TrimSpace(profile.BaseURL) == "" { + return nil + } + return &kernel.FallbackProfileConfig{ + APIKey: strings.TrimSpace(apiKey), + BaseURL: strings.TrimSpace(profile.BaseURL), + TextModelID: strings.TrimSpace(profile.TextModelID), + ImageModelID: strings.TrimSpace(profile.ImageModelID), + APIMode: client.APIMode(normalizeProfileAPIMode(profile.APIMode)), + ResponsesTransport: client.ResponsesTransport(normalizeProfileResponsesTransport(profile.ResponsesTransport)), + RequestPolicy: client.RequestPolicy(normalizeProfilePolicy(profile.RequestPolicy)), + ImagesNewAPICompat: profile.ImagesNewAPICompat, + ReasoningEffort: normalizeReasoningEffort(profile.ReasoningEffort), + } +} + +func (a *App) batchSourcePaths() []string { + paths, _ := a.batchSourcePathsForRun() + return paths +} + +func (a *App) batchSourcePathsForRun() ([]string, error) { + manual := a.sourcePaths() + if len(manual) > 0 { + return manual, nil + } + dir := strings.TrimSpace(a.batchInputDirInput.Text()) + if dir == "" { + return manual, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + switch ext { + case ".png", ".jpg", ".jpeg", ".webp": + out = append(out, filepath.Join(dir, entry.Name())) + } + } + return out, nil +} + +func (a *App) copyBatchResultIfNeeded(savedPath string, sourcePath string) (string, error) { + savedPath = strings.TrimSpace(savedPath) + sourcePath = strings.TrimSpace(sourcePath) + if savedPath == "" || sourcePath == "" { + return "", nil + } + targetDir := strings.TrimSpace(a.batchOutputDirInput.Text()) + if targetDir == "" { + targetDir = filepath.Dir(sourcePath) + } + if targetDir == "" { + return "", nil + } + base := filepath.Base(sourcePath) + targetName := "processed-" + base + dst := filepath.Join(targetDir, targetName) + if _, err := os.Stat(dst); err == nil { + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + for idx := 2; ; idx++ { + candidate := filepath.Join(targetDir, fmt.Sprintf("processed-%s-%d%s", stem, idx, ext)) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + dst = candidate + break + } + } + } + return copyImageFile(savedPath, dst) +} + +func imageDimensionsFromFile(path string) (int, int, error) { + file, err := os.Open(path) + if err != nil { + return 0, 0, err + } + defer file.Close() + cfg, _, err := image.DecodeConfig(file) + if err != nil { + return 0, 0, err + } + return cfg.Width, cfg.Height, nil +} + +func buildBatchAutoAspectSize(width int, height int, resolution string) string { + if width <= 0 || height <= 0 { + return "1024x1024" + } + longSide := 1536 + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "256": + longSide = 256 + case "512": + longSide = 512 + case "2k": + longSide = 2048 + case "4k": + longSide = 3840 + } + if width >= height { + shortSide := max(64, int(float64(longSide)*float64(height)/float64(width)+0.5)) + return fmt.Sprintf("%dx%d", longSide, shortSide) } + shortSide := max(64, int(float64(longSide)*float64(width)/float64(height)+0.5)) + return fmt.Sprintf("%dx%d", shortSide, longSide) } diff --git a/gio-client/internal/ui/runtime_cleanup.go b/gio-client/internal/ui/runtime_cleanup.go new file mode 100644 index 000000000..24d617e60 --- /dev/null +++ b/gio-client/internal/ui/runtime_cleanup.go @@ -0,0 +1,99 @@ +package ui + +import ( + "errors" + "os" + "path/filepath" + "strings" + + gioCompat "image-studio/gio-client/internal/compat" + "image-studio/gio-client/internal/kernel" + sharedCompat "image-studio/shared/compat" +) + +func normalizeCleanupRoots(roots []string) []string { + out := make([]string, 0, len(roots)) + seen := map[string]struct{}{} + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + abs, err := filepath.Abs(root) + if err == nil { + root = abs + } + root = filepath.Clean(root) + if _, ok := seen[root]; ok { + continue + } + seen[root] = struct{}{} + out = append(out, root) + } + return out +} + +func managedOutputRootsForCleanup(state sharedCompat.State, currentOutputDir string) []string { + roots := []string{kernel.DefaultOutputDir()} + if outputDir := strings.TrimSpace(state.Settings.OutputDir); outputDir != "" { + roots = append(roots, outputDir) + } + if outputDir := strings.TrimSpace(currentOutputDir); outputDir != "" { + roots = append(roots, outputDir) + } + roots = append(roots, state.Settings.TrustedOutputRoots...) + return normalizeCleanupRoots(roots) +} + +func managedRuntimeCleanupDirs(state sharedCompat.State, currentOutputDir string, stableDataRoot string, keepLogs bool, cleanupPreviewCacheOnExit bool) []string { + roots := managedOutputRootsForCleanup(state, currentOutputDir) + dirs := make([]string, 0, len(roots)*3+1) + if cleanupPreviewCacheOnExit { + for _, root := range roots { + dirs = append(dirs, filepath.Join(root, "thumbs"), filepath.Join(root, "previews")) + } + if strings.TrimSpace(stableDataRoot) != "" { + dirs = append(dirs, filepath.Join(stableDataRoot, "source-previews")) + } + } + if !keepLogs { + for _, root := range roots { + dirs = append(dirs, filepath.Join(root, "log")) + } + } + return normalizeCleanupRoots(dirs) +} + +func removeCleanupDirs(dirs []string) error { + var firstErr error + for _, dir := range normalizeCleanupRoots(dirs) { + if strings.TrimSpace(dir) == "" { + continue + } + if err := os.RemoveAll(dir); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (a *App) cleanupRuntimeArtifactsOnExit() { + state, _, err := gioCompat.LoadState() + if err != nil { + return + } + state = sharedCompat.Normalize(state) + a.mu.Lock() + keepLogs := a.keepLogs + cleanupPreviewCacheOnExit := a.cleanupPreviewCacheOnExit + currentOutputDir := strings.TrimSpace(a.outputDirInput.Text()) + a.mu.Unlock() + stableDataRoot, err := gioCompat.StableDataRoot() + if err != nil { + stableDataRoot = "" + } + dirs := managedRuntimeCleanupDirs(state, currentOutputDir, stableDataRoot, keepLogs, cleanupPreviewCacheOnExit) + if err := removeCleanupDirs(dirs); err != nil { + a.appendLog("退出清理运行时缓存失败: " + err.Error()) + } +} diff --git a/gio-client/internal/ui/runtime_cleanup_test.go b/gio-client/internal/ui/runtime_cleanup_test.go new file mode 100644 index 000000000..7725f74f1 --- /dev/null +++ b/gio-client/internal/ui/runtime_cleanup_test.go @@ -0,0 +1,133 @@ +package ui + +import ( + "os" + "path/filepath" + "testing" + + "image-studio/gio-client/internal/kernel" + sharedCompat "image-studio/shared/compat" +) + +func TestManagedRuntimeCleanupDirsMatchesPreviewAndLogSemantics(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + state := sharedCompat.State{ + Settings: sharedCompat.Settings{ + OutputDir: filepath.Join(t.TempDir(), "state-output"), + TrustedOutputRoots: []string{filepath.Join(t.TempDir(), "trusted-a"), filepath.Join(t.TempDir(), "trusted-b")}, + }, + } + currentOutputDir := filepath.Join(t.TempDir(), "current-output") + stableDataRoot := filepath.Join(t.TempDir(), "stable-data") + + got := managedRuntimeCleanupDirs(state, currentOutputDir, stableDataRoot, false, true) + roots := normalizeCleanupRoots([]string{ + kernel.DefaultOutputDir(), + state.Settings.OutputDir, + currentOutputDir, + state.Settings.TrustedOutputRoots[0], + state.Settings.TrustedOutputRoots[1], + }) + want := make([]string, 0, len(roots)*3+1) + for _, root := range roots { + want = append(want, + filepath.Join(root, "thumbs"), + filepath.Join(root, "previews"), + filepath.Join(root, "log"), + ) + } + want = append(want, filepath.Join(stableDataRoot, "source-previews")) + want = normalizeCleanupRoots(want) + + if len(got) != len(want) { + t.Fatalf("managedRuntimeCleanupDirs len=%d want %d: %v", len(got), len(want), got) + } + for _, path := range want { + found := false + for _, gotPath := range got { + if gotPath == path { + found = true + break + } + } + if !found { + t.Fatalf("missing cleanup path %q in %v", path, got) + } + } + for _, forbidden := range []string{ + kernel.DefaultOutputDir(), + state.Settings.OutputDir, + currentOutputDir, + stableDataRoot, + filepath.Join(kernel.DefaultOutputDir(), "images"), + filepath.Join(state.Settings.OutputDir, "images"), + filepath.Join(currentOutputDir, "images"), + } { + for _, gotPath := range got { + if gotPath == filepath.Clean(forbidden) { + t.Fatalf("unexpected primary data path scheduled for cleanup: %q", gotPath) + } + } + } +} + +func TestManagedRuntimeCleanupDirsKeepsPreviewCachesWhenDisabled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + state := sharedCompat.State{ + Settings: sharedCompat.Settings{ + OutputDir: filepath.Join(t.TempDir(), "state-output"), + }, + } + got := managedRuntimeCleanupDirs(state, "", filepath.Join(t.TempDir(), "stable-data"), false, false) + + for _, path := range got { + base := filepath.Base(path) + if base == "thumbs" || base == "previews" || base == "source-previews" { + t.Fatalf("unexpected preview cache cleanup path when disabled: %q", path) + } + } + if len(got) == 0 { + t.Fatal("expected log cleanup paths when keepLogs is disabled") + } + for _, path := range got { + if filepath.Base(path) != "log" { + t.Fatalf("expected only log cleanup paths when preview cleanup is disabled, got %q", path) + } + } +} + +func TestRemoveCleanupDirsOnlyRemovesRequestedCaches(t *testing.T) { + root := t.TempDir() + thumbsDir := filepath.Join(root, "thumbs") + previewsDir := filepath.Join(root, "previews") + imagesDir := filepath.Join(root, "images") + for _, dir := range []string{thumbsDir, previewsDir, imagesDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(thumbsDir, "thumb.avif"), []byte("thumb"), 0o600); err != nil { + t.Fatalf("write thumb: %v", err) + } + if err := os.WriteFile(filepath.Join(previewsDir, "preview.avif"), []byte("preview"), 0o600); err != nil { + t.Fatalf("write preview: %v", err) + } + keepImage := filepath.Join(imagesDir, "result.png") + if err := os.WriteFile(keepImage, []byte("image"), 0o600); err != nil { + t.Fatalf("write image: %v", err) + } + + if err := removeCleanupDirs([]string{thumbsDir, previewsDir}); err != nil { + t.Fatalf("removeCleanupDirs: %v", err) + } + for _, dir := range []string{thumbsDir, previewsDir} { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, got %v", dir, err) + } + } + if _, err := os.Stat(keepImage); err != nil { + t.Fatalf("expected %s to remain, got %v", keepImage, err) + } +} diff --git a/gio-client/internal/ui/state.go b/gio-client/internal/ui/state.go index db82f0489..5d0945095 100644 --- a/gio-client/internal/ui/state.go +++ b/gio-client/internal/ui/state.go @@ -6,6 +6,7 @@ import ( "time" "gioui.org/widget" + "github.com/yuanhua/image-gptcodex/pkg/client" gioCompat "image-studio/gio-client/internal/compat" sharedCompat "image-studio/shared/compat" ) @@ -17,6 +18,9 @@ func (a *App) saveCurrentConfig() { if err := gioCompat.SaveConfig(a.currentConfig()); err != nil { a.appendLog("兼容配置保存失败: " + err.Error()) } + if err := a.persistGeneralSettings(); err != nil { + a.appendLog("通用设置保存失败: " + err.Error()) + } if err := a.saveActiveProfileMetadata(); err != nil { a.appendLog("配置元数据保存失败: " + err.Error()) } @@ -268,6 +272,7 @@ func (a *App) readSnapshot() snapshot { batchResults := a.batchResultsSnapshotLocked(history) profiles := a.profiles promptHistory := a.promptHistory + promptTemplates := a.promptTemplates presets := a.presets todayCount := a.todayHistoryCountLocked() snap := snapshot{ @@ -288,6 +293,7 @@ func (a *App) readSnapshot() snapshot { SettingsSelectedProfileID: a.settingsSelectedProfileID, SelectedHistoryID: a.selectedHistoryID, PromptHistory: promptHistory, + PromptTemplates: promptTemplates, Presets: presets, OptimizingPrompt: a.optimizingPrompt, TestingUpstream: a.testingUpstream, @@ -308,6 +314,14 @@ func (a *App) readSnapshot() snapshot { CompareSplit: a.compareSplitSlider.Value, Result: a.result, SavePromptVisible: a.savePromptVisible, + PromptImportVisible: a.promptImportOpen, + PromptImportLoading: a.promptImportLoading, + PromptImportToken: a.promptImportToken, + PromptImportPayload: a.promptImportPayload, + PromptImportResolvedSize: a.promptImportResolvedSize, + PromptImportRegisterOpen: a.promptImportRegisterOpen, + PromptImportRegisterBusy: a.promptImportRegisterBusy, + PromptImportRegisterNote: a.promptImportRegisterNote, } a.snapshotCache = snap a.snapshotReady = true @@ -361,6 +375,16 @@ func (a *App) setPromptHistoryLocked(items []string) { a.promptButtons = map[string]*widget.Clickable{} } +func (a *App) setPromptTemplatesLocked(items []sharedCompat.PromptTemplate) { + a.promptTemplates = append([]sharedCompat.PromptTemplate(nil), items...) + a.promptButtons = map[string]*widget.Clickable{} +} + +func (a *App) setPresetsLocked(items []sharedCompat.Preset) { + a.presets = append([]sharedCompat.Preset(nil), items...) + a.promptButtons = map[string]*widget.Clickable{} +} + func (a *App) openGeneralSettingsModal() { a.mu.Lock() a.generalSettingsOpen = true @@ -390,12 +414,24 @@ func (a *App) persistGeneralSettings() error { if state.Settings.ProxyMode == "" { state.Settings.ProxyMode = "system" } + protectStreamPreview := a.protectStreamPreview + state.Settings.ProtectStreamPreview = &protectStreamPreview + autoRetryEnabled := a.autoRetryEnabled + state.Settings.AutoRetryEnabled = &autoRetryEnabled + autoRetryCount := normalizeAutoRetryCount(a.autoRetryCount) + state.Settings.AutoRetryCount = &autoRetryCount + completionSound := a.completionSound + state.Settings.CompletionSound = &completionSound + completionNotification := a.completionNotification + state.Settings.CompletionNotification = &completionNotification + state.Settings.CleanupPreviewCacheOnExit = a.cleanupPreviewCacheOnExit state.Settings.KernelRuntimeMode = normalizeKernelRuntimeMode(a.kernelRuntimeMode) state.Settings.FontScale = normalizeFontScale(a.fontScale) state.Settings.ReducedEffects = a.reducedEffects state.Settings.ProxyURL = strings.TrimSpace(a.proxyURLInput.Text()) state.Settings.OutputDir = strings.TrimSpace(a.outputDirInput.Text()) state.Settings.KeepLogs = a.keepLogs + state.Settings.UserIdentifier = strings.TrimSpace(a.userIdentifierInput.Text()) state.UpdatedAt = time.Now().UnixMilli() if err := gioCompat.SaveState(state); err != nil { return err @@ -417,6 +453,37 @@ func (a *App) dismissFailureState() { a.invalidateNow() } +func (a *App) applyPartialPreview(partial client.PartialImage) { + imageB64 := strings.TrimSpace(partial.ImageB64) + if imageB64 == "" { + return + } + img, err := decodeImageB64(imageB64) + if err != nil { + a.appendLog("解析流式预览失败: " + err.Error()) + return + } + preview := a.prepareCanvasDisplayImage(img) + a.mu.Lock() + if !a.running { + a.mu.Unlock() + return + } + a.result = resultState{ + Image: preview, + RevisedPrompt: strings.TrimSpace(partial.RevisedPrompt), + SourceEvent: "partial", + Rev: a.result.Rev + 1, + } + a.compare = resultState{Rev: a.compare.Rev + 1} + a.compareSplitSlider.Value = 0.5 + a.selectedHistoryID = "" + a.imageOpRev = 0 + a.compareImageOpRev = 0 + a.mu.Unlock() + a.invalidateSoon(33 * time.Millisecond) +} + func (a *App) isRunning() bool { a.mu.Lock() defer a.mu.Unlock() diff --git a/gio-client/internal/ui/stream_preview_policy.go b/gio-client/internal/ui/stream_preview_policy.go new file mode 100644 index 000000000..359aa2d14 --- /dev/null +++ b/gio-client/internal/ui/stream_preview_policy.go @@ -0,0 +1,37 @@ +package ui + +import "image-studio/gio-client/internal/kernel" + +const desktopStreamPreviewConcurrencyLimit = 8 + +type streamPreviewDisableReason string + +const ( + streamPreviewDisableReasonNone streamPreviewDisableReason = "" + streamPreviewDisableReasonDesktopConcurrency streamPreviewDisableReason = "desktop_concurrency" +) + +func getStreamPreviewDisableReason(enabled bool, requestedConcurrency int) streamPreviewDisableReason { + if !enabled { + return streamPreviewDisableReasonNone + } + if requestedConcurrency >= desktopStreamPreviewConcurrencyLimit { + return streamPreviewDisableReasonDesktopConcurrency + } + return streamPreviewDisableReasonNone +} + +func (a *App) effectivePartialImagesForRun(cfg kernel.Config, requestedConcurrency int) (int, streamPreviewDisableReason) { + partial := cfg.PartialImages + if partial <= 0 { + return 0, streamPreviewDisableReasonNone + } + if a.loopEnabled && !a.loopLivePreview { + return 0, streamPreviewDisableReasonNone + } + reason := getStreamPreviewDisableReason(cfg.ProtectStreamPreview, requestedConcurrency) + if reason != streamPreviewDisableReasonNone { + return 0, reason + } + return partial, streamPreviewDisableReasonNone +} diff --git a/gio-client/internal/ui/ui_state.go b/gio-client/internal/ui/ui_state.go index d34c80f90..e28c1701f 100644 --- a/gio-client/internal/ui/ui_state.go +++ b/gio-client/internal/ui/ui_state.go @@ -1298,6 +1298,9 @@ func (a *App) switchActiveProfile(profileID string) { a.setProfilesLocked(state.Profiles) a.activeProfileID = profileID a.settingsSelectedProfileID = profileID + if profile, ok := profileByID(state.Profiles, profileID); ok { + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) + } a.status = "已切换上游: " + activeProfileName(state.Profiles, profileID) a.appendLogLocked("切换上游配置: " + activeProfileName(state.Profiles, profileID)) a.mu.Unlock() @@ -1361,6 +1364,26 @@ func normalizeProfilePolicy(policy string) string { return string(client.RequestPolicyOpenAI) } +func normalizeProfileResponsesTransport(value string) string { + if strings.TrimSpace(value) == string(client.ResponsesTransportWebSocket) { + return string(client.ResponsesTransportWebSocket) + } + return string(client.ResponsesTransportSSE) +} + +func normalizeReasoningEffort(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "low": + return "low" + case "medium": + return "medium" + case "high": + return "high" + default: + return client.DefaultReasoningEffort + } +} + func normalizeSettingsSelectedProfileID(state sharedCompat.State, profileID string) string { profileID = strings.TrimSpace(profileID) if profileID != "" { @@ -1388,6 +1411,9 @@ func (a *App) applySettingsProfileDraft(state sharedCompat.State, profile shared a.profileNameInput.SetText(strings.TrimSpace(profile.Name)) a.api = normalizeProfileAPIMode(profile.APIMode) a.policy = normalizeProfilePolicy(profile.RequestPolicy) + a.responsesTransport = normalizeProfileResponsesTransport(profile.ResponsesTransport) + a.reasoningEffort = normalizeReasoningEffort(profile.ReasoningEffort) + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) a.imagesNewAPICompat = profile.ImagesNewAPICompat a.baseURLInput.SetText(strings.TrimSpace(profile.BaseURL)) a.textModelInput.SetText(strings.TrimSpace(profile.TextModelID)) @@ -1436,6 +1462,16 @@ func (a *App) applySettingsProfileDraft(state sharedCompat.State, profile shared } else { a.partialImagesInput.SetText(strconv.Itoa(kernel.DefaultConfig().PartialImages)) } + a.completionSound = normaliseCompletionSoundSettings(state.Settings.CompletionSound) + a.completionNotification = normaliseCompletionNotificationSettings(state.Settings.CompletionNotification) + a.completionNotificationPermission = readSystemNotificationPermission() + a.keepLogs = state.Settings.KeepLogs + a.cleanupPreviewCacheOnExit = state.Settings.CleanupPreviewCacheOnExit + if state.Settings.ProtectStreamPreview != nil { + a.protectStreamPreview = *state.Settings.ProtectStreamPreview + } else { + a.protectStreamPreview = kernel.DefaultConfig().ProtectStreamPreview + } a.apiKeyVisible = false } @@ -1454,6 +1490,9 @@ func (a *App) loadSettingsProfileDraft(profileID string) error { a.textModelInput.SetText(client.TextModel) a.imageModelInput.SetText(client.ImageModel) a.concurrencyLimitInput.SetText("") + a.responsesTransport = string(client.ResponsesTransportSSE) + a.reasoningEffort = client.DefaultReasoningEffort + a.fallbackProfileID = "" a.proxy = strings.TrimSpace(state.Settings.ProxyMode) if a.proxy == "" { a.proxy = client.ProxyModeSystem @@ -1495,6 +1534,16 @@ func (a *App) loadSettingsProfileDraft(profileID string) error { } else { a.partialImagesInput.SetText(strconv.Itoa(kernel.DefaultConfig().PartialImages)) } + a.completionSound = normaliseCompletionSoundSettings(state.Settings.CompletionSound) + a.completionNotification = normaliseCompletionNotificationSettings(state.Settings.CompletionNotification) + a.completionNotificationPermission = readSystemNotificationPermission() + a.keepLogs = state.Settings.KeepLogs + a.cleanupPreviewCacheOnExit = state.Settings.CleanupPreviewCacheOnExit + if state.Settings.ProtectStreamPreview != nil { + a.protectStreamPreview = *state.Settings.ProtectStreamPreview + } else { + a.protectStreamPreview = kernel.DefaultConfig().ProtectStreamPreview + } a.imagesNewAPICompat = false a.api = string(client.APIModeResponses) a.policy = string(client.RequestPolicyOpenAI) @@ -1521,6 +1570,11 @@ func (a *App) restoreActiveRuntimeConfig(logErrors bool) error { cfg := gioCompat.ConfigFromState(kernel.DefaultConfig(), state) a.applyRuntimeConfig(cfg) a.profileNameInput.SetText(activeProfileName(state.Profiles, state.ActiveProfile)) + if profile, ok := profileByID(state.Profiles, state.ActiveProfile); ok { + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) + } else { + a.fallbackProfileID = "" + } if limit := activeProfileConcurrencyLimit(state.Profiles, state.ActiveProfile); limit > 0 { a.concurrencyLimitInput.SetText(strconv.Itoa(limit)) } else { @@ -1530,6 +1584,10 @@ func (a *App) restoreActiveRuntimeConfig(logErrors bool) error { a.setProfilesLocked(state.Profiles) a.activeProfileID = state.ActiveProfile a.settingsSelectedProfileID = state.ActiveProfile + a.completionNotification = normaliseCompletionNotificationSettings(state.Settings.CompletionNotification) + a.completionNotificationPermission = readSystemNotificationPermission() + a.keepLogs = state.Settings.KeepLogs + a.cleanupPreviewCacheOnExit = state.Settings.CleanupPreviewCacheOnExit a.mu.Unlock() a.apiKeyVisible = false return nil @@ -1583,6 +1641,9 @@ func (a *App) saveSettingsSelection() error { state.Profiles[i].Name = name state.Profiles[i].APIMode = normalizeProfileAPIMode(a.api) state.Profiles[i].RequestPolicy = normalizeProfilePolicy(a.policy) + state.Profiles[i].ResponsesTransport = normalizeProfileResponsesTransport(a.responsesTransport) + state.Profiles[i].ReasoningEffort = normalizeReasoningEffort(a.reasoningEffort) + state.Profiles[i].FallbackProfileID = strings.TrimSpace(a.fallbackProfileID) state.Profiles[i].ImagesNewAPICompat = a.imagesNewAPICompat state.Profiles[i].BaseURL = strings.TrimSpace(a.baseURLInput.Text()) state.Profiles[i].TextModelID = strings.TrimSpace(a.textModelInput.Text()) @@ -1619,6 +1680,14 @@ func (a *App) saveSettingsSelection() error { } } state.Settings.PartialImages = &partialImages + completionSound := a.completionSound + state.Settings.CompletionSound = &completionSound + completionNotification := a.completionNotification + state.Settings.CompletionNotification = &completionNotification + protectStreamPreview := a.protectStreamPreview + state.Settings.ProtectStreamPreview = &protectStreamPreview + state.Settings.KeepLogs = a.keepLogs + state.Settings.CleanupPreviewCacheOnExit = a.cleanupPreviewCacheOnExit state.UpdatedAt = now if err := gioCompat.SaveState(state); err != nil { return err @@ -1683,11 +1752,12 @@ func (a *App) createSettingsProfile(apiMode string) error { ID: profileID, Name: nextProfileName(state.Profiles), APIMode: normalizeProfileAPIMode(apiMode), + ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), ImagesNewAPICompat: false, TextModelID: client.TextModel, ImageModelID: client.ImageModel, - ReasoningEffort: "xhigh", + ReasoningEffort: client.DefaultReasoningEffort, CreatedAt: now, LastUsedAt: now, } @@ -1912,11 +1982,12 @@ func (a *App) createBlankProfileWithMode(apiMode string) { ID: profileID, Name: nextProfileName(state.Profiles), APIMode: apiMode, + ResponsesTransport: string(client.ResponsesTransportSSE), RequestPolicy: string(client.RequestPolicyOpenAI), ImagesNewAPICompat: false, TextModelID: client.TextModel, ImageModelID: client.ImageModel, - ReasoningEffort: "xhigh", + ReasoningEffort: client.DefaultReasoningEffort, CreatedAt: now, LastUsedAt: now, } @@ -1932,6 +2003,7 @@ func (a *App) createBlankProfileWithMode(apiMode string) { a.mu.Lock() a.setProfilesLocked(state.Profiles) a.activeProfileID = profileID + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) a.status = "已创建配置: " + profile.Name a.appendLogLocked("已创建配置: " + profile.Name) a.mu.Unlock() @@ -1974,6 +2046,7 @@ func (a *App) duplicateActiveProfile() { a.mu.Lock() a.setProfilesLocked(state.Profiles) a.activeProfileID = profileID + a.fallbackProfileID = strings.TrimSpace(clone.FallbackProfileID) a.status = "已复制配置: " + clone.Name a.appendLogLocked("已复制配置: " + clone.Name) a.mu.Unlock() @@ -2025,6 +2098,11 @@ func (a *App) deleteActiveProfile() { a.mu.Lock() a.setProfilesLocked(state.Profiles) a.activeProfileID = state.ActiveProfile + if profile, ok := profileByID(state.Profiles, state.ActiveProfile); ok { + a.fallbackProfileID = strings.TrimSpace(profile.FallbackProfileID) + } else { + a.fallbackProfileID = "" + } a.status = "已删除配置: " + current.Name a.appendLogLocked("已删除配置: " + current.Name) a.mu.Unlock() @@ -2718,11 +2796,49 @@ func (a *App) applyPreset(preset sharedCompat.Preset) { a.format = preset.OutputFormat } a.negativePromptInput.SetText(strings.TrimSpace(preset.NegativePrompt)) + if background := strings.TrimSpace(preset.Background); background != "" { + a.background = background + } + if preset.OutputCompression != nil { + a.outputCompressionInput.SetText(strconv.Itoa(*preset.OutputCompression)) + } + if fidelity := strings.TrimSpace(preset.InputFidelity); fidelity != "" { + a.inputFidelity = fidelity + } + if imageStyle := strings.TrimSpace(preset.ImageStyle); imageStyle != "" { + a.imageStyle = imageStyle + } + if moderation := strings.TrimSpace(preset.Moderation); moderation != "" { + a.moderation = moderation + } + a.styleTag = strings.TrimSpace(preset.StyleTag) + if runtimeMode := strings.TrimSpace(preset.KernelRuntimeMode); runtimeMode != "" { + a.kernelRuntimeMode = normalizeKernelRuntimeMode(runtimeMode) + } a.batchCount = normalizeBatchCount(preset.BatchCount) a.promptHelperOpen = false + a.appendLog("已应用预设: " + strings.TrimSpace(preset.Name)) a.invalidateNow() } +func (a *App) applyPresetByID(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + a.mu.Lock() + presets := append([]sharedCompat.Preset(nil), a.presets...) + a.mu.Unlock() + for _, preset := range presets { + if strings.TrimSpace(preset.ID) != id { + continue + } + a.applyPreset(preset) + return true + } + return false +} + func (a *App) rememberPrompt(text string) { text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ") if text == "" { @@ -2752,7 +2868,7 @@ func (a *App) rememberPrompt(text string) { } a.mu.Lock() a.setPromptHistoryLocked(next) - a.presets = append([]sharedCompat.Preset(nil), state.Settings.Presets...) + a.setPresetsLocked(state.Settings.Presets) a.mu.Unlock() a.invalidateNow() } diff --git a/gio-client/internal/ui/ui_system.go b/gio-client/internal/ui/ui_system.go index dcaaa5515..c5685b986 100644 --- a/gio-client/internal/ui/ui_system.go +++ b/gio-client/internal/ui/ui_system.go @@ -157,6 +157,69 @@ func chooseJSONFile() (string, error) { } } +func chooseAudioFile() (string, error) { + switch runtime.GOOS { + case "windows": + cmd := exec.Command( + "powershell", + "-NoProfile", + "-Command", + `Add-Type -AssemblyName System.Windows.Forms; `+ + `$dlg = New-Object System.Windows.Forms.OpenFileDialog; `+ + `$dlg.Multiselect = $false; `+ + `$dlg.Filter = "Audio|*.mp3;*.wav;*.ogg;*.m4a;*.aac;*.webm"; `+ + `if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::UTF8; $dlg.FileName }`, + ) + out, err := cmd.Output() + if err != nil { + return "", err + } + paths := parseDialogPaths(string(out)) + if len(paths) == 0 { + return "", nil + } + return paths[0], nil + case "darwin": + out, err := exec.Command( + "osascript", + "-e", + `POSIX path of (choose file)`, + ).Output() + if err != nil { + return "", err + } + paths := parseDialogPaths(string(out)) + if len(paths) == 0 { + return "", nil + } + return paths[0], nil + default: + if path, err := exec.LookPath("zenity"); err == nil { + out, err := exec.Command(path, "--file-selection", "--file-filter=Audio | *.mp3 *.wav *.ogg *.m4a *.aac *.webm").Output() + if err != nil { + return "", err + } + paths := parseDialogPaths(string(out)) + if len(paths) == 0 { + return "", nil + } + return paths[0], nil + } + if path, err := exec.LookPath("kdialog"); err == nil { + out, err := exec.Command(path, "--getopenfilename", ".", "Audio (*.mp3 *.wav *.ogg *.m4a *.aac *.webm)").Output() + if err != nil { + return "", err + } + paths := parseDialogPaths(string(out)) + if len(paths) == 0 { + return "", nil + } + return paths[0], nil + } + return "", fmt.Errorf("当前系统没有可用的音频文件选择器") + } +} + func chooseSaveJSONFile(suggestedName string) (string, error) { suggestedName = strings.TrimSpace(suggestedName) if suggestedName == "" { diff --git a/gio-client/internal/ui/ui_workspaces.go b/gio-client/internal/ui/ui_workspaces.go index 629c9c273..cce632230 100644 --- a/gio-client/internal/ui/ui_workspaces.go +++ b/gio-client/internal/ui/ui_workspaces.go @@ -87,6 +87,17 @@ func (a *App) buildWorkspaceSnapshot() workspaceState { StyleTag: a.styleTag, SeedText: a.seedInput.Text(), BatchCount: a.batchCount, + LoopEnabled: a.loopEnabled, + LoopTotalCount: normalizeLoopGenerationCount(a.loopTotalCount), + LoopConcurrency: normalizeLoopGenerationConcurrency(a.loopConcurrency), + LoopAutoSave: a.loopAutoSave, + LoopAutoSaveDir: strings.TrimSpace(a.loopAutoSaveDirInput.Text()), + LoopLivePreview: a.loopLivePreview, + BatchMode: a.batchMode, + BatchInputDir: strings.TrimSpace(a.batchInputDirInput.Text()), + BatchOutputDir: strings.TrimSpace(a.batchOutputDirInput.Text()), + BatchRetryOnFail: a.batchRetryOnFail, + BatchAutoAspect: strings.TrimSpace(a.batchAutoAspect), SourcePathsText: a.sourcePathsInput.Text(), ResultSavedPath: a.result.SavedPath, ResultRawPath: a.result.RawPath, @@ -140,6 +151,17 @@ func (a *App) applyWorkspace(ws workspaceState) { a.styleTag = ws.StyleTag a.seedInput.SetText(ws.SeedText) a.batchCount = normalizeBatchCount(ws.BatchCount) + a.loopEnabled = ws.LoopEnabled + a.loopTotalCount = normalizeLoopGenerationCount(ws.LoopTotalCount) + a.loopConcurrency = normalizeLoopGenerationConcurrency(ws.LoopConcurrency) + a.loopAutoSave = ws.LoopAutoSave + a.loopAutoSaveDirInput.SetText(strings.TrimSpace(ws.LoopAutoSaveDir)) + a.loopLivePreview = ws.LoopLivePreview + a.batchMode = ws.BatchMode + a.batchInputDirInput.SetText(strings.TrimSpace(ws.BatchInputDir)) + a.batchOutputDirInput.SetText(strings.TrimSpace(ws.BatchOutputDir)) + a.batchRetryOnFail = ws.BatchRetryOnFail + a.batchAutoAspect = strings.TrimSpace(ws.BatchAutoAspect) a.sourcePathsInput.SetText(ws.SourcePathsText) a.sourceButtons = map[string]*widget.Clickable{} a.selectedHistoryID = ws.SelectedHistoryID @@ -255,6 +277,15 @@ func (a *App) createWorkspace() { Moderation: kernel.DefaultConfig().Moderation, PartialImages: strconv.Itoa(kernel.DefaultConfig().PartialImages), BatchCount: 1, + LoopEnabled: false, + LoopTotalCount: normalizeLoopGenerationCount(10), + LoopConcurrency: normalizeLoopGenerationConcurrency(2), + LoopAutoSave: false, + LoopAutoSaveDir: "", + LoopLivePreview: true, + BatchMode: false, + BatchRetryOnFail: false, + BatchAutoAspect: "", ResultGridOpen: false, } a.workspaces = append(a.workspaces, ws) diff --git a/go-cli/pkg/client/client.go b/go-cli/pkg/client/client.go index 47bcd7e0f..c2c33d80d 100644 --- a/go-cli/pkg/client/client.go +++ b/go-cli/pkg/client/client.go @@ -147,6 +147,17 @@ func RequestAndExtractWithRetriesAndPartial( return responsesAPIWithRetries(ctx, transport, opts, outputDir, timestamp, onLog, onProgress, onPartial) } +func effectiveMaxAttempts(opts Options) int { + retryCount := opts.AutoRetryCount + if retryCount <= 0 { + retryCount = DefaultAutoRetryCount + } + if retryCount > MaxAutoRetryCount { + retryCount = MaxAutoRetryCount + } + return retryCount + 1 +} + func responsesAPIWithRetries( ctx context.Context, transport Transport, @@ -171,15 +182,17 @@ func responsesAPIWithRetries( if opts.AutoRetryEnabled != nil && !*opts.AutoRetryEnabled { autoRetryEnabled = false } + maxAttempts := effectiveMaxAttempts(opts) + sizeRepairTried := false - for attempt := 1; attempt <= MaxAttempts; attempt++ { + for attempt := 1; attempt <= maxAttempts; attempt++ { rawPrefix := "sse-response" if normalizeResponsesTransport(opts.ResponsesTransport) == ResponsesTransportWebSocket { rawPrefix = "ws-response" } rawPath := filepath.Join(outputDir, fmt.Sprintf("%s-%s-attempt%d.txt", rawPrefix, timestamp, attempt)) lastPath = rawPath - onLog(fmt.Sprintf("第 %d/%d 次请求...", attempt, MaxAttempts)) + onLog(fmt.Sprintf("第 %d/%d 次请求...", attempt, maxAttempts)) f, err := os.OpenFile(rawPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { @@ -205,10 +218,19 @@ func responsesAPIWithRetries( rawBytes, _ := os.ReadFile(rawPath) raw := string(rawBytes) + if !sizeRepairTried { + if repairedOpts, repaired, reason := repairSizeRetryOptions(opts, raw, reqErr); repaired { + sizeRepairTried = true + opts = repairedOpts + onLog(reason) + continue + } + } + if errors.Is(reqErr, ErrNoImageInResponse) { lastErr = reqErr reason := DescribeProblem(raw) - if autoRetryEnabled && attempt < MaxAttempts && IsRetryable(raw) { + if autoRetryEnabled && attempt < maxAttempts && IsRetryable(raw) { onLog(reason) onLog(fmt.Sprintf("这是可重试错误,%d 秒后自动重试...", RetryBackoffSeconds)) if !sleepCtx(ctx, time.Duration(RetryBackoffSeconds)*time.Second) { @@ -223,7 +245,7 @@ func responsesAPIWithRetries( // Transport-level error (network / native HTTP failure). Retry up to MaxAttempts. lastErr = reqErr - if autoRetryEnabled && attempt < MaxAttempts { + if autoRetryEnabled && attempt < maxAttempts { onLog(fmt.Sprintf("%v", reqErr)) onLog(fmt.Sprintf("%d 秒后自动重试...", RetryBackoffSeconds)) if !sleepCtx(ctx, time.Duration(RetryBackoffSeconds)*time.Second) { @@ -277,11 +299,13 @@ func imagesAPIWithRetries( if opts.AutoRetryEnabled != nil && !*opts.AutoRetryEnabled { autoRetryEnabled = false } + maxAttempts := effectiveMaxAttempts(opts) + sizeRepairTried := false - for attempt := 1; attempt <= MaxAttempts; attempt++ { + for attempt := 1; attempt <= maxAttempts; attempt++ { rawPath := filepath.Join(outputDir, fmt.Sprintf("images-response-%s-attempt%d.json", timestamp, attempt)) lastPath = rawPath - onLog(fmt.Sprintf("[Images API] 第 %d/%d 次请求...", attempt, MaxAttempts)) + onLog(fmt.Sprintf("[Images API] 第 %d/%d 次请求...", attempt, maxAttempts)) f, err := os.OpenFile(rawPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { @@ -297,10 +321,19 @@ func imagesAPIWithRetries( rawBytes, _ := os.ReadFile(rawPath) raw := string(rawBytes) + if !sizeRepairTried { + if repairedOpts, repaired, reason := repairSizeRetryOptions(opts, raw, reqErr); repaired { + sizeRepairTried = true + opts = repairedOpts + onLog(reason) + continue + } + } + lastErr = reqErr // Images API has no SSE / no partial — only retry on transport-level // errors and Cloudflare 5xx HTML pages. - if autoRetryEnabled && attempt < MaxAttempts && (IsRetryable(raw) || isTransportishError(reqErr)) { + if autoRetryEnabled && attempt < maxAttempts && (IsRetryable(raw) || isTransportishError(reqErr)) { onLog(fmt.Sprintf("%v", reqErr)) onLog(fmt.Sprintf("%d 秒后自动重试...", RetryBackoffSeconds)) if !sleepCtx(ctx, time.Duration(RetryBackoffSeconds)*time.Second) { @@ -315,6 +348,17 @@ func imagesAPIWithRetries( return ImageResult{}, lastPath, fmt.Errorf("多次请求后仍未成功:%w", lastErr) } +func repairSizeRetryOptions(opts Options, raw string, reqErr error) (Options, bool, string) { + if extractInvalidSize(raw) == "" && (reqErr == nil || extractInvalidSize(reqErr.Error()) == "") { + return opts, false, "" + } + repaired := repairSizeForOpenAIOptions(opts) + if repaired == nil { + return opts, false, "" + } + return *repaired, true, fmt.Sprintf("检测到上游拒绝当前尺寸 %s,自动改为最近合法尺寸 %s 后重试一次...", opts.Size, repaired.Size) +} + // isTransportishError treats common transport-layer failures as retryable. func isTransportishError(err error) bool { if err == nil { diff --git a/go-cli/pkg/client/client_test.go b/go-cli/pkg/client/client_test.go index 209998421..0eb4400f1 100644 --- a/go-cli/pkg/client/client_test.go +++ b/go-cli/pkg/client/client_test.go @@ -310,6 +310,110 @@ func TestRequestAndExtractWithRetriesCanDisableAutoRetry(t *testing.T) { } } +func TestRequestAndExtractWithRetriesUsesConfiguredRetryCount(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if hits <= 3 { + fmt.Fprint(w, `{"error":{"message":"Upstream request failed","type":"upstream_error","upstreamStatus":503}}`) + return + } + body, _ := json.Marshal(map[string]any{ + "type": "response.output_item.done", + "item": map[string]any{ + "type": "image_generation_call", + "result": pngB64, + }, + }) + fmt.Fprintln(w, "data: "+string(body)) + })) + defer srv.Close() + + original := RetryBackoffSeconds + RetryBackoffSeconds = 0 + t.Cleanup(func() { RetryBackoffSeconds = original }) + + transport := &injectingTransport{inner: &NativeTransport{}, url: srv.URL} + dir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + res, _, err := RequestAndExtractWithRetries( + ctx, transport, + Options{ + APIKey: "sk-test", + Prompt: "p", + Size: "1024x1024", + Quality: "auto", + BaseURL: "https://test.local", + AutoRetryCount: 3, + }, + dir, "20260609-100001", + nil, nil, + ) + if err != nil { + t.Fatalf("err = %v", err) + } + if res.ImageB64 != pngB64 { + t.Fatalf("unexpected result: %+v", res) + } + if hits != 4 { + t.Fatalf("hits = %d, want 4", hits) + } +} + +func TestRequestAndExtractWithRetriesRepairsInvalidSizeAndRetriesOnce(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfixed")) + hits := 0 + var requestBodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + body, _ := io.ReadAll(r.Body) + requestBodies = append(requestBodies, string(body)) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if hits == 1 { + fmt.Fprint(w, `{"error":{"message":"Invalid size '872x2048'. Width and height must both be divisible by 16.","type":"image_generation_user_error","param":"tools","code":"invalid_value"}}`) + return + } + payload, _ := json.Marshal(map[string]any{ + "type": "response.output_item.done", + "item": map[string]any{ + "type": "image_generation_call", + "result": pngB64, + }, + }) + fmt.Fprintln(w, "data: "+string(payload)) + })) + defer srv.Close() + + transport := &injectingTransport{inner: &NativeTransport{}, url: srv.URL} + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + res, _, err := RequestAndExtractWithRetries( + ctx, transport, + Options{APIKey: "sk-test", Prompt: "p", Size: "872x2048", Quality: "auto", BaseURL: "https://test.local"}, + dir, "20260609-100002", + nil, nil, + ) + if err != nil { + t.Fatalf("err = %v", err) + } + if hits != 2 { + t.Fatalf("hits = %d, want 2", hits) + } + if res.ImageB64 != pngB64 { + t.Fatalf("unexpected result: %+v", res) + } + if len(requestBodies) != 2 || !strings.Contains(requestBodies[0], `"size":"872x2048"`) || !strings.Contains(requestBodies[1], `"size":"880x2048"`) { + t.Fatalf("unexpected request bodies: %#v", requestBodies) + } +} + // TestRequestAndExtract_StreamCutAfterFinal:服务端发完 final event 后立刻 // 关掉连接(模拟 Cloudflare 在 idle 阶段 reset),客户端不应再算作失败 —— // buffer 已经包含完整 final event,parse 出图。 diff --git a/go-cli/pkg/client/payload.go b/go-cli/pkg/client/payload.go index 5bfb77484..a21a7820c 100644 --- a/go-cli/pkg/client/payload.go +++ b/go-cli/pkg/client/payload.go @@ -4,9 +4,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "math" "os" "path/filepath" "regexp" + "sort" "strings" "unicode" ) @@ -176,6 +178,164 @@ func normalizeReasoningEffort(value string) string { } } +func parseSizeValue(size string) (width int, height int, ok bool) { + match := regexp.MustCompile(`^(\d+)x(\d+)$`).FindStringSubmatch(strings.TrimSpace(size)) + if len(match) != 3 { + return 0, 0, false + } + w := 0 + h := 0 + if _, err := fmt.Sscanf(match[1], "%d", &w); err != nil { + return 0, 0, false + } + if _, err := fmt.Sscanf(match[2], "%d", &h); err != nil { + return 0, 0, false + } + if w <= 0 || h <= 0 { + return 0, 0, false + } + return w, h, true +} + +func normalizeOpenAIImageSize(width, height int) (int, int, bool) { + const ( + minSide = 64 + maxSide = 3840 + maxPixels = 3840 * 2160 + maxAspect = 3.0 + alignment = 16 + minAspect = 1.0 / maxAspect + ) + if width <= 0 || height <= 0 { + return 0, 0, false + } + + targetWidth := float64(width) + targetHeight := float64(height) + aspect := targetWidth / targetHeight + if aspect > maxAspect { + aspect = maxAspect + } + if aspect < minAspect { + aspect = minAspect + } + if targetWidth/targetHeight != aspect { + if targetWidth >= targetHeight { + targetWidth = targetHeight * aspect + } else { + targetHeight = targetWidth / aspect + } + } + if currentMax := math.Max(targetWidth, targetHeight); currentMax > maxSide { + scale := float64(maxSide) / currentMax + targetWidth *= scale + targetHeight *= scale + } + if pixelCount := targetWidth * targetHeight; pixelCount > maxPixels { + scale := math.Sqrt(float64(maxPixels) / pixelCount) + targetWidth *= scale + targetHeight *= scale + } + + widthCandidates := alignedDimensionCandidates(targetWidth, minSide, maxSide, alignment) + heightCandidates := alignedDimensionCandidates(targetHeight, minSide, maxSide, alignment) + bestW, bestH := 0, 0 + bestDistance := math.Inf(1) + bestAspectDistance := math.Inf(1) + bestAreaDistance := math.Inf(1) + + for _, candidateWidth := range widthCandidates { + for _, candidateHeight := range heightCandidates { + if !sizeWithinLimits(candidateWidth, candidateHeight, minSide, maxSide, maxPixels, maxAspect, minAspect) { + continue + } + distance := sizeDistance(candidateWidth, candidateHeight, targetWidth, targetHeight) + aspectDistance := math.Abs((float64(candidateWidth) / float64(candidateHeight)) - (targetWidth / targetHeight)) + areaDistance := math.Abs(float64(candidateWidth*candidateHeight)-targetWidth*targetHeight) / math.Max(targetWidth*targetHeight, 1) + if distance < bestDistance || + (distance == bestDistance && aspectDistance < bestAspectDistance) || + (distance == bestDistance && aspectDistance == bestAspectDistance && areaDistance < bestAreaDistance) { + bestW = candidateWidth + bestH = candidateHeight + bestDistance = distance + bestAspectDistance = aspectDistance + bestAreaDistance = areaDistance + } + } + } + if bestW == 0 || bestH == 0 { + return 0, 0, false + } + return bestW, bestH, true +} + +func alignedDimensionCandidates(value float64, min, max, alignment int) []int { + clamped := math.Max(float64(min), math.Min(float64(max), value)) + candidates := []int{ + int(math.Round(clamped/float64(alignment))) * alignment, + int(math.Floor(clamped/float64(alignment))) * alignment, + int(math.Ceil(clamped/float64(alignment))) * alignment, + } + seen := map[int]struct{}{} + out := make([]int, 0, len(candidates)) + for _, candidate := range candidates { + if candidate < min { + candidate = min + } + if candidate > max { + candidate = max + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + out = append(out, candidate) + } + sort.Slice(out, func(i, j int) bool { + left := math.Abs(float64(out[i]) - clamped) + right := math.Abs(float64(out[j]) - clamped) + if left == right { + return out[i] > out[j] + } + return left < right + }) + return out +} + +func sizeDistance(width, height int, targetWidth, targetHeight float64) float64 { + return math.Abs(float64(width)-targetWidth)/math.Max(targetWidth, 1) + + math.Abs(float64(height)-targetHeight)/math.Max(targetHeight, 1) +} + +func sizeWithinLimits(width, height, minSide, maxSide, maxPixels int, maxAspect, minAspect float64) bool { + if width < minSide || height < minSide || width > maxSide || height > maxSide { + return false + } + if width*height > maxPixels { + return false + } + aspect := float64(width) / float64(height) + return aspect <= maxAspect && aspect >= minAspect +} + +func repairSizeForOpenAIOptions(opts Options) *Options { + width, height, ok := parseSizeValue(opts.Size) + if !ok { + return nil + } + nextWidth, nextHeight, ok := normalizeOpenAIImageSize(width, height) + if !ok { + return nil + } + nextSize := fmt.Sprintf("%dx%d", nextWidth, nextHeight) + if nextSize == strings.TrimSpace(opts.Size) { + return nil + } + repaired := opts + repaired.Size = nextSize + return &repaired +} + func normalizeBackground(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "opaque": diff --git a/go-cli/pkg/client/payload_test.go b/go-cli/pkg/client/payload_test.go index c7e7a9540..47bb9febb 100644 --- a/go-cli/pkg/client/payload_test.go +++ b/go-cli/pkg/client/payload_test.go @@ -275,6 +275,19 @@ func TestBuildPayloadAlwaysKeepsPromptVerbatim(t *testing.T) { } } +func TestRepairSizeForOpenAIOptionsSnapsToNearestLegalSize(t *testing.T) { + repaired := repairSizeForOpenAIOptions(Options{ + Prompt: "cat", + Size: "872x2048", + }) + if repaired == nil { + t.Fatal("expected repaired options") + } + if repaired.Size != "880x2048" { + t.Fatalf("repaired size = %q, want 880x2048", repaired.Size) + } +} + func TestBuildPayloadMultiImageReferences(t *testing.T) { urls := []string{ "data:image/png;base64,AAA", diff --git a/go-cli/pkg/client/retry.go b/go-cli/pkg/client/retry.go index 62c0f23e3..3c45a85fd 100644 --- a/go-cli/pkg/client/retry.go +++ b/go-cli/pkg/client/retry.go @@ -8,14 +8,46 @@ import ( ) var retryableMarkers = []string{ + "upstream request failed", "error code 524", "524: a timeout occurred", + "error code 503", + "service unavailable", "error code 504", "gateway time-out", "service temporarily unavailable", "origin_gateway_timeout", } +var invalidSize16Re = regexp.MustCompile(`Invalid size '(\d+x\d+)'\. Width and height must both be divisible by 16`) + +func extractInvalidSize(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + if match := invalidSize16Re.FindStringSubmatch(text); len(match) >= 2 { + return strings.TrimSpace(match[1]) + } + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return "" + } + if errObj, ok := data["error"].(map[string]any); ok { + if message, _ := errObj["message"].(string); message != "" { + if match := invalidSize16Re.FindStringSubmatch(message); len(match) >= 2 { + return strings.TrimSpace(match[1]) + } + } + } + if message, _ := data["message"].(string); message != "" { + if match := invalidSize16Re.FindStringSubmatch(message); len(match) >= 2 { + return strings.TrimSpace(match[1]) + } + } + return "" +} + func hasPartialImageWithoutFinal(raw string) bool { text := strings.TrimSpace(raw) if text == "" { @@ -76,18 +108,45 @@ func IsRetryable(raw string) bool { } if status, ok := data["status"].(float64); ok { switch int(status) { - case 502, 503, 504, 524: + case 403, 502, 503, 504, 524: return true } } if errObj, ok := data["error"].(map[string]any); ok { message, _ := errObj["message"].(string) errType, _ := errObj["type"].(string) + switch statusValue := errObj["upstreamStatus"].(type) { + case float64: + switch int(statusValue) { + case 403, 502, 503, 504, 524: + return true + } + case int: + switch statusValue { + case 403, 502, 503, 504, 524: + return true + } + } + switch statusValue := errObj["upstream_status"].(type) { + case float64: + switch int(statusValue) { + case 403, 502, 503, 504, 524: + return true + } + case int: + switch statusValue { + case 403, 502, 503, 504, 524: + return true + } + } if strings.Contains(strings.ToLower(message), "temporarily unavailable") { return true } + if strings.Contains(strings.ToLower(message), "upstream request failed") { + return true + } switch strings.ToLower(errType) { - case "api_error", "server_error": + case "api_error", "server_error", "upstream_error": return true } } diff --git a/go-cli/pkg/client/retry_test.go b/go-cli/pkg/client/retry_test.go index a5ee6c838..4c57cd1fd 100644 --- a/go-cli/pkg/client/retry_test.go +++ b/go-cli/pkg/client/retry_test.go @@ -33,6 +33,13 @@ func TestIsRetryableJSON504(t *testing.T) { } } +func TestIsRetryableJSON403(t *testing.T) { + body := `{"error":{"message":"Upstream request failed","type":"upstream_error","upstreamStatus":403}}` + if !IsRetryable(body) { + t.Errorf("JSON upstream 403 body should be retryable") + } +} + func TestIsRetryableFalseForSuccess(t *testing.T) { if IsRetryable(`{"status":200,"output":[]}`) { t.Errorf("200 success should not be retryable") diff --git a/go-cli/pkg/client/types.go b/go-cli/pkg/client/types.go index 03bebf1a2..dae0f166f 100644 --- a/go-cli/pkg/client/types.go +++ b/go-cli/pkg/client/types.go @@ -16,8 +16,10 @@ const ( DefaultModeration = "low" DefaultReasoningEffort = "xhigh" DefaultPartialImages = 1 + DefaultAutoRetryCount = 5 + MaxAutoRetryCount = 10 MaxInputImageBytes = 50 * 1024 * 1024 - MaxAttempts = 3 + MaxAttempts = DefaultAutoRetryCount + 1 ) // Tunable knobs (exposed as vars so tests can shrink them). @@ -233,6 +235,10 @@ type Options struct { // AutoRetryEnabled controls whether retryable upstream/transport failures are // retried automatically. Nil means "use the default" (enabled). AutoRetryEnabled *bool + + // AutoRetryCount controls how many extra retry attempts should be issued + // after the initial request. Values <= 0 fall back to DefaultAutoRetryCount. + AutoRetryCount int } // EffectiveImageDataURLs returns the merged list, deduplicating empty entries. diff --git a/go-cli/pkg/promptimport/promptimport.go b/go-cli/pkg/promptimport/promptimport.go new file mode 100644 index 000000000..b549bbaa1 --- /dev/null +++ b/go-cli/pkg/promptimport/promptimport.go @@ -0,0 +1,281 @@ +package promptimport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/yuanhua/image-gptcodex/pkg/client" +) + +const ( + DefaultBaseURL = "https://prompts.sorry.ink" + TokenLength = 8 +) + +var retryBackoffs = []time.Duration{ + 500 * time.Millisecond, + 1 * time.Second, + 2 * time.Second, +} + +type ImportErrorCode string + +const ( + TokenNotFound ImportErrorCode = "TOKEN_NOT_FOUND" + TokenUsed ImportErrorCode = "TOKEN_USED" + TokenExpired ImportErrorCode = "TOKEN_EXPIRED" + TokenUnavailable ImportErrorCode = "TOKEN_UNAVAILABLE" + TokenInvalid ImportErrorCode = "TOKEN_INVALID" +) + +type ImportError struct { + Code ImportErrorCode + StatusCode int + Cause error +} + +func (e *ImportError) Error() string { + if e == nil { + return "" + } + return string(e.Code) +} + +func (e *ImportError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +type BilingualText struct { + Zh string `json:"zh,omitempty"` + En string `json:"en,omitempty"` +} + +type ImportPayload struct { + Prompt BilingualText `json:"prompt"` + NegativePrompt *BilingualText `json:"negative_prompt,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ResolvedSize string `json:"resolvedSize,omitempty"` +} + +type FetchOptions struct { + BaseURL string + UserAgent string + Client *http.Client +} + +func IsValidToken(token string) bool { + if len(token) != TokenLength { + return false + } + for _, r := range token { + switch { + case r >= '0' && r <= '9': + case r >= 'A' && r <= 'Z': + case r >= 'a' && r <= 'z': + default: + return false + } + } + return true +} + +func RedactToken(token string) string { + if strings.TrimSpace(token) == "" { + return "" + } + token = strings.TrimSpace(token) + if len(token) <= 3 { + return token + "***" + } + return token[:3] + "***" +} + +func ParseTokenFromURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "", &ImportError{Code: TokenInvalid, Cause: err} + } + if parsed.Scheme != "image-studio" || parsed.Host != "import" { + return "", &ImportError{Code: TokenInvalid} + } + token := parsed.Query().Get("token") + if !IsValidToken(token) { + return "", &ImportError{Code: TokenInvalid} + } + return token, nil +} + +func ExtractFirstTokenFromArgs(args []string) string { + for _, arg := range args { + token, err := ParseTokenFromURL(arg) + if err == nil && token != "" { + return token + } + } + return "" +} + +func PreferChinese(text *BilingualText) string { + if text == nil { + return "" + } + if zh := strings.TrimSpace(text.Zh); zh != "" { + return zh + } + return strings.TrimSpace(text.En) +} + +func ResolvedSizeForAspectRatio(aspectRatio string) string { + switch strings.TrimSpace(aspectRatio) { + case "", "auto": + return "auto" + case "1:1": + return "1024x1024" + case "3:2": + return "1536x1024" + case "2:3": + return "1024x1536" + case "16:9": + return "1536x864" + case "9:16": + return "864x1536" + case "4:3": + return "1360x1024" + case "3:4": + return "1024x1360" + case "21:9": + return "1792x768" + case "9:21": + return "768x1792" + default: + return "auto" + } +} + +func Fetch(ctx context.Context, token string, options FetchOptions) (*ImportPayload, error) { + if !IsValidToken(token) { + return nil, &ImportError{Code: TokenInvalid} + } + baseURL := strings.TrimSpace(options.BaseURL) + if baseURL == "" { + baseURL = DefaultBaseURL + } + validatedBaseURL, err := client.ValidateBaseURL(baseURL) + if err != nil { + return nil, &ImportError{Code: TokenUnavailable, Cause: err} + } + httpClient := options.Client + if httpClient == nil { + transport, transportErr := client.NewHTTPTransport(client.ProxyConfig{Mode: client.ProxyModeSystem}) + if transportErr != nil { + return nil, &ImportError{Code: TokenUnavailable, Cause: transportErr} + } + httpClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + } + } + userAgent := strings.TrimSpace(options.UserAgent) + if userAgent == "" { + userAgent = client.UserAgent() + } + requestURL := fmt.Sprintf("%s/api/import-tokens/%s", strings.TrimRight(validatedBaseURL, "/"), token) + var lastErr error + attempts := len(retryBackoffs) + 1 + for attempt := 0; attempt < attempts; attempt++ { + payload, retryable, fetchErr := fetchOnce(ctx, httpClient, requestURL, userAgent) + if fetchErr == nil { + payload.ResolvedSize = ResolvedSizeForAspectRatio(payload.AspectRatio) + return payload, nil + } + lastErr = fetchErr + if !retryable || attempt == attempts-1 { + return nil, fetchErr + } + if sleepErr := sleepWithContext(ctx, retryBackoffs[attempt]); sleepErr != nil { + return nil, sleepErr + } + } + if lastErr != nil { + return nil, lastErr + } + return nil, &ImportError{Code: TokenUnavailable} +} + +func fetchOnce(ctx context.Context, httpClient *http.Client, requestURL string, userAgent string) (*ImportPayload, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, false, &ImportError{Code: TokenUnavailable, Cause: err} + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, true, &ImportError{Code: TokenUnavailable, Cause: err} + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if readErr != nil { + return nil, true, &ImportError{Code: TokenUnavailable, StatusCode: resp.StatusCode, Cause: readErr} + } + + var decoded struct { + ImportPayload + Error string `json:"error,omitempty"` + } + decodeErr := json.Unmarshal(body, &decoded) + + switch resp.StatusCode { + case http.StatusOK: + if decodeErr != nil { + return nil, true, &ImportError{Code: TokenUnavailable, StatusCode: resp.StatusCode, Cause: decodeErr} + } + return &decoded.ImportPayload, false, nil + case http.StatusNotFound: + return nil, false, &ImportError{Code: TokenNotFound, StatusCode: resp.StatusCode} + case http.StatusGone: + if strings.TrimSpace(decoded.Error) == "token_expired" { + return nil, false, &ImportError{Code: TokenExpired, StatusCode: resp.StatusCode} + } + return nil, false, &ImportError{Code: TokenUsed, StatusCode: resp.StatusCode} + default: + if resp.StatusCode >= 500 { + return nil, true, &ImportError{Code: TokenUnavailable, StatusCode: resp.StatusCode} + } + return nil, false, &ImportError{Code: TokenUnavailable, StatusCode: resp.StatusCode} + } +} + +func ErrorCode(err error) ImportErrorCode { + var importErr *ImportError + if errors.As(err, &importErr) { + return importErr.Code + } + return "" +} + +func sleepWithContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/go-cli/pkg/promptimport/promptimport_test.go b/go-cli/pkg/promptimport/promptimport_test.go new file mode 100644 index 000000000..db75d0a14 --- /dev/null +++ b/go-cli/pkg/promptimport/promptimport_test.go @@ -0,0 +1,170 @@ +package promptimport + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestParseTokenFromURL(t *testing.T) { + token, err := ParseTokenFromURL("image-studio://import?token=k7Bx2QzR") + if err != nil { + t.Fatalf("ParseTokenFromURL returned error: %v", err) + } + if token != "k7Bx2QzR" { + t.Fatalf("token = %q", token) + } +} + +func TestParseTokenFromURLRejectsInvalidToken(t *testing.T) { + if _, err := ParseTokenFromURL("image-studio://import?token=abc"); ErrorCode(err) != TokenInvalid { + t.Fatalf("error code = %q", ErrorCode(err)) + } +} + +func TestExtractFirstTokenFromArgs(t *testing.T) { + args := []string{"--flag", "image-studio://import?token=AB12cd34", "image-studio://import?token=zzzzzzzz"} + if got := ExtractFirstTokenFromArgs(args); got != "AB12cd34" { + t.Fatalf("ExtractFirstTokenFromArgs() = %q", got) + } +} + +func TestResolvedSizeForAspectRatio(t *testing.T) { + cases := map[string]string{ + "auto": "auto", + "1:1": "1024x1024", + "3:2": "1536x1024", + "2:3": "1024x1536", + "16:9": "1536x864", + "9:16": "864x1536", + "4:3": "1360x1024", + "3:4": "1024x1360", + "21:9": "1792x768", + "9:21": "768x1792", + "5:4": "auto", + } + for input, want := range cases { + if got := ResolvedSizeForAspectRatio(input); got != want { + t.Fatalf("ResolvedSizeForAspectRatio(%q) = %q, want %q", input, got, want) + } + } +} + +func TestFetchSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); got != "Image-Studio/test" { + t.Fatalf("user-agent = %q", got) + } + if r.URL.Path != "/api/import-tokens/TESTTEST" { + t.Fatalf("path = %q", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "prompt": map[string]string{ + "zh": "森林里的猫", + "en": "a cat in the forest", + }, + "negative_prompt": map[string]string{ + "zh": "模糊", + }, + "aspect_ratio": "3:2", + }) + })) + defer server.Close() + + payload, err := Fetch(context.Background(), "TESTTEST", FetchOptions{ + BaseURL: server.URL, + UserAgent: "Image-Studio/test", + Client: server.Client(), + }) + if err != nil { + t.Fatalf("Fetch returned error: %v", err) + } + if payload.ResolvedSize != "1536x1024" { + t.Fatalf("ResolvedSize = %q", payload.ResolvedSize) + } + if payload.Prompt.Zh != "森林里的猫" { + t.Fatalf("prompt.zh = %q", payload.Prompt.Zh) + } + if payload.NegativePrompt == nil || payload.NegativePrompt.Zh != "模糊" { + t.Fatalf("negativePrompt = %#v", payload.NegativePrompt) + } +} + +func TestFetchMaps404And410(t *testing.T) { + tests := []struct { + name string + status int + body map[string]string + expected ImportErrorCode + }{ + {name: "not-found", status: http.StatusNotFound, body: map[string]string{"error": "token_not_found"}, expected: TokenNotFound}, + {name: "used", status: http.StatusGone, body: map[string]string{"error": "token_used"}, expected: TokenUsed}, + {name: "expired", status: http.StatusGone, body: map[string]string{"error": "token_expired"}, expected: TokenExpired}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _ = json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + _, err := Fetch(context.Background(), "TESTTEST", FetchOptions{ + BaseURL: server.URL, + Client: server.Client(), + }) + if ErrorCode(err) != tt.expected { + t.Fatalf("ErrorCode(err) = %q, want %q", ErrorCode(err), tt.expected) + } + }) + } +} + +func TestFetchRetriesUnavailable(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusBadGateway) + })) + defer server.Close() + + start := time.Now() + _, err := Fetch(context.Background(), "TESTTEST", FetchOptions{ + BaseURL: server.URL, + Client: server.Client(), + }) + if ErrorCode(err) != TokenUnavailable { + t.Fatalf("ErrorCode(err) = %q", ErrorCode(err)) + } + if attempts != 4 { + t.Fatalf("attempts = %d, want 4", attempts) + } + if time.Since(start) < retryBackoffs[0] { + t.Fatal("expected retry backoff to run") + } +} + +func TestFetchRejectsInvalidToken(t *testing.T) { + _, err := Fetch(context.Background(), "bad", FetchOptions{}) + if ErrorCode(err) != TokenInvalid { + t.Fatalf("ErrorCode(err) = %q", ErrorCode(err)) + } +} + +func TestPreferChinese(t *testing.T) { + text := &BilingualText{Zh: "", En: "fallback"} + if got := PreferChinese(text); got != "fallback" { + t.Fatalf("PreferChinese() = %q", got) + } +} + +func TestSleepWithContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := sleepWithContext(ctx, time.Second); !errors.Is(err, context.Canceled) { + t.Fatalf("sleepWithContext error = %v", err) + } +} diff --git a/image-studio/backend/fixed_webview_runtime_nonwindows.go b/image-studio/backend/fixed_webview_runtime_nonwindows.go new file mode 100644 index 000000000..59df7e1ee --- /dev/null +++ b/image-studio/backend/fixed_webview_runtime_nonwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package backend + +func WindowsPortableWebviewBrowserPath() (string, error) { + return "", nil +} + +func EnsureWindowsFixedWebviewRuntimePermissions(_ string) error { + return nil +} diff --git a/image-studio/backend/fixed_webview_runtime_windows.go b/image-studio/backend/fixed_webview_runtime_windows.go new file mode 100644 index 000000000..7e4972fe9 --- /dev/null +++ b/image-studio/backend/fixed_webview_runtime_windows.go @@ -0,0 +1,175 @@ +//go:build windows + +package backend + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + windowsFixedWebviewEnvName = "IMAGE_STUDIO_WEBVIEW2_BROWSER_PATH" + windowsFixedWebviewDirName = "WebView2FixedRuntime" + windowsFixedWebviewLegacyName = "webview2-fixed" + windowsFixedWebviewExeName = "msedgewebview2.exe" +) + +var windowsFixedWebviewACLGrants = []string{ + "*S-1-15-2-2:(OI)(CI)(RX)", + "*S-1-15-2-1:(OI)(CI)(RX)", +} + +// WindowsPortableWebviewBrowserPath returns the directory that contains a +// bundled Fixed Version WebView2 runtime, if one is present next to the app. +func WindowsPortableWebviewBrowserPath() (string, error) { + candidates, err := windowsFixedWebviewCandidates() + if err != nil { + return "", err + } + for _, candidate := range candidates { + resolved, err := resolveWindowsFixedWebviewDir(candidate) + if err != nil { + return "", err + } + if resolved != "" { + return resolved, nil + } + } + return "", nil +} + +// EnsureWindowsFixedWebviewRuntimePermissions applies the ACLs Microsoft +// requires for unpackaged Win32 apps that ship Fixed Version WebView2. +func EnsureWindowsFixedWebviewRuntimePermissions(runtimeDir string) error { + runtimeDir = strings.TrimSpace(runtimeDir) + if runtimeDir == "" { + return nil + } + if _, err := os.Stat(filepath.Join(runtimeDir, windowsFixedWebviewExeName)); err != nil { + return err + } + + var failures []string + for _, grant := range windowsFixedWebviewACLGrants { + cmd := exec.Command("icacls", runtimeDir, "/grant", grant) + output, err := cmd.CombinedOutput() + if err == nil { + continue + } + msg := strings.TrimSpace(string(output)) + if msg == "" { + msg = err.Error() + } + failures = append(failures, fmt.Sprintf("%s: %s", grant, msg)) + } + if len(failures) > 0 { + return fmt.Errorf("failed to apply Fixed Version WebView2 ACLs: %s", strings.Join(failures, "; ")) + } + return nil +} + +func windowsFixedWebviewCandidates() ([]string, error) { + exePath, err := os.Executable() + if err != nil { + return nil, err + } + exeDir := filepath.Dir(exePath) + + var candidates []string + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + for _, existing := range candidates { + if strings.EqualFold(filepath.Clean(existing), filepath.Clean(path)) { + return + } + } + candidates = append(candidates, path) + } + + add(os.Getenv(windowsFixedWebviewEnvName)) + add(filepath.Join(exeDir, windowsFixedWebviewDirName)) + add(filepath.Join(exeDir, windowsFixedWebviewLegacyName)) + + entries, err := os.ReadDir(exeDir) + if err != nil { + if os.IsNotExist(err) { + return candidates, nil + } + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.ToLower(strings.TrimSpace(entry.Name())) + if strings.HasPrefix(name, "microsoft.webview2.fixedversionruntime.") { + add(filepath.Join(exeDir, entry.Name())) + } + } + return candidates, nil +} + +func resolveWindowsFixedWebviewDir(candidate string) (string, error) { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return "", nil + } + + info, err := os.Stat(candidate) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + if !info.IsDir() { + if strings.EqualFold(filepath.Base(candidate), windowsFixedWebviewExeName) { + return filepath.Dir(candidate), nil + } + return "", nil + } + + if fileExists(filepath.Join(candidate, windowsFixedWebviewExeName)) { + return candidate, nil + } + + rootDepth := strings.Count(filepath.Clean(candidate), string(os.PathSeparator)) + var resolved string + walkErr := filepath.WalkDir(candidate, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path == candidate { + return nil + } + depth := strings.Count(filepath.Clean(path), string(os.PathSeparator)) - rootDepth + if depth > 3 { + return fs.SkipDir + } + return nil + } + if strings.EqualFold(d.Name(), windowsFixedWebviewExeName) { + resolved = filepath.Dir(path) + return fs.SkipAll + } + return nil + }) + if walkErr != nil && walkErr != fs.SkipAll { + return "", walkErr + } + return resolved, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/image-studio/backend/prompt_import.go b/image-studio/backend/prompt_import.go new file mode 100644 index 000000000..86f0cc3b9 --- /dev/null +++ b/image-studio/backend/prompt_import.go @@ -0,0 +1,131 @@ +package backend + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/yuanhua/image-gptcodex/pkg/client" + "github.com/yuanhua/image-gptcodex/pkg/promptimport" +) + +const promptImportEventName = "studio-import-token" +const promptImportInvalidEventName = "studio-import-token-invalid" + +var imagePromptsBaseURL = promptimport.DefaultBaseURL + +func (s *Service) ImportPromptByToken(token string) (PromptImportPayload, error) { + ctx := context.Background() + if s.ctx != nil { + ctx = s.ctx + } + fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + payload, err := promptimport.Fetch(fetchCtx, strings.TrimSpace(token), promptimport.FetchOptions{ + BaseURL: imagePromptsBaseURL, + UserAgent: client.UserAgent(), + }) + if err != nil { + code := promptimport.ErrorCode(err) + if code != "" { + return PromptImportPayload{}, errors.New(string(code)) + } + return PromptImportPayload{}, err + } + result := PromptImportPayload{ + Prompt: PromptImportBilingualText{ + Zh: payload.Prompt.Zh, + En: payload.Prompt.En, + }, + AspectRatio: payload.AspectRatio, + ResolvedSize: payload.ResolvedSize, + } + if payload.NegativePrompt != nil { + result.NegativePrompt = &PromptImportBilingualText{ + Zh: payload.NegativePrompt.Zh, + En: payload.NegativePrompt.En, + } + } + return result, nil +} + +func (s *Service) GetImagePromptsBaseURL() string { + return imagePromptsBaseURL +} + +func (s *Service) ActivatePromptImportListener() PromptImportActivation { + s.mu.Lock() + s.promptImportListenerReady = true + activation := PromptImportActivation{ + Tokens: append([]string(nil), s.pendingPromptImportTokens...), + InvalidCount: s.pendingPromptImportInvalidCount, + } + s.pendingPromptImportTokens = nil + s.pendingPromptImportInvalidCount = 0 + s.mu.Unlock() + return activation +} + +func (s *Service) HandlePromptImportArgs(args []string) { + token := promptimport.ExtractFirstTokenFromArgs(args) + if token == "" { + return + } + s.dispatchPromptImportToken(token) +} + +func (s *Service) HandlePromptImportURL(rawURL string) { + token, err := promptimport.ParseTokenFromURL(rawURL) + if err != nil { + if promptimport.ErrorCode(err) == promptimport.TokenInvalid { + s.dispatchPromptImportInvalid() + } + return + } + s.dispatchPromptImportToken(token) +} + +func (s *Service) dispatchPromptImportToken(token string) { + token = strings.TrimSpace(token) + if !promptimport.IsValidToken(token) { + s.dispatchPromptImportInvalid() + return + } + s.focusMainWindow() + s.mu.Lock() + ready := s.promptImportListenerReady + ctx := s.ctx + if !ready || ctx == nil { + s.pendingPromptImportTokens = append(s.pendingPromptImportTokens, token) + s.mu.Unlock() + return + } + s.mu.Unlock() + runtime.EventsEmit(ctx, promptImportEventName, token) +} + +func (s *Service) dispatchPromptImportInvalid() { + s.focusMainWindow() + s.mu.Lock() + ready := s.promptImportListenerReady + ctx := s.ctx + if !ready || ctx == nil { + s.pendingPromptImportInvalidCount++ + s.mu.Unlock() + return + } + s.mu.Unlock() + runtime.EventsEmit(ctx, promptImportInvalidEventName) +} + +func (s *Service) focusMainWindow() { + if s.ctx == nil { + return + } + runtime.WindowUnminimise(s.ctx) + runtime.WindowShow(s.ctx) + runtime.Show(s.ctx) +} diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index 4a3de662c..2dcc6fc83 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -44,6 +44,10 @@ type Service struct { trustedOutputRoots map[string]struct{} mediaAssets map[string]mediaAsset + + promptImportListenerReady bool + pendingPromptImportTokens []string + pendingPromptImportInvalidCount int } type job struct { @@ -67,6 +71,7 @@ func NewService() *Service { func (s *Service) Startup(ctx context.Context) { s.ctx = ctx s.loadCompatibilitySettings() + s.HandlePromptImportArgs(os.Args[1:]) if strings.TrimSpace(os.Getenv(appUpdateProbePathEnv)) != "" || commandLineArgValue(os.Args[1:], appUpdateProbePathArg) != "" { go s.captureAppUpdateProbe() } @@ -377,6 +382,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions NoPromptRevision: opts.NoPromptRevision, DisablePreview: opts.DisablePreview, AutoRetryEnabled: &opts.AutoRetryEnabled, + AutoRetryCount: opts.AutoRetryCount, PartialImages: client.DefaultPartialImages, } if opts.PartialImages > 0 { @@ -415,6 +421,7 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions NoPromptRevision: opts.NoPromptRevision, DisablePreview: opts.DisablePreview, AutoRetryEnabled: &opts.AutoRetryEnabled, + AutoRetryCount: opts.AutoRetryCount, PartialImages: clientOpts.PartialImages, } } diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 3fe3fe0c0..b23d7bd83 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -28,22 +28,22 @@ type GenerateOptions struct { // frontend builds. Folded into ImagePaths when present. ImagePath string `json:"imagePath"` - MaskB64 string `json:"maskB64"` // optional, phase 3 reservation - Seed int64 `json:"seed"` // 0 = random - NegativePrompt string `json:"negativePrompt"` // optional - Background string `json:"background"` // "auto" | "opaque" | "transparent" - OutputCompression int `json:"outputCompression"` - InputFidelity string `json:"inputFidelity"` // "auto" | "low" | "high" - ImageStyle string `json:"imageStyle"` // "default" | "vivid" | "natural" - Moderation string `json:"moderation"` // "low" | "auto" - UserIdentifier string `json:"userIdentifier"` - BaseURL string `json:"baseURL"` // overrides the default upstream URL - TextModelID string `json:"textModelID"` // overrides the default text model - ImageModelID string `json:"imageModelID"` // overrides the default image model - ReasoningEffort string `json:"reasoningEffort"` - APIMode string `json:"apiMode"` // "responses" (default) | "images" + MaskB64 string `json:"maskB64"` // optional, phase 3 reservation + Seed int64 `json:"seed"` // 0 = random + NegativePrompt string `json:"negativePrompt"` // optional + Background string `json:"background"` // "auto" | "opaque" | "transparent" + OutputCompression int `json:"outputCompression"` + InputFidelity string `json:"inputFidelity"` // "auto" | "low" | "high" + ImageStyle string `json:"imageStyle"` // "default" | "vivid" | "natural" + Moderation string `json:"moderation"` // "low" | "auto" + UserIdentifier string `json:"userIdentifier"` + BaseURL string `json:"baseURL"` // overrides the default upstream URL + TextModelID string `json:"textModelID"` // overrides the default text model + ImageModelID string `json:"imageModelID"` // overrides the default image model + ReasoningEffort string `json:"reasoningEffort"` + APIMode string `json:"apiMode"` // "responses" (default) | "images" ResponsesTransport string `json:"responsesTransport,omitempty"` - RequestPolicy string `json:"requestPolicy"` // "openai" (default) | "compat" + RequestPolicy string `json:"requestPolicy"` // "openai" (default) | "compat" // ImagesNewAPICompat 开启后仅影响 Images API 请求: // 强制使用 b64_json,并关闭 stream/partial_images,用于兼容部分 NewAPI 中转。 ImagesNewAPICompat bool `json:"imagesNewAPICompat,omitempty"` @@ -62,6 +62,9 @@ type GenerateOptions struct { // AutoRetryEnabled controls whether retryable upstream failures should be // re-issued automatically. Default true. AutoRetryEnabled bool `json:"autoRetryEnabled"` + // AutoRetryCount controls how many extra retry attempts are made after the + // first request. 0 falls back to the app default. + AutoRetryCount int `json:"autoRetryCount"` // FallbackProfile is an optional backup upstream used only after the main // upstream exhausted its own automatic retries and still failed. FallbackProfile *FallbackProfileOptions `json:"fallbackProfile,omitempty"` @@ -105,11 +108,11 @@ type ProbeUpstreamOptions struct { } type ProbeUpstreamResult struct { - ModelCount int `json:"modelCount"` - Models []UpstreamModelDescriptor `json:"models,omitempty"` - ResponsesTransport string `json:"responsesTransport,omitempty"` - ResponsesTransportOK bool `json:"responsesTransportOK,omitempty"` - ResponsesTransportError string `json:"responsesTransportError,omitempty"` + ModelCount int `json:"modelCount"` + Models []UpstreamModelDescriptor `json:"models,omitempty"` + ResponsesTransport string `json:"responsesTransport,omitempty"` + ResponsesTransportOK bool `json:"responsesTransportOK,omitempty"` + ResponsesTransportError string `json:"responsesTransportError,omitempty"` } type UpstreamModelDescriptor struct { @@ -128,6 +131,23 @@ type CodexAPIConfig struct { WireAPI string `json:"wireAPI"` } +type PromptImportBilingualText struct { + Zh string `json:"zh,omitempty"` + En string `json:"en,omitempty"` +} + +type PromptImportPayload struct { + Prompt PromptImportBilingualText `json:"prompt"` + NegativePrompt *PromptImportBilingualText `json:"negative_prompt,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ResolvedSize string `json:"resolvedSize,omitempty"` +} + +type PromptImportActivation struct { + Tokens []string `json:"tokens,omitempty"` + InvalidCount int `json:"invalidCount,omitempty"` +} + func (o PromptOptimizeOptions) collectPaths() []string { paths := make([]string, 0, len(o.ImagePaths)+1) for _, p := range o.ImagePaths { diff --git a/image-studio/frontend/package.json b/image-studio/frontend/package.json index 0f3837adc..c6ee02715 100644 --- a/image-studio/frontend/package.json +++ b/image-studio/frontend/package.json @@ -13,6 +13,7 @@ "dev:android-pad": "node ./scripts/platform-vite.mjs dev android-pad", "build": "node ./scripts/platform-vite.mjs build", "build:windows": "node ./scripts/platform-vite.mjs build windows", + "build:windows:file-preview": "node ./scripts/platform-vite.mjs build-file-preview windows", "build:macos": "node ./scripts/platform-vite.mjs build macos", "build:linux": "node ./scripts/platform-vite.mjs build linux", "build:android": "node ./scripts/platform-vite.mjs build android", diff --git a/image-studio/frontend/scripts/capture-windows-preview.mjs b/image-studio/frontend/scripts/capture-windows-preview.mjs new file mode 100644 index 000000000..19b3212b3 --- /dev/null +++ b/image-studio/frontend/scripts/capture-windows-preview.mjs @@ -0,0 +1,77 @@ +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +const previewUrl = process.env.IMAGE_STUDIO_PREVIEW_URL + ?? "file:///Users/lin/Image-Studio/image-studio/frontend/dist-file-preview/index.html?preview=windows-right-rail&target=windows"; +const fullPath = process.env.IMAGE_STUDIO_PREVIEW_FULL_PATH + ?? "/Users/lin/Image-Studio/.tmp-windows-right-rail.png"; +const studioPath = process.env.IMAGE_STUDIO_PREVIEW_STUDIO_PATH + ?? "/Users/lin/Image-Studio/.tmp-windows-right-rail-studio.png"; + +async function loadPlaywright() { + try { + return await import("playwright"); + } catch {} + + const nodeBinDir = path.dirname(process.execPath); + const bundledNodeModules = path.resolve(nodeBinDir, "..", "node_modules"); + const bundledEntry = path.join(bundledNodeModules, "playwright", "index.mjs"); + return import(pathToFileURL(bundledEntry).href); +} + +function resolveExecutablePath(playwrightChromium) { + const candidates = [ + path.join( + "/Users/lin/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64", + "chrome-headless-shell", + ), + path.join( + "/Users/lin/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS", + "Google Chrome for Testing", + ), + playwrightChromium.executablePath(), + ]; + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + + throw new Error(`No usable Playwright browser executable found. Tried: ${candidates.join(", ")}`); +} + +const { chromium } = await loadPlaywright(); +const executablePath = resolveExecutablePath(chromium); + +const browser = await chromium.launch({ + headless: true, + executablePath, +}); + +try { + const page = await browser.newPage({ + viewport: { width: 1280, height: 1400 }, + deviceScaleFactor: 1, + }); + await page.goto(previewUrl, { waitUntil: "load" }); + await page.waitForTimeout(1500); + + await page.screenshot({ path: fullPath, fullPage: true }); + const studio = page.locator(".studio"); + if (await studio.count()) { + await studio.screenshot({ path: studioPath }); + } + + console.log(JSON.stringify({ + previewUrl, + fullPath, + studioPath, + executablePath, + cwd: projectRoot, + })); +} finally { + await browser.close(); +} diff --git a/image-studio/frontend/scripts/platform-vite.mjs b/image-studio/frontend/scripts/platform-vite.mjs index 8852fe75d..5f7d108d3 100644 --- a/image-studio/frontend/scripts/platform-vite.mjs +++ b/image-studio/frontend/scripts/platform-vite.mjs @@ -46,6 +46,14 @@ const env = { ...process.env, VITE_TARGET_PLATFORM: mode }; if (command === "build") { await run("node_modules/typescript/bin/tsc", ["--noEmit"], env); await run("node_modules/vite/bin/vite.js", ["build", "--mode", mode], env); +} else if (command === "build-file-preview") { + const previewEnv = { + ...env, + VITE_BUILD_BASE: "./", + VITE_OUT_DIR: "dist-file-preview", + }; + await run("node_modules/typescript/bin/tsc", ["--noEmit"], previewEnv); + await run("node_modules/vite/bin/vite.js", ["build", "--mode", mode], previewEnv); } else if (command === "dev") { await run("node_modules/vite/bin/vite.js", ["--mode", mode], env); } else { diff --git a/image-studio/frontend/src/app/App.tsx b/image-studio/frontend/src/app/App.tsx index 7acd4ffa0..918527c7b 100644 --- a/image-studio/frontend/src/app/App.tsx +++ b/image-studio/frontend/src/app/App.tsx @@ -14,8 +14,10 @@ import { SavePromptGate } from "./gates/SavePromptGate"; import { SettingsPanelGate } from "./gates/SettingsPanelGate"; import { StarPromptGate } from "./gates/StarPromptGate"; import { AppUpdateGate } from "./gates/AppUpdateGate"; +import { PromptImportGate } from "./gates/PromptImportGate"; import { UpstreamConfigGate } from "./gates/UpstreamConfigGate"; import { useAndroidView } from "./hooks/useAndroidView"; +import { useDesktopPromptImport } from "./hooks/useDesktopPromptImport"; import { useGlobalImageImport } from "./hooks/useGlobalImageImport"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useStudioBootstrap } from "./hooks/useStudioBootstrap"; @@ -30,6 +32,7 @@ export default function App() { const { isMac } = usePlatform(); const { androidView, setAndroidView } = useAndroidView(); const { dragHover } = useGlobalImageImport(importImageFile, reuseAsSource); + const promptImportDialog = useDesktopPromptImport(); useStudioBootstrap(); useGlobalShortcuts({ isMac }); @@ -57,6 +60,7 @@ export default function App() { + ); } diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index 20d4220ef..f74ff84ca 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -2,12 +2,24 @@ import type { BatchProcessConfig, HistoryItem, LoopGenerationConfig, + Preset, + PromptTemplate, SourceImage, UpstreamProfile, Workspace, } from "../../types/domain"; -export type PreviewScenario = "mac-workspace"; +export type PreviewScenario = "mac-workspace" | "windows-right-rail"; + +export interface WorkspacePreviewData { + profile: UpstreamProfile; + history: HistoryItem[]; + currentImage: HistoryItem; + sources: SourceImage[]; + workspace: Workspace; + promptTemplates?: PromptTemplate[]; + presets?: Preset[]; +} const PREVIEW_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAbUlEQVR4nO3PQQ3AIADAQMD2/hdwwZE8SBR0ztn3jJ9Zd7wD8E1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYE1gTWBNYF0X2AGCb5Q0aAAAAAElFTkSuQmCC"; @@ -22,7 +34,9 @@ export function readPreviewScenario(): PreviewScenario | null { try { const params = new URLSearchParams(window.location.search); const preview = (params.get("preview") ?? "").trim().toLowerCase(); - return preview === "mac-workspace" ? "mac-workspace" : null; + if (preview === "mac-workspace") return "mac-workspace"; + if (preview === "windows-right-rail") return "windows-right-rail"; + return null; } catch { return null; } @@ -227,7 +241,7 @@ function buildWorkspace( }; } -export function buildMacWorkspacePreview(workspaceId: string) { +export function buildMacWorkspacePreview(workspaceId: string): WorkspacePreviewData { const now = Date.now(); const history = buildHistory(now); const currentImage = history[0]; @@ -243,3 +257,159 @@ export function buildMacWorkspacePreview(workspaceId: string) { workspace, }; } + +function buildWindowsRightRailHistory(now: number): HistoryItem[] { + const prompt = "画一只小猫"; + const defs = [ + { + id: "win-preview-history-1", + prompt, + revisedPrompt: "奶油色英短幼猫,坐在木地板边缘,室内自然光,干净背景,照片感", + mode: "generate" as const, + size: "3456x2304" as const, + quality: "auto" as const, + negativePrompt: "模糊, 双影, 多余肢体", + styleTag: "", + previewUrl: previewImageUrl("文生图", 36), + createdAt: now - 8 * 60 * 1000, + savedPath: "/tmp/win-preview-history-1.png", + rawPath: "/tmp/win-preview-history-1.json", + outputFormat: "png" as const, + background: "auto" as const, + outputCompression: 100, + inputFidelity: "auto" as const, + imageStyle: "default" as const, + moderation: "low" as const, + seed: 1201, + elapsedSec: 16, + }, + { + id: "win-preview-history-2", + prompt, + revisedPrompt: "同一提示词第二版,拉近景别,保留木色背景与柔和窗边光", + mode: "generate" as const, + size: "3456x2304" as const, + quality: "auto" as const, + negativePrompt: "模糊, 双影, 多余肢体", + styleTag: "", + previewUrl: previewImageUrl("2", 28), + createdAt: now - 18 * 60 * 1000, + savedPath: "/tmp/win-preview-history-2.png", + rawPath: "/tmp/win-preview-history-2.json", + outputFormat: "png" as const, + background: "auto" as const, + outputCompression: 100, + inputFidelity: "auto" as const, + imageStyle: "default" as const, + moderation: "low" as const, + seed: 1202, + elapsedSec: 19, + }, + { + id: "win-preview-history-3", + prompt: "奶白色小猫窝在亚麻靠垫上,近景,柔光,干净背景", + revisedPrompt: "更近的半身取景,毛发细节清楚,保持暖白色调", + mode: "edit" as const, + size: "1024x1024" as const, + quality: "medium" as const, + negativePrompt: "锐化过度, 假眼神光", + styleTag: "静物生活", + previewUrl: previewImageUrl("图生图", 58), + createdAt: now - 44 * 60 * 1000, + savedPath: "/tmp/win-preview-history-3.png", + rawPath: "/tmp/win-preview-history-3.json", + outputFormat: "png" as const, + background: "auto" as const, + outputCompression: 100, + inputFidelity: "high" as const, + imageStyle: "natural" as const, + moderation: "low" as const, + seed: 1203, + elapsedSec: 22, + }, + ]; + + return defs; +} + +function buildWindowsWorkspace( + workspaceId: string, + currentImage: HistoryItem, + sources: SourceImage[], +): Workspace { + const loopGeneration: LoopGenerationConfig = { + enabled: false, + totalCount: 10, + concurrency: 2, + autoSave: false, + autoSaveDir: "", + livePreview: true, + }; + const batchProcess: BatchProcessConfig = { + enabled: false, + inputDir: "", + outputMode: "source_dir", + outputDir: "", + concurrency: 2, + retryOnFailure: false, + fileNamePrefix: "processed-", + autoAspectResolution: "", + discoveredSources: [], + }; + return { + id: workspaceId, + name: "Windows 右栏预览", + prompt: "", + negativePrompt: "", + mode: "generate", + size: "1024x1024", + quality: "medium", + outputFormat: "png", + seed: 0, + background: "auto", + outputCompression: 100, + inputFidelity: "auto", + imageStyle: "default", + moderation: "low", + userIdentifier: "", + partialImages: 0, + batchCount: 1, + editSourceMode: "manual", + batchProcess, + loopGeneration, + sources, + currentImageId: currentImage.id, + batchResultIds: [], + resultGridOpen: false, + runningJobIds: [], + jobsTotal: 0, + jobsCompleted: 0, + progress: null, + streamPreview: null, + streamPreviews: {}, + lastLogLine: "", + errorMessage: null, + errorRawPath: null, + lastPayload: null, + }; +} + +export function buildWindowsRightRailPreview(workspaceId: string): WorkspacePreviewData { + const now = Date.now(); + const history = buildWindowsRightRailHistory(now); + const currentImage = history[0]; + const sources: SourceImage[] = []; + const profile = buildPreviewProfile(now); + profile.name = "Windows Preview"; + const workspace = buildWindowsWorkspace(workspaceId, currentImage, sources); + + return { + profile, + history, + currentImage, + sources, + workspace, + promptTemplates: [], + presets: [], + }; +} diff --git a/image-studio/frontend/src/app/dev/previewScenario.ts b/image-studio/frontend/src/app/dev/previewScenario.ts index b1f602487..e8b7880d4 100644 --- a/image-studio/frontend/src/app/dev/previewScenario.ts +++ b/image-studio/frontend/src/app/dev/previewScenario.ts @@ -1,6 +1,7 @@ import { useStudioStore } from "../../state/studioStore"; import { applyTheme } from "../../state/studioStore.shared"; -import { buildMacWorkspacePreview, readPreviewScenario } from "./previewData"; +import { buildMacWorkspacePreview, buildWindowsRightRailPreview, readPreviewScenario } from "./previewData"; +import { DEFAULT_AUTO_RETRY_COUNT } from "../../../../../shared/kernel/requestModel.js"; export { readPreviewScenario } from "./previewData"; @@ -25,6 +26,8 @@ export function applyMacWorkspacePreviewToStore(workspaceId = "preview-workspace moderation: preview.currentImage.moderation ?? "low", userIdentifier: "", partialImages: 1, + autoRetryEnabled: true, + autoRetryCount: DEFAULT_AUTO_RETRY_COUNT, kernelRuntimeMode: "auto", baseURL: preview.profile.baseURL, textModelID: preview.profile.textModelID, @@ -94,3 +97,97 @@ export function applyMacWorkspacePreviewToStore(workspaceId = "preview-workspace starPromptSource: "auto", }); } + +export function applyWindowsRightRailPreviewToStore(workspaceId = "preview-workspace") { + const preview = buildWindowsRightRailPreview(workspaceId); + applyTheme("light"); + document.documentElement.style.setProperty("--font-scale", "1"); + + useStudioStore.setState({ + apiKey: "sk-preview", + mode: "generate", + prompt: preview.workspace.prompt, + negativePrompt: preview.workspace.negativePrompt, + size: preview.workspace.size, + quality: preview.workspace.quality, + outputFormat: preview.workspace.outputFormat, + seed: preview.workspace.seed, + background: preview.workspace.background, + outputCompression: preview.workspace.outputCompression, + inputFidelity: preview.workspace.inputFidelity, + imageStyle: preview.workspace.imageStyle, + moderation: preview.workspace.moderation, + userIdentifier: "", + partialImages: preview.workspace.partialImages, + autoRetryEnabled: true, + autoRetryCount: DEFAULT_AUTO_RETRY_COUNT, + kernelRuntimeMode: "auto", + baseURL: preview.profile.baseURL, + textModelID: preview.profile.textModelID, + imageModelID: preview.profile.imageModelID, + reasoningEffort: preview.profile.reasoningEffort, + apiMode: preview.profile.apiMode, + requestPolicy: preview.profile.requestPolicy, + noPromptRevision: true, + profiles: [preview.profile], + activeProfileId: preview.profile.id, + sources: preview.sources, + runningJobs: [], + jobsTotal: 0, + jobsCompleted: 0, + progress: null, + streamPreview: null, + streamPreviews: {}, + lastLogLine: "", + errorMessage: null, + errorRawPath: null, + isRunning: false, + lastPayload: null, + runningJobMeta: {}, + currentImage: preview.currentImage, + history: preview.history, + batchResults: [], + resultGridOpen: false, + historyRailCollapsed: false, + historyTimelineOpen: false, + tool: "pan", + brushSize: 24, + brushMode: "paint", + annotationKind: "rect", + annotationColor: "#ff4d4d", + selectedAnnotationId: null, + maskDataURL: null, + strokes: [], + annotations: [], + compareB: null, + compareSplit: 0.5, + toasts: [], + recentDurations: preview.history.map((item) => item.elapsedSec ?? 0).filter((value) => value > 0), + viewZoom: 1, + canvasViewResetTick: 0, + fullscreen: false, + promptHistory: [], + promptTemplates: preview.promptTemplates ?? [], + batchCount: preview.workspace.batchCount, + loopGeneration: preview.workspace.loopGeneration, + presets: preview.presets ?? [], + customAspectRatios: [], + theme: "light", + fontScale: 1, + workspaces: [preview.workspace], + activeWorkspaceId: workspaceId, + styleTag: preview.currentImage.styleTag ?? "", + undoStack: [], + redoStack: [], + resultDetail: null, + settingsOpen: false, + isTestingKey: false, + isOptimizingPrompt: false, + customAspectRatioModalOpen: false, + customSizeModalOpen: false, + upstreamModalOpen: false, + upstreamReturnTarget: "app", + starPromptOpen: false, + starPromptSource: "auto", + }); +} diff --git a/image-studio/frontend/src/app/gates/PromptImportGate.tsx b/image-studio/frontend/src/app/gates/PromptImportGate.tsx new file mode 100644 index 000000000..d57f739bd --- /dev/null +++ b/image-studio/frontend/src/app/gates/PromptImportGate.tsx @@ -0,0 +1,19 @@ +import { lazy, Suspense } from "react"; +import type { PromptImportDialogState } from "../hooks/useDesktopPromptImport"; + +const PromptImportModal = lazy(() => import("../../components/common/PromptImportModal").then((module) => ({ default: module.PromptImportModal }))); + +export function PromptImportGate({ dialog }: { dialog: PromptImportDialogState }) { + if (!dialog.open) return null; + return ( + + + + ); +} diff --git a/image-studio/frontend/src/app/hooks/useDesktopPromptImport.ts b/image-studio/frontend/src/app/hooks/useDesktopPromptImport.ts new file mode 100644 index 000000000..652ccb7af --- /dev/null +++ b/image-studio/frontend/src/app/hooks/useDesktopPromptImport.ts @@ -0,0 +1,228 @@ +import { useEffect, useRef, useState } from "react"; +import { + ActivatePromptImportListener, + EventsOn, + ImportPromptByToken, + canImportPromptByToken, +} from "../../platform/runtime/host"; +import type { PromptImportPayloadLike } from "../../platform/runtime/hostTypes"; +import { useStudioStore } from "../../state/studioStore"; +import { saveActiveWorkspaceSnapshot } from "../../state/studioStore.runtime"; +import { normalizeSizeSelection } from "../../components/panel/sizeCapabilities"; +import type { SizeValue } from "../../types/domain"; + +const promptImportEventName = "studio-import-token"; +const promptImportInvalidEventName = "studio-import-token-invalid"; + +export type PromptImportDialogState = { + open: boolean; + loading: boolean; + token: string; + payload: PromptImportPayloadLike | null; + resolvedSize: SizeValue; + close: () => void; + confirm: () => void; +}; + +type DialogState = { + open: boolean; + loading: boolean; + token: string; + payload: PromptImportPayloadLike | null; + resolvedSize: SizeValue; +}; + +const emptyDialogState: DialogState = { + open: false, + loading: false, + token: "", + payload: null, + resolvedSize: "auto", +}; + +function preferredText(text?: { zh?: string; en?: string } | null): string { + if (!text) return ""; + const zh = text.zh?.trim() || ""; + if (zh) return zh; + return text.en?.trim() || ""; +} + +function normalizePromptImportPayload(raw: PromptImportPayloadLike | null | undefined): PromptImportPayloadLike | null { + if (!raw?.prompt) return null; + return { + prompt: { + zh: raw.prompt.zh?.trim() || "", + en: raw.prompt.en?.trim() || "", + }, + negative_prompt: raw.negative_prompt + ? { + zh: raw.negative_prompt.zh?.trim() || "", + en: raw.negative_prompt.en?.trim() || "", + } + : undefined, + aspect_ratio: raw.aspect_ratio?.trim() || "", + resolvedSize: raw.resolvedSize?.trim() || "", + }; +} + +function promptImportErrorMessage(error: unknown): string { + const raw = String((error as any)?.message || error || "").trim(); + if (raw.includes("TOKEN_USED")) return "这个提示词已经导入过了"; + if (raw.includes("TOKEN_EXPIRED")) return "导入链接已过期,请回网页重新发送"; + if (raw.includes("TOKEN_NOT_FOUND") || raw.includes("TOKEN_INVALID")) { + return "提示词链接无效或已被清理,请回网页重新发送"; + } + return "导入服务暂时不可用"; +} + +function toastInvalidToken() { + useStudioStore.getState().pushToast("提示词链接无效或已被清理,请回网页重新发送", "error", 5000); +} + +function applyPromptImportPayload(payload: PromptImportPayloadLike, resolvedSize: SizeValue) { + const prompt = preferredText(payload.prompt); + const negativePrompt = preferredText(payload.negative_prompt); + useStudioStore.setState((state) => { + const nextState = { + ...state, + prompt, + negativePrompt, + size: resolvedSize, + }; + return { + prompt, + negativePrompt, + size: resolvedSize, + workspaces: saveActiveWorkspaceSnapshot(nextState), + }; + }); + useStudioStore.getState().pushToast("已从 Image-Prompts 导入提示词", "success"); +} + +export function useDesktopPromptImport(): PromptImportDialogState { + const [dialog, setDialog] = useState(emptyDialogState); + const queueRef = useRef([]); + const busyRef = useRef(false); + const openRef = useRef(false); + const activePayloadRef = useRef(null); + const activeSizeRef = useRef("auto"); + const pumpRef = useRef<() => void>(() => undefined); + + useEffect(() => { + openRef.current = dialog.open; + activePayloadRef.current = dialog.payload; + activeSizeRef.current = dialog.resolvedSize; + }, [dialog.open, dialog.payload, dialog.resolvedSize]); + + useEffect(() => { + if (!canImportPromptByToken()) { + return; + } + + let cancelled = false; + + const drainQueue = async () => { + if (cancelled || busyRef.current || openRef.current) return; + const token = queueRef.current.shift(); + if (!token) return; + busyRef.current = true; + setDialog((current) => ({ ...current, loading: true, token })); + try { + const payload = normalizePromptImportPayload(await ImportPromptByToken(token)); + if (!payload) { + throw new Error("TOKEN_INVALID"); + } + const state = useStudioStore.getState(); + const resolvedSize = normalizeSizeSelection( + (payload.resolvedSize || "auto") as SizeValue, + { + apiMode: state.apiMode, + requestPolicy: state.requestPolicy, + imageModelID: state.imageModelID, + }, + state.customAspectRatios, + ); + if (cancelled) return; + openRef.current = true; + setDialog({ + open: true, + loading: false, + token, + payload, + resolvedSize, + }); + } catch (error) { + if (!cancelled) { + useStudioStore.getState().pushToast(promptImportErrorMessage(error), "error", 5000); + setDialog(emptyDialogState); + } + } finally { + busyRef.current = false; + if (!cancelled && !openRef.current) { + void drainQueue(); + } + } + }; + pumpRef.current = () => { + void drainQueue(); + }; + + const enqueue = (token: unknown) => { + if (typeof token !== "string" || !token.trim()) { + toastInvalidToken(); + return; + } + queueRef.current.push(token.trim()); + void drainQueue(); + }; + + const offToken = EventsOn(promptImportEventName, (token: string) => { + enqueue(token); + }); + const offInvalid = EventsOn(promptImportInvalidEventName, () => { + toastInvalidToken(); + }); + + void ActivatePromptImportListener().then((activation) => { + if (cancelled) return; + const invalidCount = Math.max(0, Number(activation?.invalidCount || 0)); + for (let i = 0; i < invalidCount; i += 1) { + toastInvalidToken(); + } + for (const token of activation?.tokens ?? []) { + enqueue(token); + } + }).catch(() => undefined); + + return () => { + cancelled = true; + offToken(); + offInvalid(); + }; + }, []); + + const close = () => { + openRef.current = false; + setDialog(emptyDialogState); + pumpRef.current(); + }; + + const confirm = () => { + const payload = activePayloadRef.current; + if (!payload) { + openRef.current = false; + setDialog(emptyDialogState); + return; + } + applyPromptImportPayload(payload, activeSizeRef.current); + openRef.current = false; + setDialog(emptyDialogState); + pumpRef.current(); + }; + + return { + ...dialog, + close, + confirm, + }; +} diff --git a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx index 512d5d4e0..da328ea4a 100644 --- a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx +++ b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx @@ -33,8 +33,35 @@ export function BatchResultGrid({ livePreview?: boolean; }) { const gridSlots = slots ?? items.map((item) => ({ type: "result", item }) satisfies BatchGridSlot); - const singleLivePreview = livePreview && gridSlots.length === 1; - const columns = singleLivePreview ? 1 : gridSlots.length <= 4 ? 2 : 3; + const singlePendingPreview = livePreview && gridSlots.length === 1 && gridSlots[0]?.type === "pending"; + const singleLivePreview = livePreview && gridSlots.length === 1 && gridSlots[0]?.type !== "pending"; + const columns = singleLivePreview || singlePendingPreview ? 1 : gridSlots.length <= 4 ? 2 : 3; + const rows = Math.min(3, Math.ceil(Math.max(gridSlots.length, 1) / columns)); + const scrollGrid = !singleLivePreview && !singlePendingPreview && gridSlots.length > 9; + const fillGrid = !singleLivePreview && !singlePendingPreview && !scrollGrid; + + if (singlePendingPreview) { + return ( +
+
+ {title ?? `本批结果 · ${items.length} 张`} + {showClose ? ( + + ) : null} +
+
+
+ + 等待第一张预览 + 收到首帧后,这里会自动切成实时预览画面。 +
+
+
+ ); + } + return (
@@ -46,8 +73,12 @@ export function BatchResultGrid({ ) : null}
{gridSlots.map((slot, index) => { if (slot.type === "pending") { @@ -142,8 +173,8 @@ function PendingGridTile({ index, singleLayout }: { index: number; singleLayout:
{index + 1} - 等待预览 - {singleLayout ? 第一张流式预览出现后,会在这里实时刷新。 : null} + {singleLayout ? "等待第一张预览" : "等待预览"} + {singleLayout ? 收到首帧后,这里会自动切成实时预览画面。 : null}
); } diff --git a/image-studio/frontend/src/components/common/PromptImportModal.tsx b/image-studio/frontend/src/components/common/PromptImportModal.tsx new file mode 100644 index 000000000..a17223ed0 --- /dev/null +++ b/image-studio/frontend/src/components/common/PromptImportModal.tsx @@ -0,0 +1,105 @@ +import { ExternalLink, Languages } from "lucide-react"; +import { Modal } from "./Modal"; +import type { PromptImportPayloadLike } from "../../platform/runtime/hostTypes"; + +function promptPreview(text?: { zh?: string; en?: string } | null) { + return { + zh: text?.zh?.trim() || "", + en: text?.en?.trim() || "", + }; +} + +function PreviewBlock({ + title, + text, +}: { + title: string; + text?: { zh?: string; en?: string } | null; +}) { + const preview = promptPreview(text); + return ( +
+
+ + {title} +
+
+
+
中文
+
+ {preview.zh || (空)} +
+
+
+
English
+
+ {preview.en || (空)} +
+
+
+
+ ); +} + +export function PromptImportModal({ + open, + payload, + resolvedSize, + onClose, + onConfirm, +}: { + open: boolean; + payload: PromptImportPayloadLike | null; + resolvedSize: string; + onClose: () => void; + onConfirm: () => void; +}) { + const aspectRatio = payload?.aspect_ratio?.trim() || "auto"; + + return ( + +
+
+
+ + 来源站点: prompts.sorry.ink +
+

+ 确认后将覆盖当前表单中的提示词、反向提示词与尺寸,不会改动参考图、模式、批处理或上游配置。 +

+
+ + + + +
+
+
站点比例
+
{aspectRatio}
+
+
+
应用后尺寸
+
{resolvedSize || "auto"}
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/image-studio/frontend/src/components/common/SavePromptModal.tsx b/image-studio/frontend/src/components/common/SavePromptModal.tsx index 6ac018904..fec1676b6 100644 --- a/image-studio/frontend/src/components/common/SavePromptModal.tsx +++ b/image-studio/frontend/src/components/common/SavePromptModal.tsx @@ -191,9 +191,9 @@ export function SavePromptModal() { title={`本次结果 · ${batchItems.length} 张`} width={isAndroidPhone ? 420 : 860} cardClassName={!isAndroidPhone ? "max-w-[92vw]" : ""} - bodyClassName="space-y-4" + bodyClassName="flex min-h-0 flex-col gap-4" > -
+

@@ -221,7 +221,7 @@ export function SavePromptModal() {

-
+
- {renderDragImageNode()} + {renderDragImageNode("h-full w-full", "h-full w-full object-cover")} {isCompare ? B : null}
-

{item.prompt || "(无 prompt)"}

- +
+

{item.prompt || "(无 prompt)"}

+ +
-
@@ -194,13 +196,13 @@ export function HistoryTile({ className={`windows-history-row ${isCurrent ? "active" : ""} ${isCompare ? "compare" : ""}`} >
- {renderDragImageNode("h-full w-full")} + {renderDragImageNode("h-full w-full", "h-full w-full object-cover")}

{item.prompt || "(无 prompt)"}

- - + + {isCompare ? B : null}
@@ -208,17 +210,6 @@ export function HistoryTile({ -
); diff --git a/image-studio/frontend/src/components/panel/CustomSizeModal.tsx b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx index bcc3cb55d..c7d24c33b 100644 --- a/image-studio/frontend/src/components/panel/CustomSizeModal.tsx +++ b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx @@ -45,9 +45,9 @@ export function CustomSizeModal() { return `请保持宽高都在 ${MIN_EXACT_SIZE} 到 ${MAX_EXACT_SIZE}px 之间,并满足最长边不超过 ${MAX_EXACT_SIZE}px。`; } if (normalized.width !== width || normalized.height !== height) { - return `当前输入会被收口为 ${normalized.width}×${normalized.height},因为上游要求最长边不超过 ${MAX_EXACT_SIZE}px、宽高比不超过 ${MAX_OPENAI_IMAGE_ASPECT_RATIO}:1、总像素不超过 ${MAX_OPENAI_IMAGE_PIXELS}。`; + return `当前输入会被收口为 ${normalized.width}×${normalized.height}。应用会自动按最接近的合法值修正,因为上游要求宽高都能被 16 整除,同时最长边不超过 ${MAX_EXACT_SIZE}px、宽高比不超过 ${MAX_OPENAI_IMAGE_ASPECT_RATIO}:1、总像素不超过 ${MAX_OPENAI_IMAGE_PIXELS}。`; } - return `当前尺寸满足上游限制:最长边 <= ${MAX_EXACT_SIZE}px,宽高比 <= ${MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素 <= ${MAX_OPENAI_IMAGE_PIXELS}。`; + return `当前尺寸满足上游限制:宽高都能被 16 整除,最长边 <= ${MAX_EXACT_SIZE}px,宽高比 <= ${MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素 <= ${MAX_OPENAI_IMAGE_PIXELS}。`; }, [heightInput, widthInput]); const handleApply = (event?: FormEvent) => { @@ -78,7 +78,7 @@ export function CustomSizeModal() {
设置精确尺寸
- 请输入 {MIN_EXACT_SIZE} 到 {MAX_EXACT_SIZE} 之间的整数像素值。最长边不能超过 {MAX_EXACT_SIZE}px,宽高比不能超过 {MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素不能超过 {MAX_OPENAI_IMAGE_PIXELS}。 + 请输入 {MIN_EXACT_SIZE} 到 {MAX_EXACT_SIZE} 之间的整数像素值。应用会自动收口到最接近的 16 倍数合法尺寸;最长边不能超过 {MAX_EXACT_SIZE}px,宽高比不能超过 {MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素不能超过 {MAX_OPENAI_IMAGE_PIXELS}。
{ratioHint ? ( diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index 289cf8604..59c22a280 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -121,8 +121,8 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void 生成失败 / 504 / 524 怎么办?

- 上游网关超时(Cloudflare 504/524)在中转站上很常见。本应用默认自动重试 3 次,每次间隔 15 秒。 - 你也可以在「设置 → 失败自动重试」里关闭它,避免测试问题时重复扣费。 + 上游网关波动或中转临时抖动很常见。本应用默认会对403 / 502 / 503 / 504 / 524及可重试网络错误自动重试, + 默认额外重试 5 次,每次间隔 15 秒。你也可以在「设置 → 失败自动重试」里关闭或调整次数,避免测试问题时重复扣费。 如果三次都失败:

    diff --git a/image-studio/frontend/src/components/panel/ParameterPresetsSection.tsx b/image-studio/frontend/src/components/panel/ParameterPresetsSection.tsx index c013f57aa..58f703220 100644 --- a/image-studio/frontend/src/components/panel/ParameterPresetsSection.tsx +++ b/image-studio/frontend/src/components/panel/ParameterPresetsSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { FolderCog, Plus, Save } from "lucide-react"; -import { findMatchingPresetId, nextDefaultPresetName, pickPresetStateSnapshot } from "../../lib/presets"; +import { findMatchingPresetId, nextDefaultPresetName, normalizeSelectedPresetId, pickPresetStateSnapshot } from "../../lib/presets"; import { usePlatform } from "../../platform/context"; import { useStudioStore } from "../../state/studioStore"; import type { Preset } from "../../types/domain"; @@ -26,6 +26,8 @@ export function ParameterPresetsSection({ const state = useStudioStore(); const { presets, + selectedPresetId, + setField, savePreset, overwritePreset, updatePreset, @@ -33,12 +35,12 @@ export function ParameterPresetsSection({ deletePreset, } = state; const { usesFluentUI } = usePlatform(); - const [selectedPresetId, setSelectedPresetId] = useState(null); const [managerOpen, setManagerOpen] = useState(false); const [saveModalMode, setSaveModalMode] = useState<"save-current" | "new" | null>(null); const currentSnapshot = pickPresetStateSnapshot(state); const matchedPresetId = findMatchingPresetId(presets, currentSnapshot); - const selectedPreset = presets.find((preset) => preset.id === selectedPresetId) ?? null; + const normalizedSelectedPresetId = normalizeSelectedPresetId(presets, selectedPresetId); + const selectedPreset = presets.find((preset) => preset.id === normalizedSelectedPresetId) ?? null; const currentPreset = selectedPreset ?? presets.find((preset) => preset.id === matchedPresetId) ?? null; const isAndroid = variant === "android"; const cardRadius = isAndroid ? "rounded-[18px]" : usesFluentUI ? "rounded-[10px]" : "rounded-[16px]"; @@ -46,19 +48,19 @@ export function ParameterPresetsSection({ const suggestedName = useMemo(() => nextDefaultPresetName(presets), [presets]); useEffect(() => { - if (selectedPresetId && presets.some((preset) => preset.id === selectedPresetId)) return; - setSelectedPresetId(matchedPresetId); - }, [selectedPresetId, matchedPresetId, presets]); + if (normalizedSelectedPresetId === selectedPresetId) return; + setField("selectedPresetId", normalizedSelectedPresetId); + }, [normalizedSelectedPresetId, selectedPresetId, setField]); function handleApplyPreset(id: string) { - setSelectedPresetId(id); + setField("selectedPresetId", id); applyPreset(id); } function handleSaveAsNewPreset(name: string) { const id = savePreset(name); if (id) { - setSelectedPresetId(id); + setField("selectedPresetId", id); } return id; } @@ -76,13 +78,18 @@ export function ParameterPresetsSection({ matchedPresetName: presets.find((preset) => preset.id === matchedPresetId)?.name ?? null, selectedPreset, }); + const actionGridClassName = isAndroid + ? "grid-cols-1 gap-2.5" + : usesFluentUI + ? "windows-preset-actions" + : "grid-cols-3 gap-2"; return ( <> -
    -
    -
    - +
    +
    +
    + 参数预设 0 ? "bg-[var(--accent)] shadow-[0_0_6px_rgb(0_122_255_/_0.45)]" : "bg-zinc-300 dark:bg-zinc-600"}`} /> @@ -90,11 +97,11 @@ export function ParameterPresetsSection({ {presets.length > 0 ? `已保存 ${presets.length} 条` : "未保存"}
    - 当前方案 + 当前方案
    -
    +
    {summary.title}
    {summary.detail}
    @@ -107,11 +114,11 @@ export function ParameterPresetsSection({ {presets.length > 0 ? (
    ) : ( -
    +
    还没有保存任何预设。先调好参数,再新建一条。
    )} -
    +
    } onClick={() => setSaveModalMode("save-current")}> 保存当前预设 @@ -159,10 +166,10 @@ export function ParameterPresetsSection({ setManagerOpen(false)} onDeletePreset={(id) => { - if (selectedPresetId === id) setSelectedPresetId(null); + if (normalizedSelectedPresetId === id) setField("selectedPresetId", null); deletePreset(id); }} onUpdatePreset={handleUpdatePreset} @@ -186,7 +193,7 @@ function ActionButton({
    -