Skip to content

Commit 0a67d37

Browse files
committed
release: prepare v0.2.22
2 parents bc496dc + 4e56f38 commit 0a67d37

33 files changed

Lines changed: 1823 additions & 630 deletions

README.md

Lines changed: 107 additions & 182 deletions
Large diffs are not rendered by default.

deploy/Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ WORKDIR /app
55

66
ENV VITE_DEFAULT_API_URL=__VITE_DEFAULT_API_URL_PLACEHOLDER__
77
ENV VITE_API_PROXY_AVAILABLE=__VITE_API_PROXY_AVAILABLE_PLACEHOLDER__
8+
ENV VITE_DOCKER_DEPLOYMENT=__VITE_DOCKER_DEPLOYMENT_PLACEHOLDER__
9+
ENV VITE_DOCKER_LEGACY_API_URL_USED=__VITE_DOCKER_LEGACY_API_URL_USED_PLACEHOLDER__
810

911
COPY package.json package-lock.json ./
1012
RUN npm ci
@@ -17,11 +19,13 @@ FROM nginx:alpine
1719

1820
ENV HOST=0.0.0.0
1921
ENV PORT=80
20-
ENV API_URL=https://api.openai.com
22+
ENV DEFAULT_API_URL=
23+
ENV API_PROXY_URL=
2124
ENV ENABLE_API_PROXY=false
2225

2326
COPY --from=build /app/dist /usr/share/nginx/html
2427
COPY deploy/nginx.conf /etc/nginx/templates/default.conf.template
28+
COPY --chmod=755 deploy/migrate-api-env.envsh /docker-entrypoint.d/05-migrate-api-env.envsh
2529
COPY --chmod=755 deploy/inject-api-url.sh /docker-entrypoint.d/40-inject-api-url.sh
2630

2731
EXPOSE 80

deploy/inject-api-url.sh

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
#!/bin/sh
22

3-
# 用环境变量替换默认 API URL
4-
API_URL=${API_URL:-https://api.openai.com}
3+
# 用环境变量替换前端默认 API URL
4+
DEFAULT_API_URL=${DEFAULT_API_URL:-${API_URL:-https://api.openai.com/v1}}
5+
DOCKER_LEGACY_API_URL_USED=${DOCKER_LEGACY_API_URL_USED:-false}
6+
if [ -n "$API_URL" ]; then
7+
DOCKER_LEGACY_API_URL_USED=true
8+
fi
9+
510
API_PROXY_AVAILABLE=false
611
if [ "$ENABLE_API_PROXY" = "true" ]; then
712
API_PROXY_AVAILABLE=true
813
fi
914

10-
# 查找所有 js 文件并将占位符替换为实际的 API_URL
11-
find /usr/share/nginx/html/assets -type f -name "*.js" -exec sed -i "s|__VITE_DEFAULT_API_URL_PLACEHOLDER__|$API_URL|g" {} +
15+
# 查找所有 js 文件并将占位符替换为运行时配置
16+
find /usr/share/nginx/html/assets -type f -name "*.js" -exec sed -i "s|__VITE_DEFAULT_API_URL_PLACEHOLDER__|$DEFAULT_API_URL|g" {} +
1217
find /usr/share/nginx/html/assets -type f -name "*.js" -exec sed -i "s|__VITE_API_PROXY_AVAILABLE_PLACEHOLDER__|$API_PROXY_AVAILABLE|g" {} +
18+
find /usr/share/nginx/html/assets -type f -name "*.js" -exec sed -i "s|__VITE_DOCKER_DEPLOYMENT_PLACEHOLDER__|true|g" {} +
19+
find /usr/share/nginx/html/assets -type f -name "*.js" -exec sed -i "s|__VITE_DOCKER_LEGACY_API_URL_USED_PLACEHOLDER__|$DOCKER_LEGACY_API_URL_USED|g" {} +
1320

1421
# 检查是否启用了 API 代理
1522
if [ "$ENABLE_API_PROXY" != "true" ]; then

deploy/migrate-api-env.envsh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
3+
# 兼容旧版本 Docker 环境变量:API_URL 同时作为两个新变量的兜底值。
4+
DOCKER_LEGACY_API_URL_USED=false
5+
if [ -n "$API_URL" ]; then
6+
DOCKER_LEGACY_API_URL_USED=true
7+
fi
8+
9+
DEFAULT_API_URL=${DEFAULT_API_URL:-${API_URL:-https://api.openai.com/v1}}
10+
API_PROXY_URL=${API_PROXY_URL:-${API_URL:-https://api.openai.com/v1}}
11+
12+
export DEFAULT_API_URL
13+
export API_PROXY_URL
14+
export DOCKER_LEGACY_API_URL_USED

deploy/nginx.conf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ server {
1818
}
1919

2020
# BEGIN API PROXY
21-
# API 代理。前端请求同源 /api-proxy/...,由 Nginx 转发到部署时配置的 API_URL
21+
# API 代理。前端请求同源 /api-proxy/...,由 Nginx 转发到部署时配置的 API_PROXY_URL
2222
location /api-proxy/ {
2323
limit_except POST OPTIONS {
2424
deny all;
@@ -32,7 +32,7 @@ server {
3232
return 403 "Forbidden: API Proxy path restricted";
3333
}
3434

35-
proxy_pass ${API_URL}/;
35+
proxy_pass ${API_PROXY_URL}/;
3636
proxy_http_version 1.1;
3737
proxy_set_header Host $proxy_host;
3838
proxy_set_header X-Real-IP $remote_addr;

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "gpt-image-playground",
33
"private": true,
4-
"version": "0.2.18",
4+
"version": "0.2.22",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src/App.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { useEffect } from 'react'
22
import { initStore } from './store'
33
import { useStore } from './store'
44
import { normalizeBaseUrl } from './lib/api'
5-
import type { ApiMode, ApiProvider } from './types'
5+
import { normalizeSettings, switchApiProfileProvider } from './lib/apiProfiles'
6+
import { useDockerApiUrlMigrationNotice } from './hooks/useDockerApiUrlMigrationNotice'
7+
import type { ApiMode, ApiProvider, AppSettings } from './types'
68
import Header from './components/Header'
79
import SearchBar from './components/SearchBar'
810
import TaskGrid from './components/TaskGrid'
@@ -17,10 +19,11 @@ import ImageContextMenu from './components/ImageContextMenu'
1719

1820
export default function App() {
1921
const setSettings = useStore((s) => s.setSettings)
22+
useDockerApiUrlMigrationNotice()
2023

2124
useEffect(() => {
2225
const searchParams = new URLSearchParams(window.location.search)
23-
const nextSettings: { baseUrl?: string; apiKey?: string; codexCli?: boolean; apiMode?: ApiMode; profiles?: any[]; activeProfileId?: string } = {}
26+
const nextSettings: Partial<AppSettings> = {}
2427

2528
const apiUrlParam = searchParams.get('apiUrl')
2629
if (apiUrlParam !== null) {
@@ -46,15 +49,24 @@ export default function App() {
4649
if (providerParam) {
4750
const provider: ApiProvider | null = providerParam === 'fal'
4851
? 'fal'
49-
: ['oai', 'openai', 'openai-compatible', 'new-api', 'oai-like'].includes(providerParam)
50-
? 'oai-like'
52+
: ['openai', 'openai-compatible'].includes(providerParam)
53+
? 'openai'
5154
: null
5255
if (provider) {
5356
const state = useStore.getState()
54-
const current = state.settings.profiles.find((profile) => profile.id === state.settings.activeProfileId) ?? state.settings.profiles[0]
57+
const settings = normalizeSettings(state.settings)
58+
const current = settings.profiles.find((profile) => profile.id === settings.activeProfileId) ?? settings.profiles[0]
5559
if (current) {
56-
nextSettings.profiles = state.settings.profiles.map((profile) =>
57-
profile.id === current.id ? { ...profile, provider } : profile,
60+
nextSettings.profiles = settings.profiles.map((profile) =>
61+
profile.id === current.id
62+
? {
63+
...switchApiProfileProvider(profile, provider),
64+
...(nextSettings.baseUrl !== undefined ? { baseUrl: nextSettings.baseUrl } : {}),
65+
...(nextSettings.apiKey !== undefined ? { apiKey: nextSettings.apiKey } : {}),
66+
...(provider === 'openai' && nextSettings.apiMode !== undefined ? { apiMode: nextSettings.apiMode } : {}),
67+
...(provider === 'openai' && nextSettings.codexCli !== undefined ? { codexCli: nextSettings.codexCli } : {}),
68+
}
69+
: profile,
5870
)
5971
nextSettings.activeProfileId = current.id
6072
}

src/components/ConfirmDialog.tsx

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,40 @@
1+
import { useEffect, useState } from 'react'
12
import { useStore } from '../store'
23
import { useCloseOnEscape } from '../hooks/useCloseOnEscape'
34

5+
function renderMessage(message: string) {
6+
return message.split(/(`[^`]+`)/g).map((part, index) => {
7+
if (part.startsWith('`') && part.endsWith('`')) {
8+
return (
9+
<code key={index} className="rounded bg-gray-100 px-1 py-0.5 text-[0.85em] text-gray-700 dark:bg-white/[0.06] dark:text-gray-200">
10+
{part.slice(1, -1)}
11+
</code>
12+
)
13+
}
14+
15+
return part
16+
})
17+
}
18+
419
export default function ConfirmDialog() {
520
const confirmDialog = useStore((s) => s.confirmDialog)
621
const setConfirmDialog = useStore((s) => s.setConfirmDialog)
22+
const [canConfirm, setCanConfirm] = useState(true)
23+
24+
useEffect(() => {
25+
const delay = confirmDialog?.minConfirmDelayMs ?? 0
26+
if (!confirmDialog || delay <= 0) {
27+
setCanConfirm(true)
28+
return
29+
}
30+
31+
setCanConfirm(false)
32+
const timer = window.setTimeout(() => setCanConfirm(true), delay)
33+
return () => window.clearTimeout(timer)
34+
}, [confirmDialog])
735

836
const handleClose = () => {
37+
if (!canConfirm) return
938
setConfirmDialog(null)
1039
}
1140

@@ -14,7 +43,7 @@ export default function ConfirmDialog() {
1443
handleClose()
1544
}
1645

17-
useCloseOnEscape(Boolean(confirmDialog), handleClose)
46+
useCloseOnEscape(Boolean(confirmDialog) && canConfirm, handleClose)
1847

1948
if (!confirmDialog) return null
2049
const isDestructive = confirmDialog.title.includes('删除') || confirmDialog.title.includes('清空')
@@ -38,25 +67,36 @@ export default function ConfirmDialog() {
3867
className="relative bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl border border-white/50 dark:border-white/[0.08] rounded-3xl shadow-[0_8px_40px_rgb(0,0,0,0.12)] dark:shadow-[0_8px_40px_rgb(0,0,0,0.4)] max-w-sm w-full p-6 z-10 ring-1 ring-black/5 dark:ring-white/10 animate-confirm-in"
3968
onClick={(e) => e.stopPropagation()}
4069
>
41-
<h3 className="text-base font-bold text-gray-800 dark:text-gray-100 mb-2">
70+
<h3 className="mb-2 flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
71+
{confirmDialog.icon === 'info' && (
72+
<svg className="h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
73+
<circle cx="12" cy="12" r="10" />
74+
<path d="M12 16v-4" />
75+
<path d="M12 8h.01" />
76+
</svg>
77+
)}
4278
{confirmDialog.title}
4379
</h3>
4480
<p className={`text-sm text-gray-500 dark:text-gray-400 mb-6 leading-relaxed whitespace-pre-line ${confirmDialog.messageAlign === 'center' ? 'text-center' : ''}`}>
45-
{confirmDialog.message}
81+
{renderMessage(confirmDialog.message)}
4682
</p>
4783
<div className="flex gap-2">
48-
<button
49-
onClick={handleCancel}
50-
className="flex-1 py-2 rounded-lg border border-gray-200 dark:border-white/[0.08] text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/[0.06] transition"
51-
>
52-
取消
53-
</button>
84+
{confirmDialog.showCancel !== false && (
85+
<button
86+
onClick={handleCancel}
87+
className="flex-1 py-2 rounded-lg border border-gray-200 dark:border-white/[0.08] text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/[0.06] transition"
88+
>
89+
取消
90+
</button>
91+
)}
5492
<button
5593
onClick={() => {
94+
if (!canConfirm) return
5695
confirmDialog.action()
5796
setConfirmDialog(null)
5897
}}
59-
className={`flex-1 py-2 rounded-lg text-white text-sm font-medium transition ${confirmClassName}`}
98+
disabled={!canConfirm}
99+
className={`flex-1 py-2 rounded-lg text-white text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60 ${confirmClassName}`}
60100
>
61101
{confirmText}
62102
</button>

src/components/DetailModal.tsx

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ export default function DetailModal() {
4040
}, [detailTaskId])
4141

4242
useEffect(() => {
43-
if (task?.status !== 'running') return
43+
if (task?.status !== 'running' && !(task?.status === 'error' && task.falRecoverable)) return
4444
const id = window.setInterval(() => setNow(Date.now()), 1000)
45+
setNow(Date.now())
4546
return () => window.clearInterval(id)
46-
}, [task?.status])
47+
}, [task?.falRecoverable, task?.status])
4748

4849
// 加载所有相关图片
4950
useEffect(() => {
@@ -164,17 +165,19 @@ export default function DetailModal() {
164165
const showPromptWarning = Boolean(currentOutputImageId && (!currentRevisedPrompt || showRevisedPrompt) && !hasHandledPromptWarning)
165166
const aggregateActualParams = outputLen > 0 ? { ...task.actualParams, n: outputLen } : task.actualParams
166167
const taskProvider = task.apiProvider
167-
const taskProviderName = taskProvider === 'fal' ? 'fal.ai' : taskProvider === 'oai-like' ? 'OAI-like' : '未知'
168-
const taskProfileName = task.apiProfileName || '旧记录未保存'
168+
const taskProviderName = taskProvider === 'fal' ? 'fal.ai' : taskProvider ? 'OpenAI' : '未知'
169+
const taskProfileName = task.apiProfileName || '未知'
169170
const taskModel = task.apiModel || '未知'
171+
const showSourceInfo = Boolean(task.apiProvider || task.apiProfileName || task.apiModel)
172+
const isFalReconnecting = task.status === 'error' && task.falRecoverable
170173

171174
const formatTime = (ts: number | null) => {
172175
if (!ts) return ''
173176
return new Date(ts).toLocaleString('zh-CN')
174177
}
175178

176179
const formatDuration = () => {
177-
if (task.status === 'running') {
180+
if (task.status === 'running' || isFalReconnecting) {
178181
const seconds = Math.max(0, Math.floor((now - task.createdAt) / 1000))
179182
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
180183
const ss = String(seconds % 60).padStart(2, '0')
@@ -294,7 +297,7 @@ export default function DetailModal() {
294297
<img
295298
ref={mainImageRef}
296299
src={currentOutputImageSrc}
297-
className="max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] object-contain cursor-pointer"
300+
className="saveable-image max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] object-contain cursor-pointer"
298301
onLoad={() => {
299302
const panel = imagePanelRef.current
300303
const image = mainImageRef.current
@@ -361,21 +364,31 @@ export default function DetailModal() {
361364
)}
362365
</>
363366
)}
364-
{task.status === 'running' && (
367+
{(task.status === 'running' || isFalReconnecting) && (
365368
<>
366369
<div className="absolute left-4 top-4 flex items-center gap-1 bg-black/50 text-white text-xs px-2 py-0.5 rounded backdrop-blur-sm font-mono">
367370
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
368371
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
369372
</svg>
370373
{formatDuration()}
371374
</div>
372-
<svg className="w-10 h-10 text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
373-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
374-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
375-
</svg>
375+
{task.status === 'running' && (
376+
<svg className="w-10 h-10 text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
377+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
378+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
379+
</svg>
380+
)}
376381
</>
377382
)}
378-
{task.status === 'error' && (
383+
{task.status === 'error' && isFalReconnecting && (
384+
<div className="w-full max-w-md px-4 text-center">
385+
<svg className="w-10 h-10 text-yellow-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
386+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
387+
</svg>
388+
<p className="text-sm font-medium text-yellow-500">重连中</p>
389+
</div>
390+
)}
391+
{task.status === 'error' && !isFalReconnecting && (
379392
<div className="w-full max-w-md px-4 text-center">
380393
<svg className="w-10 h-10 text-red-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
381394
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -527,12 +540,14 @@ export default function DetailModal() {
527540
<h3 className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
528541
参数配置
529542
</h3>
530-
<div className="mb-2 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-white/[0.03]">
531-
<span className="text-gray-400 dark:text-gray-500">Provider</span>
532-
<br />
533-
<span className="font-medium text-gray-700 dark:text-gray-200">{taskProviderName}</span>
534-
<span className="text-gray-400 dark:text-gray-500"> · {taskProfileName} · {taskModel}</span>
535-
</div>
543+
{showSourceInfo && (
544+
<div className="mb-2 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-white/[0.03]">
545+
<span className="text-gray-400 dark:text-gray-500">来源</span>
546+
<br />
547+
<span className="font-medium text-gray-700 dark:text-gray-200">{taskProviderName}</span>
548+
<span className="text-gray-400 dark:text-gray-500"> · {taskProfileName} · {taskModel}</span>
549+
</div>
550+
)}
536551
<div className="grid grid-cols-2 gap-2 text-xs mb-4">
537552
<div className="bg-gray-50 dark:bg-white/[0.03] rounded-lg px-3 py-2">
538553
<span className="text-gray-400 dark:text-gray-500">尺寸</span>

0 commit comments

Comments
 (0)