From eb799879ff7b7627d1a0b863ca9c29229c7249b6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 22 Sep 2024 00:16:36 +0800 Subject: [PATCH] feat: export topic message as image #103 --- package.json | 3 +- src/main/ipc.ts | 6 +- src/main/services/FileManager.ts | 75 ++++++++++++++++++- src/main/utils/file.ts | 52 ------------- src/preload/index.d.ts | 5 +- src/preload/index.ts | 11 +-- .../components/Popups/AddAssistantPopup.tsx | 3 +- src/renderer/src/components/app/Sidebar.tsx | 4 +- src/renderer/src/i18n/index.ts | 4 + .../pages/home/Messages/MessageMenubar.tsx | 2 +- .../src/pages/home/Messages/Messages.tsx | 9 ++- src/renderer/src/pages/home/Topics.tsx | 15 +++- .../src/pages/settings/SettingsPage.tsx | 1 + src/renderer/src/services/backup.ts | 4 +- src/renderer/src/services/event.ts | 3 +- src/renderer/src/utils/index.ts | 64 ++++++++++++++++ yarn.lock | 45 +++++++++++ 17 files changed, 232 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index b8efda1d..ae7c46b2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "electron-log": "^5.1.5", "electron-store": "^8.2.0", "electron-updater": "^6.1.7", - "electron-window-state": "^5.0.3" + "electron-window-state": "^5.0.3", + "html2canvas": "^1.4.1" }, "devDependencies": { "@anthropic-ai/sdk": "^0.24.3", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9e3ae3b2..13a06bd9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,7 +4,6 @@ import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'elect import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' import FileManager from './services/FileManager' -import { openFile, saveFile } from './utils/file' import { compress, decompress } from './utils/zip' import { createMinappWindow } from './window' @@ -28,13 +27,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}) }) - ipcMain.handle('save-file', saveFile) - ipcMain.handle('open-file', openFile) ipcMain.handle('reload', () => mainWindow.reload()) ipcMain.handle('zip:compress', (_, text: string) => compress(text)) ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) + ipcMain.handle('file:open', fileManager.open) + ipcMain.handle('file:save', fileManager.save) + ipcMain.handle('file:saveImage', fileManager.saveImage) ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id)) ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options)) ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file)) diff --git a/src/main/services/FileManager.ts b/src/main/services/FileManager.ts index 7ac30ae7..e031c548 100644 --- a/src/main/services/FileManager.ts +++ b/src/main/services/FileManager.ts @@ -1,8 +1,18 @@ import { getFileType } from '@main/utils/file' import { FileType } from '@types' import * as crypto from 'crypto' -import { app, dialog, OpenDialogOptions } from 'electron' +import { + app, + dialog, + OpenDialogOptions, + OpenDialogReturnValue, + SaveDialogOptions, + SaveDialogReturnValue +} from 'electron' +import logger from 'electron-log' import * as fs from 'fs' +import { writeFileSync } from 'fs' +import { readFile } from 'fs/promises' import * as path from 'path' import { v4 as uuidv4 } from 'uuid' @@ -193,6 +203,69 @@ class FileManager { await fs.promises.rmdir(this.storageDir, { recursive: true }) await this.initStorageDir() } + + async open( + _: Electron.IpcMainInvokeEvent, + options: OpenDialogOptions + ): Promise<{ fileName: string; content: Buffer } | null> { + try { + const result: OpenDialogReturnValue = await dialog.showOpenDialog({ + title: '打开文件', + properties: ['openFile'], + filters: [{ name: '所有文件', extensions: ['*'] }], + ...options + }) + + if (!result.canceled && result.filePaths.length > 0) { + const filePath = result.filePaths[0] + const fileName = filePath.split('/').pop() || '' + const content = await readFile(filePath) + return { fileName, content } + } + + return null + } catch (err) { + logger.error('[IPC - Error]', 'An error occurred opening the file:', err) + return null + } + } + + async save( + _: Electron.IpcMainInvokeEvent, + fileName: string, + content: string, + options?: SaveDialogOptions + ): Promise { + try { + const result: SaveDialogReturnValue = await dialog.showSaveDialog({ + title: '保存文件', + defaultPath: fileName, + ...options + }) + + if (!result.canceled && result.filePath) { + await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) + } + } catch (err) { + logger.error('[IPC - Error]', 'An error occurred saving the file:', err) + } + } + + async saveImage(_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise { + try { + const filePath = dialog.showSaveDialogSync({ + defaultPath: `${name}.png`, + filters: [{ name: 'PNG Image', extensions: ['png'] }] + }) + + if (filePath) { + const base64Data = data.replace(/^data:image\/png;base64,/, '') + fs.writeFileSync(filePath, base64Data, 'base64') + } + } catch (error) { + logger.error('[IPC - Error]', 'An error occurred saving the image:', error) + } + } } export default FileManager diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 9bfdf58c..f2b1d626 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,57 +1,5 @@ -import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' -import logger from 'electron-log' -import { writeFileSync } from 'fs' -import { readFile } from 'fs/promises' - import { FileTypes } from '../../renderer/src/types' -export async function saveFile( - _: Electron.IpcMainInvokeEvent, - fileName: string, - content: string, - options?: SaveDialogOptions -): Promise { - try { - const result: SaveDialogReturnValue = await dialog.showSaveDialog({ - title: '保存文件', - defaultPath: fileName, - ...options - }) - - if (!result.canceled && result.filePath) { - await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) - } - } catch (err) { - logger.error('[IPC - Error]', 'An error occurred saving the file:', err) - } -} - -export async function openFile( - _: Electron.IpcMainInvokeEvent, - options: OpenDialogOptions -): Promise<{ fileName: string; content: Buffer } | null> { - try { - const result: OpenDialogReturnValue = await dialog.showOpenDialog({ - title: '打开文件', - properties: ['openFile'], - filters: [{ name: '所有文件', extensions: ['*'] }], - ...options - }) - - if (!result.canceled && result.filePaths.length > 0) { - const filePath = result.filePaths[0] - const fileName = filePath.split('/').pop() || '' - const content = await readFile(filePath) - return { fileName, content } - } - - return null - } catch (err) { - logger.error('[IPC - Error]', 'An error occurred opening the file:', err) - return null - } -} - export function getFileType(ext: string): FileTypes { const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ecbf2802..4a210e25 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -14,8 +14,6 @@ declare global { checkForUpdate: () => void openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void - saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void - openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null> setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void reload: () => void @@ -31,6 +29,9 @@ declare global { get: (filePath: string) => Promise create: (fileName: string) => Promise write: (filePath: string, data: Uint8Array | string) => Promise + open: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null> + save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void + saveImage: (name: string, data: string) => void } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index b894397f..ba38277e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,11 +9,7 @@ const api = { setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme), minApp: (url: string) => ipcRenderer.invoke('minapp', url), - openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options), reload: () => ipcRenderer.invoke('reload'), - saveFile: (path: string, content: string, options?: { compress: boolean }) => { - return ipcRenderer.invoke('save-file', path, content, options) - }, compress: (text: string) => ipcRenderer.invoke('zip:compress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text), file: { @@ -25,7 +21,12 @@ const api = { clear: () => ipcRenderer.invoke('file:clear'), get: (filePath: string) => ipcRenderer.invoke('file:get', filePath), create: (fileName: string) => ipcRenderer.invoke('file:create', fileName), - write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data) + write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data), + open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options), + save: (path: string, content: string, options?: { compress: boolean }) => { + return ipcRenderer.invoke('file:save', path, content, options) + }, + saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data) } } diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index 5ceb4242..04f4d999 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined, SearchOutlined } from '@ant-design/icons' +import { SearchOutlined } from '@ant-design/icons' import { TopView } from '@renderer/components/TopView' import systemAgents from '@renderer/config/agents.json' import { useAgents } from '@renderer/hooks/useAgents' @@ -110,7 +110,6 @@ const PopupContainer: React.FC = ({ resolve }) => { onClick={() => onCreateAssistant(agent)} className={agent.id === 'default' ? 'default' : ''}> - {agent.id === 'default' && } {agent.emoji} {agent.name} {agent.group === 'system' && {t('agents.tag.system')}} diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 04403570..7ed53495 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -119,8 +119,8 @@ const Menus = styled.div` ` const Icon = styled.div` - width: 34px; - height: 34px; + width: 35px; + height: 35px; display: flex; justify-content: center; align-items: center; diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index ee099ce6..b6785068 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -77,6 +77,8 @@ const resources = { 'topics.delete.all.content': 'Are you sure you want to delete all topics?', 'topics.move_to': 'Move to', 'topics.list': 'Topic List', + 'topics.export.title': 'Export', + 'topics.export.image': 'Export as image', 'input.new_topic': 'New Topic', 'input.topics': ' Topics ', 'input.clear': 'Clear', @@ -355,6 +357,8 @@ const resources = { 'topics.delete.all.content': '确定要删除所有话题吗?', 'topics.move_to': '移动到', 'topics.list': '话题列表', + 'topics.export.title': '导出', + 'topics.export.image': '导出为图片', 'input.new_topic': '新话题', 'input.topics': ' 话题 ', 'input.clear': '清除会话消息', diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index a4f3fe36..c973f2b9 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -65,7 +65,7 @@ const MessageMenubar: FC = (props) => { icon: , onClick: () => { const fileName = message.createdAt + '.md' - window.api.saveFile(fileName, message.content) + window.api.file.save(fileName, message.content) } } ], diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index fe4e71b2..8881a374 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -7,7 +7,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages' import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens' import { Assistant, Message, Model, Topic } from '@renderer/types' -import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils' +import { captureScrollableDiv, getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils' import { t } from 'i18next' import { flatten, last, reverse, take } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' @@ -104,6 +104,12 @@ const Messages: FC = ({ assistant, topic, setActiveTopic }) => { updateTopic({ ...topic, messages: [] }) TopicManager.clearTopicMessages(topic.id) }), + EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => { + const imageData = await captureScrollableDiv(containerRef) + if (imageData) { + window.api.file.saveImage(topic.name, imageData) + } + }), EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => { const lastMessage = last(messages) @@ -200,6 +206,7 @@ const Container = styled.div` flex-direction: column-reverse; max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height)); padding: 10px 0; + background-color: var(--color-background); ` export default Messages diff --git a/src/renderer/src/pages/home/Topics.tsx b/src/renderer/src/pages/home/Topics.tsx index 5f590f83..c3b6810d 100644 --- a/src/renderer/src/pages/home/Topics.tsx +++ b/src/renderer/src/pages/home/Topics.tsx @@ -1,9 +1,10 @@ -import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined } from '@ant-design/icons' +import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined, UploadOutlined } from '@ant-design/icons' import DragableList from '@renderer/components/DragableList' import PromptPopup from '@renderer/components/Popups/PromptPopup' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { TopicManager } from '@renderer/hooks/useTopic' import { fetchMessagesSummary } from '@renderer/services/api' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { useAppSelector } from '@renderer/store' import { Assistant, Topic } from '@renderer/types' import { Dropdown, MenuProps } from 'antd' @@ -94,6 +95,18 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic updateTopic({ ...topic, name }) } } + }, + { + label: t('chat.topics.export.title'), + key: 'export', + icon: , + children: [ + { + label: t('chat.topics.export.image'), + key: 'image', + onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic) + } + ] } ] diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index a49c3a6b..cd19f289 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -118,6 +118,7 @@ const MenuItem = styled.li` } .iconfont { font-size: 18px; + line-height: 18px; opacity: 0.7; margin-left: -1px; } diff --git a/src/renderer/src/services/backup.ts b/src/renderer/src/services/backup.ts index 3e2ae699..691633a0 100644 --- a/src/renderer/src/services/backup.ts +++ b/src/renderer/src/services/backup.ts @@ -18,13 +18,13 @@ export async function backup() { const fileContnet = JSON.stringify(data) const file = await window.api.compress(fileContnet) - await window.api.saveFile(filename, file) + await window.api.file.save(filename, file) window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) } export async function restore() { - const file = await window.api.openFile() + const file = await window.api.file.open() if (file) { try { diff --git a/src/renderer/src/services/event.ts b/src/renderer/src/services/event.ts index d0b70b37..2fe1c8b7 100644 --- a/src/renderer/src/services/event.ts +++ b/src/renderer/src/services/event.ts @@ -17,5 +17,6 @@ export const EVENT_NAMES = { SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR', SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR', NEW_CONTEXT: 'NEW_CONTEXT', - NEW_BRANCH: 'NEW_BRANCH' + NEW_BRANCH: 'NEW_BRANCH', + EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE' } diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index c97cc324..8808798c 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -1,5 +1,6 @@ import { Model } from '@renderer/types' import imageCompression from 'browser-image-compression' +import html2canvas from 'html2canvas' // @ts-ignore next-line` import { v4 as uuidv4 } from 'uuid' @@ -241,3 +242,66 @@ export function getFileExtension(filePath: string) { const extension = parts.slice(-1)[0] return '.' + extension } + +export async function captureDiv(divRef: React.RefObject) { + if (divRef.current) { + try { + const canvas = await html2canvas(divRef.current) + const imageData = canvas.toDataURL('image/png') + return imageData + } catch (error) { + console.error('Error capturing div:', error) + return Promise.reject() + } + } + return Promise.resolve(undefined) +} + +export const captureScrollableDiv = async (divRef: React.RefObject) => { + if (divRef.current) { + try { + const div = divRef.current + + // 保存原始样式 + const originalStyle = { + height: div.style.height, + maxHeight: div.style.maxHeight, + overflow: div.style.overflow, + position: div.style.position + } + + const originalScrollTop = div.scrollTop + + // 修改样式以显示全部内容 + div.style.height = 'auto' + div.style.maxHeight = 'none' + div.style.overflow = 'visible' + div.style.position = 'static' + + // 捕获整个内容 + const canvas = await html2canvas(div, { + scrollY: -window.scrollY, + windowHeight: document.documentElement.scrollHeight + }) + + // 恢复原始样式 + div.style.height = originalStyle.height + div.style.maxHeight = originalStyle.maxHeight + div.style.overflow = originalStyle.overflow + div.style.position = originalStyle.position + + const imageData = canvas.toDataURL('image/png') + + // 恢复原始滚动位置 + setTimeout(() => { + div.scrollTop = originalScrollTop + }, 0) + + return imageData + } catch (error) { + console.error('Error capturing scrollable div:', error) + } + } + + return Promise.resolve(undefined) +} diff --git a/yarn.lock b/yarn.lock index 228950db..306f76bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1785,6 +1785,7 @@ __metadata: eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.0.0" gpt-tokens: "npm:^1.3.10" + html2canvas: "npm:^1.4.1" i18next: "npm:^23.11.5" localforage: "npm:^1.10.0" lodash: "npm:^4.17.21" @@ -2290,6 +2291,13 @@ __metadata: languageName: node linkType: hard +"base64-arraybuffer@npm:^1.0.2": + version: 1.0.2 + resolution: "base64-arraybuffer@npm:1.0.2" + checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f + languageName: node + linkType: hard + "base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -2881,6 +2889,15 @@ __metadata: languageName: node linkType: hard +"css-line-break@npm:^2.1.0": + version: 2.1.0 + resolution: "css-line-break@npm:2.1.0" + dependencies: + utrie: "npm:^1.0.2" + checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048 + languageName: node + linkType: hard + "css-to-react-native@npm:3.2.0": version: 3.2.0 resolution: "css-to-react-native@npm:3.2.0" @@ -4669,6 +4686,16 @@ __metadata: languageName: node linkType: hard +"html2canvas@npm:^1.4.1": + version: 1.4.1 + resolution: "html2canvas@npm:1.4.1" + dependencies: + css-line-break: "npm:^2.1.0" + text-segmentation: "npm:^1.0.3" + checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -8750,6 +8777,15 @@ __metadata: languageName: node linkType: hard +"text-segmentation@npm:^1.0.3": + version: 1.0.3 + resolution: "text-segmentation@npm:1.0.3" + dependencies: + utrie: "npm:^1.0.2" + checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -9179,6 +9215,15 @@ __metadata: languageName: node linkType: hard +"utrie@npm:^1.0.2": + version: 1.0.2 + resolution: "utrie@npm:1.0.2" + dependencies: + base64-arraybuffer: "npm:^1.0.2" + checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4 + languageName: node + linkType: hard + "uuid@npm:^10.0.0": version: 10.0.0 resolution: "uuid@npm:10.0.0"