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",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mime": "^4.0.4",
"openai": "^4.52.1",
"prettier": "^3.2.4",
"react": "^18.2.0",

View File

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

View File

@ -1,5 +1,9 @@
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 { File } from './file'
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: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: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) => {
await fileManager.deleteFile(fileId)
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[]) => {
await fileManager.batchDeleteFiles(fileIds)
return { success: true }
})
ipcMain.handle('file:getAll', () => fileManager.getAllFiles())
ipcMain.handle('file:all', () => fileManager.getAllFiles())
ipcMain.handle('minapp', (_, args) => {
createMinappWindow({

View File

@ -3,6 +3,8 @@ import logger from 'electron-log'
import { writeFile } from 'fs'
import { readFile } from 'fs/promises'
import { FileType } from '../../renderer/src/types'
export async function saveFile(
_: Electron.IpcMainInvokeEvent,
fileName: string,
@ -53,3 +55,17 @@ export async function openFile(
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
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
fileSelect: (options?: OpenDialogOptions) => Promise<FileMetadata[] | null>
fileUpload: (filePath: string) => Promise<FileMetadata>
fileDelete: (fileId: string) => Promise<{ success: boolean }>
fileBatchUpload: (filePaths: string[]) => Promise<FileMetadata[]>
fileBatchDelete: (fileIds: string[]) => Promise<{ success: boolean }>
fileGetAll: () => Promise<FileMetadata[]>
file: {
select: (options?: OpenDialogOptions) => Promise<FileMetadata[] | null>
upload: (file: FileMetadata) => Promise<FileMetadata>
delete: (fileId: string) => Promise<{ success: boolean }>
batchUpload: (files: FileMetadata[]) => 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),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
fileSelect: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
fileUpload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
fileDelete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
fileBatchUpload: (filePaths: string[]) => ipcRenderer.invoke('file:batchUpload', filePaths),
fileBatchDelete: (fileIds: string[]) => ipcRenderer.invoke('file:batchDelete', fileIds),
fileGetAll: () => ipcRenderer.invoke('file:getAll')
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
batchUpload: (filePaths: string[]) => ipcRenderer.invoke('file:batchUpload', filePaths),
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

View File

@ -1,4 +1,5 @@
import { getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistant,
@ -16,7 +17,6 @@ import {
} from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import localforage from 'localforage'
export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants)
@ -30,7 +30,7 @@ export function useAssistants() {
dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id)
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[]>([])
useEffect(() => {
window.api.fileGetAll().then(setFiles)
window.api.file.all().then(setFiles)
}, [])
const dataSource = files.map((file) => ({

View File

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

View File

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

View File

@ -8,7 +8,11 @@ interface Props {
}
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`

View File

@ -3,7 +3,12 @@ import { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
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 { Assistant, Message, Model, Topic } from '@renderer/types'
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)
setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages })
deleteMessageFiles(message)
},
[messages, topic.id]
)

View File

@ -3,7 +3,7 @@ import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@rende
import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages'
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 OpenAI from 'openai'
import {
@ -33,13 +33,13 @@ export default class OpenAIProvider extends BaseProvider {
return message.content
}
if (file.type.includes('image')) {
if (file.type === 'image') {
return [
{ type: 'text', text: message.content },
{
type: '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 systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = []
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
}
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 localforage from 'localforage'
import { deleteMessageFiles } from './messages'
const IMAGE_PREFIX = 'image://'
export default class LocalStorage {
@ -15,12 +17,23 @@ export default class LocalStorage {
}
static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
localforage.removeItem(`topic:${id}`)
}
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
if (topic) {
for (const message of topic?.messages ?? []) {
await deleteMessageFiles(message)
}
topic.messages = []
await localforage.setItem(`topic:${id}`, topic)
}

View File

@ -27,7 +27,7 @@ export type Message = {
createdAt: string
status: 'sending' | 'pending' | 'success' | 'paused' | 'error'
modelId?: string
files?: File[]
files?: FileMetadata[]
images?: string[]
usage?: OpenAI.Completions.CompletionUsage
type?: 'text' | '@' | 'clear'
@ -94,5 +94,14 @@ export interface FileMetadata {
path: string
size: number
ext: string
type: FileType
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 imageCompression from 'browser-image-compression'
// @ts-ignore next-line`
import { v4 as uuidv4 } from 'uuid'
export const runAsyncFunction = async (fn: () => void) => {
@ -223,18 +224,3 @@ export function getBriefInfo(text: string, maxLength: number = 50): string {
// 截取前面的内容,并在末尾添加 "..."
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/preload/**/*",
"src/main/env.d.ts",
"src/renderer/src/types/index.ts"
],
"compilerOptions": {
"composite": true,

View File

@ -1944,6 +1944,7 @@ __metadata:
i18next: "npm:^23.11.5"
localforage: "npm:^1.10.0"
lodash: "npm:^4.17.21"
mime: "npm:^4.0.4"
openai: "npm:^4.52.1"
prettier: "npm:^3.2.4"
react: "npm:^18.2.0"
@ -6592,6 +6593,15 @@ __metadata:
languageName: node
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":
version: 2.1.0
resolution: "mimic-fn@npm:2.1.0"