feat: add attachment files
This commit is contained in:
parent
a03d619e2f
commit
2016ba7062
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
17
src/preload/index.d.ts
vendored
17
src/preload/index.d.ts
vendored
@ -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>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
@ -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={() => {}}
|
<PaperClipOutlined style={{ rotate: '135deg' }} />
|
||||||
accept="image/*"
|
</ToolbarButton>
|
||||||
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' }} />
|
|
||||||
</ToolbarButton>
|
|
||||||
</Upload>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)))) {
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user