From ca3150c6edfe88d17dd3b4c916d0c3ad915dd546 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 19 Sep 2024 10:51:30 +0800 Subject: [PATCH] feat: Improved file management and added new features. - Updated file manager to use FileManager class instead of File class. - Improved file management functionality with features for finding duplicate files, file uploading, and storage management. - Added styles to wrap and truncate text in a no-drag area. - Added explicit file extensions to imageExts constant. - Added the 'paste long text as file' input setting. - Added image file display and UI improvements for file names and overflow. - Improved file paste and long text handling functionality. - awaited onSendMessage function call and added message to chat completion. - Implemented new option to paste long text as file in the Settings page. - Updated content display logic to include file origin name along with the file content for text files. - Improved functionality for handling image and text file contents in the Gemini chat provider. - Updated file content formatting logic for text files with origin name and content prefix. - Added a new setting "pasteLongTextAsFile" and its corresponding action to the application settings. --- src/main/ipc.ts | 4 +- src/main/services/{File.ts => FileManager.ts} | 6 +- src/renderer/src/assets/styles/index.scss | 6 ++ src/renderer/src/config/constant.ts | 2 +- src/renderer/src/i18n/index.ts | 2 + src/renderer/src/pages/files/FilesPage.tsx | 10 ++- .../src/pages/home/Inputbar/Inputbar.tsx | 87 +++++++++++-------- .../src/pages/home/Messages/Messages.tsx | 2 +- src/renderer/src/pages/home/Settings.tsx | 20 ++++- .../src/providers/AnthropicProvider.ts | 3 +- src/renderer/src/providers/GeminiProvider.ts | 22 ++--- src/renderer/src/providers/OpenAIProvider.ts | 3 +- src/renderer/src/store/settings.ts | 10 ++- 13 files changed, 111 insertions(+), 66 deletions(-) rename src/main/services/{File.ts => FileManager.ts} (97%) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7e00749a66..9e3ae3b299 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,12 +3,12 @@ import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'elect import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' -import File from './services/File' +import FileManager from './services/FileManager' import { openFile, saveFile } from './utils/file' import { compress, decompress } from './utils/zip' import { createMinappWindow } from './window' -const fileManager = new File() +const fileManager = new FileManager() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const { autoUpdater } = new AppUpdater(mainWindow) diff --git a/src/main/services/File.ts b/src/main/services/FileManager.ts similarity index 97% rename from src/main/services/File.ts rename to src/main/services/FileManager.ts index aa660e0a3c..7ac30ae781 100644 --- a/src/main/services/File.ts +++ b/src/main/services/FileManager.ts @@ -6,7 +6,7 @@ import * as fs from 'fs' import * as path from 'path' import { v4 as uuidv4 } from 'uuid' -class File { +class FileManager { private storageDir: string constructor() { @@ -169,7 +169,7 @@ class File { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }) } - const tempFilePath = path.join(tempDir, `${uuidv4()}_${fileName}`) + const tempFilePath = path.join(tempDir, `temp_file_${uuidv4()}_${fileName}`) return tempFilePath } @@ -195,4 +195,4 @@ class File { } } -export default File +export default FileManager diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 155e2cc97a..7bd504742a 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -182,6 +182,12 @@ body, -webkit-app-region: no-drag; } +.text-nowrap { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .minapp-drawer { .ant-drawer-content-wrapper { box-shadow: none; diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index e2cd9a9468..ada48a8108 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -8,7 +8,7 @@ export const isMac = platform === 'darwin' export const isWindows = platform === 'win32' || platform === 'win64' export const isLinux = platform === 'linux' -export const imageExts = ['jpg', 'png', 'jpeg'] +export const imageExts = ['.jpg', '.png', '.jpeg'] export const textExts = [ '.txt', // 普通文本文件 '.md', // Markdown 文件 diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 320dec3b17..41906be5eb 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -166,6 +166,7 @@ const resources = { 'messages.input.title': 'Input Settings', 'messages.input.show_estimated_tokens': 'Show estimated input tokens', 'messages.input.send_shortcuts': 'Send shortcuts', + 'messages.input.paste_long_text_as_file': 'Paste long text as file', 'general.title': 'General Settings', 'general.user_name': 'User Name', 'general.user_name.placeholder': 'Enter your name', @@ -435,6 +436,7 @@ const resources = { 'messages.input.title': '输入设置', 'messages.input.show_estimated_tokens': '状态显示', 'messages.input.send_shortcuts': '发送快捷键', + 'messages.input.paste_long_text_as_file': '长文本粘贴为文件', 'general.title': '常规设置', 'general.user_name': '用户名', 'general.user_name.placeholder': '请输入用户名', diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 2ba4b82d85..6c6b96e9e6 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -12,14 +12,14 @@ import styled from 'styled-components' const FilesPage: FC = () => { const { t } = useTranslation() - const files = useLiveQuery(() => db.files.toArray()) + const files = useLiveQuery(() => db.files.orderBy('created_at').reverse().toArray()) const dataSource = files?.map((file) => { const isImage = file.type === FileTypes.IMAGE const ImageView = return { key: file.id, - file: isImage ? ImageView : file.origin_name, + file: isImage ? ImageView : {file.origin_name}, name: {file.origin_name}, size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, created_at: dayjs(file.created_at).format('MM-DD HH:mm') @@ -84,4 +84,10 @@ const ContentContainer = styled.div` padding: 20px; ` +const FileNameText = styled.div` + font-size: 14px; + color: var(--color-text); + max-width: 300px; +` + export default FilesPage diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 75b0f6b6af..c08a83fa09 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -8,7 +8,8 @@ import { PauseCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons' -import { textExts } from '@renderer/config/constant' +import { imageExts, textExts } from '@renderer/config/constant' +import { isVisionModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' @@ -45,7 +46,7 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { const [text, setText] = useState(_text) const [inputFocus, setInputFocus] = useState(false) const { addTopic, model } = useAssistant(assistant.id) - const { sendMessageShortcut, fontSize } = useSettings() + const { sendMessageShortcut, fontSize, pasteLongTextAsFile } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [contextCount, setContextCount] = useState(0) @@ -58,6 +59,9 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { const { searching } = useRuntime() const dispatch = useAppDispatch() + const isVision = useMemo(() => isVisionModel(model), [model]) + const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision]) + _text = text const sendMessage = useCallback(async () => { @@ -172,43 +176,52 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { const onInput = () => !expended && resizeTextArea() - const onPaste = useCallback(async (event: ClipboardEvent) => { - for (const file of event.clipboardData?.files || []) { - event.preventDefault() - const ext = getFileExtension(file.path) - if (textExts.includes(ext)) { - const selectedFile = await window.api.file.get(file.path) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - } - } + const onPaste = useCallback( + async (event: ClipboardEvent) => { + for (const file of event.clipboardData?.files || []) { + event.preventDefault() - if (event.clipboardData?.items) { - const item = event.clipboardData.items[0] - const file = item.getAsFile() - if (file && file.type.startsWith('image/')) { - const tempFilePath = await window.api.file.create(file.name) - const arrayBuffer = await file.arrayBuffer() - const uint8Array = new Uint8Array(arrayBuffer) - await window.api.file.write(tempFilePath, uint8Array) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + if (file.path === '') { + if (file.type.startsWith('image/')) { + const tempFilePath = await window.api.file.create(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + break + } + } + + if (file.path) { + if (supportExts.includes(getFileExtension(file.path))) { + const selectedFile = await window.api.file.get(file.path) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + } + } } - // if (item.kind === 'string' && item.type === 'text/plain') { - // // 处理文本内容 - // await new Promise((resolve) => { - // item.getAsString(async (text) => { - // const tempFilePath = await window.api.file.create('pasted_text.txt') - // await window.api.file.write(tempFilePath, text) - // const selectedFile = await window.api.file.get(tempFilePath) - // if (selectedFile) { - // newFiles.push(selectedFile) - // } - // resolve() - // }) - // }) - // } - } - }, []) + + if (pasteLongTextAsFile) { + const item = event.clipboardData?.items[0] + if (item && item.kind === 'string' && item.type === 'text/plain') { + event.preventDefault() + item.getAsString(async (text) => { + if (text.length > 1500) { + console.debug(item.getAsFile()) + const tempFilePath = await window.api.file.create('pasted_text.txt') + await window.api.file.write(tempFilePath, text) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + } else { + setText((prevText) => prevText + text) + setTimeout(() => resizeTextArea(), 0) + } + }) + } + } + }, + [supportExts, pasteLongTextAsFile] + ) // Command or Ctrl + N create new topic useEffect(() => { diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index ef885e7614..e6826d4f34 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -66,7 +66,7 @@ const Messages: FC = ({ assistant, topic, setActiveTopic }) => { useEffect(() => { const unsubscribes = [ EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => { - onSendMessage(msg) + await onSendMessage(msg) fetchChatCompletion({ assistant, messages: [...messages, msg], diff --git a/src/renderer/src/pages/home/Settings.tsx b/src/renderer/src/pages/home/Settings.tsx index 450819ad6c..5ece9485cb 100644 --- a/src/renderer/src/pages/home/Settings.tsx +++ b/src/renderer/src/pages/home/Settings.tsx @@ -8,6 +8,7 @@ import { useAppDispatch } from '@renderer/store' import { setFontSize, setMessageFont, + setPasteLongTextAsFile, setShowInputEstimatedTokens, setShowMessageDivider } from '@renderer/store/settings' @@ -33,8 +34,14 @@ const SettingsTab: FC = (props) => { const dispatch = useAppDispatch() - const { showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, setSendMessageShortcut } = - useSettings() + const { + showMessageDivider, + messageFont, + showInputEstimatedTokens, + sendMessageShortcut, + setSendMessageShortcut, + pasteLongTextAsFile + } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { updateAssistantSettings({ @@ -210,6 +217,15 @@ const SettingsTab: FC = (props) => { /> + + {t('settings.messages.input.paste_long_text_as_file')} + dispatch(setPasteLongTextAsFile(checked))} + /> + + {t('settings.messages.input.send_shortcuts')} diff --git a/src/renderer/src/providers/AnthropicProvider.ts b/src/renderer/src/providers/AnthropicProvider.ts index 0723091812..76a9b17cc1 100644 --- a/src/renderer/src/providers/AnthropicProvider.ts +++ b/src/renderer/src/providers/AnthropicProvider.ts @@ -34,9 +34,10 @@ export default class AnthropicProvider extends BaseProvider { }) } if (file.type === FileTypes.TEXT) { + const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() parts.push({ type: 'text', - text: (await window.api.file.read(file.id + file.ext)).trimEnd() + text: file.origin_name + '\n' + fileContent }) } } diff --git a/src/renderer/src/providers/GeminiProvider.ts b/src/renderer/src/providers/GeminiProvider.ts index f1ccfeccca..c28b395c1f 100644 --- a/src/renderer/src/providers/GeminiProvider.ts +++ b/src/renderer/src/providers/GeminiProvider.ts @@ -4,7 +4,7 @@ import { EVENT_NAMES } from '@renderer/services/event' import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types' import axios from 'axios' -import { flatten, isEmpty, takeRight } from 'lodash' +import { isEmpty, takeRight } from 'lodash' import OpenAI from 'openai' import BaseProvider from './BaseProvider' @@ -20,12 +20,7 @@ export default class GeminiProvider extends BaseProvider { private async getMessageContents(message: Message): Promise { const role = message.role === 'user' ? 'user' : 'model' - const parts: Part[] = [ - { - type: 'text', - text: message.content - } as TextPart - ] + const parts: Part[] = [{ text: message.content }] for (const file of message.files || []) { if (file.type === FileTypes.IMAGE) { @@ -38,15 +33,16 @@ export default class GeminiProvider extends BaseProvider { } as InlineDataPart) } if (file.type === FileTypes.TEXT) { + const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() parts.push({ - text: await window.api.file.read(file.id + file.ext) + text: file.origin_name + '\n' + fileContent } as TextPart) } } return { role, - parts: parts + parts } } @@ -60,14 +56,12 @@ export default class GeminiProvider extends BaseProvider { const userLastMessage = userMessages.pop() - let historyContents: Content[][] = [] + const history: Content[] = [] for (const message of userMessages) { - historyContents = historyContents.concat(await this.getMessageContents(message)) + history.push(await this.getMessageContents(message)) } - const history = flatten(historyContents) - const geminiModel = this.sdk.getGenerativeModel({ model: model.id, systemInstruction: assistant.prompt, @@ -79,7 +73,7 @@ export default class GeminiProvider extends BaseProvider { const chat = geminiModel.startChat({ history }) const messageContents = await this.getMessageContents(userLastMessage!) - const userMessagesStream = await chat.sendMessageStream(messageContents[0].parts) + const userMessagesStream = await chat.sendMessageStream(messageContents.parts) for await (const chunk of userMessagesStream.stream) { if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break diff --git a/src/renderer/src/providers/OpenAIProvider.ts b/src/renderer/src/providers/OpenAIProvider.ts index af2d39d4b1..3f2e20930f 100644 --- a/src/renderer/src/providers/OpenAIProvider.ts +++ b/src/renderer/src/providers/OpenAIProvider.ts @@ -50,9 +50,10 @@ export default class OpenAIProvider extends BaseProvider { }) } if (file.type === FileTypes.TEXT) { + const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() parts.push({ type: 'text', - text: await window.api.file.read(file.id + file.ext) + text: file.origin_name + '\n' + fileContent }) } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 9e34c372a1..3d9fd6f4d7 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -17,6 +17,7 @@ export interface SettingsState { windowStyle: 'transparent' | 'opaque' fontSize: number topicPosition: 'left' | 'right' + pasteLongTextAsFile: boolean } const initialState: SettingsState = { @@ -32,7 +33,8 @@ const initialState: SettingsState = { theme: ThemeMode.light, windowStyle: 'opaque', fontSize: 14, - topicPosition: 'right' + topicPosition: 'right', + pasteLongTextAsFile: true } const settingsSlice = createSlice({ @@ -84,6 +86,9 @@ const settingsSlice = createSlice({ }, setTopicPosition: (state, action: PayloadAction<'left' | 'right'>) => { state.topicPosition = action.payload + }, + setPasteLongTextAsFile: (state, action: PayloadAction) => { + state.pasteLongTextAsFile = action.payload } } }) @@ -103,7 +108,8 @@ export const { setTheme, setFontSize, setWindowStyle, - setTopicPosition + setTopicPosition, + setPasteLongTextAsFile } = settingsSlice.actions export default settingsSlice.reducer