diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 2b653a4e..f454d9c6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -518,7 +518,9 @@ "upgrade.success.content": "Please restart the application to complete the upgrade", "upgrade.success.title": "Upgrade successfully", "warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!", - "warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again." + "warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.", + "download.success": "Download successful", + "download.failed": "Download failed" }, "minapp": { "sidebar.add.title": "Add to sidebar", @@ -1182,4 +1184,4 @@ "visualization": "Visualization" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 975546cc..d2dde3e3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -518,7 +518,9 @@ "upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください", "upgrade.success.title": "アップグレードに成功しました", "warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ", - "warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。" + "warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。", + "download.success": "ダウンロード成功", + "download.failed": "ダウンロードに失敗しました" }, "minapp": { "sidebar.add.title": "サイドバーに追加", @@ -1183,4 +1185,4 @@ "visualization": "可視化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 625d0ebb..07e66da9 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -518,7 +518,9 @@ "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", "upgrade.success.title": "Обновление успешно", "warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!", - "warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова." + "warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.", + "download.success": "Скачивание успешно завершено", + "download.failed": "Ошибка загрузки" }, "minapp": { "sidebar.add.title": "Добавить в боковую панель", @@ -1183,4 +1185,4 @@ "visualization": "Визуализация" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 983cad6b..6eb9706c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -518,7 +518,9 @@ "upgrade.success.content": "重启用以完成升级", "upgrade.success.title": "升级成功", "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", - "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试" + "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试", + "download.success": "下载成功", + "download.failed": "下载失败" }, "minapp": { "sidebar.add.title": "添加到侧边栏", @@ -1183,4 +1185,4 @@ "visualization": "可视化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a8985666..13e6b7d2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -518,7 +518,9 @@ "upgrade.success.content": "請重新啟動程式以完成升級", "upgrade.success.title": "升級成功", "warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!", - "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試" + "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試", + "download.success": "下載成功", + "download.failed": "下載失敗" }, "minapp": { "sidebar.add.title": "新增到側邊欄", @@ -1183,4 +1185,4 @@ "visualization": "視覺化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/pages/home/Messages/MessageImage.tsx b/src/renderer/src/pages/home/Messages/MessageImage.tsx index 0f24eded..9dc0566c 100644 --- a/src/renderer/src/pages/home/Messages/MessageImage.tsx +++ b/src/renderer/src/pages/home/Messages/MessageImage.tsx @@ -1,6 +1,17 @@ +import { + CopyOutlined, + DownloadOutlined, + RotateLeftOutlined, + RotateRightOutlined, + SwapOutlined, + UndoOutlined, + ZoomInOutlined, + ZoomOutOutlined +} from '@ant-design/icons' import { Message } from '@renderer/types' -import { Image as AntdImage } from 'antd' +import { Image as AntdImage, Space } from 'antd' import { FC } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface Props { @@ -8,10 +19,88 @@ interface Props { } const MessageImage: FC = ({ message }) => { + const { t } = useTranslation() + + const onDownload = (imageBase64: string, index: number) => { + try { + const link = document.createElement('a') + link.href = imageBase64 + link.download = `image-${Date.now()}-${index}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.message.success(t('message.download.success')) + } catch (error) { + console.error('下载图片失败:', error) + window.message.error(t('message.download.failed')) + } + } + + // 复制 base64 图片到剪贴板 + const onCopy = async (imageBase64: string) => { + try { + const base64Data = imageBase64.split(',')[1] + const mimeType = imageBase64.split(';')[0].split(':')[1] + + const byteCharacters = atob(base64Data) + const byteArrays: Uint8Array[] = [] + + for (let i = 0; i < byteCharacters.length; i += 512) { + const slice = byteCharacters.slice(i, i + 512) + + const byteNumbers = new Array(slice.length) + for (let j = 0; j < slice.length; j++) { + byteNumbers[j] = slice.charCodeAt(j) + } + + const byteArray = new Uint8Array(byteNumbers) + byteArrays.push(byteArray) + } + + const blob = new Blob(byteArrays, { type: mimeType }) + + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob + }) + ]) + + window.message.success(t('message.copy.success')) + } catch (error) { + console.error('复制图片失败:', error) + window.message.error(t('message.copy.failed')) + } + } + return ( {message.metadata?.generateImage!.images.map((image, index) => ( - + ( + + + + + + + + + onCopy(image)} /> + onDownload(image, index)} /> + + ) + }} + /> ))} ) @@ -25,5 +114,19 @@ const Container = styled.div` const Image = styled(AntdImage)` border-radius: 10px; ` +const ToobarWrapper = styled(Space)` + padding: 0px 24px; + color: #fff; + font-size: 20px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 100px; + .anticon { + padding: 12px; + cursor: pointer; + } + .anticon:hover { + opacity: 0.3; + } +` export default MessageImage diff --git a/src/renderer/src/providers/GeminiProvider.ts b/src/renderer/src/providers/GeminiProvider.ts index a21385fb..66dea24d 100644 --- a/src/renderer/src/providers/GeminiProvider.ts +++ b/src/renderer/src/providers/GeminiProvider.ts @@ -580,7 +580,7 @@ export default class GeminiProvider extends BaseProvider { private async generateImageExp({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise { const defaultModel = getDefaultModel() const model = assistant.model || defaultModel - const { contextCount } = getAssistantSettings(assistant) + const { contextCount, streamOutput, maxTokens } = getAssistantSettings(assistant) const userMessages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2))) onFilterMessages(userMessages) @@ -603,16 +603,22 @@ export default class GeminiProvider extends BaseProvider { contents = await this.addImageFileToContents(userLastMessage, contents) - const response = await this.callGeminiGenerateContent(model.id, contents) + if (!streamOutput) { + const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens) - console.log('response', response) + const { isValid, message } = this.isValidGeminiResponse(response) + if (!isValid) { + throw new Error(`Gemini API error: ${message}`) + } - const { isValid, message } = this.isValidGeminiResponse(response) - if (!isValid) { - throw new Error(`Gemini API error: ${message}`) + this.processGeminiImageResponse(response, onChunk) + return } + const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens) - this.processGeminiImageResponse(response, onChunk) + for await (const chunk of response) { + this.processGeminiImageResponse(chunk, onChunk) + } } /** @@ -642,7 +648,8 @@ export default class GeminiProvider extends BaseProvider { */ private async callGeminiGenerateContent( modelId: string, - contents: ContentListUnion + contents: ContentListUnion, + maxTokens?: number ): Promise { try { return await this.imageSdk.models.generateContent({ @@ -650,7 +657,29 @@ export default class GeminiProvider extends BaseProvider { contents: contents, config: { responseModalities: ['Text', 'Image'], - responseMimeType: 'text/plain' + responseMimeType: 'text/plain', + maxOutputTokens: maxTokens + } + }) + } catch (error) { + console.error('Gemini API error:', error) + throw error + } + } + + private async callGeminiGenerateContentStream( + modelId: string, + contents: ContentListUnion, + maxTokens?: number + ): Promise> { + try { + return await this.imageSdk.models.generateContentStream({ + model: modelId, + contents: contents, + config: { + responseModalities: ['Text', 'Image'], + responseMimeType: 'text/plain', + maxOutputTokens: maxTokens } }) } catch (error) { @@ -678,7 +707,9 @@ export default class GeminiProvider extends BaseProvider { */ private processGeminiImageResponse(response: any, onChunk: (chunk: ChunkCallbackData) => void): void { const parts = response.candidates[0].content.parts - + if (!parts) { + return + } // 提取图像数据 const images = parts .filter((part: Part) => part.inlineData) diff --git a/src/renderer/src/providers/index.d.ts b/src/renderer/src/providers/index.d.ts index f21880b4..f634880d 100644 --- a/src/renderer/src/providers/index.d.ts +++ b/src/renderer/src/providers/index.d.ts @@ -15,7 +15,16 @@ interface ChunkCallbackData { interface CompletionsParams { messages: Message[] assistant: Assistant - onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse }: ChunkCallbackData) => void + onChunk: ({ + text, + reasoning_content, + usage, + metrics, + search, + citations, + mcpToolResponse, + generateImage + }: ChunkCallbackData) => void onFilterMessages: (messages: Message[]) => void mcpTools?: MCPTool[] } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 868632a4..3e321e1d 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -127,7 +127,10 @@ export async function fetchChatCompletion({ if (mcpToolResponse) { message.metadata = { ...message.metadata, mcpTools: cloneDeep(mcpToolResponse) } } - if (generateImage) { + if (generateImage && generateImage.images.length > 0) { + const existingImages = message.metadata?.generateImage?.images || [] + generateImage.images = [...existingImages, ...generateImage.images] + console.log('generateImage', generateImage) message.metadata = { ...message.metadata, generateImage: generateImage