Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deploy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 600m;
add_header Referrer-Policy "unsafe-url" always;

# gzip 压缩
gzip on;
Expand Down Expand Up @@ -34,13 +35,16 @@ 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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_buffering off;
proxy_request_buffering off;
}
# END API PROXY
Expand Down
99 changes: 99 additions & 0 deletions scripts/mock-image-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/components/DetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>({})
Expand Down Expand Up @@ -408,7 +409,19 @@ export default function DetailModal() {
</svg>
{formatDuration()}
</div>
{task.status === 'running' && (
{task.status === 'running' && streamPreviewSrc && (
<>
<img
src={streamPreviewSrc}
className="max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] object-contain"
alt=""
/>
<span className="absolute bottom-4 right-4 bg-blue-500/80 text-white text-xs px-2 py-0.5 rounded backdrop-blur-sm">
流式预览
</span>
</>
)}
{task.status === 'running' && !streamPreviewSrc && (
<svg className="w-10 h-10 text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
Expand Down
26 changes: 25 additions & 1 deletion src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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') })
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1524,6 +1527,27 @@ export default function SettingsModal() {
</div>
)}

{activeProfile.provider === 'openai' && (
<div className="block">
<div className="mb-1.5 flex items-center justify-between">
<span className="block text-sm text-gray-600 dark:text-gray-300">流式图片预览</span>
<button
type="button"
onClick={() => updateActiveProfile({ streamImages: !activeProfile.streamImages }, true)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${activeProfile.streamImages ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'}`}
role="switch"
aria-checked={!!activeProfile.streamImages}
aria-label="流式图片预览"
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${activeProfile.streamImages ? 'translate-x-[14px]' : 'translate-x-[2px]'}`} />
</button>
</div>
<div data-selectable-text className="text-xs text-gray-500 dark:text-gray-500">
开启后请求会发送 <code className="rounded bg-gray-100 px-1 py-0.5 dark:bg-white/[0.06]">stream: true</code> 和 <code className="rounded bg-gray-100 px-1 py-0.5 dark:bg-white/[0.06]">partial_images: 2</code>,任务生成中会显示部分图片预览。
</div>
</div>
)}

<label className="block">
<span className="mb-1.5 block text-sm text-gray-600 dark:text-gray-300">
模型 ID
Expand Down
15 changes: 14 additions & 1 deletion src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function TaskCard({
const [swipeActionActive, setSwipeActionActive] = useState(false)
const toggleTaskSelection = useStore((s) => s.toggleTaskSelection)
const settings = useStore((s) => s.settings)
const streamPreviewSrc = useStore((s) => s.streamPreviews[task.id] || '')
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const swipeResetTimerRef = useRef<number | null>(null)
const suppressClickUntilRef = useRef(0)
Expand Down Expand Up @@ -265,7 +266,19 @@ export default function TaskCard({
<div className="flex h-40">
{/* 左侧图片区域 */}
<div className="w-40 min-w-[10rem] h-full bg-gray-100 dark:bg-black/20 relative flex items-center justify-center overflow-hidden flex-shrink-0">
{task.status === 'running' && (
{task.status === 'running' && streamPreviewSrc && (
<>
<img
src={streamPreviewSrc}
className="h-full w-full object-cover"
alt=""
/>
<span className="absolute bottom-1 right-1 bg-blue-500/80 text-white text-xs px-1.5 py-0.5 rounded">
预览
</span>
</>
)}
{task.status === 'running' && !streamPreviewSrc && (
<div className="flex flex-col items-center gap-2">
<svg
className="w-8 h-8 text-blue-400 animate-spin"
Expand Down
101 changes: 101 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,107 @@ describe('callImageApi', () => {
}])
})

it('streams Images API partial images and resolves the final completed image', async () => {
const streamBody = [
'data: {"type":"image_generation.partial_image","partial_image_index":0,"b64_json":"cGFydGlhbA=="}',
'',
'data: {"type":"image_generation.completed","b64_json":"ZmluYWw=","size":"1024x1024","quality":"high","output_format":"png"}',
'',
'data: [DONE]',
'',
].join('\n')
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
}))
const partialImages: string[] = []

const result = await callImageApi({
settings: {
...DEFAULT_SETTINGS,
apiKey: 'test-key',
streamImages: true,
profiles: DEFAULT_SETTINGS.profiles.map((profile) => ({
...profile,
apiKey: 'test-key',
streamImages: true,
})),
},
prompt: 'prompt',
params: { ...DEFAULT_PARAMS },
inputImageDataUrls: [],
onPartialImage: (partial: { image: string }) => partialImages.push(partial.image),
} as any)

const [, init] = fetchMock.mock.calls[0]
const body = JSON.parse(String((init as RequestInit).body))
expect(body).toMatchObject({
stream: true,
partial_images: 2,
})
expect(partialImages).toEqual(['data:image/png;base64,cGFydGlhbA=='])
expect(result).toMatchObject({
images: ['data:image/png;base64,ZmluYWw='],
actualParams: {
output_format: 'png',
quality: 'high',
size: '1024x1024',
},
actualParamsList: [{
output_format: 'png',
quality: 'high',
size: '1024x1024',
}],
})
})

it('streams Responses API partial images and resolves the completed response image', async () => {
const streamBody = [
'data: {"type":"response.image_generation_call.partial_image","partial_image_index":0,"partial_image_b64":"cGFydGlhbA=="}',
'',
'data: {"type":"response.completed","response":{"output":[{"type":"image_generation_call","result":"ZmluYWw=","revised_prompt":"rewritten","size":"1024x1024"}]}}',
'',
'data: [DONE]',
'',
].join('\n')
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
}))
const partialImages: string[] = []

const result = await callImageApi({
settings: {
...DEFAULT_SETTINGS,
apiKey: 'test-key',
apiMode: 'responses',
streamImages: true,
profiles: DEFAULT_SETTINGS.profiles.map((profile) => ({
...profile,
apiKey: 'test-key',
apiMode: 'responses',
streamImages: true,
})),
},
prompt: 'prompt',
params: { ...DEFAULT_PARAMS },
inputImageDataUrls: [],
onPartialImage: (partial: { image: string }) => partialImages.push(partial.image),
} as any)

const [, init] = fetchMock.mock.calls[0]
const body = JSON.parse(String((init as RequestInit).body))
expect(body.stream).toBe(true)
expect(body.tools[0].partial_images).toBe(2)
expect(partialImages).toEqual(['data:image/png;base64,cGFydGlhbA=='])
expect(result).toMatchObject({
images: ['data:image/png;base64,ZmluYWw='],
actualParams: { size: '1024x1024' },
actualParamsList: [{ size: '1024x1024' }],
revisedPrompts: ['rewritten'],
})
})

it('uses the same-origin API proxy path when API proxy is enabled', async () => {
vi.stubEnv('VITE_API_PROXY_AVAILABLE', 'true')
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
Expand Down
Loading