diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 030473a2..9ec9be91 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -14,6 +14,7 @@ import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HistoryPage from './pages/history/HistoryPage'
import HomePage from './pages/home/HomePage'
+import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@@ -30,6 +31,7 @@ function App(): JSX.Element {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/renderer/src/assets/images/paintings/image-size-1-1.svg b/src/renderer/src/assets/images/paintings/image-size-1-1.svg
new file mode 100644
index 00000000..b463b0fe
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-1-1.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/images/paintings/image-size-1-2.svg b/src/renderer/src/assets/images/paintings/image-size-1-2.svg
new file mode 100644
index 00000000..6b20097c
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-1-2.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/images/paintings/image-size-16-9.svg b/src/renderer/src/assets/images/paintings/image-size-16-9.svg
new file mode 100644
index 00000000..5ccef2c5
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-16-9.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/images/paintings/image-size-3-2.svg b/src/renderer/src/assets/images/paintings/image-size-3-2.svg
new file mode 100644
index 00000000..49ec7d50
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-3-2.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/images/paintings/image-size-3-4.svg b/src/renderer/src/assets/images/paintings/image-size-3-4.svg
new file mode 100644
index 00000000..ab632866
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-3-4.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/images/paintings/image-size-9-16.svg b/src/renderer/src/assets/images/paintings/image-size-9-16.svg
new file mode 100644
index 00000000..2d6964fb
--- /dev/null
+++ b/src/renderer/src/assets/images/paintings/image-size-9-16.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss
index 57c9cacf..28590852 100644
--- a/src/renderer/src/assets/styles/ant.scss
+++ b/src/renderer/src/assets/styles/ant.scss
@@ -1,4 +1,4 @@
-#inputbar .ant-input {
+#inputbar {
resize: none;
}
diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx
index fad745e3..53c9b548 100644
--- a/src/renderer/src/components/app/Sidebar.tsx
+++ b/src/renderer/src/components/app/Sidebar.tsx
@@ -1,4 +1,4 @@
-import { FileSearchOutlined, FolderOutlined, TranslationOutlined } from '@ant-design/icons'
+import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import useAvatar from '@renderer/hooks/useAvatar'
@@ -58,6 +58,11 @@ const Sidebar: FC = () => {
+ to('/paintings')}>
+
+
+
+
to('/translate')}>
diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts
index 198060c1..0b8d8dd5 100644
--- a/src/renderer/src/config/models.ts
+++ b/src/renderer/src/config/models.ts
@@ -930,6 +930,51 @@ export const SYSTEM_MODELS: Record = {
]
}
+export const TEXT_TO_IMAGES_MODELS = [
+ {
+ id: 'black-forest-labs/FLUX.1-dev',
+ provider: 'silicon',
+ name: 'FLUX.1-dev',
+ group: 'FLUX'
+ },
+ {
+ id: 'black-forest-labs/FLUX.1-schnell',
+ provider: 'silicon',
+ name: 'FLUX.1-schnell',
+ group: 'FLUX'
+ },
+ {
+ id: 'Pro/black-forest-labs/FLUX.1-schnell',
+ provider: 'silicon',
+ name: 'FLUX.1-schnell Pro',
+ group: 'FLUX'
+ },
+ {
+ id: 'stabilityai/stable-diffusion-3-5-large',
+ provider: 'silicon',
+ name: 'Stable Diffusion 3.5 Large',
+ group: 'Stable Diffusion'
+ },
+ {
+ id: 'stabilityai/stable-diffusion-3-medium',
+ provider: 'silicon',
+ name: 'Stable Diffusion 3 Medium',
+ group: 'Stable Diffusion'
+ },
+ {
+ id: 'stabilityai/stable-diffusion-2-1',
+ provider: 'silicon',
+ name: 'Stable Diffusion 2.1',
+ group: 'Stable Diffusion'
+ },
+ {
+ id: 'stabilityai/stable-diffusion-xl-base-1.0',
+ provider: 'silicon',
+ name: 'Stable Diffusion XL Base 1.0',
+ group: 'Stable Diffusion'
+ }
+]
+
export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id)
}
diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts
new file mode 100644
index 00000000..c7f11014
--- /dev/null
+++ b/src/renderer/src/hooks/usePaintings.ts
@@ -0,0 +1,40 @@
+import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
+import { useAppDispatch, useAppSelector } from '@renderer/store'
+import { addPainting, removePainting, updatePainting, updatePaintings } from '@renderer/store/paintings'
+import { Painting } from '@renderer/types'
+import { uuid } from '@renderer/utils'
+
+export function usePaintings() {
+ const paintings = useAppSelector((state) => state.paintings.paintings)
+ const dispatch = useAppDispatch()
+
+ return {
+ paintings,
+ addPainting: () => {
+ const newPainting: Painting = {
+ id: uuid(),
+ urls: [],
+ files: [],
+ prompt: '',
+ negativePrompt: '',
+ imageSize: '1024x1024',
+ numImages: 1,
+ seed: '',
+ steps: 25,
+ guidanceScale: 4.5,
+ model: TEXT_TO_IMAGES_MODELS[0].id
+ }
+ dispatch(addPainting(newPainting))
+ return newPainting
+ },
+ removePainting: (painting: Painting) => {
+ dispatch(removePainting(painting))
+ },
+ updatePainting: (painting: Painting) => {
+ dispatch(updatePainting(painting))
+ },
+ updatePaintings: (paintings: Painting[]) => {
+ dispatch(updatePaintings(paintings))
+ }
+ }
+}
diff --git a/src/renderer/src/i18n/en-us.json b/src/renderer/src/i18n/en-us.json
index 5f07b776..14821f45 100644
--- a/src/renderer/src/i18n/en-us.json
+++ b/src/renderer/src/i18n/en-us.json
@@ -137,6 +137,25 @@
"stream_output": "Stream Output",
"search": "Search models..."
},
+ "images": {
+ "title": "Images",
+ "image.size": "Image Size",
+ "button.new.image": "New Image",
+ "button.delete.image": "Delete Image",
+ "button.delete.image.confirm": "Are you sure you want to delete this image?",
+ "number_images": "Number Images",
+ "number_images_tip": "Number of images to generate (1-4)",
+ "seed": "Seed",
+ "seed_tip": "The same seed and prompt can produce similar images",
+ "inference_steps": "Inference Steps",
+ "inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer",
+ "guidance_scale": "Guidance Scale",
+ "guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you",
+ "negative_prompt": "Negative Prompt",
+ "negative_prompt_tip": "Describe what you don't want included in the image",
+ "prompt_placeholder": "Describe the image you want to create, e.g. 'A serene lake at sunset with mountains in the background'",
+ "regenerate.confirm": "This will replace your existing generated images. Do you want to continue?"
+ },
"files": {
"title": "Files",
"file": "File",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
new file mode 100644
index 00000000..0519ecba
--- /dev/null
+++ b/src/renderer/src/i18n/locales/en.json
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/renderer/src/i18n/zh-cn.json b/src/renderer/src/i18n/zh-cn.json
index f00339b1..bdbbf5d4 100644
--- a/src/renderer/src/i18n/zh-cn.json
+++ b/src/renderer/src/i18n/zh-cn.json
@@ -60,7 +60,7 @@
"reset.double.confirm.title": "数据丢失!!!",
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
"upgrade.success.title": "升级成功",
- "upgrade.success.content": "重启应用以完成升级",
+ "upgrade.success.content": "重启用以完成升级",
"upgrade.success.button": "重启",
"topic.added": "话题添加成功",
"save.success.title": "保存成功"
@@ -137,6 +137,25 @@
"stream_output": "流式输出",
"search": "搜索模型..."
},
+ "images": {
+ "title": "图片",
+ "image.size": "图片尺寸",
+ "button.new.image": "新建图片",
+ "button.delete.image": "删除图片",
+ "button.delete.image.confirm": "确定要删除此图片吗?",
+ "number_images": "生成数量",
+ "number_images_tip": "一次生成的图片数量 (1-4)",
+ "seed": "随机种子",
+ "seed_tip": "相同的种子和提示词可以生成相似的图片",
+ "inference_steps": "推理步数",
+ "inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长",
+ "guidance_scale": "引导比例",
+ "guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
+ "negative_prompt": "反向提示词",
+ "negative_prompt_tip": "描述你不想在图片中出现的内容",
+ "prompt_placeholder": "描述你想创建的图片,例如:'一个宁静的湖泊,夕阳西下,远处是群山'",
+ "regenerate.confirm": "这将覆盖已生成的图片,是否继续?"
+ },
"files": {
"title": "文件",
"file": "文件",
@@ -317,7 +336,7 @@
"new_topic": "新建话题",
"zoom_in": "放大界面",
"zoom_out": "缩小界面",
- "zoom_reset": "重置缩放"
+ "zoom_reset": "���置缩放"
}
},
"translate": {
diff --git a/src/renderer/src/i18n/zh-tw.json b/src/renderer/src/i18n/zh-tw.json
index c3c5de29..24a66a80 100644
--- a/src/renderer/src/i18n/zh-tw.json
+++ b/src/renderer/src/i18n/zh-tw.json
@@ -137,6 +137,25 @@
"stream_output": "串流輸出",
"search": "搜尋模型..."
},
+ "images": {
+ "title": "繪圖",
+ "image.size": "影像尺寸",
+ "button.new.image": "新繪圖",
+ "button.delete.image": "刪除繪圖",
+ "button.delete.image.confirm": "確定要刪除此繪圖嗎?",
+ "number_images": "生成數量",
+ "number_images_tip": "一次生成的圖片數量 (1-4)",
+ "seed": "隨機種子",
+ "seed_tip": "相同的種子和提示詞可以生成相似的圖片",
+ "inference_steps": "推理步數",
+ "inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長",
+ "guidance_scale": "引導比例",
+ "guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度",
+ "negative_prompt": "反向提示詞",
+ "negative_prompt_tip": "描述你不想在圖片中出現的內容",
+ "prompt_placeholder": "描述你想創建的圖片,例如:'一個寧靜的湖泊,夕陽西下,遠處是群山'",
+ "regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?"
+ },
"files": {
"title": "檔案",
"file": "檔案",
diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
index b551c3c7..d8c26a8d 100644
--- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
+++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
@@ -394,7 +394,7 @@ const Textarea = styled(TextArea)`
display: flex;
flex: 1;
font-family: Ubuntu;
- resize: vertical;
+ resize: none !important;
overflow: auto;
width: 100%;
box-sizing: border-box;
diff --git a/src/renderer/src/pages/home/Markdown/ImagePreview.tsx b/src/renderer/src/pages/home/Markdown/ImagePreview.tsx
index dbb62f70..3ed40bef 100644
--- a/src/renderer/src/pages/home/Markdown/ImagePreview.tsx
+++ b/src/renderer/src/pages/home/Markdown/ImagePreview.tsx
@@ -8,19 +8,21 @@ import {
ZoomOutOutlined
} from '@ant-design/icons'
import { download } from '@renderer/utils/download'
-import { Image, Space } from 'antd'
+import { Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
import React from 'react'
import styled from 'styled-components'
-interface ImagePreviewProps extends React.ImgHTMLAttributes {
+interface ImagePreviewProps extends AntImageProps {
src: string
}
-const ImagePreview: React.FC = ({ src }) => {
+const ImagePreview: React.FC = ({ src, ...props }) => {
return (
- void
+ onDeletePainting: (painting: Painting) => void
+}
+
+const PaintingsList: FC = ({ paintings, selectedPainting, onSelectPainting, onDeletePainting }) => {
+ const { t } = useTranslation()
+ const [dragging, setDragging] = useState(false)
+ const { updatePaintings } = usePaintings()
+
+ return (
+
+ setDragging(true)}
+ onDragEnd={() => setDragging(false)}>
+ {(item: Painting) => (
+
+
+ )}
+
+
+ )
+}
+
+const Container = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+ gap: 10px;
+ padding: 10px 0;
+ background-color: var(--color-background);
+ max-width: 100px;
+ border-left: 0.5px solid var(--color-border);
+`
+
+const CanvasWrapper = styled.div`
+ position: relative;
+
+ &:hover {
+ .delete-button {
+ opacity: 1;
+ }
+ }
+`
+
+const Canvas = styled.div<{ thumbnail?: string }>`
+ width: 80px;
+ height: 80px;
+ background-color: var(--color-background-soft);
+ background-image: ${(props) => (props.thumbnail ? `url(${props.thumbnail})` : 'none')};
+ background-size: cover;
+ background-position: center;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ border: 1px solid var(--color-background-soft);
+
+ &.selected {
+ border: 1px solid var(--color-primary);
+ }
+
+ &:hover {
+ background-color: var(--color-background-mute);
+ }
+`
+
+const DeleteButton = styled.div.attrs({ className: 'delete-button' })`
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ border-radius: 50%;
+ padding: 4px;
+ cursor: pointer;
+ color: var(--color-error);
+ background-color: var(--color-background-soft);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`
+
+export default PaintingsList
diff --git a/src/renderer/src/pages/paintings/PaintingsPage.tsx b/src/renderer/src/pages/paintings/PaintingsPage.tsx
new file mode 100644
index 00000000..32a34ae4
--- /dev/null
+++ b/src/renderer/src/pages/paintings/PaintingsPage.tsx
@@ -0,0 +1,559 @@
+import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
+import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg'
+import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg'
+import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg'
+import ImageSize3_4 from '@renderer/assets/images/paintings/image-size-3-4.svg'
+import ImageSize9_16 from '@renderer/assets/images/paintings/image-size-9-16.svg'
+import ImageSize16_9 from '@renderer/assets/images/paintings/image-size-16-9.svg'
+import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
+import { VStack } from '@renderer/components/Layout'
+import Scrollbar from '@renderer/components/Scrollbar'
+import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
+import { useTheme } from '@renderer/context/ThemeProvider'
+import { usePaintings } from '@renderer/hooks/usePaintings'
+import { useProviders } from '@renderer/hooks/useProvider'
+import AiProvider from '@renderer/providers/AiProvider'
+import { getProviderByModel } from '@renderer/services/assistant'
+import { DEFAULT_PAINTING } from '@renderer/store/paintings'
+import { Painting } from '@renderer/types'
+import { getErrorMessage } from '@renderer/utils'
+import { Button, Input, InputNumber, Radio, Select, Slider, Spin, Tooltip } from 'antd'
+import TextArea from 'antd/es/input/TextArea'
+import { FC, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+import SendMessageButton from '../home/Inputbar/SendMessageButton'
+import ImagePreview from '../home/Markdown/ImagePreview'
+import { SettingTitle } from '../settings'
+import PaintingsList from './PaintingsList'
+
+const IMAGE_SIZES = [
+ {
+ label: '1:1',
+ value: '1024x1024',
+ icon: ImageSize1_1
+ },
+ {
+ label: '1:2',
+ value: '512x1024',
+ icon: ImageSize1_2
+ },
+ {
+ label: '3:2',
+ value: '768x512',
+ icon: ImageSize3_2
+ },
+ {
+ label: '3:4',
+ value: '768x1024',
+ icon: ImageSize3_4
+ },
+ {
+ label: '16:9',
+ value: '1024x576',
+ icon: ImageSize16_9
+ },
+ {
+ label: '9:16',
+ value: '576x1024',
+ icon: ImageSize9_16
+ }
+]
+
+let _painting: Painting
+
+const PaintingsPage: FC = () => {
+ const { t } = useTranslation()
+ const { paintings, addPainting, removePainting, updatePainting } = usePaintings()
+ const [painting, setPainting] = useState(_painting || paintings[0])
+ const { theme } = useTheme()
+ const { providers } = useProviders()
+ const siliconProvider = providers.find((p) => p.id === 'silicon')!
+ const [currentImageIndex, setCurrentImageIndex] = useState(0)
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [abortController, setAbortController] = useState(null)
+
+ const modelOptions = TEXT_TO_IMAGES_MODELS.map((model) => ({
+ label: model.name,
+ value: model.id
+ }))
+
+ const textareaRef = useRef(null)
+ _painting = painting
+
+ const updatePaintingState = (updates: Partial) => {
+ const updatedPainting = { ...painting, ...updates }
+ setPainting(updatedPainting)
+ updatePainting(updatedPainting)
+ }
+
+ const onSelectModel = (modelId: string) => {
+ const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === modelId)
+ if (model) {
+ updatePaintingState({ model: modelId })
+ }
+ }
+
+ const onGenerate = async () => {
+ if (painting.urls.length > 0) {
+ const confirmed = await window.modal.confirm({
+ content: t('images.regenerate.confirm'),
+ centered: true
+ })
+
+ if (!confirmed) {
+ return
+ }
+ }
+
+ const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
+ updatePaintingState({ prompt })
+
+ const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === painting.model)
+ const provider = getProviderByModel(model)
+
+ if (!provider.apiKey) {
+ window.message.error(t('error.no_api_key'))
+ return
+ }
+
+ const controller = new AbortController()
+ setAbortController(controller)
+ setIsLoading(true)
+ const AI = new AiProvider(provider)
+
+ try {
+ const urls = await AI.generateImage({
+ prompt,
+ negativePrompt: painting.negativePrompt || '',
+ imageSize: painting.imageSize || '1024x1024',
+ batchSize: painting.numImages || 1,
+ seed: painting.seed || undefined,
+ numInferenceSteps: painting.steps || 25,
+ guidanceScale: painting.guidanceScale || 4.5,
+ signal: controller.signal
+ })
+
+ if (urls.length > 0) {
+ updatePaintingState({ urls })
+ }
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name !== 'AbortError') {
+ window.message.error(getErrorMessage(error))
+ }
+ } finally {
+ setIsLoading(false)
+ setAbortController(null)
+ }
+ }
+
+ const onCancel = () => {
+ abortController?.abort()
+ }
+
+ const onSelectImageSize = (v: string) => {
+ const size = IMAGE_SIZES.find((i) => i.value === v)
+ size && updatePaintingState({ imageSize: size.value })
+ }
+
+ const nextImage = () => {
+ setCurrentImageIndex((prev) => (prev + 1) % painting.urls.length)
+ }
+
+ const prevImage = () => {
+ setCurrentImageIndex((prev) => (prev - 1 + painting.urls.length) % painting.urls.length)
+ }
+
+ const onDeletePainting = (paintingToDelete: Painting) => {
+ if (paintingToDelete.id === painting.id) {
+ const currentIndex = paintings.findIndex((p) => p.id === paintingToDelete.id)
+
+ if (currentIndex > 0) {
+ setPainting(paintings[currentIndex - 1])
+ } else if (paintings.length > 1) {
+ setPainting(paintings[1])
+ }
+ }
+
+ removePainting(paintingToDelete)
+
+ if (paintings.length === 1) {
+ setPainting(DEFAULT_PAINTING)
+ }
+ }
+
+ const onSelectPainting = (newPainting: Painting) => {
+ setPainting(newPainting)
+ setCurrentImageIndex(0)
+ }
+
+ return (
+
+
+ {t('images.title')}
+
+ } onClick={() => setPainting(addPainting())}>
+ {t('images.button.new.image')}
+
+
+
+
+
+ {t('common.provider')}
+
+ {t('common.model')}
+
+ {t('images.image.size')}
+ onSelectImageSize(e.target.value)}
+ style={{ display: 'flex' }}>
+ {IMAGE_SIZES.map((size) => (
+
+
+
+ {size.label}
+
+
+ ))}
+
+
+
+ {t('images.number_images')}
+
+
+
+
+ updatePaintingState({ numImages: v || 1 })}
+ />
+
+
+ {t('images.seed')}
+
+
+
+
+ updatePaintingState({ seed: e.target.value })}
+ suffix={ updatePaintingState({ seed: '' })} />}
+ />
+
+
+ {t('images.inference_steps')}
+
+
+
+
+ updatePaintingState({ steps: v })} />
+ updatePaintingState({ steps: v || 25 })}
+ />
+
+
+ {t('images.guidance_scale')}
+
+
+
+
+ updatePaintingState({ guidanceScale: v })}
+ />
+ updatePaintingState({ guidanceScale: v || 4.5 })}
+ />
+
+
+ {t('images.negative_prompt')}
+
+
+
+
+
+
+
+
+ {painting.urls.length > 0 ? (
+
+ {painting.urls.length > 1 && (
+
+ ←
+
+ )}
+
+ {painting.urls.length > 1 && (
+
+ →
+
+ )}
+
+ {currentImageIndex + 1} / {painting.urls.length}
+
+
+ ) : (
+
+ )}
+ {isLoading && (
+
+
+ {t('common.cancel')}
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+const Container = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ height: 100%;
+`
+
+const ContentContainer = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ height: 100%;
+ background-color: var(--color-background);
+ overflow: hidden;
+`
+
+const LeftContainer = styled(Scrollbar)`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ height: 100%;
+ padding: 20px;
+ background-color: var(--color-background);
+ max-width: var(--assistants-width);
+ border-right: 0.5px solid var(--color-border);
+`
+
+const MainContainer = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ height: 100%;
+ background-color: var(--color-background);
+`
+
+const Artboard = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+`
+
+const InputContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-height: 100px;
+ max-height: 100px;
+ position: relative;
+ border: 1px solid var(--color-border-soft);
+ transition: all 0.3s ease;
+ margin: 0 20px 15px 20px;
+ border-radius: 10px;
+`
+
+const Textarea = styled(TextArea)`
+ padding: 10px;
+ border-radius: 0;
+ display: flex;
+ flex: 1;
+ font-family: Ubuntu;
+ resize: none !important;
+ overflow: auto;
+ width: auto;
+`
+
+const Toolbar = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ justify-content: flex-end;
+ padding: 0 8px;
+ padding-bottom: 0;
+ height: 36px;
+`
+
+const ToolbarMenu = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 6px;
+`
+
+const ImagePlaceholder = styled.div`
+ display: flex;
+ width: 70vh;
+ height: 70vh;
+ background-color: var(--color-background-soft);
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+`
+
+const ImageSizeImage = styled.img<{ theme: string }>`
+ filter: ${({ theme }) => (theme === 'dark' ? 'invert(100%)' : 'none')};
+ margin-top: 8px;
+`
+
+const RadioButton = styled(Radio.Button)`
+ width: 30px;
+ height: 55px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+`
+
+const InfoIcon = styled(QuestionCircleOutlined)`
+ margin-left: 5px;
+ cursor: help;
+ color: var(--color-text-2);
+ opacity: 0.6;
+
+ &:hover {
+ opacity: 1;
+ }
+`
+
+const RefreshIcon = styled.span`
+ cursor: pointer;
+`
+
+const ImageContainer = styled.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .ant-spin {
+ max-height: none;
+ }
+
+ .ant-spin-spinning {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 3;
+ }
+`
+
+const NavigationButton = styled(Button)`
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 2;
+ opacity: 0.7;
+ &:hover {
+ opacity: 1;
+ }
+`
+
+const ImageCounter = styled.div`
+ position: absolute;
+ bottom: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: rgba(0, 0, 0, 0.5);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+`
+
+const LoadingContainer = styled.div<{ spinning: boolean }>`
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ opacity: ${(props) => (props.spinning ? 0.5 : 1)};
+ transition: opacity 0.3s;
+`
+
+const LoadingOverlay = styled.div`
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+`
+
+const CancelButton = styled(Button)`
+ margin-top: 10px;
+ z-index: 1001;
+`
+
+export default PaintingsPage
diff --git a/src/renderer/src/providers/AiProvider.ts b/src/renderer/src/providers/AiProvider.ts
index 80dcd359..f17929f0 100644
--- a/src/renderer/src/providers/AiProvider.ts
+++ b/src/renderer/src/providers/AiProvider.ts
@@ -41,4 +41,17 @@ export default class AiProvider {
public async models(): Promise {
return this.sdk.models()
}
+
+ public async generateImage(params: {
+ prompt: string
+ negativePrompt: string
+ imageSize: string
+ batchSize: number
+ seed?: string
+ numInferenceSteps: number
+ guidanceScale: number
+ signal?: AbortSignal
+ }): Promise {
+ return this.sdk.generateImage(params)
+ }
}
diff --git a/src/renderer/src/providers/BaseProvider.ts b/src/renderer/src/providers/BaseProvider.ts
index bdb15c93..94313b26 100644
--- a/src/renderer/src/providers/BaseProvider.ts
+++ b/src/renderer/src/providers/BaseProvider.ts
@@ -41,4 +41,14 @@ export default abstract class BaseProvider {
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise
+ abstract generateImage(_params: {
+ prompt: string
+ negativePrompt: string
+ imageSize: string
+ batchSize: number
+ seed?: string
+ numInferenceSteps: number
+ guidanceScale: number
+ signal?: AbortSignal
+ }): Promise
}
diff --git a/src/renderer/src/providers/OpenAIProvider.ts b/src/renderer/src/providers/OpenAIProvider.ts
index d2a77b2c..6ec21685 100644
--- a/src/renderer/src/providers/OpenAIProvider.ts
+++ b/src/renderer/src/providers/OpenAIProvider.ts
@@ -350,4 +350,48 @@ export default class OpenAIProvider extends BaseProvider {
return []
}
}
+
+ public async generateImage({
+ prompt,
+ negativePrompt,
+ imageSize,
+ batchSize,
+ seed,
+ numInferenceSteps,
+ guidanceScale,
+ signal
+ }: {
+ prompt: string
+ negativePrompt?: string
+ imageSize: string
+ batchSize: number
+ seed?: string
+ numInferenceSteps: number
+ guidanceScale: number
+ signal?: AbortSignal
+ }): Promise {
+ try {
+ const response = (await this.sdk.request({
+ method: 'post',
+ path: '/images/generations',
+ headers: this.getHeaders(),
+ signal,
+ body: {
+ model: 'stabilityai/stable-diffusion-3-5-large',
+ prompt,
+ negative_prompt: negativePrompt,
+ image_size: imageSize,
+ batch_size: batchSize,
+ seed: seed ? parseInt(seed) : undefined,
+ num_inference_steps: numInferenceSteps,
+ guidance_scale: guidanceScale
+ }
+ })) as { data: Array<{ url: string }> }
+
+ return response.data.map((item) => item.url)
+ } catch (error) {
+ console.error('Image generation error:', error)
+ return []
+ }
+ }
}
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index 16b34ba9..2130a84d 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -7,14 +7,16 @@ import agents from './agents'
import assistants from './assistants'
import llm from './llm'
import migrate from './migrate'
+import paintings from './paintings'
import runtime from './runtime'
import settings from './settings'
const rootReducer = combineReducers({
assistants,
- settings,
- llm,
agents,
+ paintings,
+ llm,
+ settings,
runtime
})
diff --git a/src/renderer/src/store/paintings.ts b/src/renderer/src/store/paintings.ts
new file mode 100644
index 00000000..20266481
--- /dev/null
+++ b/src/renderer/src/store/paintings.ts
@@ -0,0 +1,53 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
+import { Painting } from '@renderer/types'
+import { uuid } from '@renderer/utils'
+
+export interface PaintingsState {
+ paintings: Painting[]
+}
+
+export const DEFAULT_PAINTING: Painting = {
+ id: uuid(),
+ urls: [],
+ files: [],
+ prompt: '',
+ negativePrompt: '',
+ imageSize: '1024x1024',
+ numImages: 1,
+ seed: '',
+ steps: 25,
+ guidanceScale: 4.5,
+ model: TEXT_TO_IMAGES_MODELS[0].id
+}
+
+const initialState: PaintingsState = {
+ paintings: [DEFAULT_PAINTING]
+}
+
+const paintingsSlice = createSlice({
+ name: 'paintings',
+ initialState,
+ reducers: {
+ updatePaintings: (state, action: PayloadAction) => {
+ state.paintings = action.payload
+ },
+ addPainting: (state, action: PayloadAction) => {
+ state.paintings.push(action.payload)
+ },
+ removePainting: (state, action: PayloadAction) => {
+ if (state.paintings.length === 1) {
+ state.paintings = [DEFAULT_PAINTING]
+ } else {
+ state.paintings = state.paintings.filter((c) => c.id !== action.payload.id)
+ }
+ },
+ updatePainting: (state, action: PayloadAction) => {
+ state.paintings = state.paintings.map((c) => (c.id === action.payload.id ? action.payload : c))
+ }
+ }
+})
+
+export const { updatePaintings, addPainting, removePainting, updatePainting } = paintingsSlice.actions
+
+export default paintingsSlice.reducer
diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts
index fa8d19d4..3cf01059 100644
--- a/src/renderer/src/types/index.ts
+++ b/src/renderer/src/types/index.ts
@@ -87,6 +87,20 @@ export type Suggestion = {
content: string
}
+export interface Painting {
+ id: string
+ urls: string[]
+ files: FileType[]
+ prompt?: string
+ negativePrompt?: string
+ imageSize?: string
+ numImages?: number
+ seed?: string
+ steps?: number
+ guidanceScale?: number
+ model?: string
+}
+
export type MinAppType = {
id?: string | number
name: string