From 3e45ff94980e661143bff696cfd536f0c7cfa059 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 12 May 2026 20:06:48 +0000 Subject: [PATCH 1/4] Add streaming image support and source deploy compose --- deploy/nginx.conf | 2 + docker-compose.yml | 9 + scripts/mock-image-api.mjs | 99 +++++++++++ src/components/DetailModal.tsx | 15 +- src/components/SettingsModal.tsx | 26 ++- src/components/TaskCard.tsx | 15 +- src/lib/api.test.ts | 101 ++++++++++++ src/lib/apiProfiles.test.ts | 15 ++ src/lib/apiProfiles.ts | 15 +- src/lib/imageApiShared.ts | 1 + src/lib/openaiCompatibleImageApi.ts | 246 +++++++++++++++++++++++++++- src/lib/urlSettings.ts | 6 +- src/store.ts | 33 +++- src/types.ts | 4 +- 14 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 docker-compose.yml diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 11f741ae..c311a9b6 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -34,6 +34,8 @@ server { proxy_pass ${API_PROXY_URL}/; proxy_http_version 1.1; + proxy_ssl_server_name on; + proxy_ssl_name $proxy_host; proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f9e57ce1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + gpt-image-playground: + image: gpt-image-playground:latest + pull_policy: build + build: + context: . + dockerfile: deploy/Dockerfile + pull: true + restart: unless-stopped diff --git a/scripts/mock-image-api.mjs b/scripts/mock-image-api.mjs index 0aaeaf67..df5ca2b2 100644 --- a/scripts/mock-image-api.mjs +++ b/scripts/mock-image-api.mjs @@ -42,6 +42,21 @@ function sendJson(res, status, payload, options = {}) { send(res, status, appendCors({ 'Content-Type': 'application/json; charset=utf-8' }, options.cors !== false), body) } +async function sendSse(res, events, options = {}) { + res.writeHead(200, appendCors({ + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + }, options.cors !== false)) + + for (const event of events) { + res.write(`data: ${JSON.stringify(event)}\n\n`) + await new Promise((resolve) => setTimeout(resolve, 250)) + } + res.write('data: [DONE]\n\n') + res.end() +} + function readBody(req) { return new Promise((resolve) => { const chunks = [] @@ -140,10 +155,68 @@ function isOpenAIImagesPath(pathname) { return pathname.endsWith('/v1/images/generations') || pathname.endsWith('/v1/images/edits') } +function isOpenAIResponsesPath(pathname) { + return pathname.endsWith('/v1/responses') +} + function isCustomPath(pathname) { return pathname === '/custom/random-image' || pathname === '/custom/generate' } +function createImagesStreamEvents(req, mode, n, isEdit) { + const created = Math.floor(Date.now() / 1000) + const prefix = isEdit ? 'image_edit' : 'image_generation' + const partialCount = mode === 'empty' ? 0 : 2 + const partials = Array.from({ length: partialCount }, (_, i) => ({ + type: `${prefix}.partial_image`, + created_at: created, + partial_image_index: i, + b64_json: tinyPngBase64, + output_format: 'png', + quality: 'auto', + size: '1024x1024', + })) + const completed = Array.from({ length: n }, () => ({ + type: `${prefix}.completed`, + created_at: created, + b64_json: tinyPngBase64, + output_format: 'png', + quality: 'auto', + size: '1024x1024', + })) + return [...partials, ...completed] +} + +function createResponsesStreamEvents(mode) { + const partials = mode === 'empty' + ? [] + : [0, 1].map((index) => ({ + type: 'response.image_generation_call.partial_image', + output_index: 0, + item_id: 'mock-image-generation', + partial_image_index: index, + partial_image_b64: tinyPngBase64, + })) + + return [ + ...partials, + { + type: 'response.completed', + response: { + output: mode === 'empty' ? [] : [{ + type: 'image_generation_call', + status: 'completed', + revised_prompt: `mock ${mode} response image`, + result: tinyPngBase64, + output_format: 'png', + quality: 'auto', + size: '1024x1024', + }], + }, + }, + ] +} + async function handleApi(req, res, url) { const body = req.method === 'GET' ? { json: null } : await readBody(req) const mode = getMode(url, body.json) @@ -169,9 +242,30 @@ async function handleApi(req, res, url) { return } + const wantsStream = (body.json && typeof body.json === 'object' && body.json.stream === true) || + /name="stream"[\s\S]*?\r?\n\r?\ntrue/.test(body.text) + if (wantsStream) { + await sendSse(res, createImagesStreamEvents(req, mode, n, url.pathname.endsWith('/v1/images/edits'))) + return + } + sendJson(res, 200, createOpenAIResponse(req, mode, n)) } +async function handleResponses(req, res, url) { + const body = req.method === 'GET' ? { json: null } : await readBody(req) + const mode = getMode(url, body.json) + + if (body.json && typeof body.json === 'object' && body.json.stream === true) { + await sendSse(res, createResponsesStreamEvents(mode)) + return + } + + sendJson(res, 200, { + output: createResponsesStreamEvents(mode).at(-1)?.response?.output ?? [], + }) +} + async function handleCustom(req, res, url) { const body = req.method === 'GET' ? { json: null } : await readBody(req) const mode = getMode(url, body.json) @@ -257,6 +351,11 @@ const server = http.createServer(async (req, res) => { return } + if (isOpenAIResponsesPath(url.pathname)) { + await handleResponses(req, res, url) + return + } + if (isCustomPath(url.pathname)) { await handleCustom(req, res, url) return diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index c06de6e2..8a103b31 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -22,6 +22,7 @@ export default function DetailModal() { const showToast = useStore((s) => s.showToast) const settings = useStore((s) => s.settings) const dismissedCodexCliPrompts = useStore((s) => s.dismissedCodexCliPrompts) + const streamPreviewSrc = useStore((s) => detailTaskId ? s.streamPreviews[detailTaskId] || '' : '') const [imageIndex, setImageIndex] = useState(0) const [imageSrcs, setImageSrcs] = useState>({}) @@ -408,7 +409,19 @@ export default function DetailModal() { {formatDuration()} - {task.status === 'running' && ( + {task.status === 'running' && streamPreviewSrc && ( + <> + + + 流式预览 + + + )} + {task.status === 'running' && !streamPreviewSrc && ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index bff32f1e..3c4591e9 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -164,7 +164,8 @@ function isPristineNewOpenAIProfile(profile: ApiProfile) { profile.timeout === DEFAULT_SETTINGS.timeout && profile.apiMode === 'images' && profile.codexCli === false && - profile.apiProxy === defaultProfile.apiProxy + profile.apiProxy === defaultProfile.apiProxy && + profile.streamImages === defaultProfile.streamImages } function getImportedProfileFromMergedSettings( @@ -484,6 +485,7 @@ export default function SettingsModal() { timeout: Number(profile.timeout) || DEFAULT_SETTINGS.timeout, apiProxy: profile.provider === 'openai' && apiProxyAvailable ? (apiProxyLocked || profile.apiProxy) : false, codexCli: profile.provider === 'openai' ? profile.codexCli : false, + streamImages: profile.provider === 'openai' ? profile.streamImages : false, } }) const fallbackProfile = createDefaultOpenAIProfile({ id: newId('openai') }) @@ -523,6 +525,7 @@ export default function SettingsModal() { const model = profile.model.trim() || getDefaultModelForMode(profile.apiMode) url.searchParams.set('model', !options.includeApiKey && options.useNewApiModel ? '{model}' : model) if (profile.codexCli) url.searchParams.set('codexCli', 'true') + if (profile.streamImages) url.searchParams.set('streamImages', 'true') let result = url.toString() if (!options.includeApiKey) { @@ -1524,6 +1527,27 @@ export default function SettingsModal() { )} + {activeProfile.provider === 'openai' && ( +
+
+ 流式图片预览 + +
+
+ 开启后请求会发送 stream: truepartial_images: 2,任务生成中会显示部分图片预览。 +
+
+ )} +