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) => ( + + onSelectPainting(item)} + thumbnail={item.urls[0]} + /> + + onDeletePainting(item)} + okButtonProps={{ danger: true }} + placement="left"> + + + + + )} + + + ) +} + +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')} + + + + + + + {t('common.provider')} + + {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')} + + + + +