feat: add attachment files

This commit is contained in:
kangfenmao 2024-09-11 11:54:37 +08:00
parent a03d619e2f
commit 2016ba7062
20 changed files with 149 additions and 76 deletions

View File

@ -70,6 +70,7 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.4",
"openai": "^4.52.1", "openai": "^4.52.1",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS files (
path TEXT NOT NULL, path TEXT NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
ext TEXT NOT NULL, ext TEXT NOT NULL,
type TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
) )

View File

@ -5,15 +5,8 @@ import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
interface FileMetadata { import { FileMetadata } from '../renderer/src/types'
id: string import { getFileType } from './utils/file'
name: string
file_name: string
path: string
size: number
ext: string
created_at: Date
}
export class File { export class File {
private storageDir: string private storageDir: string
@ -88,6 +81,7 @@ export class File {
const fileMetadataPromises = result.filePaths.map(async (filePath) => { const fileMetadataPromises = result.filePaths.map(async (filePath) => {
const stats = fs.statSync(filePath) const stats = fs.statSync(filePath)
const ext = path.extname(filePath) const ext = path.extname(filePath)
const fileType = getFileType(ext)
return { return {
id: uuidv4(), id: uuidv4(),
@ -96,27 +90,29 @@ export class File {
path: filePath, path: filePath,
created_at: stats.birthtime, created_at: stats.birthtime,
size: stats.size, size: stats.size,
ext: ext ext: ext,
type: fileType
} }
}) })
return Promise.all(fileMetadataPromises) return Promise.all(fileMetadataPromises)
} }
async uploadFile(filePath: string): Promise<FileMetadata> { async uploadFile(file: FileMetadata): Promise<FileMetadata> {
const duplicateFile = await this.findDuplicateFile(filePath) const duplicateFile = await this.findDuplicateFile(file.path)
if (duplicateFile) { if (duplicateFile) {
return duplicateFile return duplicateFile
} }
const uuid = uuidv4() const uuid = uuidv4()
const name = path.basename(filePath) const name = path.basename(file.path)
const ext = path.extname(name) const ext = path.extname(name)
const destPath = path.join(this.storageDir, uuid + ext) const destPath = path.join(this.storageDir, uuid + ext)
await fs.promises.copyFile(filePath, destPath) await fs.promises.copyFile(file.path, destPath)
const stats = await fs.promises.stat(destPath) const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = { const fileMetadata: FileMetadata = {
id: uuid, id: uuid,
@ -125,12 +121,13 @@ export class File {
path: destPath, path: destPath,
created_at: stats.birthtime, created_at: stats.birthtime,
size: stats.size, size: stats.size,
ext: ext ext: ext,
type: fileType
} }
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO files (id, name, file_name, path, size, ext, created_at) INSERT INTO files (id, name, file_name, path, size, ext, type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`) `)
stmt.run( stmt.run(
@ -140,6 +137,7 @@ export class File {
fileMetadata.path, fileMetadata.path,
fileMetadata.size, fileMetadata.size,
fileMetadata.ext, fileMetadata.ext,
fileMetadata.type,
fileMetadata.created_at.toISOString() fileMetadata.created_at.toISOString()
) )
@ -155,8 +153,8 @@ export class File {
} }
} }
async batchUploadFiles(filePaths: string[]): Promise<FileMetadata[]> { async batchUploadFiles(files: FileMetadata[]): Promise<FileMetadata[]> {
const uploadPromises = filePaths.map((filePath) => this.uploadFile(filePath)) const uploadPromises = files.map((file) => this.uploadFile(file))
return Promise.all(uploadPromises) return Promise.all(uploadPromises)
} }

View File

@ -1,5 +1,9 @@
import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron' import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron'
import Logger from 'electron-log'
import fs from 'fs'
import path from 'path'
import { FileMetadata } from '../renderer/src/types'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import { File } from './file' import { File } from './file'
import AppUpdater from './updater' import AppUpdater from './updater'
@ -34,18 +38,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('zip:compress', (_, text: string) => compress(text)) ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle('image:base64', async (_, filePath) => {
try {
const data = await fs.promises.readFile(filePath)
return `data:image/${path.extname(filePath).slice(1)};base64,${data.toString('base64')}`
} catch (error) {
Logger.error('Error reading file:', error)
return ''
}
})
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options)) ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
ipcMain.handle('file:upload', async (_, filePath: string) => await fileManager.uploadFile(filePath)) ipcMain.handle('file:upload', async (_, file: FileMetadata) => await fileManager.uploadFile(file))
ipcMain.handle('file:delete', async (_, fileId: string) => { ipcMain.handle('file:delete', async (_, fileId: string) => {
await fileManager.deleteFile(fileId) await fileManager.deleteFile(fileId)
return { success: true } return { success: true }
}) })
ipcMain.handle('file:batchUpload', async (_, filePaths: string[]) => await fileManager.batchUploadFiles(filePaths)) ipcMain.handle('file:batchUpload', async (_, files: FileMetadata[]) => await fileManager.batchUploadFiles(files))
ipcMain.handle('file:batchDelete', async (_, fileIds: string[]) => { ipcMain.handle('file:batchDelete', async (_, fileIds: string[]) => {
await fileManager.batchDeleteFiles(fileIds) await fileManager.batchDeleteFiles(fileIds)
return { success: true } return { success: true }
}) })
ipcMain.handle('file:getAll', () => fileManager.getAllFiles()) ipcMain.handle('file:all', () => fileManager.getAllFiles())
ipcMain.handle('minapp', (_, args) => { ipcMain.handle('minapp', (_, args) => {
createMinappWindow({ createMinappWindow({

View File

@ -3,6 +3,8 @@ import logger from 'electron-log'
import { writeFile } from 'fs' import { writeFile } from 'fs'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { FileType } from '../../renderer/src/types'
export async function saveFile( export async function saveFile(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
fileName: string, fileName: string,
@ -53,3 +55,17 @@ export async function openFile(
return null return null
} }
} }
export function getFileType(ext: string): FileType {
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt']
ext = ext.toLowerCase()
if (imageExts.includes(ext)) return FileType.IMAGE
if (videoExts.includes(ext)) return FileType.VIDEO
if (audioExts.includes(ext)) return FileType.AUDIO
if (documentExts.includes(ext)) return FileType.DOCUMENT
return FileType.OTHER
}

View File

@ -22,12 +22,17 @@ declare global {
reload: () => void reload: () => void
compress: (text: string) => Promise<Buffer> compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string> decompress: (text: Buffer) => Promise<string>
fileSelect: (options?: OpenDialogOptions) => Promise<FileMetadata[] | null> file: {
fileUpload: (filePath: string) => Promise<FileMetadata> select: (options?: OpenDialogOptions) => Promise<FileMetadata[] | null>
fileDelete: (fileId: string) => Promise<{ success: boolean }> upload: (file: FileMetadata) => Promise<FileMetadata>
fileBatchUpload: (filePaths: string[]) => Promise<FileMetadata[]> delete: (fileId: string) => Promise<{ success: boolean }>
fileBatchDelete: (fileIds: string[]) => Promise<{ success: boolean }> batchUpload: (files: FileMetadata[]) => Promise<FileMetadata[]>
fileGetAll: () => Promise<FileMetadata[]> batchDelete: (fileIds: string[]) => Promise<{ success: boolean }>
all: () => Promise<FileMetadata[]>
}
image: {
base64: (filePath: string) => Promise<string>
}
} }
} }
} }

View File

@ -16,12 +16,17 @@ const api = {
}, },
compress: (text: string) => ipcRenderer.invoke('zip:compress', text), compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
fileSelect: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), file: {
fileUpload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath), select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
fileDelete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId), upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
fileBatchUpload: (filePaths: string[]) => ipcRenderer.invoke('file:batchUpload', filePaths), delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
fileBatchDelete: (fileIds: string[]) => ipcRenderer.invoke('file:batchDelete', fileIds), batchUpload: (filePaths: string[]) => ipcRenderer.invoke('file:batchUpload', filePaths),
fileGetAll: () => ipcRenderer.invoke('file:getAll') batchDelete: (fileIds: string[]) => ipcRenderer.invoke('file:batchDelete', fileIds),
all: () => ipcRenderer.invoke('file:all')
},
image: {
base64: (filePath: string) => ipcRenderer.invoke('image:base64', filePath)
}
} }
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to

View File

@ -1,4 +1,5 @@
import { getDefaultTopic } from '@renderer/services/assistant' import { getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
addAssistant, addAssistant,
@ -16,7 +17,6 @@ import {
} from '@renderer/store/assistants' } from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import localforage from 'localforage'
export function useAssistants() { export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants) const { assistants } = useAppSelector((state) => state.assistants)
@ -30,7 +30,7 @@ export function useAssistants() {
dispatch(removeAssistant({ id })) dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id) const assistant = assistants.find((a) => a.id === id)
if (assistant) { if (assistant) {
assistant.topics.forEach((id) => localforage.removeItem(`topic:${id}`)) assistant.topics.forEach(({ id }) => LocalStorage.removeTopic(id))
} }
} }
} }

View File

@ -11,7 +11,7 @@ const FilesPage: FC = () => {
const [files, setFiles] = useState<FileMetadata[]>([]) const [files, setFiles] = useState<FileMetadata[]>([])
useEffect(() => { useEffect(() => {
window.api.fileGetAll().then(setFiles) window.api.file.all().then(setFiles)
}, []) }, [])
const dataSource = files.map((file) => ({ const dataSource = files.map((file) => ({

View File

@ -1,29 +1,30 @@
import { PaperClipOutlined } from '@ant-design/icons' import { PaperClipOutlined } from '@ant-design/icons'
import { Tooltip, Upload } from 'antd' import { FileMetadata } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface Props { interface Props {
files: File[] files: FileMetadata[]
setFiles: (files: File[]) => void setFiles: (files: FileMetadata[]) => void
ToolbarButton: any ToolbarButton: any
} }
const AttachmentButton: FC<Props> = ({ files, setFiles, ToolbarButton }) => { const AttachmentButton: FC<Props> = ({ files, setFiles, ToolbarButton }) => {
const { t } = useTranslation() const { t } = useTranslation()
const onSelectFile = async () => {
const _files = await window.api.file.select({
filters: [{ name: 'Files', extensions: ['jpg', 'png', 'jpeg'] }]
})
_files && setFiles(_files)
}
return ( return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow> <Tooltip placement="top" title={t('chat.input.upload')} arrow>
<Upload <ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>
customRequest={() => {}}
accept="image/*"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => file?.originFileObj && setFiles([file.originFileObj as File])}>
<ToolbarButton type="text" className={files.length ? 'active' : ''}>
<PaperClipOutlined style={{ rotate: '135deg' }} /> <PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton> </ToolbarButton>
</Upload>
</Tooltip> </Tooltip>
) )
} }

View File

@ -16,7 +16,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateInputTokenCount } from '@renderer/services/messages' import { estimateInputTokenCount } from '@renderer/services/messages'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime' import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, Message, Topic } from '@renderer/types' import { Assistant, FileMetadata, Message, Topic } from '@renderer/types'
import { delay, uuid } from '@renderer/utils' import { delay, uuid } from '@renderer/utils'
import { Button, Popconfirm, Tooltip } from 'antd' import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
@ -47,7 +47,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [contextCount, setContextCount] = useState(0) const [contextCount, setContextCount] = useState(0)
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
const textareaRef = useRef<TextAreaRef>(null) const textareaRef = useRef<TextAreaRef>(null)
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<FileMetadata[]>([])
const { t } = useTranslation() const { t } = useTranslation()
const containerRef = useRef(null) const containerRef = useRef(null)
const { showTopics, toggleShowTopics } = useShowTopics() const { showTopics, toggleShowTopics } = useShowTopics()
@ -56,7 +56,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
_text = text _text = text
const sendMessage = useCallback(() => { const sendMessage = useCallback(async () => {
if (generating) { if (generating) {
return return
} }
@ -76,7 +76,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
} }
if (files.length > 0) { if (files.length > 0) {
message.files = files message.files = await window.api.file.batchUpload(files)
} }
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)

View File

@ -8,7 +8,11 @@ interface Props {
} }
const MessageAttachments: FC<Props> = ({ message }) => { const MessageAttachments: FC<Props> = ({ message }) => {
return <Container>{message.images?.map((image) => <Image src={image} key={image} width="33%" />)}</Container> return (
<Container>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)}
</Container>
)
} }
const Container = styled.div` const Container = styled.div`

View File

@ -3,7 +3,12 @@ import { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { getTopic } from '@renderer/hooks/useTopic' import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api' import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateHistoryTokenCount, filterMessages, getContextCount } from '@renderer/services/messages' import {
deleteMessageFiles,
estimateHistoryTokenCount,
filterMessages,
getContextCount
} from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage' import LocalStorage from '@renderer/services/storage'
import { Assistant, Message, Model, Topic } from '@renderer/types' import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils' import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
@ -56,6 +61,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const _messages = messages.filter((m) => m.id !== message.id) const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages }) localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages })
deleteMessageFiles(message)
}, },
[messages, topic.id] [messages, topic.id]
) )

View File

@ -3,7 +3,7 @@ import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@rende
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { fileToBase64, removeQuotes } from '@renderer/utils' import { removeQuotes } from '@renderer/utils'
import { first, takeRight } from 'lodash' import { first, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import { import {
@ -33,13 +33,13 @@ export default class OpenAIProvider extends BaseProvider {
return message.content return message.content
} }
if (file.type.includes('image')) { if (file.type === 'image') {
return [ return [
{ type: 'text', text: message.content }, { type: 'text', text: message.content },
{ {
type: 'image_url', type: 'image_url',
image_url: { image_url: {
url: await fileToBase64(file) url: await window.api.image.base64(file.path)
} }
} }
] ]
@ -58,7 +58,6 @@ export default class OpenAIProvider extends BaseProvider {
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = [] const userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) { for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) {

View File

@ -59,3 +59,7 @@ export function estimateHistoryTokenCount(assistant: Assistant, msgs: Message[])
return all.usedTokens - 7 return all.usedTokens - 7
} }
export function deleteMessageFiles(message: Message) {
message.files && window.api.file.batchDelete(message.files.map((f) => f.id))
}

View File

@ -2,6 +2,8 @@ import { Topic } from '@renderer/types'
import { convertToBase64 } from '@renderer/utils' import { convertToBase64 } from '@renderer/utils'
import localforage from 'localforage' import localforage from 'localforage'
import { deleteMessageFiles } from './messages'
const IMAGE_PREFIX = 'image://' const IMAGE_PREFIX = 'image://'
export default class LocalStorage { export default class LocalStorage {
@ -15,12 +17,23 @@ export default class LocalStorage {
} }
static async removeTopic(id: string) { static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
localforage.removeItem(`topic:${id}`) localforage.removeItem(`topic:${id}`)
} }
static async clearTopicMessages(id: string) { static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id) const topic = await this.getTopic(id)
if (topic) { if (topic) {
for (const message of topic?.messages ?? []) {
await deleteMessageFiles(message)
}
topic.messages = [] topic.messages = []
await localforage.setItem(`topic:${id}`, topic) await localforage.setItem(`topic:${id}`, topic)
} }

View File

@ -27,7 +27,7 @@ export type Message = {
createdAt: string createdAt: string
status: 'sending' | 'pending' | 'success' | 'paused' | 'error' status: 'sending' | 'pending' | 'success' | 'paused' | 'error'
modelId?: string modelId?: string
files?: File[] files?: FileMetadata[]
images?: string[] images?: string[]
usage?: OpenAI.Completions.CompletionUsage usage?: OpenAI.Completions.CompletionUsage
type?: 'text' | '@' | 'clear' type?: 'text' | '@' | 'clear'
@ -94,5 +94,14 @@ export interface FileMetadata {
path: string path: string
size: number size: number
ext: string ext: string
type: FileType
created_at: Date created_at: Date
} }
export enum FileType {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
DOCUMENT = 'document',
OTHER = 'other'
}

View File

@ -1,5 +1,6 @@
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import imageCompression from 'browser-image-compression' import imageCompression from 'browser-image-compression'
// @ts-ignore next-line`
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
export const runAsyncFunction = async (fn: () => void) => { export const runAsyncFunction = async (fn: () => void) => {
@ -223,18 +224,3 @@ export function getBriefInfo(text: string, maxLength: number = 50): string {
// 截取前面的内容,并在末尾添加 "..." // 截取前面的内容,并在末尾添加 "..."
return truncatedText + '...' return truncatedText + '...'
} }
export async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const result = e.target?.result
resolve(typeof result === 'string' ? result : '')
}
reader.readAsDataURL(file)
} catch (error: any) {
reject(error)
}
})
}

View File

@ -5,6 +5,7 @@
"src/main/**/*", "src/main/**/*",
"src/preload/**/*", "src/preload/**/*",
"src/main/env.d.ts", "src/main/env.d.ts",
"src/renderer/src/types/index.ts"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,

View File

@ -1944,6 +1944,7 @@ __metadata:
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
localforage: "npm:^1.10.0" localforage: "npm:^1.10.0"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
mime: "npm:^4.0.4"
openai: "npm:^4.52.1" openai: "npm:^4.52.1"
prettier: "npm:^3.2.4" prettier: "npm:^3.2.4"
react: "npm:^18.2.0" react: "npm:^18.2.0"
@ -6592,6 +6593,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mime@npm:^4.0.4":
version: 4.0.4
resolution: "mime@npm:4.0.4"
bin:
mime: bin/cli.js
checksum: 10c0/3046e425ed616613af8c7f4b268ff33ab564baeb24a117ef00475cbb23fbae91369ff2d9918cc6408162b0016bde34ea8cc4041b830fc2c45a8ecaf5b7e3e26f
languageName: node
linkType: hard
"mimic-fn@npm:^2.1.0": "mimic-fn@npm:^2.1.0":
version: 2.1.0 version: 2.1.0
resolution: "mimic-fn@npm:2.1.0" resolution: "mimic-fn@npm:2.1.0"