Skip to content

Commit bc496dc

Browse files
feat(api): add fal provider support
- Split image API calls into OAI-like and fal-specific modules - Use the official fal client for GPT Image 2 requests with existing provider API keys - Add provider profiles in settings and record provider metadata on tasks Co-Authored-By: KohakuTerrarium <noreply@kohaku-lab.org>
1 parent 73e7527 commit bc496dc

12 files changed

Lines changed: 1197 additions & 729 deletions

File tree

package-lock.json

Lines changed: 39 additions & 0 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"test:watch": "vitest"
1212
},
1313
"dependencies": {
14+
"@fal-ai/client": "^1.10.0",
1415
"fflate": "^0.8.2",
1516
"react": "^19.1.0",
1617
"react-dom": "^19.1.0",

src/App.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
22
import { initStore } from './store'
33
import { useStore } from './store'
44
import { normalizeBaseUrl } from './lib/api'
5-
import type { ApiMode } from './types'
5+
import type { ApiMode, ApiProvider } from './types'
66
import Header from './components/Header'
77
import SearchBar from './components/SearchBar'
88
import TaskGrid from './components/TaskGrid'
@@ -20,7 +20,7 @@ export default function App() {
2020

2121
useEffect(() => {
2222
const searchParams = new URLSearchParams(window.location.search)
23-
const nextSettings: { baseUrl?: string; apiKey?: string; codexCli?: boolean; apiMode?: ApiMode } = {}
23+
const nextSettings: { baseUrl?: string; apiKey?: string; codexCli?: boolean; apiMode?: ApiMode; profiles?: any[]; activeProfileId?: string } = {}
2424

2525
const apiUrlParam = searchParams.get('apiUrl')
2626
if (apiUrlParam !== null) {
@@ -42,13 +42,33 @@ export default function App() {
4242
nextSettings.apiMode = apiModeParam
4343
}
4444

45+
const providerParam = searchParams.get('provider')?.trim().toLowerCase()
46+
if (providerParam) {
47+
const provider: ApiProvider | null = providerParam === 'fal'
48+
? 'fal'
49+
: ['oai', 'openai', 'openai-compatible', 'new-api', 'oai-like'].includes(providerParam)
50+
? 'oai-like'
51+
: null
52+
if (provider) {
53+
const state = useStore.getState()
54+
const current = state.settings.profiles.find((profile) => profile.id === state.settings.activeProfileId) ?? state.settings.profiles[0]
55+
if (current) {
56+
nextSettings.profiles = state.settings.profiles.map((profile) =>
57+
profile.id === current.id ? { ...profile, provider } : profile,
58+
)
59+
nextSettings.activeProfileId = current.id
60+
}
61+
}
62+
}
63+
4564
setSettings(nextSettings)
4665

47-
if (searchParams.has('apiUrl') || searchParams.has('apiKey') || searchParams.has('codexCli') || searchParams.has('apiMode')) {
66+
if (searchParams.has('apiUrl') || searchParams.has('apiKey') || searchParams.has('codexCli') || searchParams.has('apiMode') || searchParams.has('provider')) {
4867
searchParams.delete('apiUrl')
4968
searchParams.delete('apiKey')
5069
searchParams.delete('codexCli')
5170
searchParams.delete('apiMode')
71+
searchParams.delete('provider')
5272

5373
const nextSearch = searchParams.toString()
5474
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`

src/components/DetailModal.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ export default function DetailModal() {
163163
const hasHandledPromptWarning = settings.codexCli || dismissedCodexCliPrompts.includes(codexCliPromptKey)
164164
const showPromptWarning = Boolean(currentOutputImageId && (!currentRevisedPrompt || showRevisedPrompt) && !hasHandledPromptWarning)
165165
const aggregateActualParams = outputLen > 0 ? { ...task.actualParams, n: outputLen } : task.actualParams
166+
const taskProvider = task.apiProvider
167+
const taskProviderName = taskProvider === 'fal' ? 'fal.ai' : taskProvider === 'oai-like' ? 'OAI-like' : '未知'
168+
const taskProfileName = task.apiProfileName || '旧记录未保存'
169+
const taskModel = task.apiModel || '未知'
166170

167171
const formatTime = (ts: number | null) => {
168172
if (!ts) return ''
@@ -285,7 +289,7 @@ export default function DetailModal() {
285289

286290
{/* 左侧:图片 */}
287291
<div ref={imagePanelRef} className="md:w-1/2 w-full h-64 md:h-auto bg-gray-100 dark:bg-black/20 relative flex items-center justify-center flex-shrink-0 min-h-[16rem]">
288-
{task.status === 'done' && outputLen > 0 && (
292+
{task.status === 'done' && outputLen > 0 && currentOutputImageSrc && (
289293
<>
290294
<img
291295
ref={mainImageRef}
@@ -499,11 +503,13 @@ export default function DetailModal() {
499503
}`}
500504
onClick={() => setLightboxImageId(imgId, allInputImageIds)}
501505
>
502-
<img
503-
src={displaySrc}
504-
className="w-full h-full object-cover"
505-
alt=""
506-
/>
506+
{displaySrc && (
507+
<img
508+
src={displaySrc}
509+
className="w-full h-full object-cover"
510+
alt=""
511+
/>
512+
)}
507513
{isMaskTarget && (
508514
<span className="absolute left-1 top-1 rounded bg-blue-500/90 px-1.5 py-0.5 text-[8px] leading-none text-white font-bold tracking-wider backdrop-blur-sm z-10 pointer-events-none">
509515
MASK
@@ -521,6 +527,12 @@ export default function DetailModal() {
521527
<h3 className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
522528
参数配置
523529
</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>
524536
<div className="grid grid-cols-2 gap-2 text-xs mb-4">
525537
<div className="bg-gray-50 dark:bg-white/[0.03] rounded-lg px-3 py-2">
526538
<span className="text-gray-400 dark:text-gray-500">尺寸</span>

src/components/InputBar.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -741,11 +741,13 @@ export default function InputBar() {
741741
setLightboxImageId(img.id, inputImages.map((i) => i.id))
742742
}}
743743
>
744-
<img
745-
src={displaySrc}
746-
className="w-full h-full object-cover hover:opacity-90 transition-opacity pointer-events-none"
747-
alt=""
748-
/>
744+
{displaySrc && (
745+
<img
746+
src={displaySrc}
747+
className="w-full h-full object-cover hover:opacity-90 transition-opacity pointer-events-none"
748+
alt=""
749+
/>
750+
)}
749751
{isMaskTarget && (
750752
<span className="absolute left-1 top-1 rounded bg-blue-500/90 px-1.5 py-0.5 text-[8px] leading-none text-white font-bold tracking-wider backdrop-blur-sm z-10 pointer-events-none">
751753
MASK
@@ -811,7 +813,7 @@ export default function InputBar() {
811813
{inputImages.map((img, idx) => renderImageThumb(img, idx))}
812814
{renderClearAllButton()}
813815
</div>
814-
{touchDragPreview && createPortal(
816+
{touchDragPreview?.src && createPortal(
815817
<div
816818
className="fixed z-[140] h-[52px] w-[52px] overflow-hidden rounded-xl shadow-xl pointer-events-none opacity-90"
817819
style={{ left: touchDragPreview.x, top: touchDragPreview.y, transform: 'translate(-50%, -50%)' }}

0 commit comments

Comments
 (0)