From 4307ecae2d9a81883d167a97b1bf43168bb7cc2e Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 14:57:22 +0800 Subject: [PATCH 01/11] Fix canvas image navigation by createdAt --- .../src/components/canvas/CanvasStage.tsx | 11 +++++++---- image-studio/frontend/src/lib/images.ts | 18 ++++++++++++++++++ .../android/canvas/AndroidCanvasStage.tsx | 11 +++++++---- .../frontend/src/state/studioStore.media.ts | 16 +++++++++++----- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx index e0276f5e7..eec4c3cff 100644 --- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx +++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx @@ -14,7 +14,7 @@ import { AnnotationShape } from "./AnnotationShape"; import { useCanvasShortcuts } from "./useCanvasShortcuts"; import { StreamPreviewBadge } from "./StreamPreviewBadge"; import { streamPreviewItemsFromPreviews } from "../../state/studioStore.streamPreview"; -import { historyFullSrc } from "../../lib/images"; +import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../lib/images"; import { DragExportHandle } from "./DragExportHandle"; export function CanvasStage() { @@ -34,6 +34,7 @@ export function CanvasStage() { jobsTotal, jobsCompleted, toggleFullscreen, + history, batchResults, resultGridOpen, selectBatchResult, closeResultGrid, canvasViewResetTick, stepBatchResult, @@ -48,6 +49,8 @@ export function CanvasStage() { outputFormat: useStudioStore.getState().outputFormat, currentImage, }); + const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); + const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); for (const item of batchResults) { @@ -62,8 +65,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; + const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; + const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; // Hold-space-for-pan: while space is held, override tool to "pan". const [spacePan, setSpacePan] = useState(false); @@ -465,7 +468,7 @@ export function CanvasStage() { ) : null} {showingResultGrid && ( (items: T[]): T[] { + return [...items].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); +} + +export function orderedNavigationItemsForCurrent( + currentId: string | null | undefined, + historyItems: T[], + batchItems: T[], +): T[] { + const orderedBatchItems = sortHistoryItemsByCreatedAtAsc(batchItems); + if (currentId && orderedBatchItems.some((item) => item.id === currentId)) return orderedBatchItems; + + const orderedHistoryItems = sortHistoryItemsByCreatedAtAsc(historyItems); + if (currentId && orderedHistoryItems.some((item) => item.id === currentId)) return orderedHistoryItems; + + return orderedBatchItems.length > 1 ? orderedBatchItems : orderedHistoryItems; +} + export function isTransientPreviewItem( item: { id?: string | null; diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx index b6ddacefd..db63f4af2 100644 --- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx +++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx @@ -10,7 +10,7 @@ import { CompareOverlay } from "../../../components/canvas/CompareOverlay"; import { copyImageB64ToClipboard, copyImageURLToClipboard, useImageFromSource } from "../../../components/canvas/canvasImage"; import { StreamPreviewBadge } from "../../../components/canvas/StreamPreviewBadge"; import { useCanvasShortcuts } from "../../../components/canvas/useCanvasShortcuts"; -import { historyFullSrc } from "../../../lib/images"; +import { historyFullSrc, orderedNavigationItemsForCurrent, sortHistoryItemsByCreatedAtAsc } from "../../../lib/images"; import { streamPreviewItemsFromPreviews } from "../../../state/studioStore.streamPreview"; import { vibrateForPlatform } from "../bridge"; @@ -43,6 +43,7 @@ export function AndroidCanvasStage() { jobsCompleted, activeWorkspaceId, toggleFullscreen, + history, batchResults, resultGridOpen, selectBatchResult, closeResultGrid, canvasViewResetTick, stepBatchResult, @@ -56,6 +57,8 @@ export function AndroidCanvasStage() { outputFormat: useStudioStore.getState().outputFormat, currentImage, }); + const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); + const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); for (const item of batchResults) { @@ -70,8 +73,8 @@ export function AndroidCanvasStage() { } 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; + const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; + const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; const stageRef = useRef(null); const roRef = useRef(null); @@ -463,7 +466,7 @@ export function AndroidCanvasStage() { > {showingResultGrid ? ( item.id === state.currentImage?.id); + if (!state.currentImage) return; + const navigationItems = orderedNavigationItemsForCurrent( + state.currentImage.id, + state.history, + state.batchResults, + ); + if (navigationItems.length <= 1) return; + const currentIndex = navigationItems.findIndex((item) => item.id === state.currentImage?.id); if (currentIndex < 0) return; - const nextIndex = (currentIndex + delta + state.batchResults.length) % state.batchResults.length; - const nextItem = state.batchResults[nextIndex]; + const nextIndex = (currentIndex + delta + navigationItems.length) % navigationItems.length; + const nextItem = navigationItems[nextIndex]; if (!nextItem) return; await store.getState().selectBatchResult(nextItem); }, From 508e72cfc61bf9ee233fdfeb3ef14dabbd9447b9 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 17:01:47 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/panel/SettingsPanel.tsx | 69 +++++++++++++- image-studio/frontend/src/lib/compatState.ts | 12 +++ .../src/lib/completionNotification.ts | 86 ++++++++++++++++++ .../frontend/src/state/studioStore.ts | 55 +++++++++++- .../frontend/src/state/studioStore.types.ts | 6 ++ image-studio/frontend/src/types/domain.ts | 5 ++ .../test/completionNotification.test.mjs | 90 +++++++++++++++++++ 7 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 image-studio/frontend/src/lib/completionNotification.ts create mode 100644 image-studio/frontend/test/completionNotification.test.mjs diff --git a/image-studio/frontend/src/components/panel/SettingsPanel.tsx b/image-studio/frontend/src/components/panel/SettingsPanel.tsx index 453973055..7fd76f7fa 100644 --- a/image-studio/frontend/src/components/panel/SettingsPanel.tsx +++ b/image-studio/frontend/src/components/panel/SettingsPanel.tsx @@ -7,7 +7,7 @@ import { useStudioStore } from "../../state/studioStore"; import { GetOutputDir, OpenOutputDir, OpenExternalURL, ChooseOutputDir, SetOutputDir, } from "../../platform/runtime/host"; -import type { KernelRuntimeMode, ProxyMode } from "../../types/domain"; +import type { KernelRuntimeMode, ProxyMode, SystemNotificationPermissionState } from "../../types/domain"; import { Modal } from "../common/Modal"; import { rememberTrustedOutputRoot } from "../../lib/storage"; import { scheduleCompatibilityExport } from "../../lib/compatState"; @@ -45,11 +45,15 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => keepLogs, setKeepLogs, cleanupPreviewCacheOnExit, setCleanupPreviewCacheOnExit, completionSound, + completionNotification, + completionNotificationPermission, setCompletionSoundEnabled, setCompletionSoundMode, setCompletionSoundCustom, resetCompletionSoundCustom, previewCompletionSound, + setCompletionNotificationEnabled, + requestCompletionNotificationPermission, } = useStudioStore(); const [outputDir, setOutputDir] = useState(""); @@ -138,6 +142,47 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => input.click(); } + function systemNotificationPermissionLabel(permission: SystemNotificationPermissionState): string { + if (permission === "granted") return "已允许"; + if (permission === "denied") return "已拒绝"; + if (permission === "unsupported") return "当前平台不支持"; + return "尚未授权"; + } + + async function updateCompletionNotificationEnabled(value: boolean) { + const permission = await setCompletionNotificationEnabled(value); + if (!value) { + pushToast("已关闭系统通知", "success"); + return; + } + if (permission === "granted") { + pushToast("已开启系统通知", "success"); + return; + } + if (permission === "unsupported") { + pushToast("当前平台不支持系统通知", "warn", 5000); + return; + } + if (permission === "denied") { + pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000); + return; + } + pushToast("请先允许系统通知权限", "warn"); + } + + async function askCompletionNotificationPermission() { + const permission = await requestCompletionNotificationPermission(); + if (permission === "granted") { + pushToast("系统通知权限已授权", "success"); + } else if (permission === "denied") { + pushToast("系统通知权限已被拒绝,请在系统设置中允许后重试", "warn", 6000); + } else if (permission === "unsupported") { + pushToast("当前平台不支持系统通知", "warn", 5000); + } else { + pushToast("系统通知权限仍未授权", "warn"); + } + } + function closeSettings() { setAboutOpen(false); onClose(); @@ -475,6 +520,28 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>

+ +
+ void updateCompletionNotificationEnabled(true)}> + 开启 + + void updateCompletionNotificationEnabled(false)}> + 关闭 + +
+
+ +
+

+ 整批任务全部完成后,仅在窗口不在前台时发送系统通知。当前权限:{systemNotificationPermissionLabel(completionNotificationPermission)}。 +

+
+
setTheme("system")}> diff --git a/image-studio/frontend/src/lib/compatState.ts b/image-studio/frontend/src/lib/compatState.ts index 6bbed1dcf..4f45ff4b8 100644 --- a/image-studio/frontend/src/lib/compatState.ts +++ b/image-studio/frontend/src/lib/compatState.ts @@ -2,6 +2,7 @@ import { LoadCompatibilityState, SaveCompatibilityState } from "../platform/runt import type { BatchProcessConfig, BackgroundValue, + CompletionNotificationConfig, CustomAspectRatio, EditSourceMode, CompletionSoundConfig, @@ -33,6 +34,10 @@ import { normalizeCompletionSoundConfig, persistCompletionSoundConfig, } from "./completionSound"; +import { + normalizeCompletionNotificationConfig, + persistCompletionNotificationConfig, +} from "./completionNotification"; import { normalizePromptTemplates } from "./promptTemplates"; const SCHEMA_VERSION = 1; @@ -69,6 +74,7 @@ export type CompatibilityState = { cleanupPreviewCacheOnExit?: boolean; ignoredReleaseTag?: string; completionSound?: CompletionSoundConfig; + completionNotification?: CompletionNotificationConfig; }; profiles: UpstreamProfile[]; activeProfileId: string; @@ -103,6 +109,7 @@ export type CompatibilityExportInput = { cleanupPreviewCacheOnExit: boolean; ignoredReleaseTag: string; completionSound: CompletionSoundConfig; + completionNotification: CompletionNotificationConfig; }; let exportTimer: ReturnType | null = null; @@ -162,6 +169,7 @@ export function compatibilityExportFingerprint(input: CompatibilityExportInput): cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit, ignoredReleaseTag: input.ignoredReleaseTag, completionSound: input.completionSound, + completionNotification: input.completionNotification, outputDir: readLocalStorageString("gptcodex.outputDir"), trustedOutputRoots: loadTrustedOutputRoots(), savePromptSuppressed: readLocalStorageString("gptcodex.savePromptSuppressed") === "1", @@ -202,6 +210,7 @@ function buildCompatibilityState(input: CompatibilityExportInput): Compatibility cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit === true, ignoredReleaseTag: readLocalStorageString("gptcodex.ignoredReleaseTag"), completionSound: normalizeCompletionSoundConfig(input.completionSound), + completionNotification: normalizeCompletionNotificationConfig(input.completionNotification), }, profiles: normalizeProfiles(input.profiles), activeProfileId: input.activeProfileId || "", @@ -254,6 +263,7 @@ function applyCompatibilityLocalStorage(state: CompatibilityState): void { removeLocalStorage("gptcodex.ignoredReleaseTag"); } persistCompletionSoundConfig(normalizeCompletionSoundConfig(settings.completionSound)); + persistCompletionNotificationConfig(normalizeCompletionNotificationConfig(settings.completionNotification)); } async function persistCompatibilityHistory(state: CompatibilityState): Promise { @@ -320,6 +330,7 @@ function normalizeSettings(raw: unknown): CompatibilityState["settings"] { cleanupPreviewCacheOnExit: source.cleanupPreviewCacheOnExit === true, ignoredReleaseTag: typeof source.ignoredReleaseTag === "string" ? source.ignoredReleaseTag.trim() : "", completionSound: normalizeCompletionSoundConfig(source.completionSound), + completionNotification: normalizeCompletionNotificationConfig(source.completionNotification), }; } @@ -425,6 +436,7 @@ function cloneExportInput(input: CompatibilityExportInput): CompatibilityExportI cleanupPreviewCacheOnExit: input.cleanupPreviewCacheOnExit, ignoredReleaseTag: input.ignoredReleaseTag, completionSound: normalizeCompletionSoundConfig(input.completionSound), + completionNotification: normalizeCompletionNotificationConfig(input.completionNotification), }; } diff --git a/image-studio/frontend/src/lib/completionNotification.ts b/image-studio/frontend/src/lib/completionNotification.ts new file mode 100644 index 000000000..ee9c7d88c --- /dev/null +++ b/image-studio/frontend/src/lib/completionNotification.ts @@ -0,0 +1,86 @@ +import type { + CompletionNotificationConfig, + SystemNotificationPermissionState, +} from "../types/domain"; + +const COMPLETION_NOTIFICATION_ENABLED_KEY = "gptcodex.completionNotification.enabled"; + +export function normalizeCompletionNotificationConfig(raw: unknown): CompletionNotificationConfig { + const source = raw && typeof raw === "object" ? raw as Record : {}; + return { + enabled: source.enabled !== false, + }; +} + +export function readCompletionNotificationConfig(): CompletionNotificationConfig { + try { + return normalizeCompletionNotificationConfig({ + enabled: localStorage.getItem(COMPLETION_NOTIFICATION_ENABLED_KEY) !== "0", + }); + } catch { + return normalizeCompletionNotificationConfig(null); + } +} + +export function persistCompletionNotificationConfig(value: CompletionNotificationConfig): void { + const next = normalizeCompletionNotificationConfig(value); + try { + localStorage.setItem(COMPLETION_NOTIFICATION_ENABLED_KEY, next.enabled ? "1" : "0"); + } catch { + // localStorage can be unavailable in tests/previews. + } +} + +export function readSystemNotificationPermission(): SystemNotificationPermissionState { + try { + if (typeof Notification === "undefined") return "unsupported"; + if (Notification.permission === "granted") return "granted"; + if (Notification.permission === "denied") return "denied"; + return "default"; + } catch { + return "unsupported"; + } +} + +export async function requestSystemNotificationPermission(): Promise { + const current = readSystemNotificationPermission(); + if (current === "unsupported" || current === "granted") return current; + try { + if (typeof Notification === "undefined" || typeof Notification.requestPermission !== "function") { + return current; + } + const next = await Notification.requestPermission(); + if (next === "granted" || next === "denied" || next === "default") return next; + } catch {} + return readSystemNotificationPermission(); +} + +export function shouldSendCompletionNotification(input: { + config: CompletionNotificationConfig; + completedNow: number; + totalNow: number; + windowHidden: boolean; +}): boolean { + const next = normalizeCompletionNotificationConfig(input.config); + return next.enabled + && input.windowHidden + && input.totalNow > 0 + && input.completedNow === input.totalNow; +} + +export function showSystemNotification(title: string, body: string, onClick?: () => void): boolean { + if (readSystemNotificationPermission() !== "granted") return false; + try { + const notification = new Notification(title, { body }); + if (onClick) { + notification.onclick = () => { + try { window.focus(); } catch {} + onClick(); + notification.close(); + }; + } + return true; + } catch { + return false; + } +} diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index f2585843a..cf599853d 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -80,6 +80,15 @@ import { readCompletionSoundConfig, shouldPlayCompletionSound, } from "../lib/completionSound"; +import { + normalizeCompletionNotificationConfig, + persistCompletionNotificationConfig, + readCompletionNotificationConfig, + readSystemNotificationPermission, + requestSystemNotificationPermission, + shouldSendCompletionNotification, + showSystemNotification, +} from "../lib/completionNotification"; import { persistPromptTemplates, readStoredPromptTemplates, @@ -160,7 +169,6 @@ import { ensureFullHistoryItem as ensureFullHistoryItemRuntime, materializeHistoryItem as materializeHistoryItemRuntime, STYLE_SUFFIXES, - tryNotify, withMediaAssetRef, } from "./studioStore.runtime"; import { createMediaActions } from "./studioStore.media"; @@ -601,6 +609,8 @@ export const useStudioStore = create((set, get) => ({ keepLogs: readKeepLogs(), cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(), completionSound: readCompletionSoundConfig(), + completionNotification: readCompletionNotificationConfig(), + completionNotificationPermission: readSystemNotificationPermission(), ignoredReleaseTag: readIgnoredReleaseTag(), appUpdate: null, appUpdateModalOpen: false, @@ -790,6 +800,30 @@ export const useStudioStore = create((set, get) => ({ previewCompletionSound: async () => { await playCompletionSound(get().completionSound, { force: true }); }, + setCompletionNotificationEnabled: async (value) => { + if (!value) { + const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: false }); + persistCompletionNotificationConfig(next); + const permission = readSystemNotificationPermission(); + set({ + completionNotification: next, + completionNotificationPermission: permission, + }); + return permission; + } + const permission = await requestSystemNotificationPermission(); + set({ completionNotificationPermission: permission }); + if (permission !== "granted") return permission; + const next = normalizeCompletionNotificationConfig({ ...get().completionNotification, enabled: true }); + persistCompletionNotificationConfig(next); + set({ completionNotification: next }); + return permission; + }, + requestCompletionNotificationPermission: async () => { + const permission = await requestSystemNotificationPermission(); + set({ completionNotificationPermission: permission }); + return permission; + }, workspaces: [], activeWorkspaceId: "", styleTag: "", @@ -1406,6 +1440,8 @@ export const useStudioStore = create((set, get) => ({ keepLogs: readKeepLogs(), cleanupPreviewCacheOnExit: readCleanupPreviewCacheOnExit(), completionSound: readCompletionSoundConfig(), + completionNotification: readCompletionNotificationConfig(), + completionNotificationPermission: readSystemNotificationPermission(), ignoredReleaseTag: readIgnoredReleaseTag(), appUpdate: null, appUpdateModalOpen: false, @@ -1452,6 +1488,8 @@ export const useStudioStore = create((set, get) => ({ const keepLogs = readKeepLogs(); const cleanupPreviewCacheOnExit = readCleanupPreviewCacheOnExit(); const completionSound = readCompletionSoundConfig(); + const completionNotification = readCompletionNotificationConfig(); + const completionNotificationPermission = readSystemNotificationPermission(); const ignoredReleaseTag = readIgnoredReleaseTag(); const updateInfo = normalizeAppUpdateInfo(await CheckForAppUpdate().catch(() => null)); const shouldShowUpdate = !!updateInfo?.hasUpdate && updateInfo.releaseTag !== ignoredReleaseTag; @@ -1697,6 +1735,8 @@ export const useStudioStore = create((set, get) => ({ keepLogs, cleanupPreviewCacheOnExit, completionSound, + completionNotification, + completionNotificationPermission, ignoredReleaseTag, appUpdate: shouldShowUpdate ? updateInfo : null, appUpdateModalOpen: shouldShowUpdate, @@ -2253,12 +2293,19 @@ async function launchOneJob( completedNow, totalNow, }); + const shouldNotifyCompletion = shouldSendCompletionNotification({ + config: store.getState().completionNotification, + completedNow, + totalNow, + windowHidden: willNotify, + }); if (shouldPlaySound) { void playCompletionSound(store.getState().completionSound); } - // 桌面通知 —— 点击拉前台 + 直达详情抽屉 - if (willNotify && (!loopMode || isFinalLoopResult)) { - tryNotify("Image Studio · 已完成", r.prompt ?? "", () => { + if (shouldNotifyCompletion && (!loopMode || isFinalLoopResult)) { + const notificationBody = (r.revisedPrompt ?? r.prompt ?? payload.prompt ?? "").trim() + || (historyItem.mode === "edit" ? "图片编辑任务已完成" : "图片生成任务已完成"); + showSystemNotification("Image Studio · 已完成", notificationBody, () => { store.getState().openResultDetail(historyItem); }); } diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts index 185616cf2..16e30942b 100644 --- a/image-studio/frontend/src/state/studioStore.types.ts +++ b/image-studio/frontend/src/state/studioStore.types.ts @@ -17,6 +17,7 @@ import type { OutputFormatValue, CustomAspectRatio, CompletionSoundConfig, + CompletionNotificationConfig, Preset, ProgressInfo, PromptTemplate, @@ -27,6 +28,7 @@ import type { SourceImage, StreamPreview, StreamPreviewMap, + SystemNotificationPermissionState, ThemeMode, Toast, UpstreamProfile, @@ -216,6 +218,8 @@ export interface StudioState { keepLogs: boolean; cleanupPreviewCacheOnExit: boolean; completionSound: CompletionSoundConfig; + completionNotification: CompletionNotificationConfig; + completionNotificationPermission: SystemNotificationPermissionState; ignoredReleaseTag: string; appUpdate: AppUpdateInfo | null; appUpdateModalOpen: boolean; @@ -231,6 +235,8 @@ export interface StudioState { setCompletionSoundCustom: (input: { name: string; dataURL: string }) => void; resetCompletionSoundCustom: () => void; previewCompletionSound: () => Promise; + setCompletionNotificationEnabled: (value: boolean) => Promise; + requestCompletionNotificationPermission: () => Promise; materializeCurrentImage: (item: HistoryItem) => Promise; retryLast: () => Promise; setHistoryRailCollapsed: (collapsed: boolean) => void; diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts index deeca8cf5..c5c6c68bf 100644 --- a/image-studio/frontend/src/types/domain.ts +++ b/image-studio/frontend/src/types/domain.ts @@ -57,6 +57,7 @@ export type ThemeMode = "system" | "light" | "dark"; export type CompletionSoundMode = "default" | "custom"; export type EditSourceMode = "manual" | "batch"; export type BatchProcessOutputMode = "source_dir" | "custom_dir"; +export type SystemNotificationPermissionState = "default" | "granted" | "denied" | "unsupported"; export interface CompletionSoundConfig { enabled: boolean; @@ -65,6 +66,10 @@ export interface CompletionSoundConfig { customDataURL: string; } +export interface CompletionNotificationConfig { + enabled: boolean; +} + export interface SizeOption { value: SizeValue; label: string; } export interface QualityOption { value: QualityValue; label: string; } export interface OutputFormatOption { value: OutputFormatValue; label: string; } diff --git a/image-studio/frontend/test/completionNotification.test.mjs b/image-studio/frontend/test/completionNotification.test.mjs new file mode 100644 index 000000000..712f8c164 --- /dev/null +++ b/image-studio/frontend/test/completionNotification.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const realLocalStorage = globalThis.localStorage; +const realNotification = globalThis.Notification; +const realWindow = globalThis.window; + +function installStorage() { + const store = new Map(); + globalThis.localStorage = { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(key, String(value)); + }, + removeItem(key) { + store.delete(key); + }, + }; + return store; +} + +test.afterEach(() => { + globalThis.localStorage = realLocalStorage; + globalThis.Notification = realNotification; + globalThis.window = realWindow; +}); + +test("completion notification persists enabled state", async () => { + const store = installStorage(); + const completionNotification = await import("../src/lib/completionNotification.ts"); + + completionNotification.persistCompletionNotificationConfig({ enabled: false }); + assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: false }); + assert.equal(store.get("gptcodex.completionNotification.enabled"), "0"); + + completionNotification.persistCompletionNotificationConfig({ enabled: true }); + assert.deepEqual(completionNotification.readCompletionNotificationConfig(), { enabled: true }); + assert.equal(store.get("gptcodex.completionNotification.enabled"), "1"); +}); + +test("completion notification only fires for the final hidden task", async () => { + const completionNotification = await import("../src/lib/completionNotification.ts"); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 1, + totalNow: 3, + windowHidden: true, + }), false); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 3, + totalNow: 3, + windowHidden: false, + }), false); + + assert.equal(completionNotification.shouldSendCompletionNotification({ + config: { enabled: true }, + completedNow: 3, + totalNow: 3, + windowHidden: true, + }), true); +}); + +test("system notification only shows after permission is granted", async () => { + const seen = []; + globalThis.window = { focus() {} }; + globalThis.Notification = class MockNotification { + static permission = "granted"; + static async requestPermission() { + return this.permission; + } + constructor(title, options) { + seen.push({ title, body: options.body }); + this.onclick = null; + } + close() {} + }; + + const completionNotification = await import("../src/lib/completionNotification.ts"); + assert.equal(completionNotification.showSystemNotification("done", "body"), true); + assert.deepEqual(seen, [{ title: "done", body: "body" }]); + + globalThis.Notification.permission = "denied"; + assert.equal(completionNotification.showSystemNotification("blocked", "body"), false); + assert.equal(seen.length, 1); +}); From 65784c38fff6af7baa4eb9c77d18bebfefc6b654 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 22:28:29 +0800 Subject: [PATCH 03/11] Improve batch save prompt for concurrent generations --- .../frontend/src/app/dev/previewData.ts | 1 + .../frontend/src/app/gates/SavePromptGate.tsx | 4 +- .../src/components/canvas/BatchResultGrid.tsx | 22 +- .../src/components/common/SavePromptModal.tsx | 226 +++++++++++++++--- .../frontend/src/lib/savePromptState.ts | 13 + .../frontend/src/lib/saveResultImage.ts | 8 + .../frontend/src/state/studioStore.ts | 73 +++++- .../frontend/src/state/studioStore.types.ts | 7 +- image-studio/frontend/src/styles/_canvas.css | 16 +- .../frontend/test/savePromptState.test.mjs | 49 ++++ 10 files changed, 364 insertions(+), 55 deletions(-) create mode 100644 image-studio/frontend/src/lib/savePromptState.ts create mode 100644 image-studio/frontend/test/savePromptState.test.mjs diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index 27600aa03..e56f09f91 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -28,6 +28,7 @@ export function readPreviewScenario(): PreviewScenario | null { } } + function buildHistory(now: number): HistoryItem[] { const batchPrompt = "赛博雨夜角色海报,湿地街道反光,红青霓虹边缘光,35mm,电影感,超细节"; const batchDefs = Array.from({ length: 9 }, (_, index) => ({ diff --git a/image-studio/frontend/src/app/gates/SavePromptGate.tsx b/image-studio/frontend/src/app/gates/SavePromptGate.tsx index c267cab74..7212394ba 100644 --- a/image-studio/frontend/src/app/gates/SavePromptGate.tsx +++ b/image-studio/frontend/src/app/gates/SavePromptGate.tsx @@ -4,8 +4,8 @@ import { useStudioStore } from "../../state/studioStore"; const SavePromptModal = lazy(() => import("../../components/common/SavePromptModal").then((module) => ({ default: module.SavePromptModal }))); export function SavePromptGate() { - const item = useStudioStore((s) => s.savePromptItem); - if (!item) return null; + const request = useStudioStore((s) => s.savePromptRequest); + if (!request) return null; return ( diff --git a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx index b626b97f7..104c86c0d 100644 --- a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx +++ b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx @@ -15,6 +15,9 @@ export function BatchResultGrid({ onClose, showClose = true, title, + selectedIds, + onToggleSelect, + selectionMode = false, }: { items: HistoryItem[]; slots?: BatchGridSlot[]; @@ -23,6 +26,9 @@ export function BatchResultGrid({ onClose: () => void; showClose?: boolean; title?: string; + selectedIds?: Set; + onToggleSelect?: (item: HistoryItem) => void; + selectionMode?: boolean; }) { const gridSlots = slots ?? items.map((item) => ({ type: "result", item }) satisfies BatchGridSlot); const columns = gridSlots.length <= 2 ? 2 : gridSlots.length <= 4 ? 2 : 3; @@ -52,6 +58,9 @@ export function BatchResultGrid({ active={slot.type === "result" && slot.item.id === currentId} preview={slot.type === "preview"} onSelect={onSelect} + selected={slot.type === "result" && !!selectedIds?.has(slot.item.id)} + onToggleSelect={onToggleSelect} + selectionMode={selectionMode} /> ); })} @@ -66,24 +75,34 @@ function BatchGridTile({ active, preview, onSelect, + selected, + onToggleSelect, + selectionMode, }: { item: HistoryItem; index: number; active: boolean; preview: boolean; onSelect: (item: HistoryItem) => void | Promise; + selected: boolean; + onToggleSelect?: (item: HistoryItem) => void; + selectionMode: boolean; }) { const previewURL = useBlobURL(item.imageBlob ?? item.previewBlob ?? null, item.imageB64 ?? null); const src = historyPreviewSrc(item, previewURL); return (
diff --git a/image-studio/frontend/src/components/common/SavePromptModal.tsx b/image-studio/frontend/src/components/common/SavePromptModal.tsx index d088b8752..8cc6cce74 100644 --- a/image-studio/frontend/src/components/common/SavePromptModal.tsx +++ b/image-studio/frontend/src/components/common/SavePromptModal.tsx @@ -1,30 +1,49 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FolderOpen, Save } from "lucide-react"; import { useStudioStore } from "../../state/studioStore"; +import { BatchResultGrid } from "../canvas/BatchResultGrid"; import { historyPreviewSrc, useBlobURL } from "../../lib/images"; -import { saveHistoryItemAs } from "../../lib/saveResultImage"; +import { saveHistoryItemAs, saveHistoryItemsToDirectory } from "../../lib/saveResultImage"; import { androidSaveHint, androidTarget } from "../../platform/android/bridge"; +import { getHostCapabilities, ChooseDirectory } from "../../platform/runtime/host"; import { usePlatform } from "../../platform/context"; import { Modal } from "./Modal"; export function SavePromptModal() { - const item = useStudioStore((s) => s.savePromptItem); + const request = useStudioStore((s) => s.savePromptRequest); const closeSavePrompt = useStudioStore((s) => s.closeSavePrompt); const suppressed = useStudioStore((s) => s.savePromptSuppressed); const setSuppressed = useStudioStore((s) => s.setSavePromptSuppressed); const pushToast = useStudioStore((s) => s.pushToast); - const { usesFluentUI, isAndroidPhone } = usePlatform(); + const { usesFluentUI, isAndroidPhone, isAndroid } = usePlatform(); const [saving, setSaving] = useState(false); - const previewURL = useBlobURL(item?.previewBlob ?? item?.imageBlob ?? null, item?.imageB64 ?? null); + const [selectedIds, setSelectedIds] = useState>(new Set()); - if (!item) return null; - const imageSrc = historyPreviewSrc(item, previewURL); + useEffect(() => { + if (request?.kind === "batch") { + setSelectedIds(new Set(request.items.map((item) => item.id))); + return; + } + setSelectedIds(new Set()); + }, [request]); + + const batchItems = request?.kind === "batch" ? request.items : []; + const selectedBatchItems = useMemo( + () => batchItems.filter((item) => selectedIds.has(item.id)), + [batchItems, selectedIds], + ); + + if (!request) return null; + const isBatch = request.kind === "batch"; + const singleItem = request.kind === "single" ? request.item : null; + const batchRequest = request.kind === "batch" ? request : null; + const singlePreviewURL = useBlobURL(singleItem?.previewBlob ?? singleItem?.imageBlob ?? null, singleItem?.imageB64 ?? null); - async function saveAs() { - if (saving || !item) return; + async function saveSingleAs() { + if (saving || !singleItem) return; setSaving(true); try { - const saved = await saveHistoryItemAs(item); + const saved = await saveHistoryItemAs(singleItem); if (saved) pushToast(`已保存:${saved.split(/[\\/]/).pop()}`, "success"); closeSavePrompt(); } catch (error: any) { @@ -34,39 +53,172 @@ export function SavePromptModal() { } } - return ( - -
-
-
- {imageSrc ? ( - 生成结果预览 - ) : ( - - )} + async function saveBatchAs() { + if (saving || !batchRequest) return; + if (selectedBatchItems.length === 0) { + pushToast("请先勾选要另存的图片", "warn"); + return; + } + if (!(hostCapabilities.nativeOutputDirectoryPicker && !isAndroid)) { + pushToast("当前平台暂不支持批量选择目录另存,请改用单图另存为。", "warn", 5000); + return; + } + setSaving(true); + try { + const directory = await ChooseDirectory("选择批量另存为目录"); + if (!directory) { + setSaving(false); + return; + } + const saved = await saveHistoryItemsToDirectory(selectedBatchItems, directory); + pushToast(`已另存 ${saved.length} 张到 ${directory.split(/[\\/]/).pop() || directory}`, "success", 6000); + closeSavePrompt(); + } catch (error: any) { + pushToast(`批量保存失败:${error?.message ?? error}`, "error", 6000); + } finally { + setSaving(false); + } + } + + if (!isBatch) { + const imageSrc = historyPreviewSrc(singleItem, singlePreviewURL); + return ( + +
+
+
+ {imageSrc ? ( + 生成结果预览 + ) : ( + + )} +
+
+

+ 图片已生成并保存在默认输出目录。 +

+

+ 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。 +

+ {singleItem?.savedPath ? ( +

+ {singleItem.savedPath} +

+ ) : null} +
+
+ + {androidTarget.isAndroid ? ( +

{androidSaveHint()}

+ ) : null} + + + +
+ +
-
+
+ + ); + } + + const hostCapabilities = getHostCapabilities(); + const canBatchSaveToDirectory = hostCapabilities.nativeOutputDirectoryPicker && !isAndroid; + + return ( + +
+
+

- 图片已生成并保存在默认输出目录。 + 单次任务只弹这一次。勾选需要的图片后,再统一另存为到目标目录。

- 需要放到项目、相册或其他目录时,可以现在选择目标位置另存一份。 + 当前已选 {selectedBatchItems.length} / {batchItems.length} 张。

- {item.savedPath ? ( -

- {item.savedPath} -

- ) : null}
+
+ + +
+
+ +
+ undefined} + onClose={closeSavePrompt} + showClose={false} + title={`勾选要另存的结果 · ${batchItems.length} 张`} + selectionMode + selectedIds={selectedIds} + onToggleSelect={(item) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }} + />
{androidTarget.isAndroid ? ( -

{androidSaveHint()}

+

+ {androidSaveHint()} 当前平台批量勾选后统一目录另存尚不可用,请改用单图另存为。 +

+ ) : !canBatchSaveToDirectory ? ( +

+ 当前环境没有可用的目录选择器,暂时无法一次性批量另存。 +

) : null}
diff --git a/image-studio/frontend/src/lib/savePromptState.ts b/image-studio/frontend/src/lib/savePromptState.ts new file mode 100644 index 000000000..ed5cde231 --- /dev/null +++ b/image-studio/frontend/src/lib/savePromptState.ts @@ -0,0 +1,13 @@ +import type { HistoryItem } from "../types/domain"; + +export type SavePromptRequest = + | { kind: "single"; item: HistoryItem } + | { kind: "batch"; items: HistoryItem[]; workspaceId: string }; + +export function normalizeSavePromptRequest(request: SavePromptRequest): SavePromptRequest | null { + if (request.kind === "single") { + return request.item ? request : null; + } + if (request.items.length === 0) return null; + return request; +} diff --git a/image-studio/frontend/src/lib/saveResultImage.ts b/image-studio/frontend/src/lib/saveResultImage.ts index c976cafaa..59288e34f 100644 --- a/image-studio/frontend/src/lib/saveResultImage.ts +++ b/image-studio/frontend/src/lib/saveResultImage.ts @@ -22,3 +22,11 @@ export async function saveHistoryItemToDirectoryAs(item: HistoryItem, directory: if (item.imageB64) return SaveImageToDir(item.imageB64, directory, suggestedName); throw new Error("当前图片没有可保存内容"); } + +export async function saveHistoryItemsToDirectory(items: HistoryItem[], directory: string): Promise { + const saved: string[] = []; + for (const item of items) { + saved.push(await saveHistoryItemToDirectory(item, directory)); + } + return saved; +} diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index cf599853d..d0413221f 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -69,6 +69,7 @@ import { } from "../lib/compatState"; import { normalizeAppUpdateInfo } from "../lib/appUpdate.ts"; import { appVersion } from "../lib/version.ts"; +import { normalizeSavePromptRequest, type SavePromptRequest } from "../lib/savePromptState"; import { readSavePromptSuppressed, writeSavePromptSuppressed, @@ -167,6 +168,7 @@ import type { ModeConfig, PromptOptimizeRequest, Stroke, StudioState, UndoEntry import { cryptoIDFallback, ensureFullHistoryItem as ensureFullHistoryItemRuntime, + historyItemsByIds, materializeHistoryItem as materializeHistoryItemRuntime, STYLE_SUFFIXES, withMediaAssetRef, @@ -448,6 +450,36 @@ function mergeHistoryMediaRef(current: HistoryItem | null, nextById: Map entry.id === workspaceId); + const items = historyItemsByIds(state.history, workspace?.batchResultIds ?? []).sort((a, b) => { + const aIndex = typeof a.batchIndex === "number" ? a.batchIndex : Number.MAX_SAFE_INTEGER; + const bIndex = typeof b.batchIndex === "number" ? b.batchIndex : Number.MAX_SAFE_INTEGER; + if (aIndex !== bIndex) return aIndex - bIndex; + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a.id.localeCompare(b.id); + }); + if (items.length === 0) return null; + return { + kind: "batch", + items, + workspaceId, + }; +} + +function maybeEnqueueBatchSavePrompt( + store: typeof useStudioStore, + workspaceId: string, + completedNow: number, + totalNow: number, +): void { + if (totalNow <= 1 || completedNow !== totalNow) return; + const batchRequest = batchSavePromptRequestFromState(store.getState(), workspaceId); + if (batchRequest) { + store.getState().enqueueSavePrompt(batchRequest); + } +} + async function hydrateHistoryPreviewRefs(items: HistoryItem[]): Promise { return mapWithConcurrency(items, HISTORY_MEDIA_HYDRATE_CONCURRENCY, async (item) => { if (!needsHistoryPreviewHydration(item)) return item; @@ -603,7 +635,7 @@ export const useStudioStore = create((set, get) => ({ fullscreen: false, starPromptOpen: false, starPromptSource: "auto", - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs: readKeepLogs(), @@ -725,25 +757,34 @@ export const useStudioStore = create((set, get) => ({ set({ starPromptOpen: false }); try { localStorage.setItem("gptcodex.starPrompted", "1"); } catch {} }, - enqueueSavePrompt: (item) => { + enqueueSavePrompt: (request) => { if (get().savePromptSuppressed) return; + const normalized = normalizeSavePromptRequest(request); + if (!normalized) return; + const hydrated = normalized.kind === "batch" + ? { + ...normalized, + items: historyItemsByIds(normalized.items, normalized.items.map((item) => item.id)), + } + : normalized; + if (hydrated.kind === "batch" && hydrated.items.length === 0) return; set((state) => { - if (!state.savePromptItem) return { savePromptItem: item }; - return { savePromptQueue: [...state.savePromptQueue, item].slice(-12) }; + if (!state.savePromptRequest) return { savePromptRequest: hydrated }; + return { savePromptQueue: [...state.savePromptQueue, hydrated].slice(-12) }; }); }, closeSavePrompt: () => { set((state) => { const [next, ...rest] = state.savePromptQueue; return { - savePromptItem: next ?? null, + savePromptRequest: next ?? null, savePromptQueue: rest, }; }); }, setSavePromptSuppressed: (value) => { writeSavePromptSuppressed(value); - set(value ? { savePromptSuppressed: true, savePromptQueue: [] } : { savePromptSuppressed: false }); + set(value ? { savePromptSuppressed: true, savePromptRequest: null, savePromptQueue: [] } : { savePromptSuppressed: false }); }, setKeepLogs: async (value) => { writeKeepLogs(value); @@ -1434,7 +1475,7 @@ export const useStudioStore = create((set, get) => ({ starPromptOpen: false, starPromptSource: "auto", autoRetryEnabled: readAutoRetryEnabled(), - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs: readKeepLogs(), @@ -1729,7 +1770,7 @@ export const useStudioStore = create((set, get) => ({ customSizeModalOpen: false, upstreamModalOpen: false, upstreamReturnTarget: shouldAutoOpenSettings ? "settings" : "app", - savePromptItem: null, + savePromptRequest: null, savePromptQueue: [], savePromptSuppressed: readSavePromptSuppressed(), keepLogs, @@ -2318,7 +2359,10 @@ async function launchOneJob( 6000, { label: "查看详情", onClick: () => store.getState().openResultDetail(historyItem) }, ); - store.getState().enqueueSavePrompt(historyItem); + if (totalNow <= 1) { + store.getState().enqueueSavePrompt({ kind: "single", item: historyItem }); + } + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); } else if (isFinalLoopResult) { store.getState().pushToast( `循环出图完成 · ${completedNow} 张`, @@ -2376,7 +2420,8 @@ async function launchOneJob( workspaces: patchWorkspaceRuntime(state.workspaces, snapshot.workspaceId, patch), ...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(patch) : {}), } as Partial)); - removeFromRunning(); + const { completed: completedNow, total: totalNow } = removeFromRunning(); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); } })(); @@ -2411,7 +2456,8 @@ async function launchOneJob( : {}), } as Partial; }); - removeFromRunning(); + const { completed: completedNow, total: totalNow } = removeFromRunning(); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); }); const started = mode === "edit" @@ -2428,8 +2474,12 @@ async function launchOneJob( errorCanRetry: true, errorRawPath: null, }; + let completedNow = 0; + let totalNow = 0; store.setState((state) => { const runtime = workspaceRuntimeFromState(state, snapshot.workspaceId); + completedNow = runtime.jobsCompleted + 1; + totalNow = runtime.jobsTotal; const nextMeta = { ...state.runningJobMeta }; delete nextMeta[jobId]; const remaining = runtime.runningJobs.filter((id) => id !== jobId); @@ -2450,6 +2500,7 @@ async function launchOneJob( ...(state.activeWorkspaceId === snapshot.workspaceId ? activeRuntimePatch(nextPatch) : {}), } as Partial; }); + maybeEnqueueBatchSavePrompt(store, snapshot.workspaceId, completedNow, totalNow); settle("error"); } } diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts index 16e30942b..4ef3081e2 100644 --- a/image-studio/frontend/src/state/studioStore.types.ts +++ b/image-studio/frontend/src/state/studioStore.types.ts @@ -1,4 +1,5 @@ import type { GenerateOptionsLike } from "../platform/runtime/hostTypes"; +import type { SavePromptRequest } from "../lib/savePromptState"; import type { Annotation, AppUpdateInfo, @@ -212,8 +213,8 @@ export interface StudioState { resultDetail: HistoryItem | null; openResultDetail: (item: HistoryItem) => Promise; closeResultDetail: () => void; - savePromptItem: HistoryItem | null; - savePromptQueue: HistoryItem[]; + savePromptRequest: SavePromptRequest | null; + savePromptQueue: SavePromptRequest[]; savePromptSuppressed: boolean; keepLogs: boolean; cleanupPreviewCacheOnExit: boolean; @@ -223,7 +224,7 @@ export interface StudioState { ignoredReleaseTag: string; appUpdate: AppUpdateInfo | null; appUpdateModalOpen: boolean; - enqueueSavePrompt: (item: HistoryItem) => void; + enqueueSavePrompt: (request: SavePromptRequest) => void; closeSavePrompt: () => void; setSavePromptSuppressed: (value: boolean) => void; setKeepLogs: (value: boolean) => Promise; diff --git a/image-studio/frontend/src/styles/_canvas.css b/image-studio/frontend/src/styles/_canvas.css index d13004ed7..a3e6b5de8 100644 --- a/image-studio/frontend/src/styles/_canvas.css +++ b/image-studio/frontend/src/styles/_canvas.css @@ -512,9 +512,16 @@ html.dark .stream-preview-badge { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 12px 34px rgb(0 0 0 / 0.16); } +.batch-grid-tile.selected { + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent), 0 12px 34px rgb(0 0 0 / 0.16); +} .batch-grid-tile:disabled { cursor: default; } +.batch-grid-tile.selection-mode .batch-grid-drag-export { + display: none; +} .batch-grid-tile.previewing { cursor: default; border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); @@ -541,7 +548,8 @@ html.dark .stream-preview-badge { background: var(--canvas-bg); } .batch-grid-index, -.batch-grid-meta { +.batch-grid-meta, +.batch-grid-check { position: absolute; top: 8px; background: rgb(0 0 0 / 0.62); @@ -554,6 +562,12 @@ html.dark .stream-preview-badge { } .batch-grid-index { left: 8px; } .batch-grid-meta { right: 8px; font-family: var(--mono-font); } +.batch-grid-check { + top: auto; + right: 8px; + bottom: 8px; + background: color-mix(in srgb, var(--accent) 82%, black 18%); +} .batch-grid-drag-export { right: 8px; bottom: 8px; diff --git a/image-studio/frontend/test/savePromptState.test.mjs b/image-studio/frontend/test/savePromptState.test.mjs new file mode 100644 index 000000000..205064f4b --- /dev/null +++ b/image-studio/frontend/test/savePromptState.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const savePromptState = await import("../src/lib/savePromptState.ts"); + +function makeItem(id, createdAt) { + return { + id, + prompt: `prompt-${id}`, + mode: "generate", + size: "1024x1024", + quality: "medium", + createdAt, + }; +} + +test("normalizeSavePromptRequestForTest keeps batch items in request order", () => { + const one = makeItem("one", 1); + const two = makeItem("two", 2); + const request = savePromptState.normalizeSavePromptRequest({ + kind: "batch", + items: [two, one], + workspaceId: "ws-1", + }); + + assert.equal(request?.kind, "batch"); + assert.deepEqual(request?.items.map((item) => item.id), ["two", "one"]); +}); + +test("normalizeSavePromptRequest keeps single requests intact", () => { + const one = makeItem("one", 1); + const request = savePromptState.normalizeSavePromptRequest({ + kind: "single", + item: one, + }); + + assert.equal(request?.kind, "single"); + assert.equal(request?.item.id, "one"); +}); + +test("normalizeSavePromptRequest rejects empty batch requests", () => { + const request = savePromptState.normalizeSavePromptRequest({ + kind: "batch", + items: [], + workspaceId: "ws-1", + }); + + assert.equal(request, null); +}); From 4552f298e66cb76783d22cb1f244cd91f6ca87bf Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 22:45:46 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E6=89=B9=E5=A4=84=E7=90=86=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/batch-img2img/README.md | 35 +++++++++++++++ image-studio/backend/dialogs.go | 4 ++ image-studio/backend/types.go | 2 + .../frontend/src/app/dev/previewData.ts | 1 + .../components/panel/BatchProcessSection.tsx | 45 +++++++++++++++++++ .../src/components/panel/sizeCapabilities.ts | 26 +++++++++++ image-studio/frontend/src/lib/compatState.ts | 3 ++ .../src/platform/runtime/hostTypes.ts | 2 + .../frontend/src/state/studioStore.images.ts | 4 ++ .../frontend/src/state/studioStore.ts | 27 +++++++++++ .../frontend/src/state/workspaceRuntime.ts | 13 ++++++ image-studio/frontend/src/types/domain.ts | 4 ++ .../frontend/test/sizeCapabilities.test.mjs | 16 +++++++ .../frontend/test/workspaceRuntime.test.mjs | 26 +++++++++++ 14 files changed, 208 insertions(+) diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md index b7d49dbce..8f5a58e7a 100644 --- a/docs/batch-img2img/README.md +++ b/docs/batch-img2img/README.md @@ -89,6 +89,41 @@ - 实际可并发数量仍受当前上游 profile 的并发限制约束。 - 如果并发开得太高,应用可能会自动关闭流式预览,优先保证最终图完整。 +## 不同比例源图的处理 + +如果一个目录里的源图宽高比不一致,但你仍然希望: + +- 所有图片使用相同提示词 +- 所有图片使用同一档分辨率 +- 每张图仍然尽量保持各自源图的比例 + +可以在批处理面板里开启: + +`按源图比例自动适配` + +然后再选择统一的分辨率档位,例如 `1K`、`2K`、`4K`。 + +这时应用会对每一张源图分别计算: + +- 比例:取该源图自身宽高比 +- 分辨率:取你在批处理面板里指定的统一档位,并沿用应用当前对 `1K / 2K / 4K` 的尺寸换算规则 + +例如: + +- 横图会得到横向尺寸 +- 竖图会得到纵向尺寸 +- 它们都仍然共享同一个提示词和同一个分辨率档位 + +这里的 `1K / 2K / 4K` 仍然遵循应用当前的尺寸语义: + +- 内置比例会落到内置尺寸矩阵 +- 非内置比例会按该档位的像素面积参考值推导具体宽高 + +注意: + +- 这是批处理模式下的额外尺寸策略。 +- 它不会改变普通图生图里 `Auto` 比例与 `size=auto` 的原有语义。 + ## 参数继承 批处理会复用你当前图生图控制面板里的参数,包括: diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go index a49c926a7..6f31bff70 100644 --- a/image-studio/backend/dialogs.go +++ b/image-studio/backend/dialogs.go @@ -121,6 +121,10 @@ func (s *Service) ListBatchInputImages(directory string) (BatchInputDirectory, e Name: entry.Name(), Size: info.Size(), } + if cfg, cfgErr := imageConfig(path); cfgErr == nil { + item.Width = cfg.Width + item.Height = cfg.Height + } if info.Size() > 0 && info.Size() <= maxDialogReadBytes { if preview, previewErr := s.registerImportedPreview(path); previewErr == nil { item.PreviewURL = preview.PreviewURL diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 9c25c7f57..4abec4c67 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -203,6 +203,8 @@ type BatchInputImage struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` PreviewWidth int `json:"previewWidth,omitempty"` PreviewHeight int `json:"previewHeight,omitempty"` diff --git a/image-studio/frontend/src/app/dev/previewData.ts b/image-studio/frontend/src/app/dev/previewData.ts index e56f09f91..695f94e83 100644 --- a/image-studio/frontend/src/app/dev/previewData.ts +++ b/image-studio/frontend/src/app/dev/previewData.ts @@ -184,6 +184,7 @@ function buildWorkspace( outputDir: "", concurrency: 2, fileNamePrefix: "processed-", + autoAspectResolution: "", discoveredSources: [], }; return { diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx index 42a24824b..8617fdff4 100644 --- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -140,6 +140,51 @@ export function BatchProcessSection({ /> +
+ 不同源图比例处理 + + setBatchProcess({ ...batchProcess, autoAspectResolution: "" })} + > + 沿用当前比例 + + setBatchProcess({ ...batchProcess, autoAspectResolution: batchProcess.autoAspectResolution || "1k" })} + > + 按源图比例自动适配 + + + {batchProcess.autoAspectResolution !== "" ? ( + <> +
+ {(["256", "512", "1k", "2k", "4k"] as const).map((value) => ( + + ))} +
+
+ 开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。 +
+ + ) : ( +
+ 关闭时仍沿用当前控制面板的比例和尺寸语义。 +
+ )} +
+
结果文件名前缀固定为 processed-,遇到同名会自动追加 -2、-3。
diff --git a/image-studio/frontend/src/components/panel/sizeCapabilities.ts b/image-studio/frontend/src/components/panel/sizeCapabilities.ts index dcd82f699..7677582ad 100644 --- a/image-studio/frontend/src/components/panel/sizeCapabilities.ts +++ b/image-studio/frontend/src/components/panel/sizeCapabilities.ts @@ -408,6 +408,32 @@ export function buildResolutionSizeSelection( ); } +export function buildReferenceResolutionSizeSelection( + resolution: Exclude, + reference: { width: number; height: number } | null, + input: SizeCapabilityInput, + customRatios: CustomAspectRatio[] = [], +): SizeValue { + if (!reference || !Number.isFinite(reference.width) || !Number.isFinite(reference.height) || reference.width <= 0 || reference.height <= 0) { + return buildResolutionSizeSelection("auto", resolution, input, customRatios); + } + if (supportsCustomAspectRatios(input)) { + const customReference = buildReferenceAspectRatio(reference.width, reference.height, customRatios); + if (customReference) { + const ratios = customRatios.some((item) => item.id === customReference.id) + ? customRatios + : [...customRatios, customReference]; + return buildSizeSelection(buildCustomAspectValue(customReference.id), resolution, input, ratios); + } + } + return buildSizeSelection( + nearestBuiltinAspect(reference.width, reference.height).value, + resolution, + input, + customRatios, + ); +} + export function normalizeResolutionSelection(resolution: ResolutionPreset, input: SizeCapabilityInput): ResolutionPreset { const allowed = availableResolutionPresets(input); if (allowed.includes(resolution)) return resolution; diff --git a/image-studio/frontend/src/lib/compatState.ts b/image-studio/frontend/src/lib/compatState.ts index 4f45ff4b8..0c53e9ab9 100644 --- a/image-studio/frontend/src/lib/compatState.ts +++ b/image-studio/frontend/src/lib/compatState.ts @@ -575,6 +575,9 @@ export function normalizeBatchProcess(value: unknown): BatchProcessConfig { outputDir: typeof source.outputDir === "string" ? source.outputDir.trim() : "", concurrency: Number.isFinite(Number(source.concurrency)) ? Math.max(1, Math.min(9, Math.floor(Number(source.concurrency)))) : 2, fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-", + autoAspectResolution: source.autoAspectResolution === "256" || source.autoAspectResolution === "512" || source.autoAspectResolution === "1k" || source.autoAspectResolution === "2k" || source.autoAspectResolution === "4k" + ? source.autoAspectResolution + : "", discoveredSources, }; } diff --git a/image-studio/frontend/src/platform/runtime/hostTypes.ts b/image-studio/frontend/src/platform/runtime/hostTypes.ts index 2da022f42..0290631d1 100644 --- a/image-studio/frontend/src/platform/runtime/hostTypes.ts +++ b/image-studio/frontend/src/platform/runtime/hostTypes.ts @@ -103,6 +103,8 @@ export type BatchInputImageLike = { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; diff --git a/image-studio/frontend/src/state/studioStore.images.ts b/image-studio/frontend/src/state/studioStore.images.ts index dd7c2cf89..1897b206f 100644 --- a/image-studio/frontend/src/state/studioStore.images.ts +++ b/image-studio/frontend/src/state/studioStore.images.ts @@ -68,6 +68,8 @@ export function createImageActions(store: StateAdapter) { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; @@ -76,6 +78,8 @@ export function createImageActions(store: StateAdapter) { path: source.path, name: source.name, size: source.size, + width: source.width, + height: source.height, previewUrl: source.previewUrl, previewWidth: source.previewWidth, previewHeight: source.previewHeight, diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index d0413221f..df9570b17 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -135,6 +135,7 @@ import { getStreamPreviewDisableReason } from "./streamPreviewPolicy"; import { buildAspectSizeSelection, buildExactSizeValue, + buildReferenceResolutionSizeSelection, buildCustomAspectValue, deriveAspectPreset, deriveResolutionPreset, @@ -221,6 +222,13 @@ type LoopRunController = { batchSources?: BatchProcessSourceImage[]; batchOutputDir?: string; batchOutputPrefix?: string; + batchAutoAspectResolution?: Exclude | ""; + batchCapabilityInput?: { + apiMode: APIMode; + requestPolicy: RequestPolicy; + imageModelID?: string; + }; + batchCustomAspectRatios?: import("../types/domain").CustomAspectRatio[]; stopped: boolean; }; @@ -261,9 +269,21 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { controller.launchedJobs += 1; const payloadSeed = controller.payload.seed ? controller.payload.seed + batchIndex : 0; const batchSource = controller.batchSources?.[batchIndex]; + const batchPayloadSize = batchSource + && controller.snapshotBase.editSourceMode === "batch" + && controller.batchAutoAspectResolution + && controller.batchCapabilityInput + ? buildReferenceResolutionSizeSelection( + controller.batchAutoAspectResolution, + batchSource.width && batchSource.height ? { width: batchSource.width, height: batchSource.height } : null, + controller.batchCapabilityInput, + controller.batchCustomAspectRatios ?? [], + ) + : controller.payload.size; const nextPayload: RuntimeGenerateOptions = { ...controller.payload, seed: payloadSeed, + size: batchPayloadSize, imagePaths: batchSource ? [batchSource.path] : controller.payload.imagePaths, sourceImages: batchSource ? [batchSource] : controller.payload.sourceImages, }; @@ -1304,6 +1324,13 @@ export const useStudioStore = create((set, get) => ({ batchSources: batchProcess.discoveredSources, batchOutputDir: batchProcess.outputMode === "custom_dir" ? batchProcess.outputDir : "", batchOutputPrefix: batchProcess.fileNamePrefix, + batchAutoAspectResolution: batchProcess.autoAspectResolution, + batchCapabilityInput: { + apiMode: s.apiMode, + requestPolicy: s.requestPolicy, + imageModelID: s.imageModelID, + }, + batchCustomAspectRatios: s.customAspectRatios, stopped: false, }; loopRunControllers.set(workspaceId, controller); diff --git a/image-studio/frontend/src/state/workspaceRuntime.ts b/image-studio/frontend/src/state/workspaceRuntime.ts index 6d8027937..2498b7d9c 100644 --- a/image-studio/frontend/src/state/workspaceRuntime.ts +++ b/image-studio/frontend/src/state/workspaceRuntime.ts @@ -1,4 +1,5 @@ import type { + BatchProcessAutoAspectResolution, BatchProcessSourceImage, BatchProcessConfig, LoopGenerationConfig, @@ -97,10 +98,17 @@ export function defaultBatchProcessConfig(): BatchProcessConfig { outputDir: "", concurrency: DEFAULT_BATCH_PROCESS_CONCURRENCY, fileNamePrefix: "processed-", + autoAspectResolution: "", discoveredSources: [], }; } +export function normalizeBatchProcessAutoAspectResolution(value: unknown): BatchProcessAutoAspectResolution { + return value === "256" || value === "512" || value === "1k" || value === "2k" || value === "4k" + ? value + : ""; +} + export function normalizeBatchProcessConcurrency(value: unknown): number { const n = Number(value); if (!Number.isFinite(n)) return DEFAULT_BATCH_PROCESS_CONCURRENCY; @@ -119,6 +127,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig path?: unknown; name?: unknown; size?: unknown; + width?: unknown; + height?: unknown; previewUrl?: unknown; previewWidth?: unknown; previewHeight?: unknown; @@ -130,6 +140,8 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig path, name, size: Number.isFinite(Number(candidate.size)) ? Math.max(0, Math.floor(Number(candidate.size))) : 0, + width: Number.isFinite(Number(candidate.width)) ? Math.floor(Number(candidate.width)) : undefined, + height: Number.isFinite(Number(candidate.height)) ? Math.floor(Number(candidate.height)) : undefined, previewUrl: typeof candidate.previewUrl === "string" ? candidate.previewUrl : undefined, previewWidth: Number.isFinite(Number(candidate.previewWidth)) ? Math.floor(Number(candidate.previewWidth)) : undefined, previewHeight: Number.isFinite(Number(candidate.previewHeight)) ? Math.floor(Number(candidate.previewHeight)) : undefined, @@ -145,6 +157,7 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-", + autoAspectResolution: normalizeBatchProcessAutoAspectResolution(source.autoAspectResolution), discoveredSources, }; } diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts index c5c6c68bf..767a0aee8 100644 --- a/image-studio/frontend/src/types/domain.ts +++ b/image-studio/frontend/src/types/domain.ts @@ -57,6 +57,7 @@ export type ThemeMode = "system" | "light" | "dark"; export type CompletionSoundMode = "default" | "custom"; export type EditSourceMode = "manual" | "batch"; export type BatchProcessOutputMode = "source_dir" | "custom_dir"; +export type BatchProcessAutoAspectResolution = "" | "256" | "512" | "1k" | "2k" | "4k"; export type SystemNotificationPermissionState = "default" | "granted" | "denied" | "unsupported"; export interface CompletionSoundConfig { @@ -165,6 +166,8 @@ export interface BatchProcessSourceImage { path: string; name: string; size: number; + width?: number; + height?: number; previewUrl?: string; previewWidth?: number; previewHeight?: number; @@ -177,6 +180,7 @@ export interface BatchProcessConfig { outputDir: string; concurrency: number; fileNamePrefix: string; + autoAspectResolution: BatchProcessAutoAspectResolution; discoveredSources: BatchProcessSourceImage[]; } diff --git a/image-studio/frontend/test/sizeCapabilities.test.mjs b/image-studio/frontend/test/sizeCapabilities.test.mjs index 23c158d3e..1cf69627b 100644 --- a/image-studio/frontend/test/sizeCapabilities.test.mjs +++ b/image-studio/frontend/test/sizeCapabilities.test.mjs @@ -295,3 +295,19 @@ test("auto aspect can resolve 2K/4K against a reference image ratio", () => { "3840x1920", ); }); + +test("reference resolution selection can use source image ratio directly", () => { + const input = { + apiMode: "responses", + requestPolicy: "openai", + imageModelID: "gpt-image-2", + }; + assert.equal( + caps.buildReferenceResolutionSizeSelection("2k", { width: 1200, height: 1600 }, input, []), + "1448x1928", + ); + assert.equal( + caps.buildReferenceResolutionSizeSelection("4k", { width: 1600, height: 1200 }, input, []), + "3312x2496", + ); +}); diff --git a/image-studio/frontend/test/workspaceRuntime.test.mjs b/image-studio/frontend/test/workspaceRuntime.test.mjs index 960c13f92..94b9e0eb9 100644 --- a/image-studio/frontend/test/workspaceRuntime.test.mjs +++ b/image-studio/frontend/test/workspaceRuntime.test.mjs @@ -42,6 +42,32 @@ test("normalizes api mode and concurrency values", () => { assert.equal(runtime.normalizeBatchCount(3.8), 3); assert.equal(runtime.normalizeBatchCount(0), 1); assert.equal(runtime.normalizeBatchCount(99), 9); + assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("2k"), "2k"); + assert.equal(runtime.normalizeBatchProcessAutoAspectResolution("oops"), ""); +}); + +test("normalizes batch process config and keeps scanned image dimensions", () => { + const value = runtime.normalizeBatchProcessConfig({ + enabled: true, + inputDir: " /tmp/src ", + outputMode: "custom_dir", + outputDir: " /tmp/out ", + concurrency: 3.8, + fileNamePrefix: "processed-", + autoAspectResolution: "4k", + discoveredSources: [ + { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800 }, + { path: " ", name: "bad", size: 0 }, + ], + }); + assert.equal(value.enabled, true); + assert.equal(value.inputDir, "/tmp/src"); + assert.equal(value.outputDir, "/tmp/out"); + assert.equal(value.concurrency, 3); + assert.equal(value.autoAspectResolution, "4k"); + assert.deepEqual(value.discoveredSources, [ + { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800, previewUrl: undefined, previewWidth: undefined, previewHeight: undefined }, + ]); }); test("patches only the target workspace runtime", () => { From 86ab13dfa6efd6be01697a548aa1c6ad76fda0b2 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 22:54:22 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=89=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86=E7=8A=B6=E6=80=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/panel/BatchProcessSection.tsx | 16 ++++++++++++++-- image-studio/frontend/src/state/studioStore.ts | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx index 8617fdff4..d928fefcb 100644 --- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -36,10 +36,22 @@ export function BatchProcessSection({
- setEditSourceMode("manual")}> + { + setEditSourceMode("manual"); + setBatchProcess({ ...batchProcess, enabled: false }); + }} + > 普通图生图 - setEditSourceMode("batch")}> + { + setEditSourceMode("batch"); + setBatchProcess({ ...batchProcess, enabled: true }); + }} + > 批处理 diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index df9570b17..8529ce50b 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -1085,7 +1085,8 @@ export const useStudioStore = create((set, get) => ({ const batchCount = normalizeBatchCount(s.batchCount); const batchProcess = normalizeBatchProcessConfig(s.batchProcess); const loopGeneration = normalizeLoopGenerationConfig(s.loopGeneration); - const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcess.enabled; + const batchProcessActive = batchProcess.enabled || !!batchProcess.inputDir.trim() || batchProcess.discoveredSources.length > 0; + const batchProcessEnabled = s.mode === "edit" && s.editSourceMode === "batch" && batchProcessActive; const loopEnabled = !batchProcessEnabled && loopGeneration.enabled; const requestedJobCount = loopEnabled ? normalizeLoopGenerationCount(loopGeneration.totalCount) From bbb74bed843c0da1f8612c0428dcadae2ab1d7c8 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 23:19:52 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/canvas/CanvasStage.tsx | 11 +++-- .../components/panel/BatchProcessSection.tsx | 43 ++++++++++++------- .../android/canvas/AndroidCanvasStage.tsx | 11 +++-- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/image-studio/frontend/src/components/canvas/CanvasStage.tsx b/image-studio/frontend/src/components/canvas/CanvasStage.tsx index eec4c3cff..6282f9bac 100644 --- a/image-studio/frontend/src/components/canvas/CanvasStage.tsx +++ b/image-studio/frontend/src/components/canvas/CanvasStage.tsx @@ -51,7 +51,12 @@ export function CanvasStage() { }); const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); - const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); + const liveBatchSlotCount = Math.max( + runningJobs.length, + batchResults.length, + streamPreviewItems.length, + Math.min(runningJobs.length, batchResults.length + streamPreviewItems.length), + ); const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); for (const item of batchResults) { const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); @@ -63,7 +68,7 @@ export function CanvasStage() { liveBatchSlots[index] = { type: "preview", item }; } } - const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1; + const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0; const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1); const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; @@ -474,7 +479,7 @@ export function CanvasStage() { onSelect={selectBatchResult} onClose={closeResultGrid} showClose={!showingLiveBatchGrid} - title={showingLiveBatchGrid ? `本批预览 · ${jobsCompleted}/${jobsTotal}` : undefined} + title={showingLiveBatchGrid ? `当前并发预览 · ${runningJobs.length} 路 · ${jobsCompleted}/${jobsTotal}` : undefined} /> )} {!showingResultGrid && currentImage && compareB && ( diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx index d928fefcb..12b44b275 100644 --- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -170,21 +170,34 @@ export function BatchProcessSection({ {batchProcess.autoAspectResolution !== "" ? ( <> -
- {(["256", "512", "1k", "2k", "4k"] as const).map((value) => ( - - ))} +
+
+
+
统一分辨率档位
+
+ 每张图按自己的比例自动适配,但都会使用这里选定的分辨率档位。 +
+
+ + 当前 {batchProcess.autoAspectResolution.toUpperCase()} + +
+
+ {(["256", "512", "1k", "2k", "4k"] as const).map((value) => ( + + ))} +
开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。 diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx index db63f4af2..20c8bdbab 100644 --- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx +++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx @@ -59,7 +59,12 @@ export function AndroidCanvasStage() { }); const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); - const liveBatchSlotCount = Math.max(jobsTotal, batchResults.length + runningJobs.length, batchResults.length + streamPreviewItems.length); + const liveBatchSlotCount = Math.max( + runningJobs.length, + batchResults.length, + streamPreviewItems.length, + Math.min(runningJobs.length, batchResults.length + streamPreviewItems.length), + ); const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); for (const item of batchResults) { const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); @@ -71,7 +76,7 @@ export function AndroidCanvasStage() { liveBatchSlots[index] = { type: "preview", item }; } } - const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 1; + const showingLiveBatchGrid = isRunning && liveBatchSlotCount > 0; const showingResultGrid = showingLiveBatchGrid || (resultGridOpen && batchResults.length > 1); const currentBatchIndex = currentImage ? navigationItems.findIndex((item) => item.id === currentImage.id) : -1; const canNavigateBatchResults = currentBatchIndex >= 0 && navigationItems.length > 1; @@ -472,7 +477,7 @@ export function AndroidCanvasStage() { onSelect={selectBatchResult} onClose={closeResultGrid} showClose={!showingLiveBatchGrid} - title={showingLiveBatchGrid ? `本批预览 · ${jobsCompleted}/${jobsTotal}` : undefined} + title={showingLiveBatchGrid ? `当前并发预览 · ${runningJobs.length} 路 · ${jobsCompleted}/${jobsTotal}` : undefined} /> ) : null} {!showingResultGrid && currentImage && compareB ? ( From 6d0066a7939dcb690b4f1ac5f32fc515b519f13e Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Mon, 8 Jun 2026 23:42:46 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=89=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/batch-img2img/README.md | 16 ++++--- image-studio/backend/dialogs.go | 44 +++++++++++++++++++ image-studio/backend/types.go | 4 ++ .../components/panel/BatchProcessSection.tsx | 15 ++++++- .../src/components/panel/ControlPanel.tsx | 5 ++- .../panel/DesktopComposeSections.tsx | 3 ++ .../src/components/panel/MacComposePanel.tsx | 3 ++ .../components/panel/MacComposeSources.tsx | 3 ++ .../components/panel/WindowsComposePanel.tsx | 3 ++ .../frontend/src/platform/runtime/host.ts | 8 ++++ .../src/platform/runtime/hostTypes.ts | 3 ++ .../frontend/src/state/studioStore.images.ts | 28 ++++++++++++ .../frontend/src/state/studioStore.ts | 19 ++++---- .../frontend/src/state/studioStore.types.ts | 1 + 14 files changed, 137 insertions(+), 18 deletions(-) diff --git a/docs/batch-img2img/README.md b/docs/batch-img2img/README.md index 8f5a58e7a..10bb44519 100644 --- a/docs/batch-img2img/README.md +++ b/docs/batch-img2img/README.md @@ -28,20 +28,22 @@ ## 使用流程 -1. 选择输入文件夹。 -2. 应用扫描当前目录中的图片文件。 -3. 确认扫描数量和示例文件名。 -4. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。 -5. 选择输出位置。 +1. 选择输入文件夹并扫描当前目录中的图片文件。 + 或者直接选择多张图片,把它们加入当前批处理队列。 +2. 确认当前批处理队列中的图片数量和示例文件名。 +3. 输入 prompt,并按需要设置比例、质量、seed、negative prompt 等图生图参数。 +4. 选择输出位置。 - 默认保存回原目录:每张结果回到对应源图所在目录。 - 独立输出路径:所有结果统一保存到你选定的目录。 -6. 设置并发数。 -7. 点击 `编辑` 开始处理。 +5. 设置并发数。 +6. 点击 `编辑` 开始处理。 ## 输入规则 当前版本只处理你选择的这一层目录,不递归子目录。 +如果你不想走目录模式,也可以直接选择多张图片加入批处理队列。 + 支持的输入图片格式: - `png` diff --git a/image-studio/backend/dialogs.go b/image-studio/backend/dialogs.go index 6f31bff70..d858f2303 100644 --- a/image-studio/backend/dialogs.go +++ b/image-studio/backend/dialogs.go @@ -66,6 +66,50 @@ func (s *Service) OpenImageDialog() (SelectFileResponse, error) { return resp, nil } +func (s *Service) OpenImagesDialog() (SelectFilesResponse, error) { + paths, err := runtime.OpenMultipleFilesDialog(s.ctx, runtime.OpenDialogOptions{ + Title: "选择批处理源图片", + Filters: []runtime.FileFilter{ + {DisplayName: "支持的图片 (*.png;*.jpg;*.jpeg;*.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp"}, + {DisplayName: "所有文件 (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil { + return SelectFilesResponse{}, err + } + if len(paths) == 0 { + return SelectFilesResponse{}, nil + } + files := make([]BatchInputImage, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + info, statErr := os.Stat(path) + if statErr != nil || info.IsDir() { + continue + } + item := BatchInputImage{ + Path: path, + Name: filepath.Base(path), + Size: info.Size(), + } + if cfg, cfgErr := imageConfig(path); cfgErr == nil { + item.Width = cfg.Width + item.Height = cfg.Height + } + 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 + } + } + files = append(files, item) + } + return SelectFilesResponse{Files: files}, nil +} + func (s *Service) ChooseBatchInputDir() (BatchInputDirectory, error) { if s.ctx == nil { return BatchInputDirectory{}, errors.New("服务未启动") diff --git a/image-studio/backend/types.go b/image-studio/backend/types.go index 4abec4c67..0ede8f858 100644 --- a/image-studio/backend/types.go +++ b/image-studio/backend/types.go @@ -215,6 +215,10 @@ type BatchInputDirectory struct { Images []BatchInputImage `json:"images"` } +type SelectFilesResponse struct { + Files []BatchInputImage `json:"files"` +} + // ImportedImage describes a freshly imported (drag-dropped or pasted) image. type ImportedImage struct { Path string `json:"path"` diff --git a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx index 12b44b275..12f978fc7 100644 --- a/image-studio/frontend/src/components/panel/BatchProcessSection.tsx +++ b/image-studio/frontend/src/components/panel/BatchProcessSection.tsx @@ -1,4 +1,4 @@ -import { FolderOpen, RefreshCw } from "lucide-react"; +import { FolderOpen, Images, RefreshCw } from "lucide-react"; import type { BatchProcessConfig, EditSourceMode } from "../../types/domain"; import { Section, Seg, SegItem } from "./panelChrome"; @@ -16,6 +16,7 @@ export function BatchProcessSection({ setEditSourceMode, setBatchProcess, onChooseInputDir, + onChooseInputFiles, onChooseOutputDir, onRefreshInputDir, usesFluentUI = false, @@ -26,6 +27,7 @@ export function BatchProcessSection({ setEditSourceMode: (mode: EditSourceMode) => void; setBatchProcess: (next: BatchProcessConfig) => void; onChooseInputDir: () => void; + onChooseInputFiles?: () => void; onChooseOutputDir?: () => void; onRefreshInputDir: () => void; usesFluentUI?: boolean; @@ -86,6 +88,15 @@ export function BatchProcessSection({ > 选择目录 + {onChooseInputFiles ? ( + + ) : null} - {onChooseInputFiles ? ( - - ) : null} - -
- -
- 当前队列 {batchProcess.discoveredSources.length} 张 -
{sampleNames(batchProcess)}
-
+
+
+
+
批处理队列
+
+ 支持选目录扫描,也支持直接选择多张图片加入队列。 +
+
+ + {batchProcess.discoveredSources.length} 张 + +
-
- 输出位置 - - 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 ? ( +
+ {onChooseInputFiles ? ( + + ) : null} + +
+
+ +
+
样例: {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 ${roundedClass}`} + /> + {onChooseOutputDir ? ( + + ) : null} +
) : null} + +
+ + +
+ 失败处理 + + setBatchProcess({ ...batchProcess, retryOnFailure: false })} + > + 失败直接跳过 + + setBatchProcess({ ...batchProcess, retryOnFailure: true })} + > + 自动重试 + + +
+ 批处理默认关闭自动重试,避免单张失败时整批任务长时间卡在 15 秒回退等待。 +
+
+
- ) : null} - - - -
- 不同源图比例处理 - - setBatchProcess({ ...batchProcess, autoAspectResolution: "" })} - > - 沿用当前比例 - - setBatchProcess({ ...batchProcess, autoAspectResolution: batchProcess.autoAspectResolution || "1k" })} - > - 按源图比例自动适配 - - +
+ +
+
批处理尺寸策略
+
+ 批处理开启按源图比例自动适配后,会接管本批任务的比例与分辨率计算。 +
+ +
+ 不同源图比例处理 + + setBatchProcess({ ...batchProcess, autoAspectResolution: "" })} + > + 沿用当前比例 + + setBatchProcess({ ...batchProcess, autoAspectResolution: batchProcess.autoAspectResolution || "1k" })} + > + 按源图比例自动适配 + + +
+ {batchProcess.autoAspectResolution !== "" ? ( <> -
+
统一分辨率档位
@@ -210,18 +284,18 @@ export function BatchProcessSection({ ))}
-
+
开启后,批处理会按每张源图自身宽高比自动适配尺寸,同时统一使用这里选定的分辨率档位。适合同一提示词但源图比例不同的目录批处理。
) : ( -
- 关闭时仍沿用当前控制面板的比例和尺寸语义。 +
+ 关闭时,批处理直接沿用当前控制面板里的比例和分辨率设置。
)}
-
+
结果文件名前缀固定为 processed-,遇到同名会自动追加 -2、-3。
diff --git a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx index d39fb7f6a..dd4c6e56e 100644 --- a/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx +++ b/image-studio/frontend/src/components/panel/DesktopComposeSections.tsx @@ -90,6 +90,9 @@ export function DesktopComposeSections({ styleTag: string; availableResolutions: ResolutionPreset[]; }) { + const batchMode = mode === "edit" && editSourceMode === "batch"; + const batchAutoAspectActive = batchMode && batchProcess.autoAspectResolution !== ""; + return ( <>
@@ -119,79 +122,89 @@ export function DesktopComposeSections({
-
- 自定义比例 - - ) : undefined} - > -
- {aspectOptions.map((aspect) => { - const active = activeAspect === aspect.value; - return ( + {!batchAutoAspectActive ? ( + <> +
handleAspectSelect(aspect.value)} - title={aspect.auto ? "让上游决定尺寸 / 比例" : aspect.label} - className={`flex min-h-[56px] flex-col items-center justify-center gap-1 ring-1 transition-colors ${ - active - ? "bg-[var(--accent-soft)] ring-[color:var(--accent)]/35" - : "ring-black/[0.08] dark:ring-white/[0.08] hover:ring-[color:var(--accent)]/30" - } py-2 ${usesFluentUI ? "rounded-[10px]" : "rounded-[14px]"}`} + type="button" + onClick={onOpenCustomAspectRatioModal} + className="text-[11px] text-[var(--accent)] transition-opacity hover:opacity-80" > - - {aspect.label} + 自定义比例 - ); - })} -
-
+ ) : undefined} + > +
+ {aspectOptions.map((aspect) => { + const active = activeAspect === aspect.value; + return ( + + ); + })} +
+ -
- {allowPreciseSizeControl ? ( -
- +
+ {allowPreciseSizeControl ? ( +
+ +
+ ) : null} + + {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( + handleResolutionSelect(item.value)} + > + {item.label} + + ))} + + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} + {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( +

+ {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} +

+ ) : null} +
+ + ) : ( +
+
+ 当前批处理已开启“按源图比例自动适配”,本批任务的比例与分辨率由“批处理图生图”区统一控制。这里的普通比例/分辨率预设已暂时隐藏,避免出现两套尺寸入口。
- ) : null} - - {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( - handleResolutionSelect(item.value)} - > - {item.label} - - ))} - - {exactSizeLabel ? ( -

- 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 -

- ) : null} - {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( -

- {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} -

- ) : null} -
+
+ )}
diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index c1f8ad224..53c6f5e77 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -33,7 +33,7 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void
  • Responses API · CF 超时推荐:POST /v1/responses,通过模型内置的 - image_generation 工具触发生图,SSE 流式接收。 + image_generation 工具触发生图,支持 HTTP SSE 与 WebSocket mode 两种传输。 能防 Cloudflare 524/504 超时(图像推理常常超过 100 秒)。
    Key 要绑「拥有 gpt-5.5 模型的分组」(中转站后台通常叫「余额分组」或「套餐分组」), @@ -51,6 +51,9 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void 选哪个?看你 key 绑的是哪个分组。 两边都有的话优先 Responses(SSE 更抗 524)。

    +

    + 如果在 Responses API 配置里把传输切到 WebSocket mode,应用会在连接中断时快速重连一次;若仍然失败,通常说明上游或代理不兼容,建议切回 HTTP SSE 再试。 +

diff --git a/image-studio/frontend/src/components/panel/MacComposePanel.tsx b/image-studio/frontend/src/components/panel/MacComposePanel.tsx index 41174da30..87f6dbc1b 100644 --- a/image-studio/frontend/src/components/panel/MacComposePanel.tsx +++ b/image-studio/frontend/src/components/panel/MacComposePanel.tsx @@ -111,6 +111,7 @@ export function MacComposePanel({ activeAspect={activeAspect} aspectOptions={aspectOptions} activeResolution={activeResolution} + batchAutoAspectActive={mode === "edit" && editSourceMode === "batch" && batchProcess.autoAspectResolution !== ""} exactSizeLabel={exactSizeLabel} allowCustomAspectRatios={allowCustomAspectRatios} allowPreciseSizeControl={allowPreciseSizeControl} diff --git a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx index a1ae401db..f3c416bc1 100644 --- a/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx +++ b/image-studio/frontend/src/components/panel/MacComposeStyleAndSize.tsx @@ -13,6 +13,7 @@ export function MacComposeStyleAndSize({ activeAspect, aspectOptions, activeResolution, + batchAutoAspectActive = false, exactSizeLabel, allowCustomAspectRatios, allowPreciseSizeControl, @@ -35,6 +36,7 @@ export function MacComposeStyleAndSize({ activeAspect: AspectPreset | null; aspectOptions: AspectPresetOption[]; activeResolution: ResolutionPreset | null; + batchAutoAspectActive?: boolean; exactSizeLabel?: string | null; allowCustomAspectRatios: boolean; allowPreciseSizeControl: boolean; @@ -81,81 +83,89 @@ export function MacComposeStyleAndSize({
-
-
- 比例 - {allowCustomAspectRatios ? ( - - ) : null} -
-
- {aspectOptions.map((aspect) => { - const active = activeAspect === aspect.value; - return ( - - ); - })} -
-
+ {!batchAutoAspectActive ? ( + <> +
+
+ 比例 + {allowCustomAspectRatios ? ( + + ) : null} +
+
+ {aspectOptions.map((aspect) => { + const active = activeAspect === aspect.value; + return ( + + ); + })} +
+
-
-
- 分辨率 - {allowPreciseSizeControl ? ( - - ) : null} +
+
+ 分辨率 + {allowPreciseSizeControl ? ( + + ) : null} +
+ + {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( + handleResolutionSelect(item.value)} + > + {item.label} + + ))} + + {exactSizeLabel ? ( +

+ 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 +

+ ) : null} + {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( +

+ {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} +

+ ) : null} +
+ + ) : ( +
+ 当前批处理已开启“按源图比例自动适配”,本批任务的比例与分辨率由“批处理图生图”区统一控制。这里的普通比例/分辨率预设已暂时隐藏,避免出现两套尺寸入口。
- - {RESOLUTION_PRESETS.filter((item) => availableResolutions.includes(item.value)).map((item) => ( - handleResolutionSelect(item.value)} - > - {item.label} - - ))} - - {exactSizeLabel ? ( -

- 当前精确尺寸 {exactSizeLabel}。点击比例或分辨率预设后会切回预设档位。 -

- ) : null} - {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID }) ? ( -

- {sizeCapabilityHint({ apiMode, requestPolicy, imageModelID })} -

- ) : null} -
+ )}
质量
diff --git a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx index 785b61551..bc1c64ac1 100644 --- a/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamConfigModal.tsx @@ -187,6 +187,7 @@ export function UpstreamConfigModal({ await updateProfile(match.id, { name: incoming.name, apiMode: incoming.apiMode, + responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, imagesNewAPICompat: incoming.imagesNewAPICompat, baseURL: incoming.baseURL, @@ -206,6 +207,7 @@ export function UpstreamConfigModal({ const newId = await createProfile({ name: incoming.name, apiMode: incoming.apiMode, + responsesTransport: incoming.responsesTransport ?? "sse", requestPolicy: incoming.requestPolicy, imagesNewAPICompat: incoming.imagesNewAPICompat, baseURL: incoming.baseURL, @@ -276,6 +278,7 @@ export function UpstreamConfigModal({ await updateProfile(draft.id, { name: draft.name, apiMode: draft.apiMode, + responsesTransport: draft.responsesTransport ?? "sse", requestPolicy: draft.requestPolicy, imagesNewAPICompat: draft.imagesNewAPICompat === true, baseURL: draft.baseURL, @@ -320,15 +323,31 @@ export function UpstreamConfigModal({ setLoadingModels(true); setModelCatalogError(null); try { - const result = await probeCurrentUpstream(baseURL, apiKey); + const runtimeState = useStudioStore.getState(); + const result = await probeCurrentUpstream( + baseURL, + apiKey, + runtimeState.proxyMode, + runtimeState.proxyURL, + draft.apiMode, + draft.responsesTransport ?? "sse", + ); const catalog = buildUpstreamModelCatalog(result.models ?? []); setModelCatalog(catalog); - pushToast( - catalog.all.length > 0 - ? `已加载 ${catalog.all.length} 个模型` - : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, - catalog.all.length > 0 ? "success" : "warn", - ); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + pushToast( + `已拉取模型,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, + "warn", + 7000, + ); + } else { + const message = result.responsesTransport === "websocket" + ? `已加载 ${catalog.all.length} 个模型,Responses WebSocket 可用` + : catalog.all.length > 0 + ? `已加载 ${catalog.all.length} 个模型` + : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`; + pushToast(message, catalog.all.length > 0 ? "success" : "warn"); + } } catch (error: any) { const message = `加载模型失败:${error?.message ?? error}`; setModelCatalogError(message); diff --git a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx index 0ed6a0083..ff793bf6f 100644 --- a/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx +++ b/image-studio/frontend/src/components/panel/UpstreamProfileEditor.tsx @@ -3,6 +3,7 @@ import { REASONING_EFFORT_OPTIONS, type APIMode, type RequestPolicy, + type ResponsesTransport, type UpstreamProfile, } from "../../types/domain"; import { requestPolicyLabel } from "../../lib/profiles"; @@ -66,6 +67,10 @@ export function UpstreamProfileEditor({ { id: "openai" as RequestPolicy, title: requestPolicyLabel("openai"), sub: "默认。只发送 OpenAI 官方公开字段。" }, { id: "compat" as RequestPolicy, title: requestPolicyLabel("compat"), sub: "兼容部分 relay 扩展字段,例如 seed / negative_prompt。" }, ]; + const responsesTransportOptions = [ + { id: "sse" as ResponsesTransport, title: "HTTP SSE", sub: "默认,兼容性更稳" }, + { id: "websocket" as ResponsesTransport, title: "WebSocket mode", sub: "需要上游支持" }, + ]; const selectedAPIMode = apiModeOptions.find((option) => option.id === draft.apiMode) ?? apiModeOptions[0]; const selectedRequestPolicy = requestPolicyOptions.find((option) => option.id === draft.requestPolicy) ?? requestPolicyOptions[0]; const selectedReasoningEffort = REASONING_EFFORT_OPTIONS.find((option) => option.value === draft.reasoningEffort) ?? REASONING_EFFORT_OPTIONS[0]; @@ -217,6 +222,36 @@ export function UpstreamProfileEditor({ {draft.apiMode === "responses" ? ( <> + + Responses 传输 + + 已选 {(draft.responsesTransport ?? "sse") === "websocket" ? "WebSocket mode" : "HTTP SSE"} + + + )} + > +
+ {responsesTransportOptions.map((option) => { + const active = (draft.responsesTransport ?? "sse") === option.id; + return ( + onPatchDraft({ responsesTransport: option.id })} + /> + ); + })} +
+ + 这是 Responses API 的传输方式,不是 Realtime API。当前仅桌面本地内核与 Android 壳层支持 WebSocket mode。 + +
+ ({ ...profile, + responsesTransport: profile.responsesTransport === "websocket" ? "websocket" : "sse", imagesNewAPICompat: profile.imagesNewAPICompat === true, apiKey: (apiKeysById[profile.id] ?? "").trim(), })), diff --git a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx index 20c8bdbab..313ff2f1b 100644 --- a/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx +++ b/image-studio/frontend/src/platform/android/canvas/AndroidCanvasStage.tsx @@ -59,20 +59,21 @@ export function AndroidCanvasStage() { }); const orderedBatchResults = sortHistoryItemsByCreatedAtAsc(batchResults); const navigationItems = orderedNavigationItemsForCurrent(currentImage?.id, history, batchResults); - const liveBatchSlotCount = Math.max( - runningJobs.length, - batchResults.length, - streamPreviewItems.length, - Math.min(runningJobs.length, batchResults.length + streamPreviewItems.length), - ); + const liveBatchSlotCount = runningJobs.length; const liveBatchSlots: BatchGridSlot[] = Array.from({ length: liveBatchSlotCount }, (_, index) => ({ type: "pending", id: `pending-${index}` })); - for (const item of batchResults) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length) liveBatchSlots[index] = { type: "result", item }; + for (const item of [...orderedBatchResults].reverse()) { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + liveBatchSlots[index] = { type: "result", item }; + } } for (const item of streamPreviewItems) { - const index = typeof item.batchIndex === "number" ? item.batchIndex : liveBatchSlots.findIndex((slot) => slot.type === "pending"); - if (index >= 0 && index < liveBatchSlots.length && liveBatchSlots[index].type === "pending") { + const index = typeof item.previewSlotIndex === "number" + ? item.previewSlotIndex + : liveBatchSlots.findIndex((slot) => slot.type === "pending"); + if (index >= 0 && index < liveBatchSlots.length) { liveBatchSlots[index] = { type: "preview", item }; } } diff --git a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx index 72056a3ae..a7e8339d0 100644 --- a/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx +++ b/image-studio/frontend/src/platform/android/upstream/AndroidUpstreamProfileForm.tsx @@ -152,6 +152,25 @@ export function AndroidUpstreamProfileForm({ {draft.apiMode === "responses" ? ( <> + +
+ {[ + { id: "sse", title: "HTTP SSE", meta: "默认,兼容性更稳" }, + { id: "websocket", title: "WebSocket", meta: "需要上游支持" }, + ].map((option) => ( + + ))} +
+
+ 0 - ? `已加载 ${catalog.all.length} 个模型` - : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, - catalog.all.length > 0 ? "success" : "warn", - ); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + pushToast( + `已拉取模型,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, + "warn", + 7000, + ); + } else { + pushToast( + result.responsesTransport === "websocket" + ? `已加载 ${catalog.all.length} 个模型,Responses WebSocket 可用` + : catalog.all.length > 0 + ? `已加载 ${catalog.all.length} 个模型` + : `已连接上游,共返回 ${result.modelCount} 个条目,但没有可识别的模型 ID`, + catalog.all.length > 0 ? "success" : "warn", + ); + } } catch (error: any) { const message = `加载模型失败:${error?.message ?? error}`; setModelCatalogError(message); diff --git a/image-studio/frontend/src/platform/runtime/host.ts b/image-studio/frontend/src/platform/runtime/host.ts index 744f98531..d56ec03bd 100644 --- a/image-studio/frontend/src/platform/runtime/host.ts +++ b/image-studio/frontend/src/platform/runtime/host.ts @@ -726,10 +726,19 @@ export async function probeCurrentUpstream( apiKey: string, proxyMode = "system", proxyURL = "", + apiMode = "responses", + responsesTransport = "sse", signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - const options: ProbeUpstreamOptionsLike = { baseURL, apiKey, proxyMode, proxyURL }; + const options: ProbeUpstreamOptionsLike = { + baseURL, + apiKey, + proxyMode, + proxyURL, + apiMode, + responsesTransport, + }; if (hasServiceMethod("ProbeUpstream")) { return invokeService(unsupportedMessage, "ProbeUpstream", options); } diff --git a/image-studio/frontend/src/platform/runtime/hostTypes.ts b/image-studio/frontend/src/platform/runtime/hostTypes.ts index c3ba9e0f8..2577fd3fc 100644 --- a/image-studio/frontend/src/platform/runtime/hostTypes.ts +++ b/image-studio/frontend/src/platform/runtime/hostTypes.ts @@ -25,6 +25,7 @@ export type GenerateOptionsLike = { proxyMode?: string; proxyURL?: string; apiMode: string; + responsesTransport?: string; requestPolicy: string; imagesNewAPICompat?: boolean; noPromptRevision: boolean; @@ -37,6 +38,7 @@ export type GenerateOptionsLike = { imageModelID: string; reasoningEffort?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; }; @@ -69,11 +71,16 @@ export type ProbeUpstreamOptionsLike = { baseURL: string; proxyMode?: string; proxyURL?: string; + apiMode?: string; + responsesTransport?: string; }; export type ProbeUpstreamResultLike = { modelCount: number; models?: UpstreamModelDescriptorLike[]; + responsesTransport?: string; + responsesTransportOK?: boolean; + responsesTransportError?: string; }; export type UpstreamModelDescriptorLike = { diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts new file mode 100644 index 000000000..ec3a64887 --- /dev/null +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/nativeWebSocket.ts @@ -0,0 +1,62 @@ +import { invokeAndroidNative } from "../../android/nativeInvoke.ts"; +import type { NativeTextResponse } from "./types.ts"; + +type NativeProgressWindow = Window & { + __imageStudioNativeProgress?: (requestId: string, payload: unknown) => void; +}; + +const nativeWSProgressHandlers = new Map void>(); +let wsProgressHookInstalled = false; +let wsProgressHookWindow: NativeProgressWindow | null = null; + +function ensureAndroidWSProgressHook() { + if (typeof window === "undefined") return; + const browserWindow = window as NativeProgressWindow; + if (wsProgressHookInstalled && wsProgressHookWindow === browserWindow) return; + const previous = browserWindow.__imageStudioNativeProgress; + browserWindow.__imageStudioNativeProgress = (requestId, payload) => { + const handler = nativeWSProgressHandlers.get(requestId); + if (handler) { + handler(payload); + return; + } + previous?.(requestId, payload); + }; + wsProgressHookInstalled = true; + wsProgressHookWindow = browserWindow; +} + +export async function nativeWebSocketResponsesRequest( + requestKey: string, + baseURL: string, + apiKey: string, + payload: string, + signal?: AbortSignal, + onStreamPayload?: (payload: unknown) => void, + proxyConfig?: { proxyMode?: string; proxyURL?: string }, +): Promise { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + ensureAndroidWSProgressHook(); + if (onStreamPayload) nativeWSProgressHandlers.set(requestKey, onStreamPayload); + let aborted = false; + const onAbort = () => { + aborted = true; + void invokeAndroidNative("CancelWebSocketRequest", requestKey).catch(() => undefined); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const response = await invokeAndroidNative("ResponsesWebSocketRequest", { + requestKey, + baseURL, + apiKey, + payload, + proxyMode: proxyConfig?.proxyMode || "system", + proxyURL: proxyConfig?.proxyURL || "", + }); + if (aborted) throw new DOMException("Aborted", "AbortError"); + return response; + } finally { + nativeWSProgressHandlers.delete(requestKey); + signal?.removeEventListener("abort", onAbort); + } +} diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts index 62a479cbc..3e33ea904 100644 --- a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts @@ -7,6 +7,7 @@ import { shouldUseAndroidNativeHTTP, } from "./common.ts"; import { nativeHttpRequestText } from "./nativeHttp.ts"; +import { nativeWebSocketResponsesRequest } from "./nativeWebSocket.ts"; import { buildResponsesPayload } from "./requestPayloads.ts"; import { MAX_ATTEMPTS, @@ -18,6 +19,19 @@ import { type RemoteJobResult, } from "./types.ts"; +type ResponsesWSRunStateSnapshot = { + attemptIndex: number; + socketEpoch: number; + createdAt: number; + lastActivityAt: number; + requestPayload: string; + latestEventType: string; + partialPreviewCount: number; + hasFinalImage: boolean; + completed: boolean; + cancelled: boolean; +}; + function summarizeSSELine(line: string): string { const stripped = line.trim(); if (!stripped) return ""; @@ -120,6 +134,21 @@ function emitPartialPreview(event: any, callbacks: RemoteJobCallbacks) { }); } +function updateRunStateFromEvent(state: ResponsesWSRunStateSnapshot, event: any) { + if (!event?.type) return; + state.lastActivityAt = Date.now(); + state.latestEventType = event.type; + if (event.type === "response.image_generation_call.partial_image") { + state.partialPreviewCount += 1; + } + if (event.type === "response.output_item.done" && event?.item?.type === "image_generation_call" && event?.item?.result) { + state.hasFinalImage = true; + } + if (event.type === "response.completed") { + state.completed = true; + } +} + function walkForImageCall(value: any): any | null { if (!value) return null; if (Array.isArray(value)) { @@ -181,6 +210,14 @@ function extractImageResult(raw: string): ExtractedImageResult | null { return null; } +function buildWebSocketCreatePayload(body: string): string { + const parsed = JSON.parse(body) as Record; + delete parsed.stream; + delete parsed.background; + parsed.type = "response.create"; + return JSON.stringify(parsed); +} + export async function requestResponsesOnce( request: RemoteJobRequest, attempt: number, @@ -200,14 +237,28 @@ export async function requestResponsesOnce( }, STATUS_INTERVAL_MS); try { const proxyMode = request.payload.proxyMode === "none" || request.payload.proxyMode === "custom" ? request.payload.proxyMode : "system"; + const responsesTransport = request.payload.responsesTransport === "websocket" ? "websocket" : "sse"; if (shouldUseAndroidNativeHTTP()) { let receivedNativeStreamPayload = false; + const runState: ResponsesWSRunStateSnapshot = { + attemptIndex: attempt, + socketEpoch: 1, + createdAt: Date.now(), + lastActivityAt: Date.now(), + requestPayload: body, + latestEventType: "", + partialPreviewCount: 0, + hasFinalImage: false, + completed: false, + cancelled: false, + }; const consumeNativePayload = (payload: unknown) => { receivedNativeStreamPayload = true; const parsed = parseNativeProgressPayload(payload); if (parsed.line) { bytesReceived += parsed.line.length + 1; } + updateRunStateFromEvent(runState, parsed.event); emitPartialPreview(parsed.event, callbacks); const summary = parsed.event ? summarizeSSEEvent(parsed.event) : summarizeSSELine(parsed.line); if (summary) { @@ -216,19 +267,48 @@ export async function requestResponsesOnce( callbacks.onProgress?.(lastStage, nowSeconds(startedAt), bytesReceived); } }; - const response = await nativeHttpRequestText( - url, - "POST", - { - Authorization: `Bearer ${request.payload.apiKey}`, - "Content-Type": "application/json", - Accept: "text/event-stream, application/json", - }, - body, - callbacks.signal, - consumeNativePayload, - { proxyMode, proxyURL: request.payload.proxyURL || "" }, - ); + const requestOnce = async (socketEpoch: number) => { + runState.socketEpoch = socketEpoch; + return responsesTransport === "websocket" + ? await nativeWebSocketResponsesRequest( + `native-ws-${attempt}-${socketEpoch}-${Math.random().toString(36).slice(2, 10)}`, + normalizeBaseURL(request.payload.baseURL), + request.payload.apiKey, + buildWebSocketCreatePayload(body), + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ) + : await nativeHttpRequestText( + url, + "POST", + { + Authorization: `Bearer ${request.payload.apiKey}`, + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + }, + body, + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ); + }; + let response: Awaited>; + try { + response = await requestOnce(1); + } catch (error) { + if (responsesTransport === "websocket" && !runState.hasFinalImage) { + callbacks.onLog?.("WebSocket 连接中断,正在重新连接并重放本次生成..."); + try { + response = await requestOnce(2); + } catch (retryError) { + callbacks.onLog?.(`WebSocket 重连失败: ${String((retryError as any)?.message || retryError)}`); + throw retryError; + } + } else { + throw error; + } + } raw = response.body || ""; if (response.resultImageB64) { return { diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts index d7efb32d2..ba2be8f54 100644 --- a/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/types.ts @@ -33,6 +33,7 @@ export type RemoteGeneratePayload = { proxyMode?: string; proxyURL?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; noPromptRevision: boolean; @@ -46,6 +47,7 @@ export type RemoteGeneratePayload = { imageModelID: string; reasoningEffort?: string; apiMode: string; + responsesTransport?: string; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; }; diff --git a/image-studio/frontend/src/state/studioStore.profiles.ts b/image-studio/frontend/src/state/studioStore.profiles.ts index e5560656e..3e96cb083 100644 --- a/image-studio/frontend/src/state/studioStore.profiles.ts +++ b/image-studio/frontend/src/state/studioStore.profiles.ts @@ -10,6 +10,7 @@ import { genProfileId, keyringUserFor, nextDefaultProfileName, + normalizeResponsesTransport, pickActiveProfile, } from "../lib/profiles"; import { cleanBaseURL } from "../lib/security"; @@ -26,6 +27,7 @@ export function createProfileActions(store: StateAdapter) { async createProfile(input: { name?: string; apiMode: APIMode; + responsesTransport?: UpstreamProfile["responsesTransport"]; baseURL?: string; requestPolicy?: RequestPolicy; imagesNewAPICompat?: boolean; @@ -42,6 +44,7 @@ export function createProfileActions(store: StateAdapter) { id, name: input.name?.trim() || nextDefaultProfileName(list), apiMode: input.apiMode, + responsesTransport: normalizeResponsesTransport(input.apiMode === "responses" ? input.responsesTransport : "sse"), requestPolicy: input.requestPolicy ?? "openai", imagesNewAPICompat: input.imagesNewAPICompat === true, baseURL: cleanBaseURL(input.baseURL ?? ""), @@ -76,6 +79,9 @@ export function createProfileActions(store: StateAdapter) { ...current, name: patch.name !== undefined ? patch.name.trim() : current.name, apiMode: patch.apiMode ?? current.apiMode, + responsesTransport: patch.responsesTransport !== undefined + ? normalizeResponsesTransport(patch.responsesTransport) + : normalizeResponsesTransport(current.responsesTransport), requestPolicy: patch.requestPolicy ?? current.requestPolicy, imagesNewAPICompat: patch.imagesNewAPICompat ?? current.imagesNewAPICompat ?? false, baseURL: patch.baseURL !== undefined ? cleanBaseURL(patch.baseURL) : current.baseURL, @@ -100,6 +106,7 @@ export function createProfileActions(store: StateAdapter) { const apiKey = patch.apiKey !== undefined ? patch.apiKey.trim() : store.getState().apiKey; store.setState({ apiMode: next.apiMode, + responsesTransport: next.responsesTransport ?? "sse", requestPolicy: next.requestPolicy, imagesNewAPICompat: next.imagesNewAPICompat ?? false, baseURL: next.baseURL, @@ -138,6 +145,7 @@ export function createProfileActions(store: StateAdapter) { imageModelID: "", reasoningEffort: "xhigh", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, upstreamModalOpen: false, @@ -176,6 +184,7 @@ export function createProfileActions(store: StateAdapter) { profiles: nextProfiles, activeProfileId: id, apiMode: profile.apiMode, + responsesTransport: profile.responsesTransport ?? "sse", requestPolicy: profile.requestPolicy, imagesNewAPICompat: profile.imagesNewAPICompat ?? false, baseURL: profile.baseURL, diff --git a/image-studio/frontend/src/state/studioStore.streamPreview.ts b/image-studio/frontend/src/state/studioStore.streamPreview.ts index 5852819b9..9f4ddc662 100644 --- a/image-studio/frontend/src/state/studioStore.streamPreview.ts +++ b/image-studio/frontend/src/state/studioStore.streamPreview.ts @@ -23,6 +23,7 @@ export type StreamPreviewPayload = { previewHeight?: number; revisedPrompt?: string; partialImageIndex?: number; + previewSlotIndex?: number; mode?: string; prompt?: string; }; @@ -36,6 +37,7 @@ export type StreamPreviewSnapshot = { outputFormat: OutputFormatValue; currentImage: HistoryItem | null; batchIndex?: number; + previewSlotIndex?: number; }; export function latestStreamPreview(previews: StreamPreviewMap | null | undefined): StreamPreview | null { @@ -75,6 +77,7 @@ export function streamPreviewItemFromPayload( parentId: mode === "edit" ? snapshot.currentImage?.savedPath : undefined, createdAt: Date.now(), batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, previewOnly: true, }; } @@ -101,6 +104,7 @@ export function streamPreviewStatePatch( revisedPrompt: item.revisedPrompt, partialImageIndex: payload.partialImageIndex, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, updatedAt: Date.now(), }; const streamPreviews = { ...previousPreviews, [jobId]: nextPreview }; @@ -185,6 +189,7 @@ export function streamPreviewItemFromWorkspace( outputFormat: workspace.outputFormat, currentImage, batchIndex: preview.batchIndex, + previewSlotIndex: preview.previewSlotIndex, }); } diff --git a/image-studio/frontend/src/state/studioStore.ts b/image-studio/frontend/src/state/studioStore.ts index ab9620263..20509edd7 100644 --- a/image-studio/frontend/src/state/studioStore.ts +++ b/image-studio/frontend/src/state/studioStore.ts @@ -196,6 +196,7 @@ type JobSnapshot = { workspaceId: string; apiMode: APIModeValue; batchIndex: number; + previewSlotIndex?: number; size: SizeValue; quality: QualityValue; outputFormat: OutputFormatValue; @@ -216,6 +217,7 @@ type LoopRunController = { totalJobs: number; maxConcurrent: number; launchedJobs: number; + nextPreviewSlotIndex: number; mode: Mode; payload: RuntimeGenerateOptions; snapshotBase: Omit; @@ -268,6 +270,8 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { if ((!batchQueueMode && latestRuntime.jobsTotal === 0) || latestRuntime.runningJobs.length >= controller.maxConcurrent) break; const batchIndex = controller.launchedJobs; controller.launchedJobs += 1; + const previewSlotIndex = controller.nextPreviewSlotIndex; + controller.nextPreviewSlotIndex = (controller.nextPreviewSlotIndex + 1) % Math.max(1, controller.maxConcurrent); const payloadSeed = controller.payload.seed ? controller.payload.seed + batchIndex : 0; const batchSource = controller.batchSources?.[batchIndex]; const batchPayloadSize = batchSource @@ -291,6 +295,7 @@ function launchQueuedLoopJobs(controller: LoopRunController): void { void launchOneJob(controller.mode, nextPayload, { ...controller.snapshotBase, batchIndex, + previewSlotIndex, sources: batchSource ? [batchSource as SourceImage] : controller.snapshotBase.sources, batchProcessLink: batchSource ? { @@ -608,6 +613,7 @@ export const useStudioStore = create((set, get) => ({ proxyMode: "system", proxyURL: "", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, noPromptRevision: true, @@ -1125,6 +1131,15 @@ export const useStudioStore = create((set, get) => ({ } } const activeProfile = s.profiles.find((p) => p.id === s.activeProfileId); + const responsesTransport = activeProfile?.responsesTransport ?? s.responsesTransport; + if (s.apiMode === "responses" && responsesTransport === "websocket" && s.kernelRuntimeMode === "remote" && !runtimePlatform.isAndroid) { + set({ + errorMessage: "当前远程内核模式暂不支持 Responses WebSocket mode,请切回本地内核或关闭该开关。", + errorCanRetry: false, + errorRawPath: null, + }); + return; + } const concurrencyLimit = normalizeConcurrencyLimit(activeProfile?.concurrencyLimit ?? 0); const fallbackProfile = activeProfile?.fallbackProfileId ? s.profiles.find((profile) => profile.id === activeProfile.fallbackProfileId) ?? null @@ -1255,6 +1270,7 @@ export const useStudioStore = create((set, get) => ({ reasoningEffort: s.reasoningEffort, proxyMode: s.proxyMode, proxyURL: s.proxyURL, + responsesTransport: s.responsesTransport, requestPolicy: s.requestPolicy, imagesNewAPICompat: s.imagesNewAPICompat, apiMode: s.apiMode, @@ -1269,11 +1285,12 @@ export const useStudioStore = create((set, get) => ({ imageModelID: fallbackProfile.imageModelID, reasoningEffort: fallbackProfile.reasoningEffort, apiMode: fallbackProfile.apiMode, + responsesTransport: fallbackProfile.responsesTransport ?? "sse", requestPolicy: fallbackProfile.requestPolicy, imagesNewAPICompat: fallbackProfile.imagesNewAPICompat === true, } : undefined, - autoRetryEnabled: s.autoRetryEnabled, + autoRetryEnabled: batchProcessEnabled ? batchProcess.retryOnFailure : s.autoRetryEnabled, disablePreview: s.partialImages === 0 || (loopEnabled && !loopGeneration.livePreview) || forceDisableStreamPreview, }; const remotePayload: RuntimeGenerateOptions = { @@ -1322,6 +1339,7 @@ export const useStudioStore = create((set, get) => ({ totalJobs: batchProcess.discoveredSources.length, maxConcurrent: requestedConcurrency, launchedJobs: 0, + nextPreviewSlotIndex: 0, mode: s.mode, payload: remotePayload, snapshotBase, @@ -1348,6 +1366,7 @@ export const useStudioStore = create((set, get) => ({ totalJobs: requestedJobCount, maxConcurrent: requestedConcurrency, launchedJobs: 0, + nextPreviewSlotIndex: 0, mode: s.mode, payload: remotePayload, snapshotBase, @@ -1364,6 +1383,7 @@ export const useStudioStore = create((set, get) => ({ void launchOneJob(s.mode, p, { ...snapshotBase, batchIndex: i, + previewSlotIndex: i, }); } }, @@ -1438,6 +1458,7 @@ export const useStudioStore = create((set, get) => ({ proxyMode: "system", proxyURL: "", apiMode: preview.profile.apiMode, + responsesTransport: preview.profile.responsesTransport ?? "sse", requestPolicy: preview.profile.requestPolicy, noPromptRevision: true, profiles: [preview.profile], @@ -1644,6 +1665,7 @@ export const useStudioStore = create((set, get) => ({ id, name: "Responses · 默认", apiMode: "responses", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, baseURL: legacyResponses.baseURL, @@ -1664,6 +1686,7 @@ export const useStudioStore = create((set, get) => ({ id, name: "Images · 默认", apiMode: "images", + responsesTransport: "sse", requestPolicy: "openai", imagesNewAPICompat: false, baseURL: legacyImages.baseURL, @@ -1700,6 +1723,7 @@ export const useStudioStore = create((set, get) => ({ persistActiveProfileId(activeProfileId); } const apiMode: APIMode = activeProfile?.apiMode ?? "responses"; + const responsesTransport = activeProfile?.responsesTransport ?? "sse"; const requestPolicy: RequestPolicy = activeProfile?.requestPolicy ?? "openai"; const imagesNewAPICompat = activeProfile?.imagesNewAPICompat === true; const baseURL = activeProfile?.baseURL ?? ""; @@ -1775,7 +1799,7 @@ export const useStudioStore = create((set, get) => ({ historyHasMore, historyLoading: false, historyCursorBeforeDayStart: initialHistoryPage.nextCursor?.beforeDayStart ?? null, - apiMode, requestPolicy, imagesNewAPICompat, baseURL, textModelID, imageModelID, reasoningEffort, kernelRuntimeMode, noPromptRevision, + apiMode, responsesTransport, requestPolicy, imagesNewAPICompat, baseURL, textModelID, imageModelID, reasoningEffort, kernelRuntimeMode, noPromptRevision, proxyMode: proxyConfig.mode, proxyURL: proxyConfig.url, outputFormat, @@ -2028,9 +2052,22 @@ export const useStudioStore = create((set, get) => ({ set({ isTestingKey: true }); s.pushToast("正在测试连接...", "info", 8000); try { - await probeCurrentUpstream(cleanedBaseURL, s.apiKey.trim(), s.proxyMode, s.proxyURL); + const result = await probeCurrentUpstream( + cleanedBaseURL, + s.apiKey.trim(), + s.proxyMode, + s.proxyURL, + s.apiMode, + s.responsesTransport, + ); set({ isTestingKey: false }); - s.pushToast("连接 OK · 上游 models 列表可访问", "success"); + if (result.responsesTransport === "websocket" && result.responsesTransportOK === false) { + s.pushToast(`基础连接 OK,但 Responses WebSocket 不可用:${result.responsesTransportError || "未返回具体原因"}`, "warn", 7000); + } else if (result.responsesTransport === "websocket") { + s.pushToast("连接 OK · /v1/models 可访问,Responses WebSocket 可用", "success"); + } else { + s.pushToast("连接 OK · 上游 models 列表可访问", "success"); + } } catch (e: any) { set({ isTestingKey: false }); s.pushToast(`连接失败:${e?.message ?? e}`, "error", 6000); @@ -2261,6 +2298,7 @@ async function launchOneJob( outputFormat: snapshot.outputFormat, currentImage: snapshot.currentImage, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, }) ?? {} )); }); @@ -2305,6 +2343,7 @@ async function launchOneJob( moderation: payload.moderation === "auto" ? "auto" : "low", styleTag: snapshot.styleTag || undefined, batchIndex: snapshot.batchIndex, + previewSlotIndex: snapshot.previewSlotIndex, elapsedSec: Number(elapsedSec.toFixed(1)), savedPath: r.savedPath, rawPath: r.rawPath, diff --git a/image-studio/frontend/src/state/studioStore.types.ts b/image-studio/frontend/src/state/studioStore.types.ts index 5598c874a..fb68019f7 100644 --- a/image-studio/frontend/src/state/studioStore.types.ts +++ b/image-studio/frontend/src/state/studioStore.types.ts @@ -95,6 +95,7 @@ export interface StudioState { imageModelID: string; reasoningEffort: import("../types/domain").ReasoningEffortValue; apiMode: APIMode; + responsesTransport: import("../types/domain").ResponsesTransport; requestPolicy: RequestPolicy; imagesNewAPICompat: boolean; noPromptRevision: boolean; @@ -162,6 +163,7 @@ export interface StudioState { createProfile: (input: { name?: string; apiMode: APIMode; + responsesTransport?: import("../types/domain").ResponsesTransport; baseURL?: string; requestPolicy?: RequestPolicy; imagesNewAPICompat?: boolean; diff --git a/image-studio/frontend/src/state/workspaceRuntime.ts b/image-studio/frontend/src/state/workspaceRuntime.ts index 2498b7d9c..a86272c01 100644 --- a/image-studio/frontend/src/state/workspaceRuntime.ts +++ b/image-studio/frontend/src/state/workspaceRuntime.ts @@ -97,6 +97,7 @@ export function defaultBatchProcessConfig(): BatchProcessConfig { outputMode: "source_dir", outputDir: "", concurrency: DEFAULT_BATCH_PROCESS_CONCURRENCY, + retryOnFailure: false, fileNamePrefix: "processed-", autoAspectResolution: "", discoveredSources: [], @@ -154,6 +155,7 @@ export function normalizeBatchProcessConfig(value: unknown): BatchProcessConfig outputMode: source.outputMode === "custom_dir" ? "custom_dir" : "source_dir", outputDir: typeof source.outputDir === "string" ? source.outputDir.trim() : "", concurrency: normalizeBatchProcessConcurrency(source.concurrency), + retryOnFailure: source.retryOnFailure === true, fileNamePrefix: typeof source.fileNamePrefix === "string" && source.fileNamePrefix.trim() ? source.fileNamePrefix.trim() : "processed-", diff --git a/image-studio/frontend/src/types/domain.ts b/image-studio/frontend/src/types/domain.ts index 767a0aee8..4f15db409 100644 --- a/image-studio/frontend/src/types/domain.ts +++ b/image-studio/frontend/src/types/domain.ts @@ -8,6 +8,7 @@ export type Mode = "generate" | "edit"; export type APIMode = "responses" | "images"; export type RequestPolicy = "openai" | "compat"; export type ReasoningEffortValue = "low" | "medium" | "high" | "xhigh"; +export type ResponsesTransport = "sse" | "websocket"; // UpstreamProfile 是一组完整可用于生成的上游配置。用户可以保存多个,例如 // 「gptcodex 主号 / gptcodex 备号 / OpenAI 直连」,在 UI 里下拉切换 active。 @@ -19,6 +20,7 @@ export interface UpstreamProfile { id: string; name: string; apiMode: APIMode; + responsesTransport?: ResponsesTransport; requestPolicy: RequestPolicy; imagesNewAPICompat?: boolean; baseURL: string; @@ -179,6 +181,7 @@ export interface BatchProcessConfig { outputMode: BatchProcessOutputMode; outputDir: string; concurrency: number; + retryOnFailure: boolean; fileNamePrefix: string; autoAspectResolution: BatchProcessAutoAspectResolution; discoveredSources: BatchProcessSourceImage[]; @@ -218,6 +221,7 @@ export interface HistoryItem { moderation?: ModerationValue; styleTag?: string; batchIndex?: number; + previewSlotIndex?: number; elapsedSec?: number; // generation duration in seconds savedPath?: string; @@ -240,6 +244,7 @@ export interface StreamPreview { revisedPrompt?: string; partialImageIndex?: number; batchIndex?: number; + previewSlotIndex?: number; updatedAt: number; } diff --git a/image-studio/frontend/test/remoteKernel.test.mjs b/image-studio/frontend/test/remoteKernel.test.mjs index 046fb0806..44c7ab521 100644 --- a/image-studio/frontend/test/remoteKernel.test.mjs +++ b/image-studio/frontend/test/remoteKernel.test.mjs @@ -944,3 +944,66 @@ test("Android shell remote kernel can use native HTTP bridge to bypass browser f assert.ok(progressEvents.some(([stage]) => stage === "已收到图片数据片段")); }); }); + +test("Android shell remote kernel wraps Responses websocket requests as response.create payloads", async () => { + let capturedInvoke = null; + await withPatchedGlobals(async () => { + globalThis.window.AndroidImageStudio = { + invoke(requestId, method, payloadJson) { + const args = JSON.parse(payloadJson); + if (method === "ResponsesWebSocketRequest") { + capturedInvoke = { method, args }; + queueMicrotask(() => { + window.__imageStudioNativeResolve?.(requestId, { + status: 200, + body: "", + contentType: "text/event-stream", + resultImageB64: "ZmluYWw=", + revisedPrompt: "rev", + sourceEvent: "final", + }); + }); + return; + } + window.__imageStudioNativeReject?.(requestId, `unsupported ${method}`); + }, + }; + globalThis.fetch = async () => { + throw new Error("websocket branch should not use browser fetch"); + }; + }, async () => { + const kernel = await loadRemoteKernel(); + const result = await kernel.runRemoteImageJob( + { + payload: { + apiKey: "key", + mode: "generate", + prompt: "cat", + size: "1024x1024", + quality: "low", + outputFormat: "png", + imagePaths: [], + imagePath: "", + maskB64: "", + seed: 0, + negativePrompt: "", + baseURL: "https://upstream.example", + textModelID: "gpt-5.5", + imageModelID: "gpt-image-2", + apiMode: "responses", + responsesTransport: "websocket", + requestPolicy: "openai", + noPromptRevision: false, + }, + }, + { signal: new AbortController().signal }, + ); + assert.equal(result.imageB64, "ZmluYWw="); + assert.equal(capturedInvoke?.method, "ResponsesWebSocketRequest"); + const payload = JSON.parse(capturedInvoke.args[0].payload); + assert.equal(payload.type, "response.create"); + assert.equal(payload.model, "gpt-5.5"); + assert.equal(payload.store, false); + assert.equal(payload.stream, undefined); + }); +}); diff --git a/image-studio/frontend/test/runtimeHost.test.mjs b/image-studio/frontend/test/runtimeHost.test.mjs index 624d7b732..6ce522ea4 100644 --- a/image-studio/frontend/test/runtimeHost.test.mjs +++ b/image-studio/frontend/test/runtimeHost.test.mjs @@ -652,7 +652,14 @@ test("runtimeHost probes upstream through Wails backend", async () => { const runtimeHost = await loadRuntimeHost(); await runtimeHost.probeCurrentUpstream("https://relay.example.com", "sk-test"); assert.deepEqual(globalThis.__probeCalls, [ - { baseURL: "https://relay.example.com", apiKey: "sk-test", proxyMode: "system", proxyURL: "" }, + { + baseURL: "https://relay.example.com", + apiKey: "sk-test", + proxyMode: "system", + proxyURL: "", + apiMode: "responses", + responsesTransport: "sse", + }, ]); }); }); @@ -682,7 +689,14 @@ test("runtimeHost probes upstream through Android backend", async () => { assert.deepEqual(globalThis.__probeCalls, [ { method: "ProbeUpstream", - args: [{ baseURL: "https://relay.example.com", apiKey: "sk-android", proxyMode: "system", proxyURL: "" }], + args: [{ + baseURL: "https://relay.example.com", + apiKey: "sk-android", + proxyMode: "system", + proxyURL: "", + apiMode: "responses", + responsesTransport: "sse", + }], }, ]); }); diff --git a/image-studio/frontend/test/upstreamConfigTransfer.test.mjs b/image-studio/frontend/test/upstreamConfigTransfer.test.mjs index d85863459..1c8192d5e 100644 --- a/image-studio/frontend/test/upstreamConfigTransfer.test.mjs +++ b/image-studio/frontend/test/upstreamConfigTransfer.test.mjs @@ -35,6 +35,7 @@ test("parseUpstreamConfigImportFile normalizes profiles and preserves embedded a id: "p-2", name: "备用", apiMode: "images", + responsesTransport: "sse", requestPolicy: "compat", imagesNewAPICompat: true, baseURL: " https://img.example.com/ ", @@ -57,6 +58,7 @@ test("parseUpstreamConfigImportFile normalizes profiles and preserves embedded a id: "p-2", name: "备用", apiMode: "images", + responsesTransport: "sse", requestPolicy: "compat", imagesNewAPICompat: true, baseURL: "https://img.example.com/", diff --git a/image-studio/frontend/test/workspaceRuntime.test.mjs b/image-studio/frontend/test/workspaceRuntime.test.mjs index 94b9e0eb9..eff06da25 100644 --- a/image-studio/frontend/test/workspaceRuntime.test.mjs +++ b/image-studio/frontend/test/workspaceRuntime.test.mjs @@ -53,6 +53,7 @@ test("normalizes batch process config and keeps scanned image dimensions", () => outputMode: "custom_dir", outputDir: " /tmp/out ", concurrency: 3.8, + retryOnFailure: true, fileNamePrefix: "processed-", autoAspectResolution: "4k", discoveredSources: [ @@ -64,6 +65,7 @@ test("normalizes batch process config and keeps scanned image dimensions", () => assert.equal(value.inputDir, "/tmp/src"); assert.equal(value.outputDir, "/tmp/out"); assert.equal(value.concurrency, 3); + assert.equal(value.retryOnFailure, true); assert.equal(value.autoAspectResolution, "4k"); assert.deepEqual(value.discoveredSources, [ { path: "/tmp/a.png", name: "a.png", size: 12, width: 1200, height: 800, previewUrl: undefined, previewWidth: undefined, previewHeight: undefined }, diff --git a/image-studio/frontend/wailsjs/go/models.ts b/image-studio/frontend/wailsjs/go/models.ts index 7c662bda6..48297bc7f 100755 --- a/image-studio/frontend/wailsjs/go/models.ts +++ b/image-studio/frontend/wailsjs/go/models.ts @@ -26,6 +26,7 @@ export namespace backend { proxyMode: string; proxyURL: string; apiMode: string; + responsesTransport: string; requestPolicy: string; imagesNewAPICompat: boolean; noPromptRevision: boolean; @@ -66,6 +67,7 @@ export namespace backend { this.proxyMode = source["proxyMode"]; this.proxyURL = source["proxyURL"]; this.apiMode = source["apiMode"]; + this.responsesTransport = source["responsesTransport"]; this.requestPolicy = source["requestPolicy"]; this.imagesNewAPICompat = source["imagesNewAPICompat"]; this.noPromptRevision = source["noPromptRevision"]; @@ -209,6 +211,8 @@ export namespace backend { baseURL: string; proxyMode: string; proxyURL: string; + apiMode: string; + responsesTransport: string; static createFrom(source: any = {}) { return new ProbeUpstreamOptions(source); @@ -220,11 +224,16 @@ export namespace backend { this.baseURL = source["baseURL"]; this.proxyMode = source["proxyMode"]; this.proxyURL = source["proxyURL"]; + this.apiMode = source["apiMode"]; + this.responsesTransport = source["responsesTransport"]; } } export class ProbeUpstreamResult { modelCount: number; models?: UpstreamModelDescriptor[]; + responsesTransport?: string; + responsesTransportOK?: boolean; + responsesTransportError?: string; static createFrom(source: any = {}) { return new ProbeUpstreamResult(source); @@ -234,6 +243,9 @@ export namespace backend { if ('string' === typeof source) source = JSON.parse(source); this.modelCount = source["modelCount"]; this.models = this.convertValues(source["models"], UpstreamModelDescriptor); + this.responsesTransport = source["responsesTransport"]; + this.responsesTransportOK = source["responsesTransportOK"]; + this.responsesTransportError = source["responsesTransportError"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/scripts/local-websocket-smoke-check.mjs b/scripts/local-websocket-smoke-check.mjs new file mode 100644 index 000000000..53daf2d0e --- /dev/null +++ b/scripts/local-websocket-smoke-check.mjs @@ -0,0 +1,125 @@ +import path from "node:path"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; + +function run(cmd, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd ?? process.cwd(), + env: { ...process.env, ...(options.env ?? {}) }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code ?? 1}\n${stderr || stdout}`)); + }); + }); +} + +const tmpDir = path.join(process.cwd(), ".tmp"); +await mkdir(tmpDir, { recursive: true }); +const sourcePath = path.join(tmpDir, "local-websocket-smoke-main.go"); + +const source = `package main +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + client "github.com/yuanhua/image-gptcodex/pkg/client" + "github.com/gorilla/websocket" +) +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(\`{"data":[{"id":"gpt-5.5"},{"id":"gpt-image-2"}]}\`)) + }) + upgrader := websocket.Upgrader{} + mux.HandleFunc("/v1/responses", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + panic(err) + } + defer conn.Close() + _, raw, err := conn.ReadMessage() + if err != nil { + panic(err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + panic(err) + } + if payload["type"] != "response.create" { + panic("expected response.create payload") + } + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.created","response":{"id":"resp_local_smoke"}}\`)) + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.output_item.done","item":{"type":"image_generation_call","result":"c21va2Utd3MtaW1hZ2U=","revised_prompt":"smoke ws revised prompt"}}\`)) + _ = conn.WriteMessage(websocket.TextMessage, []byte(\`{"type":"response.completed"}\`)) + }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { panic(err) } + server := &http.Server{Handler: mux} + defer server.Shutdown(context.Background()) + go server.Serve(listener) + origin := "http://" + listener.Addr().String() + outDir, err := os.MkdirTemp("", "image-studio-ws-smoke-*") + if err != nil { panic(err) } + defer os.RemoveAll(outDir) + result, rawPath, err := client.RequestAndExtractWithRetriesAndPartial( + context.Background(), + &client.NativeTransport{}, + client.Options{ + APIKey: "smoke-key", + Prompt: "cat", + BaseURL: origin, + APIMode: client.APIModeResponses, + ResponsesTransport: client.ResponsesTransportWebSocket, + }, + outDir, + "smoke", + nil, + nil, + nil, + ) + if err != nil { panic(err) } + abs, _ := filepath.Abs(rawPath) + fmt.Println(result.ImageB64) + fmt.Println(result.RevisedPrompt) + fmt.Println(strings.TrimSpace(abs)) +} +`; + +await writeFile(sourcePath, source, "utf8"); + +try { + const { stdout } = await run("go", ["run", sourcePath], { + cwd: process.cwd(), + env: { + GOPATH: path.join(process.cwd(), ".gopath"), + GOMODCACHE: path.join(process.cwd(), ".gomodcache"), + GOCACHE: path.join(process.cwd(), ".gocache"), + }, + }); + const lines = stdout.trim().split(/\r?\n/); + const imageB64 = lines[0] ?? ""; + const revisedPrompt = lines[1] ?? ""; + const rawPath = lines.slice(2).join("\n"); + console.log(JSON.stringify({ + status: "passed", + imageB64, + revisedPrompt, + rawPath, + }, null, 2)); +} finally { + await rm(sourcePath, { force: true }); +} diff --git a/shared/compat-go/state.go b/shared/compat-go/state.go index 3ff83c328..a2377d16b 100644 --- a/shared/compat-go/state.go +++ b/shared/compat-go/state.go @@ -61,6 +61,7 @@ type UpstreamProfile struct { ID string `json:"id"` Name string `json:"name"` APIMode string `json:"apiMode"` + ResponsesTransport string `json:"responsesTransport,omitempty"` RequestPolicy string `json:"requestPolicy"` ImagesNewAPICompat bool `json:"imagesNewAPICompat,omitempty"` BaseURL string `json:"baseURL"` @@ -216,6 +217,9 @@ func Normalize(state State) State { if state.Profiles[i].ReasoningEffort == "" { state.Profiles[i].ReasoningEffort = "xhigh" } + if state.Profiles[i].ResponsesTransport == "" { + state.Profiles[i].ResponsesTransport = "sse" + } } if state.History == nil { state.History = []HistoryItem{} From 57d7c37db5293a5ca8ef8fb47c213794a067074c Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Tue, 9 Jun 2026 15:21:14 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E4=BC=98=E5=8C=96ws=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/troubleshooting.md | 42 ++++++++++++ go-cli/pkg/client/client_test.go | 45 +++++++++++++ go-cli/pkg/client/responses_websocket.go | 44 +++++++++++++ go-cli/pkg/client/url.go | 5 ++ go-cli/pkg/client/url_test.go | 2 + .../src/components/panel/FAQModal.tsx | 3 + .../runtime/remote-kernel/responses.ts | 25 +++++++- .../frontend/test/remoteKernel.test.mjs | 64 +++++++++++++++++++ shared/kernel/requestModel.js | 3 +- 9 files changed, 231 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e0eb6abf4..d48a9297d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,6 +27,8 @@ 常见原因是 relay 只“接受字段”但没有真正透传,或目标模型本身就不支持这些能力。 - `Responses API` 一直失败: 常见原因是上游根本没实现 `/v1/responses`、会缓冲 SSE,或者 Key 只有 image-only 权限。 +- `Responses WebSocket mode` 握手失败: + 如果 raw 日志或上游报错里出现 `WebSocket upgrade required (Upgrade: websocket)`,说明当前链路把 WebSocket 请求降成了普通 HTTP,请求没有真正完成 Upgrade。常见原因是中转站 / 反向代理 / 网关没有正确放行 `Upgrade: websocket`,这种情况应直接切回 `HTTP SSE` 或修上游代理配置。 - Android 看不到“目录”或保存位置和桌面端不一样: 常见原因是 Android 走 `MediaStore` / 系统相册,不会像桌面文件管理器那样直接暴露同一个物理目录。 - 浏览器预览里看到 `memory://...`: @@ -99,6 +101,46 @@ log/ 4. 从历史项查看 raw 响应,确认是 Cloudflare 524/504、上游 JSON 5xx,还是模型权限错误。 5. 如果上游本身不支持 SSE 或会缓冲 SSE,换上游或走 Images API。 +## Responses WebSocket 握手失败 / `Upgrade: websocket` + +如果你在 raw 日志里看到类似: + +```text +websocket handshake failed: HTTP 400: WebSocket upgrade required (Upgrade: websocket) +``` + +或上游直接返回: + +```json +{ + "error": { + "message": "WebSocket upgrade required (Upgrade: websocket)", + "type": "invalid_request_error" + } +} +``` + +这代表问题发生在 **握手阶段**,不是生图中途断流: + +1. 客户端已经尝试走 `Responses WebSocket mode` +2. 但链路上的某一层把请求当成了普通 HTTP +3. 上游没有收到正确的 `Upgrade: websocket` + +常见原因: + +- Nginx / 网关把 `Connection` 头清空了 +- `Upgrade` 头没有透传 +- 某些 HTTP/2 / H2C 转发链路不支持或没有正确桥接 WebSocket Upgrade +- 上游只实现了普通 `/v1/responses`,没有真正实现 WS `/v1/responses` + +处理建议: + +1. 先把当前 profile 的传输切回 `HTTP SSE` +2. 如果同一个 `BASE_URL + Key` 在 Codex 自己的 WS 能正常,而本应用不行,优先检查: + - 你在本应用里填的是不是站点根地址,而不是已经带 `/v1` 的地址 + - 真实链路上是否存在额外的代理层 +3. 如果上游明说支持 WS,但仍返回这个错误,优先让服务商检查 `Upgrade: websocket` 是否真的到达了后端 + ## `model not found` / 401 / 403 Responses API: diff --git a/go-cli/pkg/client/client_test.go b/go-cli/pkg/client/client_test.go index 70c2da020..209998421 100644 --- a/go-cli/pkg/client/client_test.go +++ b/go-cli/pkg/client/client_test.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -476,3 +477,47 @@ func TestRequestResponsesOverWebSocketSucceedsWhenFinalArrivesBeforeDisconnect(t t.Fatalf("result image mismatch") } } + +func TestRequestResponsesWithWebSocketReplayFallsBackToSSEOnHandshakeFailure(t *testing.T) { + pngB64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfallback")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/responses" && strings.EqualFold(r.Header.Get("Upgrade"), "websocket"): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUpgradeRequired) + _, _ = w.Write([]byte(`{"error":{"message":"WebSocket upgrade required (Upgrade: websocket)"}}`)) + case r.URL.Path == "/v1/responses": + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(w, `data: {"type":"response.created"}`) + fmt.Fprintln(w, `data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"`+pngB64+`"}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var raw bytes.Buffer + result, err := requestResponsesWithWebSocketReplay( + context.Background(), + Options{ + APIKey: "sk-test", + Prompt: "hello", + BaseURL: srv.URL, + ResponsesTransport: ResponsesTransportWebSocket, + }, + &raw, + nil, + nil, + 1, + nil, + ) + if err != nil { + t.Fatalf("requestResponsesWithWebSocketReplay: %v", err) + } + if result.ImageB64 != pngB64 { + t.Fatalf("result image mismatch") + } + if !strings.Contains(raw.String(), "websocket-error-1") { + t.Fatalf("expected raw log to record websocket handshake failure, got %q", raw.String()) + } +} diff --git a/go-cli/pkg/client/responses_websocket.go b/go-cli/pkg/client/responses_websocket.go index 052f128f0..3fb2dd165 100644 --- a/go-cli/pkg/client/responses_websocket.go +++ b/go-cli/pkg/client/responses_websocket.go @@ -17,6 +17,24 @@ import ( const websocketQuickReconnectAttempts = 1 +type responsesWebSocketFallbackError struct { + err error +} + +func (e *responsesWebSocketFallbackError) Error() string { + if e == nil || e.err == nil { + return "responses websocket fallback" + } + return e.err.Error() +} + +func (e *responsesWebSocketFallbackError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + type ResponsesWSRunStateSnapshot struct { AttemptIndex int SocketEpoch int @@ -100,6 +118,17 @@ func requestResponsesWithWebSocketReplay( onLog("使用 Responses WebSocket mode 发起请求...") } result, err := requestResponsesOverWebSocket(ctx, baseURL, opts.APIKey, opts.Proxy, payload, rawSink, onPartial, snapshot) + var fallbackErr *responsesWebSocketFallbackError + if errors.As(err, &fallbackErr) { + if onLog != nil { + onLog("Responses WebSocket 握手失败,当前上游不兼容该 WS 路径,自动切回 HTTP SSE...") + } + transport, terr := PickTransportWithProxy(opts.Proxy) + if terr != nil { + return ImageResult{}, terr + } + return RequestAndExtractWithPartial(ctx, transport, opts, rawSink, onProgress, onPartial) + } if err != nil && !snapshot.HasFinalImage && onLog != nil { onLog("WebSocket 连接中断,正在重新连接并重放本次生成...") } @@ -173,6 +202,9 @@ func requestResponsesOverWebSocket( if rawSink != nil { _, _ = io.WriteString(rawSink, fmt.Sprintf("--- websocket-error-%d: %v ---\n", snapshot.SocketEpoch, err)) } + if isResponsesWebSocketFallbackError(err) { + return ImageResult{}, &responsesWebSocketFallbackError{err: err} + } lastErr = err if snapshot.HasFinalImage { return result, nil @@ -455,8 +487,20 @@ func summarizeWebSocketHandshakeBody(body []byte) string { text = msg } } + lower := strings.ToLower(text) + if strings.Contains(lower, "websocket upgrade required") || strings.Contains(lower, "upgrade: websocket") { + return "上游要求 WebSocket Upgrade,但当前链路没有正确转发 Upgrade: websocket。通常是中转站 / 反向代理 / 网关不支持或没放行 Responses WebSocket,建议切回 HTTP SSE。" + } if len(text) > 160 { return text[:160] } return text } + +func isResponsesWebSocketFallbackError(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(err.Error()) + return strings.Contains(text, "websocket handshake failed") +} diff --git a/go-cli/pkg/client/url.go b/go-cli/pkg/client/url.go index 2ac94b9df..b379818aa 100644 --- a/go-cli/pkg/client/url.go +++ b/go-cli/pkg/client/url.go @@ -15,6 +15,11 @@ func ValidateBaseURL(raw string) (string, error) { if cleaned == "" { return "", fmt.Errorf("未配置上游 BASE_URL") } + cleaned = strings.TrimSuffix(cleaned, "/v1") + cleaned = strings.TrimRight(cleaned, "/") + if cleaned == "" { + return "", fmt.Errorf("未配置上游 BASE_URL") + } u, err := url.Parse(cleaned) if err != nil { return "", fmt.Errorf("BASE_URL 无效: %w", err) diff --git a/go-cli/pkg/client/url_test.go b/go-cli/pkg/client/url_test.go index d6faf7e50..70a5368ee 100644 --- a/go-cli/pkg/client/url_test.go +++ b/go-cli/pkg/client/url_test.go @@ -12,6 +12,8 @@ func TestValidateBaseURL(t *testing.T) { wantErr bool }{ {name: "https ok", raw: " https://relay.example.com/ ", want: "https://relay.example.com"}, + {name: "https trailing v1 trimmed", raw: "https://relay.example.com/v1", want: "https://relay.example.com"}, + {name: "https nested path trailing v1 trimmed", raw: "https://relay.example.com/proxy/v1/", want: "https://relay.example.com/proxy"}, {name: "http localhost ok", raw: "http://localhost:8787/api", want: "http://localhost:8787/api"}, {name: "http loopback ip ok", raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080"}, {name: "http remote rejected", raw: "http://relay.example.com", wantErr: true}, diff --git a/image-studio/frontend/src/components/panel/FAQModal.tsx b/image-studio/frontend/src/components/panel/FAQModal.tsx index 53c6f5e77..289cf8604 100644 --- a/image-studio/frontend/src/components/panel/FAQModal.tsx +++ b/image-studio/frontend/src/components/panel/FAQModal.tsx @@ -54,6 +54,9 @@ export function FAQModal({ open, onClose }: { open: boolean; onClose: () => void

如果在 Responses API 配置里把传输切到 WebSocket mode,应用会在连接中断时快速重连一次;若仍然失败,通常说明上游或代理不兼容,建议切回 HTTP SSE 再试。

+

+ 如果错误里直接出现 WebSocket upgrade required (Upgrade: websocket),说明失败发生在握手阶段:当前链路没有把 WebSocket Upgrade 正确转发到上游,这种情况不是“生成到一半断了”,而是上游 / 代理当前不支持这个 WS 路径。 +

diff --git a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts index 3e33ea904..52d74a2c9 100644 --- a/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts +++ b/image-studio/frontend/src/platform/runtime/remote-kernel/responses.ts @@ -218,6 +218,14 @@ function buildWebSocketCreatePayload(body: string): string { return JSON.stringify(parsed); } +function isWebSocketHandshakeFailure(error: unknown): boolean { + const message = String((error as any)?.message || error || "").toLowerCase(); + return message.includes("bad handshake") + || message.includes("upgrade: websocket") + || message.includes("websocket upgrade required") + || message.includes("websocket handshake failed"); +} + export async function requestResponsesOnce( request: RemoteJobRequest, attempt: number, @@ -297,7 +305,22 @@ export async function requestResponsesOnce( try { response = await requestOnce(1); } catch (error) { - if (responsesTransport === "websocket" && !runState.hasFinalImage) { + if (responsesTransport === "websocket" && isWebSocketHandshakeFailure(error)) { + callbacks.onLog?.("Responses WebSocket 握手失败,当前上游不兼容该 WS 路径,自动切回 HTTP SSE..."); + response = await nativeHttpRequestText( + url, + "POST", + { + Authorization: `Bearer ${request.payload.apiKey}`, + "Content-Type": "application/json", + Accept: "text/event-stream, application/json", + }, + body, + callbacks.signal, + consumeNativePayload, + { proxyMode, proxyURL: request.payload.proxyURL || "" }, + ); + } else if (responsesTransport === "websocket" && !runState.hasFinalImage) { callbacks.onLog?.("WebSocket 连接中断,正在重新连接并重放本次生成..."); try { response = await requestOnce(2); diff --git a/image-studio/frontend/test/remoteKernel.test.mjs b/image-studio/frontend/test/remoteKernel.test.mjs index 44c7ab521..a0b418bf7 100644 --- a/image-studio/frontend/test/remoteKernel.test.mjs +++ b/image-studio/frontend/test/remoteKernel.test.mjs @@ -1007,3 +1007,67 @@ test("Android shell remote kernel wraps Responses websocket requests as response assert.equal(payload.stream, undefined); }); }); + +test("Android shell remote kernel falls back to native HTTP when Responses websocket handshake fails", async () => { + const calls = []; + await withPatchedGlobals(async () => { + globalThis.window.AndroidImageStudio = { + invoke(requestId, method, payloadJson) { + const args = JSON.parse(payloadJson); + calls.push(method); + queueMicrotask(() => { + if (method === "ResponsesWebSocketRequest") { + window.__imageStudioNativeReject?.(requestId, "websocket handshake failed: HTTP 426: WebSocket upgrade required (Upgrade: websocket)"); + return; + } + if (method === "HttpRequestText") { + const payload = args[0]; + assert.equal(payload.url, "https://upstream.example/v1/responses"); + window.__imageStudioNativeResolve?.(requestId, { + status: 200, + body: "", + contentType: "text/event-stream", + resultImageB64: "ZmFsbGJhY2s=", + revisedPrompt: "fallback", + sourceEvent: "final", + }); + return; + } + window.__imageStudioNativeReject?.(requestId, `unsupported ${method}`); + }); + }, + }; + globalThis.fetch = async () => { + throw new Error("fallback path should stay on Android native transport"); + }; + }, async () => { + const kernel = await loadRemoteKernel(); + const result = await kernel.runRemoteImageJob( + { + payload: { + apiKey: "key", + mode: "generate", + prompt: "cat", + size: "1024x1024", + quality: "low", + outputFormat: "png", + imagePaths: [], + imagePath: "", + maskB64: "", + seed: 0, + negativePrompt: "", + baseURL: "https://upstream.example", + textModelID: "gpt-5.5", + imageModelID: "gpt-image-2", + apiMode: "responses", + responsesTransport: "websocket", + requestPolicy: "openai", + noPromptRevision: false, + }, + }, + { signal: new AbortController().signal }, + ); + assert.equal(result.imageB64, "ZmFsbGJhY2s="); + assert.deepEqual(calls.slice(0, 2), ["ResponsesWebSocketRequest", "HttpRequestText"]); + }); +}); diff --git a/shared/kernel/requestModel.js b/shared/kernel/requestModel.js index b18d1db8b..5b9816d6b 100644 --- a/shared/kernel/requestModel.js +++ b/shared/kernel/requestModel.js @@ -18,7 +18,8 @@ export const STATUS_INTERVAL_MS = 10_000; const NO_PROMPT_REVISION_INSTRUCTIONS = "You are a tool runner. Pass the user prompt to image_generation VERBATIM. DO NOT rewrite, expand, polish, or revise it in any way. Use the exact text the user gave."; export function normalizeBaseURL(raw) { - return String(raw || "").trim().replace(/\/+$/, ""); + const trimmed = String(raw || "").trim().replace(/\/+$/, ""); + return trimmed.replace(/\/v1$/i, ""); } export function normalizeAPIMode(apiMode) { From f8013825aebe858cbaf699449067a016a279ae98 Mon Sep 17 00:00:00 2001 From: lin <1609924163@qq.com> Date: Tue, 9 Jun 2026 16:20:15 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=8B=96=E6=94=BE?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E7=9A=84=E9=9D=9E=E5=8E=9F=E5=9B=BEbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/canvas/BatchResultGrid.tsx | 42 ++++--- .../src/components/canvas/CanvasStage.tsx | 1 + .../src/components/common/SavePromptModal.tsx | 37 +++++- .../history/HistoryPromptGroupModal.tsx | 2 +- .../history/HistoryPromptThumbnailStack.tsx | 2 +- .../src/components/history/HistoryTile.tsx | 1 + .../components/panel/ResultDetailDrawer.tsx | 36 +++++- .../android/canvas/AndroidCanvasStage.tsx | 1 + image-studio/frontend/src/styles/_canvas.css | 115 ++++++++++++++++++ .../frontend/test/dragExport.test.mjs | 25 ++++ 10 files changed, 240 insertions(+), 22 deletions(-) diff --git a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx index 104c86c0d..512d5d4e0 100644 --- a/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx +++ b/image-studio/frontend/src/components/canvas/BatchResultGrid.tsx @@ -18,6 +18,7 @@ export function BatchResultGrid({ selectedIds, onToggleSelect, selectionMode = false, + livePreview = false, }: { items: HistoryItem[]; slots?: BatchGridSlot[]; @@ -29,12 +30,14 @@ export function BatchResultGrid({ selectedIds?: Set; onToggleSelect?: (item: HistoryItem) => void; selectionMode?: boolean; + livePreview?: boolean; }) { const gridSlots = slots ?? items.map((item) => ({ type: "result", item }) satisfies BatchGridSlot); - const columns = gridSlots.length <= 2 ? 2 : gridSlots.length <= 4 ? 2 : 3; + const singleLivePreview = livePreview && gridSlots.length === 1; + const columns = singleLivePreview ? 1 : gridSlots.length <= 4 ? 2 : 3; return ( -
-
+
+
{title ?? `本批结果 · ${items.length} 张`} {showClose ? (
{gridSlots.map((slot, index) => { if (slot.type === "pending") { - return ; + return ; } return ( ); })} @@ -78,6 +82,7 @@ function BatchGridTile({ selected, onToggleSelect, selectionMode, + singleLayout, }: { item: HistoryItem; index: number; @@ -87,6 +92,7 @@ function BatchGridTile({ selected: boolean; onToggleSelect?: (item: HistoryItem) => void; selectionMode: boolean; + singleLayout: boolean; }) { const previewURL = useBlobURL(item.imageBlob ?? item.previewBlob ?? null, item.imageB64 ?? null); const src = historyPreviewSrc(item, previewURL); @@ -97,7 +103,7 @@ function BatchGridTile({ >