mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
support tokenflux image generation for [Flux.1 Kontext] (#6705)
* Add support for TokenFlux image generation service This commit integrates TokenFlux as a new painting provider with dynamic form generation based on model schemas, real-time generation polling, and full painting history management. Key features: - Dynamic form rendering from JSON schema input parameters - Model selection with pricing display - Real-time generation status polling - Integration with existing painting workflow and file management - Provider-specific painting state management The implementation follows existing patterns from other painting pages while adding TokenFlux-specific functionality like schema-based form generation and asynchronous polling for generation results. * Add image upload support and comparison view to TokenFlux Implements file upload handling for image parameters with base64 conversion, random seed generation, and side-by-side comparison layout when input images are present. * Refactor TokenFlux to use service class and components Extract form rendering logic to DynamicFormRender component and API logic to TokenFluxService class. Simplifies the main component by removing duplicate code for model fetching, image generation polling, and form field rendering. * Refactor TokenFlux to fix state management and polling - Change painting field from modelId to model for consistency - Fix updatePaintingState to use functional state updates - Add automatic polling for in-progress generations on mount - Group models by provider in the selection dropdown - Separate prompt from other input params in form data handling - Improve error handling in the paintings store * Auto-select first model when models are loaded * Add image generation UI localization strings Add translation keys for model selection, input parameters, image labels, pricing display, and form validation across all supported locales (en-us, ja-jp, ru-ru, zh-cn, zh-tw). Update TokenFluxPage component to use localized strings instead of hardcoded English text. * fix: Add a right border to the first child of the ImageComparisonSection * style: Remove padding from UploadedImageContainer in TokenFluxPage * feat: Implement caching for TokenFlux model fetching and update image upload handling * feat: Enhance localization support by adding language context handling in TokenFluxPage * refactor: Simplify layout structure in TokenFluxPage by removing unnecessary SectionGroup components and improving section title styling --------- Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
parent
9dcd4d299e
commit
9538d07169
@ -1,10 +1,10 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
|
||||
import { locales } from '@main/utils/locales'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
@ -10,16 +10,19 @@ export function usePaintings() {
|
||||
const edit = useAppSelector((state) => state.paintings.edit)
|
||||
const upscale = useAppSelector((state) => state.paintings.upscale)
|
||||
const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings)
|
||||
const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
paintings,
|
||||
DMXAPIPaintings,
|
||||
tokenFluxPaintings,
|
||||
persistentData: {
|
||||
generate,
|
||||
remix,
|
||||
edit,
|
||||
upscale
|
||||
upscale,
|
||||
tokenFluxPaintings
|
||||
},
|
||||
addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => {
|
||||
dispatch(addPainting({ namespace, painting }))
|
||||
|
||||
@ -952,7 +952,17 @@
|
||||
"text_desc_required": "Please enter image description first",
|
||||
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
|
||||
"auto_create_paint": "Auto-create image",
|
||||
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically."
|
||||
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically.",
|
||||
"select_model": "Select Model",
|
||||
"input_parameters": "Input Parameters",
|
||||
"input_image": "Input Image",
|
||||
"generated_image": "Generated Image",
|
||||
"pricing": "Pricing",
|
||||
"model_and_pricing": "Model & Pricing",
|
||||
"per_image": "per image",
|
||||
"per_images": "per images",
|
||||
"required_field": "Required field",
|
||||
"uploaded_input": "Uploaded input"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Explain this concept to me",
|
||||
|
||||
@ -952,7 +952,17 @@
|
||||
"text_desc_required": "画像の説明を先に入力してください",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"auto_create_paint": "画像を自動作成",
|
||||
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。"
|
||||
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。",
|
||||
"select_model": "モデルを選択",
|
||||
"input_parameters": "パラメータ入力",
|
||||
"input_image": "入力画像",
|
||||
"generated_image": "生成画像",
|
||||
"pricing": "料金",
|
||||
"model_and_pricing": "モデルと料金",
|
||||
"per_image": "1枚あたり",
|
||||
"per_images": "複数枚あたり",
|
||||
"required_field": "必須項目",
|
||||
"uploaded_input": "アップロード済みの入力"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "この概念を説明してください",
|
||||
|
||||
@ -952,7 +952,17 @@
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"auto_create_paint": "Автоматическое создание изображения",
|
||||
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое."
|
||||
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое.",
|
||||
"select_model": "Выбрать модель",
|
||||
"input_parameters": "Ввести параметры",
|
||||
"input_image": "Входное изображение",
|
||||
"generated_image": "Сгенерированное изображение",
|
||||
"pricing": "Цены",
|
||||
"model_and_pricing": "Модель и цены",
|
||||
"per_image": "за изображение",
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
|
||||
@ -952,7 +952,17 @@
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自动新建图片",
|
||||
"auto_create_paint_tip": "在图片生成后,会自动新建图片"
|
||||
"auto_create_paint_tip": "在图片生成后,会自动新建图片",
|
||||
"select_model": "选择模型",
|
||||
"input_parameters": "输入参数",
|
||||
"input_image": "输入图片",
|
||||
"generated_image": "生成图片",
|
||||
"pricing": "定价",
|
||||
"model_and_pricing": "模型与定价",
|
||||
"per_image": "每张图片",
|
||||
"per_images": "每张图片",
|
||||
"required_field": "必填项",
|
||||
"uploaded_input": "已上传输入"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "帮我解释一下这个概念",
|
||||
|
||||
@ -952,7 +952,17 @@
|
||||
"text_desc_required": "請先輸入圖片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自動新增圖片",
|
||||
"auto_create_paint_tip": "圖片生成後,會自動新增圖片"
|
||||
"auto_create_paint_tip": "圖片生成後,會自動新增圖片",
|
||||
"select_model": "選擇模型",
|
||||
"input_parameters": "輸入參數",
|
||||
"input_image": "輸入圖片",
|
||||
"generated_image": "生成圖片",
|
||||
"pricing": "定價",
|
||||
"model_and_pricing": "模型與定價",
|
||||
"per_image": "每張圖片",
|
||||
"per_images": "每張圖片",
|
||||
"required_field": "必填欄位",
|
||||
"uploaded_input": "已上傳輸入"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "幫我解釋一下這個概念",
|
||||
|
||||
@ -7,8 +7,9 @@ import { Route, Routes, useParams } from 'react-router-dom'
|
||||
import AihubmixPage from './AihubmixPage'
|
||||
import DmxapiPage from './DmxapiPage'
|
||||
import SiliconPage from './SiliconPage'
|
||||
import TokenFluxPage from './TokenFluxPage'
|
||||
|
||||
const Options = ['aihubmix', 'silicon', 'dmxapi']
|
||||
const Options = ['aihubmix', 'silicon', 'dmxapi', 'tokenflux']
|
||||
|
||||
const PaintingsRoutePage: FC = () => {
|
||||
const params = useParams()
|
||||
@ -28,6 +29,7 @@ const PaintingsRoutePage: FC = () => {
|
||||
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
|
||||
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
|
||||
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
|
||||
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
786
src/renderer/src/pages/paintings/TokenFluxPage.tsx
Normal file
786
src/renderer/src/pages/paintings/TokenFluxPage.tsx
Normal file
@ -0,0 +1,786 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import type { TokenFluxPainting } from '@renderer/types'
|
||||
import { getErrorMessage, uuid } from '@renderer/utils'
|
||||
import { Avatar, Button, Select, Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { Info } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||
import Artboard from './components/Artboard'
|
||||
import { DynamicFormRender } from './components/DynamicFormRender'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
|
||||
import TokenFluxService from './utils/TokenFluxService'
|
||||
|
||||
const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const [models, setModels] = useState<TokenFluxModel[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<TokenFluxModel | null>(null)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({})
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
const { t, i18n } = useTranslation()
|
||||
const providers = useAllProviders()
|
||||
const { addPainting, removePainting, updatePainting, persistentData } = usePaintings()
|
||||
const tokenFluxPaintings = useMemo(() => persistentData.tokenFluxPaintings || [], [persistentData.tokenFluxPaintings])
|
||||
const [painting, setPainting] = useState<TokenFluxPainting>(
|
||||
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
|
||||
)
|
||||
|
||||
const providerOptions = Options.map((option) => {
|
||||
const provider = providers.find((p) => p.id === option)
|
||||
return {
|
||||
label: t(`provider.${provider?.id}`),
|
||||
value: provider?.id
|
||||
}
|
||||
})
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { generating } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { autoTranslateWithSpace } = useSettings()
|
||||
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
|
||||
const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')!
|
||||
const textareaRef = useRef<any>(null)
|
||||
const tokenFluxService = useMemo(
|
||||
() => new TokenFluxService(tokenfluxProvider.apiHost, tokenfluxProvider.apiKey),
|
||||
[tokenfluxProvider]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
tokenFluxService.fetchModels().then((models) => {
|
||||
setModels(models)
|
||||
if (models.length > 0) {
|
||||
setSelectedModel(models[0])
|
||||
}
|
||||
})
|
||||
}, [tokenFluxService])
|
||||
|
||||
const getNewPainting = useCallback(() => {
|
||||
return {
|
||||
...DEFAULT_TOKENFLUX_PAINTING,
|
||||
id: uuid(),
|
||||
model: selectedModel?.id || '',
|
||||
inputParams: {},
|
||||
generationId: undefined
|
||||
}
|
||||
}, [selectedModel])
|
||||
|
||||
const updatePaintingState = useCallback(
|
||||
(updates: Partial<TokenFluxPainting>) => {
|
||||
setPainting((prevPainting) => {
|
||||
const updatedPainting = { ...prevPainting, ...updates }
|
||||
updatePainting('tokenFluxPaintings', updatedPainting)
|
||||
return updatedPainting
|
||||
})
|
||||
},
|
||||
[updatePainting]
|
||||
)
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
window.modal.error({
|
||||
content: getErrorMessage(error),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId)
|
||||
if (model) {
|
||||
setSelectedModel(model)
|
||||
setFormData({})
|
||||
updatePaintingState({ model: model.id, inputParams: {} })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormFieldChange = (field: string, value: any) => {
|
||||
const newFormData = { ...formData, [field]: value }
|
||||
setFormData(newFormData)
|
||||
updatePaintingState({ inputParams: newFormData })
|
||||
}
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (painting.files.length > 0) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('paintings.regenerate.confirm'),
|
||||
centered: true
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
await FileManager.deleteFiles(painting.files)
|
||||
}
|
||||
|
||||
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
|
||||
|
||||
if (!tokenfluxProvider.enabled) {
|
||||
window.modal.error({
|
||||
content: t('error.provider_disabled'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!tokenfluxProvider.apiKey) {
|
||||
window.modal.error({
|
||||
content: t('error.no_api_key'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedModel || !prompt) {
|
||||
window.modal.error({
|
||||
content: t('paintings.text_desc_required'),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
setIsLoading(true)
|
||||
dispatch(setGenerating(true))
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
model: selectedModel.id,
|
||||
input: {
|
||||
prompt,
|
||||
...formData
|
||||
}
|
||||
}
|
||||
|
||||
const inputParams = { prompt, ...formData }
|
||||
updatePaintingState({
|
||||
model: selectedModel.id,
|
||||
prompt,
|
||||
status: 'processing',
|
||||
inputParams
|
||||
})
|
||||
|
||||
const result = await tokenFluxService.generateAndWait(requestBody, {
|
||||
signal: controller.signal,
|
||||
onStatusUpdate: (updates) => {
|
||||
updatePaintingState(updates)
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.images && result.images.length > 0) {
|
||||
const urls = result.images.map((img: { url: string }) => img.url)
|
||||
const validFiles = await tokenFluxService.downloadImages(urls)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
} catch (error: unknown) {
|
||||
handleError(error)
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
abortController?.abort()
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
|
||||
}
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length)
|
||||
}
|
||||
|
||||
const handleAddPainting = () => {
|
||||
const newPainting = addPainting('tokenFluxPaintings', getNewPainting())
|
||||
updatePainting('tokenFluxPaintings', newPainting)
|
||||
setPainting(newPainting as TokenFluxPainting)
|
||||
return newPainting
|
||||
}
|
||||
|
||||
const onDeletePainting = (paintingToDelete: TokenFluxPainting) => {
|
||||
if (paintingToDelete.id === painting.id) {
|
||||
const currentIndex = tokenFluxPaintings.findIndex((p) => p.id === paintingToDelete.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
setPainting(tokenFluxPaintings[currentIndex - 1])
|
||||
} else if (tokenFluxPaintings.length > 1) {
|
||||
setPainting(tokenFluxPaintings[1])
|
||||
}
|
||||
}
|
||||
|
||||
removePainting('tokenFluxPaintings', paintingToDelete)
|
||||
}
|
||||
|
||||
const translate = async () => {
|
||||
if (isTranslating) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!painting.prompt) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(painting.prompt, 'english')
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoTranslateWithSpace && event.key === ' ') {
|
||||
setSpaceClickCount((prev) => prev + 1)
|
||||
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
|
||||
spaceClickTimer.current = setTimeout(() => {
|
||||
setSpaceClickCount(0)
|
||||
}, 200)
|
||||
|
||||
if (spaceClickCount === 2) {
|
||||
setSpaceClickCount(0)
|
||||
setIsTranslating(true)
|
||||
translate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
const routeName = location.pathname.split('/').pop()
|
||||
if (providerId !== routeName) {
|
||||
navigate('../' + providerId, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectPainting = (newPainting: TokenFluxPainting) => {
|
||||
if (generating) return
|
||||
setPainting(newPainting)
|
||||
setCurrentImageIndex(0)
|
||||
|
||||
// Set form data from painting's input params
|
||||
if (newPainting.inputParams) {
|
||||
// Filter out the prompt from inputParams since it's handled separately
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { prompt, ...formInputParams } = newPainting.inputParams
|
||||
setFormData(formInputParams)
|
||||
} else {
|
||||
setFormData({})
|
||||
}
|
||||
|
||||
// Set selected model if available
|
||||
if (newPainting.model) {
|
||||
const model = models.find((m) => m.id === newPainting.model)
|
||||
if (model) {
|
||||
setSelectedModel(model)
|
||||
}
|
||||
} else {
|
||||
setSelectedModel(null)
|
||||
}
|
||||
}
|
||||
|
||||
const readI18nContext = (property: Record<string, any>, key: string): string => {
|
||||
const lang = i18n.language.split('-')[0] // Get the base language code (e.g., 'en' from 'en-US')
|
||||
console.log('readI18nContext', { property, key, lang })
|
||||
return property[`${key}_${lang}`] || property[key]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenFluxPaintings.length === 0) {
|
||||
const newPainting = getNewPainting()
|
||||
addPainting('tokenFluxPaintings', newPainting)
|
||||
setPainting(newPainting)
|
||||
}
|
||||
}, [tokenFluxPaintings, addPainting, getNewPainting])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = spaceClickTimer.current
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (painting.status === 'processing' && painting.generationId) {
|
||||
tokenFluxService
|
||||
.pollGenerationResult(painting.generationId, {
|
||||
onStatusUpdate: (updates) => {
|
||||
console.log('Polling status update:', updates)
|
||||
updatePaintingState(updates)
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
if (result && result.images && result.images.length > 0) {
|
||||
const urls = result.images.map((img: { url: string }) => img.url)
|
||||
tokenFluxService.downloadImages(urls).then(async (validFiles) => {
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Polling failed:', error)
|
||||
updatePaintingState({ status: 'failed' })
|
||||
})
|
||||
}
|
||||
}, [painting.generationId, painting.status, tokenFluxService, updatePaintingState])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
|
||||
{isMac && (
|
||||
<NavbarRight style={{ justifyContent: 'flex-end' }}>
|
||||
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={handleAddPainting}>
|
||||
{t('paintings.button.new.image')}
|
||||
</Button>
|
||||
</NavbarRight>
|
||||
)}
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<LeftContainer>
|
||||
{/* Provider Section */}
|
||||
<ProviderTitleContainer>
|
||||
<SettingTitle style={{ marginBottom: 8 }}>{t('common.provider')}</SettingTitle>
|
||||
<SettingHelpLink target="_blank" href="https://tokenflux.ai">
|
||||
{t('paintings.learn_more')}
|
||||
<ProviderLogo shape="square" src={getProviderLogo('tokenflux')} size={16} style={{ marginLeft: 5 }} />
|
||||
</SettingHelpLink>
|
||||
</ProviderTitleContainer>
|
||||
|
||||
<Select
|
||||
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
|
||||
onChange={handleProviderChange}
|
||||
style={{ width: '100%' }}>
|
||||
{providerOptions.map((provider) => (
|
||||
<Select.Option value={provider.value} key={provider.value}>
|
||||
<SelectOptionContainer>
|
||||
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
|
||||
{provider.label}
|
||||
</SelectOptionContainer>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Model & Pricing Section */}
|
||||
<SectionTitle
|
||||
style={{ marginBottom: 5, marginTop: 15, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{t('paintings.model_and_pricing')}
|
||||
{selectedModel && selectedModel.pricing && (
|
||||
<PricingContainer>
|
||||
<PricingBadge>
|
||||
{selectedModel.pricing.price} {selectedModel.pricing.currency}{' '}
|
||||
{selectedModel.pricing.unit > 1 ? t('paintings.per_images') : t('paintings.per_image')}
|
||||
</PricingBadge>
|
||||
</PricingContainer>
|
||||
)}
|
||||
</SectionTitle>
|
||||
<Select
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
value={selectedModel?.id}
|
||||
onChange={handleModelChange}
|
||||
placeholder={t('paintings.select_model')}>
|
||||
{Object.entries(
|
||||
models.reduce(
|
||||
(acc, model) => {
|
||||
const provider = model.model_provider || 'Other'
|
||||
if (!acc[provider]) {
|
||||
acc[provider] = []
|
||||
}
|
||||
acc[provider].push(model)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof models>
|
||||
)
|
||||
).map(([provider, providerModels]) => (
|
||||
<Select.OptGroup key={provider} label={provider}>
|
||||
{providerModels.map((model) => (
|
||||
<Select.Option key={model.id} value={model.id}>
|
||||
<Tooltip title={model.description} placement="right">
|
||||
<ModelOptionContainer>
|
||||
<ModelName>{model.name}</ModelName>
|
||||
</ModelOptionContainer>
|
||||
</Tooltip>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Input Parameters Section */}
|
||||
{selectedModel && selectedModel.input_schema && (
|
||||
<>
|
||||
<SectionTitle style={{ marginBottom: 5, marginTop: 10 }}>{t('paintings.input_parameters')}</SectionTitle>
|
||||
<ParametersContainer>
|
||||
{Object.entries(selectedModel.input_schema.properties).map(([key, property]: [string, any]) => {
|
||||
if (key === 'prompt') return null // Skip prompt as it's handled separately
|
||||
|
||||
const isRequired = selectedModel.input_schema.required?.includes(key)
|
||||
|
||||
return (
|
||||
<ParameterField key={key}>
|
||||
<ParameterLabel>
|
||||
<ParameterName>
|
||||
{readI18nContext(property, 'title')}
|
||||
{isRequired && <RequiredIndicator> *</RequiredIndicator>}
|
||||
</ParameterName>
|
||||
{property.description && (
|
||||
<Tooltip title={readI18nContext(property, 'description')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ParameterLabel>
|
||||
<DynamicFormRender
|
||||
schemaProperty={property}
|
||||
propertyName={key}
|
||||
value={formData[key]}
|
||||
onChange={handleFormFieldChange}
|
||||
/>
|
||||
</ParameterField>
|
||||
)
|
||||
})}
|
||||
</ParametersContainer>
|
||||
</>
|
||||
)}
|
||||
</LeftContainer>
|
||||
|
||||
<MainContainer>
|
||||
{/* Check if any form field contains an uploaded image */}
|
||||
{Object.keys(formData).some((key) => key.toLowerCase().includes('image') && formData[key]) ? (
|
||||
<ComparisonContainer>
|
||||
<ImageComparisonSection>
|
||||
<SectionLabel>{t('paintings.input_image')}</SectionLabel>
|
||||
<UploadedImageContainer>
|
||||
{Object.entries(formData).map(([key, value]) => {
|
||||
if (key.toLowerCase().includes('image') && value) {
|
||||
return (
|
||||
<ImageWrapper key={key}>
|
||||
<img
|
||||
src={value}
|
||||
alt={t('paintings.uploaded_input')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'var(--color-background-soft)'
|
||||
}}
|
||||
/>
|
||||
</ImageWrapper>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</UploadedImageContainer>
|
||||
</ImageComparisonSection>
|
||||
<ImageComparisonSection>
|
||||
<SectionLabel>{t('paintings.generated_image')}</SectionLabel>
|
||||
<Artboard
|
||||
painting={painting}
|
||||
isLoading={isLoading}
|
||||
currentImageIndex={currentImageIndex}
|
||||
onPrevImage={prevImage}
|
||||
onNextImage={nextImage}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</ImageComparisonSection>
|
||||
</ComparisonContainer>
|
||||
) : (
|
||||
<Artboard
|
||||
painting={painting}
|
||||
isLoading={isLoading}
|
||||
currentImageIndex={currentImageIndex}
|
||||
onPrevImage={prevImage}
|
||||
onNextImage={nextImage}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
<InputContainer>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
disabled={isLoading}
|
||||
value={painting.prompt || ''}
|
||||
spellCheck={false}
|
||||
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
|
||||
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Toolbar>
|
||||
<ToolbarMenu>
|
||||
<TranslateButton
|
||||
text={textareaRef.current?.resizableTextArea?.textArea?.value}
|
||||
onTranslated={(translatedText) => updatePaintingState({ prompt: translatedText })}
|
||||
disabled={isLoading || isTranslating}
|
||||
isLoading={isTranslating}
|
||||
style={{ marginRight: 6, borderRadius: '50%' }}
|
||||
/>
|
||||
<SendMessageButton sendMessage={onGenerate} disabled={isLoading} />
|
||||
</ToolbarMenu>
|
||||
</Toolbar>
|
||||
</InputContainer>
|
||||
</MainContainer>
|
||||
|
||||
<PaintingsList
|
||||
namespace="tokenFluxPaintings"
|
||||
paintings={tokenFluxPaintings}
|
||||
selectedPainting={painting}
|
||||
onSelectPainting={onSelectPainting as any}
|
||||
onDeletePainting={onDeletePainting as any}
|
||||
onNewPainting={handleAddPainting}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const SectionTitle = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const ModelOptionContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ModelName = styled.div`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const PricingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const PricingBadge = styled.div`
|
||||
background-color: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-primary-border);
|
||||
`
|
||||
|
||||
const ParametersContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const ParameterField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ParameterLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
`
|
||||
|
||||
const ParameterName = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-transform: capitalize;
|
||||
`
|
||||
|
||||
const RequiredIndicator = styled.span`
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const LeftContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background-color: var(--color-background);
|
||||
max-width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const MainContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const ComparisonContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
gap: 1px;
|
||||
`
|
||||
|
||||
const ImageComparisonSection = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
&:first-child {
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
}
|
||||
`
|
||||
|
||||
const SectionLabel = styled.div`
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
background-color: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const UploadedImageContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 95px;
|
||||
max-height: 95px;
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border-soft);
|
||||
transition: all 0.3s ease;
|
||||
margin: 0 20px 15px 20px;
|
||||
border-radius: 10px;
|
||||
`
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 10px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 0;
|
||||
height: 40px;
|
||||
`
|
||||
|
||||
const ToolbarMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const InfoIcon = styled(Info)`
|
||||
margin-left: 5px;
|
||||
cursor: help;
|
||||
color: var(--color-text-2);
|
||||
opacity: 0.6;
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ProviderTitleContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
`
|
||||
|
||||
const SelectOptionContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
export default TokenFluxPage
|
||||
@ -0,0 +1,213 @@
|
||||
import { CloseOutlined, LinkOutlined, RedoOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { convertToBase64 } from '@renderer/utils'
|
||||
import { Button, Input, InputNumber, Select, Switch, Upload } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface DynamicFormRenderProps {
|
||||
schemaProperty: any
|
||||
propertyName: string
|
||||
value: any
|
||||
onChange: (field: string, value: any) => void
|
||||
}
|
||||
|
||||
export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({
|
||||
schemaProperty,
|
||||
propertyName,
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const { type, enum: enumValues, description, default: defaultValue, format } = schemaProperty
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (
|
||||
propertyName: string,
|
||||
fileOrUrl: File | string,
|
||||
onChange: (field: string, value: any) => void
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (typeof fileOrUrl === 'string') {
|
||||
// Handle URL case - validate and set directly
|
||||
if (fileOrUrl.match(/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i)) {
|
||||
onChange(propertyName, fileOrUrl)
|
||||
} else {
|
||||
window.message?.error('Invalid image URL format')
|
||||
}
|
||||
} else {
|
||||
// Handle File case - convert to base64
|
||||
const base64Image = await convertToBase64(fileOrUrl)
|
||||
if (typeof base64Image === 'string') {
|
||||
onChange(propertyName, base64Image)
|
||||
} else {
|
||||
console.error('Failed to convert image to base64')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing image:', error)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (type === 'string' && propertyName.toLowerCase().includes('image') && format === 'uri') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '0' }}>
|
||||
<Input
|
||||
style={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderRight: 'none'
|
||||
}}
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
placeholder="Enter image URL or upload file"
|
||||
prefix={<LinkOutlined style={{ color: '#999' }} />}
|
||||
/>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleImageUpload(propertyName, file, onChange)
|
||||
return false
|
||||
}}>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
title="Upload image file"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
height: '32px'
|
||||
}}
|
||||
/>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'var(--color-fill-quaternary)',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-border)'
|
||||
}}>
|
||||
<img
|
||||
src={value}
|
||||
alt="Image preview"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--color-border-secondary)',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.1)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{value.startsWith('data:') ? 'Uploaded image' : 'Image URL'}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => onChange(propertyName, '')}
|
||||
title="Remove image"
|
||||
style={{ flexShrink: 0, minWidth: 'auto', padding: '0 8px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'string' && enumValues) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={value || defaultValue}
|
||||
options={enumValues.map((val: string) => ({ label: val, value: val }))}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
if (propertyName.toLowerCase().includes('prompt') && propertyName !== 'prompt') {
|
||||
return (
|
||||
<TextArea
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
rows={3}
|
||||
placeholder={description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={value || defaultValue || ''}
|
||||
onChange={(e) => onChange(propertyName, e.target.value)}
|
||||
placeholder={description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'integer' && propertyName === 'seed') {
|
||||
const generateRandomSeed = () => Math.floor(Math.random() * 1000000)
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<InputNumber
|
||||
style={{ flex: 1 }}
|
||||
value={value || defaultValue}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
step={1}
|
||||
min={schemaProperty.minimum}
|
||||
max={schemaProperty.maximum}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={() => onChange(propertyName, generateRandomSeed())}
|
||||
title="Generate random seed"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'integer' || type === 'number') {
|
||||
const step = type === 'number' ? 0.1 : 1
|
||||
return (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={value || defaultValue}
|
||||
onChange={(v) => onChange(propertyName, v)}
|
||||
step={step}
|
||||
min={schemaProperty.minimum}
|
||||
max={schemaProperty.maximum}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<Switch
|
||||
checked={value !== undefined ? value : defaultValue}
|
||||
onChange={(checked) => onChange(propertyName, checked)}
|
||||
style={{ width: '2px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
27
src/renderer/src/pages/paintings/config/tokenFluxConfig.ts
Normal file
27
src/renderer/src/pages/paintings/config/tokenFluxConfig.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { TokenFluxPainting } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
|
||||
export interface TokenFluxModel {
|
||||
id: string
|
||||
name: string
|
||||
model_provider: string
|
||||
description: string
|
||||
tags: string[]
|
||||
pricing: any
|
||||
input_schema: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
required: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_TOKENFLUX_PAINTING: TokenFluxPainting = {
|
||||
id: uuid(),
|
||||
model: '',
|
||||
prompt: '',
|
||||
inputParams: {},
|
||||
status: 'starting',
|
||||
generationId: undefined,
|
||||
urls: [],
|
||||
files: []
|
||||
}
|
||||
237
src/renderer/src/pages/paintings/utils/TokenFluxService.ts
Normal file
237
src/renderer/src/pages/paintings/utils/TokenFluxService.ts
Normal file
@ -0,0 +1,237 @@
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { FileType, TokenFluxPainting } from '@renderer/types'
|
||||
|
||||
import type { TokenFluxModel } from '../config/tokenFluxConfig'
|
||||
|
||||
export interface TokenFluxGenerationRequest {
|
||||
model: string
|
||||
input: {
|
||||
prompt: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenFluxGenerationResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
id: string
|
||||
status: string
|
||||
images?: Array<{ url: string }>
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface TokenFluxModelsResponse {
|
||||
success: boolean
|
||||
data?: TokenFluxModel[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export class TokenFluxService {
|
||||
private apiHost: string
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiHost: string, apiKey: string) {
|
||||
this.apiHost = apiHost
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }))
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: Request failed`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from TokenFlux API
|
||||
*/
|
||||
async fetchModels(): Promise<TokenFluxModel[]> {
|
||||
const cacheKey = `tokenflux_models_${this.apiHost}`
|
||||
|
||||
// Check cache first
|
||||
const cachedModels = CacheService.get<TokenFluxModel[]>(cacheKey)
|
||||
if (cachedModels) {
|
||||
return cachedModels
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiHost}/v1/images/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
const data: TokenFluxModelsResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error('Failed to fetch models')
|
||||
}
|
||||
|
||||
// Cache for 60 minutes (3,600,000 milliseconds)
|
||||
CacheService.set(cacheKey, data.data, 60 * 60 * 1000)
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new image generation request
|
||||
*/
|
||||
async createGeneration(request: TokenFluxGenerationRequest, signal?: AbortSignal): Promise<string> {
|
||||
const response = await fetch(`${this.apiHost}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
signal
|
||||
})
|
||||
|
||||
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data?.id) {
|
||||
throw new Error(data.message || 'Generation failed')
|
||||
}
|
||||
|
||||
return data.data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status and result of a generation
|
||||
*/
|
||||
async getGenerationResult(generationId: string): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const response = await fetch(`${this.apiHost}/v1/images/generations/${generationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error('Invalid response from generation service')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for generation result with automatic retry logic
|
||||
*/
|
||||
async pollGenerationResult(
|
||||
generationId: string,
|
||||
options: {
|
||||
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
|
||||
maxRetries?: number
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
} = {}
|
||||
): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const {
|
||||
onStatusUpdate,
|
||||
maxRetries = 10,
|
||||
timeoutMs = 120000, // 2 minutes
|
||||
intervalMs = 2000
|
||||
} = options
|
||||
|
||||
const startTime = Date.now()
|
||||
let retryCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check for timeout
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error('Image generation timed out. Please try again.'))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.getGenerationResult(generationId)
|
||||
|
||||
// Reset retry count on successful response
|
||||
retryCount = 0
|
||||
|
||||
if (result) {
|
||||
onStatusUpdate?.({ status: result.status as TokenFluxPainting['status'] })
|
||||
|
||||
if (result.status === 'succeeded') {
|
||||
resolve(result)
|
||||
return
|
||||
} else if (result.status === 'failed') {
|
||||
reject(new Error('Image generation failed'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Continue polling for other statuses (processing, queued, etc.)
|
||||
setTimeout(poll, intervalMs)
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error)
|
||||
retryCount++
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
reject(new Error('Failed to check generation status after multiple attempts. Please try again.'))
|
||||
return
|
||||
}
|
||||
|
||||
// Retry after interval
|
||||
setTimeout(poll, intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create generation and poll for result in one call
|
||||
*/
|
||||
async generateAndWait(
|
||||
request: TokenFluxGenerationRequest,
|
||||
options: {
|
||||
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
|
||||
signal?: AbortSignal
|
||||
maxRetries?: number
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
} = {}
|
||||
): Promise<TokenFluxGenerationResponse['data']> {
|
||||
const { signal, onStatusUpdate, ...pollOptions } = options
|
||||
const generationId = await this.createGeneration(request, signal)
|
||||
if (onStatusUpdate) {
|
||||
onStatusUpdate({ generationId })
|
||||
}
|
||||
return this.pollGenerationResult(generationId, { ...pollOptions, onStatusUpdate })
|
||||
}
|
||||
|
||||
async downloadImages(urls: string[]) {
|
||||
const downloadedFiles = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (!url?.trim()) {
|
||||
console.error('Image URL is empty')
|
||||
window.message.warning({
|
||||
content: 'Image URL is empty',
|
||||
key: 'empty-url-warning'
|
||||
})
|
||||
return null
|
||||
}
|
||||
return await window.api.file.download(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return downloadedFiles.filter((file): file is FileType => file !== null)
|
||||
}
|
||||
}
|
||||
|
||||
export default TokenFluxService
|
||||
@ -1476,6 +1476,9 @@ const migrateConfig = {
|
||||
},
|
||||
'110': (state: RootState) => {
|
||||
try {
|
||||
if (state.paintings && !state.paintings.tokenFluxPaintings) {
|
||||
state.paintings.tokenFluxPaintings = []
|
||||
}
|
||||
state.settings.showTokens = true
|
||||
return state
|
||||
} catch (error) {
|
||||
|
||||
@ -7,7 +7,8 @@ const initialState: PaintingsState = {
|
||||
remix: [],
|
||||
edit: [],
|
||||
upscale: [],
|
||||
DMXAPIPaintings: []
|
||||
DMXAPIPaintings: [],
|
||||
tokenFluxPaintings: []
|
||||
}
|
||||
|
||||
const paintingsSlice = createSlice({
|
||||
@ -38,7 +39,13 @@ const paintingsSlice = createSlice({
|
||||
action: PayloadAction<{ namespace?: keyof PaintingsState; painting: PaintingAction }>
|
||||
) => {
|
||||
const { namespace = 'paintings', painting } = action.payload
|
||||
state[namespace] = state[namespace].map((c) => (c.id === painting.id ? painting : c))
|
||||
|
||||
const existingIndex = state[namespace].findIndex((c) => c.id === painting.id)
|
||||
if (existingIndex !== -1) {
|
||||
state[namespace] = state[namespace].map((c) => (c.id === painting.id ? painting : c))
|
||||
} else {
|
||||
console.error(`Painting with id ${painting.id} not found in ${namespace}`)
|
||||
}
|
||||
},
|
||||
updatePaintings: (
|
||||
state: PaintingsState,
|
||||
|
||||
@ -269,7 +269,18 @@ export interface DmxapiPainting extends PaintingParams {
|
||||
autoCreate?: boolean
|
||||
}
|
||||
|
||||
export type PaintingAction = Partial<GeneratePainting & RemixPainting & EditPainting & ScalePainting> & PaintingParams
|
||||
export interface TokenFluxPainting extends PaintingParams {
|
||||
generationId?: string
|
||||
model?: string
|
||||
prompt?: string
|
||||
inputParams?: Record<string, any>
|
||||
status?: 'starting' | 'processing' | 'succeeded' | 'failed' | 'cancelled'
|
||||
}
|
||||
|
||||
export type PaintingAction = Partial<
|
||||
GeneratePainting & RemixPainting & EditPainting & ScalePainting & DmxapiPainting & TokenFluxPainting
|
||||
> &
|
||||
PaintingParams
|
||||
|
||||
export interface PaintingsState {
|
||||
paintings: Painting[]
|
||||
@ -278,6 +289,7 @@ export interface PaintingsState {
|
||||
edit: Partial<EditPainting> & PaintingParams[]
|
||||
upscale: Partial<ScalePainting> & PaintingParams[]
|
||||
DMXAPIPaintings: DmxapiPainting[]
|
||||
tokenFluxPaintings: TokenFluxPainting[]
|
||||
}
|
||||
|
||||
export type MinAppType = {
|
||||
|
||||
304
tokenflux_painting_page.md
Normal file
304
tokenflux_painting_page.md
Normal file
@ -0,0 +1,304 @@
|
||||
# Task: Implement TokenFlux Painting Page
|
||||
|
||||
I want you to implement a new painting page, `TokenFluxPage.tsx`, for interacting with the TokenFlux image generation API. This page should allow users to select a model, dynamically fill in parameters based on the model's schema, generate images, and view their generation history.
|
||||
|
||||
Please adhere to the existing project structure, coding style, and best practices found in `cherry-studio`. Use TypeScript for type safety.
|
||||
|
||||
Refer to `cherry-studio/src/renderer/src/pages/paintings/AihubmixPage.tsx` and `cherry-studio/src/renderer/src/pages/paintings/DmxapiPage.tsx` as primary examples for page structure, state management, UI components, and overall functionality.
|
||||
|
||||
## Files to Implement/Modify
|
||||
|
||||
1. **`cherry-studio/src/renderer/src/pages/paintings/TokenFluxPage.tsx`**:
|
||||
|
||||
- This file currently contains placeholder content. Replace it with the full implementation of the TokenFlux painting page.
|
||||
- It should take an `Options: string[]` prop, similar to other painting pages.
|
||||
|
||||
2. **`cherry-studio/src/renderer/src/pages/paintings/config/tokenFluxConfig.ts`** (Create this file):
|
||||
|
||||
- This file will store configurations specific to the TokenFlux page, such as the default painting state object, type definitions, and potentially helper functions for rendering forms from JSON schema.
|
||||
|
||||
3. **`cherry-studio/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx`**:
|
||||
|
||||
- Ensure `TokenFluxPage` is correctly imported and used in the routes. The route `/tokenflux` and its presence in the `Options` array are already set up. Your main task is the implementation of `TokenFluxPage.tsx` itself.
|
||||
|
||||
4. **Update `usePaintings` hook related types**:
|
||||
- If necessary, update types in `cherry-studio/src/renderer/src/types/index.d.ts` (or similar central type definition file) to include a new state key and type for TokenFlux paintings (e.g., `tokenFluxPaintings: TokenFluxPainting[]` in `PaintingsState` and the `TokenFluxPainting` type itself).
|
||||
|
||||
## TokenFlux API Details
|
||||
|
||||
The base URL for the TokenFlux API is: `https://api.tokenflux.ai/v1`
|
||||
|
||||
Assume the API key is available via `tokenfluxProvider.apiKey`, obtained using the `useAllProviders` hook, similar to other painting pages. All API requests should include this key if required by the API (e.g., in an `Authorization: Bearer <API_KEY>` header or a custom header like `Api-Key`).
|
||||
|
||||
### 1. List Models
|
||||
|
||||
- **Endpoint**: `GET /images/models`
|
||||
- **Description**: Fetches all available models. The `input_schema` field in the response is a JSON schema that defines the input parameters for each model. This schema **must** be used to dynamically build the image generation form.
|
||||
- **Response Example**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"id": "black-forest-labs/flux-1.1-pro-ultra",
|
||||
"name": "FLUX1.1 [pro] in ultra and raw modes",
|
||||
"model_provider": "black-forest-labs",
|
||||
"description": "FLUX1.1 [pro] in ultra and raw modes. Images are up to 4 megapixels. Use raw mode for realism.",
|
||||
"tags": ["image-to-image", "text-to-image"],
|
||||
"pricing": { "...": "..." },
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The main prompt for image generation."
|
||||
},
|
||||
"negative_prompt": {
|
||||
"type": "string",
|
||||
"description": "The negative prompt."
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": "Width of the image."
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": "Height of the image."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"type": "string",
|
||||
"default": "1:1",
|
||||
"description": "Aspect ratio for the generated image",
|
||||
"enum": ["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "9:21"]
|
||||
}
|
||||
// ... other parameters
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
}
|
||||
// ... other models
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Generate Image
|
||||
|
||||
- **Endpoint**: `POST /images/generations`
|
||||
- **Description**: Creates/starts an image generation task.
|
||||
- **Request Body Example**:
|
||||
```json
|
||||
{
|
||||
"model": "black-forest-labs/flux-schnell", // Selected model ID
|
||||
"input": {
|
||||
// Input parameters based on the model's input_schema
|
||||
"prompt": "a photo of a cat",
|
||||
"negative_prompt": "blurry",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"steps": 20,
|
||||
"guidance_scale": 7.5,
|
||||
"seed": 42
|
||||
// ... other parameters from the dynamic form
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Response Example** (The `id` is used to poll for the result):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": {
|
||||
"id": "2d8e9cda-b5ed-4115-897c-28f7da6c6b80",
|
||||
"model": "black-forest-labs/flux-schnell",
|
||||
"status": "starting" // or "pending", "processing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Get Image Generation Result
|
||||
|
||||
- **Endpoint**: `GET /images/generations/{id}`
|
||||
- **Description**: Fetches the result of an image generation task. This endpoint should be polled periodically after a generation is initiated until the `status` is `succeeded` or a terminal failure state.
|
||||
- **Response Example** (when succeeded):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": {
|
||||
"id": "2d8e9cda-b5ed-4115-897c-28f7da6c6b80",
|
||||
"model": "black-forest-labs/flux-schnell",
|
||||
"status": "succeeded", // Other statuses: "failed", "processing"
|
||||
"images": [
|
||||
{
|
||||
"url": "https://replicate.delivery/xezq/..." // Image URL
|
||||
}
|
||||
// Potentially multiple images
|
||||
],
|
||||
"error": null // or error details if status is "failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. List All Generations (Optional for initial UI, good for context)
|
||||
|
||||
- **Endpoint**: `GET /images/generations`
|
||||
- **Description**: Lists all generations for the user/API key. This might be useful for future enhancements or if a painting history needs to be synced from the server. For the initial version, focus on managing history via `usePaintings`.
|
||||
- **Response Example**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"id": "2d8e9cda-b5ed-4115-897c-28f7da6c6b80",
|
||||
"model": "black-forest-labs/flux-schnell",
|
||||
"status": "succeeded",
|
||||
"images": [{ "url": "..." }]
|
||||
}
|
||||
// ... other generations
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Core Requirements for `TokenFluxPage.tsx`
|
||||
|
||||
### 1. Layout & Structure:
|
||||
|
||||
- Follow a two-column layout similar to `AihubmixPage.tsx`:
|
||||
- **Left Panel (`LeftContainer`):** For configuration options.
|
||||
- Provider selection (using the `Options` prop, though TokenFluxPage is specific to 'tokenflux').
|
||||
- Model selection dropdown.
|
||||
- Dynamically generated form for model parameters.
|
||||
- **Main Panel (`MainContainer`):**
|
||||
- `Artboard` component for displaying generated images.
|
||||
- Prompt input area (e.g., `TextArea` from Ant Design).
|
||||
- Generation button (`SendMessageButton`).
|
||||
- **Right Panel (`PaintingsList`):** For displaying history of generations for TokenFlux.
|
||||
- Use `Navbar` for the page title.
|
||||
- Use `Scrollbar` component for scrollable areas.
|
||||
|
||||
### 2. Model Fetching and Selection:
|
||||
|
||||
- On component mount, fetch the list of models using `GET /images/models`.
|
||||
- Store the models in component state (e.g., `useState<ModelType[]>([])`).
|
||||
- Display model names in a `Select` component from Ant Design.
|
||||
- When a model is selected, store its ID and its `input_schema` in state.
|
||||
|
||||
### 3. Dynamic Form Generation:
|
||||
|
||||
- **Crucial Requirement**: When a model is selected, dynamically render form fields based on its `input_schema` (JSON Schema).
|
||||
- Map JSON schema properties to Ant Design form components:
|
||||
- `type: "string"` with `enum`: `Select` or `Radio.Group`.
|
||||
- `type: "string"` (no enum): `Input` (or `TextArea` for multi-line prompts).
|
||||
- `type: "integer"` or `type: "number"`: `InputNumber` or `Slider`.
|
||||
- `type: "boolean"`: `Switch`.
|
||||
- Use `description` from the schema for labels or tooltips (`Tooltip` with `InfoIcon`).
|
||||
- Use `default` values from the schema as initial form values.
|
||||
- Clearly indicate `required` fields.
|
||||
- Store the form data in component state (e.g., `useState<Record<string, any>>({})`).
|
||||
|
||||
### 4. Image Generation Workflow:
|
||||
|
||||
- Implement an `onGenerate` function triggered by the "Send" button.
|
||||
- Construct the request body for `POST /images/generations` using the selected model ID and the current form data.
|
||||
- Handle loading states (`isLoading`, `dispatch(setGenerating(true/false))` from `useRuntime`).
|
||||
- Store the `id` from the generation response.
|
||||
- Implement a polling mechanism:
|
||||
- After `POST /images/generations` returns successfully, start polling `GET /images/generations/{id}` every few seconds.
|
||||
- Continue polling until `status` is `succeeded` or `failed`.
|
||||
- If `succeeded`, extract image URLs, download/save them using `window.api.file.download` and `FileManager` (similar to other pages), and update the painting state.
|
||||
- If `failed`, display an error message.
|
||||
- Provide a way to cancel the polling/generation (e.g., `onCancel` for `Artboard`).
|
||||
|
||||
### 5. State Management:
|
||||
|
||||
- **Local State (`useState`):**
|
||||
- Selected model ID and schema.
|
||||
- List of available models.
|
||||
- Current form input values.
|
||||
- Loading indicators for API calls.
|
||||
- Current image generation task ID and status.
|
||||
- **Persistent State (`usePaintings`):**
|
||||
- Define a `TokenFluxPainting` type (see `tokenFluxConfig.ts` section).
|
||||
- Use a unique namespace like `'tokenFluxPaintings'` with `usePaintings` hooks (`addPainting`, `removePainting`, `updatePainting`, `persistentData.tokenFluxPaintings`).
|
||||
- Each `TokenFluxPainting` object should store: `id` (UUID), `modelId`, `inputParams` (the form data used), `files` (array of `FileType`), `urls` (array of original image URLs), `status`, `timestamp`, etc.
|
||||
- **Global State (`useRuntime`):**
|
||||
- Use `generating` state from `useRuntime` to indicate global generation activity.
|
||||
|
||||
### 6. UI Components:
|
||||
|
||||
- Utilize Ant Design components extensively (`Button`, `Select`, `Input`, `InputNumber`, `Slider`, `Switch`, `Spin`, `Tooltip`, `Radio`, `Form` if suitable for dynamic rendering).
|
||||
- Reuse shared components:
|
||||
- `Artboard`: To display images, handle loading/cancel.
|
||||
- `PaintingsList`: To show generation history for TokenFlux.
|
||||
- `Navbar`, `NavbarCenter`, `NavbarRight`.
|
||||
- `Scrollbar`.
|
||||
- `SendMessageButton`.
|
||||
- `TranslateButton` (if applicable for prompts).
|
||||
- `SettingTitle`, `InfoIcon`.
|
||||
- Implement internationalization using `useTranslation` (`t` function) for all static text.
|
||||
|
||||
### 7. Error Handling:
|
||||
|
||||
- Display user-friendly error messages for API failures or issues during generation (e.g., using `window.modal.error`).
|
||||
- Handle cases where image URLs might be empty or invalid.
|
||||
|
||||
## Requirements for `cherry-studio/src/renderer/src/pages/paintings/config/tokenFluxConfig.ts`
|
||||
|
||||
1. **`TokenFluxPainting` Type Definition**:
|
||||
|
||||
```typescript
|
||||
import type { FileType } from '@renderer/types' // Adjust import path if needed
|
||||
|
||||
export interface TokenFluxModel {
|
||||
id: string
|
||||
name: string
|
||||
input_schema: any // Or a more specific JSONSchema type
|
||||
// ... other model properties
|
||||
}
|
||||
|
||||
export interface TokenFluxPainting {
|
||||
id: string // Unique UUID for the painting entry
|
||||
modelId: string
|
||||
prompt?: string // Or make this part of inputParams
|
||||
inputParams: Record<string, any> // Stores the actual inputs used for generation
|
||||
files: FileType[] // Local file info after download
|
||||
urls: string[] // Original URLs from API
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'polling'
|
||||
timestamp: number
|
||||
// ... any other relevant fields
|
||||
}
|
||||
```
|
||||
|
||||
2. **`DEFAULT_TOKENFLUX_PAINTING` Constant**:
|
||||
|
||||
- Define a default state object for a new TokenFlux painting session.
|
||||
|
||||
```typescript
|
||||
import { uuid } from '@renderer/utils' // Adjust import
|
||||
|
||||
export const DEFAULT_TOKENFLUX_PAINTING: TokenFluxPainting = {
|
||||
id: uuid(),
|
||||
modelId: '', // Should be set when a model is selected
|
||||
inputParams: {},
|
||||
files: [],
|
||||
urls: [],
|
||||
status: 'pending',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
```
|
||||
|
||||
3. **Helper function for JSON Schema to Form (Optional but Recommended)**:
|
||||
- Consider creating a helper function or a small component within `TokenFluxPage.tsx` or this config file that takes a JSON schema property definition and returns the corresponding Ant Design form item. This will keep the main component cleaner.
|
||||
- Example signature: `function renderFormField(schemaProperty: any, propertyName: string, value: any, onChange: (field: string, value: any) => void): React.ReactNode;`
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Ensure all asynchronous operations are handled correctly with `async/await`.
|
||||
- Manage component lifecycle and side effects with `useEffect`.
|
||||
- Use `useCallback` and `useMemo` for performance optimizations where appropriate.
|
||||
- Fetch API key from `tokenfluxProvider.apiKey` using `useAllProviders()`.
|
||||
- The page should be responsive and user-friendly.
|
||||
|
||||
By following these detailed instructions and referencing the existing painting pages, you should be able to generate a robust `TokenFluxPage.tsx` and its associated configuration.
|
||||
Loading…
Reference in New Issue
Block a user