From 749a4f46792977fa1b47b9ec444d5407dc6e1f4a Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Mon, 20 Oct 2025 15:41:34 +0800 Subject: [PATCH] feat: new painting provider: intel ovms (#10570) * new painting provider: intel ovms Signed-off-by: Ma, Kejiang Signed-off-by: Kejiang Ma * cherryin -> cherryai Signed-off-by: Kejiang Ma * ovms painting only valid when ovms is running Signed-off-by: Kejiang Ma * fix: painting(ovms) still appear while ovms is not running after rebase Signed-off-by: Kejiang Ma * fix warning in PaintingRoute Signed-off-by: Kejiang Ma * add ovms_paintings in migrate config 163 --------- Signed-off-by: Ma, Kejiang Signed-off-by: Kejiang Ma --- src/renderer/src/hooks/usePaintings.ts | 2 + src/renderer/src/pages/paintings/OvmsPage.tsx | 692 ++++++++++++++++++ .../pages/paintings/PaintingsRoutePage.tsx | 36 +- .../src/pages/paintings/config/ovmsConfig.tsx | 129 ++++ src/renderer/src/store/migrate.ts | 4 +- src/renderer/src/store/paintings.ts | 4 +- src/renderer/src/types/index.ts | 16 +- 7 files changed, 868 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/pages/paintings/OvmsPage.tsx create mode 100644 src/renderer/src/pages/paintings/config/ovmsConfig.tsx diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index ebf73e97c5..e7c7f22359 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -14,6 +14,7 @@ export function usePaintings() { const aihubmix_image_upscale = useAppSelector((state) => state.paintings.aihubmix_image_upscale) const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate) const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit) + const ovms_paintings = useAppSelector((state) => state.paintings.ovms_paintings) const dispatch = useAppDispatch() return { @@ -27,6 +28,7 @@ export function usePaintings() { aihubmix_image_upscale, openai_image_generate, openai_image_edit, + ovms_paintings, addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => { dispatch(addPainting({ namespace, painting })) return painting diff --git a/src/renderer/src/pages/paintings/OvmsPage.tsx b/src/renderer/src/pages/paintings/OvmsPage.tsx new file mode 100644 index 0000000000..5cd42e3052 --- /dev/null +++ b/src/renderer/src/pages/paintings/OvmsPage.tsx @@ -0,0 +1,692 @@ +import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import { loggerService } from '@logger' +import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' +import { HStack } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' +import { isMac } from '@renderer/config/constant' +import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' +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 { getProviderLabel } from '@renderer/i18n/label' +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 { FileMetadata, OvmsPainting } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { Avatar, Button, Input, InputNumber, Select, Slider, Switch, 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 PaintingsList from './components/PaintingsList' +import { + type ConfigItem, + createDefaultOvmsPainting, + createOvmsConfig, + DEFAULT_OVMS_PAINTING, + getOvmsModels, + OVMS_MODELS +} from './config/ovmsConfig' + +const logger = loggerService.withContext('OvmsPage') + +const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => { + const { addPainting, removePainting, updatePainting, ovms_paintings } = usePaintings() + const ovmsPaintings = useMemo(() => ovms_paintings || [], [ovms_paintings]) + const [painting, setPainting] = useState(ovmsPaintings[0] || DEFAULT_OVMS_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 [availableModels, setAvailableModels] = useState>([]) + const [ovmsConfig, setOvmsConfig] = useState([]) + + const { t } = useTranslation() + const providers = useAllProviders() + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + if (provider) { + return { + label: getProviderLabel(provider.id), + value: provider.id + } + } else { + return { + label: 'Unknown Provider', + value: undefined + } + } + }) + const dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + const { autoTranslateWithSpace } = useSettings() + const spaceClickTimer = useRef(null) + const ovmsProvider = providers.find((p) => p.id === 'ovms')! + + const getNewPainting = useCallback(() => { + if (availableModels.length > 0) { + return createDefaultOvmsPainting(availableModels) + } + return { + ...DEFAULT_OVMS_PAINTING, + id: uuid() + } + }, [availableModels]) + + const textareaRef = useRef(null) + + // Load available models on component mount + useEffect(() => { + const loadModels = () => { + try { + // Get OVMS provider to access its models + const ovmsProvider = providers.find((p) => p.id === 'ovms') + const providerModels = ovmsProvider?.models || [] + + // Filter and format models for image generation + const filteredModels = getOvmsModels(providerModels) + setAvailableModels(filteredModels) + setOvmsConfig(createOvmsConfig(filteredModels)) + + // Update painting if it doesn't have a valid model + if (filteredModels.length > 0 && !filteredModels.some((m) => m.value === painting.model)) { + const defaultPainting = createDefaultOvmsPainting(filteredModels) + setPainting(defaultPainting) + } + } catch (error) { + logger.error(`Failed to load OVMS models: ${error}`) + // Use default config if loading fails + setOvmsConfig(createOvmsConfig()) + } + } + + loadModels() + }, [providers, painting.model]) // Re-run when providers change + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting('ovms_paintings', updatedPainting) + } + + 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()) { + logger.error('Image URL is empty, possibly due to prohibited prompt') + window.toast.warning(t('message.empty_url')) + return null + } + return await window.api.file.download(url) + } catch (error) { + logger.error(`Failed to download image: ${error}`) + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.toast.warning(t('message.empty_url')) + } + 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 (!painting.model || !painting.prompt) { + return + } + + const controller = new AbortController() + setAbortController(controller) + setIsLoading(true) + dispatch(setGenerating(true)) + + try { + // Prepare request body for OVMS + const requestBody = { + model: painting.model, + prompt: painting.prompt, + size: painting.size || '512x512', + num_inference_steps: painting.num_inference_steps || 4, + rng_seed: painting.rng_seed || 0 + } + + logger.info('OVMS API request:', requestBody) + + const response = await fetch(`${ovmsProvider.apiHost}images/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody), + signal: controller.signal + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } })) + logger.error('OVMS API error:', errorData) + throw new Error(errorData.error?.message || 'Image generation failed') + } + + const data = await response.json() + logger.info('OVMS API response:', data) + + // Handle base64 encoded images + if (data.data && data.data.length > 0) { + const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json) + + 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) }) + } + + // Handle URL-based images if available + const urls = data.data.filter((item) => item.url).map((item) => item.url) + + if (urls.length > 0) { + const validFiles = await downloadImages(urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + } + } catch (error: unknown) { + handleError(error) + } finally { + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } + + const handleRetry = async (painting: OvmsPainting) => { + 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('ovms_paintings', getNewPainting()) + updatePainting('ovms_paintings', newPainting) + setPainting(newPainting) + return newPainting + } + + const onDeletePainting = (paintingToDelete: OvmsPainting) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = ovmsPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(ovmsPaintings[currentIndex - 1]) + } else if (ovmsPaintings.length > 1) { + setPainting(ovmsPaintings[1]) + } + } + + removePainting('ovms_paintings', 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) { + logger.error('Translation failed:', error as 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 }) + } + } + + // Handle random seed generation + const handleRandomSeed = () => { + const randomSeed = Math.floor(Math.random() * 2147483647) + updatePaintingState({ rng_seed: randomSeed }) + return randomSeed + } + + // Render configuration form + const renderConfigForm = (item: ConfigItem) => { + switch (item.type) { + case 'select': { + const isDisabled = typeof item.disabled === 'function' ? item.disabled(item, painting) : item.disabled + const selectOptions = + typeof item.options === 'function' + ? item.options(item, painting).map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + : item.options?.map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + + return ( + updatePaintingState({ [item.key!]: e.target.value })} + suffix={ + item.key === 'rng_seed' ? ( + + ) : ( + item.suffix + ) + } + /> + ) + case 'inputNumber': + return ( + updatePaintingState({ [item.key!]: v })} + /> + ) + case 'textarea': + return ( +