From a343377a43860b7cbd6d5f25c09e6efe01390d10 Mon Sep 17 00:00:00 2001 From: Calcium-Ion <61247483+Calcium-Ion@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:27:53 +0800 Subject: [PATCH] feat: add painting support for NewAPI provider (#7905) * feat: add NewAPI painting support * fix(NewApiPage): update help link to point to the correct documentation * feat(NewApiPage): support image generation in API client * fix: resolve the issue of messy drawing data from aihubmix provider * feat: group model options in dropdown by category * fix: update translation to use LanguagesEnum --- .../src/aiCore/clients/NewAPIClient.ts | 2 +- src/renderer/src/config/endpointTypes.ts | 10 + src/renderer/src/hooks/usePaintings.ts | 6 + src/renderer/src/i18n/locales/en-us.json | 13 + src/renderer/src/i18n/locales/ja-jp.json | 13 + src/renderer/src/i18n/locales/ru-ru.json | 13 + src/renderer/src/i18n/locales/zh-cn.json | 13 + src/renderer/src/i18n/locales/zh-tw.json | 13 + .../src/pages/paintings/NewApiPage.tsx | 830 ++++++++++++++++++ .../pages/paintings/PaintingsRoutePage.tsx | 4 +- .../pages/paintings/config/NewApiConfig.ts | 47 + .../ProviderSettings/ModelEditContent.tsx | 11 +- .../ProviderSettings/NewApiAddModelPopup.tsx | 18 +- .../NewApiBatchAddModelPopup.tsx | 11 +- src/renderer/src/store/paintings.ts | 4 +- src/renderer/src/types/index.ts | 6 +- 16 files changed, 991 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/config/endpointTypes.ts create mode 100644 src/renderer/src/pages/paintings/NewApiPage.tsx create mode 100644 src/renderer/src/pages/paintings/config/NewApiConfig.ts diff --git a/src/renderer/src/aiCore/clients/NewAPIClient.ts b/src/renderer/src/aiCore/clients/NewAPIClient.ts index 3162cad0fe..769ca90acf 100644 --- a/src/renderer/src/aiCore/clients/NewAPIClient.ts +++ b/src/renderer/src/aiCore/clients/NewAPIClient.ts @@ -106,7 +106,7 @@ export class NewAPIClient extends BaseApiClient { return client } - if (model.endpoint_type === 'openai') { + if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') { const client = this.clients.get('openai') if (!client || !this.isValidClient(client)) { throw new Error('Failed to get openai client') diff --git a/src/renderer/src/config/endpointTypes.ts b/src/renderer/src/config/endpointTypes.ts new file mode 100644 index 0000000000..4340e5628f --- /dev/null +++ b/src/renderer/src/config/endpointTypes.ts @@ -0,0 +1,10 @@ +import { EndpointType } from '@renderer/types' + +export const endpointTypeOptions: { label: string; value: EndpointType }[] = [ + { value: 'openai', label: 'endpoint_type.openai' }, + { value: 'openai-response', label: 'endpoint_type.openai-response' }, + { value: 'anthropic', label: 'endpoint_type.anthropic' }, + { value: 'gemini', label: 'endpoint_type.gemini' }, + { value: 'image-generation', label: 'endpoint_type.image-generation' }, + { value: 'jina-rerank', label: 'endpoint_type.jina-rerank' } +] diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index 47528622f2..79f2277df6 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -11,6 +11,8 @@ export function usePaintings() { const upscale = useAppSelector((state) => state.paintings.upscale) const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings) const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings) + const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate) + const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit) const dispatch = useAppDispatch() return { @@ -24,6 +26,10 @@ export function usePaintings() { upscale, tokenFluxPaintings }, + newApiPaintings: { + openai_image_generate, + openai_image_edit + }, addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => { dispatch(addPainting({ namespace, painting })) return painting diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d80fa4e2b6..8d6184b132 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -471,6 +471,14 @@ "messages": "Messages", "user": "User" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "Image Generation", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "Actions", "all": "All Files", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "No available image generation model, please add a model and set the endpoint type to {{endpoint_type}}", + "go_to_settings": "Go to Settings", "button.delete.image": "Delete Image", "button.delete.image.confirm": "Are you sure you want to delete this image?", "button.new.image": "New Image", @@ -941,6 +951,9 @@ "allow_adult": "Allow adult", "allow_none": "Not allowed" }, + "image_size_options": { + "auto": "Auto" + }, "quality": "Quality", "moderation": "Moderation", "background": "Background", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5012193624..b3596d7b7a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -471,6 +471,14 @@ "messages": "メッセージ", "user": "ユーザー" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "画像生成", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "操作", "all": "すべてのファイル", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "利用可能な画像生成モデルがありません。モデルを追加し、エンドポイントタイプを {{endpoint_type}} に設定してください", + "go_to_settings": "設定に移動", "button.delete.image": "画像を削除", "button.delete.image.confirm": "この画像を削除してもよろしいですか?", "button.new.image": "新しい画像", @@ -939,6 +949,9 @@ "allow_adult": "許可する", "allow_none": "許可しない" }, + "image_size_options": { + "auto": "自動" + }, "quality": "品質", "moderation": "敏感度", "background": "背景", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index dc6010e5d8..48324a8c4f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -471,6 +471,14 @@ "messages": "Сообщения", "user": "Пользователь" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "Изображение", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "Действия", "all": "Все файлы", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "Нет доступных моделей изображения, пожалуйста, добавьте модель и установите тип конечной точки на {{endpoint_type}}", + "go_to_settings": "Перейти в настройки", "button.delete.image": "Удалить изображение", "button.delete.image.confirm": "Вы уверены, что хотите удалить это изображение?", "button.new.image": "Новое изображение", @@ -940,6 +950,9 @@ "allow_adult": "Разрешено взрослые", "allow_none": "Не разрешено" }, + "image_size_options": { + "auto": "Авто" + }, "quality": "Качество", "moderation": "Сенсорность", "background": "Фон", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b0651eab66..8c4ca4db4c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -471,6 +471,14 @@ "messages": "消息数", "user": "用户" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "图片生成", + "jina-rerank": "Jina 重排序" + }, "files": { "actions": "操作", "all": "所有文件", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "暂无可用的图片生成模型,请先新增模型并设置端点类型为 {{endpoint_type}}", + "go_to_settings": "去设置", "button.delete.image": "删除图片", "button.delete.image.confirm": "确定要删除此图片吗?", "button.new.image": "新建图片", @@ -936,6 +946,9 @@ "allow_adult": "允许成人", "allow_none": "不允许" }, + "image_size_options": { + "auto": "自动" + }, "aspect_ratios": { "square": "方形", "portrait": "竖图", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4272ef4467..d85ea3c5b6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -471,6 +471,14 @@ "messages": "訊息數", "user": "使用者" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "圖片生成", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "操作", "all": "所有檔案", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "暫無可用的圖片生成模型,請先新增模型並設置端點類型為 {{endpoint_type}}", + "go_to_settings": "去設置", "button.delete.image": "刪除繪圖", "button.delete.image.confirm": "確定要刪除此繪圖嗎?", "button.new.image": "新繪圖", @@ -940,6 +950,9 @@ "allow_adult": "允許成人", "allow_none": "不允許" }, + "image_size_options": { + "auto": "自動" + }, "quality": "品質", "moderation": "敏感度", "background": "背景", diff --git a/src/renderer/src/pages/paintings/NewApiPage.tsx b/src/renderer/src/pages/paintings/NewApiPage.tsx new file mode 100644 index 0000000000..030c53d82e --- /dev/null +++ b/src/renderer/src/pages/paintings/NewApiPage.tsx @@ -0,0 +1,830 @@ +import { PlusOutlined } from '@ant-design/icons' +import AiProvider from '@renderer/aiCore' +import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg' +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 { LanguagesEnum } from '@renderer/config/translate' +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 PaintingsList from '@renderer/pages/paintings/components/PaintingsList' +import { + DEFAULT_PAINTING, + getModelGroup, + MODELS, + SUPPORTED_MODELS +} from '@renderer/pages/paintings/config/NewApiConfig' +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 { PaintingAction, PaintingsState } from '@renderer/types' +import { FileMetadata } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { Avatar, Button, Empty, InputNumber, Segmented, Select, Upload } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import React, { 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' + +const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => { + const [mode, setMode] = useState('openai_image_generate') + const { addPainting, removePainting, updatePainting, newApiPaintings } = usePaintings() + const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode]) + const [painting, setPainting] = useState(filteredPaintings[0] || DEFAULT_PAINTING) + 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 [editImageFiles, setEditImageFiles] = useState([]) + + const { t } = useTranslation() + const { theme } = useTheme() + 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 dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + const { autoTranslateWithSpace } = useSettings() + const spaceClickTimer = useRef(null) + const newApiProvider = providers.find((p) => p.id === 'new-api')! + + const modeOptions = [ + { label: t('paintings.mode.generate'), value: 'openai_image_generate' }, + { label: t('paintings.mode.edit'), value: 'openai_image_edit' } + ] + + const textareaRef = useRef(null) + + // 获取编辑模式的图片文件 + const editImages = useMemo(() => { + return editImageFiles + }, [editImageFiles]) + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting(mode, updatedPainting) + } + + // ---------------- Model Related Configurations ---------------- + // const modelOptions = MODELS.map((m) => ({ label: m.name, value: m.name })) + + const modelOptions = useMemo(() => { + const customModels = newApiProvider.models + .filter((m) => m.endpoint_type && m.endpoint_type === 'image-generation') + .map((m) => ({ + label: m.name, + value: m.id, + custom: !SUPPORTED_MODELS.includes(m.id), + group: getModelGroup(m.id) + })) + return [...customModels] + }, [newApiProvider.models]) + + // 根据 group 将模型进行分组,便于在下拉列表中分组渲染 + const groupedModelOptions = useMemo(() => { + return modelOptions.reduce>((acc, option) => { + const groupName = option.group + if (!acc[groupName]) { + acc[groupName] = [] + } + acc[groupName].push(option) + return acc + }, {}) + }, [modelOptions]) + + const getNewPainting = useCallback(() => { + return { + ...DEFAULT_PAINTING, + model: painting.model || modelOptions[0]?.value || '', + id: uuid() + } + }, [modelOptions, painting.model]) + + const selectedModelConfig = useMemo( + () => MODELS.find((m) => m.name === painting.model) || MODELS[0], + [painting.model] + ) + + const handleModelChange = (value: string) => { + const modelConfig = MODELS.find((m) => m.name === value) + const updates: Partial = { model: value } + + // 设置默认值 + if (modelConfig?.imageSizes?.length) { + updates.size = modelConfig.imageSizes[0].value + } + if (modelConfig?.quality?.length) { + updates.quality = modelConfig.quality[0].value + } + if (modelConfig?.moderation?.length) { + updates.moderation = modelConfig.moderation[0].value + } + updates.n = 1 + updatePaintingState(updates) + } + + const handleSizeChange = (value: string) => { + updatePaintingState({ size: value }) + } + + const handleQualityChange = (value: string) => { + updatePaintingState({ quality: value }) + } + + const handleModerationChange = (value: string) => { + updatePaintingState({ moderation: value }) + } + + const handleNChange = (value: number | string | null) => { + if (value !== null && value !== undefined && value !== '') { + updatePaintingState({ n: Number(value) }) + } + } + + const handleError = (error: unknown) => { + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: getErrorMessage(error), + centered: true + }) + } + } + + const downloadImages = async (urls: string[]) => { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + if (!url?.trim()) { + console.error('图像URL为空') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', 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 + } + }) + ) + + return downloadedFiles.filter((file): file is FileMetadata => file !== null) + } + + 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 || '' + updatePaintingState({ prompt }) + + if (!newApiProvider.enabled) { + window.modal.error({ + content: t('error.provider_disabled'), + centered: true + }) + return + } + + const AI = new AiProvider(newApiProvider) + + if (!AI.getApiKey()) { + window.modal.error({ + content: t('error.no_api_key'), + centered: true + }) + return + } + + if (!painting.model || !painting.prompt) { + return + } + + const controller = new AbortController() + setAbortController(controller) + setIsLoading(true) + dispatch(setGenerating(true)) + + let body: string | FormData = '' + const headers: Record = { + Authorization: `Bearer ${AI.getApiKey()}` + } + const url = newApiProvider.apiHost + `/v1/images/generations` + const editUrl = newApiProvider.apiHost + `/v1/images/edits` + + try { + if (mode === 'openai_image_generate') { + const requestData = { + prompt, + model: painting.model, + size: painting.size === 'auto' ? undefined : painting.size, + background: painting.background === 'auto' ? undefined : painting.background, + n: painting.n, + quality: painting.quality === 'auto' ? undefined : painting.quality, + moderation: painting.moderation === 'auto' ? undefined : painting.moderation + } + + body = JSON.stringify(requestData) + headers['Content-Type'] = 'application/json' + } else if (mode === 'openai_image_edit') { + // -------- Edit Mode -------- + if (editImages.length === 0) { + window.message.warning({ content: t('paintings.image_file_required') }) + return + } + + const formData = new FormData() + formData.append('prompt', prompt) + if (painting.background && painting.background !== 'auto') { + formData.append('background', painting.background) + } + + if (painting.size && painting.size !== 'auto') { + formData.append('size', painting.size) + } + + if (painting.quality && painting.quality !== 'auto') { + formData.append('quality', painting.quality) + } + + if (painting.moderation && painting.moderation !== 'auto') { + formData.append('moderation', painting.moderation) + } + + // append images + editImages.forEach((file) => { + formData.append('image', file) + }) + + // TODO: mask support later + + body = formData + + // For edit mode we do not set content-type; browser will set multipart boundary + } + + const requestUrl = mode === 'openai_image_edit' ? editUrl : url + const response = await fetch(requestUrl, { method: 'POST', headers, body }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error?.message || '生成图像失败') + } + + const data = await response.json() + const urls = data.data.filter((item) => item.url).map((item) => item.url) + const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json) + + if (urls.length > 0) { + const validFiles = await downloadImages(urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + + if (base64s?.length > 0) { + const validFiles = await Promise.all( + base64s.map(async (base64) => { + return await window.api.file.saveBase64Image(base64) + }) + ) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) }) + } + } catch (error: unknown) { + handleError(error) + } finally { + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } + + const handleRetry = async (painting: PaintingAction) => { + setIsLoading(true) + try { + const validFiles = await downloadImages(painting.urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: painting.urls }) + } catch (error) { + handleError(error) + } finally { + setIsLoading(false) + } + } + + const onCancel = () => { + abortController?.abort() + } + + 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(mode, getNewPainting()) + updatePainting(mode, newPainting) + setPainting(newPainting) + return newPainting + } + + const onDeletePainting = (paintingToDelete: PaintingAction) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = filteredPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(filteredPaintings[currentIndex - 1]) + } else if (filteredPaintings.length > 1) { + setPainting(filteredPaintings[1]) + } + } + + removePainting(mode, paintingToDelete) + } + + const translate = async () => { + if (isTranslating) { + return + } + + if (!painting.prompt) { + return + } + + try { + setIsTranslating(true) + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) + 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 handleModeChange = (value: string) => { + setMode(value as keyof PaintingsState) + if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) { + setPainting(newApiPaintings[value as keyof PaintingsState][0]) + } else { + setPainting(DEFAULT_PAINTING) + } + } + + // 渲染配置项的函数 + const onSelectPainting = (newPainting: PaintingAction) => { + if (generating) return + setPainting(newPainting) + setCurrentImageIndex(0) + } + + const handleImageUpload = (file: File) => { + setEditImageFiles((prev) => [...prev, file]) + return false // 阻止默认上传行为 + } + + // 当 modelOptions 为空时,引导用户跳转到 Provider 设置页面,新增 image-generation 端点模型 + const handleShowAddModelPopup = () => { + navigate(`/settings/provider?id=${newApiProvider.id}`) + } + + useEffect(() => { + if (filteredPaintings.length === 0) { + const newPainting = getNewPainting() + addPainting(mode, newPainting) + setPainting(newPainting) + } + }, [filteredPaintings, mode, addPainting, painting, getNewPainting]) + + useEffect(() => { + const timer = spaceClickTimer.current + return () => { + if (timer) { + clearTimeout(timer) + } + } + }, []) + + return ( + + + {t('paintings.title')} + {isMac && ( + + + + )} + + + + + {t('common.provider')} + + {t('paintings.learn_more')} + + + + + + + {/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */} + {modelOptions.length === 0 && ( + + + + )} + + {modelOptions.length > 0 && ( + <> + {mode === 'openai_image_edit' && ( + <> + {t('paintings.input_image')} + + + + + + + )} + + {/* Model Selector */} + {t('paintings.model')} + + + {/* Image Size */} + {selectedModelConfig?.imageSizes && selectedModelConfig.imageSizes.length > 0 && ( + <> + {t('paintings.image.size')} + + + )} + + {/* Quality */} + {selectedModelConfig?.quality && selectedModelConfig.quality.length > 0 && ( + <> + {t('paintings.quality')} + + + )} + + {/* Moderation */} + {mode !== 'openai_image_edit' && + selectedModelConfig?.moderation && + selectedModelConfig.moderation.length > 0 && ( + <> + {t('paintings.moderation')} + + + )} + + {/* Background */} + {mode === 'openai_image_edit' && + selectedModelConfig?.background && + selectedModelConfig.background.length > 0 && ( + <> + {t('paintings.background')} + + + )} + + {/* Number of Images (n) */} + {selectedModelConfig?.max_images && ( + <> + {t('paintings.number_images')} + + + )} + + )} + + + {/* 添加功能切换分段控制器 */} + + + + + +