diff --git a/.github/workflows/live-verify-platform-kernel.yml b/.github/workflows/live-verify-platform-kernel.yml index 11fd2a1db..215f4090d 100644 --- a/.github/workflows/live-verify-platform-kernel.yml +++ b/.github/workflows/live-verify-platform-kernel.yml @@ -26,4 +26,26 @@ jobs: IMAGE_STUDIO_API_KEY: ${{ secrets.IMAGE_STUDIO_API_KEY }} IMAGE_STUDIO_TEXT_MODEL_ID: ${{ secrets.IMAGE_STUDIO_TEXT_MODEL_ID }} IMAGE_STUDIO_IMAGE_MODEL_ID: ${{ secrets.IMAGE_STUDIO_IMAGE_MODEL_ID }} + IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/live-verify-results run: node ./scripts/live-verify.mjs + + - name: Upload live verify results + if: always() + uses: actions/upload-artifact@v4 + with: + name: live-verify-platform-kernel-results + path: .tmp/live-verify-results + if-no-files-found: warn + + - name: Render live verify job summary + if: always() + run: | + if [ -f .tmp/live-verify-results/live-verify.json ]; then + node ./scripts/render-live-verify-summary.mjs .tmp/live-verify-results/live-verify.json >> "$GITHUB_STEP_SUMMARY" + else + { + echo "## Live Verify Platform Kernel" + echo + echo "- result file missing: \`.tmp/live-verify-results/live-verify.json\`" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d87c592e7..15fbabb3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,8 +169,22 @@ jobs: echo "artifact_path=${artifact_path}" } >> "$GITHUB_OUTPUT" + - name: Detect Windows signing inputs + id: windows_signing + if: matrix.goos == 'windows' + shell: bash + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + run: | + if [ -n "$IMAGE_STUDIO_WINDOWS_CERT_BASE64" ] && [ -n "$IMAGE_STUDIO_WINDOWS_CERT_PASSWORD" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - name: Sign Windows artifact - if: matrix.goos == 'windows' && secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 != '' && secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD != '' + if: matrix.goos == 'windows' && steps.windows_signing.outputs.available == 'true' shell: pwsh env: IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} @@ -180,7 +194,7 @@ jobs: ./scripts/sign-windows-binary.ps1 -Path "${{ steps.package.outputs.artifact_path }}" - name: Warn on unsigned Windows artifacts - if: matrix.goos == 'windows' && (secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 == '' || secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD == '') + if: matrix.goos == 'windows' && steps.windows_signing.outputs.available != 'true' shell: bash run: | echo "::warning::Windows artifact is unsigned. Windows 11 Smart App Control / SmartScreen may block it." @@ -192,6 +206,261 @@ jobs: path: ${{ steps.package.outputs.artifact_path }} retention-days: 14 + build-windows-installer: + name: Build windows universal installer + runs-on: windows-latest + needs: + - prepare-version + - build-desktop + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Windows desktop artifacts + uses: actions/download-artifact@v4 + with: + path: dist + pattern: image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-*.exe + + - name: Prepare installer inputs + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path image-studio/build/bin | Out-Null + Copy-Item "dist/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-amd64.exe/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-amd64.exe" "image-studio/build/bin/image-studio-amd64.exe" + Copy-Item "dist/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-arm64.exe/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-arm64.exe" "image-studio/build/bin/image-studio-arm64.exe" + + - name: Install NSIS + shell: pwsh + run: | + choco install nsis --no-progress -y + $nsisBin = Join-Path ${env:ProgramFiles(x86)} "NSIS" + if (-not (Test-Path (Join-Path $nsisBin "makensis.exe"))) { + throw "makensis.exe not found after NSIS install." + } + Add-Content -Path $env:GITHUB_PATH -Value $nsisBin + + - name: Build NSIS installer + working-directory: image-studio/build/windows/installer + shell: pwsh + run: | + $defines = @( + "/DINFO_PROJECTNAME=image-studio" + "/DINFO_COMPANYNAME=YuanHua" + "/DINFO_PRODUCTNAME=Image Studio" + "/DINFO_PRODUCTVERSION=${{ needs.prepare-version.outputs.product_version }}" + "/DINFO_COPYRIGHT=Copyright (c) 2026" + "/DARG_WAILS_AMD64_BINARY=..\..\bin\image-studio-amd64.exe" + "/DARG_WAILS_ARM64_BINARY=..\..\bin\image-studio-arm64.exe" + ) + & makensis @defines project.nsi + + - name: Stage installer artifact + id: package + shell: bash + run: | + mkdir -p dist/out + artifact_name="image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-installer.exe" + artifact_path="dist/out/${artifact_name}" + cp image-studio/build/bin/image-studio-amd64_arm64-installer.exe "$artifact_path" + ls -la dist/out/ + { + echo "artifact_name=${artifact_name}" + echo "artifact_path=${artifact_path}" + } >> "$GITHUB_OUTPUT" + + - name: Detect Windows signing inputs + id: windows_signing + shell: bash + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + run: | + if [ -n "$IMAGE_STUDIO_WINDOWS_CERT_BASE64" ] && [ -n "$IMAGE_STUDIO_WINDOWS_CERT_PASSWORD" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Sign Windows installer + if: steps.windows_signing.outputs.available == 'true' + shell: pwsh + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL: ${{ vars.IMAGE_STUDIO_WINDOWS_TIMESTAMP_URL }} + run: | + ./scripts/sign-windows-binary.ps1 -Path "${{ steps.package.outputs.artifact_path }}" + + - name: Warn on unsigned Windows installer + if: steps.windows_signing.outputs.available != 'true' + shell: bash + run: | + echo "::warning::Windows installer is unsigned. Windows 11 Smart App Control / SmartScreen may block it." + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.artifact_name }} + path: ${{ steps.package.outputs.artifact_path }} + retention-days: 14 + + build-windows-msix: + name: Build windows msix + runs-on: windows-latest + needs: + - prepare-version + - build-desktop + strategy: + fail-fast: false + matrix: + include: + - id: x64 + artifact_suffix: windows-x64.msix + input_artifact: windows-amd64.exe + input_binary: image-studio-amd64.exe + package_arch: x64 + - id: arm64 + artifact_suffix: windows-arm64.msix + input_artifact: windows-arm64.exe + input_binary: image-studio-arm64.exe + package_arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python packaging dependencies + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install pillow + + - name: Download desktop artifact + uses: actions/download-artifact@v4 + with: + path: dist + pattern: image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.input_artifact }} + + - name: Prepare msix input + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path image-studio/build/bin | Out-Null + Copy-Item "dist/image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.input_artifact }}/image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.input_artifact }}" "image-studio/build/bin/${{ matrix.input_binary }}" + + - name: Detect MSIX identity configuration + id: msix_identity + shell: bash + env: + IMAGE_STUDIO_MSIX_IDENTITY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_IDENTITY_NAME || 'Image-Studio.Image-Studio' }} + IMAGE_STUDIO_MSIX_PUBLISHER: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER || 'CN=D9AC33A5-F2A0-4194-9BCA-D17B6B918495' }} + IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME || 'Image-Studio' }} + run: | + if [ -n "$IMAGE_STUDIO_MSIX_IDENTITY_NAME" ] && [ -n "$IMAGE_STUDIO_MSIX_PUBLISHER" ] && [ -n "$IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build MSIX package + if: steps.msix_identity.outputs.available == 'true' + shell: pwsh + env: + IMAGE_STUDIO_MSIX_IDENTITY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_IDENTITY_NAME || 'Image-Studio.Image-Studio' }} + IMAGE_STUDIO_MSIX_PUBLISHER: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER || 'CN=D9AC33A5-F2A0-4194-9BCA-D17B6B918495' }} + IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME || 'Image-Studio' }} + run: | + ./scripts/build-msix.ps1 ` + -Architecture "${{ matrix.package_arch }}" ` + -Version "${{ needs.prepare-version.outputs.product_version }}" ` + -Publisher "$env:IMAGE_STUDIO_MSIX_PUBLISHER" ` + -IdentityName "$env:IMAGE_STUDIO_MSIX_IDENTITY_NAME" ` + -PublisherDisplayName "$env:IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME" ` + -SourceExe "image-studio/build/bin/${{ matrix.input_binary }}" ` + -OutputPath "dist/msix/image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.artifact_suffix }}" + + - name: Warn on skipped MSIX package + if: steps.msix_identity.outputs.available != 'true' + shell: bash + run: | + echo "::warning::Skipping MSIX packaging because IMAGE_STUDIO_MSIX_IDENTITY_NAME / IMAGE_STUDIO_MSIX_PUBLISHER / IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME are not all configured." + + - name: Upload MSIX artifact + if: steps.msix_identity.outputs.available == 'true' + uses: actions/upload-artifact@v4 + with: + name: image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.artifact_suffix }} + path: dist/msix/image-studio-${{ needs.prepare-version.outputs.app_version }}-${{ matrix.artifact_suffix }} + retention-days: 14 + + build-windows-msixbundle: + name: Build windows msixbundle + runs-on: windows-latest + needs: + - prepare-version + - build-windows-msix + steps: + - name: Detect MSIX identity configuration + id: msix_identity + shell: bash + env: + IMAGE_STUDIO_MSIX_IDENTITY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_IDENTITY_NAME || 'Image-Studio.Image-Studio' }} + IMAGE_STUDIO_MSIX_PUBLISHER: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER || 'CN=D9AC33A5-F2A0-4194-9BCA-D17B6B918495' }} + IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME: ${{ vars.IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME || 'Image-Studio' }} + run: | + if [ -n "$IMAGE_STUDIO_MSIX_IDENTITY_NAME" ] && [ -n "$IMAGE_STUDIO_MSIX_PUBLISHER" ] && [ -n "$IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + - name: Download MSIX artifacts + if: steps.msix_identity.outputs.available == 'true' + uses: actions/download-artifact@v4 + with: + path: dist/msix + pattern: image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-*.msix + + - name: Build MSIX bundle + if: steps.msix_identity.outputs.available == 'true' + shell: pwsh + run: | + $makeAppx = Get-ChildItem -Path (Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin") -Filter makeappx.exe -Recurse -ErrorAction Stop | + Where-Object { $_.FullName -match "\\x64\\makeappx\.exe$" } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $makeAppx) { + throw "makeappx.exe not found." + } + New-Item -ItemType Directory -Force -Path dist/msixbundle | Out-Null + $mappingLines = @( + "[Files]" + '"dist/msix/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-x64.msix\image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-x64.msix" "image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-x64.msix"' + '"dist/msix/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-arm64.msix\image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-arm64.msix" "image-studio-${{ needs.prepare-version.outputs.app_version }}-windows-arm64.msix"' + ) + Set-Content -LiteralPath dist/msixbundle/bundle.map -Value $mappingLines -Encoding ASCII + & $makeAppx.FullName bundle /f dist/msixbundle/bundle.map /p "dist/msixbundle/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows.msixbundle" /o + if ($LASTEXITCODE -ne 0) { + throw "makeappx bundle failed with exit code $LASTEXITCODE." + } + + - name: Warn on skipped MSIX bundle + if: steps.msix_identity.outputs.available != 'true' + shell: bash + run: | + echo "::warning::Skipping MSIX bundle because IMAGE_STUDIO_MSIX_IDENTITY_NAME / IMAGE_STUDIO_MSIX_PUBLISHER / IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME are not all configured." + + - name: Upload MSIX bundle artifact + if: steps.msix_identity.outputs.available == 'true' + uses: actions/upload-artifact@v4 + with: + name: image-studio-${{ needs.prepare-version.outputs.app_version }}-windows.msixbundle + path: dist/msixbundle/image-studio-${{ needs.prepare-version.outputs.app_version }}-windows.msixbundle + retention-days: 14 + build-android-apk: name: Build android apk runs-on: ubuntu-24.04 @@ -361,8 +630,22 @@ jobs: echo "artifact_path=${artifact_path}" } >> "$GITHUB_OUTPUT" + - name: Detect Windows signing inputs + id: windows_signing + if: matrix.goos == 'windows' + shell: bash + env: + IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} + IMAGE_STUDIO_WINDOWS_CERT_PASSWORD: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD }} + run: | + if [ -n "$IMAGE_STUDIO_WINDOWS_CERT_BASE64" ] && [ -n "$IMAGE_STUDIO_WINDOWS_CERT_PASSWORD" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - name: Sign Gio Windows artifact - if: matrix.goos == 'windows' && secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 != '' && secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD != '' + if: matrix.goos == 'windows' && steps.windows_signing.outputs.available == 'true' shell: pwsh env: IMAGE_STUDIO_WINDOWS_CERT_BASE64: ${{ secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 }} @@ -372,7 +655,7 @@ jobs: ./scripts/sign-windows-binary.ps1 -Path "${{ steps.package.outputs.artifact_path }}" - name: Warn on unsigned Gio Windows artifacts - if: matrix.goos == 'windows' && (secrets.IMAGE_STUDIO_WINDOWS_CERT_BASE64 == '' || secrets.IMAGE_STUDIO_WINDOWS_CERT_PASSWORD == '') + if: matrix.goos == 'windows' && steps.windows_signing.outputs.available != 'true' shell: bash run: | echo "::warning::Windows artifact is unsigned. Windows 11 Smart App Control / SmartScreen may block it." @@ -390,6 +673,9 @@ jobs: needs: - prepare-version - build-desktop + - build-windows-installer + - build-windows-msix + - build-windows-msixbundle - build-android-apk - build-gio-desktop if: startsWith(github.ref, 'refs/tags/v') diff --git a/.github/workflows/verify-issue-close-tooling.yml b/.github/workflows/verify-issue-close-tooling.yml new file mode 100644 index 000000000..eb89782cf --- /dev/null +++ b/.github/workflows/verify-issue-close-tooling.yml @@ -0,0 +1,65 @@ +name: Verify Issue Close Tooling + +on: + workflow_dispatch: + +jobs: + verify-issue-close-tooling: + name: Verify issue close tooling + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Prepare verification output directory + run: | + mkdir -p .tmp/issue-close-results + mkdir -p .tmp/issue-close-export + + - name: Verify issue close tooling + env: + GITHUB_TOKEN: ${{ github.token }} + IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/issue-close-results + run: node ./scripts/verify-issue-close-tooling.mjs + + - name: Export issue close comment bundle + if: always() + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + node ./scripts/issue-close-helper.mjs export .tmp/issue-close-export > .tmp/issue-close-export/export-result.json + + - name: Upload issue close tooling results + if: always() + uses: actions/upload-artifact@v4 + with: + name: verify-issue-close-tooling-results + path: .tmp/issue-close-results + if-no-files-found: warn + + - name: Upload issue close comment bundle + if: always() + uses: actions/upload-artifact@v4 + with: + name: verify-issue-close-comment-bundle + path: .tmp/issue-close-export + if-no-files-found: warn + + - name: Render issue close tooling job summary + if: always() + run: | + if [ -f .tmp/issue-close-results/issue-close-tooling.json ]; then + node ./scripts/render-issue-close-summary.mjs .tmp/issue-close-results/issue-close-tooling.json .tmp/issue-close-export/manifest.json >> "$GITHUB_STEP_SUMMARY" + else + { + echo "## Issue Close Tooling" + echo + echo "- result file missing: \`.tmp/issue-close-results/issue-close-tooling.json\`" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/verify-platform-kernel.yml b/.github/workflows/verify-platform-kernel.yml index ff7a33fe3..02a1cd411 100644 --- a/.github/workflows/verify-platform-kernel.yml +++ b/.github/workflows/verify-platform-kernel.yml @@ -52,5 +52,34 @@ jobs: mkdir -p .tmp/android-home/.android printf 'sdk.dir=%s\n' "$ANDROID_SDK_ROOT" > android-shell/local.properties + - name: Prepare verification output directory + run: mkdir -p .tmp/verify-results + - name: Verify local platform kernel suite + env: + IMAGE_STUDIO_SKIP_RUNTIME_UPDATE_PROBE: "1" + IMAGE_STUDIO_INCLUDE_ISSUE_CLOSE_VERIFY: "1" + IMAGE_STUDIO_SKIP_ISSUE_CLOSE_GITHUB_SYNC: "1" + IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/verify-results run: node ./scripts/verify-local-platform-kernel.mjs + + - name: Upload verification results + if: always() + uses: actions/upload-artifact@v4 + with: + name: verify-platform-kernel-results + path: .tmp/verify-results + if-no-files-found: warn + + - name: Render verification job summary + if: always() + run: | + if [ -f .tmp/verify-results/platform-kernel-summary.json ]; then + node ./scripts/render-verify-platform-summary.mjs .tmp/verify-results/platform-kernel-summary.json >> "$GITHUB_STEP_SUMMARY" + else + { + echo "## Verify Platform Kernel" + echo + echo "- summary file missing: \`.tmp/verify-results/platform-kernel-summary.json\`" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md index e2e3bc2f6..933a810ed 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 项目不内置任何默认上游。首次启动需要你自己填写 BASE_URL、API Key、文本模型与图像模型。 +当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 + ## 快速上手 1. 安装应用 @@ -52,7 +54,11 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推 | 应用展示、界面截图、能力概览 | [docs/showcase.md](./docs/showcase.md) | | 安装包下载、平台差异、产物选择 | [docs/packages.md](./docs/packages.md) | | 功能清单、平台能力、快捷键 | [docs/features.md](./docs/features.md) | +| 图生图批处理的入口、流程、输入输出规则 | [docs/batch-img2img/README.md](./docs/batch-img2img/README.md) | +| 当前 issue 处理进展与待验证项 | [docs/issue-progress.md](./docs/issue-progress.md) | +| 可直接复用的 issue 关单评论模板 | [docs/issue-close-comments.md](./docs/issue-close-comments.md) | | 源码构建、验证脚本、CI 产物链路 | [docs/build.md](./docs/build.md) | +| 真机 / 真实上游手工验证矩阵 | [docs/manual-verification.md](./docs/manual-verification.md) | | 首次配置、API 形态选择、参数策略 | [docs/usage.md](./docs/usage.md) | | 提 Issue 前自查、数据存储位置、524/504、模型权限、字段兼容问题 | [docs/troubleshooting.md](./docs/troubleshooting.md) | | 仓库结构、前端分层、内核 / Worker / Android 关系 | [docs/project-structure.md](./docs/project-structure.md) | diff --git a/android-shell/README.md b/android-shell/README.md index b2b7d1811..3c2089a6c 100644 --- a/android-shell/README.md +++ b/android-shell/README.md @@ -22,6 +22,31 @@ cd android-shell ./gradlew assembleRelease ``` +Local verification without a connected device: + +```bash +cd .. +node scripts/verify-local-android-shell.mjs +``` + +This script assembles the debug APK, checks `versionName` / `versionCode`, +verifies the APK signature, confirms the built frontend assets are embedded +into the package, and runs the Android JVM unit tests for shell-side parsing +logic. + +If you already have a device or emulator attached over `adb`, you can also ask +the script to install and launch the debug APK: + +```bash +IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 node scripts/verify-local-android-shell.mjs +``` + +Optionally pin a specific device: + +```bash +IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 IMAGE_STUDIO_ANDROID_SERIAL= node scripts/verify-local-android-shell.mjs +``` + MuMu emulator debugging: - See `../docs/mumu-android-debug.md` for the shared ADB connection, diff --git a/android-shell/app/build.gradle.kts b/android-shell/app/build.gradle.kts index e84941b9c..2d0e13a88 100644 --- a/android-shell/app/build.gradle.kts +++ b/android-shell/app/build.gradle.kts @@ -212,6 +212,7 @@ dependencies { implementation("com.google.android.material:material:1.12.0") implementation("androidx.webkit:webkit:1.11.0") testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20250517") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") } diff --git a/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt index 0127dc0ef..14f968159 100644 --- a/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt +++ b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidImageStudioBridge.kt @@ -58,6 +58,8 @@ class AndroidImageStudioBridge( companion object { private const val maxDialogReadBytes: Long = 50L * 1024L * 1024L private const val maxPreviewEdge = 384 + private const val generationConnectTimeoutMs = 30_000 + private const val generationReadTimeoutMs = 8 * 60 * 1000 } @JavascriptInterface @@ -435,8 +437,12 @@ class AndroidImageStudioBridge( val connection = openHttpConnection(url, proxyMode, proxyUrl).apply { requestMethod = method instanceFollowRedirects = true - connectTimeout = 30_000 - readTimeout = 180_000 + connectTimeout = generationConnectTimeoutMs + // Multi-image / high-resolution jobs can legitimately run + // for minutes after the last preview frame. Keeping the + // Android native HTTP bridge at only 180s makes it more + // likely to cut the stream before the final image arrives. + readTimeout = generationReadTimeoutMs doInput = true } httpRequests[requestKey] = connection @@ -457,22 +463,33 @@ class AndroidImageStudioBridge( } val status = connection.responseCode val stream = if (status >= 400) connection.errorStream else connection.inputStream + var streamResult: NativeHttpStreamResultSnapshot? = null val body = if (streamLines) { - val lines = mutableListOf() + val bodyBuilder = StringBuilder() stream?.bufferedReader()?.useLines { sequence -> sequence.forEach { line -> - lines.add(line) - emitNativeProgress(requestKey, mapOf("line" to line)) + bodyBuilder.append(line).append('\n') + if (streamResult == null) { + streamResult = extractNativeHttpStreamResult(line) + } + buildNativeHttpStreamProgressPayload(line)?.let { progressPayload -> + emitNativeProgress(requestKey, progressPayload) + } } } - if (lines.isEmpty()) "" else lines.joinToString(separator = "\n", postfix = "\n") + bodyBuilder.toString() } else { stream?.bufferedReader()?.use { it.readText() } ?: "" } + val rawPath = if (body.isNotBlank()) writeNativeHttpRawBody(body, connection.contentType ?: "", requestKey) else "" val result = mapOf( "status" to status, - "body" to body, + "body" to if (streamResult != null) "" else body, "contentType" to (connection.contentType ?: ""), + "rawPath" to rawPath, + "resultImageB64" to (streamResult?.imageB64 ?: ""), + "revisedPrompt" to (streamResult?.revisedPrompt ?: ""), + "sourceEvent" to (streamResult?.sourceEvent ?: ""), ) resolve(requestId, result) } catch (error: Exception) { @@ -484,6 +501,14 @@ class AndroidImageStudioBridge( throw EarlyResolve() } + private fun writeNativeHttpRawBody(body: String, contentType: String, requestKey: String): String { + val logDir = File(getOutputDir(), "log").apply { mkdirs() } + val ext = if (contentType.lowercase(Locale.US).contains("json")) "json" else "txt" + val file = File(logDir, "android-http-${timestamp()}-${sanitizeFileName(requestKey, "request")}.$ext") + file.writeText(body) + return file.absolutePath + } + private fun runProbeUpstream(requestId: String, payload: JSONObject): Nothing { val baseUrl = validateProbeBaseUrl(payload.optString("baseURL")) val apiKey = payload.optString("apiKey").trim() diff --git a/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidStreamEventParser.kt b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidStreamEventParser.kt new file mode 100644 index 000000000..b0a9c28dc --- /dev/null +++ b/android-shell/app/src/main/java/top/gptcodex/imagestudio/android/AndroidStreamEventParser.kt @@ -0,0 +1,92 @@ +package top.gptcodex.imagestudio.android + +import org.json.JSONObject + +internal data class NativeHttpStreamResultSnapshot( + val imageB64: String, + val revisedPrompt: String, + val sourceEvent: String, +) + +internal fun extractNativeHttpStreamResult(line: String): NativeHttpStreamResultSnapshot? { + val trimmed = line.trim() + if (!trimmed.startsWith("data: ")) return null + val payload = trimmed.removePrefix("data: ").trim() + if (payload.isBlank() || payload == "[DONE]") return null + return try { + val event = JSONObject(payload) + when (event.optString("type")) { + "response.output_item.done" -> { + val item = event.optJSONObject("item") + if (item?.optString("type") == "image_generation_call") { + val result = item.optString("result") + if (result.isNotBlank()) { + NativeHttpStreamResultSnapshot( + imageB64 = result, + revisedPrompt = item.optString("revised_prompt"), + sourceEvent = "final", + ) + } else null + } else null + } + "image_generation.completed", "image_edit.completed" -> { + val result = event.optString("b64_json") + if (result.isNotBlank()) { + NativeHttpStreamResultSnapshot( + imageB64 = result, + revisedPrompt = "", + sourceEvent = "images_api", + ) + } else null + } + else -> null + } + } catch (_: Exception) { + null + } +} + +internal fun buildNativeHttpStreamProgressPayload(line: String): Any? { + val trimmed = line.trim() + if (!trimmed.startsWith("data: ")) { + return mapOf("line" to line) + } + val payload = trimmed.removePrefix("data: ").trim() + if (payload.isBlank() || payload == "[DONE]") { + return mapOf("line" to line) + } + return try { + val event = JSONObject(payload) + when (event.optString("type")) { + "response.image_generation_call.partial_image" -> mapOf( + "event" to mapOf( + "type" to "response.image_generation_call.partial_image", + "partial_image_b64" to event.optString("partial_image_b64"), + "revised_prompt" to event.optString("revised_prompt"), + "partial_image_index" to if (event.has("partial_image_index")) event.optInt("partial_image_index", -1) else -1, + ), + ) + "image_generation.partial_image", "image_edit.partial_image" -> mapOf( + "event" to mapOf( + "type" to event.optString("type"), + "b64_json" to event.optString("b64_json"), + "partial_image_index" to if (event.has("partial_image_index")) event.optInt("partial_image_index", -1) else -1, + ), + ) + "response.output_item.done" -> { + val item = event.optJSONObject("item") + if (item?.optString("type") == "image_generation_call" && item.optString("result").isNotBlank()) { + null + } else { + mapOf("line" to line) + } + } + "image_generation.completed", "image_edit.completed" -> { + if (event.optString("b64_json").isNotBlank()) null else mapOf("line" to line) + } + else -> mapOf("line" to line) + } + } catch (_: Exception) { + mapOf("line" to line) + } +} diff --git a/android-shell/app/src/test/java/top/gptcodex/imagestudio/android/AndroidStreamEventParserTest.kt b/android-shell/app/src/test/java/top/gptcodex/imagestudio/android/AndroidStreamEventParserTest.kt new file mode 100644 index 000000000..423ffb9ed --- /dev/null +++ b/android-shell/app/src/test/java/top/gptcodex/imagestudio/android/AndroidStreamEventParserTest.kt @@ -0,0 +1,79 @@ +package top.gptcodex.imagestudio.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStreamEventParserTest { + @Test + fun `extracts final Responses image result`() { + val result = extractNativeHttpStreamResult( + """data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"b64-final","revised_prompt":"better prompt"}}""", + ) + + assertNotNull(result) + assertEquals("b64-final", result?.imageB64) + assertEquals("better prompt", result?.revisedPrompt) + assertEquals("final", result?.sourceEvent) + } + + @Test + fun `extracts final Images API result`() { + val result = extractNativeHttpStreamResult( + """data: {"type":"image_generation.completed","b64_json":"b64-images"}""", + ) + + assertNotNull(result) + assertEquals("b64-images", result?.imageB64) + assertEquals("", result?.revisedPrompt) + assertEquals("images_api", result?.sourceEvent) + } + + @Test + fun `builds progress payload for Responses partial image`() { + val payload = buildNativeHttpStreamProgressPayload( + """data: {"type":"response.image_generation_call.partial_image","partial_image_b64":"b64-preview","revised_prompt":"preview prompt","partial_image_index":2}""", + ) as Map<*, *> + + val event = payload["event"] as Map<*, *> + assertEquals("response.image_generation_call.partial_image", event["type"]) + assertEquals("b64-preview", event["partial_image_b64"]) + assertEquals("preview prompt", event["revised_prompt"]) + assertEquals(2, event["partial_image_index"]) + } + + @Test + fun `builds progress payload for Images API partial image`() { + val payload = buildNativeHttpStreamProgressPayload( + """data: {"type":"image_edit.partial_image","b64_json":"b64-preview","partial_image_index":1}""", + ) as Map<*, *> + + val event = payload["event"] as Map<*, *> + assertEquals("image_edit.partial_image", event["type"]) + assertEquals("b64-preview", event["b64_json"]) + assertEquals(1, event["partial_image_index"]) + } + + @Test + fun `suppresses final result from progress payload once image is complete`() { + val payload = buildNativeHttpStreamProgressPayload( + """data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"b64-final"}}""", + ) + + assertNull(payload) + } + + @Test + fun `passes through non data lines for raw log progress`() { + val payload = buildNativeHttpStreamProgressPayload("event: heartbeat") as Map<*, *> + assertEquals("event: heartbeat", payload["line"]) + } + + @Test + fun `returns line fallback for malformed JSON`() { + val payload = buildNativeHttpStreamProgressPayload("data: {not-json}") as Map<*, *> + assertTrue(payload.containsKey("line")) + } +} diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md new file mode 100644 index 000000000..b7d49dbce --- /dev/null +++ b/docs/batch-img2img/README.md @@ -0,0 +1,125 @@ +# 图生图批处理 + +本文档介绍桌面端图生图批处理模式的使用方式、行为边界和当前限制。 + +## 适用场景 + +适合这类需求: + +- 你有一整个文件夹的素材图。 +- 每张图都想套用同一组 prompt 和图生图参数。 +- 你希望应用按并发逐张处理,而不是手动一张张点。 + +这不是现有“多参考图”或“出图张数”的另一种说法。 + +- 多参考图:一次 edit 请求里带多张参考图,服务于同一张结果。 +- 出图张数:同一组参数重复发起多次请求,得到多张候选结果。 +- 批处理:对目录里的每一张源图分别发起独立的图生图任务。 + +## 入口位置 + +在桌面端控制面板中: + +1. 切换到 `图生图`。 +2. 找到 `源图片 / 参考图` 区域。 +3. 在顶部切换 `普通图生图 / 批处理`。 + +切到 `批处理` 后,手动参考图区会收起,改为批处理配置面板。 + +## 使用流程 + +1. 选择输入文件夹。 +2. 应用扫描当前目录中的图片文件。 +3. 确认扫描数量和示例文件名。 +4. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。 +5. 选择输出位置。 + - 默认保存回原目录:每张结果回到对应源图所在目录。 + - 独立输出路径:所有结果统一保存到你选定的目录。 +6. 设置并发数。 +7. 点击 `编辑` 开始处理。 + +## 输入规则 + +当前版本只处理你选择的这一层目录,不递归子目录。 + +支持的输入图片格式: + +- `png` +- `jpg` +- `jpeg` +- `webp` + +非图片文件和子目录会被忽略。 + +## 输出规则 + +### 默认输出 + +如果不额外指定输出目录,每张结果会保存到各自源图所在目录。 + +例如: + +- 源图: `/photos/a/cat.png` +- 结果默认保存到: `/photos/a/` + +### 独立输出目录 + +如果你在批处理面板里选择了独立输出目录,则所有结果会统一保存到该目录。 + +### 命名规则 + +结果文件默认使用: + +`processed-原文件名` + +例如: + +- `cat.png` -> `processed-cat.png` +- `portrait.webp` -> `processed-portrait.webp` + +如果目标目录已经有同名文件,会自动避重: + +- `processed-cat-2.png` +- `processed-cat-3.png` + +## 并发与运行行为 + +- 每张源图都会创建一个独立的图生图任务。 +- 并发数表示同时运行多少张,不是一次请求里上传多少张图。 +- 实际可并发数量仍受当前上游 profile 的并发限制约束。 +- 如果并发开得太高,应用可能会自动关闭流式预览,优先保证最终图完整。 + +## 参数继承 + +批处理会复用你当前图生图控制面板里的参数,包括: + +- prompt +- 比例 / 尺寸 +- 质量 +- 输出格式 +- seed +- negative prompt +- 背景、压缩率、审核等高级参数 + +对于混合比例素材,推荐保持 `Auto` 或按你的上游能力选择更宽松的尺寸策略。 + +## 当前限制 + +- 仅支持桌面端。 +- 仅扫描当前目录,不递归子目录。 +- 目录中的每张图都使用同一套参数,不支持按文件单独覆盖。 +- 批处理模式的输出文件会额外保存到目标目录,但应用内部历史仍会保留正常的生成记录,便于详情查看、历史筛选和再次复用。 + +## 什么时候不要用它 + +下面这些场景更适合继续用普通图生图: + +- 你只想编辑 1 到 2 张图。 +- 你需要手动挑多张参考图共同作用到一张结果上。 +- 你希望每张图都用不同的 prompt 或不同的参数。 + +## 相关文档 + +- [功能清单](../features.md) +- [使用与上游配置](../usage.md) +- [问题排查](../troubleshooting.md) diff --git a/docs/build.md b/docs/build.md index c4f0fdab0..0ce330361 100644 --- a/docs/build.md +++ b/docs/build.md @@ -197,23 +197,221 @@ GOPATH="../.gopath" GOMODCACHE="../.gomodcache" GOCACHE="../.gocache" go test ./ node scripts/verify-local-platform-kernel.mjs ``` -该脚本会跑前端测试 / 构建、Worker 测试、本地 smoke、Android debug assemble、Go 测试和 macOS 发布包验证。是否能全绿取决于本机 Android SDK / JDK 与 macOS 构建工具是否齐全。 +该脚本会跑前端测试 / 构建、Worker 测试、本地 live verify smoke、本地 smoke、Android shell 本地校验、Go 测试和 macOS 发布包验证。Go 部分会显式覆盖: + +- `image-studio` 模块 +- `shared/compat-go` +- `go-cli/pkg/client` + +是否能全绿取决于本机 Android SDK / JDK 与 macOS 构建工具是否齐全。 其他入口: ```bash +node scripts/verify-local-android-shell.mjs node scripts/verify-local-macos-release.mjs +node scripts/verify-local-live-verify.mjs node scripts/local-smoke-check.mjs node scripts/live-verify.mjs +node scripts/verify-issue-close-tooling.mjs +``` + +`verify-issue-close-tooling.mjs` 会校验: + +- `scripts/issue-close-data.json` +- `docs/issue-close-comments.md` +- `scripts/issue-close-helper.mjs` +- GitHub 当前 open issue 是否仍与本地“可关单 / 保持 open / defer”分组一致 +- mock GitHub 下的 `comment-only` / `comment-and-close` 执行路径 +- 导出评论包与 `manifest.json` / `plan.json` / `plan.md` + +如果你希望把这条校验也并入本地平台总链,可以显式开启: + +```bash +IMAGE_STUDIO_INCLUDE_ISSUE_CLOSE_VERIFY=1 node scripts/verify-local-platform-kernel.mjs +``` + +当前 CI 里的 `.github/workflows/verify-platform-kernel.yml` 已固定开启这条校验,但会设置: + +```text +IMAGE_STUDIO_SKIP_ISSUE_CLOSE_GITHUB_SYNC=1 +``` + +也就是: + +- 在常规 CI 里校验数据源 / helper / 渲染文档是否一致 +- 不把 GitHub 当前 open issue 状态变化变成每次 PR 的硬失败条件 + +本地独立运行时,如果环境里没有 `GITHUB_TOKEN` / `GH_TOKEN`,脚本也会自动切到同样的离线模式。 + +如果需要带 GitHub 当前 open issue 状态一起核对,可单独手动触发: + +- `.github/workflows/verify-issue-close-tooling.yml` + +该 workflow 会把 Actions 自带的 `github.token` 传给 `verify-issue-close-tooling.mjs`, +让只读 issue 列表查询优先走鉴权请求,降低公共限频带来的偶发失败概率。 + +它还会额外导出一份可直接使用的关单评论包 artifact,当前包含: + +- `manifest.json` +- `README.md` +- `plan.json` +- `plan.md` +- `issue-24.md` ... `issue-42.md` + +artifact 名称为: + +- `verify-issue-close-tooling-results` +- `verify-issue-close-comment-bundle` + +如果你想把多条验证结果统一写到同一个目录,优先使用: + +```bash +IMAGE_STUDIO_VERIFY_RESULTS_DIR=.tmp/verify-results node scripts/verify-local-platform-kernel.mjs +``` + +各脚本仍兼容原来的单文件 `*_OUTPUT_PATH` 变量;但新用法更推荐只传一个 +`IMAGE_STUDIO_VERIFY_RESULTS_DIR`。 + +对于当前仍需 Windows 真机、Android 真机或真实高并发上游才能确认的项,统一参考: + +- [manual-verification.md](./manual-verification.md) + +如果你想把当前最新总链证据、`#30/#36` 手工验证模板,以及 issue 关单评论包统一整理成一个交接目录,可直接执行: + +```bash +node scripts/prepare-external-verification-bundle.mjs +``` + +默认输出目录类似: + +```text +.tmp/external-verify-bundles//issue-30-36-handoff/ +``` + +其中会包含: + +- `evidence/` +- `manual-verify/` +- `issue-close-bundle/` +- `README.md` + +开始手工验证前,建议先用模板脚本初始化目录: + +```bash +node scripts/init-manual-verification.mjs 36-android +``` + +常用预设还有: + +```bash +node scripts/init-manual-verification.mjs 36-windows +node scripts/init-manual-verification.mjs 30-windows ``` +如果要做自定义验证主题: + +```bash +node scripts/init-manual-verification.mjs custom "my regression check" +``` + +脚本会生成 `report.md`、`meta.json` 以及 `screenshots/`、`raw/`、`logs/` 目录,方便把实机截图、原始响应和结论统一沉淀到 `.tmp/manual-verify//...` 下。 + +`verify-local-android-shell.mjs` 当前会校验: + +- `:app:testDebugUnitTest` 可完成,覆盖 Android 壳层流式事件解析等纯逻辑单测。 +- `:app:assembleDebug` 可完成。 +- `output-metadata.json` 与 APK `badging` 中的 `applicationId` / `versionCode` / `versionName` 正确。 +- APK 签名能通过 `apksigner verify -v`。 +- APK 内已经带上前端 `assets/index.html` 和构建后的静态资源。 + +如果本机已经连了 Android 设备或模拟器,还可以额外开启 APK 安装 / 启动 smoke: + +```bash +IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 node scripts/verify-local-android-shell.mjs +``` + +可选地指定设备序列号: + +```bash +IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 IMAGE_STUDIO_ANDROID_SERIAL= node scripts/verify-local-android-shell.mjs +``` + +未开启时,脚本会明确返回 `deviceSmoke.attempted=false`,不会因为没有设备而失败。 + +`verify-local-live-verify.mjs` 会在本地启动 `runtime-smoke-server.mjs` 作为 +mock upstream,再驱动 `live-verify.mjs` 完成一轮 direct vs worker parity。 +它的意义不是替代真实上游验证,而是证明: + +- `live-verify.mjs` 本身可以真实执行 +- parity checks / JSON 输出 / summary 渲染链条在本地是通的 + +`verify-local-live-verify.mjs` 会启动仓库内 `runtime-smoke-server.mjs` 作为 mock upstream, +再驱动 `live-verify.mjs` 走一遍 direct vs worker parity。它主要用于证明: + +- `live-verify.mjs` 本身可以真实执行,而不只是语法通过。 +- `live verify` 的 JSON 输出与 parity checks 在本地 smoke 环境下是可用的。 + +`verify-local-macos-release.mjs` 当前会额外校验: + +- `Info.plist` 的 `CFBundleVersion` / `CFBundleShortVersionString` 是否与 `VITE_APP_VERSION` 一致。 +- 编译后的 macOS App 在探针模式下启动后,是否会把“同 semver core 的 CI 版本不应提示更新”判断为 `false`。 + +如果当前环境没有可用的图形桌面会话,Wails GUI 进程可能无法启动。这种情况下可以先跳过运行时探针: + +```bash +IMAGE_STUDIO_SKIP_RUNTIME_UPDATE_PROBE=1 node scripts/verify-local-macos-release.mjs +``` + +正常的编译版更新判断联调,仍建议在交互式 macOS 桌面会话里完整运行,不跳过探针。 + +当前 `.github/workflows/verify-platform-kernel.yml` 会固定设置 +`IMAGE_STUDIO_SKIP_RUNTIME_UPDATE_PROBE=1`,因为 GitHub Actions 的 macOS runner +不保证可用的交互式桌面会话。 + +该 workflow 还会把结构化验证结果上传为 artifact: + +- `verify-platform-kernel-results/platform-kernel-summary.json` +- `verify-platform-kernel-results/live-verify.json` +- `verify-platform-kernel-results/local-smoke.json` +- `verify-platform-kernel-results/android-shell.json` +- `verify-platform-kernel-results/macos-release.json` + +即使验证步骤失败,artifact 上传步骤也会继续执行,尽量保留已生成的 +summary 和子结果;如果某个结果文件根本没产出,只会给出 warning,不会 +覆盖原始失败原因。 + +另外,workflow 会把 `platform-kernel-summary.json` 渲染成 Markdown,直接写到 +GitHub Actions job summary,方便不下载 artifact 时先看关键结果。summary +里会带上 Node / 平台 / Android SDK / `JAVA_HOME` 等环境指纹。 + 真实上游对比验证需要先按 `scripts/live-verify.env.example` 准备 `.env.live` 或 `.env.local`。 +`live-verify-platform-kernel.yml` 会把 `scripts/live-verify.mjs` 的结果写成 +JSON artifact,并渲染到 job summary。当前 artifact 名称为: + +- `live-verify-platform-kernel-results/live-verify.json` + +和本地平台验证 workflow 一样,artifact 上传与 summary 渲染都放在 +`always()` 步骤里;如果 live verify 中途失败,仍会尽量保留已经写出的 +JSON 结果和失败摘要。live verify 的 summary 也会带上 Node / 平台 / 模型 ID / +worker 端口等环境指纹,并列出每条 parity check 的通过/失败情况。 + +同样,本地几条子验证脚本在中途失败时,也会尽量把已知环境和错误原因写进 +对应 JSON,方便在 artifact 之外单独排查: + +- `local-smoke-check.mjs` +- `verify-local-live-verify.mjs` +- `verify-local-android-shell.mjs` +- `verify-local-macos-release.mjs` + ## CI 当前发布链路在 `.github/workflows/release.yml`: - 并行构建 Windows、macOS、Linux Wails 桌面产物。 +- Windows 额外产出单个自适应架构的 NSIS installer `image-studio--windows-installer.exe`,内部同时包含 amd64 与 arm64 二进制,供正式安装分发或 Microsoft Store Win32 提交使用。 +- Windows 额外产出 `image-studio--windows-x64.msix`、`image-studio--windows-arm64.msix` 与 `image-studio--windows.msixbundle`,供 Microsoft Store / 企业分发使用。 - 单独构建一个 Android release APK。 - tag 为 `v*` 时将所有产物附加到 GitHub Release。 @@ -239,6 +437,31 @@ Windows 11 的 Smart App Control / SmartScreen 会重点拦截无法验证发布 签名步骤由 `scripts/sign-windows-binary.ps1` 执行,内部会调用 `signtool sign`、`signtool verify /pa /all` 和 `Get-AuthenticodeSignature` 做校验。 +### Microsoft Store MSIX 身份 + +MSIX / MSIXBundle 打包内置了当前 Microsoft Store 产品 `9P9DTWG1G93N` 的公开身份值,因此首次发布不需要额外配置 GitHub Actions variables: + +| 字段 | 当前值 | +|---|---| +| Identity Name | `Image-Studio.Image-Studio` | +| Package Display Name | `Image-Studio` | +| Publisher | `CN=D9AC33A5-F2A0-4194-9BCA-D17B6B918495` | +| Publisher Display Name | `Image-Studio` | + +如需切换到新的 Partner Center 产品,可以用 repository variables 覆盖内置默认值: + +| 变量 | 用途 | +|---|---| +| `IMAGE_STUDIO_MSIX_IDENTITY_NAME` | Partner Center 中保留的包 Identity Name。 | +| `IMAGE_STUDIO_MSIX_PUBLISHER` | Partner Center 对应的 Publisher,例如 `CN=...`,必须与商店身份完全一致。 | +| `IMAGE_STUDIO_MSIX_PUBLISHER_DISPLAY_NAME` | 商店展示的发布者名称。 | + +注意: + +- `IMAGE_STUDIO_MSIX_PUBLISHER` 不能自行猜测,必须直接使用 Partner Center / Store association 给出的值。 +- 当前内置身份值会让 release workflow 直接产出 `x64.msix`、`arm64.msix` 和 `windows.msixbundle`;只有在内置值被清空或覆盖为不完整配置时,MSIX 相关 job 才会跳过。 +- 面向 Microsoft Store 提交时可以保持未签名,由商店在提交后重新签名;如果你要本地侧载测试,则还需要额外签一个测试证书。 + 平台内核验证 workflow: - `.github/workflows/verify-platform-kernel.yml` diff --git a/docs/features.md b/docs/features.md index 320428d8c..abeecbbe8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,6 +5,7 @@ ## 生成能力 - 文生图与图生图,支持多张参考图。 +- 桌面端图生图批处理:可在图生图里切换到批处理模式,按目录逐张处理图片,默认保存回原图目录,也可单独指定输出目录。详见 [batch-img2img](./batch-img2img/README.md)。 - 输入图源:文件对话框、拖拽窗口、剪贴板粘贴、历史复用、双击历史项设为源图。 - 参数:Auto 与固定尺寸、Auto / high / medium / low 质量、PNG / JPEG / WebP 输出格式、seed、negative prompt、风格 chip。 - 尺寸语义:比例与分辨率是同一个 `size` 的拆分视图;当比例切到 `Auto` 时,底层尺寸会整体交给上游决定。细节见 [size-auto.md](./size-auto.md)。 diff --git a/docs/feedback.md b/docs/feedback.md index 4187d3566..f39523f97 100644 --- a/docs/feedback.md +++ b/docs/feedback.md @@ -2,7 +2,11 @@ 如果你在使用 Image Studio 时遇到问题,或希望讨论新功能、使用方式与兼容性,可以通过下面的渠道反馈。 -提 Issue 前请先看 [docs/troubleshooting.md](./troubleshooting.md)。 +提 Issue 前请先看: + +- [docs/troubleshooting.md](./troubleshooting.md) +- [docs/issue-progress.md](./issue-progress.md) +- [docs/issue-close-comments.md](./issue-close-comments.md) 很多看起来像“软件故障”的问题,实际来自: @@ -13,6 +17,15 @@ 如果这些因素还没有排除,先不要提交 Bug Issue。 +另外: + +- `issue-progress.md` 会记录当前仓库里哪些 GitHub issue 已经有代码覆盖、哪些还只缺实机验证。 +- `issue-close-comments.md` 会整理那些“当前代码已覆盖,但 GitHub 还没关”的 issue 关单评论模板。 +- `scripts/issue-close-helper.mjs export ...` 可以把这些评论、manifest 和处理计划一起导出成文件包,便于一次性处理。 +- `scripts/issue-close-helper.mjs plan` 可以先做 dry-run,确认哪些 issue 会被评论或关闭。 + +提交前先看一眼,可以避免重复反馈已经落地但 issue 还没关闭的条目。 + ## GitHub Issues 适合提交: diff --git a/docs/issue-close-comments.md b/docs/issue-close-comments.md new file mode 100644 index 000000000..f0563779c --- /dev/null +++ b/docs/issue-close-comments.md @@ -0,0 +1,268 @@ +# Issue 关单评论模板 + +更新时间:2026-06-07 + +本文档只覆盖当前 **GitHub 仍 open,但当前仓库代码与本地验证已经覆盖** 的 issue。 + +适用范围: + +- `#24` +- `#25` +- `#26` +- `#27` +- `#28` +- `#32` +- `#35` +- `#37` +- `#40` +- `#42` + +统一验证基线: + +- 2026-06-07 已重新跑通本地平台总链: + - `IMAGE_STUDIO_INCLUDE_ISSUE_CLOSE_VERIFY=1 IMAGE_STUDIO_VERIFY_RESULTS_DIR=/private/tmp/verify-results-issue-close-final4 node scripts/verify-local-platform-kernel.mjs` + - `platform-kernel-summary.json`:`status = passed` + - 结构化结果: + - `/private/tmp/verify-results-issue-close-final4/platform-kernel-summary.json` + - `/private/tmp/verify-results-issue-close-final4/live-verify.json` + - `/private/tmp/verify-results-issue-close-final4/local-smoke.json` + - `/private/tmp/verify-results-issue-close-final4/android-shell.json` + - `/private/tmp/verify-results-issue-close-final4/macos-release.json` + - `/private/tmp/verify-results-issue-close-final4/issue-close-tooling.json` + - `/private/tmp/verify-results-issue-close-final4/issue-close-export-bundle/manifest.json` +- 前端测试当前为 `114/114` 通过。 + +建议用法: + +1. 先确认对应功能没有被后续改坏。 +2. 复制下面对应 issue 的评论模板。 +3. 如仓库维护方式允许,评论后可直接关闭 issue。 + +也可以直接使用本地 helper: + +```bash +node scripts/issue-close-helper.mjs list +node scripts/issue-close-helper.mjs comment 24 +node scripts/issue-close-helper.mjs plan +node scripts/issue-close-helper.mjs verify-open +node scripts/issue-close-helper.mjs export .tmp/issue-close-export/manual-check +node scripts/render-issue-close-comments.mjs --write +``` + +`export` 产物当前会包含: + +- `manifest.json` +- `README.md` +- `plan.json` +- `plan.md` +- `issue-24.md` ... `issue-42.md` + +如果只是想先确认“当前哪些 issue 会被处理、处理方式是什么”,优先用: + +```bash +node scripts/issue-close-helper.mjs plan +node scripts/issue-close-helper.mjs plan 24 25 --comment-only +``` + +`apply` 默认不会执行。只有显式提供 `--execute`,并且环境里存在 `GITHUB_TOKEN` 或 `GH_TOKEN` 时,才会真正对 GitHub 发评论或关闭 issue。例如: + +```bash +node scripts/issue-close-helper.mjs apply 24 --comment-only --execute +node scripts/issue-close-helper.mjs apply all --comment-and-close --execute +``` + +建议顺序: + +1. 先跑 `verify-open` 确认当前 open issue 状态没漂移。 +2. 再跑 `plan` 看本次准备处理的 issue 列表。 +3. 最后才决定是否真的执行 `apply`。 + +## `#24` 支持全部删除 + +Issue: [#24](https://github.com/RoseKhlifa/Image-Studio/issues/24) + +```md +这个需求当前仓库已经覆盖。 + +- 历史结果列表已支持“全部删除”。 +- 主要落点在 `image-studio/frontend/src/components/history/HistoryRail.tsx`。 +- 当前本地验证链已重新通过,2026-06-07 的 `platform-kernel` 总链状态为 `passed`。 + +如果你这边没有新的复现条件,这个 issue 可以关闭。 +``` + +## `#25` 上游配置升级后的同步问题 + +Issue: [#25](https://github.com/RoseKhlifa/Image-Studio/issues/25) + +```md +这个问题当前仓库已经做了两层处理: + +- 已支持上游配置导入 / 导出。 +- 兼容状态会落到宿主侧共享路径,减轻更新后重新录入配置的问题。 + +主要落点: + +- `image-studio/frontend/src/lib/upstreamConfigTransfer.ts` +- `image-studio/backend/compatibility.go` + +本地验证链已在 2026-06-07 重新通过。如果你这边没有新的“升级后配置丢失”复现路径,这个 issue 可以关闭。 +``` + +## `#26` 失败重试开关 + +Issue: [#26](https://github.com/RoseKhlifa/Image-Studio/issues/26) + +```md +这个需求当前仓库已经覆盖。 + +- 设置页已提供全局“失败自动重试”开关。 +- 桌面端和 Android 端都已有入口。 + +主要落点: + +- `image-studio/frontend/src/components/panel/SettingsPanel.tsx` +- `image-studio/frontend/src/platform/android/settings/AndroidSettingsPanel.tsx` + +当前本地验证链已重新通过。如果没有新的行为差异,这个 issue 可以关闭。 +``` + +## `#27` 上游失败重试路由功能 + +Issue: [#27](https://github.com/RoseKhlifa/Image-Studio/issues/27) + +```md +这个需求当前仓库已经覆盖。 + +- 失败重试现在可以路由到用户指定的备用上游。 +- 不是只做“原上游再试一次”,而是支持切到备用 profile。 + +主要落点: + +- `go-cli/pkg/client/retry.go` +- `image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx` + +本地验证链已重新通过。如果没有新的复现场景,这个 issue 可以关闭。 +``` + +## `#28` 保存用户自定义提示词模板 + +Issue: [#28](https://github.com/RoseKhlifa/Image-Studio/issues/28) + +```md +这个功能当前仓库已经覆盖。 + +已支持: + +- 添加提示词模板 +- 删除提示词模板 +- 修改并保存模板 +- 自定义模板标题 +- 点击模板快速填入提示词 + +主要落点: + +- `image-studio/frontend/src/lib/promptTemplates.ts` +- `image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx` + +本地测试与验证链已重新通过。如果没有新的补充需求,这个 issue 可以关闭。 +``` + +## `#32` 拖结果到文件复制 + +Issue: [#32](https://github.com/RoseKhlifa/Image-Studio/issues/32) + +```md +这个需求当前仓库已经覆盖。 + +- 桌面端结果图已支持直接拖出到系统文件管理器复制。 +- 导出逻辑优先走真实结果文件 / 全尺寸媒体路径,不是只拖缩略图。 + +主要落点: + +- `image-studio/backend/dialogs.go` +- `image-studio/frontend/src/lib/dragExport.ts` + +本地验证链已重新通过。如果你这边没有新的拖拽失败复现,这个 issue 可以关闭。 +``` + +## `#35` 工作区的鼠标滚动方向改为横向 + +Issue: [#35](https://github.com/RoseKhlifa/Image-Studio/issues/35) + +```md +这个修正当前仓库已经覆盖。 + +- 多工作区场景下,工作区栏滚轮方向已改为横向滚动。 + +主要落点: + +- `image-studio/frontend/src/components/layout/WorkspaceBar.tsx` + +本地测试与验证链已重新通过。如果没有新的滚动行为问题,这个 issue 可以关闭。 +``` + +## `#37` 结果里生成的多张图片查看问题 + +Issue: [#37](https://github.com/RoseKhlifa/Image-Studio/issues/37) + +```md +这个需求当前仓库已经覆盖。 + +- 当前已支持在画板里对同批生成结果做左右切换浏览。 +- 不需要再回到结果列表逐张找。 + +主要落点: + +- `image-studio/frontend/src/components/canvas/useCanvasShortcuts.ts` +- `image-studio/frontend/src/state/studioStore.media.ts` + +本地测试与验证链已重新通过。如果没有新的查看路径问题,这个 issue 可以关闭。 +``` + +## `#40` 多图张数按钮选择 + +Issue: [#40](https://github.com/RoseKhlifa/Image-Studio/issues/40) + +```md +这个需求当前仓库已经覆盖。 + +- Android 端多图张数选择已从滑杆改为带边界的按钮选择。 + +主要落点: + +- `image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx` +- `image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx` + +本地验证链已重新通过。如果没有新的交互差异,这个 issue 可以关闭。 +``` + +## `#42` 一些功能需求 + +Issue: [#42](https://github.com/RoseKhlifa/Image-Studio/issues/42) + +```md +这个需求集合当前仓库已覆盖主要条目。 + +已支持: + +1. 有参考图时,图生图默认切到 `Auto` 比例。 +2. 会根据参考图比例推导最接近的 2K / 4K 分辨率。 +3. 支持自定义提示词模板与快捷填入。 +4. 已支持结果图与参考图对比查看。 + +主要落点: + +- `image-studio/frontend/src/components/panel/sizeCapabilities.ts` +- `image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx` +- `image-studio/frontend/src/components/canvas/CompareOverlay.tsx` + +本地验证链已重新通过。如果没有新的补充项或复现场景,这个 issue 可以关闭。 +``` + +## 暂不建议关闭的 open issue + +- `#30`:代码已修,但仍缺 Windows 真机视觉确认。 +- `#36`:核心修复已落地,但仍缺 Android / Windows 真机和真实高并发上游长链路证明。 +- `#23`:按当前任务约定视为上游问题,不纳入本轮收口。 +- `#14`:独立在线 Web 版已明确搁置。 diff --git a/docs/issue-progress.md b/docs/issue-progress.md new file mode 100644 index 000000000..2a903238b --- /dev/null +++ b/docs/issue-progress.md @@ -0,0 +1,384 @@ +# Issue 进展记录 + +更新时间:2026-06-07 + +GitHub issue 状态已按 2026-06-07 当天的 GitHub API 重新核对。 + +## 全部 issue 总表 + +下表只统计 GitHub 上真正的 issue,不包含 PR 编号。 + +| 编号 | 标题 | GitHub 状态 | 当前仓库状态 | 下一步 | +|---|---|---|---|---| +| `#1` | Releases v0.1.0 里没有包含 `image-studio-windows-amd64.exe` | closed | 已关闭 | 无 | +| `#2` | 比例增加 auto / 自定义分辨率 | closed | 已关闭 | 无 | +| `#3` | 无法识别透明背景参考图 | closed | 已关闭 | 无 | +| `#4` | 无法并发生图 | closed | 已关闭 | 无 | +| `#9` | 多张出图只会保存一张 | closed | 已关闭 | 无 | +| `#10` | 无法右击已生成的图片 | closed | 已关闭 | 无 | +| `#11` | 点击全屏按钮会黑屏 | closed | 已关闭 | 无 | +| `#12` | `gpt-image-2` 也会优化提示词且无法关闭 | closed | 已关闭 | 无 | +| `#13` | `/v1/images/edits` 的 mask MIME 类型问题 | closed | 已关闭 | 无 | +| `#14` | 有 web 端吗 | open | 已明确“无独立在线 Web 版”,当前目标也已搁置 | 保持搁置,不继续推进 | +| `#15` | 点击历史图后提示词保留可复制修改 | closed | 已关闭 | 无 | +| `#18` | 点了“比例/分辨率自动”后无法再更改 | closed | 已关闭 | 无 | +| `#20` | macOS 版本无法删除配置 | closed | 已关闭 | 无 | +| `#21` | PC 端 log 文件自动清除 | closed | 已关闭 | 无 | +| `#22` | relay 已支持 `b64_json` 但程序提示不支持 | closed | 已关闭 | 无 | +| `#23` | 不生图 | open | 按当前任务约定视为上游问题,不纳入本轮收口 | 保持不处理 | +| `#24` | 支持全部删除 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#25` | 上游配置升级后的同步问题 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#26` | 失败重试开关 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#27` | 上游失败重试路由功能 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#28` | 保存用户自定义提示词模板 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#29` | 生图完成时触发提示音 | closed | 已关闭 | 无 | +| `#30` | 标题背景与标题栏颜色不一致 | open | 代码已修,但仍缺 Windows 真机视觉确认 | 需要 Windows 真机手工验证 | +| `#32` | 拖结果到文件复制 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#33` | 点击参考图缩略图无法查看大图 | closed | 已关闭 | 无 | +| `#34` | 应用参数未包含参考图 | closed | 已关闭 | 无 | +| `#35` | 工作区滚动方向改为横向 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#36` | 多图并发时模糊 / 只出轮廓 | open | 核心修复已落地,但仍缺真实上游与真机长链路证明 | 需要 Android / Windows 真机 + 真实高并发上游复核 | +| `#37` | 结果多图希望能左右键浏览 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#40` | 多图张数按钮选择 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | +| `#42` | 一些图生图 / 模板 / 对比功能需求 | open | 已有代码覆盖 | 如允许维护 issue,可直接关闭或补评论 | + +这份文档只记录当前仓库代码与本地验证状态,不代表 GitHub issue 已关闭。 + +## 按状态分组 + +### 已关闭的 issue + +`#1` `#2` `#3` `#4` `#9` `#10` `#11` `#12` `#13` `#15` `#18` `#20` `#21` `#22` `#29` `#33` `#34` + +这些 issue 当前 GitHub 状态已经是 `closed`,本地不需要再补额外动作。 + +### GitHub 仍 open,但当前代码已覆盖 + +`#24` `#25` `#26` `#27` `#28` `#32` `#35` `#37` `#40` `#42` + +这批 issue 的共同特点是: + +- 当前仓库已经有对应实现。 +- 本地验证链已经覆盖到主要行为。 +- 没有新的外部前置条件阻塞。 + +如果允许直接维护 GitHub issue,优先建议先清这一批,避免后续重复盘点。 + +可直接复用的评论模板见 [issue-close-comments.md](./issue-close-comments.md)。 + +当前这批 open issue 还额外有一层本地工具链保证: + +- 数据源:`scripts/issue-close-data.json` +- helper:`scripts/issue-close-helper.mjs` +- 独立校验:`scripts/verify-issue-close-tooling.mjs` + +截至 2026-06-07 的最新本地实证结果: + +- `closable = 10` +- `holdOpen = 2` +- `deferred = 2` +- `unexpectedOpen = 0` +- 最新总链结果目录: + - `/private/tmp/verify-results-issue-close-final4/platform-kernel-summary.json` + - `/private/tmp/verify-results-issue-close-final4/issue-close-tooling.json` + - `/private/tmp/verify-results-issue-close-final4/issue-close-export-bundle/manifest.json` + +另外,本地 issue 收口工具链已经在 mock GitHub 环境里补了两类真实执行证明: + +- `comment-only` 会发评论,但不会关闭 issue。 +- `comment-and-close` 会发评论,并发送关闭 issue 的 patch。 +- 无 `GITHUB_TOKEN` / `GH_TOKEN` 时,独立校验会默认切到离线模式,不再因为 GitHub rate limit 打断本地验证。 + +也就是说: + +- 当前建议关闭的 open issue 仍然就是这 10 个。 +- 需要继续保留 open 的仍然只有 `#30` 和 `#36`。 +- 目前没有冒出新的、未被本地分组覆盖的 open issue。 + +## 已有代码覆盖 + +### `#42` 一些功能需求 + +当前代码已覆盖: + +- 参考图存在时,图生图默认切到 `Auto` 比例。 +- 会根据参考图比例推导最接近的 2K / 4K 分辨率。 +- 支持自定义提示词模板与快捷填入。 +- 结果图与参考图对比查看。 + +主要落点: + +- `image-studio/frontend/src/components/panel/sizeCapabilities.ts` +- `image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx` +- `image-studio/frontend/src/components/canvas/CompareOverlay.tsx` + +### `#40` 多图张数按钮选择 + +Android 端多图张数已从滑杆改为带边界的按钮选择。 + +主要落点: + +- `image-studio/frontend/src/platform/android/AndroidPhoneComposePanel.tsx` +- `image-studio/frontend/src/platform/android/AndroidPadComposePanel.tsx` + +### `#37` 结果里生成的多张图片查看问题 + +当前代码已支持在画板里对同批结果做左右切换浏览。 + +主要落点: + +- `image-studio/frontend/src/components/canvas/useCanvasShortcuts.ts` +- `image-studio/frontend/src/state/studioStore.media.ts` + +### `#35` 工作区滚轮改横向 + +工作区滚轮方向已改为横向滚动。 + +主要落点: + +- `image-studio/frontend/src/components/layout/WorkspaceBar.tsx` + +### `#32` 拖结果到文件复制 + +桌面端结果图已支持拖出到文件管理器复制。 + +主要落点: + +- `image-studio/backend/dialogs.go` +- `image-studio/frontend/src/lib/dragExport.ts` + +### `#29` 生图完成时触发提示音 + +已支持: + +- 完成提示音开关。 +- 默认 / 自定义提示音切换。 +- 预览提示音。 +- 整批任务完成时只播放一次。 + +主要落点: + +- `image-studio/frontend/src/lib/completionSound.ts` +- `image-studio/frontend/src/components/panel/SettingsPanel.tsx` +- `image-studio/frontend/src/platform/android/settings/AndroidSettingsPanel.tsx` + +### `#28` 提示词模板 + +已支持添加、删除、修改、保存、点击填充提示词模板。 + +主要落点: + +- `image-studio/frontend/src/lib/promptTemplates.ts` +- `image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx` + +### `#27` 上游失败重试路由 + +已支持把失败后的重试路由到用户指定的备用上游。 + +主要落点: + +- `go-cli/pkg/client/retry.go` +- `image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx` + +### `#26` 失败重试开关 + +已支持全局失败自动重试开关。 + +主要落点: + +- `image-studio/frontend/src/components/panel/SettingsPanel.tsx` +- `image-studio/frontend/src/platform/android/settings/AndroidSettingsPanel.tsx` + +### `#25` 上游配置升级后的同步问题 + +已支持上游配置导入 / 导出;兼容状态也会落到宿主侧共享路径。 + +主要落点: + +- `image-studio/frontend/src/lib/upstreamConfigTransfer.ts` +- `image-studio/backend/compatibility.go` + +### `#24` 支持全部删除 + +历史结果已支持全部删除。 + +主要落点: + +- `image-studio/frontend/src/components/history/HistoryRail.tsx` + +## 已部分解决,但还缺更强实机证明 + +### `#36` 多张并发时模糊 / 只出轮廓 + +当前已做: + +- 不再把 `partial_image` 预览当成最终成功结果。 +- “只拿到流式预览,没有最终图”会被判定为不完整并进入重试。 +- Android native HTTP 桥接做了长超时、精简进度、原始响应落盘、宿主侧提取最终图。 +- 新增“流式预览保护”开关: + - Android:并发 `>=2` 自动关闭流式预览。 + - Android:2K / 4K 大尺寸自动关闭流式预览。 + - Desktop:并发 `>=8` 自动关闭流式预览。 +- “生成后保存提示”与“完成提示音”都已有设置入口,并补了回归测试,确认关掉后仍能重新打开。 + +当前证据: + +- 前端测试:`114/114` 通过。 +- `go-cli/pkg/client`、`image-studio/backend`、`shared/compat-go` 测试通过。 +- 2026-06-07 本地平台总链再次通过: + - `IMAGE_STUDIO_INCLUDE_ISSUE_CLOSE_VERIFY=1 IMAGE_STUDIO_VERIFY_RESULTS_DIR=/private/tmp/verify-results-issue-close-final4 node scripts/verify-local-platform-kernel.mjs` + - `platform-kernel-summary.json` 状态为 `passed`,共 `11` 个步骤。 +- 总的平台验证链已显式覆盖: + - 本地 `live verify` smoke + - `image-studio` + - `shared/compat-go` + - `go-cli/pkg/client` +- `live-verify.mjs` 已有本地 smoke 证明: + - `scripts/verify-local-live-verify.mjs` + - 会启动仓库内 mock upstream,再跑 direct vs worker parity,确认 `live verify` 脚本本身可执行。 +- Android 本地校验脚本可通过: + - `scripts/verify-local-android-shell.mjs` + - 会先跑 Android JVM 单测,再检查 debug APK 的 `versionName` / `versionCode`、APK 签名和前端资源注入。 + - 若本机接入设备或模拟器,还可用 `IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1` 继续做 APK 安装 / 启动 smoke。 +- 2026-06-07 的结构化子结果也都已通过: + - `/private/tmp/verify-results-issue-close-final4/live-verify.json` + - `/private/tmp/verify-results-issue-close-final4/local-smoke.json` + - `/private/tmp/verify-results-issue-close-final4/android-shell.json` + - `/private/tmp/verify-results-issue-close-final4/macos-release.json` + - `/private/tmp/verify-results-issue-close-final4/issue-close-tooling.json` +- `issue-close-tooling.json` 还会额外沉淀: + - mock GitHub 下的 `comment-only` / `comment-and-close` 执行结果 + - 导出评论包摘要 + - 是否跳过 GitHub 实时同步 +- 本地 smoke / local live verify / Android shell / macOS release 这几条子验证脚本在中途失败时,也会尽量把环境与错误原因写进各自 JSON 结果,避免只剩控制台日志。 + +这轮还额外确认了两个与 `#36` 相邻的关键点: + +- 设置里新增的“流式预览保护”开关已经在桌面端和 Android 端落地,不再只是策略硬编码。 +- 编译后的 macOS App 已完成实际探针联调,确认同 semver core 的 CI 版本不会再误报更新: + - `runtimeUpdateProbe.hasUpdate = false` + - `runtimeUpdateProbe.shouldShowUpdate = false` + - `runtimeUpdateProbe.appUpdateModalOpen = false` + +仍缺: + +- 真实高并发上游环境下的长链路实机证明。 +- Windows 真机 / Android 真机上更大批量、更大尺寸任务的稳定性复核。 + +## 已修代码,但缺目标平台外观实机确认 + +### `#30` 窗口标题背景与标题栏颜色不一致 + +Windows Fluent 标题区和工作区上沿的颜色对齐代码已调整。 + +主要落点: + +- `image-studio/frontend/src/styles/fluent/_windows-theme.css` +- `image-studio/frontend/src/components/layout/WorkspaceBar.tsx` +- `image-studio/frontend/test/windowsThemeParity.test.mjs` + +当前额外保证: + +- 前端标题区与 workspace 条带现在共用 `--window-titlebar-bg` token。 +- 自动化测试会校验这个 token 与 `main.go` 里 Wails 原生标题栏颜色一致,避免后续再漂移。 + +仍缺: + +- Windows 真机视觉确认。 + +## 按当前范围暂不继续推进 + +### `#23` 不生图 + +按当前任务约定,视为上游问题,本轮不继续处理。 + +### `#14` 有 web 端吗 + +当前仓库已经明确说明: + +- 没有独立在线 Web / SaaS 版。 +- 浏览器形态主要用于调试和目标平台预览。 + +但“独立在线 Web 版”已经被明确后移,当前暂不继续推进。 + +## 建议的下一步 + +1. 如果有 Windows 真机,先补 `#30` 外观确认,再决定是否需要继续微调标题区样式。 +2. 如果有稳定可复现的真实上游并发环境,重点补 `#36` 的 Windows / Android 实机长链路证明。 + 手工验证步骤可直接按 [manual-verification.md](./manual-verification.md) 执行。 +3. 如果允许维护 issue 状态,可以把上述“代码已覆盖”的 issue 补评论或直接关闭,避免后续重复盘点。 +4. `#14` 等独立在线 Web 版需求,继续保持搁置,直到桌面 / Android 路径完全收口。 + +## 关单顺序建议 + +如果下一步允许直接维护 GitHub issue,建议按这个顺序处理: + +1. 先关闭“代码已覆盖且不依赖外部条件”的 open issue: + `#24` `#25` `#26` `#27` `#28` `#32` `#35` `#37` `#40` `#42` +2. 保留 `#30` 与 `#36` 为 open,直到真机 / 真实上游证据补齐。 +3. 保持 `#23` 不处理、`#14` 搁置,避免范围重新发散。 + +## 剩余执行计划 + +### 阶段 1:清理已完成但未关单的 issue + +目标: + +- 先把“代码已经落地、当前本地验证已覆盖、且不依赖外部条件”的 open issue 清掉。 + +建议顺序: + +1. `#24` 全部删除 +2. `#25` 上游配置导入导出 / 共享 +3. `#26` 自动重试开关 +4. `#27` 失败重试路由 +5. `#28` 提示词模板 +6. `#32` 拖结果到文件复制 +7. `#35` 工作区横向滚动 +8. `#37` 画板内左右切换同批结果 +9. `#40` Android 多图张数按钮 +10. `#42` 图生图相关需求集合 + +执行方式: + +- 每个 issue 至少补一条评论,附上对应功能点与主要落点。 +- 若仓库维护方式允许,评论后可直接关闭。 + +### 阶段 2:完成 `#30` Windows 真机确认 + +目标: + +- 确认原生标题栏、应用标题区、workspace 条带在 Windows 真机上亮/暗色都无明显断层。 + +执行方式: + +1. 按 [manual-verification.md](./manual-verification.md) 初始化 `30-windows` 手工验证目录。 +2. 使用当前构建版本在 Windows 真机截图取证。 +3. 通过后给 `#30` 补图和结论,再关闭 issue。 + +### 阶段 3:完成 `#36` 真实环境长链路验证 + +目标: + +- 验证高并发与大尺寸场景下,“流式预览保护”策略和“最终图完整性优先”策略在真实环境里成立。 + +执行方式: + +1. Android 真机 / 模拟器执行 `36-android` 验证矩阵。 +2. Windows 真机执行 `36-windows` 验证矩阵。 +3. 尽量使用真实高并发上游,覆盖: + - 并发 1 + - 并发 2 / 8 + - 2K / 4K + - 保护开关开 / 关对照 +4. 保存截图、raw 响应、日志和结论。 +5. 证据充分后再决定是否关闭 `#36`。 + +### 阶段 4:保持范围稳定 + +当前明确不进入本轮收口的内容: + +- `#23`:按上游问题处理。 +- `#14`:独立在线 Web 版继续搁置。 + +如果后续重新启动这两项,应作为新阶段单独规划,不与当前桌面 / Android 收尾混在一起。 diff --git a/docs/manual-verification.md b/docs/manual-verification.md new file mode 100644 index 000000000..f2f2d4bfa --- /dev/null +++ b/docs/manual-verification.md @@ -0,0 +1,178 @@ +# 手工验证手册 + +本文档只覆盖当前仍然需要外部条件的验证项: + +- `#36` 多张并发时模糊 / 只出轮廓 +- `#30` Windows 标题栏与标题区颜色一致性真机确认 + +在开始手工验证前,建议先跑一遍当前仓库内可自动完成的验证链: + +```bash +IMAGE_STUDIO_SKIP_RUNTIME_UPDATE_PROBE=1 node scripts/verify-local-platform-kernel.mjs +``` + +如果这一条不过,先不要进入真机/真实上游验证。 + +如果你需要把当前可自动证明的结果、`#30/#36` 模板和 issue 关单评论包一起整理给另一台机器或另一位验证者,可以先执行: + +```bash +node scripts/prepare-external-verification-bundle.mjs +``` + +默认会生成: + +```text +.tmp/external-verify-bundles//issue-30-36-handoff/ +``` + +开始记录之前,建议先初始化一份本次验证目录: + +```bash +node scripts/init-manual-verification.mjs 36-android +node scripts/init-manual-verification.mjs 36-windows +node scripts/init-manual-verification.mjs 30-windows +``` + +如果不是预设场景,也可以自己给一个标题: + +```bash +node scripts/init-manual-verification.mjs custom "my regression check" +``` + +脚本会在 `.tmp/manual-verify///` 下生成: + +- `report.md` +- `meta.json` +- `screenshots/` +- `raw/` +- `logs/` + +## 统一取证目录 + +建议把所有截图、raw 响应和说明统一放到: + +```text +.tmp/manual-verify// +``` + +建议至少保存: + +- 平台与版本信息 +- 验证步骤截图 +- raw 响应或 log 文件 +- 失败时的最短复现说明 + +## `#36` 高并发与大尺寸最终图完整性 + +### 目标 + +确认以下策略在真实环境里成立: + +- 不会把流式预览误当成最终图 +- 高并发或大尺寸时,自动关闭流式预览后,最终图仍然完整 +- Android 壳层在真实设备 / 模拟器上不会因为 partial preview 路径而只留下模糊轮廓 + +### 前置条件 + +- 至少一条真实可用上游,且能稳定返回图像 +- 已知正常的 `BASE_URL` / `API Key` / 文本模型 ID / 图像模型 ID +- 如果是 Android: + - 已接入真机或模拟器 + - 可先执行: + +```bash +IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 node scripts/verify-local-android-shell.mjs +``` + +若用 MuMu,可参考 [mumu-android-debug.md](./mumu-android-debug.md)。 + +### 建议矩阵 + +#### Windows / 桌面端 + +1. `protectStreamPreview = 开` +2. `partialImages > 0` +3. 做以下组合: + +| 组合 | 预期 | +|---|---| +| 并发 1,1024 级尺寸 | 可看到流式预览,最终图完整 | +| 并发 8,1024 级尺寸 | 自动关闭流式预览,最终图完整 | +| 并发 8,2048/4K 级尺寸 | 自动关闭流式预览,最终图完整 | +| 并发 8,关闭保护开关 | 按当前预览帧数请求,用于对照问题是否复现 | + +建议每组至少记录: + +- 提交前参数截图 +- 结果图截图 +- raw 响应文件 +- 是否出现只剩轮廓 / 明显模糊 / 少帧终止 + +#### Android 真机 / 模拟器 + +1. `protectStreamPreview = 开` +2. `partialImages > 0` +3. 做以下组合: + +| 组合 | 预期 | +|---|---| +| 并发 1,1024 级尺寸 | 最终图完整 | +| 并发 2,1024 级尺寸 | 自动关闭流式预览,最终图完整 | +| 并发 1,2K/4K 级尺寸 | 自动关闭流式预览,最终图完整 | +| 并发 2,关闭保护开关 | 用于对照老问题是否仍可能复现 | + +建议额外记录: + +- APK 版本 / `versionName` +- 设备型号或模拟器类型 +- 是否发生闪退 +- 系统相册 / 输出目录里保存的最终图是否完整 + +### 通过标准 + +- 开启保护后,测试矩阵中的“应关闭预览”组合不再出现“只有轮廓/明显模糊但直接被当成功”的情况 +- raw 响应里如果没有 final image,不应被应用当作最终成功结果 +- Android 上保存到相册或输出目录的最终图与画面显示一致,不是只保存到某一帧 partial preview + +## `#30` Windows 标题栏颜色真机确认 + +### 目标 + +确认 Windows 真机上的: + +- 原生标题栏 +- 应用内部标题区 +- workspace 条带 + +在亮色和暗色下没有明显色差或断层。 + +### 前置条件 + +- Windows 真机 +- 当前构建版本已通过: + - `image-studio/frontend/test/windowsThemeParity.test.mjs` + +### 步骤 + +1. 启动桌面版应用 +2. 切到亮色主题截图一张 +3. 切到暗色主题截图一张 +4. 如果 workspace 数量 > 1,再补一张带 workspace 条带的截图 + +### 通过标准 + +- 标题栏颜色与应用内部标题区不出现肉眼明显断层 +- workspace 条带和标题区背景保持同一主题层级 +- 亮色 / 暗色切换后都没有出现“外层原生标题栏更浅/更深一截”的问题 + +## 结果回填建议 + +如果允许继续维护 issue 状态,建议在 GitHub issue 回填: + +- 平台 +- 版本 +- 验证矩阵 +- 是否通过 +- 关键截图 / raw 响应路径 + +若不直接关闭 issue,至少补一条“已在某平台实机确认/仍待某平台确认”的评论,避免后续重复盘点。 diff --git a/docs/packages.md b/docs/packages.md index 64bb1d03f..8ce8c739c 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -22,8 +22,12 @@ Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 | 平台 | 文件名模式 | 说明 | |---|---|---| -| Windows x64 | `image-studio--windows-amd64.exe` | 标准 Wails 桌面版,依赖 WebView2 Runtime。 | -| Windows ARM64 | `image-studio--windows-arm64.exe` | 面向 Windows on Arm 设备。 | +| Windows x64 | `image-studio--windows-amd64.exe` | 裸 Wails 可执行文件,适合内部测试,不是安装器。 | +| Windows ARM64 | `image-studio--windows-arm64.exe` | 裸 Wails 可执行文件,适合内部测试,不是安装器。 | +| 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 包。 | +| Windows MSIX Bundle | `image-studio--windows.msixbundle` | 同时包含 x64 与 ARM64 的 MSIX Bundle,优先用于 Microsoft Store 提交。 | | macOS universal | `image-studio--macos-universal.zip` | 解压后得到 `Image Studio.app`。 | | Linux x64 | `image-studio--linux-amd64.tar.gz` | 标准 Wails 桌面版。 | | Linux ARM64 | `image-studio--linux-arm64.tar.gz` | 面向 ARM64 Linux 桌面环境。 | @@ -44,9 +48,11 @@ Windows 用户需要额外注意:Actions 里的 CI artifact 如果没有经过 ### Windows -- Wails 桌面版依赖 WebView2 Runtime;Windows 10+ 通常已预装。 +- Wails 裸 `exe` 依赖 WebView2 Runtime;安装器版本会在安装阶段检查并静默拉起 WebView2 Runtime 安装。 - ARM64 设备优先下载 `windows-arm64`,避免 x64 仿真带来的额外开销。 -- 对外分发请优先使用带有效 Authenticode 签名的正式 release;未签名的 CI `exe` 在 Windows 11 上可能被智能应用控制直接阻止运行。 +- 对外普通分发时请优先使用 `image-studio--windows-installer.exe`,不要直接使用裸 `exe`。 +- 提交 Microsoft Store 时请优先使用 `image-studio--windows.msixbundle`,其次再按需要使用分架构 `.msix`。 +- 对外分发请优先使用带有效 Authenticode 签名的正式 release;未签名的 CI `exe` 或安装器在 Windows 11 上可能被智能应用控制直接阻止运行。 ### macOS diff --git a/docs/picture/image-studio-icon-2160.png b/docs/picture/image-studio-icon-2160.png new file mode 100644 index 000000000..0dad02e08 Binary files /dev/null and b/docs/picture/image-studio-icon-2160.png differ diff --git a/docs/project-structure.md b/docs/project-structure.md index 84c5271f0..53b1ff940 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -136,14 +136,27 @@ Gradle 构建时执行前端 `build:android`,把 `image-studio/frontend/dist/` - `compute-version.sh`:从 tag 或 wails.json 计算版本元数据。 - `sync-version-metadata.mjs`:同步 wails/frontend/package 版本。 - `verify-local-platform-kernel.mjs`:跨平台内核本地全量验证。 +- `verify-local-live-verify.mjs`:以本地 mock upstream 驱动 `live-verify.mjs` 的 smoke 验证。 +- `verify-local-android-shell.mjs`:Android shell 本地 APK 与 JVM 单测验证。 - `verify-local-macos-release.mjs`:macOS release 包验证。 +- `init-manual-verification.mjs`:初始化手工验证目录、报告模板与取证子目录。 +- `issue-close-data.json`:可关单 issue、暂不关闭原因、统一验证基线的结构化数据源。 +- `issue-close-helper.mjs`:读取 `issue-close-data.json`,列出可关单 issue、输出评论模板、生成 dry-run 计划,并在显式授权时对 GitHub issue 执行评论/关闭。 +- `render-issue-close-comments.mjs`:从 `issue-close-data.json` 生成 `docs/issue-close-comments.md`。 +- `render-issue-close-summary.mjs`:把 `issue-close-tooling.json` 渲染成 Markdown summary。 +- `verify-issue-close-tooling.mjs`:校验 issue 关单数据源、渲染文档、helper 输出与 GitHub 当前 open issue 状态是否一致。 +- `prepare-external-verification-bundle.mjs`:把 `#30/#36` 的手工验证模板、最新总链证据和 issue 关单评论包整理到统一交接目录。 +- `verify-output-paths.mjs`:统一解析各类验证结果的输出文件路径。 - `local-smoke-check.mjs`:本地 mock upstream smoke。 - `live-verify.mjs`:真实上游 direct vs worker 对比验证。 +- `render-verify-platform-summary.mjs`:把本地平台验证 JSON 渲染成 GitHub job summary。 +- `render-live-verify-summary.mjs`:把真实上游验证 JSON 渲染成 GitHub job summary。 ## Workflows - `.github/workflows/release.yml`:并行构建桌面产物,构建 Android APK,并在 tag release 时发布。 - `.github/workflows/verify-platform-kernel.yml`:自动化验证本地可证明部分。 +- `.github/workflows/verify-issue-close-tooling.yml`:手动触发 issue 关单工具链验证。 - `.github/workflows/live-verify-platform-kernel.yml`:手动触发真实上游验证。 ## 维护约束 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e1a78cf39..e0eb6abf4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4,6 +4,9 @@ 很多报错看起来像“软件坏了”,实际是上游配置、权限、兼容性或网络环境问题。 +如果你是在对照 GitHub issue 排查,先看 [issue-progress.md](./issue-progress.md)。 +那份文档会标出哪些问题已经有代码覆盖、哪些还缺真机或真实上游验证。 + 先做这组最短排查: 1. 在当前 profile 里点一次「测试连接」,确认 `BASE_URL`、`API Key`、文本模型 ID、图像模型 ID 至少能通过基础校验。 diff --git a/docs/usage.md b/docs/usage.md index f842d0031..1d2b18f63 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -92,6 +92,27 @@ 4. 如需蒙版,在画板里切换到蒙版工具绘制。 5. 点击「生成」。 +### 图生图批处理 + +桌面端图生图支持批处理模式,入口位于 `源图片 / 参考图` 区域顶部的 `普通图生图 / 批处理` 切换。 + +适合: + +- 一个目录里有很多源图。 +- 所有图片共用同一组 prompt 和图生图参数。 + +基本流程: + +1. 切到 `图生图`。 +2. 在 `源图片 / 参考图` 里切到 `批处理`。 +3. 选择输入目录。 +4. 确认扫描到的图片数量。 +5. 选择默认回原目录,或单独指定输出目录。 +6. 设置并发数。 +7. 点击 `编辑` 开始处理。 + +详细说明见 [batch-img2img](./batch-img2img/README.md)。 + 生成成功后,toast 会提供查看详情入口;详情抽屉里可以查看图片预览、全部参数、原始 prompt、优化后 prompt、保存路径与 raw 响应路径。 ## 输出与历史 diff --git a/go-cli/pkg/client/client.go b/go-cli/pkg/client/client.go index 6d2f309e5..0f9c06a35 100644 --- a/go-cli/pkg/client/client.go +++ b/go-cli/pkg/client/client.go @@ -167,6 +167,10 @@ func responsesAPIWithRetries( var lastErr error var lastPath string + autoRetryEnabled := true + if opts.AutoRetryEnabled != nil && !*opts.AutoRetryEnabled { + autoRetryEnabled = false + } for attempt := 1; attempt <= MaxAttempts; attempt++ { rawPath := filepath.Join(outputDir, fmt.Sprintf("sse-response-%s-attempt%d.txt", timestamp, attempt)) @@ -192,7 +196,7 @@ func responsesAPIWithRetries( if errors.Is(reqErr, ErrNoImageInResponse) { lastErr = reqErr reason := DescribeProblem(raw) - if 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) { @@ -207,7 +211,7 @@ func responsesAPIWithRetries( // Transport-level error (network / native HTTP failure). Retry up to MaxAttempts. lastErr = reqErr - if attempt < MaxAttempts { + if autoRetryEnabled && attempt < MaxAttempts { onLog(fmt.Sprintf("%v", reqErr)) onLog(fmt.Sprintf("%d 秒后自动重试...", RetryBackoffSeconds)) if !sleepCtx(ctx, time.Duration(RetryBackoffSeconds)*time.Second) { @@ -257,6 +261,10 @@ func imagesAPIWithRetries( var lastErr error var lastPath string + autoRetryEnabled := true + if opts.AutoRetryEnabled != nil && !*opts.AutoRetryEnabled { + autoRetryEnabled = false + } for attempt := 1; attempt <= MaxAttempts; attempt++ { rawPath := filepath.Join(outputDir, fmt.Sprintf("images-response-%s-attempt%d.json", timestamp, attempt)) @@ -280,7 +288,7 @@ func imagesAPIWithRetries( lastErr = reqErr // Images API has no SSE / no partial — only retry on transport-level // errors and Cloudflare 5xx HTML pages. - if 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) { diff --git a/go-cli/pkg/client/client_test.go b/go-cli/pkg/client/client_test.go index 35963e9a0..a26d66692 100644 --- a/go-cli/pkg/client/client_test.go +++ b/go-cli/pkg/client/client_test.go @@ -141,6 +141,74 @@ func TestRequestAndExtractWithRetriesEmitsPartialImages(t *testing.T) { } } +func TestRequestAndExtractWithRetriesRetriesWhenOnlyPartialPreviewArrives(t *testing.T) { + finalB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfinal")) + hits := 0 + ev := func(m map[string]any) string { + b, _ := json.Marshal(m) + return "data: " + string(b) + } + 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) + flusher, _ := w.(http.Flusher) + if hits == 1 { + fmt.Fprintln(w, ev(map[string]any{ + "type": "response.image_generation_call.partial_image", + "partial_image_index": 0, + "partial_image_b64": base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\npartial")), + })) + fmt.Fprintln(w, ev(map[string]any{"type": "response.completed", "response": map[string]any{"status": "completed"}})) + if flusher != nil { + flusher.Flush() + } + return + } + fmt.Fprintln(w, ev(map[string]any{ + "type": "response.output_item.done", + "item": map[string]any{ + "type": "image_generation_call", + "result": finalB64, + }, + })) + if flusher != nil { + flusher.Flush() + } + })) + 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(), 5*time.Second) + defer cancel() + res, _, err := RequestAndExtractWithRetries( + ctx, + transport, + Options{APIKey: "sk-test", Prompt: "hello", Size: "1024x1024", Quality: "auto", BaseURL: "https://test.local"}, + dir, + "20260518-200003", + nil, + nil, + ) + if err != nil { + t.Fatalf("err = %v", err) + } + if hits != 2 { + t.Fatalf("hits = %d, want 2", hits) + } + if res.ImageB64 != finalB64 || res.SourceEvent != "final" { + t.Fatalf("unexpected result: %+v", res) + } +} + func TestRequestAndExtractWithRetries_RetryOn524(t *testing.T) { // First attempt returns Cloudflare 524 HTML (retryable); second attempt succeeds. pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) @@ -199,6 +267,46 @@ func TestRequestAndExtractWithRetries_RetryOn524(t *testing.T) { } } +func TestRequestAndExtractWithRetriesCanDisableAutoRetry(t *testing.T) { + 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) + fmt.Fprint(w, "Error code 524 | 524: A timeout occurred") + })) + 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() + _, _, err := RequestAndExtractWithRetries( + ctx, transport, + Options{ + APIKey: "sk-test", + Prompt: "p", + Size: "1024x1024", + Quality: "auto", + BaseURL: "https://test.local", + AutoRetryEnabled: func() *bool { v := false; return &v }(), + }, + dir, "20260518-200002", + nil, nil, + ) + if err == nil { + t.Fatal("expected error when auto retry is disabled") + } + if hits != 1 { + t.Errorf("hits = %d, want 1", hits) + } +} + // TestRequestAndExtract_StreamCutAfterFinal:服务端发完 final event 后立刻 // 关掉连接(模拟 Cloudflare 在 idle 阶段 reset),客户端不应再算作失败 —— // buffer 已经包含完整 final event,parse 出图。 diff --git a/go-cli/pkg/client/images_api.go b/go-cli/pkg/client/images_api.go index 401cac679..9bbbb82b2 100644 --- a/go-cli/pkg/client/images_api.go +++ b/go-cli/pkg/client/images_api.go @@ -176,9 +176,6 @@ func (e *imageStreamExtractor) result() (ImageResult, bool) { if e.hasFinal { return e.final, true } - if e.partialB64 != "" { - return ImageResult{ImageB64: e.partialB64, SourceEvent: "images_api_partial"}, true - } return ImageResult{}, false } diff --git a/go-cli/pkg/client/images_api_test.go b/go-cli/pkg/client/images_api_test.go index c84927d81..edfd3eb7e 100644 --- a/go-cli/pkg/client/images_api_test.go +++ b/go-cli/pkg/client/images_api_test.go @@ -65,6 +65,60 @@ func TestRequestImagesAPIWithPartialStreamsPreviews(t *testing.T) { } } +func TestRequestImagesAPIWithRetriesRetriesWhenOnlyPartialPreviewArrives(t *testing.T) { + partialB64 := base64.StdEncoding.EncodeToString([]byte("partial")) + finalB64 := base64.StdEncoding.EncodeToString([]byte("final")) + 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 == 1 { + fmt.Fprintf(w, "data: {\"type\":\"image_generation.partial_image\",\"partial_image_index\":0,\"b64_json\":\"%s\"}\n", partialB64) + fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed"}}`) + return + } + fmt.Fprintf(w, "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"%s\"}\n", finalB64) + })) + defer srv.Close() + + original := RetryBackoffSeconds + RetryBackoffSeconds = 0 + t.Cleanup(func() { RetryBackoffSeconds = original }) + + var partials []PartialImage + res, _, err := RequestAndExtractWithRetriesAndPartial( + context.Background(), + nil, + Options{ + APIKey: "sk-test", + Prompt: "cat", + BaseURL: srv.URL, + APIMode: APIModeImages, + PartialImages: 2, + }, + t.TempDir(), + "20260518-200004", + nil, + nil, + func(partial PartialImage) { + partials = append(partials, partial) + }, + ) + if err != nil { + t.Fatal(err) + } + if hits != 2 { + t.Fatalf("hits = %d, want 2", hits) + } + if res.ImageB64 != finalB64 || res.SourceEvent != "images_api" { + t.Fatalf("unexpected result: %+v", res) + } + if len(partials) != 1 || partials[0].ImageB64 != partialB64 { + t.Fatalf("unexpected partials: %+v", partials) + } +} + func TestBuildEditsMultipartSetsMaskMimeType(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "source.png") diff --git a/go-cli/pkg/client/retry.go b/go-cli/pkg/client/retry.go index 90f8c3fa9..62c0f23e3 100644 --- a/go-cli/pkg/client/retry.go +++ b/go-cli/pkg/client/retry.go @@ -16,6 +16,44 @@ var retryableMarkers = []string{ "origin_gateway_timeout", } +func hasPartialImageWithoutFinal(raw string) bool { + text := strings.TrimSpace(raw) + if text == "" { + return false + } + partialSeen := false + finalSeen := false + for ev := range IterEvents(text) { + evType, _ := ev["type"].(string) + switch evType { + case "response.image_generation_call.partial_image": + if v, ok := ev["partial_image_b64"].(string); ok && v != "" { + partialSeen = true + } + case "response.output_item.done": + itemAny, _ := ev["item"] + item, ok := itemAny.(map[string]any) + if !ok { + continue + } + if t, _ := item["type"].(string); t == "image_generation_call" { + if result, _ := item["result"].(string); result != "" { + finalSeen = true + } + } + case "image_generation.partial_image", "image_edit.partial_image": + if v, ok := ev["b64_json"].(string); ok && v != "" { + partialSeen = true + } + case "image_generation.completed", "image_edit.completed": + if v, ok := ev["b64_json"].(string); ok && v != "" { + finalSeen = true + } + } + } + return partialSeen && !finalSeen +} + // IsRetryable mirrors Python is_retryable_response. func IsRetryable(raw string) bool { text := strings.TrimSpace(raw) @@ -25,6 +63,9 @@ func IsRetryable(raw string) bool { return true } } + if hasPartialImageWithoutFinal(text) { + return true + } var data map[string]any if err := json.Unmarshal([]byte(text), &data); err != nil { @@ -111,6 +152,9 @@ func DescribeProblem(raw string) string { return msg } } + if hasPartialImageWithoutFinal(raw) { + return "接口只返回了流式预览帧,没有返回最终图片。" + } return "接口已返回内容,但没有发现 image_generation_call.result。" } diff --git a/go-cli/pkg/client/sse.go b/go-cli/pkg/client/sse.go index d9cd0333f..d053717d2 100644 --- a/go-cli/pkg/client/sse.go +++ b/go-cli/pkg/client/sse.go @@ -44,23 +44,17 @@ func IterEvents(raw string) iter.Seq[Event] { // ExtractImageResult parses raw SSE text and returns the image base64. // Priority: // 1. response.output_item.done with item.type == image_generation_call and item.result -// 2. last partial_image_b64 seen (fallback) -// 3. JSON walk of the entire body (non-SSE responses) +// 2. JSON walk of the entire body (non-SSE responses) // -// Returns ErrNoImageInResponse if nothing matches. +// Partial preview frames are intentionally not treated as success. If the +// stream only delivered partial_image previews but never produced the final +// image result, callers should retry instead of persisting a blurry preview as +// if it were the completed image. func ExtractImageResult(raw string) (ImageResult, error) { - var partialB64, partialPrompt string - for ev := range IterEvents(raw) { evType, _ := ev["type"].(string) if evType == "response.image_generation_call.partial_image" { - if v, ok := ev["partial_image_b64"].(string); ok && v != "" { - partialB64 = v - } - if v, ok := ev["revised_prompt"].(string); ok && v != "" { - partialPrompt = v - } continue } @@ -83,27 +77,12 @@ func ExtractImageResult(raw string) (ImageResult, error) { SourceEvent: "final", }, nil } - if partialB64 != "" { - return ImageResult{ - ImageB64: partialB64, - RevisedPrompt: partialPrompt, - SourceEvent: "partial", - }, nil - } } if r, ok := findImageResultInJSON(raw); ok { return r, nil } - if partialB64 != "" { - return ImageResult{ - ImageB64: partialB64, - RevisedPrompt: partialPrompt, - SourceEvent: "partial", - }, nil - } - return ImageResult{}, ErrNoImageInResponse } diff --git a/go-cli/pkg/client/sse_test.go b/go-cli/pkg/client/sse_test.go index 89ad55899..6adbc0327 100644 --- a/go-cli/pkg/client/sse_test.go +++ b/go-cli/pkg/client/sse_test.go @@ -51,7 +51,7 @@ func TestExtractFinalImageResult(t *testing.T) { } } -func TestExtractFallsBackToPartial(t *testing.T) { +func TestExtractPartialOnlyReturnsSentinelError(t *testing.T) { pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) raw := strings.Join([]string{ sseLine(t, map[string]any{"type": "response.created", "sequence_number": 0}), @@ -61,15 +61,9 @@ func TestExtractFallsBackToPartial(t *testing.T) { }), sseLine(t, map[string]any{"type": "response.completed", "response": map[string]any{"status": "completed"}}), }, "\n") - res, err := ExtractImageResult(raw) - if err != nil { - t.Fatal(err) - } - if res.ImageB64 != pngB64 { - t.Errorf("image b64 mismatch") - } - if res.SourceEvent != "partial" { - t.Errorf("source_event = %q, want partial", res.SourceEvent) + _, err := ExtractImageResult(raw) + if !errors.Is(err, ErrNoImageInResponse) { + t.Fatalf("err = %v, want ErrNoImageInResponse", err) } } diff --git a/go-cli/pkg/client/stream_collect.go b/go-cli/pkg/client/stream_collect.go index 60f70ade1..5680d17da 100644 --- a/go-cli/pkg/client/stream_collect.go +++ b/go-cli/pkg/client/stream_collect.go @@ -198,13 +198,6 @@ func (e *streamImageExtractor) result() (ImageResult, bool) { if e.hasFinal { return e.final, true } - if e.partialB64 != "" { - return ImageResult{ - ImageB64: e.partialB64, - RevisedPrompt: e.partialPrompt, - SourceEvent: "partial", - }, true - } return ImageResult{}, false } diff --git a/go-cli/pkg/client/stream_collect_test.go b/go-cli/pkg/client/stream_collect_test.go index 6678df2a9..cdee5f4d9 100644 --- a/go-cli/pkg/client/stream_collect_test.go +++ b/go-cli/pkg/client/stream_collect_test.go @@ -2,6 +2,7 @@ package client import ( "encoding/base64" + "errors" "testing" ) @@ -29,18 +30,15 @@ func TestResponseCollectorExtractsFinalAndPartial(t *testing.T) { } }) - t.Run("partial fallback", func(t *testing.T) { + t.Run("partial only is not a success result", func(t *testing.T) { c := newResponseCollector(nil) _, err := c.Write([]byte("data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"" + pngB64 + "\"}\n")) if err != nil { t.Fatal(err) } - got, err := c.result() - if err != nil { - t.Fatalf("collector result: %v", err) - } - if got.ImageB64 != pngB64 || got.SourceEvent != "partial" { - t.Fatalf("unexpected partial result: %+v", got) + _, err = c.result() + if !errors.Is(err, ErrNoImageInResponse) { + t.Fatalf("collector result err = %v, want ErrNoImageInResponse", err) } }) diff --git a/go-cli/pkg/client/types.go b/go-cli/pkg/client/types.go index dbaf6cd4b..6657daa03 100644 --- a/go-cli/pkg/client/types.go +++ b/go-cli/pkg/client/types.go @@ -218,6 +218,10 @@ type Options struct { // DisablePreview forces partial_images = 0 for this request. DisablePreview bool + + // AutoRetryEnabled controls whether retryable upstream/transport failures are + // retried automatically. Nil means "use the default" (enabled). + AutoRetryEnabled *bool } // EffectiveImageDataURLs returns the merged list, deduplicating empty entries. diff --git a/image-studio/backend/app_update.go b/image-studio/backend/app_update.go index 4b10460ce..368390a48 100644 --- a/image-studio/backend/app_update.go +++ b/image-studio/backend/app_update.go @@ -21,6 +21,7 @@ const ( defaultAppVersion = "0.1.5" releasesPageURL = "https://github.com/RoseKhlifa/Image-Studio/releases" latestReleaseAPIURL = "https://api.github.com/repos/RoseKhlifa/Image-Studio/releases/latest" + latestReleaseAPIURLEnv = "IMAGE_STUDIO_LATEST_RELEASE_API_URL" latestReleaseAPIVersion = "2022-11-28" updateRequestTimeout = 8 * time.Second ) @@ -69,6 +70,10 @@ func (s *Service) CheckForAppUpdate() (AppUpdateInfo, error) { if latestVersion == "" { return AppUpdateInfo{}, fmt.Errorf("无法识别 release 版本: %q", release.TagName) } + hasUpdate := compareSemver(latestVersion, currentVersion) > 0 + if semverCore(latestVersion) != "" && semverCore(latestVersion) == semverCore(currentVersion) { + hasUpdate = false + } return AppUpdateInfo{ CurrentVersion: currentVersion, @@ -78,12 +83,19 @@ func (s *Service) CheckForAppUpdate() (AppUpdateInfo, error) { ReleaseURL: chooseReleaseURL(strings.TrimSpace(release.HTMLURL)), PublishedAt: strings.TrimSpace(release.PublishedAt), Body: strings.TrimSpace(release.Body), - HasUpdate: compareSemver(latestVersion, currentVersion) > 0, + HasUpdate: hasUpdate, }, nil } func fetchLatestGitHubRelease(ctx context.Context) (githubLatestRelease, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseAPIURL, nil) + apiURL := strings.TrimSpace(os.Getenv(latestReleaseAPIURLEnv)) + if apiURL == "" { + apiURL = commandLineArgValue(os.Args[1:], latestReleaseAPIURLArg) + } + if apiURL == "" { + apiURL = latestReleaseAPIURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return githubLatestRelease{}, err } @@ -244,6 +256,20 @@ func parseSemver(input string) (parsedSemver, bool) { }, true } +func semverCore(input string) string { + value := normalizeReleaseVersion(input) + if value == "" { + return "" + } + if idx := strings.Index(value, "+"); idx >= 0 { + value = value[:idx] + } + if idx := strings.Index(value, "-"); idx >= 0 { + value = value[:idx] + } + return value +} + func compareSemverSuffix(a, b []string) int { if len(a) == 0 && len(b) == 0 { return 0 diff --git a/image-studio/backend/app_update_probe.go b/image-studio/backend/app_update_probe.go new file mode 100644 index 000000000..4a5d01881 --- /dev/null +++ b/image-studio/backend/app_update_probe.go @@ -0,0 +1,63 @@ +package backend + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + appUpdateProbePathEnv = "IMAGE_STUDIO_APP_UPDATE_PROBE_PATH" + appUpdateProbeQuitEnv = "IMAGE_STUDIO_APP_UPDATE_PROBE_QUIT" +) + +type AppUpdateProbeResult struct { + AppVersion string `json:"appVersion,omitempty"` + CurrentVersion string `json:"currentVersion,omitempty"` + LatestVersion string `json:"latestVersion,omitempty"` + ReleaseTag string `json:"releaseTag,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` + IgnoredReleaseTag string `json:"ignoredReleaseTag,omitempty"` + UpdateInfoAvailable bool `json:"updateInfoAvailable"` + HasUpdate bool `json:"hasUpdate"` + ShouldShowUpdate bool `json:"shouldShowUpdate"` + AppUpdateModalOpen bool `json:"appUpdateModalOpen"` + CapturedAt string `json:"capturedAt,omitempty"` +} + +func (s *Service) WriteAppUpdateProbe(result AppUpdateProbeResult) error { + path := strings.TrimSpace(os.Getenv(appUpdateProbePathEnv)) + if path == "" { + path = commandLineArgValue(os.Args[1:], appUpdateProbePathArg) + } + if path == "" { + return nil + } + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + result.CapturedAt = time.Now().UTC().Format(time.RFC3339Nano) + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(absPath), secureDirMode); err != nil { + return err + } + if err := os.WriteFile(absPath, append(data, '\n'), secureFileMode); err != nil { + return err + } + quitRequested := strings.TrimSpace(os.Getenv(appUpdateProbeQuitEnv)) != "" || commandLineBoolFlag(os.Args[1:], appUpdateProbeQuitArg) + if quitRequested && s.ctx != nil { + go func() { + time.Sleep(150 * time.Millisecond) + runtime.Quit(s.ctx) + }() + } + return nil +} diff --git a/image-studio/backend/app_update_test.go b/image-studio/backend/app_update_test.go index 246345fc1..a558e26ab 100644 --- a/image-studio/backend/app_update_test.go +++ b/image-studio/backend/app_update_test.go @@ -1,6 +1,17 @@ package backend -import "testing" +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/yuanhua/image-gptcodex/pkg/client" +) func TestCompareSemver(t *testing.T) { cases := []struct { @@ -18,6 +29,7 @@ func TestCompareSemver(t *testing.T) { {name: "build metadata ignored", a: "1.1.12+abc123", b: "1.1.12+def456", want: 0}, {name: "ci prerelease newer than old stable", a: "1.1.12-ci.37.1+f1b0c7428c17", b: "0.1.5", want: 1}, {name: "numeric prerelease identifiers compare numerically", a: "1.1.12-ci.10", b: "1.1.12-ci.2", want: 1}, + {name: "same core release and ci build compare as same update generation", a: "1.1.13", b: "1.1.13-ci.49.1+4c4e3507d6ca", want: 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -45,3 +57,116 @@ func TestNormalizeReleaseVersion(t *testing.T) { t.Fatalf("normalizeReleaseVersion() = %q, want empty", got) } } + +func TestSemverCore(t *testing.T) { + if got := semverCore("v1.1.13"); got != "1.1.13" { + t.Fatalf("semverCore() = %q, want 1.1.13", got) + } + if got := semverCore("1.1.13-ci.49.1+4c4e3507d6ca"); got != "1.1.13" { + t.Fatalf("semverCore() = %q, want 1.1.13", got) + } + if got := semverCore("release-1.1.13"); got != "" { + t.Fatalf("semverCore() = %q, want empty", got) + } +} + +func TestCheckForAppUpdateDoesNotFlagSameCoreReleaseForCIBuild(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "tag_name":"v1.1.13", + "name":"v1.1.13", + "html_url":"https://example.com/releases/v1.1.13", + "published_at":"2026-06-07T00:00:00Z", + "body":"bugfixes", + "draft":false, + "prerelease":false + }`) + })) + defer srv.Close() + + t.Setenv(latestReleaseAPIURLEnv, srv.URL) + original := client.Version + client.Version = "1.1.13-ci.49.1+4c4e3507d6ca" + t.Cleanup(func() { client.Version = original }) + + info, err := new(Service).CheckForAppUpdate() + if err != nil { + t.Fatalf("CheckForAppUpdate() error = %v", err) + } + if info.HasUpdate { + t.Fatalf("HasUpdate = true, want false for same-core CI build vs release") + } + if info.CurrentVersion != "1.1.13-ci.49.1+4c4e3507d6ca" { + t.Fatalf("CurrentVersion = %q", info.CurrentVersion) + } + if info.LatestVersion != "1.1.13" { + t.Fatalf("LatestVersion = %q", info.LatestVersion) + } +} + +func TestCaptureAppUpdateProbeWritesNoUpdateForSameCoreCIBuild(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "tag_name":"v1.1.13", + "name":"v1.1.13", + "html_url":"https://example.com/releases/v1.1.13", + "published_at":"2026-06-07T00:00:00Z", + "body":"bugfixes", + "draft":false, + "prerelease":false + }`) + })) + defer srv.Close() + + tempDir := t.TempDir() + probePath := filepath.Join(tempDir, "app-update-probe.json") + t.Setenv(latestReleaseAPIURLEnv, srv.URL) + t.Setenv(appUpdateProbePathEnv, probePath) + original := client.Version + client.Version = "1.1.13-ci.49.1+4c4e3507d6ca" + t.Cleanup(func() { client.Version = original }) + + new(Service).captureAppUpdateProbe() + + var data []byte + var err error + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, err = os.ReadFile(probePath) + if err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", probePath, err) + } + + var probe AppUpdateProbeResult + if err := json.Unmarshal(data, &probe); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if probe.AppVersion != "1.1.13-ci.49.1+4c4e3507d6ca" { + t.Fatalf("AppVersion = %q", probe.AppVersion) + } + if probe.CurrentVersion != "1.1.13-ci.49.1+4c4e3507d6ca" { + t.Fatalf("CurrentVersion = %q", probe.CurrentVersion) + } + if probe.LatestVersion != "1.1.13" { + t.Fatalf("LatestVersion = %q", probe.LatestVersion) + } + if !probe.UpdateInfoAvailable { + t.Fatal("UpdateInfoAvailable = false, want true") + } + if probe.HasUpdate { + t.Fatal("HasUpdate = true, want false") + } + if probe.ShouldShowUpdate { + t.Fatal("ShouldShowUpdate = true, want false") + } + if probe.AppUpdateModalOpen { + t.Fatal("AppUpdateModalOpen = true, want false") + } +} diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go index a5070ee66..a49c926a7 100644 --- a/image-studio/backend/dialogs.go +++ b/image-studio/backend/dialogs.go @@ -13,6 +13,13 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" ) +var supportedBatchInputExtensions = map[string]struct{}{ + ".png": {}, + ".jpg": {}, + ".jpeg": {}, + ".webp": {}, +} + func (s *Service) BeginNativeFileDrag(path string) error { allowed, err := s.ensureManagedReadablePath(path, managedImageFile) if err != nil { @@ -59,6 +66,76 @@ func (s *Service) OpenImageDialog() (SelectFileResponse, error) { return resp, nil } +func (s *Service) ChooseBatchInputDir() (BatchInputDirectory, error) { + if s.ctx == nil { + return BatchInputDirectory{}, errors.New("服务未启动") + } + chosen, err := runtime.OpenDirectoryDialog(s.ctx, runtime.OpenDialogOptions{ + Title: "选择批处理输入目录", + }) + if err != nil { + return BatchInputDirectory{}, err + } + if chosen == "" { + return BatchInputDirectory{}, nil + } + return s.ListBatchInputImages(chosen) +} + +func (s *Service) ListBatchInputImages(directory string) (BatchInputDirectory, error) { + clean := strings.TrimSpace(directory) + if clean == "" { + return BatchInputDirectory{}, errors.New("目标目录不能为空") + } + root, err := filepath.Abs(clean) + if err != nil { + return BatchInputDirectory{}, err + } + info, err := os.Stat(root) + if err != nil { + return BatchInputDirectory{}, fmt.Errorf("读取目录失败: %w", err) + } + if !info.IsDir() { + return BatchInputDirectory{}, fmt.Errorf("不是目录: %s", root) + } + entries, err := os.ReadDir(root) + if err != nil { + return BatchInputDirectory{}, fmt.Errorf("读取目录失败: %w", err) + } + images := make([]BatchInputImage, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if _, ok := supportedBatchInputExtensions[ext]; !ok { + continue + } + path := filepath.Join(root, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + item := BatchInputImage{ + Path: path, + Name: entry.Name(), + Size: info.Size(), + } + if info.Size() > 0 && info.Size() <= maxDialogReadBytes { + if preview, previewErr := s.registerImportedPreview(path); previewErr == nil { + item.PreviewURL = preview.PreviewURL + item.PreviewWidth = preview.PreviewWidth + item.PreviewHeight = preview.PreviewHeight + } + } + images = append(images, item) + } + return BatchInputDirectory{ + Directory: root, + Images: images, + }, nil +} + // SaveImageAs prompts the user for a destination and writes the base64 PNG to disk. func (s *Service) SaveImageAs(imageB64, suggestedName string) (string, error) { if suggestedName == "" { @@ -208,6 +285,15 @@ func uniqueTargetPath(dir, fileName string) (string, error) { return "", fmt.Errorf("目标目录中同名文件过多:%s", fileName) } +func uniquePrefixedTargetPath(dir, sourceName, prefix string) (string, error) { + base := ensureTargetFileName(sourceName, "image.png") + trimmedPrefix := strings.TrimSpace(prefix) + if trimmedPrefix == "" { + return uniqueTargetPath(dir, base) + } + return uniqueTargetPath(dir, trimmedPrefix+base) +} + // OpenOutputDir reveals the output directory in the OS file explorer. // 兜底:用户在第一次生成前就点「打开输出目录」,默认路径还不存在 —— // `open` / `xdg-open` / `explorer` 拿到不存在的路径都会失败(macOS / Linux @@ -311,6 +397,26 @@ func (s *Service) ExportHistoryToFile(jsonContent string) (string, error) { return dst, nil } +// ExportUpstreamConfigToFile writes an upstream-config JSON dump to a +// user-selected path so profile metadata and API keys can survive upgrades or +// manual reinstalls. +func (s *Service) ExportUpstreamConfigToFile(jsonContent string) (string, error) { + dst, err := runtime.SaveFileDialog(s.ctx, runtime.SaveDialogOptions{ + Title: "导出上游配置", + DefaultFilename: fmt.Sprintf("image-studio-upstream-config-%s.json", time.Now().Format("20060102-150405")), + Filters: []runtime.FileFilter{ + {DisplayName: "JSON (*.json)", Pattern: "*.json"}, + }, + }) + if err != nil || dst == "" { + return "", err + } + if err := os.WriteFile(dst, []byte(jsonContent), secureFileMode); err != nil { + return "", err + } + return dst, nil +} + // ImportHistoryFromFile opens a file picker and returns the JSON content as a // string. The frontend then parses and merges the entries into IndexedDB. func (s *Service) ImportHistoryFromFile() (string, error) { @@ -329,3 +435,23 @@ func (s *Service) ImportHistoryFromFile() (string, error) { } return string(data), nil } + +// ImportUpstreamConfigFromFile opens a JSON file picker and returns the file +// contents so the frontend can merge the imported upstream profiles and +// restore any bundled API keys into the system credential store. +func (s *Service) ImportUpstreamConfigFromFile() (string, error) { + src, err := runtime.OpenFileDialog(s.ctx, runtime.OpenDialogOptions{ + Title: "选择上游配置 JSON 文件", + Filters: []runtime.FileFilter{ + {DisplayName: "JSON (*.json)", Pattern: "*.json"}, + }, + }) + if err != nil || src == "" { + return "", err + } + data, err := os.ReadFile(src) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/image-studio/backend/launch_args.go b/image-studio/backend/launch_args.go new file mode 100644 index 000000000..6fe593744 --- /dev/null +++ b/image-studio/backend/launch_args.go @@ -0,0 +1,56 @@ +package backend + +import "strings" + +const ( + latestReleaseAPIURLArg = "--image-studio-latest-release-api-url" + appUpdateProbePathArg = "--image-studio-app-update-probe-path" + appUpdateProbeQuitArg = "--image-studio-app-update-probe-quit" +) + +func commandLineArgValue(args []string, flagName string) string { + if flagName == "" { + return "" + } + for index := 0; index < len(args); index++ { + current := strings.TrimSpace(args[index]) + if current == "" { + continue + } + if strings.HasPrefix(current, flagName+"=") { + return strings.TrimSpace(strings.TrimPrefix(current, flagName+"=")) + } + if current == flagName && index+1 < len(args) { + return strings.TrimSpace(args[index+1]) + } + } + return "" +} + +func commandLineBoolFlag(args []string, flagName string) bool { + if flagName == "" { + return false + } + for _, raw := range args { + current := strings.TrimSpace(raw) + if current == "" { + continue + } + if current == flagName { + return true + } + if strings.HasPrefix(current, flagName+"=") { + value := strings.TrimSpace(strings.TrimPrefix(current, flagName+"=")) + if value == "" { + return true + } + switch strings.ToLower(value) { + case "0", "false", "no", "off": + return false + default: + return true + } + } + } + return false +} diff --git a/image-studio/backend/launch_args_test.go b/image-studio/backend/launch_args_test.go new file mode 100644 index 000000000..d9fea8ea1 --- /dev/null +++ b/image-studio/backend/launch_args_test.go @@ -0,0 +1,34 @@ +package backend + +import "testing" + +func TestCommandLineArgValue(t *testing.T) { + args := []string{ + "image-studio", + "--image-studio-latest-release-api-url=https://example.com/latest.json", + "--image-studio-app-update-probe-path", + "/tmp/probe.json", + } + + if got := commandLineArgValue(args, latestReleaseAPIURLArg); got != "https://example.com/latest.json" { + t.Fatalf("commandLineArgValue() = %q, want https://example.com/latest.json", got) + } + if got := commandLineArgValue(args, appUpdateProbePathArg); got != "/tmp/probe.json" { + t.Fatalf("commandLineArgValue() = %q, want /tmp/probe.json", got) + } + if got := commandLineArgValue(args, "--missing"); got != "" { + t.Fatalf("commandLineArgValue() = %q, want empty", got) + } +} + +func TestCommandLineBoolFlag(t *testing.T) { + if !commandLineBoolFlag([]string{"image-studio", appUpdateProbeQuitArg}, appUpdateProbeQuitArg) { + t.Fatal("commandLineBoolFlag() = false, want true for bare flag") + } + if !commandLineBoolFlag([]string{"image-studio", appUpdateProbeQuitArg + "=1"}, appUpdateProbeQuitArg) { + t.Fatal("commandLineBoolFlag() = false, want true for =1") + } + if commandLineBoolFlag([]string{"image-studio", appUpdateProbeQuitArg + "=false"}, appUpdateProbeQuitArg) { + t.Fatal("commandLineBoolFlag() = true, want false for =false") + } +} diff --git a/image-studio/backend/log_settings.go b/image-studio/backend/log_settings.go index b64ba1193..b1e538065 100644 --- a/image-studio/backend/log_settings.go +++ b/image-studio/backend/log_settings.go @@ -16,8 +16,14 @@ func (s *Service) SetKeepLogsEnabled(enabled bool) { s.mu.Unlock() } +func (s *Service) SetCleanupPreviewCacheOnExitEnabled(enabled bool) { + s.mu.Lock() + s.cleanupPreviewCacheOnExit = enabled + s.mu.Unlock() +} + func (s *Service) Shutdown(_ context.Context) { - if err := cleanupLogDirsIfDisabled(s.keepLogsEnabled(), s.managedLogDirs()); err != nil { + if err := cleanupManagedRuntimeArtifacts(s.keepLogsEnabled(), s.cleanupPreviewCacheOnExitEnabled(), s.managedRuntimeCleanupDirs()); err != nil { println("Warning:", err.Error()) } } @@ -28,6 +34,12 @@ func (s *Service) keepLogsEnabled() bool { return s.keepLogs } +func (s *Service) cleanupPreviewCacheOnExitEnabled() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.cleanupPreviewCacheOnExit +} + func (s *Service) loadCompatibilitySettings() { path, err := compatibilityStatePath() if err != nil { @@ -42,6 +54,7 @@ func (s *Service) loadCompatibilitySettings() { func (s *Service) syncCompatibilitySettings(state compat.State) { s.SetKeepLogsEnabled(state.Settings.KeepLogs) + s.SetCleanupPreviewCacheOnExitEnabled(state.Settings.CleanupPreviewCacheOnExit) if outputDir := strings.TrimSpace(state.Settings.OutputDir); outputDir != "" { if abs, err := filepath.Abs(outputDir); err == nil { s.mu.Lock() @@ -59,14 +72,47 @@ func (s *Service) managedLogDirs() []string { return normalizeRoots(s.allowedRoots(managedRawLogFile)) } +func (s *Service) managedRuntimeCleanupDirs() []string { + dirs := make([]string, 0, 16) + if s.cleanupPreviewCacheOnExitEnabled() { + for _, root := range s.managedOutputRootsForCleanup() { + dirs = append(dirs, thumbsSubdir(root), previewsSubdir(root)) + } + if importsRoot, err := importsDir(); err == nil { + dirs = append(dirs, previewsSubdir(importsRoot)) + } + } + if !s.keepLogsEnabled() { + dirs = append(dirs, s.managedLogDirs()...) + } + return normalizeRoots(dirs) +} + +func (s *Service) managedOutputRootsForCleanup() []string { + roots := make([]string, 0, 8) + if root, err := defaultOutputDir(); err == nil { + roots = append(roots, root) + } + roots = append(roots, platformLegacyOutputRoots()...) + if root := s.currentOutputRootSnapshot(); root != "" { + roots = append(roots, root) + } + roots = append(roots, s.trustedOutputRootsSnapshot()...) + return normalizeRoots(roots) +} + func cleanupLogDirsIfDisabled(keepLogs bool, dirs []string) error { if keepLogs { return nil } - return removeLogDirs(dirs) + return removeDirs(dirs) +} + +func cleanupManagedRuntimeArtifacts(_ bool, _ bool, dirs []string) error { + return removeDirs(dirs) } -func removeLogDirs(dirs []string) error { +func removeDirs(dirs []string) error { var firstErr error for _, dir := range normalizeRoots(dirs) { if strings.TrimSpace(dir) == "" { diff --git a/image-studio/backend/log_settings_test.go b/image-studio/backend/log_settings_test.go index 82e6a9cdb..f760ef40b 100644 --- a/image-studio/backend/log_settings_test.go +++ b/image-studio/backend/log_settings_test.go @@ -1,6 +1,7 @@ package backend import ( + "context" "os" "path/filepath" "testing" @@ -15,15 +16,19 @@ func TestSyncCompatibilitySettingsTracksKeepLogsAndRoots(t *testing.T) { svc.syncCompatibilitySettings(compat.State{ Settings: compat.Settings{ - OutputDir: outputRoot, - TrustedOutputRoots: []string{trustedRoot}, - KeepLogs: true, + OutputDir: outputRoot, + TrustedOutputRoots: []string{trustedRoot}, + KeepLogs: true, + CleanupPreviewCacheOnExit: true, }, }) if !svc.keepLogsEnabled() { t.Fatal("expected keepLogs to be enabled") } + if !svc.cleanupPreviewCacheOnExitEnabled() { + t.Fatal("expected cleanupPreviewCacheOnExit to be enabled") + } if got := svc.currentOutputRootSnapshot(); got != outputRoot { t.Fatalf("currentOutputRootSnapshot() = %q, want %q", got, outputRoot) } @@ -77,3 +82,173 @@ func TestCleanupLogDirsIfDisabledLeavesLogsWhenEnabled(t *testing.T) { t.Fatalf("expected %s to remain, got %v", logFile, err) } } + +func TestManagedRuntimeCleanupDirsPreservePrimaryImageData(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + svc := NewService() + outputRoot := filepath.Join(t.TempDir(), "output") + trustedRoot := filepath.Join(t.TempDir(), "trusted") + svc.addTrustedOutputRoot(trustedRoot) + if err := svc.SetOutputDir(outputRoot); err != nil { + t.Fatal(err) + } + svc.SetKeepLogsEnabled(false) + svc.SetCleanupPreviewCacheOnExitEnabled(true) + for _, dir := range []string{outputRoot, trustedRoot} { + if err := os.MkdirAll(dir, secureDirMode); err != nil { + t.Fatal(err) + } + } + + importRoot, err := importsDir() + if err != nil { + t.Fatal(err) + } + defaultRoot, err := defaultOutputDir() + if err != nil { + t.Fatal(err) + } + normalizedOutputRoots := normalizeRoots([]string{defaultRoot, outputRoot, trustedRoot}) + for _, root := range normalizedOutputRoots { + for _, dir := range []string{thumbsSubdir(root), previewsSubdir(root), logSubdir(root)} { + if err := os.MkdirAll(dir, secureDirMode); err != nil { + t.Fatal(err) + } + } + } + if err := os.MkdirAll(previewsSubdir(importRoot), secureDirMode); err != nil { + t.Fatal(err) + } + wantDirs := make([]string, 0, len(normalizedOutputRoots)*3+1) + for _, root := range normalizedOutputRoots { + wantDirs = append(wantDirs, thumbsSubdir(root), previewsSubdir(root), logSubdir(root)) + } + wantDirs = append(wantDirs, normalizeRoots([]string{previewsSubdir(importRoot)})...) + wantDirs = normalizeRoots(wantDirs) + gotDirs := svc.managedRuntimeCleanupDirs() + if len(gotDirs) != len(wantDirs) { + t.Fatalf("managedRuntimeCleanupDirs len = %d, want %d (%v)", len(gotDirs), len(wantDirs), gotDirs) + } + for _, want := range wantDirs { + found := false + for _, got := range gotDirs { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("missing cleanup dir %q in %v", want, gotDirs) + } + } + for _, forbidden := range []string{ + imagesSubdir(defaultRoot), + imagesSubdir(outputRoot), + imagesSubdir(trustedRoot), + importRoot, + } { + for _, got := range gotDirs { + if got == forbidden { + t.Fatalf("unexpected primary data dir scheduled for cleanup: %q", forbidden) + } + } + } +} + +func TestShutdownRemovesOnlyManagedRuntimeCaches(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + svc := NewService() + outputRoot := filepath.Join(t.TempDir(), "output") + if err := svc.SetOutputDir(outputRoot); err != nil { + t.Fatal(err) + } + svc.SetKeepLogsEnabled(false) + svc.SetCleanupPreviewCacheOnExitEnabled(true) + + importRoot, err := importsDir() + if err != nil { + t.Fatal(err) + } + imagesDir := imagesSubdir(outputRoot) + thumbsDir := thumbsSubdir(outputRoot) + previewsDir := previewsSubdir(outputRoot) + logDir := logSubdir(outputRoot) + importPreviewDir := previewsSubdir(importRoot) + for _, dir := range []string{imagesDir, thumbsDir, previewsDir, logDir, importRoot, importPreviewDir} { + if err := os.MkdirAll(dir, secureDirMode); err != nil { + t.Fatal(err) + } + } + keepImage := filepath.Join(imagesDir, "keep.png") + keepImport := filepath.Join(importRoot, "source.png") + for _, path := range []string{ + keepImage, + keepImport, + filepath.Join(thumbsDir, "thumb.avif"), + filepath.Join(previewsDir, "preview.avif"), + filepath.Join(logDir, "raw.txt"), + filepath.Join(importPreviewDir, "import.avif"), + } { + if err := os.WriteFile(path, []byte("x"), secureFileMode); err != nil { + t.Fatal(err) + } + } + + svc.Shutdown(context.Background()) + + for _, path := range []string{keepImage, keepImport} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to remain, got %v", path, err) + } + } + for _, dir := range []string{thumbsDir, previewsDir, logDir, importPreviewDir} { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, got %v", dir, err) + } + } +} + +func TestShutdownLeavesPreviewCachesWhenCleanupDisabled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + svc := NewService() + outputRoot := filepath.Join(t.TempDir(), "output") + if err := svc.SetOutputDir(outputRoot); err != nil { + t.Fatal(err) + } + svc.SetKeepLogsEnabled(false) + svc.SetCleanupPreviewCacheOnExitEnabled(false) + + importRoot, err := importsDir() + if err != nil { + t.Fatal(err) + } + thumbsDir := thumbsSubdir(outputRoot) + previewsDir := previewsSubdir(outputRoot) + importPreviewDir := previewsSubdir(importRoot) + logDir := logSubdir(outputRoot) + for _, dir := range []string{thumbsDir, previewsDir, importPreviewDir, logDir} { + if err := os.MkdirAll(dir, secureDirMode); err != nil { + t.Fatal(err) + } + } + thumbFile := filepath.Join(thumbsDir, "thumb.avif") + previewFile := filepath.Join(previewsDir, "preview.avif") + importPreviewFile := filepath.Join(importPreviewDir, "import.avif") + logFile := filepath.Join(logDir, "raw.txt") + for _, path := range []string{thumbFile, previewFile, importPreviewFile, logFile} { + if err := os.WriteFile(path, []byte("x"), secureFileMode); err != nil { + t.Fatal(err) + } + } + + svc.Shutdown(context.Background()) + + for _, path := range []string{thumbFile, previewFile, importPreviewFile} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to remain when preview cleanup is disabled, got %v", path, err) + } + } + if _, err := os.Stat(logDir); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, got %v", logDir, err) + } +} diff --git a/image-studio/backend/media.go b/image-studio/backend/media.go index 79bc09593..0dbfc9a90 100644 --- a/image-studio/backend/media.go +++ b/image-studio/backend/media.go @@ -165,6 +165,24 @@ func (s *Service) RegisterMediaAsset(savedPath, thumbPath string) (MediaAssetRef allowedThumb = "" } } + if allowedThumb == "" { + if rebuilt, width, height, rebuildErr := rebuildGeneratedThumb(allowedFull); rebuildErr == nil { + allowedThumb = rebuilt + asset, mediaErr := s.registerGeneratedMedia(allowedFull, allowedThumb, width, height) + if mediaErr != nil { + return MediaAssetRef{}, mediaErr + } + return MediaAssetRef{ + ImageID: asset.ID, + SavedPath: asset.FullPath, + ThumbPath: asset.ThumbPath, + PreviewURL: asset.PreviewURL, + FullURL: asset.FullURL, + PreviewWidth: asset.PreviewWidth, + PreviewHeight: asset.PreviewHeight, + }, nil + } + } width, height := 0, 0 if allowedThumb != "" { if cfg, cfgErr := imageConfig(allowedThumb); cfgErr == nil { @@ -186,6 +204,36 @@ func (s *Service) RegisterMediaAsset(savedPath, thumbPath string) (MediaAssetRef }, nil } +func rebuildGeneratedThumb(fullPath string) (thumbPath string, width int, height int, err error) { + root, err := outputRootForManagedImagePath(fullPath) + if err != nil { + return "", 0, 0, err + } + base := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) + thumbPath = filepath.Join(thumbsSubdir(root), base+".avif") + width, height, err = createAVIFThumbnail(fullPath, thumbPath, mediaThumbMaxEdge) + if err != nil { + return "", 0, 0, err + } + thumbPath, err = filepath.Abs(thumbPath) + if err != nil { + return "", 0, 0, err + } + return thumbPath, width, height, nil +} + +func outputRootForManagedImagePath(fullPath string) (string, error) { + abs, err := filepath.Abs(fullPath) + if err != nil { + return "", err + } + dir := filepath.Dir(abs) + if filepath.Base(dir) != "images" { + return "", fmt.Errorf("not a generated image path: %s", abs) + } + return filepath.Dir(dir), nil +} + func mediaIDForPath(path string) string { sum := sha256.Sum256([]byte(path)) return hex.EncodeToString(sum[:16]) diff --git a/image-studio/backend/media_test.go b/image-studio/backend/media_test.go index 6700c3c47..13625d6d2 100644 --- a/image-studio/backend/media_test.go +++ b/image-studio/backend/media_test.go @@ -15,6 +15,77 @@ import ( "testing" ) +func TestListBatchInputImagesOnlyScansCurrentDirectoryImages(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(nested, secureDirMode); err != nil { + t.Fatal(err) + } + makePNG := func(path string) { + t.Helper() + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, secureFileMode) + if err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, 32, 16)) + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + } + makePNG(filepath.Join(root, "a.png")) + makePNG(filepath.Join(root, "b.webp")) + makePNG(filepath.Join(nested, "c.png")) + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("x"), secureFileMode); err != nil { + t.Fatal(err) + } + + svc := NewService() + result, err := svc.ListBatchInputImages(root) + if err != nil { + t.Fatal(err) + } + if result.Directory != root { + t.Fatalf("directory = %q, want %q", result.Directory, root) + } + if len(result.Images) != 2 { + t.Fatalf("images len = %d, want 2", len(result.Images)) + } + got := []string{result.Images[0].Name, result.Images[1].Name} + if !(got[0] == "a.png" && got[1] == "b.webp" || got[0] == "b.webp" && got[1] == "a.png") { + t.Fatalf("unexpected files: %#v", got) + } +} + +func TestBuildBatchOutputPathAppliesPrefixAndAvoidsCollisions(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "sample.png") + if err := os.WriteFile(src, []byte("png"), secureFileMode); err != nil { + t.Fatal(err) + } + svc := NewService() + first, err := svc.BuildBatchOutputPath(src, root, "processed-") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "processed-sample.png"); first != want { + t.Fatalf("first path = %q, want %q", first, want) + } + if err := os.WriteFile(first, []byte("done"), secureFileMode); err != nil { + t.Fatal(err) + } + second, err := svc.BuildBatchOutputPath(src, root, "processed-") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "processed-sample-2.png"); second != want { + t.Fatalf("second path = %q, want %q", second, want) + } +} + func TestMediaHandlerServesRegisteredFullAndAVIFThumb(t *testing.T) { t.Setenv("HOME", t.TempDir()) root, err := defaultOutputDir() @@ -212,3 +283,47 @@ func TestRegisterImportedImageAssetCreatesManagedAVIFPreview(t *testing.T) { t.Fatal("empty imported full body") } } + +func TestRegisterMediaAssetRebuildsMissingThumb(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root, err := defaultOutputDir() + if err != nil { + t.Fatal(err) + } + imagesDir := imagesSubdir(root) + if err := os.MkdirAll(imagesDir, secureDirMode); err != nil { + t.Fatal(err) + } + fullPath := filepath.Join(imagesDir, "rebuilt.png") + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, secureFileMode) + if err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, 640, 320)) + for y := 0; y < 320; y++ { + for x := 0; x < 640; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 160, A: 255}) + } + } + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + svc := NewService() + ref, err := svc.RegisterMediaAsset(fullPath, filepath.Join(thumbsSubdir(root), "rebuilt.avif")) + if err != nil { + t.Fatal(err) + } + if ref.ThumbPath == "" { + t.Fatalf("expected rebuilt thumb path in ref: %+v", ref) + } + if _, err := os.Stat(ref.ThumbPath); err != nil { + t.Fatalf("expected rebuilt thumb to exist: %v", err) + } + if ref.PreviewWidth != 384 || ref.PreviewHeight != 192 { + t.Fatalf("rebuilt preview size = %dx%d, want 384x192", ref.PreviewWidth, ref.PreviewHeight) + } +} diff --git a/image-studio/backend/persistence_migration_common.go b/image-studio/backend/persistence_migration_common.go new file mode 100644 index 000000000..382c83e50 --- /dev/null +++ b/image-studio/backend/persistence_migration_common.go @@ -0,0 +1,214 @@ +package backend + +import ( + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +var imageStudioStorageNeedles = [][]byte{ + []byte("image-studio"), + []byte("historyFull"), + []byte("gptcodex."), + []byte("gptcodex:"), +} + +type webviewMigrationCandidate struct { + path string + score int64 +} + +func webviewMigrationCandidates(dstAbs string, legacyPaths []string) []webviewMigrationCandidate { + candidates := make([]webviewMigrationCandidate, 0, len(legacyPaths)) + seen := map[string]struct{}{} + for _, legacy := range legacyPaths { + legacy = strings.TrimSpace(legacy) + if legacy == "" { + continue + } + legacyAbs, err := filepath.Abs(legacy) + if err != nil || samePath(dstAbs, legacyAbs) { + continue + } + key := strings.ToLower(filepath.Clean(legacyAbs)) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if !dirExists(legacyAbs) { + continue + } + score := imageStudioWebviewProfileScore(legacyAbs) + if score <= 0 { + continue + } + candidates = append(candidates, webviewMigrationCandidate{path: legacyAbs, score: score}) + } + return candidates +} + +func samePath(a, b string) bool { + return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func imageStudioWebviewProfileScore(path string) int64 { + var total int64 + var matched int64 + for _, rel := range []string{"IndexedDB", "Local Storage", filepath.Join("WebsiteData", "Default")} { + scanned, hit := storageDirScore(filepath.Join(path, rel)) + total += scanned + matched += hit + } + if matched > 0 { + return total + matched + } + return 0 +} + +func moveAsideEmptyWebviewProfile(path string) error { + if !dirExists(path) { + return nil + } + if !isEmptyOrDisposableWebviewProfile(path) { + return os.ErrExist + } + target := path + ".empty-before-documents-migration" + for i := 1; dirExists(target); i++ { + target = path + ".empty-before-documents-migration-" + strconv.Itoa(i) + } + return os.Rename(path, target) +} + +func isEmptyOrDisposableWebviewProfile(path string) bool { + entries, err := os.ReadDir(path) + if err != nil { + return false + } + for _, entry := range entries { + name := entry.Name() + full := filepath.Join(path, name) + if name == "Crashpad" || name == "BrowserMetrics" || name == "DevToolsActivePort" { + continue + } + if name == "Network" || name == "Default" || name == "ShaderCache" || name == "GrShaderCache" || name == "WebsiteData" { + if dirSize(full) == 0 { + continue + } + } + return false + } + return true +} + +func dirSize(path string) int64 { + var size int64 + _ = filepath.WalkDir(path, func(walkPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if err == nil { + size += info.Size() + } + _ = walkPath + return nil + }) + return size +} + +func storageDirScore(path string) (int64, int64) { + var total int64 + var matched int64 + _ = filepath.WalkDir(path, func(walkPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if err != nil { + return nil + } + total += info.Size() + if fileContainsAny(walkPath, imageStudioStorageNeedles) { + matched += info.Size() + } + return nil + }) + return total, matched +} + +func fileContainsAny(path string, needles [][]byte) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + for _, needle := range needles { + if bytes.Contains(data, needle) { + return true + } + } + return false +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if entry.IsDir() { + return os.MkdirAll(target, secureDirMode) + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + return copyFile(src, dst, rel, info) + }) +} + +func copyFile(srcRoot, dstRoot, rel string, info fs.FileInfo) error { + src := filepath.Join(srcRoot, rel) + dst := filepath.Join(dstRoot, rel) + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), secureDirMode); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + mtime := info.ModTime() + if mtime.IsZero() { + mtime = time.Now() + } + return os.Chtimes(dst, mtime, mtime) +} diff --git a/image-studio/backend/persistence_migration_darwin.go b/image-studio/backend/persistence_migration_darwin.go new file mode 100644 index 000000000..31a1841e6 --- /dev/null +++ b/image-studio/backend/persistence_migration_darwin.go @@ -0,0 +1,60 @@ +//go:build darwin + +package backend + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + macCurrentWebkitDirName = "top.gptcodex.imagestudio" + macLegacyWebkitDirName = "com.wails.image-studio" +) + +func MigrateMacWebkitDataDir() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + root := filepath.Join(home, "Library", "WebKit") + current := filepath.Join(root, macCurrentWebkitDirName) + legacy := filepath.Join(root, macLegacyWebkitDirName) + return migrateMacWebkitDataDirs(current, []string{legacy}) +} + +func migrateMacWebkitDataDirs(dst string, legacyPaths []string) error { + dst = strings.TrimSpace(dst) + if dst == "" || len(legacyPaths) == 0 { + return nil + } + dstAbs, err := filepath.Abs(dst) + if err != nil { + return err + } + candidates := webviewMigrationCandidates(dstAbs, legacyPaths) + if len(candidates) == 0 { + return nil + } + if dirExists(dstAbs) { + dstScore := imageStudioWebviewProfileScore(dstAbs) + if dstScore >= candidates[0].score { + return nil + } + if dstScore > 0 { + return nil + } + if err := moveAsideEmptyWebviewProfile(dstAbs); err != nil { + return err + } + } + legacyAbs := candidates[0].path + if err := os.MkdirAll(filepath.Dir(dstAbs), secureDirMode); err != nil { + return err + } + if err := os.Rename(legacyAbs, dstAbs); err == nil { + return nil + } + return copyDir(legacyAbs, dstAbs) +} diff --git a/image-studio/backend/persistence_migration_darwin_test.go b/image-studio/backend/persistence_migration_darwin_test.go new file mode 100644 index 000000000..768bd1064 --- /dev/null +++ b/image-studio/backend/persistence_migration_darwin_test.go @@ -0,0 +1,63 @@ +//go:build darwin + +package backend + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMigrateMacWebkitDataDirsMovesLegacyProfile(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, macLegacyWebkitDirName) + dst := filepath.Join(root, macCurrentWebkitDirName) + dbFile := filepath.Join(legacy, "WebsiteData", "Default", "profile", "profile", "LocalStorage", "localstorage.sqlite3") + if err := os.MkdirAll(filepath.Dir(dbFile), secureDirMode); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dbFile, []byte("gptcodex.profiles historyFull gptcodex.activeProfileId"), secureFileMode); err != nil { + t.Fatal(err) + } + + if err := migrateMacWebkitDataDirs(dst, []string{legacy}); err != nil { + t.Fatal(err) + } + + migrated := filepath.Join(dst, "WebsiteData", "Default", "profile", "profile", "LocalStorage", "localstorage.sqlite3") + data, err := os.ReadFile(migrated) + if err != nil { + t.Fatal(err) + } + if string(data) != "gptcodex.profiles historyFull gptcodex.activeProfileId" { + t.Fatalf("migrated data = %q", data) + } +} + +func TestMigrateMacWebkitDataDirsKeepsExistingPopulatedDestination(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, macLegacyWebkitDirName) + dst := filepath.Join(root, macCurrentWebkitDirName) + if err := os.MkdirAll(legacy, secureDirMode); err != nil { + t.Fatal(err) + } + dstFile := filepath.Join(dst, "WebsiteData", "Default", "profile", "profile", "LocalStorage", "localstorage.sqlite3") + if err := os.MkdirAll(filepath.Dir(dstFile), secureDirMode); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dstFile, []byte("gptcodex.profiles current"), secureFileMode); err != nil { + t.Fatal(err) + } + + if err := migrateMacWebkitDataDirs(dst, []string{legacy}); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(dstFile) + if err != nil { + t.Fatal(err) + } + if string(data) != "gptcodex.profiles current" { + t.Fatalf("destination was overwritten: %q", data) + } +} diff --git a/image-studio/backend/persistence_migration_mac_stub.go b/image-studio/backend/persistence_migration_mac_stub.go new file mode 100644 index 000000000..563a94995 --- /dev/null +++ b/image-studio/backend/persistence_migration_mac_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package backend + +func MigrateMacWebkitDataDir() error { + return nil +} diff --git a/image-studio/backend/persistence_migration_windows.go b/image-studio/backend/persistence_migration_windows.go index fef0d5b5a..540d7aff6 100644 --- a/image-studio/backend/persistence_migration_windows.go +++ b/image-studio/backend/persistence_migration_windows.go @@ -3,26 +3,13 @@ package backend import ( - "bytes" - "io" - "io/fs" "os" "path/filepath" - "sort" - "strconv" "strings" - "time" ) const windowsWebviewMigrationMarker = ".migrated-to-image-studio-documents" -var imageStudioStorageNeedles = [][]byte{ - []byte("image-studio"), - []byte("historyFull"), - []byte("gptcodex."), - []byte("gptcodex:"), -} - func MigrateWindowsWebviewDataDir(dst, legacy string) error { return MigrateWindowsWebviewDataDirs(dst, []string{legacy}) } @@ -36,7 +23,7 @@ func MigrateWindowsWebviewDataDirs(dst string, legacyPaths []string) error { if err != nil { return err } - candidates := windowsWebviewMigrationCandidates(dstAbs, legacyPaths) + candidates := webviewMigrationCandidates(dstAbs, legacyPaths) if len(candidates) == 0 { return nil } @@ -65,199 +52,3 @@ func MigrateWindowsWebviewDataDirs(dst string, legacyPaths []string) error { marker := filepath.Join(legacyAbs, windowsWebviewMigrationMarker) return os.WriteFile(marker, []byte(dstAbs), secureFileMode) } - -type windowsWebviewMigrationCandidate struct { - path string - score int64 -} - -func windowsWebviewMigrationCandidates(dstAbs string, legacyPaths []string) []windowsWebviewMigrationCandidate { - candidates := make([]windowsWebviewMigrationCandidate, 0, len(legacyPaths)) - seen := map[string]struct{}{} - for _, legacy := range legacyPaths { - legacy = strings.TrimSpace(legacy) - if legacy == "" { - continue - } - legacyAbs, err := filepath.Abs(legacy) - if err != nil || samePath(dstAbs, legacyAbs) { - continue - } - key := strings.ToLower(filepath.Clean(legacyAbs)) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if !dirExists(legacyAbs) { - continue - } - score := imageStudioWebviewProfileScore(legacyAbs) - if score <= 0 { - continue - } - candidates = append(candidates, windowsWebviewMigrationCandidate{path: legacyAbs, score: score}) - } - sort.SliceStable(candidates, func(i, j int) bool { - return candidates[i].score > candidates[j].score - }) - return candidates -} - -func samePath(a, b string) bool { - return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) -} - -func dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -func imageStudioWebviewProfileScore(path string) int64 { - var total int64 - var matched int64 - for _, rel := range []string{"IndexedDB", "Local Storage"} { - scanned, hit := storageDirScore(filepath.Join(path, rel)) - total += scanned - matched += hit - } - if matched > 0 { - return total + matched - } - return 0 -} - -func moveAsideEmptyWebviewProfile(path string) error { - if !dirExists(path) { - return nil - } - if !isEmptyOrDisposableWebviewProfile(path) { - return os.ErrExist - } - target := path + ".empty-before-documents-migration" - for i := 1; dirExists(target); i++ { - target = path + ".empty-before-documents-migration-" + strconv.Itoa(i) - } - return os.Rename(path, target) -} - -func isEmptyOrDisposableWebviewProfile(path string) bool { - entries, err := os.ReadDir(path) - if err != nil { - return false - } - for _, entry := range entries { - name := entry.Name() - full := filepath.Join(path, name) - if name == "Crashpad" || name == "BrowserMetrics" || name == "DevToolsActivePort" { - continue - } - if name == "Network" || name == "Default" || name == "ShaderCache" || name == "GrShaderCache" { - if dirSize(full) == 0 { - continue - } - } - return false - } - return true -} - -func dirSize(path string) int64 { - var size int64 - _ = filepath.WalkDir(path, func(walkPath string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { - return nil - } - info, err := entry.Info() - if err == nil { - size += info.Size() - } - _ = walkPath - return nil - }) - return size -} - -func storageDirScore(path string) (int64, int64) { - var total int64 - var matched int64 - _ = filepath.WalkDir(path, func(walkPath string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { - return nil - } - info, err := entry.Info() - if err != nil { - return nil - } - total += info.Size() - if fileContainsAny(walkPath, imageStudioStorageNeedles) { - matched += info.Size() - } - return nil - }) - return total, matched -} - -func fileContainsAny(path string, needles [][]byte) bool { - data, err := os.ReadFile(path) - if err != nil { - return false - } - for _, needle := range needles { - if bytes.Contains(data, needle) { - return true - } - } - return false -} - -func copyDir(src, dst string) error { - return filepath.WalkDir(src, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if entry.IsDir() { - return os.MkdirAll(target, secureDirMode) - } - if entry.Type()&os.ModeSymlink != 0 { - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - return copyFile(path, target, info) - }) -} - -func copyFile(src, dst string, info fs.FileInfo) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - if err := os.MkdirAll(filepath.Dir(dst), secureDirMode); err != nil { - return err - } - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) - if err != nil { - return err - } - _, copyErr := io.Copy(out, in) - closeErr := out.Close() - if copyErr != nil { - return copyErr - } - if closeErr != nil { - return closeErr - } - mtime := info.ModTime() - if mtime.IsZero() { - mtime = time.Now() - } - return os.Chtimes(dst, mtime, mtime) -} diff --git a/image-studio/backend/service.go b/image-studio/backend/service.go index 4d502474f..ddade9eb4 100644 --- a/image-studio/backend/service.go +++ b/image-studio/backend/service.go @@ -34,12 +34,13 @@ import ( type Service struct { ctx context.Context - mu sync.Mutex - jobs map[string]*job - runningByAPIMode map[string]int - outputDir string // 用户自定义输出目录;空时回退到 defaultOutputDir() - keepLogs bool - apiKeys apiKeyStore + mu sync.Mutex + jobs map[string]*job + runningByAPIMode map[string]int + outputDir string // 用户自定义输出目录;空时回退到 defaultOutputDir() + keepLogs bool + cleanupPreviewCacheOnExit bool + apiKeys apiKeyStore trustedOutputRoots map[string]struct{} mediaAssets map[string]mediaAsset @@ -66,6 +67,36 @@ func NewService() *Service { func (s *Service) Startup(ctx context.Context) { s.ctx = ctx s.loadCompatibilitySettings() + if strings.TrimSpace(os.Getenv(appUpdateProbePathEnv)) != "" || commandLineArgValue(os.Args[1:], appUpdateProbePathArg) != "" { + go s.captureAppUpdateProbe() + } +} + +func (s *Service) captureAppUpdateProbe() { + appVersion, err := currentDesktopAppVersion() + if err != nil || strings.TrimSpace(appVersion) == "" { + appVersion = defaultAppVersion + } + result := AppUpdateProbeResult{ + AppVersion: appVersion, + CurrentVersion: appVersion, + UpdateInfoAvailable: false, + HasUpdate: false, + ShouldShowUpdate: false, + AppUpdateModalOpen: false, + } + updateInfo, err := s.CheckForAppUpdate() + if err == nil { + result.CurrentVersion = updateInfo.CurrentVersion + result.LatestVersion = updateInfo.LatestVersion + result.ReleaseTag = updateInfo.ReleaseTag + result.ReleaseURL = updateInfo.ReleaseURL + result.UpdateInfoAvailable = true + result.HasUpdate = updateInfo.HasUpdate + result.ShouldShowUpdate = updateInfo.HasUpdate + result.AppUpdateModalOpen = updateInfo.HasUpdate + } + _ = s.WriteAppUpdateProbe(result) } // resolvedOutputDir 返回当前生效的输出目录:用户自定义优先,否则默认。 @@ -132,6 +163,35 @@ func (s *Service) ChooseOutputDir() (string, error) { return chosen, nil } +func (s *Service) ChooseDirectory(title string) (string, error) { + if s.ctx == nil { + return "", errors.New("服务未启动") + } + chosen, err := runtime.OpenDirectoryDialog(s.ctx, runtime.OpenDialogOptions{ + Title: strings.TrimSpace(title), + }) + if err != nil { + return "", err + } + return chosen, nil +} + +func (s *Service) BuildBatchOutputPath(sourcePath, outputDir, prefix string) (string, error) { + cleanSource := strings.TrimSpace(sourcePath) + if cleanSource == "" { + return "", errors.New("源文件不能为空") + } + targetRoot := strings.TrimSpace(outputDir) + if targetRoot == "" { + targetRoot = filepath.Dir(cleanSource) + } + root, err := ensureTargetDirectory(targetRoot) + if err != nil { + return "", err + } + return uniquePrefixedTargetPath(root, filepath.Base(cleanSource), prefix) +} + // --- Generation entry points ----------------------------------------------- // Generate starts a text-to-image job and returns its ID immediately. Progress @@ -281,6 +341,13 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions if apiMode == "" { apiMode = client.APIModeResponses } + fallbackAPIMode := client.APIModeResponses + if opts.FallbackProfile != nil && strings.TrimSpace(opts.FallbackProfile.APIMode) != "" { + fallbackAPIMode = client.APIMode(strings.TrimSpace(opts.FallbackProfile.APIMode)) + if fallbackAPIMode == "" { + fallbackAPIMode = client.APIModeResponses + } + } clientOpts := client.Options{ APIKey: opts.APIKey, @@ -308,11 +375,47 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions ImagesNewAPICompat: opts.ImagesNewAPICompat, NoPromptRevision: opts.NoPromptRevision, DisablePreview: opts.DisablePreview, + AutoRetryEnabled: &opts.AutoRetryEnabled, PartialImages: client.DefaultPartialImages, } if opts.PartialImages > 0 { clientOpts.PartialImages = opts.PartialImages } + var fallbackClientOpts *client.Options + if opts.AutoRetryEnabled && + opts.FallbackProfile != nil && + strings.TrimSpace(opts.FallbackProfile.APIKey) != "" && + strings.TrimSpace(opts.FallbackProfile.BaseURL) != "" { + fallbackClientOpts = &client.Options{ + APIKey: strings.TrimSpace(opts.FallbackProfile.APIKey), + Prompt: opts.Prompt, + Mode: mode, + Size: opts.Size, + Quality: opts.Quality, + OutputFormat: opts.OutputFormat, + MaskB64: opts.MaskB64, + Seed: opts.Seed, + NegativePrompt: opts.NegativePrompt, + Background: opts.Background, + OutputCompression: opts.OutputCompression, + InputFidelity: opts.InputFidelity, + ImageStyle: opts.ImageStyle, + Moderation: opts.Moderation, + UserIdentifier: opts.UserIdentifier, + BaseURL: strings.TrimSpace(opts.FallbackProfile.BaseURL), + TextModelID: strings.TrimSpace(opts.FallbackProfile.TextModelID), + ImageModelID: strings.TrimSpace(opts.FallbackProfile.ImageModelID), + ReasoningEffort: strings.TrimSpace(opts.FallbackProfile.ReasoningEffort), + Proxy: client.ProxyConfig{Mode: opts.ProxyMode, URL: opts.ProxyURL}, + APIMode: fallbackAPIMode, + RequestPolicy: client.RequestPolicy(strings.TrimSpace(opts.FallbackProfile.RequestPolicy)), + ImagesNewAPICompat: opts.FallbackProfile.ImagesNewAPICompat, + NoPromptRevision: opts.NoPromptRevision, + DisablePreview: opts.DisablePreview, + AutoRetryEnabled: &opts.AutoRetryEnabled, + PartialImages: clientOpts.PartialImages, + } + } if mode == client.ModeEdit { paths, cleanup, prepErr := prepareUploadSourcePaths(opts.collectPaths()) if prepErr != nil { @@ -321,9 +424,12 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions } defer cleanup() clientOpts.ImagePaths = paths + if fallbackClientOpts != nil { + fallbackClientOpts.ImagePaths = paths + } // Responses API 仍需 data URL(走 input_image 形态); // Images API 直接 multipart 上传文件,跳过 base64 编码节省往返开销。 - if apiMode == client.APIModeResponses { + if apiMode == client.APIModeResponses || (fallbackClientOpts != nil && fallbackClientOpts.APIMode == client.APIModeResponses) { urls := make([]string, 0, len(paths)) for _, p := range paths { dataURL, err := client.ImageFileToDataURL(p) @@ -334,6 +440,9 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions urls = append(urls, dataURL) } clientOpts.ImageDataURLs = urls + if fallbackClientOpts != nil && fallbackClientOpts.APIMode == client.APIModeResponses { + fallbackClientOpts.ImageDataURLs = urls + } } } @@ -428,6 +537,13 @@ func (s *Service) runJob(ctx context.Context, jobID string, opts GenerateOptions result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial( ctx, transport, clientOpts, logDir, timestamp, logFn, progressFn, previewFn, ) + if err != nil && fallbackClientOpts != nil && shouldRouteFallbackAttempt(err, rawPath) { + logFn("主上游自动重试失败,切换到备用上游再试一次...") + fallbackTimestamp := timestamp + "-fallback" + result, rawPath, err = client.RequestAndExtractWithRetriesAndPartial( + ctx, transport, *fallbackClientOpts, logDir, fallbackTimestamp, logFn, progressFn, previewFn, + ) + } if err != nil { // 即使失败也把 rawPath 透给前端,「查看日志」按钮直接打开它。 s.emitErrorWithRaw(jobID, err, rawPath) @@ -514,6 +630,34 @@ func apiModeLabel(mode string) string { return "Responses API" } +func shouldRouteFallbackAttempt(err error, rawPath string) bool { + if err == nil { + return false + } + if rawPath != "" { + if rawBytes, readErr := os.ReadFile(rawPath); readErr == nil && client.IsRetryable(string(rawBytes)) { + return true + } + } + lower := strings.ToLower(err.Error()) + for _, marker := range []string{ + "connection reset", + "eof", + "timeout", + "deadline exceeded", + "i/o timeout", + "tls handshake", + "no such host", + "upstream connect error", + "gateway", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + func newJobID() (string, error) { var b [12]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 7430616f0..9c25c7f57 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -58,6 +58,23 @@ type GenerateOptions struct { // DisablePreview forces partial_images = 0 for this request, regardless of // the default partial-image setting. DisablePreview bool `json:"disablePreview,omitempty"` + // AutoRetryEnabled controls whether retryable upstream failures should be + // re-issued automatically. Default true. + AutoRetryEnabled bool `json:"autoRetryEnabled"` + // 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"` +} + +type FallbackProfileOptions struct { + BaseURL string `json:"baseURL"` + APIKey string `json:"apiKey"` + TextModelID string `json:"textModelID"` + ImageModelID string `json:"imageModelID"` + ReasoningEffort string `json:"reasoningEffort"` + APIMode string `json:"apiMode"` + RequestPolicy string `json:"requestPolicy"` + ImagesNewAPICompat bool `json:"imagesNewAPICompat,omitempty"` } // PromptOptimizeOptions is the request shape for one-click prompt revision. @@ -84,7 +101,7 @@ type ProbeUpstreamOptions struct { } type ProbeUpstreamResult struct { - ModelCount int `json:"modelCount"` + ModelCount int `json:"modelCount"` Models []UpstreamModelDescriptor `json:"models,omitempty"` } @@ -182,6 +199,20 @@ type SelectFileResponse struct { PreviewHeight int `json:"previewHeight,omitempty"` } +type BatchInputImage struct { + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + PreviewURL string `json:"previewUrl,omitempty"` + PreviewWidth int `json:"previewWidth,omitempty"` + PreviewHeight int `json:"previewHeight,omitempty"` +} + +type BatchInputDirectory struct { + Directory string `json:"directory"` + Images []BatchInputImage `json:"images"` +} + // ImportedImage describes a freshly imported (drag-dropped or pasted) image. type ImportedImage struct { Path string `json:"path"` diff --git a/image-studio/build/windows/installer/wails_tools.nsh b/image-studio/build/windows/installer/wails_tools.nsh index 2f6d32195..3a02a5cd2 100644 --- a/image-studio/build/windows/installer/wails_tools.nsh +++ b/image-studio/build/windows/installer/wails_tools.nsh @@ -148,6 +148,9 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" !endif + !ifndef WAILS_SKIP_WEBVIEW2_DETAILPRINT + !define WAILS_SKIP_WEBVIEW2_DETAILPRINT "Skipping bundled WebView2 bootstrapper: tmp\\MicrosoftEdgeWebview2Setup.exe not found." + !endif SetRegView 64 # If the admin key exists and is not empty then webview2 is already installed @@ -168,11 +171,17 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" SetDetailsPrint listonly + !if /FileExists "tmp\MicrosoftEdgeWebview2Setup.exe" InitPluginsDir CreateDirectory "$pluginsdir\webview2bootstrapper" SetOutPath "$pluginsdir\webview2bootstrapper" File "tmp\MicrosoftEdgeWebview2Setup.exe" ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + !else + SetDetailsPrint both + DetailPrint "${WAILS_SKIP_WEBVIEW2_DETAILPRINT}" + SetDetailsPrint listonly + !endif SetDetailsPrint both ok: @@ -202,21 +211,13 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macroend !macro wails.associateFiles - ; Create file associations - {{range .Info.FileAssociations}} - !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - File "..\{{.IconName}}.ico" - {{end}} + ; This repo does not currently ship Windows file associations in the + ; manually-invoked NSIS workflow. Keep the macro empty so direct makensis + ; runs do not depend on Wails template expansion. !macroend !macro wails.unassociateFiles - ; Delete app associations - {{range .Info.FileAssociations}} - !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" - - Delete "$INSTDIR\{{.IconName}}.ico" - {{end}} + ; No-op. See wails.associateFiles above. !macroend !macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND @@ -234,16 +235,11 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macroend !macro wails.associateCustomProtocols - ; Create custom protocols associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - {{end}} + ; This repo does not currently ship custom protocol handlers in the + ; manually-invoked NSIS workflow. Keep the macro empty so direct makensis + ; runs do not depend on Wails template expansion. !macroend !macro wails.unassociateCustomProtocols - ; Delete app custom protocol associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" - {{end}} + ; No-op. See wails.associateCustomProtocols above. !macroend diff --git a/image-studio/build/windows/msix/AppxManifest.xml.tmpl b/image-studio/build/windows/msix/AppxManifest.xml.tmpl new file mode 100644 index 000000000..37e99d35f --- /dev/null +++ b/image-studio/build/windows/msix/AppxManifest.xml.tmpl @@ -0,0 +1,55 @@ + + + + + + {{DISPLAY_NAME}} + {{PUBLISHER_DISPLAY_NAME}} + {{DESCRIPTION}} + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index a239531a6..27600aa03 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -1,4 +1,5 @@ import type { + BatchProcessConfig, HistoryItem, LoopGenerationConfig, SourceImage, @@ -175,6 +176,15 @@ function buildWorkspace( autoSaveDir: "", livePreview: true, }; + const batchProcess: BatchProcessConfig = { + enabled: false, + inputDir: "", + outputMode: "source_dir", + outputDir: "", + concurrency: 2, + fileNamePrefix: "processed-", + discoveredSources: [], + }; return { id: workspaceId, name: "联调样例", @@ -193,6 +203,8 @@ function buildWorkspace( userIdentifier: "", partialImages: 1, batchCount: 1, + editSourceMode: "manual", + batchProcess, loopGeneration, sources, currentImageId: currentImage.id, diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx index 629a2d791..e0276f5e7 100644 --- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx +++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx @@ -36,6 +36,7 @@ export function CanvasStage() { toggleFullscreen, batchResults, resultGridOpen, selectBatchResult, closeResultGrid, canvasViewResetTick, + stepBatchResult, } = useStudioStore(); const { isMac } = usePlatform(); const streamPreviewItems = streamPreviewItemsFromPreviews(streamPreviews, { @@ -61,6 +62,8 @@ export function CanvasStage() { } const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1; const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1); + const currentBatchIndex = currentImage ? batchResults.findIndex((item) => item.id === currentImage.id) : -1; + const canNavigateBatchResults = currentBatchIndex >= 0 && batchResults.length > 1; // Hold-space-for-pan: while space is held, override tool to "pan". const [spacePan, setSpacePan] = useState(false); @@ -379,6 +382,7 @@ export function CanvasStage() { useCanvasShortcuts({ brushSize, + canNavigateBatchResults, cancel, compareB, copyCurrentImage: () => { @@ -398,6 +402,9 @@ export function CanvasStage() { errorMessage, isMac, isRunning, + navigateBatchResult: (delta) => { + void stepBatchResult(delta); + }, redo, removeAnnotation, resetView, @@ -477,6 +484,8 @@ export function CanvasStage() { bUrl={compareB.fullUrl} split={compareSplit} onSplit={setCompareSplit} + aLabel="当前图" + bLabel={compareB.id.startsWith("source-preview:") ? "参考图" : "对比图"} /> )} {!showingResultGrid && currentImage && !compareB && hostSize.w > 0 && hostSize.h > 0 && ( diff --git a/image-studio/frontend/src/components/canvas/CompareOverlay.tsx b/image-studio/frontend/src/components/canvas/CompareOverlay.tsx index e27fdeb2d..cab0bfbb3 100644 --- a/image-studio/frontend/src/components/canvas/CompareOverlay.tsx +++ b/image-studio/frontend/src/components/canvas/CompareOverlay.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { useBlobURL } from "../../lib/images"; export function CompareOverlay({ - aBlob, aB64, aUrl, bBlob, bB64, bUrl, split, onSplit, + aBlob, aB64, aUrl, bBlob, bB64, bUrl, split, onSplit, aLabel = "当前图", bLabel = "对比图", }: { aBlob: Blob | null; aB64?: string | null; @@ -12,6 +12,8 @@ export function CompareOverlay({ bUrl?: string | null; split: number; onSplit: (v: number) => void; + aLabel?: string; + bLabel?: string; }) { const wrapRef = useRef(null); const draggingRef = useRef(false); @@ -79,8 +81,8 @@ export function CompareOverlay({ color: "#fff", fontSize: 12, }}>⇆ -
A · 当前图
-
B · 对比图
+
{`A · ${aLabel}`}
+
{`B · ${bLabel}`}
); } diff --git a/image-studio/frontend/src/components/canvas/SourceStrip.tsx b/image-studio/frontend/src/components/canvas/SourceStrip.tsx index 3896242e1..40c7fdd59 100644 --- a/image-studio/frontend/src/components/canvas/SourceStrip.tsx +++ b/image-studio/frontend/src/components/canvas/SourceStrip.tsx @@ -10,6 +10,7 @@ export function SourceStrip() { const removeSource = useStudioStore((s) => s.removeSource); const reorderSources = useStudioStore((s) => s.reorderSources); const viewSourceOnCanvas = useStudioStore((s) => s.viewSourceOnCanvas); + const compareSourceOnCanvas = useStudioStore((s) => s.compareSourceOnCanvas); const mode = useStudioStore((s) => s.mode); const selectSourceImage = useStudioStore((s) => s.selectSourceImage); const { isMac, usesFluentUI, usesAppleUI } = usePlatform(); @@ -32,6 +33,13 @@ export function SourceStrip() { )}
+ {sources.map((s, i) => ( void; compareB: HistoryItem | null; copyCurrentImage: () => void; @@ -12,6 +13,7 @@ type UseCanvasShortcutsArgs = { errorMessage: string | null; isMac: boolean; isRunning: boolean; + navigateBatchResult?: (delta: -1 | 1) => void; redo: () => void; removeAnnotation: (id: string) => void; resetView: () => void; @@ -27,6 +29,7 @@ type UseCanvasShortcutsArgs = { export function useCanvasShortcuts({ brushSize, + canNavigateBatchResults = false, cancel, compareB, copyCurrentImage, @@ -34,6 +37,7 @@ export function useCanvasShortcuts({ errorMessage, isMac, isRunning, + navigateBatchResult, redo, removeAnnotation, resetView, @@ -90,6 +94,11 @@ export function useCanvasShortcuts({ copyCurrentImage(); return; } + if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && canNavigateBatchResults && navigateBatchResult) { + e.preventDefault(); + navigateBatchResult(e.key === "ArrowLeft" ? -1 : 1); + return; + } if (k === "f") { e.preventDefault(); resetView(); @@ -123,6 +132,7 @@ export function useCanvasShortcuts({ return () => window.removeEventListener("keydown", onKeyDown); }, [ brushSize, + canNavigateBatchResults, cancel, compareB, copyCurrentImage, @@ -130,6 +140,7 @@ export function useCanvasShortcuts({ errorMessage, isMac, isRunning, + navigateBatchResult, redo, removeAnnotation, resetView, diff --git a/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx b/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx index 58da6ff1a..7b5f30cd0 100644 --- a/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx +++ b/image-studio/frontend/src/components/history/HistoryPromptGroupModal.tsx @@ -2,6 +2,12 @@ import type React from "react"; import { Clock3, Ellipsis, Images, Layers3 } from "lucide-react"; import { Modal } from "../common/Modal"; import { historyPreviewSrc, useBlobURL } from "../../lib/images"; +import { + buildHistoryItemDragExport, + writeImageFileDragData, + writeInternalHistoryItemDragData, +} from "../../lib/dragExport.ts"; +import { BeginNativeFileDrag } from "../../platform/runtime/host"; import type { HistoryItem } from "../../types/domain"; import { usePlatform } from "../../platform/context"; import { HistoryModeBadge } from "./HistoryModeBadge"; @@ -119,6 +125,8 @@ function HistoryPromptModalThumbnail({ const previewURL = useBlobURL(item.previewBlob ?? item.imageBlob ?? null, item.imageB64 ?? null); const imageSrc = historyPreviewSrc(item, previewURL); const displayIndex = typeof item.batchIndex === "number" ? item.batchIndex + 1 : index + 1; + const dragSpec = buildHistoryItemDragExport(item); + const { isMac } = usePlatform(); function openMenu(event: React.MouseEvent) { event.preventDefault(); @@ -135,14 +143,34 @@ function HistoryPromptModalThumbnail({ void onSelect(item); } + function handleDragStart(event: React.DragEvent) { + if (!dragSpec) { + event.preventDefault(); + return; + } + event.stopPropagation(); + if (isMac && item.savedPath) { + event.preventDefault(); + void BeginNativeFileDrag(item.savedPath).catch((error) => { + console.error("[drag-export] native-file-drag failed", error); + }); + return; + } + event.dataTransfer.effectAllowed = "copy"; + writeInternalHistoryItemDragData(event.dataTransfer, item); + writeImageFileDragData(event.dataTransfer, dragSpec); + } + return ( + + +
+ ) : null} + {desktopHistoryCollapsed ? null : visibleDesktopEntries.length === 0 ? (
{q || modeF !== "all" || dateF !== "all" ? "没有匹配项" : "还没有结果"} diff --git a/image-studio/frontend/src/components/layout/WorkspaceBar.tsx b/image-studio/frontend/src/components/layout/WorkspaceBar.tsx index 88c94d593..9a4078851 100644 --- a/image-studio/frontend/src/components/layout/WorkspaceBar.tsx +++ b/image-studio/frontend/src/components/layout/WorkspaceBar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Plus } from "lucide-react"; import { useStudioStore } from "../../state/studioStore"; import { usePlatform } from "../../platform/context"; @@ -11,6 +11,7 @@ export function WorkspaceBar() { const { isAndroidPhone, isMac, isWindows, usesFluentUI, usesAppleUI } = usePlatform(); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); + const barRef = useRef(null); if (fullscreen) return null; if (isAndroidPhone) return null; @@ -27,8 +28,22 @@ export function WorkspaceBar() { setEditingId(null); } + function handleWheel(event: React.WheelEvent) { + if (event.defaultPrevented || event.ctrlKey) return; + const container = barRef.current; + if (!container) return; + if (container.scrollWidth <= container.clientWidth + 1) return; + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + event.preventDefault(); + container.scrollLeft += event.deltaY; + } + return ( -
+
{workspaces.map((w) => { const active = w.id === activeWorkspaceId; const isEditing = editingId === w.id; diff --git a/image-studio/frontend/src/components/panel/AdvancedParameterBlocks.tsx b/image-studio/frontend/src/components/panel/AdvancedParameterBlocks.tsx index 25371b0bb..1fb1df65b 100644 --- a/image-studio/frontend/src/components/panel/AdvancedParameterBlocks.tsx +++ b/image-studio/frontend/src/components/panel/AdvancedParameterBlocks.tsx @@ -319,7 +319,7 @@ export function AdvancedPartialImagesField({ ))} -

官方 `partial_images` 范围 `0-3`。`0` 只返回最终图,`1-3` 会流式返回预览帧。

+

官方 `partial_images` 范围 `0-3`。`0` 只返回最终图,`1-3` 会流式返回预览帧。高并发批量生成时,应用可能自动关闭预览以优先保证最终图完整。

); } diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx new file mode 100644 index 000000000..42a24824b --- /dev/null +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -0,0 +1,151 @@ +import { FolderOpen, RefreshCw } from "lucide-react"; +import type { BatchProcessConfig, EditSourceMode } from "../../types/domain"; +import { Section, Seg, SegItem } from "./panelChrome"; + +function sampleNames(config: BatchProcessConfig): string { + const names = config.discoveredSources.slice(0, 3).map((item) => item.name); + if (names.length === 0) return "未发现图片"; + if (config.discoveredSources.length <= 3) return names.join("、"); + return `${names.join("、")} 等 ${config.discoveredSources.length} 张`; +} + +export function BatchProcessSection({ + currentImageSavedPath, + editSourceMode, + batchProcess, + setEditSourceMode, + setBatchProcess, + onChooseInputDir, + onChooseOutputDir, + onRefreshInputDir, + usesFluentUI = false, +}: { + currentImageSavedPath?: string | null; + editSourceMode: EditSourceMode; + batchProcess: BatchProcessConfig; + setEditSourceMode: (mode: EditSourceMode) => void; + setBatchProcess: (next: BatchProcessConfig) => void; + onChooseInputDir: () => void; + onChooseOutputDir?: () => void; + onRefreshInputDir: () => void; + usesFluentUI?: boolean; +}) { + const batchMode = editSourceMode === "batch"; + + return ( +
+
+ + setEditSourceMode("manual")}> + 普通图生图 + + setEditSourceMode("batch")}> + 批处理 + + + + {!batchMode ? ( +
+ {currentImageSavedPath + ? "当前画板图会作为隐式源图参与本次编辑,也可以继续手动添加参考图。" + : "手动添加参考图,或从历史里挑一张结果继续编辑。"} +
+ ) : ( +
+
+ 选择一个图片目录后,会按当前 prompt 和参数逐张提交图生图任务。默认保存回原图目录,也可以单独指定输出路径。 +
+ + + +
+ + +
+ +
+ 已扫描 {batchProcess.discoveredSources.length} 张 +
{sampleNames(batchProcess)}
+
+ +
+ 输出位置 + + setBatchProcess({ ...batchProcess, outputMode: "source_dir" })} + > + 默认保存回原目录 + + setBatchProcess({ ...batchProcess, outputMode: "custom_dir" })} + > + 独立输出路径 + + +
+ + {batchProcess.outputMode === "custom_dir" ? ( +
+ 独立输出路径 + setBatchProcess({ ...batchProcess, outputDir: event.target.value })} + placeholder="请选择独立输出路径" + className={`focus-ring w-full border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-[13px] text-zinc-900 dark:border-white/[0.08] dark:text-zinc-100 ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} + /> + {onChooseOutputDir ? ( + + ) : null} +
+ ) : null} + + + +
+ 结果文件名前缀固定为 processed-,遇到同名会自动追加 -2、-3。 +
+
+ )} +
+
+ ); +} diff --git a/image-studio/frontend/src/components/panel/ControlPanel.tsx b/image-studio/frontend/src/components/panel/ControlPanel.tsx index 564d3bd47..c1259c9ae 100644 --- a/image-studio/frontend/src/components/panel/ControlPanel.tsx +++ b/image-studio/frontend/src/components/panel/ControlPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useStudioStore } from "../../state/studioStore"; import { SizeValue, QualityValue, Mode } from "../../types/domain"; import { usePlatform } from "../../platform/context"; +import { ChooseDirectory } from "../../platform/runtime/host"; import { AndroidPhoneComposePanel } from "../../platform/android/AndroidPhoneComposePanel"; import { AndroidPadComposePanel } from "../../platform/android/AndroidPadComposePanel"; import { DesktopAdvancedPanel } from "./DesktopAdvancedPanel"; @@ -20,6 +21,7 @@ import { aspectPresetLabel, availableResolutionPresets, buildAspectSizeSelection, + buildReferenceAspectRatio, buildResolutionSizeSelection, deriveExactSizeSelection, deriveAspectPreset, @@ -38,13 +40,14 @@ export function ControlPanel({ const { apiKey, mode, prompt, background, imageStyle, inputFidelity, moderation, negativePrompt, outputCompression, size, quality, seed, styleTag, userIdentifier, partialImages, - outputFormat, batchCount, loopGeneration, + outputFormat, batchCount, editSourceMode, batchProcess, loopGeneration, sources, currentImage, errorMessage, errorCanRetry, errorRawPath, isRunning, lastPayload, isTestingKey, isOptimizingPrompt, apiMode, requestPolicy, baseURL, profiles, imageModelID, customAspectRatios, setField, clearError, pushToast, - selectSourceImage, removeSource, clearSources, viewSourceOnCanvas, + selectSourceImage, chooseBatchInputDir, refreshBatchInputDir, removeSource, clearSources, viewSourceOnCanvas, + compareSourceOnCanvas, openCustomAspectRatioModal, openCustomSizeModal, openUpstreamConfig, @@ -77,14 +80,25 @@ export function ControlPanel({ const qualityOptions = availableQualityOptions(imageModelID); const allowCustomAspectRatios = supportsCustomAspectRatios(capabilityInput); const allowPreciseSizeControl = supportsPreciseSizeControl(capabilityInput); + const referenceDimensions = sources[0]?.previewWidth && sources[0]?.previewHeight + ? { width: sources[0].previewWidth, height: sources[0].previewHeight } + : currentImage?.previewWidth && currentImage?.previewHeight + ? { width: currentImage.previewWidth, height: currentImage.previewHeight } + : null; + const referenceAspectRatio = referenceDimensions + ? buildReferenceAspectRatio(referenceDimensions.width, referenceDimensions.height, customAspectRatios) + : null; + const sizingAspectRatios = referenceAspectRatio && !customAspectRatios.some((item) => item.id === referenceAspectRatio.id) + ? [...customAspectRatios, referenceAspectRatio] + : customAspectRatios; const activeStyleLabel = STYLE_CHIPS.find((item) => item.id === styleTag)?.label ?? styleTag; - const aspectOptions = listAspectPresetOptions(capabilityInput, customAspectRatios); - const exactSize = deriveExactSizeSelection(normalizedSize, capabilityInput, customAspectRatios); - const derivedAspect = deriveAspectPreset(normalizedSize, customAspectRatios); + const aspectOptions = listAspectPresetOptions(capabilityInput, sizingAspectRatios); + const exactSize = deriveExactSizeSelection(normalizedSize, capabilityInput, sizingAspectRatios); + const derivedAspect = deriveAspectPreset(normalizedSize, sizingAspectRatios); const derivedResolution = deriveResolutionPreset(normalizedSize); const activeAspect = exactSize ? null : derivedAspect; const activeResolution = exactSize ? null : derivedResolution; - const activeAspectLabel = exactSize ? "精确尺寸" : aspectPresetLabel(derivedAspect, customAspectRatios); + const activeAspectLabel = exactSize ? "精确尺寸" : aspectPresetLabel(derivedAspect, sizingAspectRatios); const activeResolutionLabel = exactSize ? exactSize.label : (RESOLUTION_PRESETS.find((item) => item.value === derivedResolution)?.label ?? derivedResolution); @@ -121,14 +135,27 @@ export function ControlPanel({ } function handleResolutionSelect(resolution: typeof activeResolution) { + const referenceAspectPreset = referenceDimensions + ? deriveAspectPreset( + `${referenceDimensions.width}x${referenceDimensions.height}` as SizeValue, + sizingAspectRatios, + ) + : null; setField("size", buildResolutionSizeSelection( derivedAspect, resolution ?? derivedResolution, capabilityInput, - customAspectRatios, + sizingAspectRatios, + referenceAspectPreset, )); } + async function chooseBatchOutputDir() { + const chosen = await ChooseDirectory("选择批处理输出目录").catch(() => ""); + if (!chosen) return; + setField("batchProcess" as any, { ...batchProcess, outputDir: chosen, outputMode: "custom_dir" }); + } + return (
@@ -218,8 +245,12 @@ export function ControlPanel({ apiMode={apiMode} availableResolutions={availableResolutions} batchCount={batchCount} + batchProcess={batchProcess} + chooseBatchInputDir={chooseBatchInputDir} + chooseBatchOutputDir={chooseBatchOutputDir} clearSources={clearSources} currentImageSavedPath={currentImage?.savedPath ?? null} + editSourceMode={editSourceMode} handleAspectSelect={handleAspectSelect} handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} @@ -227,6 +258,7 @@ export function ControlPanel({ allowPreciseSizeControl={allowPreciseSizeControl} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} onOpenCustomSizeModal={openCustomSizeModal} + onRefreshBatchInputDir={refreshBatchInputDir} usesFluentUI={usesFluentUI} mode={mode} onPreviewSource={(index) => void viewSourceOnCanvas(index)} @@ -257,8 +289,12 @@ export function ControlPanel({ activeQualityLabel={activeQualityLabel} availableResolutions={availableResolutions} batchCount={batchCount} + batchProcess={batchProcess} + chooseBatchInputDir={chooseBatchInputDir} + chooseBatchOutputDir={chooseBatchOutputDir} clearSources={clearSources} currentImageSavedPath={currentImage?.savedPath ?? null} + editSourceMode={editSourceMode} handleAspectSelect={handleAspectSelect} handleResolutionSelect={handleResolutionSelect} imageModelID={imageModelID} @@ -266,6 +302,7 @@ export function ControlPanel({ allowPreciseSizeControl={allowPreciseSizeControl} onOpenCustomAspectRatioModal={openCustomAspectRatioModal} onOpenCustomSizeModal={openCustomSizeModal} + onRefreshBatchInputDir={refreshBatchInputDir} mode={mode} onPreviewSource={(index) => void viewSourceOnCanvas(index)} onRemoveSource={removeSource} @@ -281,7 +318,7 @@ export function ControlPanel({ ) : null} {compactMacCompose && ( - void viewSourceOnCanvas(index)} + refreshBatchInputDir={refreshBatchInputDir} + clearSources={clearSources} + compareSourceOnCanvas={(index) => void compareSourceOnCanvas(index)} + viewSourceOnCanvas={(index) => void viewSourceOnCanvas(index)} quality={normalizedQuality} qualityOptions={qualityOptions} Seg={Seg as any} diff --git a/image-studio/frontend/src/components/panel/CustomSizeModal.tsx b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx index cfadaed1b..bcc3cb55d 100644 --- a/image-studio/frontend/src/components/panel/CustomSizeModal.tsx +++ b/image-studio/frontend/src/components/panel/CustomSizeModal.tsx @@ -4,7 +4,10 @@ import { useStudioStore } from "../../state/studioStore"; import { formatSizeValue, MAX_EXACT_SIZE, + MAX_OPENAI_IMAGE_ASPECT_RATIO, + MAX_OPENAI_IMAGE_PIXELS, MIN_EXACT_SIZE, + normalizeExactSizeDimensions, parseSizeValue, reduceAspectRatioLabel, } from "./sizeCapabilities"; @@ -37,9 +40,14 @@ export function CustomSizeModal() { const width = Number(widthInput); const height = Number(heightInput); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return ""; - return width % 8 === 0 && height % 8 === 0 - ? "当前尺寸已对齐到 8 的倍数。" - : "当前尺寸不是 8 的倍数,部分上游可能会拒绝。"; + const normalized = normalizeExactSizeDimensions(width, height); + if (!normalized) { + 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 `当前尺寸满足上游限制:最长边 <= ${MAX_EXACT_SIZE}px,宽高比 <= ${MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素 <= ${MAX_OPENAI_IMAGE_PIXELS}。`; }, [heightInput, widthInput]); const handleApply = (event?: FormEvent) => { @@ -70,7 +78,7 @@ export function CustomSizeModal() {
设置精确尺寸
- 请输入 {MIN_EXACT_SIZE} 到 {MAX_EXACT_SIZE} 之间的整数像素值。 + 请输入 {MIN_EXACT_SIZE} 到 {MAX_EXACT_SIZE} 之间的整数像素值。最长边不能超过 {MAX_EXACT_SIZE}px,宽高比不能超过 {MAX_OPENAI_IMAGE_ASPECT_RATIO}:1,总像素不能超过 {MAX_OPENAI_IMAGE_PIXELS}。
{ratioHint ? ( diff --git a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx index 07078b5d7..888ee79e9 100644 --- a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx +++ b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx @@ -7,7 +7,9 @@ import type { SizeValue, } from "../../types/domain"; import { useBlobURL } from "../../lib/images"; +import type { BatchProcessConfig, EditSourceMode } from "../../types/domain"; import { STYLE_CHIPS } from "./panelOptions"; +import { BatchProcessSection } from "./BatchProcessSection"; import { Section, Seg, SegItem } from "./panelChrome"; import { RESOLUTION_PRESETS, @@ -26,13 +28,18 @@ export function DesktopComposeSections({ allowPreciseSizeControl, apiMode, batchCount, + batchProcess, + chooseBatchInputDir, + chooseBatchOutputDir, clearSources, currentImageSavedPath, + editSourceMode, handleAspectSelect, handleResolutionSelect, imageModelID, onOpenCustomAspectRatioModal, onOpenCustomSizeModal, + onRefreshBatchInputDir, onRemoveSource, onPreviewSource, mode, @@ -55,13 +62,18 @@ export function DesktopComposeSections({ allowPreciseSizeControl: boolean; apiMode: "responses" | "images"; batchCount: number; + batchProcess: BatchProcessConfig; + chooseBatchInputDir: () => void; + chooseBatchOutputDir: () => void; clearSources: () => void; currentImageSavedPath?: string | null; + editSourceMode: EditSourceMode; handleAspectSelect: (aspect: AspectPreset) => void; handleResolutionSelect: (resolution: ResolutionPreset) => void; imageModelID: string; onOpenCustomAspectRatioModal: () => void; onOpenCustomSizeModal: () => void; + onRefreshBatchInputDir: () => void; usesFluentUI: boolean; mode: Mode; onRemoveSource: (index: number) => void; @@ -225,7 +237,20 @@ export function DesktopComposeSections({
{mode === "edit" ? ( -
0 ? ` · ${sources.length} 张` : ""}`}> + <> + setField("editSourceMode" as any, next)} + setBatchProcess={(next) => setField("batchProcess" as any, next)} + onChooseInputDir={chooseBatchInputDir} + onChooseOutputDir={chooseBatchOutputDir} + onRefreshInputDir={onRefreshBatchInputDir} + usesFluentUI={usesFluentUI} + /> + {editSourceMode === "manual" ? ( +
0 ? ` · ${sources.length} 张` : ""}`}>
{sources.length === 0 && currentImageSavedPath ? (
@@ -262,7 +287,9 @@ export function DesktopComposeSections({ ) : null}
-
+
+ ) : null} + ) : null} ); diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index f68c3faf4..c1f8ad224 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -75,6 +75,18 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void
+
+ 有 Web 端吗? +
+

+ 当前没有独立部署的在线 Web 版。正式交付形态仍然是桌面端(Windows / macOS / Linux)和 Android 壳层。 +

+

+ 仓库里有浏览器预览 / 调试形态,主要用于前端开发和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。 +

+
+
+
能换其他文本 / 图像模型吗?
@@ -103,7 +115,8 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void 生成失败 / 504 / 524 怎么办?

- 上游网关超时(Cloudflare 504/524)在中转站上很常见。本应用自动重试 3 次,每次间隔 15 秒。 + 上游网关超时(Cloudflare 504/524)在中转站上很常见。本应用默认自动重试 3 次,每次间隔 15 秒。 + 你也可以在「设置 → 失败自动重试」里关闭它,避免测试问题时重复扣费。 如果三次都失败:

    @@ -125,7 +138,7 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void
  • background / output_compression:属于 OpenAI 官方图像字段。仅 GPT 图像模型支持;压缩只对 JPEG / WebP 生效,透明背景需要 PNG / WebP
  • input_fidelity:用于图生图/参考图流程。`gpt-image-2` 要省略此字段;其它支持该能力的 GPT 图像模型可选 `low` / `high`
  • style:仅 `dall-e-3` 文生图支持,值为 `vivid` / `natural`
  • -
  • partial_images:属于 OpenAI 官方流式图像字段。当前支持 `0-3`,`0` 只返回最终图,`1-3` 会流式返回预览帧
  • +
  • partial_images:属于 OpenAI 官方流式图像字段。当前支持 `0-3`,`0` 只返回最终图,`1-3` 会流式返回预览帧;高并发批量生成时,应用可能自动关闭预览以优先保证最终图完整
  • 稳定用户标识:属于 OpenAI 官方请求字段。Responses 模式映射到 `safety_identifier`,Images API 模式映射到 `user`,建议传哈希后的用户标识
  • seed / negative prompt:属于 relay 常见扩展字段。只有在 `兼容中转扩展` 策略下才会附带发送,OpenAI 标准模式默认不发
  • moderation:属于 OpenAI 官方图像字段。当前支持 `low` 和 `auto`,仅在 GPT 图像模型路径上发送
  • diff --git a/image-studio/frontend/src/components/panel/FloatingAdvancedPanel.tsx b/image-studio/frontend/src/components/panel/FloatingAdvancedPanel.tsx index 87f926420..2bbe3b9d9 100644 --- a/image-studio/frontend/src/components/panel/FloatingAdvancedPanel.tsx +++ b/image-studio/frontend/src/components/panel/FloatingAdvancedPanel.tsx @@ -457,7 +457,7 @@ export function FloatingAdvancedPanel({ open={groupOpen.stream} onToggle={() => updateGroupOpen("stream")} > - + setField("partialImages", value)} diff --git a/image-studio/frontend/src/components/panel/MacComposePanel.tsx b/image-studio/frontend/src/components/panel/MacComposePanel.tsx index aebcb4a36..fcadf843e 100644 --- a/image-studio/frontend/src/components/panel/MacComposePanel.tsx +++ b/image-studio/frontend/src/components/panel/MacComposePanel.tsx @@ -1,4 +1,4 @@ -import type { APIMode, QualityValue, RequestPolicy, SourceImage } from "../../types/domain"; +import type { APIMode, BatchProcessConfig, EditSourceMode, QualityValue, RequestPolicy, SourceImage } from "../../types/domain"; import { type AspectPreset, type AspectPresetOption, @@ -23,9 +23,13 @@ export function MacComposePanel({ allowPreciseSizeControl, availableResolutions, batchCount, + batchProcess, + chooseBatchInputDir, + chooseBatchOutputDir, mode, sources, currentImage, + editSourceMode, apiMode, requestPolicy, imageModelID, @@ -34,8 +38,10 @@ export function MacComposePanel({ handleResolutionSelect, onOpenCustomAspectRatioModal, onOpenCustomSizeModal, + refreshBatchInputDir, selectSourceImage, clearSources, + compareSourceOnCanvas, viewSourceOnCanvas, quality, qualityOptions, @@ -57,9 +63,13 @@ export function MacComposePanel({ allowPreciseSizeControl: boolean; availableResolutions: ResolutionPreset[]; batchCount: number; + batchProcess: BatchProcessConfig; + chooseBatchInputDir: () => void; + chooseBatchOutputDir: () => void; mode: string; sources: SourceImage[]; currentImage: { savedPath?: string } | null; + editSourceMode: EditSourceMode; apiMode: APIMode; requestPolicy: RequestPolicy; imageModelID: string; @@ -68,8 +78,10 @@ export function MacComposePanel({ handleResolutionSelect: (resolution: ResolutionPreset) => void; onOpenCustomAspectRatioModal: () => void; onOpenCustomSizeModal: () => void; + refreshBatchInputDir: () => void; selectSourceImage: () => void; clearSources: () => void; + compareSourceOnCanvas: (index: number) => void; viewSourceOnCanvas: (index: number) => void; quality: QualityValue; qualityOptions: Array<{ value: QualityValue; label: string }>; @@ -119,8 +131,15 @@ export function MacComposePanel({ {mode === "edit" && ( void; + chooseBatchOutputDir: () => void; + compareSourceOnCanvas: (index: number) => void; clearSources: () => void; currentImageSavedPath?: string | null; + editSourceMode: EditSourceMode; + refreshBatchInputDir: () => void; + setField: (key: string, value: any) => void; selectSourceImage: () => void; viewSourceOnCanvas: (index: number) => void; sources: SourceImage[]; }) { return (
    -
    - 源图片 / 参考图 - - {sources.length > 0 ? `${sources.length} 张` : currentImageSavedPath ? "已就绪" : "未添加"} - -
    -
    + setField("editSourceMode", next)} + setBatchProcess={(next) => setField("batchProcess", next)} + onChooseInputDir={chooseBatchInputDir} + onChooseOutputDir={chooseBatchOutputDir} + onRefreshInputDir={refreshBatchInputDir} + /> + {editSourceMode === "manual" ? ( +
    {sources.length > 0 ? "已添加显式参考图,可继续追加、替换或拖入更多图片。" @@ -51,6 +71,14 @@ export function MacComposeSources({ > 添加图片 + {sources.length > 0 ? ( + + ) : null} {sources.length > 0 ? (
    -
    +
    + ) : null}
    ); } diff --git a/image-studio/frontend/src/components/panel/PromptEditorSection.tsx b/image-studio/frontend/src/components/panel/PromptEditorSection.tsx index 7f2a1dc0a..fd04c7440 100644 --- a/image-studio/frontend/src/components/panel/PromptEditorSection.tsx +++ b/image-studio/frontend/src/components/panel/PromptEditorSection.tsx @@ -1,11 +1,13 @@ -import { lazy, Suspense, useRef } from "react"; +import { lazy, Suspense, useRef, useState } from "react"; import { ListPlus, Sparkles } from "lucide-react"; import { submitShortcutLabel } from "../../platform"; import { usePlatform } from "../../platform/context"; import { useStudioStore } from "../../state/studioStore"; import { ParameterPresetsSection } from "./ParameterPresetsSection"; +import { appendPromptTemplateText } from "../../lib/promptTemplateInsert"; const PromptPopover = lazy(() => import("./PromptPopover").then((m) => ({ default: m.PromptPopover }))); +const PromptTemplateManagerModal = lazy(() => import("./PromptTemplateManagerModal").then((m) => ({ default: m.PromptTemplateManagerModal }))); export function PromptEditorSection({ mode, @@ -29,7 +31,14 @@ export function PromptEditorSection({ onOptimizePrompt: () => void; }) { const { isMac, usesFluentUI } = usePlatform(); + const promptTemplates = useStudioStore((s) => s.promptTemplates); const promptPopoverAnchorRef = useRef(null); + const [templateManagerOpen, setTemplateManagerOpen] = useState(false); + + function appendTemplate(text: string) { + const current = useStudioStore.getState().prompt; + onSetPrompt(appendPromptTemplateText(current, text)); + } return (
    @@ -55,6 +64,26 @@ export function PromptEditorSection({ onChange={(e) => onSetPrompt(e.target.value)} className={`focus-ring w-full resize-y border border-black/[0.08] bg-[var(--surface)] text-zinc-900 placeholder:text-zinc-400 dark:border-white/[0.08] dark:text-zinc-100 dark:placeholder:text-zinc-500 ${usesFluentUI ? "min-h-[124px] rounded-[10px] px-3.5 py-3 text-[14px] leading-[1.65]" : isMac ? "min-h-[176px] rounded-[18px] px-4 py-3.5 text-[15px] leading-[1.72]" : "min-h-[124px] rounded-[14px] px-3.5 py-3 text-[14px] leading-[1.65]"}`} /> +
    + + {promptTemplates.map((item) => ( + + ))} +
    @@ -76,10 +105,8 @@ export function PromptEditorSection({ setPromptPopover(false)} - onPick={(text) => { - const current = useStudioStore.getState().prompt; - onSetPrompt(current ? `${current}\n${text}` : text); - }} + onManageTemplates={() => setTemplateManagerOpen(true)} + onPick={appendTemplate} /> )} @@ -103,6 +130,11 @@ export function PromptEditorSection({ {submitShortcutLabel}
    + {templateManagerOpen ? ( + + setTemplateManagerOpen(false)} /> + + ) : null}
    ); } diff --git a/image-studio/frontend/src/components/panel/PromptPopover.tsx b/image-studio/frontend/src/components/panel/PromptPopover.tsx index dd1b8bc5b..9a06074f3 100644 --- a/image-studio/frontend/src/components/panel/PromptPopover.tsx +++ b/image-studio/frontend/src/components/panel/PromptPopover.tsx @@ -18,13 +18,16 @@ const PROMPT_TEMPLATES: { label: string; text: string }[] = [ export function PromptPopover({ anchorRef, onClose, + onManageTemplates, onPick, }: { anchorRef: React.RefObject; onClose: () => void; + onManageTemplates: () => void; onPick: (text: string) => void; }) { const history = useStudioStore((s) => s.promptHistory); + const promptTemplates = useStudioStore((s) => s.promptTemplates); const [tab, setTab] = useState<"templates" | "history">("templates"); const { isMac, usesFluentUI, usesAppleUI } = usePlatform(); const popoverRef = useRef(null); @@ -105,16 +108,48 @@ export function PromptPopover({
- {tab === "templates" && PROMPT_TEMPLATES.map((t) => ( - - ))} + {tab === "templates" && ( +
+
+
自定义模板
+ +
+ {promptTemplates.length === 0 ? ( +
+ 还没有自定义模板 +
+ ) : ( + promptTemplates.map((item) => ( + + )) + )} + +
内置模板
+ {PROMPT_TEMPLATES.map((t) => ( + + ))} +
+ )} {tab === "history" && ( history.length === 0 ? (
diff --git a/image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx b/image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx new file mode 100644 index 000000000..459f44cb7 --- /dev/null +++ b/image-studio/frontend/src/components/panel/PromptTemplateManagerModal.tsx @@ -0,0 +1,178 @@ +import { useEffect, useMemo, useState } from "react"; +import { Edit3, Plus, Save, Trash2 } from "lucide-react"; +import { Modal } from "../common/Modal"; +import { useStudioStore } from "../../state/studioStore"; +import { + NEW_PROMPT_TEMPLATE_ID, + nextDefaultPromptTemplateLabel, + resolvePromptTemplateManagerSelection, +} from "../../lib/promptTemplates"; +import { usePlatform } from "../../platform/context"; + +export function PromptTemplateManagerModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { + prompt, + promptTemplates, + addPromptTemplate, + updatePromptTemplate, + deletePromptTemplate, + pushToast, + } = useStudioStore(); + const { usesFluentUI, isAndroidPhone } = usePlatform(); + const [selectedId, setSelectedId] = useState(""); + const [draftLabel, setDraftLabel] = useState(""); + const [draftText, setDraftText] = useState(""); + + const selection = useMemo( + () => resolvePromptTemplateManagerSelection(promptTemplates, selectedId), + [promptTemplates, selectedId], + ); + const selectedTemplate = selection.mode === "selected" ? selection.template : null; + + useEffect(() => { + if (!open) return; + if (selection.selectedId !== selectedId) { + setSelectedId(selection.selectedId); + } + if (!selection.initializeDraft) return; + if (selection.mode === "selected") { + setDraftLabel(selection.template.label); + setDraftText(selection.template.text); + return; + } + setDraftLabel(nextDefaultPromptTemplateLabel(promptTemplates)); + setDraftText(""); + }, [open, promptTemplates, selectedId, selection]); + + function startCreate(fromCurrentPrompt: boolean) { + setSelectedId(NEW_PROMPT_TEMPLATE_ID); + setDraftLabel(nextDefaultPromptTemplateLabel(promptTemplates)); + setDraftText(fromCurrentPrompt ? prompt.trim() : ""); + } + + function saveTemplate() { + if (selectedTemplate) { + const ok = updatePromptTemplate(selectedTemplate.id, { label: draftLabel, text: draftText }); + if (!ok) { + pushToast("模板标题和内容不能为空", "warn"); + return; + } + pushToast(`已更新模板「${draftLabel.trim()}」`, "success"); + return; + } + const id = addPromptTemplate(draftLabel, draftText); + if (!id) { + pushToast("模板标题和内容不能为空", "warn"); + return; + } + setSelectedId(id); + pushToast(`已保存模板「${draftLabel.trim()}」`, "success"); + } + + function deleteCurrentTemplate() { + if (!selectedTemplate) return; + if (!window.confirm(`确定删除模板「${selectedTemplate.label}」吗?`)) return; + deletePromptTemplate(selectedTemplate.id); + pushToast(`已删除模板「${selectedTemplate.label}」`, "success"); + const remaining = promptTemplates.filter((item) => item.id !== selectedTemplate.id); + if (remaining[0]) { + setSelectedId(remaining[0].id); + setDraftLabel(remaining[0].label); + setDraftText(remaining[0].text); + } else { + startCreate(false); + } + } + + return ( + +
+
+
+ + +
+
+ {promptTemplates.length === 0 ? ( +
+ 还没有自定义模板 +
+ ) : ( + promptTemplates.map((item) => ( + + )) + )} +
+
+ +
+
+ {selectedTemplate ? "编辑模板" : "新建模板"} +
+
+ setDraftLabel(e.target.value)} + placeholder="模板标题" + className={`focus-ring w-full border border-black/[0.08] bg-[var(--surface)] px-3 py-2 text-[14px] text-zinc-900 dark:border-white/[0.08] dark:text-zinc-100 ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} + /> +
+