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')} + + + + ) : ( + + )} + +