diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index f055bdc5fb..9b097e96ef 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -386,7 +386,11 @@ class FileStorage { } } - public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise => { + public downloadFile = async ( + _: Electron.IpcMainInvokeEvent, + url: string, + isUseContentType?: boolean + ): Promise => { try { const response = await fetch(url) if (!response.ok) { @@ -411,7 +415,7 @@ class FileStorage { } // 如果文件名没有后缀,根据Content-Type添加后缀 - if (!filename.includes('.')) { + if (isUseContentType || !filename.includes('.')) { const contentType = response.headers.get('Content-Type') const ext = this.getExtensionFromMimeType(contentType) filename += ext diff --git a/src/preload/index.ts b/src/preload/index.ts index 81174d22d0..70aae1b18e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -74,7 +74,7 @@ const api = { selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), - download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url), + download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), diff --git a/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp new file mode 100644 index 0000000000..6d18ed8dbe Binary files /dev/null and b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp differ diff --git a/src/renderer/src/assets/images/providers/dmxapi-logo.webp b/src/renderer/src/assets/images/providers/dmxapi-logo.webp new file mode 100644 index 0000000000..ba933a09b7 Binary files /dev/null and b/src/renderer/src/assets/images/providers/dmxapi-logo.webp differ diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index ed41ce87c2..cf7dbc3cee 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -9,10 +9,12 @@ export function usePaintings() { const remix = useAppSelector((state) => state.paintings.remix) const edit = useAppSelector((state) => state.paintings.edit) const upscale = useAppSelector((state) => state.paintings.upscale) + const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings) const dispatch = useAppDispatch() return { paintings, + DMXAPIPaintings, persistentData: { generate, remix, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f39d9a33db..7579c9d474 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -813,6 +813,7 @@ "regenerate.confirm": "This will replace your existing generated images. Do you want to continue?", "seed": "Seed", "seed_tip": "The same seed and prompt can produce similar images", + "seed_desc_tip": "The same seed and prompt can generate similar images, setting -1 will generate different results each time", "title": "Images", "magic_prompt_option": "Magic Prompt", "model": "Model Version", @@ -820,6 +821,7 @@ "style_type": "Style", "rendering_speed": "Rendering Speed", "learn_more": "Learn More", + "paint_course":"tutorial", "prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", "proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future", "image_file_required": "Please upload an image first", @@ -885,7 +887,8 @@ "number_images_tip": "Number of upscaled results to generate", "seed_tip": "Controls upscaling randomness", "magic_prompt_option_tip": "Intelligently enhances upscaling prompts" - } + }, + "text_desc_required": "Please enter image description first" }, "prompts": { "explanation": "Explain this concept to me", @@ -1567,6 +1570,9 @@ "rate_limit": "Rate limiting", "tooltip": "You need to log in to Github before using Github Copilot" }, + "dmxapi": { + "select_platform": "Select the platform" + }, "delete.content": "Are you sure you want to delete this provider?", "delete.title": "Delete Provider", "docs_check": "Check", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 3dd47765c1..140ea19a16 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -813,6 +813,7 @@ "regenerate.confirm": "これにより、既存の生成画像が置き換えられます。続行しますか?", "seed": "シード", "seed_tip": "同じシードとプロンプトで似た画像を生成できます", + "seed_desc_tip": "同じシードとプロンプトで類似した画像を生成できますが、-1 に設定すると毎回異なる結果が生成されます", "title": "画像", "magic_prompt_option": "プロンプト強化", "model": "モデルバージョン", @@ -820,6 +821,7 @@ "style_type": "スタイル", "learn_more": "詳しくはこちら", "prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します", + "paint_course":"チュートリアル", "proxy_required": "現在、プロキシを開く必要があります。これは、将来サポートされる予定です", "image_file_required": "画像を先にアップロードしてください", "image_file_retry": "画像を先にアップロードしてください", @@ -885,7 +887,8 @@ "magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します" }, "rendering_speed": "レンダリング速度", - "translating": "翻訳中..." + "translating": "翻訳中...", + "text_desc_required": "画像の説明を先に入力してください" }, "prompts": { "explanation": "この概念を説明してください", @@ -1554,6 +1557,9 @@ "rate_limit": "レート制限", "tooltip": "Github Copilot を使用するには、まず Github にログインする必要があります。" }, + "dmxapi": { + "select_platform": "プラットフォームを選択" + }, "delete.content": "このプロバイダーを削除してもよろしいですか?", "delete.title": "プロバイダーを削除", "docs_check": "チェック", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index b5765087b8..fc5a35278c 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -813,6 +813,7 @@ "regenerate.confirm": "Это заменит ваши существующие сгенерированные изображения. Хотите продолжить?", "seed": "Ключ генерации", "seed_tip": "Одинаковый ключ генерации и промпт могут производить похожие изображения", + "seed_desc_tip": "Одинаковые сиды и промпты могут генерировать похожие изображения, установка -1 будет создавать разные результаты каждый раз", "title": "Изображения", "magic_prompt_option": "Улучшение промпта", "model": "Версия", @@ -821,6 +822,7 @@ "rendering_speed": "Скорость рендеринга", "learn_more": "Узнать больше", "prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки", + "paint_course":"Руководство / Учебник", "proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение", "image_file_required": "Пожалуйста, сначала загрузите изображение", "image_file_retry": "Пожалуйста, сначала загрузите изображение", @@ -885,7 +887,9 @@ "number_images_tip": "Количество увеличенных результатов для генерации", "seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов", "magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов" - } + }, + "rendering_speed": "Скорость рендеринга", + "text_desc_required": "Пожалуйста, сначала введите описание изображения" }, "prompts": { "explanation": "Объясните мне этот концепт", @@ -1554,6 +1558,9 @@ "rate_limit": "Ограничение скорости", "tooltip": "Для использования Github Copilot необходимо сначала войти в Github." }, + "dmxapi": { + "select_platform": "Выберите платформу" + }, "delete.content": "Вы уверены, что хотите удалить этот провайдер?", "delete.title": "Удалить провайдер", "docs_check": "Проверить", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 142683ee8a..5fb9880ee3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -813,6 +813,7 @@ "regenerate.confirm": "这将覆盖已生成的图片,是否继续?", "seed": "随机种子", "seed_tip": "相同的种子和提示词可以生成相似的图片", + "seed_desc_tip": "相同的种子和提示词可以生成相似的图片,设置 -1 每次生成都不一样", "title": "图片", "magic_prompt_option": "提示词增强", "model": "版本", @@ -820,6 +821,7 @@ "style_type": "风格", "rendering_speed": "渲染速度", "learn_more": "了解更多", + "paint_course":"教程", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", "proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连", "image_file_required": "请先上传图片", @@ -885,7 +887,8 @@ "number_images_tip": "生成的放大结果数量", "seed_tip": "控制放大结果的随机性", "magic_prompt_option_tip": "智能优化放大提示词" - } + }, + "text_desc_required": "请先输入图片描述" }, "prompts": { "explanation": "帮我解释一下这个概念", @@ -1567,6 +1570,9 @@ "rate_limit": "速率限制", "tooltip": "使用 Github Copilot 需要先登录 Github" }, + "dmxapi": { + "select_platform": "选择平台" + }, "delete.content": "确定要删除此模型提供商吗?", "delete.title": "删除提供商", "docs_check": "查看", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f3ee8c49f6..4cc142714b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -814,6 +814,7 @@ "regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?", "seed": "隨機種子", "seed_tip": "相同的種子和提示詞可以生成相似的圖片", + "seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣", "title": "繪圖", "magic_prompt_option": "提示詞增強", "model": "版本", @@ -821,6 +822,7 @@ "style_type": "風格", "learn_more": "了解更多", "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹", + "paint_course":"教程", "proxy_required": "目前需要打開代理才能查看生成圖片,後續會支持國內直連", "image_file_required": "請先上傳圖片", "image_file_retry": "請重新上傳圖片", @@ -886,7 +888,8 @@ "seed_tip": "控制放大結果的隨機性", "magic_prompt_option_tip": "智能優化放大提示詞" }, - "rendering_speed": "渲染速度" + "rendering_speed": "渲染速度", + "text_desc_required": "請先輸入圖片描述" }, "prompts": { "explanation": "幫我解釋一下這個概念", @@ -1558,6 +1561,9 @@ "rate_limit": "速率限制", "tooltip": "使用 Github Copilot 需要先登入 Github" }, + "dmxapi": { + "select_platform": "選擇平臺" + }, "delete.content": "確定要刪除此提供者嗎?", "delete.title": "刪除提供者", "docs_check": "檢查", diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx new file mode 100644 index 0000000000..955fb4a022 --- /dev/null +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -0,0 +1,702 @@ +import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import DMXAPIToImg from '@renderer/assets/images/providers/DMXAPI-to-img.webp' +import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' +import { VStack } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' +import { isMac } from '@renderer/config/constant' +import { getProviderLogo } from '@renderer/config/providers' +import { useTheme } from '@renderer/context/ThemeProvider' +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 { FileType, PaintingsState } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { DmxapiPainting, PaintingAction } from '@types' +import { Avatar, Button, Input, Radio, Select, Tooltip } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import { Info } from 'lucide-react' +import React, { FC } from 'react' +import { useEffect, 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 './Artboard' +import { + COURSE_URL, + DEFAULT_PAINTING, + IMAGE_SIZES, + STYLE_TYPE_OPTIONS, + TEXT_TO_IMAGES_MODELS +} from './config/DmxapiConfig' +import PaintingsList from './PaintingsList' + +const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString() + +const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { + const [mode] = useState('DMXAPIPaintings') + const { DMXAPIPaintings, addPainting, removePainting, updatePainting } = usePaintings() + const [painting, setPainting] = useState(DMXAPIPaintings?.[0] || DEFAULT_PAINTING) + const { theme } = useTheme() + const { t } = useTranslation() + const providers = useAllProviders() + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + return { + label: t(`provider.${provider?.id}`), + value: provider?.id + } + }) + + const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')! + + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(null) + const dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + + const getNewPainting = () => { + return { + ...DEFAULT_PAINTING, + id: uuid(), + seed: generateRandomSeed() + } + } + + const modelOptions = TEXT_TO_IMAGES_MODELS.map((model) => ({ + label: model.name, + value: model.id + })) + + const textareaRef = useRef(null) + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting('DMXAPIPaintings', updatedPainting) + } + + const onSelectModel = (modelId: string) => { + const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === modelId) + if (model) { + updatePaintingState({ model: modelId }) + } + } + + const onCancel = () => { + abortController?.abort() + } + + const onSelectImageSize = (v: string) => { + const size = IMAGE_SIZES.find((i) => i.value === v) + size && updatePaintingState({ image_size: size.value, aspect_ratio: size.label }) + } + + const onSelectStyleType = (v: string) => { + if (v === painting.style_type) { + updatePaintingState({ style_type: '' }) + } else { + updatePaintingState({ style_type: v }) + } + } + + const onInputSeed = (e: React.ChangeEvent) => { + const value = e.target.value + // 允许空值或合法整数,且大于等于 -1 + if (value === '' || value === '-' || /^-?\d+$/.test(value)) { + const numValue = parseInt(value, 10) + + if (numValue >= -1 || value === '' || value === '-') { + updatePaintingState({ seed: value }) + } + } + } + + // 检查提供者状态函数 + const checkProviderStatus = () => { + if (!dmxapiProvider.enabled) { + throw new Error('error.provider_disabled') + } + + if (!dmxapiProvider.apiKey) { + throw new Error('error.no_api_key') + } + + if (!painting.model) { + throw new Error('error.missing_required_fields') + } + + if (!painting.prompt) { + throw new Error('paintings.text_desc_required') + } + } + + // 准备V1生成请求函数 + const prepareV1GenerateRequest = (prompt: string, painting: DmxapiPainting) => { + const params = { + prompt, + model: painting.model, + n: painting.n + } + + if (painting.aspect_ratio) { + params['aspect_ratio'] = painting.aspect_ratio + } + + if (painting.image_size) { + params['size'] = painting.image_size + } + + if (painting.seed) { + if (Number(painting.seed) >= -1) { + params['seed'] = Number(painting.seed) + } else { + params['seed'] = -1 + } + } + + if (painting.style_type) { + params.prompt = prompt + ',风格:' + painting.style_type + } + + return { + body: JSON.stringify(params), + endpoint: `${dmxapiProvider.apiHost}/v1/images/generations` + } + } + + // API请求函数 + const callApi = async (requestConfig: { endpoint: string; body: any }) => { + const { endpoint, body } = requestConfig + const headers = {} + + // 如果是JSON数据,添加Content-Type头 + if (typeof body === 'string') { + headers['Content-Type'] = 'application/json' + headers['Authorization'] = `Bearer ${dmxapiProvider.apiKey}` + headers['User-Agent'] = 'DMXAPI/1.0.0 (https://www.dmxapi.com)' + headers['Accept'] = 'application/json' + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error?.message || '操作失败') + } + + const data = await response.json() + return data.data.map((item: { url: string }) => item.url) + } + + // 下载图像函数 + const downloadImages = async (urls: string[]) => { + return Promise.all( + urls.map(async (url) => { + try { + if (!url || url.trim() === '') { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url, true) + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + } + + // 准备请求配置函数 + const prepareRequestConfig = (prompt: string, painting: PaintingAction) => { + // 根据模式和模型版本返回不同的请求配置 + return prepareV1GenerateRequest(prompt, painting) + } + + const onGenerate = async () => { + try { + // 获取提示词 + const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' + updatePaintingState({ prompt }) + + // 检查提供者状态 + checkProviderStatus() + + // 处理已有文件 + 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 controller = new AbortController() + setAbortController(controller) + setIsLoading(true) + dispatch(setGenerating(true)) + + // 准备请求配置 + const requestConfig = prepareRequestConfig(prompt, painting) + + // 发送API请求 + const urls = await callApi(requestConfig) + + // 下载图像 + if (urls.length > 0) { + const downloadedFiles = await downloadImages(urls) + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + + // 保存文件并更新状态 + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + } catch (error) { + // 错误处理 + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: + error.message.startsWith('paintings.') || error.message.startsWith('error.') + ? t(error.message) + : getErrorMessage(error), + centered: true + }) + } + } finally { + // 清理状态 + 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 onDeletePainting = (paintingToDelete: DmxapiPainting) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = DMXAPIPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(DMXAPIPaintings[currentIndex - 1]) + } else if (DMXAPIPaintings.length > 1) { + setPainting(DMXAPIPaintings[1]) + } + } + + removePainting(mode, paintingToDelete).then(() => {}) + } + + const onSelectPainting = (newPainting: DmxapiPainting) => { + if (generating) return + setPainting(newPainting) + setCurrentImageIndex(0) + } + + const { autoTranslateWithSpace } = useSettings() + const [spaceClickCount, setSpaceClickCount] = useState(0) + const [isTranslating, setIsTranslating] = useState(false) + const spaceClickTimer = useRef(null) + + 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().then(() => {}) + } + } + } + + const handleProviderChange = (providerId: string) => { + const routeName = location.pathname.split('/').pop() + if (providerId !== routeName) { + navigate('../' + providerId, { replace: true }) + } + } + + useEffect(() => { + if (!DMXAPIPaintings || DMXAPIPaintings.length === 0) { + const newPainting = getNewPainting() + addPainting('DMXAPIPaintings', newPainting) + setPainting(newPainting) + } + + return () => { + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + } + }, [DMXAPIPaintings, DMXAPIPaintings.length, addPainting, mode]) + + return ( + + + {t('paintings.title')} + {isMac && ( + + + + )} + + + + + {t('common.provider')} + + {t('paintings.paint_course')} + + + + + {t('common.model')} + onInputSeed(e)} + suffix={ + updatePaintingState({ seed: Math.floor(Math.random() * 1000000).toString() })} + style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} + /> + } + /> + + {t('paintings.style_type')} + + + {STYLE_TYPE_OPTIONS.map((ele) => ( + onSelectStyleType(ele.label)}> + {ele.label} + + ))} + + + + + {painting?.urls?.length > 0 || DMXAPIPaintings?.length > 1 ? ( + + ) : ( + + + + )} + + +