From 9538d0716949cf9c59ce418e1bac8f120de0c7c5 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Thu, 5 Jun 2025 15:47:51 +0800 Subject: [PATCH] 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 --- src/main/services/AppUpdater.ts | 2 +- src/renderer/src/hooks/usePaintings.ts | 5 +- src/renderer/src/i18n/locales/en-us.json | 12 +- src/renderer/src/i18n/locales/ja-jp.json | 12 +- src/renderer/src/i18n/locales/ru-ru.json | 12 +- src/renderer/src/i18n/locales/zh-cn.json | 12 +- src/renderer/src/i18n/locales/zh-tw.json | 12 +- .../pages/paintings/PaintingsRoutePage.tsx | 4 +- .../src/pages/paintings/TokenFluxPage.tsx | 786 ++++++++++++++++++ .../components/DynamicFormRender.tsx | 213 +++++ .../pages/paintings/config/tokenFluxConfig.ts | 27 + .../pages/paintings/utils/TokenFluxService.ts | 237 ++++++ src/renderer/src/store/migrate.ts | 3 + src/renderer/src/store/paintings.ts | 11 +- src/renderer/src/types/index.ts | 14 +- tokenflux_painting_page.md | 304 +++++++ 16 files changed, 1655 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/pages/paintings/TokenFluxPage.tsx create mode 100644 src/renderer/src/pages/paintings/components/DynamicFormRender.tsx create mode 100644 src/renderer/src/pages/paintings/config/tokenFluxConfig.ts create mode 100644 src/renderer/src/pages/paintings/utils/TokenFluxService.ts create mode 100644 tokenflux_painting_page.md diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index de68dd248d..1733bc6068 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -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' diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index cf7dbc3cee..47528622f2 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -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 })) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 593ed6f95b..2e8d29bda5 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c1341a90d3..3238be665e 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "この概念を説明してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 019726f8d3..8fd2f91599 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Объясните мне этот концепт", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0d04e66e72..228e738dc2 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "帮我解释一下这个概念", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 73867953de..02f02bf421 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "幫我解釋一下這個概念", diff --git a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx index 4ae0041faf..6e943a342f 100644 --- a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx +++ b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx @@ -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 = () => { } /> } /> } /> + } /> ) } diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx new file mode 100644 index 0000000000..85a39df438 --- /dev/null +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -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([]) + const [selectedModel, setSelectedModel] = useState(null) + const [formData, setFormData] = useState>({}) + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(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( + 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(null) + const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')! + const textareaRef = useRef(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) => { + 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) => { + 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, 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 ( + + + {t('paintings.title')} + {isMac && ( + + + + )} + + + + {/* Provider Section */} + + {t('common.provider')} + + {t('paintings.learn_more')} + + + + + + + {/* Model & Pricing Section */} + + {t('paintings.model_and_pricing')} + {selectedModel && selectedModel.pricing && ( + + + {selectedModel.pricing.price} {selectedModel.pricing.currency}{' '} + {selectedModel.pricing.unit > 1 ? t('paintings.per_images') : t('paintings.per_image')} + + + )} + + + + {/* Input Parameters Section */} + {selectedModel && selectedModel.input_schema && ( + <> + {t('paintings.input_parameters')} + + {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 ( + + + + {readI18nContext(property, 'title')} + {isRequired && *} + + {property.description && ( + + + + )} + + + + ) + })} + + + )} + + + + {/* Check if any form field contains an uploaded image */} + {Object.keys(formData).some((key) => key.toLowerCase().includes('image') && formData[key]) ? ( + + + {t('paintings.input_image')} + + {Object.entries(formData).map(([key, value]) => { + if (key.toLowerCase().includes('image') && value) { + return ( + + {t('paintings.uploaded_input')} + + ) + } + return null + })} + + + + {t('paintings.generated_image')} + + + + ) : ( + + )} + +